Compare commits

...
50 Commits
Author SHA1 Message Date
omarandClaude Opus 5 6476722372 fix(panel): stop shipping a fabricated router in the binary
test / go + panel tests (push) Successful in 5m26s
release / test gate (push) Successful in 5m28s
release / apk aarch64_cortex-a53 (push) Successful in 6m7s
release / apk x86_64 (push) Successful in 3m5s
release / release apk (push) Successful in 7s
mock.ts was a static import and the mock switch was read from the query string at
runtime, so the bundle that ships inside the daemon carried a complete fictional
router and a link ending in ?dev rendered it: protected, 119 of 122 nodes alive,
without a single request to the daemon. The only tell was a line in the footer.
That is worse than any wrong number — there is no data at all and nothing says
so. It is out of the production bundle now, which is 21 kB smaller for it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
omarandClaude Opus 5 a8ef887c56 feat(routing)!: a rule's destination is a rule-set, and nothing else
`config rule` carried THREE ways to say where traffic is going: `dst_domain`
(an inline domain list), `dst_ip` (an inline CIDR list) and `dst_ruleset` (a
reference to a `config ruleset`). Three mechanisms meant three sets of
semantics to keep straight, and the inline pair was the worse half of the
trade: re-parsed per rule instead of compiled once into a .srs, unshareable
between rules, and — invisibly — already disagreeing with the rule-set
vocabulary about what a bare entry means.

`dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is the only
destination matcher. `Src` (the client side), `dst_port` and `proto` are
untouched: they are not lists of destinations and have no rule-set form.

THE BARE-ENTRY TRAP, and why the migration is not a copy

A bare `example.com` was an EXACT host in a routing rule (classified with
bareIsSuffix=false) and is the host AND its subdomains inside a rule-set
(bareIsSuffix=true). Copying entries across verbatim would silently widen
every such rule to every subdomain, so migrate1to2 rewrites a bare entry as
`full:example.com`. Everything else already means the same on both sides and
is copied byte-for-byte: `full:`, `suffix:`, `keyword:`, `regexp:` and a
leading dot (a synonym of `suffix:`).

`geosite:`/`geoip:` entries are copied UNCHANGED rather than promoted to a
`source=geosite` rule-set. They have been inert since the engine dropped the
route-rule geosite/geoip fields, and an unrecognised marker is equally inert
inside a rule-set — so their meaning is preserved exactly, and a dead matcher
does not start routing traffic because someone upgraded. The text is kept so
the operator can see it and convert it deliberately.

`regexp:` had no rule-set form at all, which would have made the move lossy,
so inline rule-sets learn it: peelDomainRegexes validates each pattern with
regexp.Compile before it reaches DomainRegex, because
route/rule.NewDomainRegexItem errors on an uncompilable one and that aborts
box.New for the whole config. A bare `regexp:` is dropped too — it compiles
fine and matches every host.

THE MIGRATION (schema v1 -> v2, run by `shaterd migrate` on service start and
at package install)

Per rule still carrying a legacy list: create an inline `config ruleset`
named `rule-<rule name>` (domains) and/or `rule-<rule name>-ip` (addresses),
move the entries across with the conversion above, append the new name to
`dst_ruleset`, delete the old option LAST. It is idempotent; it resumes an
interrupted run by reusing a rule-set the rule already references; and it
never overwrites a hand-written list that owns the generated name (it takes
`rule-<name>-2`). The uci sequence — `uci add` capturing the section id, then
set/add_list/delete — was verified against BananaWRT 25.12.1 in a throwaway
package.

Verified against the live router's config (4 rules, 26 entries, all
`suffix:`): every entry lands in its rule-set, every rule gains exactly one
reference, the `default` rule stays condition-less so B1's RuleReachability
still reads it as the catch-all.

ONE DELIBERATE SEMANTIC CHANGE, stated out loud: a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which
is almost never what "these sites and these networks" meant. The two
generated rule-sets are ORed, because `rule_set: [a, b]` matches when either
matches. Only configs that used both fields at once are affected.

Also fixed here, because schema v2 routes EVERY destination list through
inlineRulesetRule and the gap widens accordingly: a marker-only entry (".",
"full:", "keyword:") was dropped by the shared classifier SILENTLY on that
path, where the routing rule used to warn. An empty domain token aborts
box.New and an empty keyword is strings.Contains(host, "") — every host — so
the drop is right and the silence was not.

untunnelable stays honest: buildUntunnelablePlan already resolves `rule_set`
addresses through the running engine (inline sets are LocalRuleSets and
implement ExtractIPSet), and apply runs eng.Apply before building the plan.
A migrated `dst_ip` therefore resolves exactly as before; with the engine
down the walk truncates and denies, which is the conservative direction and
the state in which the netplane is fail-closed anyway.

Tests: migration coverage (real-router fixture, mixed prefixes, CIDRs,
idempotence, interrupted-run resume, name collision, geo markers stay inert,
absent config), and every matcher-classification test that used to live on
`dst_domain`/`dst_ip` moved to the inline rule-set rather than deleted —
including the new `regexp:` path and the inverted bare-entry convention. The
model tests grow a real in-memory uci emulator so a second migration run
actually sees its own writes.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:51:19 +03:00
omarandClaude Opus 5 c562579ef3 docs(report): correct the B1 diagnosis — last catch-all wins, not the first
The report claimed the order=20 `default` shadowed the order=100 one and sent all
unspecific traffic past the proxy. That is wrong. generate/route.go:buildRoute
does not emit a condition-less rule as a match-all route rule: it sets
route.Final and continues, so the LAST condition-less rule by order wins, and it
can never shadow a rule that has conditions (those are emitted ahead of Final
regardless of order).

For the config on the router this inverts the conclusion: traffic IS going
through the proxy (order=100 -> group:auto is the live default) and the dead knob
is the order=20 `direct` one. Severity downgraded from high to medium
accordingly — a dead setting, not a leak.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:36:36 +03:00
omarandClaude Opus 5 a8f2b0f068 ci: derive package versions from the git tag (B4)
PKG_VERSION/PKG_RELEASE were hand-written literals nobody bumped, so
v0.2.2 … v0.2.6 all shipped as `shaterd 0.2.0-r3` with different binaries
inside (v0.2.6's ELF is 5 491 616 B against r2's 5 488 336 B). Both opkg
and apk offer an upgrade only when the feed's version string differs from
the installed one, so `apk update` saw nothing new and the routers could
not be updated through the normal path at all.

ci/version.sh is now the single source of truth. It derives the version
from `git describe`:

    tag `vX.Y.Z`   -> PKG_VERSION=X.Y.Z  PKG_RELEASE=1
    off-tag build  -> nearest tag + PKG_RELEASE=<commits since it> + 1
    no tag/no git  -> 0.0.0-r1 (below everything ever published)

Ordering verified with the real tools, not from memory — apk-tools 3.0.3
(`apk version -t`) and opkg 38eccbb1 (`opkg compare-versions`) agree that
0.2.0-r3 < 0.2.6-r2 < 0.2.6-r10 < 0.2.6-r12 < 0.2.7-r1 < 0.3.0-r1, so a
release always outranks the rolling builds that preceded it and rolling
builds grow monotonically between releases.

The value travels as SHATER_PKG_VERSION/SHATER_PKG_RELEASE in the SDK
build environment of BOTH lanes; the Makefiles keep a literal fallback so
a manual/offline build still works with no CI and no git. Because the
hand-off crosses docker, `su` and make's env import, ci/sdk-build.sh and
ci/sdk-build-apk.sh now ASSERT that the produced .ipk/.apk really carries
that version — the B4 failure mode was a stale version shipping silently,
and that can no longer happen quietly.

The binary agrees with the package: scripts/build-shaterd.sh takes
constant.Version from the same ci/version.sh (vX.Y.Z-rR[-g<sha>]) instead
of its own `git describe`, and the workflow computes it once per job.
Both build jobs now check out with fetch-depth: 0 — `git describe` needs
tags and ancestry, which the default shallow checkout has neither of.

byedpi is deliberately left alone: PKG_VERSION:=0.17.3 is upstream
ByeDPI's own version, what PKG_HASH pins and what tells an operator which
ByeDPI is installed. Stamping our tag on it would also be a downgrade —
every comparator reads 0.2.7 < 0.17.3 (component-wise, 2 < 17), verified.

Docs: INSTALL.md gains §2.1 (the scheme + the ordering evidence), and the
update sections of §5/§6 now explicitly warn against a bare `opkg upgrade`
/ `apk upgrade` and give the targeted form instead, quoting apk-tools 3:
"If list of packages is provided, only those packages are upgraded along
with needed dependencies". README.md and the release bodies match.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:30:27 +03:00
omarandClaude Opus 5 02c266188f docs: live test report for v0.2.6 on mini_router (79 checks, 5 findings)
Full cycle on real hardware (BPi-R3 Mini, ImmortalWrt 25.12-linkup): purge the
previous install, install from the signed apk feed, verify the default state,
restore a working config with 315 subscription nodes, then exercise the data
plane, panel API, config lifecycle, resilience and DNS.

74 PASS. Findings (detailed separately): two catch-all `default` rules where the
first sends all unspecific traffic direct and makes the second unreachable;
`shaterd nodes` is a stub returning [] while usage promises the node list; DNS to
the router LAN address dies after `service shater restart` (stop+pause+start is
fine); PKG_RELEASE unchanged since v0.2.1 so v0.2.2..v0.2.6 all ship as r3; ANSI
colour codes reach syslog.

Also records the four-iteration CI hunt that ended in the green apk lane.

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

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

    config PACKAGE_kmod-mlx5-core
            tristate
            default m

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

bash -n clean on both.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +03:00
omarandClaude Fable 5 c257d6c5cc docs(readme): rewrite root README as Russian shater product face
- README.md: new Russian product README (what/features/architecture
  mermaid/install both feeds/build/repo layout/CI/upstream/docs/license)
- README.en.md: concise English mirror (root readme was previously English)
- README.ru.md: demoted to a pointer stub (was the sing-box-lx fork readme,
  a competing Russian README) -> points to README.md + engine-fork docs
- docs-shater/README.md: folder index

Install commands copied verbatim from docs-shater/INSTALL.md; all links
verified against existing files.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:23:04 +03:00
269 changed files with 35022 additions and 4037 deletions
+261 -271
View File
@@ -1,51 +1,62 @@
# Shater v0.2 — build the 4-package signed opkg feed and publish it as a rolling # Shater v0.2 — build the 4-package signed **apk** feed and publish it as
# Gitea release consumable as an `src/gz` feed. # per-arch Gitea releases consumable as an apk repository.
# #
# WHAT CHANGED FROM v0.1 # WHAT WE SHIP
# v0.1 shipped 3 packages: xrayctl (SDK-compiled Go) + shater-core + # ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
# luci-app-shater (hand-packed data .ipk). v0.2 collapses the runtime into ONE # SDK way:
# forked binary and ships 4 packages, all built the canonical SDK way:
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built # - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX) # OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
# and staged into openwrt/shaterd/files/ BEFORE the SDK # and staged into openwrt/shaterd/files/ BEFORE the SDK
# build; the openwrt/shaterd package just $(INSTALL_BIN)s # build; the openwrt/shaterd package just $(INSTALL_BIN)s
# the arch-matched artifact. (arch-specific .ipk) # the arch-matched artifact. (arch-specific .apk)
# - shater-core data glue, PKGARCH=all # - shater-core data glue, PKGARCH=all
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk) # - 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) # - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
# #
# TARGET HARDWARE / ARCH MATRIX # TARGET HARDWARE / ARCH MATRIX
# x86_64 -> the QEMU testbed VM (generic x86-64). # x86_64 -> the QEMU testbed VM (generic x86-64).
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 + BPI-R4, mediatek/filogic). # 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 + byedpi are arch-specific; shater-core + luci-app-shater are
# PKGARCH=all, so one build of each covers every device. opkg filters by # PKGARCH=all, so one build of each covers every device — but the RELEASES
# Architecture at install time, so a single combined feed URL serves all. # are still per-arch (see the release-apk job for why).
# #
# FEED SIGNING (opkg / usign — OpenWrt 24.10 is opkg, not apk; apk lands at 25.12) # FORMAT: apk ONLY (25.12+)
# The feed index (Packages) is usign-signed with the SECRET key in the Gitea # The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
# repo secret KEY_BUILD; routers verify it with the committed public key # apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
# dist/shater-feed.pub (fingerprint 5ac4b177689cb8e0). Do NOT regenerate the # old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
# key — that invalidates every deployed router's trust. # device we serve has an opkg binary at all, so building and signing a second
# feed served nobody.
#
# FEED SIGNING (EC / apk)
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
# secret KEY_APK; routers verify it with the committed public key
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
# invalidates every deployed router's trust.
# #
# AUTO-RELEASE # AUTO-RELEASE
# push a tag `vX.Y.Z` -> versioned release. workflow_dispatch / (optional) main # push a tag `vX.Y.Z` -> versioned per-arch releases `apk-vX.Y.Z-<arch>`.
# -> rolling `latest` pre-release (always-fresh feed). Publish uses the Gitea # workflow_dispatch -> rolling per-arch `apk-latest-<arch>` (always-fresh
# API via curl (ci/gitea-release.sh) — no external action needed. # feed). Publish uses the Gitea API via curl (ci/gitea-release.sh) — no
# external action needed. NOTE: the apk release tags deliberately do NOT start
# with `v` so publishing them cannot re-trigger this workflow's `v*` filter.
# #
# APK LANE (25.12+, ADDITIVE — T2) # PACKAGE VERSIONING (bug B4)
# The fleet is migrating to BananaWRT 25.12-mtk-vendor (= ImmortalWrt 25.12 # PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
# base), where opkg is replaced by Alpine apk (.apk, binary packages.adb # used to be, and nobody bumped them: v0.2.2…v0.2.6 all shipped as
# index, EC keys in /etc/apk/keys/). The `build-apk` + `release-apk` jobs # `shaterd 0.2.0-r3` with different binaries inside, so `apk update` never saw
# below build the SAME 4 packages through the ImmortalWrt 25.12 apk-SDK and # a new version and routers could not be updated at all. Now `ci/version.sh`
# publish PER-ARCH apk repos as releases `apk-latest-<arch>` (rolling) / # derives them from the git tag ONCE per job (the "Compute version" step,
# `apk-<tag>-<arch>` (versioned). Per-arch because apk filenames carry no # exported via $GITHUB_ENV):
# architecture (shaterd-0.2.0-r1.apk would collide across arches in one flat # tag `vX.Y.Z` -> X.Y.Z-r1
# release) and apk fetches packages relative to the packages.adb URL. # anything else -> <nearest tag>-r<commits since it + 1>
# Signed with the EC key in the Gitea secret KEY_APK; trust anchor # and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
# dist/shater-apk.pem (ci/gen-apk-key.sh). The usign/opkg lane above is # $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
# UNCHANGED and keeps serving the 24.10 fleet. NOTE: the apk release tags # into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
# deliberately do NOT start with `v` so publishing them cannot re-trigger # built .apk really carry that version, so the failure can never be silent
# this workflow's `v*` tag filter. # 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).
# CACHING (T3 — fast CI) # CACHING (T3 — fast CI)
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the # All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
@@ -63,34 +74,33 @@
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies # (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys # PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
# prefix fallback is allowed. # prefix fallback is allowed.
# - Go module + build cache — key = hash of go.sum; shared by all 4 build # - Go module + build cache — key = hash of go.sum; shared by both build
# jobs (each builds both GOARCHes). # jobs (each builds both GOARCHes).
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only # - panel/node_modules — key = hash of panel/package-lock.json, exact-only
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast. # (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
# - apt .deb archives for the apk lane's debian:bookworm host-deps # - apt .deb archives for the debian:bookworm host-deps of the apk SDK
# (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list is in it). # container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
# - usign binary (.cache/tools) — static helper, fixed key. # is in it).
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring # - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+ # cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this # telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
# runner — run 51 evidence). The feeds dir is symlinked into the SDK # runner — run 51 evidence). The feeds dir is symlinked into the SDK
# container from the workspace cache; `feeds update` on an existing clone # container from the workspace cache; `feeds update` on an existing clone
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update # is a fast fetch+checkout of the pinned revs. Correctness-safe: update
# always checks out feeds.conf's pins, and ci/sdk-build*.sh wipes the # always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
# cache + re-clones fresh if update ever fails on a cached checkout. # cache + re-clones fresh if update ever fails on a cached checkout.
# Key = lane + SDK release (shared across the two arch jobs of a lane — # Key = lane + SDK release (shared across the two arch jobs — the same
# same release pins identical feed revs; the sequential runner means the # release pins identical feed revs; the sequential runner means the second
# second arch restores what the first saved). restore-keys lets an SDK # arch restores what the first saved). restore-keys lets an SDK version
# version bump start from the old clones (git fetch delta, not re-clone). # bump start from the old clones (git fetch delta, not re-clone).
# Act_runner facts this design leans on (verified in run 51 logs): # Act_runner facts this design leans on (verified in run 51 logs):
# - the cache backend works: restores/saves confirmed, hashFiles() works; # - the cache backend works: restores/saves confirmed, hashFiles() works;
# - docker images (openwrt/sdk, debian:bookworm, runner-images) live on the # - docker images (debian:bookworm, runner-images) live on the PERSISTENT
# PERSISTENT host daemon — "Image is up to date" each run, no re-download; # host daemon — "Image is up to date" each run, no re-download;
# - each actions/cache SAVE is followed by an exact 3-minute act_runner # - each actions/cache SAVE is followed by an exact 3-minute act_runner
# stall (node process lingers; hit→no-save→no stall). Steady state saves # stall (node process lingers; hit→no-save→no stall). Steady state saves
# nothing, so adding cache entries is fine, but keys that change every # nothing, so adding cache entries is fine, but keys that change every
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that. # run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
name: release name: release
on: on:
@@ -108,41 +118,49 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build: # ---------------------------------------------------------------------------
name: ${{ matrix.arch }} # THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
# stops the release instead of shipping with it.
#
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
# workflow cannot block another one — they run side by side and a red `test`
# workflow would have published anyway. Only a `needs:` edge inside THIS
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
# call the same scripts/run-tests.sh so they cannot drift.
#
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
# three named tests. 115 of the 116 test files under shater/** had never run in
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
#
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
# transport/wireguard compiles 1 test file without the tag set and 7 with it
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
# the named, reasoned exclusions are in scripts/run-tests.sh.
test:
name: test gate
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, sdk: x86_64-24.10.4 } # testbed VM (generic x86-64)
- { arch: aarch64_cortex-a53, sdk: mediatek-filogic-24.10.4 } # BPI-R3 + BPI-R4 (mediatek/filogic)
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# scripts/build-shaterd.sh builds the engine via a go.mod # go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
# `replace => ./submodules/wireguard-go` (AmneziaWG fork), so that submodule # this even `go list` fails. Same step/reason as in build-apk below.
# must be present or `go build` dies with "no such file or directory".
# actions/checkout does not fetch submodules by default; init ONLY this one
# (clients/apple+android are large and unused here).
- name: Init wireguard-go submodule (awg) - name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go run: git submodule update --init --depth 1 submodules/wireguard-go
# Toolchain for scripts/build-shaterd.sh: Go (daemon), Node (Vite SPA), UPX.
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod # pins Go 1.24.7 (go.mod `go` line) go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below (setup-go's cache: false # explicit actions/cache@v3.3.2 below
# built-in cache uses the new API act_runner lacks)
- name: Set up Node # Same cache key as build-apk: this job runs first, so it warms the module
uses: actions/setup-node@v4 # + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
with:
node-version: '20' # Vite 5 needs Node 18+; 20 LTS
# ---- caches (see the header comment for keys + version pin rationale) ----
- name: Cache Go modules + build cache - name: Cache Go modules + build cache
uses: actions/cache@v3.3.2 uses: actions/cache@v3.3.2
with: with:
@@ -153,94 +171,36 @@ jobs:
restore-keys: | restore-keys: |
go- go-
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
# TypeScript run directly by `node --test`, and type stripping only exists
# from 22.6 — on node 20 `npm test` dies before running a single case.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules - name: Cache panel node_modules
id: npm-cache
uses: actions/cache@v3.3.2 uses: actions/cache@v3.3.2
with: with:
path: panel/node_modules path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }} key: npm-${{ hashFiles('panel/package-lock.json') }}
# NO restore-keys: node_modules must exactly match the lockfile;
# on any lockfile change this misses and `npm ci` runs fresh.
- name: Cache SDK dl/ (package sources) - name: Panel tests
uses: actions/cache@v3.3.2 run: bash scripts/run-panel-tests.sh
with:
path: .cache/dl
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
restore-keys: |
dl-
# feeds git checkouts (see header): both 24.10.4 arch jobs share one entry - name: Go tests (shipped tags, linux, + race)
# (same release = same feeds.conf.default pins), so derive the release run: bash scripts/run-tests.sh
# from the matrix sdk tag (x86_64-24.10.4 -> 24.10.4).
- name: Compute feeds cache key
id: feedskey
run: echo "ver=$(echo '${{ matrix.sdk }}' | sed 's/.*-//')" >> "$GITHUB_OUTPUT"
- name: Cache SDK feeds checkouts
uses: actions/cache@v3.3.2
with:
path: .cache/feeds
key: feeds-opkg-${{ steps.feedskey.outputs.ver }}
restore-keys: |
feeds-opkg-
- name: Cache CI tools (usign)
uses: actions/cache@v3.3.2
with:
path: .cache/tools
key: tools-usign-v1
- name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Build the SPA-embedded, static-musl, UPX'd shaterd for BOTH arches and
# stage dist/shaterd-<a>.upx into openwrt/shaterd/files/. MUST run before
# the SDK package build (the openwrt/shaterd package installs the staged
# artifact). VERSION is stamped into constant.Version. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact
env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
V="${GITHUB_REF#refs/tags/}"
else
V="v0.2.0-dev"
fi
FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh "$V" $FAST
# Compile the 4 packages through the arch-matched OpenWrt SDK and produce a
# signed per-arch opkg feed (Packages + Packages.gz + Packages.sig + .ipk).
- name: Build signed feed (SDK)
env:
KEY_BUILD: ${{ secrets.KEY_BUILD }}
run: bash ci/build-feed.sh "${{ matrix.arch }}" "${{ matrix.sdk }}" "out/${{ matrix.arch }}"
- name: Show feed
run: ls -l "out/${{ matrix.arch }}" && cat "out/${{ matrix.arch }}/Packages"
- name: Upload feed artifact
# v4 uses an artifact backend Gitea Actions does not implement
# (GHESNotSupportedError); v3 works on Gitea's act_runner.
uses: actions/upload-artifact@v3
with:
name: shater-${{ matrix.arch }}
path: out/${{ matrix.arch }}/*
if-no-files-found: error
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# APK lane (additive): the same 4 packages through the ImmortalWrt 25.12 # Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
# apk-SDK for the 25.12/apk fleet (BananaWRT 25.12-mtk-vendor routers + the # fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
# future 25.12 VM). Produces a per-arch apk repo dir: *.apk + EC-signed # and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
# packages.adb + shater-apk.pem. Artifact prefix `apkfeed-` (NOT `shater-`) # packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
# so the opkg release job's `artifacts/shater-*` glob never picks these up.
build-apk: build-apk:
name: apk ${{ matrix.arch }} name: apk ${{ matrix.arch }}
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
# (with the guards in release-apk) leaves nothing published.
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -253,8 +213,14 @@ jobs:
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4 - arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
steps: steps:
# fetch-depth: 0 — the package version is DERIVED from the git tag
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
# shallow checkout has neither tags nor ancestry, so `git describe` would
# fail and every dispatch build would fall back to 0.0.0.
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a # scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be # go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
@@ -263,6 +229,13 @@ jobs:
- name: Init wireguard-go submodule (awg) - name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go run: git submodule update --init --depth 1 submodules/wireguard-go
# THE version step (bug B4). One computation, used by both the binary
# (constant.Version) and the three tag-versioned packages, exported to
# every later step of this job:
# tag vX.Y.Z -> X.Y.Z-r1 ; off-tag -> <last tag>-r<commits+1>
- name: Compute version from git tag
run: bash ci/version.sh --env >> "$GITHUB_ENV"
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
@@ -336,25 +309,36 @@ jobs:
restore-keys: | restore-keys: |
feeds-apk- feeds-apk-
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
# everything else in CI builds with the full upstream set, so without this
# step the one combination we actually ship is never exercised. That is how
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
# binary answered a WireGuard node with "gVisor is not included in this
# build" (2026-07-25). The check runs the declared-feature/tag comparison
# and then constructs one node of every declared protocol through box.New
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
# that breaks a feature fails the release instead of shipping.
- name: Verify the shipped build-tag set (D23)
run: bash scripts/check-router-tags.sh
- name: Install UPX - name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Same artifact-order contract as the opkg lane: the SPA-embedded shaterd # Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
# binary is built OUT of the SDK and staged before the package build. # the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
# (the openwrt/shaterd package only installs the staged artifact).
# $SHATER_VERSION (from the version step above) is stamped into
# constant.Version, so the binary and the package agree. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact - name: Build & stage shaterd artifact
env: env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }} NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: | run: |
set -eu set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
V="${GITHUB_REF#refs/tags/}"
else
V="v0.2.0-dev"
fi
FAST="" FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})" echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh "$V" $FAST bash scripts/build-shaterd.sh $FAST
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and # Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
# sign the per-arch packages.adb with the EC key (secret KEY_APK). # sign the per-arch packages.adb with the EC key (secret KEY_APK).
@@ -377,120 +361,34 @@ jobs:
if-no-files-found: error if-no-files-found: error
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Publish once both arches are built. Rolling `latest` on dispatch, a versioned # Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
# release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API). # fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
release: # multi-arch release would collide). Every run refreshes the ROLLING pointer
name: release # `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
needs: build # `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
runs-on: ubuntu-latest # publishing them cannot re-trigger the build.
steps: #
- name: Checkout # WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
uses: actions/checkout@v4 # it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
- name: Download all arch feeds # pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
uses: actions/download-artifact@v3 # while v0.2.9/v0.2.10 published fine, and every router whose
with: # /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
path: artifacts # successful, silent `apk update` with nothing new. Rolling is the whole point
# of that URL, so it is now written unconditionally and asserted afterwards.
- name: Assemble release assets
id: assets
run: |
set -eu
mkdir -p release
# For each downloaded arch feed: one ready-to-serve tarball + loose ipks.
for d in artifacts/shater-*; do
[ -d "$d" ] || continue
arch="${d#artifacts/shater-}"
tar -C "$d" -czf "release/shater-feed-${arch}.tar.gz" .
# loose .ipk for direct `opkg install <url>` (dedupe shared _all ipks by name)
for ipk in "$d"/*.ipk; do
[ -e "$ipk" ] || continue
cp -n "$ipk" "release/$(basename "$ipk")"
done
done
# ship the feed's public key so routers can verify (see docs-shater/INSTALL.md)
cp -f dist/shater-feed.pub release/shater-feed.pub
ls -l release
echo "count=$(ls release | wc -l)" >> "$GITHUB_OUTPUT"
# restore the prebuilt usign binary (skips apt + cmake + clone + build)
- name: Cache CI tools (usign)
uses: actions/cache@v3.3.2
with:
path: .cache/tools
key: tools-usign-v1
- name: Install usign (feed signer)
run: bash ci/install-usign.sh
- name: Build & sign combined opkg feed index
# One Packages/Packages.gz over ALL loose .ipk (every arch + arch=all),
# with basename Filenames. opkg filters by Architecture, so a single
# release URL serves every device: BPI routers pick aarch64_cortex-a53 +
# all, the x86 testbed picks x86_64 + all. Signed with KEY_BUILD so
# routers keep check_signature on. This is what makes the release directly
# consumable as an `src/gz` feed (see docs-shater/INSTALL.md).
env:
KEY_BUILD: ${{ secrets.KEY_BUILD }}
run: bash ci/make-index.sh release
- name: Determine release identity
id: rel
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "name=shater ${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
echo "rolling=false" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
echo "name=shater latest (main)" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "rolling=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish Gitea release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
TAG: ${{ steps.rel.outputs.tag }}
NAME: ${{ steps.rel.outputs.name }}
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }}
BODY: |
Automated build. Packages: shaterd + byedpi (per-arch), shater-core +
luci-app-shater (arch=all).
Targets: x86_64 (testbed) and aarch64_cortex-a53 (BPI-R3 + BPI-R4, mediatek/filogic).
── Add as an opkg feed (recommended — then `opkg upgrade` just works) ──
This release is itself a SIGNED package feed; opkg filters by
architecture, so the same lines work on every device:
wget -O /etc/opkg/keys/5ac4b177689cb8e0 https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
opkg update
opkg install luci-app-shater # pulls shater-core + shaterd too
The public-key install is one-time; after it, `opkg update/upgrade`
verify the signature with check_signature left on. Full guide: docs-shater/INSTALL.md.
── Or install the loose .ipk directly / from the tarball feed ──
wget -O /tmp/f.tgz <this release>/shater-feed-aarch64_cortex-a53.tar.gz
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
opkg install /tmp/shater/luci-app-shater_*_all.ipk
run: bash ci/gitea-release.sh release/*
# ---------------------------------------------------------------------------
# Publish the apk lane: ONE release PER ARCH (apk package filenames carry no
# arch, and apk fetches `<name>-<ver>.apk` relative to the packages.adb URL —
# a flat multi-arch release would collide). Rolling `apk-latest-<arch>` on
# dispatch, `apk-<tag>-<arch>` on a version tag. The tags do NOT match the
# workflow's `v*` trigger, so publishing them cannot re-trigger the build.
release-apk: release-apk:
name: release apk name: release apk
needs: build-apk needs: [test, build-apk]
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release # Publish whatever arch feeds succeeded — do NOT block the aarch64 release
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches # when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs. # artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
if: ${{ !cancelled() }} #
# `needs.test.result == 'success'` is the second half of the gate. Without
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
# skipped), this job runs with no artifacts at all, and — see the guard at
# the end of the publish step — used to exit 0 having published nothing. Red
# tests must SKIP this job, not "succeed" through it.
if: ${{ !cancelled() && needs.test.result == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -501,6 +399,9 @@ jobs:
with: with:
path: artifacts path: artifacts
# Identity of the VERSIONED release only. The rolling pointer is published
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
# from here.
- name: Determine release identity - name: Determine release identity
id: rel id: rel
run: | run: |
@@ -522,25 +423,114 @@ jobs:
PRERELEASE: ${{ steps.rel.outputs.prerelease }} PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }} ROLLING: ${{ steps.rel.outputs.rolling }}
run: | run: |
set -eu set -euo pipefail
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
# was the step's whole body: with no artifacts the glob stayed
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
# ended and the step exited 0 — "release apk" went GREEN having
# published absolutely nothing. Any upstream failure (all arches
# failing to build, an artifact-name change, a download-artifact
# hiccup) therefore looked like a successful release.
published=0
for d in artifacts/apkfeed-*; do for d in artifacts/apkfeed-*; do
[ -d "$d" ] || continue [ -d "$d" ] || continue
arch="${d#artifacts/apkfeed-}" arch="${d#artifacts/apkfeed-}"
if [ "$VER" = latest ]; then TAG="apk-latest-$arch"; else TAG="apk-$VER-$arch"; fi ROLL="apk-latest-$arch"
# The version we just built, read straight off the artifact
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
# this job checks out shallow, so it has no tags to describe from.
pkg=""
for a in "$d"/shaterd-*.apk; do
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
done
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
want="${pkg#shaterd-}"; want="${want%.apk}"
echo "[release-apk] arch=$arch built version=$want"
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`. 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 + byedpi (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/\`). The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
── Add as an apk repository (auto-updates via \`apk upgrade\`) ── ── Add as an apk repository (rolling — install once, then just update) ──
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$TAG/shater-apk.pem wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list 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 update
apk add luci-app-shater # pulls shater-core + shaterd too apk add luci-app-shater # pulls shater-core + shaterd too
apk add byedpi # optional: ByeDPI desync egress apk add byedpi # optional: ByeDPI desync egress
Update: apk update && apk upgrade shaterd shater-core luci-app-shater byedpi \`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
Full guide: docs-shater/INSTALL.md §6. The opkg/24.10 feed lives in the \`latest\` release." assets, so the same repo line keeps serving the newest build. To pin a
echo "[release-apk] publishing $TAG from $d" version instead, point the repo line at
TAG="$TAG" NAME="shater apk $VER ($arch)" BODY="$BODY" \ \`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \ 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
A bare \`apk upgrade\` reconciles EVERY installed package against every
configured repo and can downgrade unrelated system packages; naming them
upgrades only those (apk-tools 3: \"If list of packages is provided, only
those packages are upgraded along with needed dependencies\").
Full guide: docs-shater/INSTALL.md §5."
# 1) the pinnable versioned release (tag runs only)
if [ "$VER" != latest ]; then
echo "[release-apk] publishing apk-$VER-$arch from $d"
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
bash ci/gitea-release.sh "$d"/*
fi
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
# deletes the existing release before recreating it, so the old
# version's assets are REPLACED, never accumulated (two versions of
# one package in one index would let apk choose, not us).
echo "[release-apk] publishing $ROLL from $d"
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
PRERELEASE=true ROLLING=true \
bash ci/gitea-release.sh "$d"/* bash ci/gitea-release.sh "$d"/*
# 3) ASSERT the rolling release really serves THIS build — same class
# of check as ci/sdk-build-apk.sh's package-version assert, and for
# the same reason: the previous failure mode was silent. Reads the
# published release back over the API and requires our three
# tag-versioned packages at $want, the index, the key — and NO
# left-over package asset at any other version.
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
| tr '{},' '\n\n\n' \
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
# here-string, NOT `printf | grep -q`: under `pipefail` the early
# exit of grep -q can SIGPIPE the writer and fail a passing check.
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
grep -qxF "$f" <<<"$got" || {
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
echo " A router pinned to the rolling URL would have silently"
echo " stayed on its old version with a successful apk update."
exit 13; }
done
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
-e "luci-app-shater-$want.apk" || true)"
[ -z "$stale" ] || {
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
printf ' %s\n' $stale
echo " Two versions of one package in one feed = apk picks by its"
echo " own rules, not by our intent."
exit 14; }
echo "[release-apk] OK — $ROLL serves $want"
published=$((published + 1))
done done
# The assert the loop above never had. Zero feeds published is a failed
# release, not a quiet success — say so with a non-zero exit.
if [ "$published" -eq 0 ]; then
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
echo " NOTHING was published. Downloaded tree:"
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
exit 10
fi
echo "[release-apk] published $published arch feed(s)"
+85
View File
@@ -0,0 +1,85 @@
# Shater — the test gate, on every push to `main`.
#
# WHY THIS FILE EXISTS (2026-07-26)
# The fork had a full suite and no CI that ran it. Upstream's
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
# the 116 test files under shater/** had never once executed in CI, and
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
# releases.
#
# RELATIONSHIP TO release.yml
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
# gate: a separate workflow cannot block another one. The gate is the `test`
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
# drift apart.
name: test
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs-shater/**'
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: go + panel tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
# starts. Same step, same reason, as in release.yml's build job.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# v3.3.2 is the last release speaking the cache API act_runner implements
# (see the header of release.yml). Same key as the release build job, so
# whichever runs first warms the other.
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
# through `node --test`, and type stripping only exists from 22.6. On
# node 20 `npm test` dies with a syntax error before running anything.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
+20 -14
View File
@@ -1,28 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683 # mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
set -e -o pipefail set -euo pipefail
ARCH=$1
DEB_SRC=$2
OUT_IPK=$3
PROJECT=$(dirname "$0")/../.. PROJECT=$(dirname "$0")/../..
TMP_PATH=`mktemp -d` TMP_PATH=$(mktemp -d)
cp $2 $TMP_PATH trap 'rm -rf "$TMP_PATH"' EXIT
pushd $TMP_PATH
DEB_NAME=`ls *.deb` cp "$DEB_SRC" "$TMP_PATH"/
ar x $DEB_NAME pushd "$TMP_PATH" >/dev/null
# Derive the name from the file we copied — do not glob-parse `ls *.deb`.
DEB_NAME=$(basename "$DEB_SRC")
ar x "$DEB_NAME"
mkdir control mkdir control
pushd control pushd control >/dev/null
tar xf ../control.tar.gz tar xf ../control.tar.gz
rm md5sums rm -f md5sums
sed "s/Architecture:\\ \w*/Architecture:\\ $1/g" ./control -i sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
cat control cat control
tar czf ../control.tar.gz ./* tar czf ../control.tar.gz ./*
popd popd >/dev/null
DEB_NAME=${DEB_NAME%.deb} DEB_NAME=${DEB_NAME%.deb}
tar czf $DEB_NAME.ipk control.tar.gz data.tar.gz debian-binary tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
popd popd >/dev/null
cp $TMP_PATH/$DEB_NAME.ipk $3 cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
rm -r $TMP_PATH
+7 -1
View File
@@ -36,6 +36,12 @@ nul
# playwright MCP screenshots/snapshots # playwright MCP screenshots/snapshots
.playwright-mcp/ .playwright-mcp/
# working-session screenshots dropped in the repo root (not shipped docs)
/*.png
# throwaway build binaries / scratch staged under tmp/
/tmp/
# -- upstream sing-box-lx ----------------------------------------- # -- upstream sing-box-lx -----------------------------------------
/.idea/ /.idea/
.idea/ .idea/
@@ -57,7 +63,7 @@ nul
/venv/ /venv/
/test/cache.db /test/cache.db
# feed artifacts (tracked public key dist/shater-feed.pub is force-added) # feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
/dist/ /dist/
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not) # local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
+7 -1
View File
@@ -7,4 +7,10 @@
[submodule "submodules/wireguard-go"] [submodule "submodules/wireguard-go"]
path = submodules/wireguard-go path = submodules/wireguard-go
url = https://github.com/Leadaxe/wireguard-go-awg2-lx url = https://github.com/Leadaxe/wireguard-go-awg2-lx
branch = lx # The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
# conn/bind_std.go at all, so a `git submodule update --remote` against it
# would silently restore the bug where ClientBind/StdNetBind shred the
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
# the line the pin is actually on.
branch = lx-awg2-v005
+105
View File
@@ -0,0 +1,105 @@
<!-- Language: [Русский](README.md) · **English** -->
# shater
**A self-hosted internet-control appliance for OpenWrt routers.** One box turns a
home or office network into a transparent VPN gateway, a network-wide
ad/tracker/malware blocker, per-device parental control, and a live traffic
dashboard — all local, all configured from a rich built-in web panel.
> The primary README is Russian — [README.md](README.md). This is a condensed
> English mirror.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
## What it is
shater is a network proxy stack for **OpenWrt / ImmortalWrt / BananaWRT** routers
(Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic
through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and
is managed from a built-in web panel.
The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
A thin **LuCI launcher** (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
(default `:8088`).
## Highlights
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks
— `:53` interception is on by default and covers the queries a client sends to the
router itself, not just the ones aimed around it (`globals.dns_intercept`, D24).
- First-match routing by source / destination / list / geo / client → outbound /
selector / chain / direct / block; node groups with balancer/observatory;
multi-hop chains; per-rule egress.
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
`inet shater` nft table; atomic apply with `nft -c` validation and commit-confirm
auto-rollback.
- **DNS filtering & blocklists** with flexible sources (inline / file / url /
geosite), compiled `.srs` matcher; Block-DoH/DoT to stop filter bypass.
- Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow,
schedules) and per-domain/client/device statistics from in-process DNS events.
Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
## Install
One signed **apk** feed (OpenWrt / ImmortalWrt / BananaWRT **25.12+**), one
release per arch. Verbatim commands, the manual `.apk` install and the
rolling-vs-pinned choice are in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
```sh
wget -O /etc/apk/keys/shater-apk.pem "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" > /etc/apk/repositories.d/shater.list
apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
once and `apk update && apk upgrade shaterd shater-core luci-app-shater byedpi`
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
shater ships **inert** (globals off) so install never breaks connectivity. After
configuring nodes/rules: `uci set shater.globals.enabled=1 && uci commit shater`,
then `shaterd apply` and `shaterd confirm`.
## Build from source
`scripts/build-shaterd.sh [VERSION] [--fast]` builds the SPA (Vite), embeds it via
`//go:embed`, cross-builds musl-static `{amd64, arm64}` and UPX-packs the artifact
into `openwrt/shaterd/files/`. Details in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
## Repository layout
| Path | What |
|------|------|
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
| `docs-shater/` | Product documentation |
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
| `docs/`, `mkdocs.yml` | **Upstream** sing-box docs (mkdocs) — kept as-is |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | sing-box-lx engine tree |
## CI, upstream & license
CI (`.gitea/workflows/release.yml`) builds all 4 packages and publishes a signed
per-arch apk repo (EC key `shater-apk.pem`). A `vX.Y.Z` tag → the pinnable
`apk-vX.Y.Z-<arch>`; every run also refreshes the rolling `apk-latest-<arch>` and
asserts over the API that it really serves the version just built.
The engine is the **sing-box-lx** fork — a thin downstream of upstream sing-box that
lives by **rebase, never merge**; its constitution is
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md). Licensed under
[GPL-3.0](LICENSE), like upstream sing-box. Unofficial fork, not affiliated with
SagerNet.
+297 -51
View File
@@ -1,71 +1,317 @@
<!-- Язык: **Русский** · [English](README.en.md) -->
# shater # shater
**A self-hosted internet-control appliance for OpenWrt.** One box turns your **Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker, домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
per-device parental control, and a live traffic dashboard — configured from a rich трекеров и вредоносных доменов, средство родительского контроля по устройствам
web admin panel, all local. и живую панель аналитики трафика — всё локально, всё self-hosted, всё
настраивается из богатой веб-панели.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![status: v0.2 in development](https://img.shields.io/badge/status-v0.2%20in%20development-orange.svg)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg) ![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
![feed: apk 25.12+](https://img.shields.io/badge/feed-apk%2025.12%2B-orange.svg)
> ⚠️ **v0.2 is under active development on a new foundation.** The previous, ---
> complete and VM-verified xray-based version lives on the **[`v0.1`](../../src/branch/v0.1)**
> branch and still installs from the signed feed.
## What v0.2 is ## Что это
shater v0.2 is built as a **fork of [sing-box](https://github.com/SagerNet/sing-box) **shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
(via [sing-box-lx](https://github.com/Leadaxe/sing-box-lx))** with our whole BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
product embedded in the one binary: the proxy engine, a control plane, a DNS клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
filter, and a full admin panel. Riding sing-box gives a broad, up-to-date protocol гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
set — VLESS/VMess/Trojan/Shadowsocks, Reality, **AmneziaWG 2.0**, Hysteria2, TUIC — веб-панели.
without reinventing the anti-DPI arms race.
The UI is split for both integration and a great experience: a **thin LuCI app** Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
(a small dashboard + an "Open panel" button) hands a short-lived token to a [sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
**standalone admin panel** the daemon serves on its own port — so panel auth is Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
bootstrapped from LuCI's existing login, and the real UX is a modern SPA we fully самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
own. протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
## Highlights (planned) Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
поднятую демоном на собственном порту (по умолчанию `:8088`).
- Transparent TPROXY proxy (TCP+UDP), split by domain/geo/client, no DNS leaks. ---
- Broad protocols incl. **AmneziaWG 2.0**, Reality, Hysteria2, TUIC.
- Network-wide **DNS blocklists** with flexible sources (inline / file / url /
geosite) and an efficient matcher for million-entry lists.
- **Per-domain, per-client, per-device statistics** — fed by the engine's DNS
events in-process (no log scraping).
- **Per-device control**: block a site for one device or everyone; per-device
exit/proxy toggles; schedules; alerts.
- Fail-closed kill-switch, atomic apply with commit-confirm rollback, signed opkg
feed.
See **[`docs-shater/FEATURES.md`](docs-shater/FEATURES.md)** for the full list. ## Ключевые возможности
## Documentation **Прозрачный прокси и маршрутизация**
- TPROXY data-plane для нескольких LAN-интерфейсов (TCP + UDP), сниффинг
SNI/Host/QUIC, без утечек DNS.
- Правила маршрутизации по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
(domain/suffix/keyword/geosite), спискам, порту, протоколу →
outbound / selector / chain / direct / block.
- Группы узлов с балансировщиком/обсерваторией (least-ping / failover /
round-robin), **мульти-хоп цепочки** и выбор egress по правилу.
| Doc | What | **Надёжность («железно»)**
|-----|------| - **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Start here** — project context, v0.1→v0.2 history, decisions in brief, testbed/infra | прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Phased plan (Phase 1 = fork + embedding prototype) | трогаем.
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Full feature list with MVP/T1/T2 tags | - Атомарный apply с валидацией движком и `nft -c`, **commit-confirm** с
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary design, auth handoff, data/DNS/apply flow (diagrams) | авто-откатом к последней рабочей конфигурации.
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Why sing-box, why fork, why the panel split, license, etc. | - Идемпотентный reconcile из hotplug/boot под flock; management-bypass
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Admin-panel visual system — the "Faceplate" direction, tokens, components, north-star prototype | (SSH/LuCI/LAN) всегда в обход.
## Status **DNS, фильтрация, блокировки**
- Перехват `:53`, DNS движка sing-box в процессе; резолверы DoH/DoT/plain/FakeIP,
выбор резолвера по домену.
- **Блок-листы с гибкими источниками**: `inline` / `file` / `url` (авто-обновление) /
категория `geosite`; hosts-файл, plain-список или AdBlock-стиль `||domain^`
компилируются в локальный `.srs`. Эффективный компилированный матчер вместо
dnsmasq-мегасписков.
- **Block-DoH/DoT** — не даёт устройствам обходить фильтр через свой шифрованный DNS.
Foundation reset complete: v0.1 preserved on its branch, `main` reset for v0.2. **Подписки и узлы**
Next is **Phase 1** — fork sing-box-lx into `main` and stand up the embedding - Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
prototype (prove AmneziaWG 2.0, measure binary size). Follow `docs-shater/ROADMAP.md`. интервал обновления + вручную + на загрузке; стабильная идентичность узла между
обновлениями; квоты/срок из `subscription-userinfo`.
- Ручные узлы: share-ссылки, импорт файла, `wg-quick`/AmneziaWG `.conf`.
- Health board: TCP + реальная проба через прокси-путь, exit-IP, «протестировать
все».
## Hardware **Контроль по устройствам**
- Авто-обнаружение устройств (dhcp.leases + `ip neigh`), имена, живой статус/трафик.
- Тумблеры на устройство: прокси on/off, блок-листы on/off, страна/узел выхода;
блок/allow домена для одного устройства или для всех; расписания.
`aarch64_cortex-a53` covers Banana Pi **BPI-R3** (MT7986/Filogic 830) and **BPI-R4** **Статистика и видимость**
(MT7988/Filogic 880), both the OpenWrt `mediatek/filogic` target. `x86_64` is the - Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
QEMU test VM. устройствам, таймлайны — из DNS-событий движка в процессе (без скрейпинга логов).
- Трафик по клиенту/узлу/правилу (байты) из nft-счётчиков; живой query-log.
## License **Панель и профили**
- Встроенная SPA-панель (собственный порт, вшита в бинарь): overview, узлы и
подписки, правила маршрутизации, DNS/блок-листы, устройства, apply/rollback.
- Именованные профили/сцены и WAN-профили (условные оверрайды).
[GPL-3.0](LICENSE) (sing-box is GPL-3.0). See `docs-shater/DECISIONS.md` D6. Полный список с тегами MVP/T1/T2 — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
---
## Архитектура
Один бинарь `shaterd` держит движок, control-plane, DNS-фильтр и веб-сервер панели
в одном процессе; OpenWrt-обвязка (тонкий LuCI + procd/system glue) оборачивает его.
Конфиг — UCI desired-state; демон рендерит его в конфиг движка и применяет;
телеметрия течёт обратно в панель.
```mermaid
flowchart TB
subgraph BIN["shaterd — один бинарь (форк sing-box-lx)"]
ENG["движок sing-box\nпротоколы · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
CTRL["control-plane (shater/)\nUCI-модель · генерация конфига · apply/rollback · nft/routing"]
FILT["DNS-фильтр + блок-листы + политика по устройствам (shater/)"]
STAT["агрегатор статистики (shater/)"]
PANEL["веб-сервер панели + вшитая SPA (свой порт, токен-auth)"]
end
subgraph WRT["OpenWrt-обвязка (openwrt/)"]
LUCI["тонкий LuCI — мини-дашборд + кнопка «Открыть панель»"]
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
end
LUCI -->|"ubus: mint token"| PANEL
PROCD --> BIN
CTRL --> ENG
FILT --> ENG
ENG --> STAT
STAT --> PANEL
```
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
(проксировано) · direct (flow-offload) · block. Подробные диаграммы (auth-handoff,
data-plane, DNS-flow, apply-flow) — в [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
---
## Установка
shater поставляется одним подписанным **apk-фидом** (OpenWrt / ImmortalWrt /
BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/apk/keys/`).
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`
(+ опциональный `byedpi`). `shaterd` подтягивается автоматически как зависимость.
### Фид apk
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
```sh
# 1) доверяем ключу apk-фида (любое имя *.pem под /etc/apk/keys подходит).
wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
# 2) добавляем репозиторий — строка указывает на сам ФАЙЛ-ИНДЕКС packages.adb.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # опционально: ByeDPI desync-egress
```
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
пакеты.
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater byedpi
# эквивалент, дополнительно закрепляющий пакеты в world:
# apk add -u shaterd shater-core luci-app-shater byedpi
```
Документация 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`.
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
> «поставил и забыл»: `apk update` сам видит новую сборку. `apk-vX.Y.Z-<arch>` —
> фиксация на конкретной сборке: роутер не получит ничего нового, пока
> `/etc/apk/repositories.d/shater.list` не отредактируют руками — на каждом
> роутере и на каждый релиз. На `mini_router` сознательно прописан
> версионированный URL, и ручная правка — его цена. Подробнее —
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §5.1.
> Версии пакетов CI берёт из git-тега (`vX.Y.Z` → `X.Y.Z-r1`, сборка вне тега →
> `X.Y.Z-r<коммитов+1>`), поэтому каждая новая сборка действительно видна
> менеджеру пакетов как новая. Подробности — `docs-shater/INSTALL.md` §2.1.
> Полные инструкции — ручная установка из `.apk`, фиксация версии
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Включение
shater ставится **инертным** (globals выключены), чтобы установка не рвала связь.
Настройте узлы/правила (через панель или `uci`), затем включите и примените:
```sh
uci set shater.globals.enabled=1
uci commit shater
shaterd apply # apply + вооружить commit-confirm на живом демоне
shaterd confirm # подтвердить (отменяет авто-откат)
```
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
панель (`:8088` по умолчанию).
---
## Сборка из исходников
Ship-артефакт — бинарь `shaterd` со вшитой SPA. Собирается вне дерева SDK скриптом
`scripts/build-shaterd.sh`:
```sh
scripts/build-shaterd.sh [VERSION] [--fast]
```
Что он делает: (1) собирает панель — `cd panel && npm ci && npm run build` (Vite →
`panel/dist`); (2) копирует `panel/dist/*` в `shater/panel/webroot/`, откуда
`//go:embed` вшивает **реальную** SPA в бинарь; (3) кросс-собирает под `{amd64,
arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), stripped/trimmed;
(4) прогоняет UPX `--lzma --best` (~42 МБ → ~8–11 МБ); (5) стейджит артефакт в
`openwrt/shaterd/files/` для пакета.
Затем OpenWrt-пакеты из `openwrt/` собираются каноническим путём SDK. Детали
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
---
## Структура репозитория
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
| Путь | Что это |
|------|---------|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанный apk-фид |
| `SPECS/` | Конституция форка движка и спеки (Spec Kit) |
| `docs-lx/` | Справочник конфигурации фич движка (`lx-config.md`, `.ru.md`) |
| `lx-test/`, `submodules/` | Примеры конфигов движка и submodule AmneziaWG-рантайма |
| `docs/`, `mkdocs.yml` | **Апстрим** документация sing-box (mkdocs) — как есть |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | Дерево движка sing-box-lx |
---
## CI и релизы
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
публикует **подписанные фиды**:
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
Gitea-secret `KEY_APK`).
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
развёрнутых роутерах.
---
## Связь с upstream и движок
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Форк
разрабатывается по Spec Kit; неизменяемые принципы — в
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md), справочник фич движка — в
[`docs-lx/lx-config.ru.md`](docs-lx/lx-config.ru.md).
История: **v0.1** (движок на xray-core, полностью рабочая и VM-проверенная версия)
сохранена на ветке **[`v0.1`](../../src/branch/v0.1)**. v0.2 схлопнула runtime в
один форкнутый бинарь.
---
## Документация
| Документ | О чём |
|----------|-------|
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка apk-фида (роллинг/фиксация) |
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary дизайн, auth-handoff, data/DNS/apply-потоки (диаграммы) |
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Фазовый план |
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия |
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Визуальная система панели — «Faceplate», токены, компоненты |
| [`docs-shater/PORTING.md`](docs-shater/PORTING.md) | Порт проверенных кусков из v0.1 |
Индекс папки — [`docs-shater/README.md`](docs-shater/README.md).
---
## Оборудование
Арка `aarch64_cortex-a53` покрывает Banana Pi **BPI-R3** (MT7986/Filogic 830) и
**BPI-R4** (MT7988/Filogic 880) — оба таргет OpenWrt `mediatek/filogic`. `x86_64` —
QEMU-стенд для тестов.
---
## Лицензия
[GPL-3.0](LICENSE) — как у upstream sing-box. Подробности — в
[`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) (D6). Неофициальный форк, не
аффилирован с SagerNet.
+15 -235
View File
@@ -1,239 +1,19 @@
[English](README.md) · **Русский** # shater — этот файл переехал
# sing-box-lx Лицо этого репозитория — продукт **shater** (управляемый интернет-шлюз для
роутеров на OpenWrt). Основной README на русском — **[README.md](README.md)**;
краткая английская версия — **[README.en.md](README.en.md)**.
> **Тонкий downstream-форк [SagerNet/sing-box](https://github.com/SagerNet/sing-box).** Раньше здесь лежал README форка движка **sing-box-lx**, который shater
> Небольшой набор клиентских фич поверх upstream — транспорт **XHTTP**, **AmneziaWG 2.0**, **MASQUE** (CONNECT-IP / Cloudflare WARP), расширения **наблюдаемости** (CommandClient) и балансировка нагрузки **round_robin** — каждая за своим build-tag. вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
> Набор может расти, философия — нет: жить ребейзом на каждый upstream-тег, а не отдельной жизнью.
> 📄 README самого upstream sing-box — **[на GitHub](https://github.com/SagerNet/sing-box/blob/main/README.md)** (всегда актуальный). - **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)** — справочник конфигурации
фич движка (XHTTP, AmneziaWG 2.0, MASQUE).
- **[SPECS/CONSTITUTION.md](SPECS/CONSTITUTION.md)** — конституция тонкого форка
(принципы, build-tag изоляция, ребейз-модель).
- **[SPECS/README.md](SPECS/README.md)** — формат задач Spec Kit.
- Апстрим-README самого sing-box —
[на GitHub](https://github.com/Leadaxe/sing-box-lx).
Это не отдельный проект и не «улучшенный sing-box». Это upstream sing-box **плюс несколько фич**, реализованных так, чтобы их можно было переносить на новые версии sing-box годами, почти без конфликтов. Со временем фич может становиться больше — другие протоколы, новые возможности, — но каждая обязана жить по тем же правилам тонкого форка ([CONSTITUTION](SPECS/CONSTITUTION.md)). > Файл оставлен как указатель, чтобы у репозитория был один основной русский
> README (`README.md`), а не два конкурирующих.
---
## Уникальное позиционирование
В экосистеме sing-box форки, добавляющие XHTTP/AmneziaWG, делятся на два лагеря — и `sing-box-lx` не входит ни в один:
| Форк | Фичи | Подход | Синк с upstream |
|------|------|--------|-----------------|
| **SagerNet/sing-box** (upstream) | базовый | — | — |
| **shtorm-7/sing-box-extended** | десятки (WARP, MASQUE, MTProxy, XHTTP, AWG2, …) | «комбайн», правки повсюду | отдельная ветка, без ребейза на теги |
| **amnezia-vpn/amnezia-box**, **hoaxisr/amnezia-box** | только AWG | толстый форк, правки in-place | синк по веткам (`dev-next`/`stable-next`) |
| **➡ sing-box-lx** (этот репозиторий) | **малый набор (XHTTP, AWG2, наблюдаемость, round_robin)** | **тонкий: новые файлы за build-tag, минимум касаний upstream** | **ребейз атомарных `// lx`-коммитов на upstream-теги** |
**Чем мы отличаемся:**
- **Минимальная дивергенция.** Новый код живёт в новых файлах. Существующие upstream-файлы трогаются только в крошечных помеченных швах `// lx:begin … // lx:end`. → дешёвые ребейзы.
- **Изоляция за build-tag.** Фичи включаются тегами `with_xhttp` / `with_awg`. Сборка **без** них байт-в-байт повторяет поведение upstream — фичи ничего не ломают по умолчанию.
- **Идентичность сохранена.** Go-модуль остаётся `github.com/sagernet/sing-box`, бинарь называется `sing-box`. Суффикс `-lx` есть только в строке версии (`1.13.13-lx.N`).
- **Build-tag — родная конвенция sing-box**, а не наше изобретение (`with_quic`, `with_wireguard`, …). Мы просто применяем её с максимальной дисциплиной.
> Готовые форки-комбайны мы **не тянем как зависимость**, а используем только как референс wire-протокола.
---
## Фичи и статус
| # | Фича | Что это | Статус |
|---|------|---------|--------|
| **XHTTP** | клиентский транспорт | Xray-совместимый «splithttp» (режимы `auto`/`packet-up`/`stream-up`/`stream-one`) поверх Reality/TLS/h2c | ✅ **проверен живым Xray (3x-ui) сервером** (packet-up/auto): handshake + DNS + HTTPS + скачивание. `stream-one` — известный баг framing |
| **AmneziaWG 2.0** | клиентский endpoint | обфускация WireGuard: `Jc/Jmin/Jmax`, `S1–S4`, `H1–H4` + **2.0**: `I1–I5` (CPS — кастомные пакеты-приманки) | ✅ собирается, проходит `check`; зависимость **активирована** ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация); **проверено живым AWG2-сервером**: handshake + keepalive + трафик наружу |
| **Маскировка `id/ip/ib`** | сахар над AWG | WireSock-стиль: декларативная маскировка поверх `I1` — домен (`id`) + протокол (`ip`: `quic`/`dns`/`stun`/`sip`) + браузер (`ib`), ядро строит клиент-инициированную `I1`-приманку: `quic` = out-of-order фрагментированный Initial (i1+i2), `dns`/`stun`/`sip` = query/Binding-Request/INVITE | ✅ **`ip=quic` device-проверен на реальном LTE/WARP DPI** (~330 мс, упрощает Cloudflare WARP); `dns`/`stun`/`sip` собираются и проходят `check`, но режутся как класс протокола к WARP-edge — для других провайдеров |
| **Наблюдаемость** (расширения CommandClient) | live-стрим для UI | нативные расширения libbox gRPC за `with_lx_command`: `URLTestOutbound`, `GetRules`, `GetGroups`, `GetOutbounds`, `GetPool`, плюс `Connection.detourList` (хвост detour'а отдельным полем, SPEC 017) и `SubscribeDNSQueries` — структурный live-поток DNS (домен, qtype, rcode `-1`=ошибка, CNAME-цепочка, привязка к процессу, `dnsServer`/`dnsServerType`/`outbound`, SPEC 018) | ✅ в rc-серии, потребляется **LxBox**. SPEC 014–018: [`014`](SPECS/014-CLASH_API_TO_COMMANDCLIENT_MIGRATION/SPEC.md) · [`015`](SPECS/015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md) · [`017`](SPECS/017-CONNECTION_DETOUR_CHAIN/SPEC.md) · [`018`](SPECS/018-DNS_QUERY_STREAM/SPEC.md) |
| **round_robin** (балансировка нагрузки) | режим `urltest` | пул-балансировка на `urltest` за `with_lx_command` (для `GetPool`): `mode` `least_test` (дефолт) \| `round_robin`; `balancer{pool (дефолт 3), pool_tolerance (0=держать живые / >0=топ по задержке), sticky_hash}`. Sticky-ключ: пропущен/`[]` → дефолт `["process","domain"]`, `["none"]` → выкл; компоненты `process`/`domain`/`source_ip`/`dest_ip`/`dest_port`. Фиксированные слоты `slot[hash(key)%pool]` (FNV-64a), замена в слоте; `GetPool` отдаёт слоты | ✅ локально равномерно (10/10/10, sticky off); rc.15 починил схлопывание `domain`-ключа (теперь читается `metadata.Domain`, переживающий resolve домен→IP, а не пустой `destination.Fqdn`) — на устройстве равномерность 0.27 → 0.95+. SPEC [`019`](SPECS/019-URLTEST_MODE_STICKY/SPEC.md), конфиг — [docs/.../urltest.md](docs/configuration/outbound/urltest.md) |
| **MASQUE** (`type: masque`) | клиентский outbound | CONNECT-IP (RFC 9484) поверх HTTP/3 **или** HTTP/2 для **Cloudflare WARP** (SPEC 021): туннелирует целые IP-пакеты через userspace gVisor-стек; `profile` (`cloudflare`/`standard`), `network` (`h3`/`h2`), pinning ECDSA public key, idle-suspend + самовосстановление. h2 — ручной фреймер поверх `x/net/http2` (без доп. зависимостей); `connect-ip-go` вкопан | ✅ **device-verified на Wi-Fi и LTE** (`warp=on`, реальный трафик на `h3` и `h2`); на сетях, режущих входящий UDP:443, `h3`-handshake виснет — там `network: h2` (TCP:443) |
Подробные отчёты — в [`SPECS/002-…`](SPECS/002-XHTTP_CLIENT_TRANSPORT/IMPLEMENTATION_REPORT.md), [`SPECS/003-…`](SPECS/003-AWG2_CLIENT_ENDPOINT/IMPLEMENTATION_REPORT.md) и [`SPECS/009-…`](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/IMPLEMENTATION_REPORT.md). Полный справочник конфига — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**.
> **Не поддерживается (слой Reality, отложено):** post-quantum Reality (`pqv` / ML-DSA-65) и `spiderX` из Xray. Это Xray-специфичные фичи Reality, которых нет в sing-box, а Reality — upstream-слой TLS, который мы держим нетронутым (это не одна из наших фич). Классический X25519 Reality работает; сервер, который **требует** post-quantum Reality, не подключится. Это ограничение sing-box — правильнее решать в upstream (получим на ребейзе).
---
## Сборка
Сборка идёт через отдельный **`Makefile.lx`** (upstream `Makefile` не трогаем):
```bash
git clone --recurse-submodules https://github.com/Leadaxe/sing-box-lx
make -f Makefile.lx lx-build
# → бинарь ./sing-box с версией вида 1.13.13-lx.1
```
> `--recurse-submodules` обязателен для `with_awg`: рантайм AmneziaWG подключён submodule'ом `submodules/wireguard-go` → [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx).
Под капотом — стандартный `go build` с набором тегов (единственный источник истины — `make -f Makefile.lx lx-print-tags`):
```
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg
```
Это клиентский feature-set upstream **минус** серверные/нерелевантные теги — `with_acme` (серверный выпуск сертов), `with_tailscale`, `with_ccm`/`with_ocm` (AI-прокси) — **плюс** `with_purego` (CGO-free кросс-сборка, чтобы `with_naive_outbound`/cronet собирался при `CGO=0` на любом desktop-таргете, кроме Windows 7 / 32-бит legacy-сборки, где naive выкинут — у `cronet-go` нет windows/386) и наши фичи `with_xhttp` / `with_awg`. Всё остальное — ровно как upstream.
Проверка конфигов:
```bash
./sing-box check -c lx-test/config/xhttp_reality.json
./sing-box check -c lx-test/config/awg2_basic.json
```
> `lx-test/config/` — наши примеры (upstream `test/` — отдельный Go-модуль, его не используем).
**Android (`libbox.aar`).** `make lib_install && make lib_android` собирает gomobile-AAR — `libbox.aar` (SDK 23) + `libbox-legacy.aar` (SDK 21) — с зашитыми `with_xhttp`/`with_awg` (и без `tailscale`), для встраивания в Android-приложение-потребитель (нужны NDK r28 + OpenJDK 17). `Libbox.version()` отдаёт `…-lx.N`.
---
## Конфигурация фич
> Полные таблицы полей, дефолты и `awg-quick`→JSON маппинг — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**. Здесь — кратко.
### XHTTP (outbound transport)
```jsonc
"transport": {
"type": "xhttp",
"host": "example.com",
"path": "/xhttp",
"mode": "auto" // auto | packet-up | stream-up | stream-one
}
```
### AmneziaWG 2.0 (endpoint)
Поля AWG промотированы прямо в `WireGuardEndpointOptions`:
```jsonc
{
"type": "wireguard",
// … стандартные поля wireguard (private_key, address, peers, …) …
"jc": 10, "jmin": 50, "jmax": 100,
"s1": 20, "s2": 20, "s3": 60, "s4": 60,
"h1": 1, "h2": 2, "h3": 3, "h4": 4,
"i1": "<b 0x...><r 12>", "i2": "", "i3": "", "i4": "", "i5": "" // 2.0 CPS
}
```
> `I1–I5` — это конфиг (не согласуется по сети), значения должны **совпадать на клиенте и сервере**, регистрозависимы.
**Сахар-маскировка (`id`/`ip`/`ib`).** Вместо ручного `i1` задаёшь домен, протокол и
браузер — ядро само собирает `I1`-приманку (стиль WireSock). Удобно для упрощения
коннекта к **Cloudflare WARP**:
```jsonc
{
"type": "wireguard",
// … стандартные поля wireguard …
"id": "www.google.com", "ip": "quic", "ib": "chrome" // quic: id идёт как SNI в ClientHello
// или: "ip": "dns", "id": "www.google.com" // dns/sip: id идёт как QNAME/host
}
```
`ip` ∈ `quic|dns|stun|sip`; `id` обязателен только для `quic` (SNI); для `dns`/`sip` опционален (без него генерится псевдо-имя), `stun` игнорирует. Где задан — идёт на провод (SNI / QNAME / host)
и опционален для `sip` (без него генерится псевдо-host) и `stun`; `ib` ∈ `chrome|firefox|curl`
(только quic, эффект минимальный — без JA3-fingerprint). Взаимоисключается с явным `i1`.
Для **`quic`** ядро генерит out-of-order фрагментированный QUIC Initial (RFC 9001) — реальный
ClientHello, нарезанный на CRYPTO-фреймы в перемешанном порядке, так что line-rate DPI парсит
мусор и пропускает. Раскладка рандомизируется на каждый вызов (нет межюзерной сигнатуры), и
`ip=quic` теперь шлёт **два** независимых Initial (i1+i2) — поток читается как развивающаяся
QUIC-сессия. Это **единственный профиль, device-проверенный на реальном LTE/WARP DPI** (~330 мс).
`dns`/`stun`/`sip` реализованы как корректные клиент-инициированные запросы, но режутся как класс
протокола к WARP-edge (raw DNS/STUN/SIP к дата-центровому IP сам по себе аномален) — сохранены
для других провайдеров, чей DPI проверяет лишь корректность пакета. См.
[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) и [примеры SPECS/009](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/EXAMPLES.md).
### MASQUE (outbound — Cloudflare WARP)
Outbound `masque` туннелирует целые IP-пакеты через **CONNECT-IP (RFC 9484)**, HTTP/3 или HTTP/2,
к **Cloudflare WARP**. Не путать с AWG-сахаром *masquerade* `id/ip/ib` выше — разные фичи, одно слово.
```jsonc
{
"type": "masque",
"tag": "warp",
"server": "162.159.198.2",
"server_port": 443,
"profile": "cloudflare", // cloudflare (WARP) | standard (RFC 9484)
"network": "h3", // ТРАНСПОРТ: h3 (QUIC) | h2 (HTTP/2). НЕ tcp/udp — это network_list
"sni": "www.microsoft.com", // domain-fronting; endpoint аутентифицируется пиннингом public key, не по SNI
"private_key": "<base64 DER EC>",
"public_key": "<base64 DER PKIX>",
"ip": "172.16.0.2/32", "ipv6": "2606:4700:110:...::/128"
}
```
Ключевой материал (`private_key`/`public_key`/`ip`/`ipv6`) берётся готовым из конфига — регистрацию
устройства в WARP делает клиент. На сетях, режущих входящий UDP:443, `h3`-handshake виснет —
переключите узел на `network: h2` (TCP:443). Полный справочник —
[docs-lx/lx-config.ru.md §4](docs-lx/lx-config.ru.md) и [SPECS/021](SPECS/021-MASQUE_CONNECT_IP_OUTBOUND/CONFIG.md).
---
## Модель сопровождения
```
upstream tag (vX.Y.Z)
│
└─► ветка lx = upstream + N атомарных // lx-коммитов
├─ FORK_BOOTSTRAP (Makefile.lx, CI, версия)
├─ XHTTP client transport
├─ AWG2 client endpoint
└─ … (новые фичи — такими же атомарными // lx-коммитами)
```
- **Только ребейз, никогда merge.** На новый upstream-тег ветка `lx` ребейзится поверх него.
- Каждая фича — атомарный коммит(ы), помеченный `// lx`. Новые файлы конфликтов не дают; швы в upstream-файлах малы и переносятся вручную.
- Разработка ведётся по **Spec Kit** (`SPECS/NNN-T-S-NAME/`: SPEC → PLAN → TASKS → IMPLEMENTATION_REPORT).
### Remotes
```bash
origin git@github.com:Leadaxe/sing-box-lx.git # ветка по умолчанию: lx
upstream https://github.com/SagerNet/sing-box.git
```
---
## Структура lx-специфики
| Путь | Назначение |
|------|------------|
| `Makefile.lx` | сборка с lx-тегами и версией `-lx` |
| `.github/workflows/lx-ci.yml` | CI: матрица фич (baseline/xhttp/awg/full) + negative-check + кросс-платформа + android AAR |
| `.github/workflows/lx-release.yml` | релиз на `v*-lx.*`: desktop ×6 + `libbox.aar` → GitHub Release |
| `SPECS/` | Spec Kit (конституция, задачи, отчёты) |
| `lx-test/config/` | примеры конфигов для `sing-box check` |
| `transport/v2rayxhttp/` | XHTTP-клиент (новый пакет) |
| `transport/wireguard/device_awg.go` | AWG IpcSet-параметры (за `with_awg`) |
| `submodules/wireguard-go` | submodule: merged-форк AmneziaWG-рантайма ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx)) |
| `option/v2ray_xhttp.go`, `option/wireguard_awg.go` | опции фич |
| `include/v2rayxhttp.go` | регистрация транспорта за build-tag |
Поиск всех правок upstream-файлов: `grep -rn "// lx"`.
---
## Потребитель
Ядро собирается для десктоп-лаунчера **singbox-launcher** (бандлит `bin/sing-box`). На Android потребитель встраивает **`libbox.aar`** (gomobile) вместо бинаря — конфиг-JSON тот же. Маппинг `type=xhttp` и AWG-полей в визарде — задачи на стороне потребителя, не здесь.
---
## Ссылки
| | |
|---|---|
| Upstream | [SagerNet/sing-box](https://github.com/SagerNet/sing-box) · [документация](https://sing-box.sagernet.org/) |
| Этот форк | [Leadaxe/sing-box-lx](https://github.com/Leadaxe/sing-box-lx) |
| AmneziaWG-рантайм | [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация (3-way merge) |
| AmneziaWG upstream | [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) · [docs.amnezia.org](https://docs.amnezia.org/documentation/amnezia-wg/) |
| XHTTP (исток) | [XTLS/Xray-core](https://github.com/XTLS/Xray-core) — `transport/internet/splithttp` |
| Конфиг фич | [docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) |
| Spec Kit | [SPECS/](SPECS/) — [README](SPECS/README.md) · [CONSTITUTION](SPECS/CONSTITUTION.md) · [IMPLEMENTATION_PROMPT](SPECS/IMPLEMENTATION_PROMPT.md) |
---
## Лицензия
Наследует лицензию upstream sing-box (**GPL-3.0**). Все правки помечены `// lx` и распространяются под той же лицензией. Это неофициальный форк, не аффилирован с SagerNet.
@@ -3,7 +3,33 @@
| Поле | Значение | | Поле | Значение |
|------|----------| |------|----------|
| Тип | B (bug) | | Тип | B (bug) |
| Статус | C (complete) | | Статус | C (complete) — guard **снят** (см. баннер ниже) |
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
>
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
> возвращал управление). Android для shater не платформа и ей не станет —
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
> на его применение.
>
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
> отклонённая, а как «нода почему-то не работает».
>
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
> тест краснел.
>
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
-22
View File
@@ -45,30 +45,8 @@ type OutboundManager interface {
Default() Outbound Default() Outbound
Remove(tag string) error Remove(tag string) error
Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error
// lx:begin awg
// ConsumersOf returns the tags of outbounds that depend on (detour through)
// the given tag — the reverse of Dependencies(). Used by the selector guard to
// walk up to AmneziaWG consumers when a group switches to a WireGuard member.
ConsumersOf(tag string) []string
// lx:end awg
} }
// lx:begin awg
// AmneziaWGSuspendable is implemented by an AmneziaWG endpoint so the selector
// guard can suspend it (bring its device down) when a group it detours through
// switches to a WireGuard member — AmneziaWG inside a WireGuard tunnel hangs the
// kernel on Android. The marker lives in adapter so protocol/group can act on it
// without importing protocol/wireguard.
type AmneziaWGSuspendable interface {
// IsAmneziaWG reports whether this endpoint runs AmneziaWG (has AWG params).
IsAmneziaWG() bool
// SuspendAmneziaWG brings the device down so no junk handshake is sent. It is
// idempotent and safe to call on a not-yet-started or already-suspended endpoint.
SuspendAmneziaWG()
}
// lx:end awg
// lx:begin idle-suspend // lx:begin idle-suspend
// IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick // IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick
// (SPEC 020) can suspend it when it is idle and unreachable, without importing // (SPEC 020) can suspend it when it is idle and unreachable, without importing
-15
View File
@@ -208,21 +208,6 @@ func (m *Manager) Outbound(tag string) (adapter.Outbound, bool) {
return m.endpoint.Get(tag) return m.endpoint.Get(tag)
} }
// lx:begin awg
// ConsumersOf returns a copy of the tags that detour through tag (reverse of
// Dependencies()), built from the dependByTag ledger populated at Create time.
func (m *Manager) ConsumersOf(tag string) []string {
m.access.RLock()
defer m.access.RUnlock()
consumers := m.dependByTag[tag]
if len(consumers) == 0 {
return nil
}
return append([]string(nil), consumers...)
}
// lx:end awg
func (m *Manager) Default() adapter.Outbound { func (m *Manager) Default() adapter.Outbound {
m.access.RLock() m.access.RLock()
defer m.access.RUnlock() defer m.access.RUnlock()
+27 -16
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (the 25.12 # ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (25.12+;
# lane — additive next to ci/build-feed.sh, which stays the opkg/24.10 lane). # the only packaging lane shater has — see docs-shater/DECISIONS.md D22).
# #
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR> # Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
# e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \ # e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \
@@ -10,14 +10,14 @@
# This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls. # This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls.
# It runs on the CI RUNNER and: # It runs on the CI RUNNER and:
# 1. asserts the prebuilt shaterd binary for this arch was already staged by # 1. asserts the prebuilt shaterd binary for this arch was already staged by
# scripts/build-shaterd.sh (same artifact-order contract as the opkg lane); # scripts/build-shaterd.sh (the artifact-order contract);
# 2. drives a plain `debian:bookworm` container (workspace shared via # 2. drives a plain `debian:bookworm` container (the job's workspace volume is
# `--volumes-from`, same trick as ci/build-feed.sh) that downloads the # shared into it with `--volumes-from $(hostname)`; a bare `-v $PWD:...`
# ImmortalWrt 25.12 apk-SDK tarball and runs ci/sdk-build-apk.sh in it: # points at a host path that does not exist under act_runner's DinD) that
# compile the 4 packages as .apk, then `apk mkndx --sign` the per-arch # downloads the ImmortalWrt 25.12 apk-SDK tarball and runs
# `packages.adb` index. Unlike the usign lane (index signed on the runner), # ci/sdk-build-apk.sh in it: compile the 4 packages as .apk, then
# apk indexing NEEDS the SDK's host `apk` tool, so index+sign happen inside # `apk mkndx --sign` the per-arch `packages.adb` index. Indexing NEEDS the
# the container. # SDK's host `apk` tool, so index+sign happen inside the container.
# #
# Why the ImmortalWrt SDK (not openwrt/sdk images): the 25.12 fleet runs # Why the ImmortalWrt SDK (not openwrt/sdk images): the 25.12 fleet runs
# BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base (target mediatek/filogic, # BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base (target mediatek/filogic,
@@ -25,9 +25,9 @@
# mediatek-filogic 25.12 tag — hence the official SDK tarball. # mediatek-filogic 25.12 tag — hence the official SDK tarball.
# #
# Env: # Env:
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret — the apk analog # KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret). If set,
# of KEY_BUILD). If set, packages.adb carries an embedded signature # packages.adb carries an embedded signature verifiable by
# verifiable by dist/shater-apk.pem (routers: /etc/apk/keys/). # dist/shater-apk.pem (routers: /etc/apk/keys/).
# If unset, an UNSIGNED index is produced (warning; not shippable — # If unset, an UNSIGNED index is produced (warning; not shippable —
# apk signatures are effectively mandatory). # apk signatures are effectively mandatory).
set -eu set -eu
@@ -55,6 +55,15 @@ fi
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# --- 0.4) package version from the git tag ------------------------------------
# The workflow puts these in the job env via `ci/version.sh --env >>
# $GITHUB_ENV`; recompute here when run standalone. Passed into the container
# below and re-exported to the unprivileged build user in ci/sdk-build-apk.sh.
if [ -z "${SHATER_PKG_VERSION:-}" ] || [ -z "${SHATER_PKG_RELEASE:-}" ]; then
eval "$(sh "$REPO/ci/version.sh" --env)"
fi
echo "[apk-feed] package version: ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
# --- 0.5) runner-side caches -------------------------------------------------- # --- 0.5) runner-side caches --------------------------------------------------
# All under $REPO/.cache so (a) actions/cache in the workflow can persist them # All under $REPO/.cache so (a) actions/cache in the workflow can persist them
# between runs and (b) the nested container sees them via --volumes-from. # between runs and (b) the nested container sees them via --volumes-from.
@@ -63,7 +72,7 @@ chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# SDK; PKG_HASH still verifies every file, so stale = re-downloaded. # SDK; PKG_HASH still verifies every file, so stale = re-downloaded.
# apt/ debian:bookworm .deb archives for the host-deps install. # apt/ debian:bookworm .deb archives for the host-deps install.
# The nested container runs the build as an unprivileged user -> must be writable # The nested container runs the build as an unprivileged user -> must be writable
# (same reason as the chmod 0777 "$OUT" in ci/build-feed.sh). # (same reason as the chmod 0777 "$OUT" above).
CACHE="$REPO/.cache" CACHE="$REPO/.cache"
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt" mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
@@ -86,14 +95,16 @@ sh "$REPO/ci/fetch-sdk.sh" "$SDK_URL" "$SDK_TAR"
# --- 1) SDK build + index + sign inside a debian container ------------------- # --- 1) SDK build + index + sign inside a debian container -------------------
# `--volumes-from $(hostname)` shares THIS job container's workspace volume into # `--volumes-from $(hostname)` shares THIS job container's workspace volume into
# the nested container (see ci/build-feed.sh for why a bare -v does not work on # the nested container: a bare `-v $PWD:...` points at a host path that does not
# the act_runner DinD setup). # exist under the act_runner DinD setup.
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)" echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
docker pull -q debian:bookworm docker pull -q debian:bookworm
docker run --rm --volumes-from "$(hostname)" \ docker run --rm --volumes-from "$(hostname)" \
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e SDK_URL="$SDK_URL" \ -e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e SDK_URL="$SDK_URL" \
-e SDK_TAR="$SDK_TAR" -e DL_DIR="$CACHE/dl" -e APT_CACHE="$CACHE/apt" \ -e SDK_TAR="$SDK_TAR" -e DL_DIR="$CACHE/dl" -e APT_CACHE="$CACHE/apt" \
-e FEEDS_CACHE="$FEEDS_CACHE" -e KEY_APK="${KEY_APK:-}" \ -e FEEDS_CACHE="$FEEDS_CACHE" -e KEY_APK="${KEY_APK:-}" \
-e SHATER_PKG_VERSION="$SHATER_PKG_VERSION" \
-e SHATER_PKG_RELEASE="$SHATER_PKG_RELEASE" \
debian:bookworm bash "$REPO/ci/sdk-build-apk.sh" debian:bookworm bash "$REPO/ci/sdk-build-apk.sh"
# --- 2) sanity: the per-arch apk repo dir must be complete ------------------- # --- 2) sanity: the per-arch apk repo dir must be complete -------------------
-92
View File
@@ -1,92 +0,0 @@
#!/bin/sh
# ci/build-feed.sh — build the signed opkg feed for ONE arch.
#
# Usage: ci/build-feed.sh <ARCH> <SDK_DOCKER_TAG> <OUTDIR>
# e.g. ci/build-feed.sh x86_64 x86_64-24.10.4 out/x86_64
# ci/build-feed.sh aarch64_cortex-a53 mediatek-filogic-24.10.4 out/aarch64_cortex-a53
#
# This is the reusable per-arch entrypoint the Gitea workflow calls. It runs on
# the CI RUNNER and:
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
# scripts/build-shaterd.sh (into openwrt/shaterd/files/) — proving artifact
# order: SPA+shaterd build BEFORE the SDK package build;
# 2. drives the arch-matched `openwrt/sdk` docker image to compile all 4
# packages (ci/sdk-build.sh) and collect their .ipk into OUTDIR;
# 3. builds + usign-signs the opkg `Packages` index over OUTDIR
# (ci/install-usign.sh + ci/make-index.sh; signs iff $KEY_BUILD is set).
#
# Env:
# KEY_BUILD usign SECRET key (Gitea repo secret). If set, the feed index is
# signed and verifiable by dist/shater-feed.pub (fp 5ac4b177689cb8e0).
# If unset, an UNSIGNED feed is produced (make-index warns).
set -eu
ARCH="${1:?arch required (x86_64 | aarch64_cortex-a53)}"
SDK_TAG="${2:?sdk docker tag required (e.g. x86_64-24.10.4)}"
OUT="${3:?output dir required}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$OUT"; OUT="$(cd "$OUT" && pwd)"
# $OUT is created here as ROOT on the runner, but the nested `openwrt/sdk`
# container runs as the unprivileged `buildbot` (uid 1000) — so it must be able
# to write the collected .ipk into $OUT. World-writable is set HERE (a chmod
# from inside the container, as buildbot, cannot fix a root-owned dir).
chmod 0777 "$OUT"
# --- 0) the prebuilt shaterd binary must already be staged for this arch ------
case "$ARCH" in
x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;;
*) echo "[feed] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
esac
if [ ! -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" ]; then
echo "[feed] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
echo " Run scripts/build-shaterd.sh BEFORE ci/build-feed.sh." >&2
exit 3
fi
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
# --- 0.5) persistent dl/ (package source tarballs) ----------------------------
# Workspace dir restored/saved by actions/cache in the workflow and shared into
# the nested SDK container via --volumes-from; becomes CONFIG_DOWNLOAD_FOLDER
# there (ci/sdk-build.sh). PKG_HASH still verifies every file, so a stale cache
# can never produce a wrong build. Must be writable by the container's
# unprivileged buildbot user (same reason as the $OUT chmod above).
DL_DIR="$REPO/.cache/dl"
mkdir -p "$DL_DIR"
chmod -R a+rwX "$DL_DIR" 2>/dev/null || true
# --- 0.6) persistent feeds/ git checkouts -------------------------------------
# Workspace dir restored/saved by actions/cache (key: feeds-opkg-<release>) and
# symlinked over the SDK's feeds/ inside the container (ci/sdk-build.sh), so
# `scripts/feeds update -a` fetches deltas instead of re-cloning base+packages+
# luci from scratch (~7 min/run on this runner's slow github.com link).
# Top-level chmod only: the contents are created by the container's uid-1000
# build user and restored with the same ownership (tar-as-root preserves it).
FEEDS_CACHE="$REPO/.cache/feeds/opkg"
mkdir -p "$FEEDS_CACHE"
chmod a+rwX "$REPO/.cache" "$REPO/.cache/feeds" "$FEEDS_CACHE" 2>/dev/null || true
# --- 1) SDK package build (4 packages) in the arch-matched SDK image ----------
# We drive the `openwrt/sdk` docker image directly (not openwrt/gh-action-sdk):
# on a self-hosted Gitea act_runner the marketplace action fetch can be
# unavailable, and we need a CLEAN single-feed layout. `--volumes-from
# $(hostname)` shares THIS job container's workspace volume into the nested SDK
# container — a bare `-v $PWD:...` points at a host path that does not exist
# under the act_runner DinD setup. (Requires the job to run inside a container,
# which Gitea Actions does by default.)
echo "[feed] SDK build arch=$ARCH image=openwrt/sdk:$SDK_TAG"
docker pull "openwrt/sdk:$SDK_TAG"
docker run --rm --volumes-from "$(hostname)" \
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e DL_DIR="$DL_DIR" \
-e FEEDS_CACHE="$FEEDS_CACHE" \
"openwrt/sdk:$SDK_TAG" \
sh "$REPO/ci/sdk-build.sh"
# --- 2) index + sign the per-arch feed (usign, KEY_BUILD passed through) -------
sh "$REPO/ci/install-usign.sh"
KEY_BUILD="${KEY_BUILD:-}" bash "$REPO/ci/make-index.sh" "$OUT"
echo "[feed] done arch=$ARCH -> $OUT"
ls -l "$OUT"
+7 -10
View File
@@ -2,24 +2,21 @@
# ci/gen-apk-key.sh — generate the Shater **apk** feed signing keypair (25.12 lane). # ci/gen-apk-key.sh — generate the Shater **apk** feed signing keypair (25.12 lane).
# #
# apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys # apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys
# (prime256v1 PEM), NOT usign — the existing usign identity # (prime256v1 PEM). This is the ONLY feed identity shater has since the opkg
# (dist/shater-feed.pub, fp 5ac4b177689cb8e0) keeps signing the opkg/24.10 feed # lane was removed (D22) — the old usign key is history, not a second lane.
# and is NOT touched by this script. This generates a SEPARATE, second identity:
# #
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored). # dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
# Paste its full PEM contents into the Gitea repo secret # Paste its full PEM contents into the Gitea repo secret
# KEY_APK (the apk analog of the usign secret KEY_BUILD). # KEY_APK. Then delete the local file (or keep it in a
# Then delete the local file (or keep it in a password # password manager as the offline backup — losing it
# manager as the offline backup — losing it means every # means every deployed router must re-trust a new key).
# deployed router must re-trust a new key). # dist/shater-apk.pem PUBLIC key. Commit it:
# dist/shater-apk.pem PUBLIC key. Commit it next to shater-feed.pub:
# git add -f dist/shater-apk.pem # git add -f dist/shater-apk.pem
# (-f because /dist/ is gitignored). Routers install it # (-f because /dist/ is gitignored). Routers install it
# as /etc/apk/keys/shater-apk.pem. # as /etc/apk/keys/shater-apk.pem.
# #
# Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of # Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of
# every router that already installed shater-apk.pem (same rule as D7 for the # every router that already installed shater-apk.pem (see D22).
# usign key).
set -eu set -eu
REPO="$(cd "$(dirname "$0")/.." && pwd)" REPO="$(cd "$(dirname "$0")/.." && pwd)"
-60
View File
@@ -1,60 +0,0 @@
#!/bin/bash
# Make `usign` available on the CI runner so ci/make-index.sh can sign the opkg
# feed index. The OpenWrt SDK ships usign, but the index/signing step runs on the
# bare runner (outside the SDK container), so we build the tiny standalone tool
# from source (no libubox — it is intentionally dependency-free so it can
# bootstrap a build system). No-op if usign is already on PATH.
#
# Ported unchanged from Shater v0.1 (ci/install-usign.sh): usign is
# format-agnostic and the signing story is identical for the v0.2 4-package feed.
#
# CI cache: a previously-built binary is reused from $USIGN_CACHE (default:
# <repo>/.cache/tools — a workspace dir the workflow persists via actions/cache),
# skipping the apt + cmake + clone + build (~1 min). After a fresh build the
# binary is copied there so the NEXT run hits the cache. usign is a tiny static
# helper with no versioned protocol — a stale cached binary cannot mis-sign.
set -eu
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TOOLS="${USIGN_CACHE:-$REPO_ROOT/.cache/tools}"
# place <binary> — install onto PATH (system-wide if we can, else ~/bin)
place() {
local SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
if $SUDO install -m0755 "$1" /usr/local/bin/usign 2>/dev/null; then
:
else
mkdir -p "$HOME/bin"
install -m0755 "$1" "$HOME/bin/usign"
echo "$HOME/bin" >> "${GITHUB_PATH:-/dev/null}"
export PATH="$HOME/bin:$PATH"
fi
}
if command -v usign >/dev/null 2>&1; then
echo "[usign] already present: $(command -v usign)"
exit 0
fi
if [ -x "$TOOLS/usign" ]; then
place "$TOOLS/usign"
echo "[usign] restored from cache: $(command -v usign || echo "$HOME/bin/usign")"
exit 0
fi
SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
if ! command -v cmake >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq cmake gcc git
fi
tmp="$(mktemp -d)"
# Canonical source; fall back to the GitHub mirror if git.openwrt.org is flaky.
git clone --depth 1 https://git.openwrt.org/project/usign.git "$tmp/usign" \
|| git clone --depth 1 https://github.com/openwrt/usign.git "$tmp/usign"
( cd "$tmp/usign" && cmake -DCMAKE_BUILD_TYPE=Release . >/dev/null && make >/dev/null )
place "$tmp/usign/usign"
# seed the cache for the next run (best-effort)
mkdir -p "$TOOLS" 2>/dev/null && install -m0755 "$tmp/usign/usign" "$TOOLS/usign" 2>/dev/null || true
echo "[usign] built: $(command -v usign || echo "$HOME/bin/usign")"
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# Build the opkg feed index (Packages + Packages.gz) with SHA256 for a dir of
# .ipk files, then optionally usign-sign it if $KEY_BUILD (the Gitea repo secret)
# is set and usign is present. Arg $1 = feed dir.
#
# Ported from Shater v0.1 (ci/make-index.sh), unchanged. It is package-count and
# package-name agnostic: it indexes whatever .ipk are in the dir, so it serves
# BOTH the per-arch feed built by ci/build-feed.sh AND the combined release feed
# assembled in the release job (shaterd + byedpi per-arch, shater-core +
# luci-app-shater = _all). opkg filters by Architecture at install time, so one
# combined URL serves every device.
#
# Feed format: opkg `src/gz` (.ipk + text Packages index, usign signature).
# OpenWrt 24.10 (our SDK) still uses opkg; apk arrives at 25.12. The committed
# trust anchor dist/shater-feed.pub is a usign (Ed25519) key, matching this.
set -e
OUT="${1:?feed dir required}"; cd "$OUT"
: > Packages
for ipk in *.ipk; do
[ -e "$ipk" ] || continue
ctrl=$(tar -xzOf "$ipk" ./control.tar.gz | tar -xzO ./control)
sz=$(wc -c < "$ipk"); sha=$(sha256sum "$ipk" | cut -d' ' -f1)
printf '%s\n' "$ctrl" | sed '/^[[:space:]]*$/d' >> Packages
printf 'Filename: %s\nSize: %s\nSHA256sum: %s\n\n' "$ipk" "$sz" "$sha" >> Packages
done
gzip -kf Packages
if [ -n "${KEY_BUILD:-}" ]; then
# Signing was requested — a missing/broken signer must FAIL the build, not
# silently ship an unsigned feed that routers with check_signature on reject.
command -v usign >/dev/null 2>&1 || { echo "[index] ERROR: KEY_BUILD set but usign not found" >&2; exit 1; }
umask 077; printf '%s\n' "$KEY_BUILD" > /tmp/usign.sec
usign -S -m Packages -s /tmp/usign.sec || { rm -f /tmp/usign.sec; echo "[index] ERROR: usign signing failed" >&2; exit 1; }
rm -f /tmp/usign.sec
echo "[index] signed -> Packages.sig ($(head -1 Packages.sig))"
else
echo "[index] no KEY_BUILD -> UNSIGNED feed (opkg needs check_signature off, or set the secret)"
fi
echo "[index] contents:"; ls -l
+247 -5
View File
@@ -10,7 +10,6 @@
# the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its # the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its
# distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is # distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is
# ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves. # ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves.
# Same --volumes-from workspace-sharing pattern as ci/sdk-build.sh (opkg lane).
# #
# The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs # The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs
# as an unprivileged `build` user created here. # as an unprivileged `build` user created here.
@@ -28,11 +27,16 @@ SDK_URL="${SDK_URL:?SDK_URL env required}"
echo "[apk-sdk] arch=$ARCH repo=$REPO out=$OUT" echo "[apk-sdk] arch=$ARCH repo=$REPO out=$OUT"
echo "[apk-sdk] sdk=$SDK_URL" 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).
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
test -f "$REPO/openwrt/shaterd/Makefile" || { test -f "$REPO/openwrt/shaterd/Makefile" || {
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; } echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
# The prebuilt shaterd artifact must already be staged for this arch (same # The prebuilt shaterd artifact must already be staged for this arch
# contract as the opkg lane — scripts/build-shaterd.sh runs first). # (artifact-order contract — scripts/build-shaterd.sh runs first).
case "$ARCH" in case "$ARCH" in
x86_64) sfx=amd64 ;; x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;; aarch64_cortex-a53) sfx=arm64 ;;
@@ -108,7 +112,7 @@ export HOME=/home/build
cd "$SDKDIR" cd "$SDKDIR"
# Register this repo's openwrt/ as a src-link feed named `shater` (absolute # Register this repo's openwrt/ as a src-link feed named `shater` (absolute
# path required) — identical to the opkg lane (ci/sdk-build.sh). # path required).
cp -f feeds.conf.default feeds.conf cp -f feeds.conf.default feeds.conf
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
@@ -133,6 +137,101 @@ fi
echo "[apk-sdk] feeds install (prefer shater feed)" 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 byedpi 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
# `# CONFIG_PACKAGE_kmod-x is not set` for all 1126 of them and re-running
# defconfig deselected exactly nothing: the count came back 1078, unchanged.
# Meanwhile the very same explicit form DID stick for CONFIG_ALL/ALL_KMODS/
# ALL_NONSHARED. The difference is prompts. kconfig only honours a user value for
# a symbol that has one (sym_calc_value ignores S_DEF_USER for a promptless
# symbol and falls back to its `default`), and the ALL* symbols carry prompts in
# the SDK's own Config.in while these generated blocks are bare:
#
# config PACKAGE_kmod-mlx5-core
# tristate
# default m
#
# So no value we write into .config can ever turn them off — the fix has to
# remove the `default m` itself. That is what this does: drop every generated
# `config PACKAGE_*` block from the SDK's Config-build.in before the first
# defconfig. Nothing is lost by it — these blocks only replay which packages the
# BUILDBOT happened to build; the packages themselves are still declared, with
# prompts, by the package tree (tmp/.config-package.in), which is what makes our
# four selectable and what `select` acts on. KERNEL_*/LIBC/TOOLCHAIN blocks are
# left untouched, so the SDK still reproduces its own toolchain settings.
CB=$(find . -maxdepth 2 -name 'Config-build.in' -print -quit 2>/dev/null || true)
if [ -n "$CB" ] && command -v perl >/dev/null 2>&1; then
pkg_before=$(grep -c '^config PACKAGE_' "$CB" || true)
# Paragraph-wise delete: a block is `config PACKAGE_x`, its indented body, and
# the blank line that ends it. Anchored per-line (/m) so nothing else matches.
perl -0777 -pi -e 's/^config PACKAGE_\S+\n(?:[ \t]+\S[^\n]*\n)+\n//gm' "$CB"
pkg_after=$(grep -c '^config PACKAGE_' "$CB" || true)
echo "[apk-sdk] $CB: stripped $((pkg_before - pkg_after)) generated PACKAGE default blocks ($pkg_before -> $pkg_after)"
else
echo "[apk-sdk] WARNING: no Config-build.in found (or no perl) — per-package"
echo "[apk-sdk] 'default m' blocks stay; the kmod tripwire will catch it"
fi
# --- .config: turn OFF the SDK's mass-select defaults ------------------------
# Symptom (v0.2.2, and still v0.2.3 run 58): the SDK ran `apk mkpkg` on ~1100
# kmod-* packages — mlx5, amdgpu, ata, isdn, none of which we ship — and died
# with `Disk quota exceeded` on the runner's 64 GB ZFS quota. Our kmod deps pull
# in `package/kernel/linux/compile`, which packs every module marked =m.
#
# Why they are =m has nothing to do with anything we write here. An OpenWrt SDK
# carries its OWN top-level Config.in (target/sdk/files/Config.in), and it reads:
#
# config ALL_NONSHARED
# bool "Select all target specific packages by default"
# default ALL
# config ALL_KMODS
# bool "Select all kernel module packages by default"
# default ALL
# config ALL
# bool "Select all userspace packages by default"
# default y <-- y, not n, and ONLY inside the SDK
#
# In the main tree those three default to n; the SDK flips ALL to y so that
# `make world` in a bare SDK builds something useful. So `make defconfig` on ANY
# .config — empty or not — selects the entire kernel. This is stock OpenWrt, not
# an ImmortalWrt quirk: openwrt/openwrt's target/sdk/files/Config.in is identical.
# (It also means the reference we copied, Slava-Shchipunov/awg-openwrt, builds
# every kmod too — it just never hits a disk quota on GitHub's runners.)
#
# Fix: state all three explicitly. They carry prompts in the SDK's Config.in, so
# they are user-settable and an explicit value beats the `default`. Note the FORM:
# kconfig writes a false bool as `# CONFIG_X is not set` and `CONFIG_X=n` is not
# reliably honoured, so `is not set` is the only form used here. All three are set
# rather than just the root `ALL`, so this keeps working whichever symbol a future
# SDK makes the root of the chain.
# Stash anything the SDK shipped (see below — today there is nothing) and start
# from a known-empty file, so what we build here is exactly what we intended.
if [ -s .config ]; then mv -f .config .config.sdk; fi
: > .config
for s in ALL ALL_KMODS ALL_NONSHARED; do
echo "# CONFIG_$s is not set" >> .config
done
# About that stash: an SDK tarball ships NO top-level .config (run 58 logged
# `grep: .config: No such file or directory` — the only `.config` inside the
# tarball is the prebuilt KERNEL's, under the linux dir). This is also why the
# first version of this fix was aimed at the wrong thing: there was never a
# buildbot .config here to append to. Nothing needs carrying over from it either,
# because
# target/sdk/Makefile bakes the buildbot's non-package settings — every
# CONFIG_KERNEL_* included — into the SDK's generated Config-build.in as kconfig
# `default`s (target/sdk/convert-config.pl). defconfig therefore reproduces the
# exact toolchain/kernel settings the SDK was built with, on its own; an earlier
# attempt to copy those lines by hand was redundant and is gone.
# Should a future SDK start shipping a .config, this keeps the two things that
# would then be worth honouring — the target identity and the package format —
# and still lets the lines above override the mass-select.
if [ -s .config.sdk ]; then
echo "[apk-sdk] SDK shipped a .config — carrying over target identity + format:"
grep -E '^CONFIG_TARGET_[a-z0-9_]+=y$|^CONFIG_TARGET_(BOARD|SUBTARGET|ARCH_PACKAGES)=|^CONFIG_USE_APK=' \
.config.sdk | tee -a .config | sed 's/^/[apk-sdk] /' || true
fi
for p in shaterd shater-core byedpi luci-app-shater; do for p in shaterd shater-core byedpi luci-app-shater; do
echo "CONFIG_PACKAGE_$p=m" >> .config echo "CONFIG_PACKAGE_$p=m" >> .config
done done
@@ -151,11 +250,135 @@ fi
echo "[apk-sdk] defconfig" echo "[apk-sdk] defconfig"
make defconfig >/dev/null make defconfig >/dev/null
# --- second pass: deselect the kernel, keep only what our packages select -----
# Turning ALL/ALL_KMODS/ALL_NONSHARED off (above) provably worked — run 59 shows
# all three as `is not set` after defconfig — and changed the kmod count by
# exactly zero, 1078 both times. The kmods are not selected through ALL_KMODS at
# all. They are selected one by one, and here is where from:
#
# target/sdk/Makefile:
# ./convert-config.pl $(TOPDIR)/.config > $(SDK_BUILD_DIR)/Config-build.in
#
# The SDK's Config-build.in is GENERATED from the buildbot's .config — a config
# in which ALL_KMODS=y had already expanded into a `CONFIG_PACKAGE_kmod-*=m` line
# per module. convert-config.pl turns every `CONFIG_X=<val>` line into a kconfig
# symbol carrying an unconditional `default <val>`; its `next if
# /^(# )?CONFIG_PACKAGE/` filter sits in the `else` branch, which a line with an
# `=` in it never reaches. So the SDK ships, verbatim, 1078 blocks of:
#
# config PACKAGE_kmod-mlx5-core
# tristate
# default m
#
# Nothing there consults ALL_KMODS, which is why switching it off was inert.
#
# Fix: give those symbols an explicit user value. We cannot do it before the
# first defconfig — the list of names only exists once kconfig has expanded the
# tree — so this is a second pass: rewrite every selected kmod to `is not set`
# and re-run defconfig. Two kconfig rules make the result exactly what we want,
# and both are already demonstrated in our own logs:
# * an explicit value in .config beats a `default` (this is precisely why the
# `# CONFIG_ALL* is not set` lines survived defconfig in run 59), so the
# ~1078 kmods we do not need stay off;
# * `select` is a reverse dependency, OR-ed into the symbol's value AFTER the
# user value in sym_calc_value(), so it cannot be overridden by an explicit
# `n`. shater-core's `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` becomes
# `select PACKAGE_kmod-nft-tproxy` (scripts/package-metadata.pl: a `+` flag
# sets `$m = "select"`, and it re-emits the dependency's own depends too, so
# transitive kmods follow). Those come back on their own.
# Net effect: we build the handful of kmods our packages actually pull in.
#
# Rejected alternatives:
# * limiting what `package/kernel/linux/compile` packs — that target has no
# such knob; it iterates the selected set, so the selection IS the knob;
# * `package/kernel/linux/clean` + a targeted build — the kernel package would
# simply be rebuilt in full as a dependency of shater-core, same cost;
# * copying OpenWrt's own feed CI (openwrt/gh-action-sdk) — it does nothing
# about this; it just runs `make defconfig` and builds. Its one disk-related
# setting, CONFIG_AUTOREMOVE=y, is already the SDK's default;
# * editing the SDK's generated Config-build.in to strip the offending blocks —
# it would work, but it means parsing a generated kconfig file by hand and a
# format change would corrupt it silently. The two-pass approach uses only
# kconfig's documented semantics and leaves the evidence in .config.
kmods_all=$(grep -c '^CONFIG_PACKAGE_kmod-[^=]*=[my]$' .config || true)
if [ "$kmods_all" -gt 0 ]; then
echo "[apk-sdk] deselecting $kmods_all kmod packages, then defconfig again"
sed -i -E 's/^CONFIG_(PACKAGE_kmod-[^=]*)=[my]$/# CONFIG_\1 is not set/' .config
make defconfig >/dev/null
fi
# --- post-defconfig sanity + disk-cost readout -------------------------------
# A failed run leaves a ~27 MB log; digging the cause out of it is miserable, so
# print the handful of numbers that decide whether this run survives the
# runner's disk quota BEFORE anything is compiled.
kmods=$(grep -c '^CONFIG_PACKAGE_kmod.*=m' .config || true)
echo "[apk-sdk] target: board=$(sed -n 's/^CONFIG_TARGET_BOARD=//p' .config)" \
"subtarget=$(sed -n 's/^CONFIG_TARGET_SUBTARGET=//p' .config)" \
"arch_packages=$(sed -n 's/^CONFIG_TARGET_ARCH_PACKAGES=//p' .config)"
echo "[apk-sdk] kmod packages selected (=m): $kmods"
# Proof the mass-select stayed off: these three must come back out of defconfig
# as `is not set`. If any reads `=y`, the SDK's `default ALL`/`default y` won and
# the kmod count above will be in the four digits.
echo "[apk-sdk] mass-select symbols after defconfig:"
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/[apk-sdk] /' || true
# After the second pass the only kmods left are the ones shater-core's
# `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` turns into kconfig `select`s, plus
# whatever those select in turn — a handful. Worth printing verbatim while the
# list is short. A count of 0 is NOT fatal: those kmods ship in the router's own
# base feed, so apk resolves them there; but it would mean the selects did not
# fire, and that is something we want to see in the log rather than guess at.
if [ "$kmods" -le 30 ]; then
grep '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/[apk-sdk] /' || true
fi
# The two cache knobs are written before the first defconfig and have to survive
# both of them — losing DOWNLOAD_FOLDER silently costs us the dl/ cache, and
# losing LOCALMIRROR brings back the sourceware.org stalls. Cheap to just look.
echo "[apk-sdk] cache settings after defconfig:"
grep -E '^CONFIG_(LOCALMIRROR|DOWNLOAD_FOLDER)=' .config | sed 's/^/[apk-sdk] /' || true
echo "[apk-sdk] our packages after defconfig:"
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|byedpi|luci-app-shater)=' .config \
| sed 's/^/[apk-sdk] /' || true
# Each of our 4 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
grep -q "^CONFIG_PACKAGE_$p=m" .config || {
echo "[apk-sdk] ERROR: $p is NOT selected after defconfig."
echo " kconfig dropped it -> one of its DEPENDS is missing from the"
echo " installed feeds (check the 'feeds install' step above)."; exit 10; }
done
# Only our two nft kmods (+ whatever they themselves depend on) have any business
# being selected here — a dozen at the very most. A count in the hundreds means an
# ALL_KMODS-style mass-select crept back in, and the run would spend ~40 min
# packing the kernel before dying on `Disk quota exceeded`. Fail now instead.
[ "$kmods" -le 200 ] || {
echo "[apk-sdk] ERROR: $kmods kmod packages selected — that is the whole kernel."
echo " Aborting before this fills the runner's disk. Two causes are"
echo " possible, and the lines below tell them apart:"
echo " (a) the mass-select is back on -> a CONFIG_ALL* line reads =y;"
echo " (b) the second pass did not take -> ALL* are 'is not set' but the"
echo " kmods returned anyway, i.e. the per-kmod 'default m' from the"
echo " SDK's generated Config-build.in outlived our explicit 'n'."
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/ /' || true
echo " first few kmods still selected:"
grep -m5 '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/ /' || true
exit 11; }
for p in shaterd shater-core byedpi luci-app-shater; do for p in shaterd shater-core byedpi luci-app-shater; do
echo "[apk-sdk] === build $p ===" echo "[apk-sdk] === build $p ==="
make "package/$p/compile" V=s -j"$(nproc)" make "package/$p/compile" V=s -j"$(nproc)"
done done
# What the build actually cost on disk. The runner's 64 GB ZFS quota is the
# binding constraint on this lane, so record it while the tree still exists.
echo "[apk-sdk] disk usage after compile:"
du -sh build_dir staging_dir bin 2>/dev/null || true
df -h /home/build || true
# A 25.12 apk-SDK must emit .apk — finding only .ipk means a wrong SDK was fed in. # A 25.12 apk-SDK must emit .apk — finding only .ipk means a wrong SDK was fed in.
anyapk=$(find bin -type f -name '*.apk' | wc -l) anyapk=$(find bin -type f -name '*.apk' | wc -l)
[ "$anyapk" -gt 0 ] || { [ "$anyapk" -gt 0 ] || {
@@ -173,6 +396,25 @@ 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 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; }
echo "[apk-sdk] collected $found of our .apk" 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.
if [ -n "${SHATER_PKG_VERSION:-}" ] && [ -n "${SHATER_PKG_RELEASE:-}" ]; then
want="${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
for p in shaterd shater-core luci-app-shater; do
[ -f "$OUT/${p}-${want}.apk" ] || {
echo "[apk-sdk] ERROR: $p was not built as version '$want'."
echo " SHATER_PKG_VERSION/SHATER_PKG_RELEASE did not reach the package"
echo " Makefile — the build would have shipped a stale version (bug B4)."
echo "[apk-sdk] collected:"; ls -1 "$OUT" | sed 's/^/ /'
exit 12; }
done
echo "[apk-sdk] version check OK — our 3 packages are $want"
fi
# --- index + sign: exactly how the OpenWrt 25.12 buildsystem does it --------- # --- index + sign: exactly how the OpenWrt 25.12 buildsystem does it ---------
# apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \ # apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \
# --output packages.adb *.apk # --output packages.adb *.apk
@@ -204,7 +446,7 @@ INNER
chmod 0644 /home/build/inner.sh chmod 0644 /home/build/inner.sh
su build -s /bin/bash -c \ su build -s /bin/bash -c \
"ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' bash /home/build/inner.sh" "ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' SHATER_PKG_VERSION='${SHATER_PKG_VERSION:-}' SHATER_PKG_RELEASE='${SHATER_PKG_RELEASE:-}' bash /home/build/inner.sh"
chmod -R a+rwX "$OUT" 2>/dev/null || true chmod -R a+rwX "$OUT" 2>/dev/null || true
echo "[apk-sdk] OK arch=$ARCH — apk feed dir:" echo "[apk-sdk] OK arch=$ARCH — apk feed dir:"
-116
View File
@@ -1,116 +0,0 @@
#!/bin/sh
# Runs INSIDE an `openwrt/sdk:<target>-<ver>` container (CWD = SDK root
# /builder). The job's workspace is shared into this container via
# `docker run --volumes-from`, so the repo is visible at $REPO and output goes
# to $OUT (a dir under the repo, hence also visible to the runner afterwards).
#
# Unlike Shater v0.1 (which compiled ONLY xrayctl in the SDK and hand-packed the
# pure-data packages with tar), v0.2 builds ALL FOUR packages the canonical way,
# via the SDK feed + `make package/<p>/compile`:
#
# shaterd prebuilt binary — Build/Compile only VALIDATES that
# openwrt/shaterd/files/shaterd-<amd64|arm64>.upx was staged
# by scripts/build-shaterd.sh on the runner BEFORE this ran.
# (arch-specific .ipk: RSTRIP/STRIP disabled — packed ELF.)
# shater-core PKGARCH=all data glue (procd init, sysctl, uci-defaults).
# luci-app-shater PKGARCH=all LuCI thin launcher — its Makefile does
# `include $(TOPDIR)/feeds/luci/luci.mk`, so the `luci` feed
# MUST be updated first (that is what creates feeds/luci/luci.mk).
# byedpi arch-specific C — the SDK cross-compiles ciadpi from the
# upstream tarball (needs network for PKG_SOURCE_URL).
#
# Env (required): ARCH, REPO, OUT.
set -eu
ARCH="${ARCH:?ARCH env required}"
REPO="${REPO:?REPO env required}"
OUT="${OUT:?OUT env required}"
mkdir -p "$OUT"
echo "[sdk] arch=$ARCH repo=$REPO out=$OUT"
test -f "$REPO/openwrt/shaterd/Makefile" || {
echo "[sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
# The prebuilt shaterd artifact must already be staged for this arch.
case "$ARCH" in
x86_64) sfx=amd64 ;;
aarch64_cortex-a53) sfx=arm64 ;;
*) echo "[sdk] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
esac
test -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" || {
echo "[sdk] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
echo " scripts/build-shaterd.sh must run on the runner before the SDK build."; exit 3; }
# --- register this repo's openwrt/ as a src-link feed named `shater` ---------
# src-link REQUIRES an absolute path; $REPO/openwrt is exactly a feed root (it
# contains the 4 package dirs and nothing else that looks like a package).
cp -f feeds.conf.default feeds.conf
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
# Update metadata for ALL feeds: our `shater` feed + the SDK defaults (base,
# luci, packages, routing, telephony). We need `luci` for feeds/luci/luci.mk and
# `base`/`packages` for the runtime deps (kmod-nft-tproxy, kmod-nft-socket,
# ip-full, rpcd, luci-base) to resolve.
#
# Persistent feeds checkouts: $FEEDS_CACHE (a workspace dir the runner restores
# via actions/cache, shared into this container via --volumes-from) replaces
# the SDK's ephemeral feeds/ dir, so `feeds update` git-fetches deltas instead
# of re-cloning base+packages+luci every run (~7 min on the runner's slow
# github.com link). Correctness-safe: update always checks out feeds.conf's
# pinned revisions; if it ever fails on a cached checkout (e.g. a force-pushed
# upstream), the cache is wiped and the update retried with fresh clones.
if [ -n "${FEEDS_CACHE:-}" ] && mkdir -p "$FEEDS_CACHE" 2>/dev/null; then
rm -rf feeds
ln -s "$FEEDS_CACHE" feeds
echo "[sdk] feeds/ -> $FEEDS_CACHE (persistent cache)"
fi
echo "[sdk] feeds update -a"
if ! ./scripts/feeds update -a; then
[ -L feeds ] || { echo "[sdk] ERROR: feeds update failed"; exit 8; }
echo "[sdk] WARNING: feeds update failed on cached checkouts — wiping cache, cloning fresh"
find "$FEEDS_CACHE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
./scripts/feeds update -a
fi
echo "[sdk] feeds install (prefer shater feed)"
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
# Select our packages, then defconfig. `make package/<p>/compile` builds the
# explicit target regardless, but selecting first makes deps visible to defconfig.
for p in shaterd shater-core byedpi luci-app-shater; do
echo "CONFIG_PACKAGE_$p=m" >> .config
done
# Route source downloads through OpenWrt's fast CDN mirror FIRST — sourceware.org
# (elfutils) and other upstreams intermittently stall mid-transfer, and curl's
# --connect-timeout doesn't cover a stalled stream, so the SDK download hangs the
# build. LOCALMIRROR is tried before each package's own PKG_SOURCE_URL. (lx CI)
echo 'CONFIG_LOCALMIRROR="https://sources.cdn.openwrt.org"' >> .config
# Persistent dl/ across runs: $DL_DIR is a workspace dir the runner restores via
# actions/cache (see ci/build-feed.sh). Correctness-safe: the buildroot verifies
# PKG_HASH on every file already in dl/ and re-downloads on mismatch, so a stale
# cache can never leak a wrong source into the build.
if [ -n "${DL_DIR:-}" ]; then
echo "CONFIG_DOWNLOAD_FOLDER=\"$DL_DIR\"" >> .config
fi
echo "[sdk] defconfig"
make defconfig >/dev/null
# --- compile the 4 packages --------------------------------------------------
for p in shaterd shater-core byedpi luci-app-shater; do
echo "[sdk] === build $p ==="
make "package/$p/compile" V=s -j"$(nproc)"
done
# --- collect ONLY our 4 packages' .ipk (per-arch shaterd/byedpi + _all core/luci)
# NOT `find bin -name '*.ipk'`: the openwrt/sdk image ships HUNDREDS of prebuilt
# kmod/base .ipk under bin/, which a blanket copy would pull into the feed and
# get signed under OUR key. Match each package's own `<name>_<ver>_<arch>.ipk`.
found=0
for p in shaterd shater-core byedpi luci-app-shater; do
for ipk in $(find bin -type f -name "${p}_*.ipk"); do
cp -f "$ipk" "$OUT/"; found=$((found+1))
done
done
[ "$found" -ge 4 ] || { echo "[sdk] ERROR: expected >=4 of OUR .ipk, collected $found"; echo "[sdk] (all .ipk under bin/:)"; find bin -type f -name '*.ipk' | head -20; exit 4; }
chmod -R a+rwX "$OUT" 2>/dev/null || true
echo "[sdk] OK arch=$ARCH — collected $found of our .ipk:"
ls -l "$OUT"
Executable
+131
View File
@@ -0,0 +1,131 @@
#!/bin/sh
# ci/version.sh — the SINGLE source of truth for "what version is this build?".
#
# WHY THIS EXISTS (bug B4)
# -----------------------
# PKG_VERSION/PKG_RELEASE used to be hand-written literals in the four package
# Makefiles, and nobody remembered to bump them: v0.2.2 … v0.2.6 all shipped as
# `shaterd 0.2.0-r3` with DIFFERENT binaries inside (v0.2.6's ELF is 5 491 616 B
# vs r2's 5 488 336 B). Since apk offers an upgrade only when the feed's version
# string differs from the installed one, `apk update` saw nothing new and the
# routers could not be updated through the normal path at all.
#
# So the version is now DERIVED, in CI, from the git tag, and the package
# Makefiles only carry a fallback for manual/offline builds.
#
# THE SCHEME
# ----------
# tag push `vX.Y.Z` -> PKG_VERSION=X.Y.Z PKG_RELEASE=1
# any other build -> PKG_VERSION=X.Y.Z of the NEAREST reachable tag,
# (workflow_dispatch, PKG_RELEASE=<commits since that tag> + 1
# rolling `latest`)
# no tag / no git at all -> PKG_VERSION=0.0.0 PKG_RELEASE=1 (+ warning)
#
# apk compares `<upstream>-r<rel>` as: the dotted upstream part first
# (numerically, component by component), the `r<rel>` only as a tie-break.
# Verified against the real tool, not from memory —
# apk-tools 3.0.3 (`apk version -t`) and apk-tools 2.14.6:
# 0.2.6-r1 > 0.2.0-r3 0.2.6-r12 > 0.2.6-r1
# 0.2.7-r1 > 0.2.6-r12 0.0.0-r1 < 0.2.0-r3
# That is exactly the ordering this scheme needs:
# * a release always outranks every rolling build that preceded it
# (0.2.7-r1 > 0.2.6-rN for any N — the dotted part decides), and
# * rolling builds between two releases grow monotonically (r2 < r10 < r11),
# so a rolling build can never look newer than the next release, and the
# `latest` feed still moves forward on every dispatch.
#
# +1 on the commit count (rather than the raw count) only avoids `-r0` and makes
# a dispatch build of the tagged commit itself identical to the release build of
# that same commit — which is the truth: same tree, same binary.
#
# `byedpi` is deliberately NOT versioned from our tag — see openwrt/byedpi/Makefile.
#
# USAGE
# ci/version.sh # or --env: eval-able / $GITHUB_ENV-able lines
# ci/version.sh --pkg-version # X.Y.Z
# ci/version.sh --pkg-release # R
# ci/version.sh --binary # vX.Y.Z-rR[-g<sha>] for constant.Version
#
# Env:
# SHATER_REF / GITHUB_REF when it is `refs/tags/<tag>` that tag wins and no
# git history is needed (the tag-push path is exact
# even on a shallow checkout).
set -eu
REPO="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
TAG=""
EXACT=0
N=0
SHA=""
# --- 1) an explicit tag ref is authoritative (and needs no git) --------------
REF="${SHATER_REF:-${GITHUB_REF:-}}"
case "$REF" in
refs/tags/*) TAG="${REF#refs/tags/}"; EXACT=1 ;;
esac
# --- 2) otherwise ask git for the nearest reachable release tag --------------
# `--match 'v[0-9]*'` keeps non-release tags (latest, sdk-cache, apk-latest-*,
# musl-toolchain-cache) out. This repo is a sing-box FORK and therefore also
# carries upstream's v1.x tags — `git describe` picks the CLOSEST tag by commit
# distance, so our own v0.2.x (a handful of commits back) always wins over
# upstream's v1.x (thousands of commits back). The tag it picked is logged
# below, so a surprise is visible in the CI log rather than silently shipped.
if [ "$EXACT" -eq 0 ]; then
if D="$(git -C "$REPO" describe --tags --long --match 'v[0-9]*' 2>/dev/null)"; then
# `v0.2.6-1-g02c266188` -> TAG=v0.2.6 N=1 SHA=g02c266188.
# `%` strips the SHORTEST matching suffix, so a tag that itself contains a
# dash (`v0.2.0-healthplan`) survives intact.
TAG="${D%-*-g*}"
REST="${D#"$TAG"-}"
N="${REST%%-*}"
SHA="${REST#*-}"
if [ "$N" -eq 0 ]; then EXACT=1; fi
fi
fi
# --- 3) tag -> numeric PKG_VERSION ------------------------------------------
# Keep the leading dotted-numeric run only: `v0.2.0-healthplan` -> `0.2.0`.
VER=""
if [ -n "$TAG" ]; then
VER="$(printf '%s' "${TAG#v}" | sed -n 's/^\([0-9][0-9.]*\).*/\1/p' | sed 's/\.*$//')"
fi
if [ -z "$VER" ]; then
# No release tag anywhere (shallow clone with no tags, a tarball export, a
# fresh fork). 0.0.0 is BELOW every version we have ever published, so such a
# build can never masquerade as an upgrade on a real router; the commit count
# still makes successive dev builds distinguishable.
VER="0.0.0"
EXACT=0
N="$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 0)"
SHA="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '')"
[ -z "$SHA" ] || SHA="g$SHA"
echo "[version] WARNING: no reachable vX.Y.Z tag (and/or no git) -> $VER" >&2
fi
# --- 4) PKG_RELEASE + the string stamped into the binary --------------------
if [ "$EXACT" -eq 1 ]; then
REL=1
FULL="v${VER}-r${REL}"
else
REL=$((N + 1))
FULL="v${VER}-r${REL}${SHA:+-$SHA}"
fi
echo "[version] tag='${TAG:-none}' commits_since=$N exact=$EXACT -> ${VER}-r${REL} (binary: $FULL)" >&2
case "${1:---env}" in
--env|"")
printf 'SHATER_PKG_VERSION=%s\n' "$VER"
printf 'SHATER_PKG_RELEASE=%s\n' "$REL"
printf 'SHATER_VERSION=%s\n' "$FULL"
;;
--pkg-version) printf '%s\n' "$VER" ;;
--pkg-release) printf '%s\n' "$REL" ;;
--binary|--version) printf '%s\n' "$FULL" ;;
*)
echo "usage: $0 [--env|--pkg-version|--pkg-release|--binary]" >&2
exit 2 ;;
esac
@@ -0,0 +1,35 @@
//go:build darwin
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
// (control.DisableUDPFragment sets IP_DONTFRAG=1 on darwin).
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
ctrlErr error
)
ctrlErr = rawConn.Control(func(fd uintptr) {
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_DONTFRAG)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Fatal(sockErr)
}
return value != 0
}
@@ -0,0 +1,36 @@
//go:build linux
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
// (control.DisableUDPFragment sets IP_MTU_DISCOVER=IP_PMTUDISC_DO on linux,
// the same flag the user-visible failure was traced to on android).
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
ctrlErr error
)
ctrlErr = rawConn.Control(func(fd uintptr) {
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU_DISCOVER)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Fatal(sockErr)
}
return value == unix.IP_PMTUDISC_DO
}
@@ -0,0 +1,14 @@
//go:build !darwin && !linux && !windows
package dialer
import (
"syscall"
"testing"
)
func udpSocketDFSet(t *testing.T, _ syscall.Conn) bool {
t.Helper()
t.Skip("DF socket-flag introspection implemented for darwin, linux and windows only")
return false
}
@@ -0,0 +1,43 @@
//go:build windows
package dialer
import (
"syscall"
"testing"
"golang.org/x/sys/windows"
)
// IP_MTU_DISCOVER on windows (ws2ipdef.h); control.DisableUDPFragment sets it to
// IP_PMTUDISC_DO, the same "don't fragment" state the linux helper checks.
const (
windowsIPMTUDiscover = 71
windowsPMTUDiscDo = 1
)
// udpSocketDFSet reports whether the socket has "don't fragment" forced on.
// shater addition: upstream ships linux + darwin only, so the whole suite
// skipped on the dev host — where it is the one platform we can actually run it
// on before the router build.
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
t.Helper()
rawConn, err := sysConn.SyscallConn()
if err != nil {
t.Fatal(err)
}
var (
value int
sockErr error
)
ctrlErr := rawConn.Control(func(fd uintptr) {
value, sockErr = windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, windowsIPMTUDiscover)
})
if ctrlErr != nil {
t.Fatal(ctrlErr)
}
if sockErr != nil {
t.Skip("IP_MTU_DISCOVER is not readable on this host: ", sockErr)
}
return value == windowsPMTUDiscDo
}
+99
View File
@@ -0,0 +1,99 @@
// lx: regression tests for the udp_fragment / UDPFragmentDefault
// plumbing. The WireGuard endpoint (and MASQUE outbound) rely on
// UDPFragmentDefault=true reaching the real UDP socket as "DF clear": with DF
// set, an outer datagram larger than the path MTU is silently dropped instead
// of fragmented, which blackholes nested tunnels (AWG-over-AWG, MASQUE-over-AWG)
// and AWG s4 transport junk. These tests assert the socket flag itself, on both
// paths a WireGuard bind can take: the dialer (ClientBind, detour case) and the
// listener control (StdNetBind via WireGuardControl, no-detour case).
package dialer
import (
"context"
"net"
"syscall"
"testing"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func dialUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
t.Helper()
d, err := NewDefault(context.Background(), options)
if err != nil {
t.Fatal(err)
}
conn, err := d.DialContext(context.Background(), N.NetworkUDP, M.ParseSocksaddr("127.0.0.1:9"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
sysConn, isSysConn := conn.(syscall.Conn)
if !isSysConn {
t.Fatalf("dialed UDP conn %T does not expose SyscallConn", conn)
}
return sysConn
}
func listenUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
t.Helper()
d, err := NewDefault(context.Background(), options)
if err != nil {
t.Fatal(err)
}
// WireGuardControl() is the listener control conn.StdNetBind installs on the
// socket a no-detour WireGuard endpoint sends its outer datagrams from — the
// exact socket the DF default decides the fate of.
listenConfig := net.ListenConfig{Control: d.WireGuardControl()}
packetConn, err := listenConfig.ListenPacket(context.Background(), "udp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = packetConn.Close() })
sysConn, isSysConn := packetConn.(syscall.Conn)
if !isSysConn {
t.Fatalf("listened UDP conn %T does not expose SyscallConn", packetConn)
}
return sysConn
}
// Upstream default: no UDPFragmentDefault, no udp_fragment → DF is set on both
// the dial and listener paths. Pins the baseline the endpoint fix opts out of.
func TestUDPFragmentDFByDefault_LX(t *testing.T) {
if !udpSocketDFSet(t, dialUDPForDF(t, option.DialerOptions{})) {
t.Fatal("default dialer must set DF on dialed UDP sockets")
}
if !udpSocketDFSet(t, listenUDPForDF(t, option.DialerOptions{})) {
t.Fatal("default dialer must set DF on listener-control UDP sockets")
}
}
// UDPFragmentDefault=true (what the WireGuard endpoint and MASQUE outbound now
// set) → DF clear on both paths, so oversize outer datagrams fragment instead
// of vanishing.
func TestUDPFragmentDefaultClearsDF_LX(t *testing.T) {
options := option.DialerOptions{UDPFragmentDefault: true}
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("UDPFragmentDefault=true must leave DF clear on dialed UDP sockets")
}
if udpSocketDFSet(t, listenUDPForDF(t, options)) {
t.Fatal("UDPFragmentDefault=true must leave DF clear on listener-control UDP sockets")
}
}
// Explicit user config always wins over the protocol default, in both
// directions.
func TestUDPFragmentExplicitOverride_LX(t *testing.T) {
fragmentOff := false
options := option.DialerOptions{UDPFragment: &fragmentOff, UDPFragmentDefault: true}
if !udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("udp_fragment=false must set DF even when the protocol default allows fragmentation")
}
fragmentOn := true
options = option.DialerOptions{UDPFragment: &fragmentOn}
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
t.Fatal("udp_fragment=true must leave DF clear even without a protocol default")
}
}
+18
View File
@@ -25,6 +25,21 @@ func requireRoot(t *testing.T) {
} }
} }
// requireTCPDump skips when tcpdump is not installed.
//
// The same honesty this package's callers demand of a health reading: a missing
// INSTRUMENT is "not checked", never "broken". Without it every test in this
// file fails on `cmd.Start()` — sixteen red results that say nothing about the
// code and hide any real failure among them — on a machine where the only thing
// wrong is that a capture tool is absent. requireRoot has always drawn that line
// for privileges; this draws it for the tool.
func requireTCPDump(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("tcpdump"); err != nil {
t.Skip("integration test requires tcpdump on PATH; install it to run this suite")
}
}
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool { func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
t.Helper() t.Helper()
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle] return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
@@ -36,6 +51,9 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
// the wire. // the wire.
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool { func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
t.Helper() t.Helper()
// Every capture in this file funnels through here, so one guard covers the
// whole suite and no future test can forget it.
requireTCPDump(t)
ctx, cancel := context.WithTimeout(context.Background(), wait) ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l", cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
+141
View File
@@ -0,0 +1,141 @@
// lx:begin health-board
package urltest
import (
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
)
// captureEvictions swaps the eviction notice sink for the duration of a test and
// returns a func that reads back everything reported.
func captureEvictions(t *testing.T) func() []string {
t.Helper()
var (
mu sync.Mutex
msgs []string
)
orig := boardEvictionLog
boardEvictionLog = func(m string) {
mu.Lock()
msgs = append(msgs, m)
mu.Unlock()
}
t.Cleanup(func() { boardEvictionLog = orig })
return func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), msgs...)
}
}
// TestBoardHoldsAGenerationWithoutEvicting is the "what it holds" half of the
// bound. A live generation on this box is ~1200 tags (≈380 nodes plus their
// per-group egress copies and chain hops); the board must carry that — and a
// second generation's worth of overlap during a subscription rename — with no
// eviction at all, or the ceiling would be silently degrading real health data.
func TestBoardHoldsAGenerationWithoutEvicting(t *testing.T) {
read := captureEvictions(t)
s := NewHistoryStorage()
const generation = 1200
for gen := 0; gen < 2; gen++ {
for i := 0; i < generation; i++ {
s.StoreURLTestHistory("gen"+strconv.Itoa(gen)+"-node-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 20})
}
}
if got := s.Evicted(); got != 0 {
t.Fatalf("two full generations (%d tags) evicted %d entries; the board must hold them",
2*generation, got)
}
if msgs := read(); len(msgs) != 0 {
t.Fatalf("unexpected eviction notices: %v", msgs)
}
// Everything is still readable.
if s.LoadURLTestHistory("gen0-node-0") == nil {
t.Fatalf("the first tag of the first generation was lost without an eviction")
}
}
// TestBoardEvictsOldestAndSaysSo is the "what happens when it overflows" half.
// Overflow must (a) actually bound the map, (b) drop the LEAST RECENTLY MEASURED
// tags — on this box, exactly the ones no config names any more — and (c) be
// audible: a silent eviction is a health board quietly forgetting nodes it is
// still being asked about.
func TestBoardEvictsOldestAndSaysSo(t *testing.T) {
read := captureEvictions(t)
s := NewHistoryStorage()
base := time.Now().Add(-24 * time.Hour)
// Stale generation first: measured a day ago, nothing since.
const stale = 1500
for i := 0; i < stale; i++ {
s.StoreURLTestHistory("stale-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: base.Add(time.Duration(i) * time.Millisecond), Delay: 30})
}
if s.Evicted() != 0 {
t.Fatalf("evicted before the ceiling was reached")
}
// Now push past the ceiling with fresh measurements.
for i := 0; i <= maxBoardEntries; i++ {
s.StoreURLTestHistory("fresh-"+strconv.Itoa(i),
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 15})
}
if got := s.Evicted(); got == 0 {
t.Fatalf("board grew past %d entries without evicting anything — it is still unbounded", maxBoardEntries)
}
s.access.RLock()
size := len(s.delayHistory)
s.access.RUnlock()
if size > maxBoardEntries {
t.Fatalf("board holds %d entries, above the %d ceiling", size, maxBoardEntries)
}
// The day-old generation is what went, not the fresh one.
if s.LoadURLTestHistory("stale-0") != nil {
t.Fatalf("the oldest observation survived while newer ones were dropped")
}
if s.LoadURLTestHistory("fresh-"+strconv.Itoa(maxBoardEntries)) == nil {
t.Fatalf("the newest measurement was evicted")
}
msgs := read()
if len(msgs) == 0 {
t.Fatalf("entries were evicted with no notice — eviction must never be silent")
}
m := msgs[0]
for _, want := range []string{"health board full", "evicted", "re-probed"} {
if !strings.Contains(m, want) {
t.Fatalf("eviction notice %q does not say %q", m, want)
}
}
}
// TestBoardEvictionThroughMarkFailed pins the OTHER write path. MarkFailed is how
// a dead node is recorded, and a flood of dead renamed nodes is exactly the shape
// of the leak — so it has to prune too, not just the success path.
func TestBoardEvictionThroughMarkFailed(t *testing.T) {
captureEvictions(t)
s := NewHistoryStorage()
for i := 0; i <= maxBoardEntries; i++ {
s.MarkFailed("dead-" + strconv.Itoa(i))
}
s.access.RLock()
size := len(s.delayHistory)
s.access.RUnlock()
if size > maxBoardEntries {
t.Fatalf("MarkFailed grew the board to %d, above the %d ceiling", size, maxBoardEntries)
}
if s.Evicted() == 0 {
t.Fatalf("MarkFailed never prunes — the failure path is still unbounded")
}
}
// lx:end health-board
+118
View File
@@ -10,11 +10,128 @@
package urltest package urltest
import ( import (
"sort"
"strconv"
"sync"
"time" "time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
) )
// --- board capacity ---------------------------------------------------------
//
// The board is the one structure in the daemon whose key space is chosen by
// somebody else. Its keys are outbound TAGS, and on this box a tag is a node
// NAME straight out of the subscription — plus the derived per-group egress
// copies ("group-<g>-m<i>-<node>") and per-chain hop copies the probe planner
// creates for the same nodes. Providers rename their nodes freely, so a daily
// subscription refresh introduces a whole new generation of keys, while the
// store itself is pinned to the ENGINE's context (shater/engine.New) and so
// outlives every generation and every Apply — by design, so health survives a
// config change.
//
// Nothing ever removed a key. DeleteURLTestHistory exists but no shater path
// calls it (only daemon/ and clashapi/, which this fork does not run), so the
// map was strictly append-only for the life of the process — and the process is
// expected to live for months.
//
// The arithmetic: ~380 nodes, and a config with a couple of egress-bound groups
// plus a handful of chains puts a LIVE generation at roughly 380 base tags +
// 2x380 group copies + ~100 chain copies ≈ 1200 keys. One new generation per day
// is ~440k keys a year, at ~200 B per entry (map bucket + a tag string that is
// routinely 30-50 B with flag emoji, + a 56 B URLTestHistory) ≈ 88 MB of a
// 512 MB box — spent entirely on nodes that no longer exist.
const (
// maxBoardEntries is the hard ceiling. 4096 is ~3.4 live generations, so the
// board comfortably holds the current config plus the overlap while a
// subscription refresh swaps names, and still costs under a megabyte. A tighter
// bound would start evicting tags the running config actually uses; a looser one
// would stop being a bound in any useful sense.
maxBoardEntries = 4096
// keepBoardEntries is the prune target: drop a quarter at a time so the
// O(n log n) selection is amortised over ~1024 inserts instead of running on
// every probe once the board is full.
keepBoardEntries = 3072
)
// boardEvictionLog reports an eviction. A package var so tests can capture it;
// production leaves it writing to the process log, which under procd is the same
// syslog/logsink stream every other daemon line lands in.
//
// Eviction is NEVER silent. It is not free either: an evicted tag reverts to
// "untested" and its next probe re-measures it, so a board that evicts entries
// belonging to the LIVE config is a board whose ceiling is too low — and the only
// way anyone finds that out is this line.
var boardEvictionLog = func(msg string) { boardLogger().Warn(msg) }
// pruneLocked drops the least-recently-OBSERVED entries when the board exceeds
// maxBoardEntries. "Least recently observed" is max(LastOK, LastFail): the entry
// nothing has measured for the longest is, on this box, precisely a tag that no
// longer exists in any config — a renamed node, a removed group copy, a retired
// chain hop. Caller holds access.
func (s *HistoryStorage) pruneLocked() {
if len(s.delayHistory) <= maxBoardEntries {
return
}
type kv struct {
tag string
seen time.Time
}
all := make([]kv, 0, len(s.delayHistory))
for tag, h := range s.delayHistory {
seen := h.LastOK
if h.LastFail.After(seen) {
seen = h.LastFail
}
all = append(all, kv{tag, seen})
}
sort.Slice(all, func(i, j int) bool { return all[i].seen.Before(all[j].seen) })
drop := len(all) - keepBoardEntries
var oldest time.Time
for i := 0; i < drop; i++ {
if i == 0 {
oldest = all[i].seen
}
delete(s.delayHistory, all[i].tag)
}
s.evicted += uint64(drop)
msg := "urltest: health board full (" + strconv.Itoa(maxBoardEntries) + " tags) — evicted " +
strconv.Itoa(drop) + " least-recently-measured entries (" + strconv.FormatUint(s.evicted, 10) +
" total since start); they revert to untested and will be re-probed"
if !oldest.IsZero() {
msg += "; oldest observation was " + time.Since(oldest).Truncate(time.Second).String() + " ago"
}
boardEvictionLog(msg)
}
// Evicted reports how many entries the capacity bound has dropped since the store
// was created. Nonzero means the board reached maxBoardEntries at least once.
func (s *HistoryStorage) Evicted() uint64 {
if s == nil {
return 0
}
s.access.RLock()
defer s.access.RUnlock()
return s.evicted
}
// boardLogger is the process-wide fallback logger for eviction notices. The store
// is built from a plain constructor with no logger in sight (box.New, the daemon,
// shater/engine all call NewHistoryStorage()), so rather than change that
// signature everywhere the notice goes to the standard logger — which on the
// router is the daemon's own stderr, i.e. the same sink logsink owns.
var (
boardLogOnce sync.Once
boardLog log.ContextLogger
)
func boardLogger() log.ContextLogger {
boardLogOnce.Do(func() { boardLog = log.StdLogger() })
return boardLog
}
// HealthVerdict classifies a stored history entry at read time. // HealthVerdict classifies a stored history entry at read time.
type HealthVerdict int type HealthVerdict int
@@ -54,6 +171,7 @@ func (s *HistoryStorage) MarkFailed(tag string) {
updated.Delay = previous.Delay updated.Delay = previous.Delay
} }
s.delayHistory[tag] = updated s.delayHistory[tag] = updated
s.pruneLocked()
s.notifyUpdated() s.notifyUpdated()
s.access.Unlock() s.access.Unlock()
} }
+61
View File
@@ -0,0 +1,61 @@
package urltest
// lx: health board §5.C — the reachability half of "should this be probed".
//
// # Two different reasons not to probe, and why they cannot be one flag
//
// A group's OWN probing schedule is stood down for two unrelated reasons, and
// conflating them breaks one of the two:
//
// - NOT USED — no enabled routing rule reaches this group, so probing it
// measures a path nothing travels. That is a property of the CONFIG, it is
// decided once when the config is generated, and it travels in the config
// itself (option.URLTestOutboundOptions.SelfCheck). It cannot change while
// the box runs, because the rules cannot change while the box runs.
//
// - NOT REACHABLE RIGHT NOW — the group is a hop of a chain and a hop in
// FRONT of it is currently dead. Every member of this group dials through
// that hop, so every probe would fail inside it: the measurement would be
// about the broken hop, and would be recorded against this one. That is a
// property of the WORLD, it changes minute by minute, and it must be
// re-asked every time rather than baked into the config — a hop that comes
// back must resume probing on its own, with no reapply and nobody pressing
// anything.
//
// ProbeGate is the second one. It is deliberately a QUESTION asked at the
// moment of probing and never a stored answer: there is no flag to set, so
// there is no flag to forget to clear.
//
// The gate governs the group's own SCHEDULE only — the warm-up sweep and the
// ticker. An explicit check (a human, an API call) is a deliberate request and
// is never refused, exactly as with SelfCheck.
type ProbeGate interface {
// ProbeAllowed reports whether the outbound tagged tag may run its own
// scheduled probe right now.
//
// Implementations MUST answer true when they do not know: a gate that
// refuses on missing information would silence probing precisely when the
// system has the least idea what is going on, and nothing would ever
// measure its way out of that. A nil ProbeGate means "no gate" and every
// probe proceeds.
ProbeAllowed(tag string) bool
// ProbeWhenIdle reports whether the outbound tagged tag must keep measuring
// even when no traffic is passing through it.
//
// A urltest group normally probes only while it is in use: Touch arms the
// ticker on a dial, and the idle timeout stops it again. That is right for a
// group whose readings matter only while somebody is dialling it, and wrong
// for one the routing config REACHES: a rule that matches rarely — a narrow
// domain list, say — is in force the whole time, so the health of its target
// is a live question the whole time. Letting it go quiet means the panel
// reports "untested" about a rule that is armed, and the first real request
// pays a cold probe instead of picking an already-known-good member.
//
// Unlike ProbeAllowed, the safe answer here is FALSE when nothing is known.
// This one ADDS work, and a gate that claimed it on missing information would
// keep every group in the process probing forever — not a default anybody
// asked for. Absent gate, unknown tag, nothing configured yet: false, and the
// idle timeout behaves exactly as it always has.
ProbeWhenIdle(tag string) bool
}
+9
View File
@@ -21,6 +21,10 @@ type HistoryStorage struct {
access sync.RWMutex access sync.RWMutex
delayHistory map[string]*adapter.URLTestHistory delayHistory map[string]*adapter.URLTestHistory
updateHooks []*observable.Subscriber[struct{}] updateHooks []*observable.Subscriber[struct{}]
// evicted counts entries dropped by the capacity bound (board_lx.go). The map
// is keyed by outbound tags chosen by a subscription provider, so it needs a
// ceiling; see the comment on maxBoardEntries.
evicted uint64
} }
func NewHistoryStorage() *HistoryStorage { func NewHistoryStorage() *HistoryStorage {
@@ -71,6 +75,11 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes
} }
// lx:end health-board // lx:end health-board
s.delayHistory[tag] = history s.delayHistory[tag] = history
// lx:begin health-board — the map is keyed by provider-chosen tags and the
// store outlives every engine generation, so it must bound itself here: no
// shater path ever calls DeleteURLTestHistory. See maxBoardEntries.
s.pruneLocked()
// lx:end health-board
s.notifyUpdated() s.notifyUpdated()
s.access.Unlock() s.access.Unlock()
} }
+19
View File
@@ -2,6 +2,9 @@ package daemon
import ( import (
"context" "context"
// lx:begin sec-oomgate
"sync"
// lx:end sec-oomgate
"time" "time"
"unsafe" "unsafe"
@@ -19,6 +22,10 @@ type ManagedService struct {
handler ManagedHandler handler ManagedHandler
debug bool debug bool
oomReporter oomkiller.OOMReporter oomReporter oomkiller.OOMReporter
// lx:begin sec-oomgate
oomReportMu sync.Mutex
oomReportLast time.Time
// lx:end sec-oomgate
} }
type ManagedServiceOptions struct { type ManagedServiceOptions struct {
@@ -90,6 +97,18 @@ func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty)
if s.oomReporter == nil { if s.oomReporter == nil {
return nil, status.Error(codes.Unavailable, "OOM reporter not available") return nil, status.Error(codes.Unavailable, "OOM reporter not available")
} }
// lx:begin sec-oomgate
// Rate-limit operator-triggered reports to at most one per minute: each write
// dumps process state + the config snapshot (secrets) to disk, so an
// authenticated client must not be able to spin it in a tight loop.
s.oomReportMu.Lock()
if !s.oomReportLast.IsZero() && time.Since(s.oomReportLast) < time.Minute {
s.oomReportMu.Unlock()
return nil, status.Error(codes.ResourceExhausted, "OOM report rate-limited (max 1/min)")
}
s.oomReportLast = time.Now()
s.oomReportMu.Unlock()
// lx:end sec-oomgate
return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total()) return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total())
} }
+7 -1
View File
@@ -2,6 +2,9 @@ package daemon
import ( import (
"context" "context"
// lx:begin sec-consttime
"crypto/subtle"
// lx:end sec-consttime
"strings" "strings"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -59,8 +62,11 @@ func authenticate(ctx context.Context, secret string) error {
return status.Error(codes.Unauthenticated, "missing authorization") return status.Error(codes.Unauthenticated, "missing authorization")
} }
token, isBearer := strings.CutPrefix(values[0], "Bearer ") token, isBearer := strings.CutPrefix(values[0], "Bearer ")
if !isBearer || token != secret { // lx:begin sec-consttime
// Constant-time compare: a plain != leaks the secret via response timing.
if !isBearer || subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 {
return status.Error(codes.Unauthenticated, "invalid authorization") return status.Error(codes.Unauthenticated, "invalid authorization")
} }
// lx:end sec-consttime
return nil return nil
} }
+23 -2
View File
@@ -131,7 +131,9 @@ func (s *StartedService) StartTailscaleSSHSession(
continue continue
} }
go ssh.DiscardRequests(reqs) go ssh.DiscardRequests(reqs)
go s.forwardSSHAgentChannel(channel) // lx:begin sec-sshagent
go s.forwardSSHAgentChannel(sessionCtx, channel)
// lx:end sec-sshagent
} }
}() }()
} }
@@ -313,7 +315,8 @@ func (s *StartedService) StartTailscaleSSHSession(
return nil return nil
} }
func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) { // lx:begin sec-sshagent
func (s *StartedService) forwardSSHAgentChannel(ctx context.Context, channel ssh.Channel) {
defer channel.Close() defer channel.Close()
fd, err := s.handler.ConnectSSHAgent() fd, err := s.handler.ConnectSSHAgent()
if err != nil { if err != nil {
@@ -326,15 +329,33 @@ func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
return return
} }
defer conn.Close() defer conn.Close()
// The ssh-agent conn stays blocked in Read while idle, so io.Copy(channel,
// conn) never returns on its own — without this it leaks a goroutine + the
// agent fd for every closed session. Cancelling on either copy finishing (or
// on the session ctx) closes both ends, unblocking the peer copy. Both Close
// calls are idempotent with the deferred ones above.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
conn.Close()
channel.Close()
}()
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
go func() { go func() {
defer wg.Done() defer wg.Done()
io.Copy(conn, channel) io.Copy(conn, channel)
cancel()
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
io.Copy(channel, conn) io.Copy(channel, conn)
cancel()
}() }()
wg.Wait() wg.Wait()
} }
// lx:end sec-sshagent
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

-2
View File
@@ -1,2 +0,0 @@
untrusted comment: shater feed signing key
RWRaxLF3aJy44JbcxSFujtrFFEQ8lIsnTkd1K5TdjIhdlC2c0wa0fv4V
+6
View File
@@ -126,6 +126,12 @@ func (t *HTTP3Transport) newTransport() *http3.Transport {
conn.Close() conn.Close()
return nil, dialErr return nil, dialErr
} }
// quic-go does not take ownership of the packet conn passed to
// DialEarly: when the connection ends it only stops reading.
go func() {
<-quicConn.Context().Done()
conn.Close()
}()
return quicConn, nil return quicConn, nil
}, },
TLSClientConfig: t.tlsConfig, TLSClientConfig: t.tlsConfig,
+351
View File
@@ -0,0 +1,351 @@
package quic
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
sbTLS "github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/dns/transport"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
mDNS "github.com/miekg/dns"
)
var _ N.Dialer = (*trackingDialer)(nil)
// These tests pin down who owns the UDP socket handed to quic-go.
//
// quic-go's Dial/DialEarly take a net.PacketConn but do NOT take ownership of
// it: quic.setupTransport() builds a Transport with createdConn=false, and
// Transport.Close() then only calls conn.SetReadDeadline(time.Now()) instead of
// conn.Close(). So every QUIC connection torn down here — idle timeout, a
// retryable error, an engine reload calling Reset() — used to strand the UDP
// socket that carried it for the rest of the process's life. On a router that
// resolves through DoQ/DoH3 for months that is an unbounded fd leak.
//
// Both tests reconnect once and assert the socket from the FIRST connection is
// actually closed. Without the `<-conn.Context().Done() -> rawConn.Close()`
// watchdogs in quic.go / http3.go they fail on that assertion.
type trackedConn struct {
net.Conn
closeOnce sync.Once
closed chan struct{}
}
func (c *trackedConn) Close() error {
c.closeOnce.Do(func() { close(c.closed) })
return c.Conn.Close()
}
// trackingDialer hands out real UDP sockets and remembers every one of them.
type trackingDialer struct {
access sync.Mutex
conns []*trackedConn
}
func (d *trackingDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, network, destination.String())
if err != nil {
return nil, err
}
tracked := &trackedConn{Conn: conn, closed: make(chan struct{})}
d.access.Lock()
d.conns = append(d.conns, tracked)
d.access.Unlock()
return tracked, nil
}
func (d *trackingDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return net.ListenUDP("udp", nil)
}
func (d *trackingDialer) count() int {
d.access.Lock()
defer d.access.Unlock()
return len(d.conns)
}
func (d *trackingDialer) at(index int) *trackedConn {
d.access.Lock()
defer d.access.Unlock()
return d.conns[index]
}
func (d *trackingDialer) closeAll() {
d.access.Lock()
defer d.access.Unlock()
for _, conn := range d.conns {
conn.Close()
}
}
func requireClosed(t *testing.T, conn *trackedConn, what string) {
t.Helper()
select {
case <-conn.closed:
case <-time.After(5 * time.Second):
t.Fatalf("%s: the UDP socket of the retired QUIC connection was never closed — quic-go does not own it, we must", what)
}
}
func requireDialed(t *testing.T, dialer *trackingDialer, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if dialer.count() >= want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("expected at least %d dial(s), got %d", want, dialer.count())
}
func testServerTLSConfig(t *testing.T, nextProtos []string) *tls.Config {
t.Helper()
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
if err != nil {
t.Fatal(err)
}
return &tls.Config{
Certificates: []tls.Certificate{*certificate},
NextProtos: nextProtos,
MinVersion: tls.VersionTLS13,
}
}
func testClientTLSConfig(t *testing.T, nextProtos []string) sbTLS.Config {
t.Helper()
config, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
Enabled: true,
Insecure: true,
ServerName: "localhost",
})
if err != nil {
t.Fatal(err)
}
config.SetNextProtos(nextProtos)
return config
}
// startDoQServer serves a minimal DoQ responder and returns its address.
func startDoQServer(t *testing.T) M.Socksaddr {
t.Helper()
listener, err := quic.ListenAddr("127.0.0.1:0", testServerTLSConfig(t, []string{"doq"}), nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
listener.Close()
})
go func() {
for {
conn, acceptErr := listener.Accept(ctx)
if acceptErr != nil {
return
}
go func(conn *quic.Conn) {
for {
stream, streamErr := conn.AcceptStream(ctx)
if streamErr != nil {
return
}
go func(stream *quic.Stream) {
defer stream.Close()
request, readErr := transport.ReadMessage(stream)
if readErr != nil {
return
}
response := new(mDNS.Msg)
response.SetReply(request)
transport.WriteMessage(stream, 0, response)
}(stream)
}
}(conn)
}
}()
return M.ParseSocksaddr(listener.Addr().String())
}
func testQuery() *mDNS.Msg {
message := new(mDNS.Msg)
message.SetQuestion("example.com.", mDNS.TypeA)
return message
}
func TestQUICTransportClosesPacketConnOnReconnect(t *testing.T) {
t.Parallel()
serverAddr := startDoQServer(t)
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
dnsTransport := &Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeQUIC, "test-doq", nil),
dialer: dialer,
serverAddr: serverAddr,
tlsConfig: testClientTLSConfig(t, []string{"doq"}),
connection: transport.NewConnPool(transport.ConnPoolOptions[*quic.Conn]{
Mode: transport.ConnPoolSingle,
IsAlive: func(conn *quic.Conn) bool {
return conn != nil && !common.Done(conn.Context())
},
Close: func(conn *quic.Conn, _ error) {
conn.CloseWithError(0, "")
},
}),
}
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("first exchange: ", err)
}
requireDialed(t, dialer, 1)
first := dialer.at(0)
// Retire the connection the way a retryable error or an engine reload does.
dnsTransport.Reset()
requireClosed(t, first, "Reset()")
// The reconnect must still work, on a fresh socket.
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("second exchange: ", err)
}
requireDialed(t, dialer, 2)
second := dialer.at(1)
if second == first {
t.Fatal("expected a new UDP socket for the reconnect")
}
if err := dnsTransport.Close(); err != nil {
t.Fatal(err)
}
requireClosed(t, second, "Close()")
}
func TestHTTP3TransportClosesPacketConnOnReconnect(t *testing.T) {
t.Parallel()
mux := http.NewServeMux()
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
message, err := readRequestMessage(request)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
response := new(mDNS.Msg)
response.SetReply(message)
rawResponse, err := response.Pack()
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", transport.MimeType)
writer.Write(rawResponse)
})
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), nil)
if err != nil {
t.Fatal(err)
}
server := &http3.Server{Handler: mux}
go server.ServeListener(listener)
t.Cleanup(func() {
server.Close()
listener.Close()
})
serverAddr := M.ParseSocksaddr(listener.Addr().String())
dialer := &trackingDialer{}
t.Cleanup(dialer.closeAll)
stdConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
NextProtos: []string{http3.NextProtoH3},
MinVersion: tls.VersionTLS13,
}
dnsTransport := &HTTP3Transport{
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3", nil),
logger: logger.NOP(),
dialer: dialer,
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
headers: http.Header{},
serverAddr: serverAddr,
tlsConfig: stdConfig,
}
dnsTransport.transport = dnsTransport.newTransport()
t.Cleanup(func() { dnsTransport.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("first exchange: ", err)
}
requireDialed(t, dialer, 1)
first := dialer.at(0)
dnsTransport.Reset()
requireClosed(t, first, "Reset()")
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
t.Fatal("second exchange: ", err)
}
requireDialed(t, dialer, 2)
second := dialer.at(1)
if second == first {
t.Fatal("expected a new UDP socket for the reconnect")
}
if err = dnsTransport.Close(); err != nil {
t.Fatal(err)
}
requireClosed(t, second, "Close()")
}
func readRequestMessage(request *http.Request) (*mDNS.Msg, error) {
defer request.Body.Close()
rawMessage := make([]byte, 4096)
n, err := readFull(request.Body, rawMessage)
if err != nil {
return nil, err
}
var message mDNS.Msg
err = message.Unpack(rawMessage[:n])
if err != nil {
return nil, err
}
return &message, nil
}
func readFull(reader interface{ Read([]byte) (int, error) }, buffer []byte) (int, error) {
var total int
for total < len(buffer) {
n, err := reader.Read(buffer[total:])
total += n
if err != nil {
if total > 0 {
return total, nil
}
return total, err
}
}
return total, nil
}
+12
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"os" "os"
"time"
"github.com/sagernet/quic-go" "github.com/sagernet/quic-go"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
@@ -117,6 +118,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
rawConn.Close() rawConn.Close()
return nil, E.Cause(err, "establish QUIC connection") return nil, E.Cause(err, "establish QUIC connection")
} }
// quic-go does not take ownership of the packet conn passed to
// DialEarly: when the connection ends it only stops reading.
go func() {
<-earlyConnection.Context().Done()
rawConn.Close()
}()
return earlyConnection, nil return earlyConnection, nil
}) })
if err != nil { if err != nil {
@@ -144,6 +151,11 @@ func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.
return nil, E.Cause(err, "open stream") return nil, E.Cause(err, "open stream")
} }
defer stream.CancelRead(0) defer stream.CancelRead(0)
stopWatch := context.AfterFunc(ctx, func() {
stream.CancelRead(0)
_ = stream.SetWriteDeadline(time.Now())
})
defer stopWatch()
err = transport.WriteMessage(stream, 0, message) err = transport.WriteMessage(stream, 0, message)
if err != nil { if err != nil {
stream.Close() stream.Close()
+10 -11
View File
@@ -31,12 +31,11 @@ Do not delete it — we port proven pieces from it. What v0.1 has:
- **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS + - **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS +
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages. toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
- **CI + signed opkg feed** on Gitea: builds per-arch, signs the feed index with - **CI + a signed package feed** on Gitea: builds per-arch, signs the feed index,
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed publishes a rolling `latest` Gitea release the router consumes as a feed.
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`, (v0.1 shipped `.ipk` signed with a usign key — that lane is retired, D22.)
secret in the Gitea repo secret `KEY_BUILD`.
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest - Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
fail-closed, opkg install/upgrade from the signed feed. fail-closed, install/upgrade from the signed feed.
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
`run.json` are xray-shaped. `run.json` are xray-shaped.
@@ -91,7 +90,7 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported - **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
from). from).
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you - Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
are reading now (LICENSE, README, `docs-shater/`, `dist/shater-feed.pub`). are reading now (LICENSE, README, `docs-shater/`, the feed signing key).
## What to port from v0.1 (don't rewrite these ideas) ## What to port from v0.1 (don't rewrite these ideas)
@@ -105,8 +104,8 @@ overlay, don't redo:
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache) - **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
and the flexible **ruleset/list** model — though sing-box has its own share-link and the flexible **ruleset/list** model — though sing-box has its own share-link
parser and config schema we now target. parser and config schema we now target.
- **CI feed build + usign signing + Gitea release** (adapt to the single forked - **CI feed build + index signing + Gitea release** (adapted to the single forked
binary; keep key `5ac4b177689cb8e0`). binary; the format is apk, signed with the EC key — D22).
- The LuCI **design system** (the "instrument panel" identity) — reused for the - The LuCI **design system** (the "instrument panel" identity) — reused for the
mini-dashboard and as the panel's visual language. mini-dashboard and as the panel's visual language.
@@ -122,9 +121,9 @@ filter/stats engine wired into sing-box's DNS.
`https://github.com/SagerNet/sing-box`). `https://github.com/SagerNet/sing-box`).
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from - **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
`main`; new CI is added when the v0.2 build exists. `main`; new CI is added when the v0.2 build exists.
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret - **Feed signing:** EC (prime256v1) key for the apk index; secret in the repo
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep secret `KEY_APK`; public key `dist/shater-apk.pem`, installed on routers as
verifying). `/etc/apk/keys/shater-apk.pem`. Never regenerate it (D22).
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter - **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt` name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt`
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt), (localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
+349 -1
View File
@@ -64,11 +64,17 @@ sing-box is GPL-3.0; linking it makes the combined work GPL-3.0. Our own files m
stay GPL-2.0-or-later (which permits the upgrade), but the project LICENSE is stay GPL-2.0-or-later (which permits the upgrade), but the project LICENSE is
GPL-3.0 for clarity. GPL-3.0 for clarity.
## D7 — Keep the v0.1 feed signing identity ## D7 — Keep the v0.1 feed signing identity *(SUPERSEDED by D22)*
The usign feed key `5ac4b177689cb8e0` (public key in `dist/shater-feed.pub`, The usign feed key `5ac4b177689cb8e0` (public key in `dist/shater-feed.pub`,
secret in Gitea secret `KEY_BUILD`) carries over, so routers that already trust it secret in Gitea secret `KEY_BUILD`) carries over, so routers that already trust it
keep verifying v0.2 packages. Do not regenerate it without a documented rotation. keep verifying v0.2 packages. Do not regenerate it without a documented rotation.
> **Superseded 2026-07-25 (D22).** The opkg feed this identity signed no longer
> exists, so there is nothing left for the key to verify. It was never rotated or
> compromised — it is simply unused. `dist/shater-feed.pub` was deleted from the
> tree; the reasoning, and how to resurrect the identity if it is ever needed
> again, is in D22.
## D8 — Preserve, don't destroy: v0.1 lives on its branch ## D8 — Preserve, don't destroy: v0.1 lives on its branch
The reset moved the full working xray-based project to the `v0.1` branch and The reset moved the full working xray-based project to the `v0.1` branch and
cleaned `main`. Nothing is lost; reusable logic (reliability layer, nft/routing, cleaned `main`. Nothing is lost; reusable logic (reliability layer, nft/routing,
@@ -95,6 +101,10 @@ runtime, forcing an ELF with `PT_INTERP=/lib64/ld-linux-x86-64.so.2` + `PT_DYNAM
plane is tproxy/redirect (netplane); generate never emits a tun inbound, so plane is tproxy/redirect (netplane); generate never emits a tun inbound, so
the userspace gvisor netstack (~3.6 MB) is unreachable. If a tun inbound ever the userspace gvisor netstack (~3.6 MB) is unreachable. If a tun inbound ever
appears it falls back to the system stack — re-add the tag then. appears it falls back to the system stack — re-add the tag then.
**REVERTED 2026-07-25 — that reasoning was wrong and shipped a dead feature.**
gVisor is not only the tun stack: it is the netstack of the **WireGuard
endpoint**, which we do emit and do declare [MVP]. See D23; the tag is back and
is now held there by a test.
- 2026-07-23: `with_clash_api` also dropped. The admin panel is shater's own - 2026-07-23: `with_clash_api` also dropped. The admin panel is shater's own
web server and generate never emits a `clash_api` service; the desktop/CLI web server and generate never emits a `clash_api` service; the desktop/CLI
`LX_TAGS` keeps the tag for external dashboards. `LX_TAGS` keeps the tag for external dashboards.
@@ -424,3 +434,341 @@ on the next render.
Consequence: all delay numbers are comparable (least ping ranks apples against Consequence: all delay numbers are comparable (least ping ranks apples against
apples), and group settings lose two footgun fields while Settings keeps the apples), and group settings lose two footgun fields while Settings keeps the
two that actually govern every check. two that actually govern every check.
## D21 — A rule's destination is a rule-set, and nothing else
Decided 2026-07-25 (product owner). `config rule` carried THREE ways to say
where traffic is going: `dst_domain` (an inline domain list), `dst_ip` (an inline
CIDR list) and `dst_ruleset` (a reference to a `config ruleset`). Three
mechanisms meant three sets of semantics to learn and keep straight, and the
inline ones were the worse half of the trade: they are re-parsed per rule instead
of being compiled once into a `.srs`, they cannot be shared between rules, and
their matcher vocabulary had drifted from the rule-set one in a way nobody could
see (below).
**Decision: `dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is
the only destination matcher.** `Src`, `dst_port` and `proto` are untouched —
they are not lists of destinations and have no rule-set form.
- **Rejected: keep the inline lists as a shorthand.** "One obvious way" is the
whole point; a shorthand that quietly means something different from the long
form (see the bare-entry trap) is worse than no shorthand.
- **Rejected: promote inline lists to rule-sets lazily at generate time.** The
config on disk would then not say what the router does, and the panel would
have to render a list the user cannot find or edit.
### The bare-entry trap, and how the migration handles it
The two contexts already disagreed about exactly one spelling, silently:
| entry | in a rule (`dst_domain`) | in a rule-set (`entry`) | migrated to |
|--------------------|--------------------------|-------------------------|--------------|
| `example.com` | **exact host** | **host + subdomains** | `full:example.com` |
| `full:example.com` | exact host | exact host | unchanged |
| `suffix:example.com` / `.example.com` | host + subdomains | host + subdomains | unchanged |
| `keyword:ads` | substring | substring | unchanged |
| `regexp:^ads\.` | pattern | pattern *(added here)* | unchanged |
| `geosite:x` / `geoip:x` | inert (engine field removed) | inert (unknown prefix) | unchanged |
`shaterd migrate` (schema v1→v2, `shater/model/migrate.go`) creates one inline
`config ruleset` per rule that still carries a legacy list — `rule-<rule name>`
for domains, `rule-<rule name>-ip` for addresses — moves the entries across with
the conversion above, appends the new name to `dst_ruleset`, and deletes the old
option. It is idempotent, it resumes an interrupted run, and it never overwrites
a hand-written rule-set that already owns the generated name (it picks
`rule-<name>-2`). `regexp:` support was added to inline rule-sets in the same
change precisely so the move can be lossless.
`geosite:`/`geoip:` entries are copied VERBATIM rather than promoted to a
`source=geosite` rule-set: those matchers have been inert since the engine
dropped the route-rule geosite/geoip fields, and turning a dead matcher live
during an upgrade would be a behaviour change, not a migration. The text is kept
so the operator can see it and convert it deliberately.
**One deliberate semantic change, called out:** a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which is
almost never what "these sites and these networks" meant. The two generated
rule-sets are ORed, because `rule_set: [a, b]` matches when either matches. Such
a rule matches more after the migration than before; it affects only configs that
used both fields at once.
That AND→OR change is about the ENGINE's TCP/UDP path, and it deliberately does
**not** extend to the untunnelable-protocol plane (`shater/apply/untunnelable.go`,
the ping / IPTV / VPN-passthrough policy in nftables). There, a v1
`dst_domain + dst_ip` rule could never claim a packet that carries no domain, so
the plan skipped it; reading the migrated form as OR would have made the address
half suddenly decisive, and with `target=direct` that means an upgrade quietly
sending previously-tunnelled ICMP out with the client's real source address. A
rule whose rule-sets are known to match by NAME is therefore still skipped by that
plan, and the skip is reported ("a routing rule matches by name … as well as by
address"). Split the rule in two if you want the addresses decided there.
### The vocabulary is about ENTRIES YOU TYPE, not about every list body
The table above is the vocabulary of an **inline** rule-set's `entry` values (and
of the DNS-filter/device lists, which share the classifier). The other two rule-set
sources are not other spellings of it:
| source | what it is | vocabulary |
|------------------------------|----------------------------------|------------|
| `inline` | entries you type | the table above |
| `url` → `.srs` / `.json` | a compiled rule-set, engine-owned | the engine's, not ours |
| `url` → anything else | a hosts / one-domain-per-line / AdBlock TEXT FILE | **none** — every line is a domain plus its subdomains |
| `file` | a local `.srs` / `.json` | the engine's, not ours |
**Rejected: run text lists through the entry classifier too.** A published
AdGuard/OISD list is full of colon-bearing tokens that are ordinary filter syntax
(`##…:has(…)`, `$domain=`, absolute URLs); classifying them would either mis-import
them or bury the operator under hundreds of "unrecognised prefix" warnings per
list. The formats also disagree structurally — a hosts line carries several names,
so the text parser works per token, while an entry is a whole line. And `regexp:`
arriving from a third-party URL is a pattern compiled into the router's matcher and
evaluated per query, which is a very different proposition from one the operator
typed.
So the difference stands and is paid for in diagnostics instead: a text list
containing `full:` / `suffix:` / `keyword:` / `regexp:` is reported per list, on
every generate, naming the entries and pointing at `source=inline` where they work
(`warnListEntryVocabulary`, `shater/generate/ruleset.go`). The check tests only
those four markers, never the general `word:` shape, so it fires on a human's
mistake and stays quiet on published filter syntax.
Consequence: one destination mechanism, one vocabulary, one place a list is
edited; every list is compiled once and reused. The panel's rule editor drops its
Domain(s) and IP/CIDR(s) fields; its destination control is a checkbox list of
the rulesets that already exist, and nothing more. Creating and filling a list
stays in the Rulesets panel — **rejected: a "create a list from here" shortcut in
the rule editor**, because a second place to author a list is a second place for
its semantics and its duplicate-name rules to drift, and the whole point of this
decision was to stop having two.
## D22 — One packaging lane: apk. The opkg/`.ipk` lane is deleted, not disabled
Decided 2026-07-25 (product owner). CI built and published TWO signed feeds from
every run: opkg/usign (`.ipk` + `Packages.gz`, OpenWrt 24.10) and apk/EC (`.apk` +
`packages.adb`, OpenWrt/ImmortalWrt 25.12). The opkg half served nobody. Checked
on the actual hardware, not inferred:
| Device | Firmware | pkg arch | package manager |
|---|---|---|---|
| `mini_router` (BPi-R3 Mini) | ImmortalWrt 25.12.1 | `aarch64_cortex-a53` | apk-tools 3.0.5 |
| `main_router` (BPi-R4) | OpenWrt 25.12.0 | `aarch64_cortex-a53` | apk-tools 3.0.5 — **no `opkg` binary on the system at all** |
**Decision: delete the opkg lane outright.** Removed: the `build` + `release`
jobs from `.gitea/workflows/release.yml`; `ci/build-feed.sh`, `ci/sdk-build.sh`,
`ci/make-index.sh`, `ci/install-usign.sh`; and the trust anchor
`dist/shater-feed.pub`. The Gitea secret `KEY_BUILD` is now referenced by
nothing and can be deleted from the repo settings. `ci/version.sh`,
`ci/gitea-release.sh` and `ci/fetch-sdk.sh` are shared or apk-only and stay.
- **Rejected: keep the lane but stop triggering it** (comment it out / gate it on
a dispatch input). Dead code in CI is worse than no code: it keeps a second SDK
matrix, a second signing key and a second feed layout alive in everyone's head
and in every future edit, and it silently rots because nothing runs it. The
24.10 SDK images it pins are themselves a frozen dependency.
- **Rejected: keep `dist/shater-feed.pub` as a historical artifact.** A committed
trust anchor is an instruction — it invites someone to follow the old install
path for a feed that is no longer produced. Nothing is lost by removing it:
git history still holds the file, the SECRET half is untouched in `KEY_BUILD`,
and a usign secret key blob contains its own public half, so the identity can
be reconstructed if a 24.10 device ever has to be served again. Deleting the
file is reversible; a stale trust anchor pointing at an unmaintained feed is
the thing that quietly misleads.
- **Not done: revoking or rotating the usign key.** There is no incident. It is
retired, not burned (D7).
Consequence: one SDK, one key, one feed layout, one set of install instructions.
It also makes the rolling release `apk-latest-<arch>` the *only* install path
that does not require hand-editing a file per release — which is why the same
change fixed it: publishing was an either/or (`apk-latest-<arch>` on dispatch,
ELSE `apk-vX.Y.Z-<arch>` on a tag), so once releases moved to tag pushes the
rolling pointer stopped being written and froze at `0.2.0` while v0.2.9/v0.2.10
shipped — routers on the rolling URL got a successful, silent `apk update` with
nothing new. `release-apk` now writes the rolling pointer on every run and
asserts, by reading the published release back over the Gitea API, that it holds
our three tag-versioned packages at exactly the version just built and no asset
at any other version.
## D23 — The router tag set is a checked contract, not a string literal
`with_gvisor` was trimmed from the router set on 2026-07-23 (D9) as "unreachable
code: we never emit a tun inbound". True about tun — and irrelevant, because
gVisor is also the netstack of the **WireGuard endpoint**, which shater emits and
FEATURES.md declares [MVP] (AmneziaWG is called *"a driving requirement"*). Every
binary shipped between then and 2026-07-25 answered a configured WireGuard node
with:
```
create instance: initialize endpoint[0]: create WireGuard device:
gVisor is not included in this build, rebuild with -tags with_gvisor
```
`transport/wireguard/device_stack_stub.go` (`//go:build !with_gvisor`) returns
`tun.ErrGVisorNotIncluded` from **both** device constructors, so
`system_interface: true` is not an escape hatch either: WireGuard was 100% dead
in the shipped artifact while the panel offered it, the parser accepted `wg://`,
`awg://` and wg-quick `.conf` imports, and the owner had 7 WireGuard sections in
UCI on a production router.
- **Decision:** `with_gvisor` is part of the router tag set and stays there for
as long as we ship WireGuard. It costs **~2.8 MB raw / ~0.65 MB UPX per arch**
(measured 2026-07-25, both arches; `/overlay` on the production router is
6.9 GB with 205 MB used). A tag whose absence turns a declared feature into a
runtime error is not "dead weight" — it is the feature.
### Why the bug was invisible, and what now makes it visible
The defect was not a typo in a tag list. It was that **nothing connected the tag
list to the feature list**, and the shipped tag combination was the one build
configuration nothing exercised: the whole test suite compiles with the FULL
upstream set (`with_gvisor` included), so `TestAmneziaWGEndpoint` passed happily
while the artifact it was supposed to vouch for could not create a WireGuard
device. Tests proved the code was right; they never proved the *build* was.
Three pieces now hold it together:
1. **One definition of the set** — `scripts/router-tags.sh` (`SHATER_ROUTER_TAGS`
+ `SHATER_ROUTER_LDFLAGS`), sourced by `scripts/build-shaterd.sh` and by the
checker. The tag list used to live as a literal inside the build script, i.e.
in a file no test reads. A second copy is a second truth.
2. **A declared-feature table** — `shater/buildtags`: every tag-gated capability
we promise, with the exact tags it needs *to run* and why (the code anchor).
`TestRouterTagSetCoversDeclaredFeatures` parses the shell file and fails if a
declared feature lost a tag. It needs no build tags, no Linux, no network and
no privileges, so it runs in every plain `go test ./...` — including on the
Windows dev host, where nothing else can see the shipped configuration.
3. **A construction test under the shipped tags** —
`shater/generate.TestShippedTagSetConstructsDeclaredProtocols` drives one node
of every declared protocol (ss/vmess/trojan/vless ws-grpc-httpupgrade-quic-
xhttp/REALITY/uTLS-fp/hysteria2/tuic/**wg**/**awg**) through `box.New`+`Start`.
`scripts/check-router-tags.sh` runs it **with `SHATER_ROUTER_TAGS`**, and CI
runs that script (`.gitea/workflows/release.yml`) *before* the artifact is
built. In a router-tag-set run nothing may be skipped: a protocol that is not
compiled in fails the run instead of quietly disappearing from it.
(2) catches a trim the moment it is made and names the feature it kills; (3)
catches what a list comparison cannot — a tag that is present but insufficient.
Neither is a substitute for the other. A new protocol in `shater/parse` +
`shater/generate` means a new row in `buildtags.Features` and a new probe case;
`TestEveryTagGatedFeatureIsProbed` fails until both exist.
- **Rejected: "just add the tag".** The one-line fix restores WireGuard and
leaves the mechanism that hid it fully intact — the next size-driven trim is
equally invisible. The tag is the smallest part of this decision.
- **Rejected: run the WHOLE test suite with the router tag set in CI.** It is the
obvious move and it does not work: parts of the suite legitimately depend on
upstream-only tags, and the run costs a second full compile of a 25 MB binary's
worth of packages on every release. A focused, unprivileged construction test
buys the same evidence for ~10 s and, unlike a full run, can be *required* to
skip nothing.
- **Rejected: assert the tag set against upstream's `DEFAULT_BUILD_TAGS`.** That
makes any trim a failure, which turns the check into noise and re-litigates D9
on every upstream rebase. The contract is with our own feature list, not with
upstream's.
- **Not done: dropping `with_lx_command`.** It is inert for `shaterd` — nothing
under `shater/` imports `sing-box/daemon` or `experimental/libbox`, and
`go list -deps ./shater/cmd/shaterd` links neither, so it costs zero bytes. It
stays only so the router set remains a subset of the lx desktop set. Noted
because "a tag that buys nothing" is the mirror image of this bug and should be
removed deliberately, not silently.
## D24 — DNS interception is the DEFAULT (`dns_intercept=1`), not an opt-in
Decided 2026-07-26. `Globals.DNSIntercept` shipped as opt-in (`default false`, and
absent from both `DefaultGlobals` and the shipped `/etc/config/shater`). The result
was an **inverted** posture, which is the reason this is a decision and not a
preference:
- a client with **standard** settings — DNS = the router's address, exactly what
DHCP hands out — sent its queries to the router. The nft `:53` divert was behind
the flag (`netplane/nft.go`), and the rule right after it is an unconditional
`fib daddr type local accept`, so the query was delivered locally to dnsmasq and
forwarded to the ISP **in the clear**: no blocklists, no per-device DNS rules,
no Block-DoH, no resolver detour, nothing;
- a client that hard-coded `8.8.8.8` "to bypass the router" was addressing a
non-local IP and **was** caught by the ordinary tproxy catch-all.
The obedient client leaked; the evader did not. Meanwhile `FEATURES.md`, `README.md`
and D14 all promised "no DNS leaks" and "dnsmasq never sees LAN queries" — true only
for the traffic pattern the default did not cover. `dns_intercept` appeared nowhere
in `docs-shater/` at all.
**Decision: `DNSIntercept` is seeded ON in `model.DefaultGlobals`, and the shipped
`/etc/config/shater` carries an explicit `option dns_intercept '1'`.** Nothing about
the interception MECHANISM changed — only which side of the switch is the default.
**`.lan` and the private PTR zones keep working, and that is a pre-existing part of
the mechanism, not something bolted on for this flip.** `generate/dns.go` adds a
synthetic DNS server (`shater-local-dns`, plain UDP to `127.0.0.1:53`, detour
`direct`, so the daemon's own loop-mark keeps it out of the divert) and PREPENDS a
`domain_suffix` rule for `lan` + the RFC6303 private reverse zones, ahead of every
device/filter rule. Two honest limitations: it hardcodes `lan` (a router whose
dnsmasq domain was changed needs a `config dns_rule` for the new suffix), and it
only exists when the model has at least one `config resolver` — with none, buildDNS
emits no DNS plane at all and the engine falls back to its built-in `local`
transport, which reads `/etc/resolv.conf` (127.0.0.1 → dnsmasq), so local names
still resolve but nothing is filtered.
**A dead engine does NOT black out the LAN's DNS.** This was the first thing checked,
because "intercept everything" invites the reading "engine down = no DNS anywhere",
and that is not what happens:
- the fail-closed **holding plane** (D17, `RenderHoldNft`) hooks `forward` ONLY.
A query addressed to the router is INPUT-hook traffic, so dnsmasq answers it as
it always did — unfiltered and plaintext to the ISP. Deliberate: blocking it
would also cut the daemon's own name resolution and with it any chance of
self-recovery;
- with the FULL plane loaded and the engine's tproxy socket gone, the `tproxy`
statement returns `NFT_BREAK`, which aborts its own rule; the packet continues
down the chain into the same `fib daddr type local accept` and reaches dnsmasq.
So the failure mode is a DNS **fail-open** (working, unfiltered) while client
TRAFFIC stays fail-closed — and a query aimed at an EXTERNAL resolver is dropped
with the rest of the forwarded traffic. Operators must know this: "the tunnel is
down" does not mean "DNS is private".
**Existing installs.** `/etc/config/shater` is a conffile
(`openwrt/shater-core/Makefile`), so an upgrade never replaces it:
- a config that never mentioned the option (all of them, before this change) now
parses over the ON seed and **starts intercepting on the next apply**. That is the
intended behaviour change, and the only one this decision makes;
- an explicit `option dns_intercept '0'` keeps winning. It survives the
`WriteUCI→ReadUCI` round-trip because `render.go` emits booleans ALWAYS —
the trap a default-true bool has and a default-false one does not: a value
omitted at false would come back as the seed and silently re-enable itself.
`shater/model/dnsintercept_test.go` pins both directions, plus the shipped file.
**Not done: silencing the "no resolvers configured" warning by shipping a resolver.**
With interception on and no `config resolver`, generate warns — and it is right to:
every client query now lands in an engine that has no resolver plane, so it is
answered by the system resolver (dnsmasq → the ISP, in the clear) with filtering and
anti-leak inert. Shipping a `type local` resolver would make the warning disappear
while changing nothing about where the queries go: the panel would show a configured
resolver and the operator would believe DNS was handled. That is the inverted lie
this project keeps deleting. The warning stays; what it needs is the accurate
wording (it currently claims `.lan` breaks, which the fallback above disproves), not
a workaround. Note also that a fresh install ships INERT (`enabled '0'`) and
`Reconcile` tears down instead of generating, so the warning cannot appear before the
operator has enabled the stack — at which point it describes their live config.
**OPEN, and it gates shipping this default: the synthetic local server changes how
proxy-endpoint DOMAINS are resolved.** Found while landing D24, reproduced on Linux
with one resolver and a node addressed by a hostname:
- `common/dialer/dialer.go` resolves a domain server address through
`route.default_domain_resolver`; when that is unset it uses
`dnsTransport.Default()` — the engine's built-in `local` transport, i.e. a
bootstrap-DIRECT lookup — but **only while fewer than two DNS transports exist**.
With two or more and no default, it reports the `missing-domain-resolver`
deprecation and leaves the query transport nil, so `dns.Router.Lookup` falls back
to `lookupWithRules`: the CLIENT DNS plane.
- `dns_intercept` adds `shater-local-dns`, which takes a single-resolver config from
one transport to two. So a config whose only resolver is DoH-through-the-tunnel —
the recommended anti-leak setup — would start resolving its own node's hostname
through that same tunnel: a bootstrap loop where there was none.
- Evidence: the same model emits no deprecation notice with `dns_intercept=0` and
two `missing-domain-resolver` notices with `dns_intercept=1`;
`generate.TestDNSFilterRemoteBlocklistHTTPClient` (Linux-only) fails on exactly
that notice and is deliberately left failing rather than relaxed.
The fix belongs in `generate` (`route.go:160` already sets
`route.default_domain_resolver` from `endpointResolver()`, which is opt-in and unset
by default): when buildDNS emits the synthetic local server and no endpoint resolver
is configured, `default_domain_resolver` must be pointed at a bootstrap-direct
server, which restores exactly the pre-D24 behaviour and clears the notice. Until
that lands, an operator can get the same result by setting `endpoint_resolver` to a
direct resolver. Note the hazard is **not** created by D24 — any config with two
resolvers has it today; the default merely makes it universal.
+20 -6
View File
@@ -13,8 +13,12 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/ - **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
Host/QUIC sniffing. Host/QUIC sniffing.
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone), - **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port, destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
proto → target (outbound/selector/chain/direct/block) + egress. A rule names its **destination through a rule-set only** — a reusable named list
(inline domains/CIDRs, a local or remote file, or a geosite/geoip category) that is
compiled once into a `.srs` and shared by every rule that references it. Domain
entries take `full:` (exact), `suffix:` / a leading dot (host + subdomains),
`keyword:` (substring) and `regexp:`; a bare entry means host + subdomains.
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin). - **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any - **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
interface/tunnel (e.g. an AmneziaWG tunnel). interface/tunnel (e.g. an AmneziaWG tunnel).
@@ -36,6 +40,16 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
type=fakeip + pool — there is no global "FakeIP mode"); no DNS leaks. Routing type=fakeip + pool — there is no global "FakeIP mode"); no DNS leaks. Routing
is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population
mechanism does not exist in v0.2 (see generate/dns.go). mechanism does not exist in v0.2 (see generate/dns.go).
The hijack covers the queries a client sends **to the router itself** — the
address DHCP hands out — because `globals.dns_intercept` is **ON by default**
(D24). With it off, those queries go to dnsmasq and out to the ISP in the clear,
so the well-behaved client leaks while the one that hard-codes 8.8.8.8 does not.
`.lan` and the private PTR zones are preserved through dnsmasq either way. Two
things the promise does NOT cover, both by design: while the engine is DOWN the
holding plane hooks `forward` only, so dnsmasq still answers router-addressed
:53 unfiltered (client traffic and DNS to external resolvers stay blocked); and
with no `config resolver` at all there is no DNS plane to filter with — queries
fall through to the system resolver and generate says so.
- **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter). - **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter).
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) / - **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
`file` / `url` (auto-update) / `geosite` category (only when geodata present). `file` / `url` (auto-update) / `geosite` category (only when geodata present).
@@ -89,8 +103,8 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
SIM uplink → different egress); backup/restore; i18n (EN + RU). SIM uplink → different egress); backup/restore; i18n (EN + RU).
## Ops & distribution ## Ops & distribution
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key - **[MVP]** Single signed binary; signed apk feed on Gitea (EC key
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`. `dist/shater-apk.pem`); one-line install; named-package `apk upgrade`.
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite. - **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
- **[T2]** apk (OpenWrt 25.x) packaging; multi-router fleet management; REST/gRPC - **[T2]** Multi-router fleet management; REST/gRPC external API; Telegram bot.
external API; Telegram bot. (apk packaging landed and is now the only lane — D22.)
+173 -84
View File
@@ -1,7 +1,7 @@
# Shater v0.2 — Build & Install # Shater v0.2 — Build & Install
How to build the ship artifact (the SPA-embedded `shaterd` binary) and install How to build the ship artifact (the SPA-embedded `shaterd` binary) and install
the OpenWrt feed onto a router. the signed apk repo onto a router.
## 1. Build the `shaterd` binary ## 1. Build the `shaterd` binary
@@ -26,7 +26,9 @@ What it does:
Arg / env: Arg / env:
- `VERSION` — stamped into `constant.Version`. Resolution: positional arg → - `VERSION` — stamped into `constant.Version`. Resolution: positional arg →
`$SHATER_VERSION` → `git describe --tags` → `v0.2.0-dev`. `$SHATER_VERSION` → `ci/version.sh --binary` → `v0.2.0-dev`. `ci/version.sh` is
the **same** computation the package version comes from (§2.1), so the string
the panel shows always matches what `apk list -I shaterd` reports.
- `--fast` — skip `npm ci` when `panel/node_modules` already exists. - `--fast` — skip `npm ci` when `panel/node_modules` already exists.
- `UPX=/path/to/upx` — override the UPX binary (default `upx` on `PATH`). UPX is - `UPX=/path/to/upx` — override the UPX binary (default `upx` on `PATH`). UPX is
cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also
@@ -41,22 +43,44 @@ UPX="…/scratchpad/upx-4.2.4-win64/upx.exe" scripts/build-shaterd.sh v0.2.0 --f
The `dist/*` and `openwrt/shaterd/files/shaterd-*.upx` outputs are gitignored — The `dist/*` and `openwrt/shaterd/files/shaterd-*.upx` outputs are gitignored —
they are release artifacts, not source. they are release artifacts, not source.
Tag set (D9 — keep in sync with `docs-shater/DECISIONS.md`): Tag set (D9/D23) — defined in **one** place, `scripts/router-tags.sh`, which
documents every tag and is sourced by the build:
``` ```
with_quic,with_wireguard,with_utls, with_gvisor,with_quic,with_wireguard,with_utls,
badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
``` ```
We drop `with_purego,with_naive_outbound`: they pull cronet-go, which forces a We drop `with_purego,with_naive_outbound`: they pull cronet-go, which forces a
glibc `PT_INTERP` even under `CGO_ENABLED=0`, making the binary unusable on musl. glibc `PT_INTERP` even under `CGO_ENABLED=0`, making the binary unusable on musl.
We drop `with_gvisor`: the shater data plane is tproxy/redirect and generate
never emits a tun inbound, so the userspace gvisor netstack is unreachable code.
We drop `with_clash_api`: the admin panel is shater's own web server and the We drop `with_clash_api`: the admin panel is shater's own web server and the
generator never emits a `clash_api` service, so the Clash server is dead code. generator never emits a `clash_api` service, so the Clash server is dead code.
We drop `with_dhcp`: shater resolver types are `udp/tcp/doh/dot/local/fakeip`; We drop `with_dhcp`: shater resolver types are `udp/tcp/doh/dot/local/fakeip`;
a `dhcp://` DNS transport is never generated or registered. a `dhcp://` DNS transport is never generated or registered.
`with_gvisor` was dropped in 2026-07 as "unreachable — we emit no tun inbound"
and **put back on 2026-07-25**: gVisor is also the netstack of the WireGuard
endpoint, so without it every `wg://`/`awg://` node died at apply time with
*"gVisor is not included in this build"* while the panel still offered the
feature. It costs ~2.8 MB raw / ~0.65 MB UPX per arch. Full story: `DECISIONS.md`
D23.
### Changing the tag set
Run the guard — it is what stands between a size trim and a silently dead
feature, and CI runs it before the artifact is built:
```sh
scripts/check-router-tags.sh # from Windows/macOS it re-execs itself in golang:1.26
```
It (1) fails if a feature declared in `FEATURES.md` lost a build tag it needs to
run (`shater/buildtags`, no tags/OS/network required) and (2) constructs one node
of every declared protocol through `box.New` **compiled with the shipped tag
set** — nothing may be skipped in that run. Adding a protocol to
`shater/parse`+`shater/generate` means adding a row to `buildtags.Features` and a
probe case in `shater/generate/shipped_tags_linux_test.go`.
## 2. Packages ## 2. Packages
Four OpenWrt packages live under `openwrt/`: Four OpenWrt packages live under `openwrt/`:
@@ -84,22 +108,65 @@ it. Because the binary is UPX-packed, the package disables the SDK's default str
feed installed and run `make package/shaterd/compile` (and the others) per target. feed installed and run `make package/shaterd/compile` (and the others) per target.
See `openwrt-package-build-ci` for SDK/feed mechanics. See `openwrt-package-build-ci` for SDK/feed mechanics.
### 2.1 Package versions come from the git tag
`PKG_VERSION`/`PKG_RELEASE` are **not** maintained by hand. They used to be, and
nobody bumped them: **v0.2.2 … v0.2.6 all shipped as `shaterd 0.2.0-r3`** with
different binaries inside (v0.2.6's ELF is 5 491 616 B against r2's 5 488 336 B).
apk offers an upgrade only when the feed's version string differs from the
installed one, so `apk update` saw nothing new and the routers could not be
updated through the normal path at all.
`ci/version.sh` now derives them from `git describe`, once per CI job:
| Build | `PKG_VERSION` | `PKG_RELEASE` | `constant.Version` |
|---|---|---|---|
| tag push `v0.2.7` | `0.2.7` | `1` | `v0.2.7-r1` |
| dispatch, 3 commits past `v0.2.7` | `0.2.7` | `4` | `v0.2.7-r4-g<sha>` |
| no reachable tag / no git | `0.0.0` | `1` | `v0.0.0-r1` |
Ordering is what makes this safe (checked with `apk version -t` on apk-tools
3.0.3): the dotted part decides first, `-rN` only breaks ties — so
`0.2.7-r1 > 0.2.6-r12 > 0.2.6-r1 > 0.2.0-r3`. A release therefore always
outranks every rolling build before it, rolling builds between two releases grow
monotonically, and an untagged build (`0.0.0`) can never masquerade as an
upgrade.
The value travels as `SHATER_PKG_VERSION`/`SHATER_PKG_RELEASE` in the SDK build
environment; the Makefiles read it with a literal fallback for manual/offline
builds. `ci/sdk-build-apk.sh` then **asserts** the produced `.apk` really carries
it, so a lost variable fails the build instead of shipping a stale version. The
release job asserts the same version again on the published rolling repo (§5.1).
`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.
## 3. Install on a router ## 3. Install on a router
Install order follows the deps (`shaterd` → `shater-core` → `luci-app-shater`): **The normal path is the signed apk repo — §5.** This section is the manual
fallback (a router with no route to the Gitea host, or a hand-carried build).
Install order follows the deps (`shaterd` → `shater-core` → `luci-app-shater`).
apk filenames carry no architecture, so make sure you copied the `.apk` built for
*this* router's arch (`cat /etc/apk/arch`):
```sh ```sh
opkg install shaterd_0.2.0-1_<arch>.ipk # or: apk add shaterd (25.12+) # <ver> = the release version, e.g. 0.2.7-r1 (§2.1 — it comes from the git tag)
opkg install shater-core_0.2.0-1_all.ipk # --allow-untrusted: our member .apk are unsigned by design — trust lives in the
opkg install luci-app-shater_0.2.0-1_all.ipk # signed packages.adb index (§5), which a loose file install does not consult.
opkg install byedpi_0.17.3-1_<arch>.ipk # optional: ByeDPI egress 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
``` ```
Installing from a signed feed instead: From the repo instead (§5 sets it up once), deps pull the rest in:
```sh ```sh
# add the feed (customfeeds.conf / apk repositories), then: apk update && apk add luci-app-shater # -> shater-core -> shaterd
opkg update && opkg install shater-core luci-app-shater # shaterd pulled in as a dep
``` ```
## 4. Enable ## 4. Enable
@@ -119,83 +186,87 @@ daemon (`shaterd run`), which owns the engine, the `inet shater` data plane, pol
routing, in-process DNS, and the admin panel (default `:8088`). The LuCI app's routing, in-process DNS, and the admin panel (default `:8088`). The LuCI app's
"Open panel" button mints a single-use token and hands the browser off to the panel. "Open panel" button mints a single-use token and hands the browser off to the panel.
## 5. Add the signed feed (recommended — then `opkg upgrade` just works) ### What enabling does to DNS
CI (`.gitea/workflows/release.yml`) publishes every build as a **rolling `latest` From the first apply, **every** LAN plaintext `:53` goes into the engine — including
Gitea release** that is itself a signed opkg `src/gz` feed: the release holds the the queries a client sends to the router's own address, which is what DHCP hands out.
`.ipk` for all arches, a `Packages`/`Packages.gz` index, a usign `Packages.sig`, That is `globals.dns_intercept`, and it is **on by default** (D24); without it those
and the public key `shater-feed.pub`. opkg filters by `Architecture`, so the **same queries reach dnsmasq and the ISP unfiltered, i.e. the client with default settings
two lines work on every device** (x86 testbed picks `x86_64 + all`; the BPI routers leaks while the one that hard-coded `8.8.8.8` does not. What follows from it:
pick `aarch64_cortex-a53 + all`).
> **Format:** OpenWrt 24.10 (our SDK) uses **opkg** (`.ipk`, `Packages.gz`, usign), - `.lan` and private reverse (PTR) lookups still go to dnsmasq — the engine gets a
> so the feed is `src/gz` and the trust anchor is the usign key rule for those suffixes. If you renamed dnsmasq's domain away from `lan`, add a
> `dist/shater-feed.pub` (fingerprint **`5ac4b177689cb8e0`**). apk only replaces `config dns_rule` for the new suffix.
> opkg at OpenWrt **25.12** — see §6. - Configure at least one `config resolver`. With none, the engine has no resolver
plane: intercepted queries fall through to the system resolver (dnsmasq → your
ISP, in the clear), blocklists and per-device DNS rules are inert, and the apply
says so in its warnings.
- While the engine is DOWN, DNS is **not** blacked out: the fail-closed holding
plane hooks `forward` only, so dnsmasq keeps answering router-addressed `:53`
(unfiltered, plaintext) while client traffic and DNS to external resolvers stay
blocked. "The tunnel is down" is not "DNS is private".
One-time setup on the router: To opt out, on the router:
```sh ```sh
# 1) trust the feed key — the FILENAME must equal the usign key fingerprint. uci set shater.globals.dns_intercept=0
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \ uci commit shater
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub shaterd apply
# 2) add the feed (one URL serves every arch).
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
>> /etc/opkg/customfeeds.conf
# 3) refresh + install (shaterd is pulled in as a dependency).
opkg update
opkg install luci-app-shater # -> shater-core -> shaterd
opkg install byedpi # optional: ByeDPI desync egress
``` ```
With the key installed, opkg's default `check_signature 1` verifies the feed on Your `0` is kept: `/etc/config/shater` is a conffile (upgrades never replace it) and
every `opkg update`; no `--nocheck-signature` needed. A **tagged** release the daemon always writes the option back explicitly, so it is never re-enabled by a
(`vX.Y.Z`) publishes the identical layout at default.
`.../releases/download/vX.Y.Z` if you prefer to pin a version instead of tracking
`latest`.
### Updating ## 5. The signed apk repo (the normal install path)
```sh OpenWrt/ImmortalWrt **25.12** packages with Alpine's **apk**: `.apk` files, a
opkg update binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
opkg upgrade shaterd shater-core luci-app-shater byedpi # only our own packages effectively mandatory signatures (unsigned needs `--allow-untrusted`). This is
``` the only format shater publishes — the `.ipk`/opkg lane was removed in 2026-07
(`DECISIONS.md` D22); every device we serve is on 25.12 with apk-tools 3.
Updates are only offered when the feed's `Version` differs from the installed one, CI (`v*` tag push or `workflow_dispatch`) compiles the 4 packages through the
so **bump `PKG_RELEASE`** (or `PKG_VERSION`) in the package Makefile on every official **ImmortalWrt 25.12 SDK** (tarballs from
shipped change — otherwise `opkg upgrade` sees the same version and does nothing.
Do **not** `opkg upgrade` base/system packages from this feed; upgrade only the
four shater packages above.
## 6. apk feed (OpenWrt/ImmortalWrt 25.12+ — incl. BananaWRT 25.12-mtk-vendor)
OpenWrt/ImmortalWrt **25.12** replaces opkg with Alpine's **apk**: `.apk` files,
a binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
effectively mandatory signatures (unsigned needs `--allow-untrusted`). The
package **Makefiles are unchanged** — the SDK release decides the format.
CI builds this lane **in parallel** with the opkg feed (same manual triggers:
`v*` tag push or `workflow_dispatch`): the `build-apk` jobs in
`.gitea/workflows/release.yml` compile the same 4 packages through the official
**ImmortalWrt 25.12 SDK** (tarballs from
`downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`) `downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`)
and publish **one release per arch** — rolling `apk-latest-x86_64` / and publishes **one release per arch**: the rolling `apk-latest-x86_64` /
`apk-latest-aarch64_cortex-a53`, or `apk-vX.Y.Z-<arch>` for a tagged version. `apk-latest-aarch64_cortex-a53`, plus `apk-vX.Y.Z-<arch>` on a tag. Per-arch
Per-arch (unlike the combined opkg release) because apk filenames carry no because apk filenames carry no architecture and packages are fetched *relative to
architecture and packages are fetched relative to the `packages.adb` URL. the `packages.adb` URL*, so one flat multi-arch release would collide.
> **Key:** apk cannot use the usign key. The apk trust anchor is the separate EC > **Key:** the trust anchor is the EC public key **`dist/shater-apk.pem`**
> public key **`dist/shater-apk.pem`** (generated once by `ci/gen-apk-key.sh`; > (generated once by `ci/gen-apk-key.sh`; the private half lives ONLY in the
> private half lives ONLY in the Gitea secret **`KEY_APK`**, the apk analog of > Gitea secret **`KEY_APK`**). Never regenerate it — that invalidates every
> `KEY_BUILD`). Never regenerate either key — that invalidates every deployed > deployed router's trust.
> router's trust. The usign identity `shater-feed.pub` keeps signing the
> opkg/24.10 feed, untouched.
One-time setup on a 25.12 router (BananaWRT `25.12-mtk-vendor` on the BPI-R3 ### 5.1 Rolling or pinned — pick the repo URL deliberately
mini, BPI-R4 on 25.12, or the future 25.12 VM — `/etc/apk/arch` picks the right
per-arch release automatically): The repo line names an **index file**, and which one you name is the whole
update policy:
| Repo line points at | Behaviour | Cost |
|---|---|---|
| `apk-latest-<arch>/packages.adb` (**rolling**) | Every release run REPLACES this release's assets, so `apk update && apk upgrade <our packages>` always sees the newest build. Install once, never touch the file again. | You get whatever CI published last; there is no per-router pin. |
| `apk-vX.Y.Z-<arch>/packages.adb` (**pinned**) | The router stays on exactly that build. `apk update` will never offer a newer shater. | `/etc/apk/repositories.d/shater.list` must be edited **by hand on every upgrade**, on every router. |
`mini_router` is deliberately on a **pinned** URL — a considered choice, and the
hand-edit per release is its price. Use rolling unless you specifically want to
freeze a device.
> The rolling release used to go stale silently: publishing was an either/or, so
> tag runs wrote only `apk-vX.Y.Z-<arch>` and `apk-latest-<arch>` was last
> refreshed on 2026-07-24 at `0.2.0` while v0.2.9/v0.2.10 shipped. A router on
> the rolling URL kept getting a successful `apk update` with nothing new. Fixed
> 2026-07-25: `release-apk` writes the rolling pointer on **every** run and then
> reads the release back over the Gitea API, asserting it holds our three
> tag-versioned packages at exactly the version just built and **no** leftover
> asset at another version (two versions of one package in one index would let
> apk choose instead of us).
### 5.2 One-time setup on the router
BananaWRT `25.12-mtk-vendor` on the BPI-R3 mini, OpenWrt 25.12 on the BPI-R4, or
the testbed VM — `/etc/apk/arch` picks the right per-arch release automatically:
```sh ```sh
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works). # 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
@@ -203,6 +274,7 @@ wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem" "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
# 2) add the repo — the line points at the packages.adb INDEX FILE itself. # 2) add the repo — the line points at the packages.adb INDEX FILE itself.
# (rolling; for a pinned router put apk-vX.Y.Z-$(cat /etc/apk/arch) here — §5.1)
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \ echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list > /etc/apk/repositories.d/shater.list
@@ -212,17 +284,34 @@ apk add luci-app-shater # -> shater-core -> shaterd
apk add byedpi # optional: ByeDPI desync egress apk add byedpi # optional: ByeDPI desync egress
``` ```
### Updating ### 5.3 Updating
**Never run a bare `apk upgrade`.** With no arguments apk reconciles *every*
installed package against *every* configured repository at once; on a router
whose distfeeds point at a moving snapshot that can pull in — or roll back —
unrelated system packages. Always name ours:
```sh ```sh
apk update apk update
apk upgrade shaterd shater-core luci-app-shater byedpi # only our own packages apk upgrade shaterd shater-core luci-app-shater byedpi
``` ```
Same rule as opkg: an upgrade is only offered when the feed version differs, so apk-tools 3 documents exactly this behaviour for `apk upgrade`: *"When no
bump `PKG_RELEASE`/`PKG_VERSION` on every shipped change (apk shows it as packages are specified, all packages are upgraded if possible. If list of
`0.2.0-r1`). Pin a version instead of tracking rolling by pointing the repo line packages is provided, only those packages are upgraded along with needed
at `.../download/apk-vX.Y.Z-$(cat /etc/apk/arch)/packages.adb`. 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
```
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
`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.
### BananaWRT `25.12-mtk-vendor` compatibility ### BananaWRT `25.12-mtk-vendor` compatibility
+41 -4
View File
@@ -195,7 +195,7 @@ type Chain struct { Name string; Hops []string } // "group:<n>" | "node:<n>", L1
type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
type Rule struct { type Rule struct {
Name string; Enabled bool; Order int Name string; Enabled bool; Order int
Src []string; DstDomain,DstRuleset,DstIP []string; DstPort,Proto string Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
Target string // chain:|group:|node:|direct|block Target string // chain:|group:|node:|direct|block
Egress,Kill string Egress,Kill string
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
@@ -251,14 +251,51 @@ 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). > v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
### uci.go — `/etc/config/shater` schema ### uci.go — `/etc/config/shater` schema
- `config globals`: enabled, loglevel, kill_switch, dns_mode, ipv6, fwmark_base, table_base, confirm_timeout, resolver_default, resolver_fallback, probe_url, probe_interval, schema_version, active_profile. - `config globals` — the full option set, with the value used when the option is
ABSENT (the `model.DefaultGlobals` seed). Booleans are always written back as
`'1'`/`'0'` by `render.go`, so an explicit value never decays into the seed:
| option | default | meaning |
|---|---|---|
| `enabled` | `0` as shipped | master switch; `0` ⇒ `Reconcile` tears the stack down instead of applying |
| `loglevel` (alias `log_level`) | `warning` | engine + daemon level; `none/off/silent/disabled` ⇒ log disabled, unknown ⇒ `warn` + a validation warning |
| `log_syslog` / `log_file` / `log_persist` | `1` / `1` / `0` | operational log (`shater/logsink`): syslog, rotated file, and whether that file lives on flash instead of tmpfs |
| `log_max_kb` | `2048` | size cap of the log file, clamped to 128…8192; `0` = "use the default", not "off" |
| `kill_switch` | `closed` | `closed` = fail-closed (block on engine loss, incl. a holding plane when the engine never started); `open` = plain routing |
| `ipv6` | `1` | `0` drops LAN IPv6 in the forward chain instead of leaving it unproxied |
| `fwmark_base` / `table_base` | `0x2000` | reserved fwmark / routing-table bases (must not collide with fw4 or other apps) |
| `confirm_timeout` | `0` | seconds before an unconfirmed apply auto-rolls back; `0` = commit-confirm off |
| `resolver_default` / `resolver_fallback` / `endpoint_resolver` | unset | `config resolver` names: the DNS catch-all, its failover chain, and the bootstrap-direct server that resolves proxy endpoint DOMAINS |
| `probe_url` / `probe_interval` | engine defaults | the ONE instrument all health probing uses (D20 — there are no per-group overrides) |
| `panel_port` | `0` ⇒ `8088` | admin-panel HTTP port |
| `dns_filter` | `0` | master enable of the blocklist/allowlist filter (D15); needs at least one `config resolver` |
| `dns_intercept` | **`1`** | force ALL LAN plaintext `:53` into the engine, INCLUDING queries addressed to the router itself. See D24 for why this is the default, what preserves `.lan`, and what happens while the engine is down |
| `block_doh` | `0` | NXDOMAIN the known public DoH hostnames + the Firefox canary and reject `:443` to their IPs, so clients fall back to `:53` (which the engine catches) |
| `group_health` | `1` | OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group |
| `untunnelable` | `block` | policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): `block` \| `icmp` (echo out, rest dropped) \| `direct` (all out, bypassing the tunnel) |
| `geo_provider` | unset = auto | `sagernet` \| `loyalsoldier` \| `metacubex` \| `custom`; auto = country codes from SagerNet, everything else from Loyalsoldier |
| `geosite_url` / `geoip_url` | unset | `{category}` templates, honoured only when `geo_provider=custom` |
| `geosite_index_url` / `geoip_index_url` | unset | git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions |
| `stats_backend` | `memory` | `off` (no aggregation at all) \| `memory` (RAM, lost on restart) \| `sqlite` (aggregates in RAM + query/connection log on disk) |
| `stats_ring_size` / `stats_timeline_minutes` / `stats_max_domains` | `200` / `60` / `5000` | live-log length, sparkline minutes, domain-map cap. **`0` = UNLIMITED** (grows with traffic), which is why these three are always emitted |
| `stats_disk_limit_mb` | `64` | on-disk cap of `stats.db`; only meaningful for `stats_backend=sqlite`; `0` = unlimited |
| `stats_retention_disabled` | `0` | master switch that turns OFF all trimming/pruning — every aggregate then grows unbounded |
| `schema_version` | `0` = pre-versioned | UCI schema revision; `shaterd migrate` writes `2` |
| `active_profile` | unset | display bookkeeping: the last profile switched to |
Deleted options still parse (unknown keys are ignored) and drain out on the next
render: `dns_mode` (D17 — fake-IP is a resolver TYPE), `sweep_interval` (D19).
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff. - `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff.
- `config subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days. - `config 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 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 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 chain`: name, list hop. `config egress`: name, type, interface, target.
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url), url, path, format, update_interval, list entry. - `config ruleset`: name, type(domain|ipcidr), source(inline|file|url|geosite|geoip), url, path, format, update_interval, list category, list entry.
- `config rule`: name, enabled, order, list src/dst_domain/dst_ruleset/dst_ip, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end/tz. - `config 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 preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress. - `config preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver. - `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
+18
View File
@@ -0,0 +1,18 @@
# Документация shater
Документация продукта **shater** (управляемый интернет-шлюз для роутеров на
OpenWrt). Лицо репозитория и быстрый старт — в корневом [`../README.md`](../README.md).
| Документ | О чём |
|----------|-------|
| [CONTEXT.md](CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, решения в кратце, testbed/инфра |
| [INSTALL.md](INSTALL.md) | Сборка ship-артефакта (`shaterd`) и установка apk-фида (25.12+): роллинг или фиксация версии |
| [ARCHITECTURE.md](ARCHITECTURE.md) | One-binary дизайн, auth-handoff LuCI→панель, data/DNS/apply-потоки (диаграммы) |
| [FEATURES.md](FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [ROADMAP.md](ROADMAP.md) | Фазовый план |
| [DECISIONS.md](DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия и т.д. |
| [DESIGN.md](DESIGN.md) | Визуальная система панели — направление «Faceplate», токены, компоненты |
| [PORTING.md](PORTING.md) | Порт проверенных кусков из v0.1 |
Документация движка-форка (sing-box-lx) — в его слое: [`../docs-lx/`](../docs-lx/)
и [`../SPECS/`](../SPECS/).
+1 -1
View File
@@ -104,7 +104,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
## Phase 8 — Ship it ✅ DONE ## Phase 8 — Ship it ✅ DONE
- Adapt CI to build/sign the single forked binary for both arches; publish the - Adapt CI to build/sign the single forked binary for both arches; publish the
signed opkg feed (reuse key `5ac4b177689cb8e0`); install/upgrade docs. signed feed (apk since D22, EC key `dist/shater-apk.pem`); install/upgrade docs.
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite). - Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
## Cross-cutting (every phase) ## Cross-cutting (every phase)
@@ -0,0 +1,170 @@
# Живое тестирование shater v0.2.6 на mini_router
**Дата:** 2026-07-25
**Устройство:** Bananapi BPi-R3 Mini · ImmortalWrt **25.12-linkup** · `aarch64_cortex-a53`
**Установка:** из подписанного apk-фида `apk-v0.2.6-aarch64_cortex-a53`
**Пакеты:** `shaterd 0.2.0-r3`, `shater-core 0.2.0-r3`, `luci-app-shater 0.2.0-r2`, `byedpi 0.17.3-r1`
**Сборка:** CI run 61, коммит `024e9308c` (вершина `main`)
Сценарий: полное удаление предыдущей установки → чистая установка из фида →
проверка дефолтного состояния → восстановление рабочего конфига с подписками
(315 узлов) → функциональная проверка.
**Итог: 79 проверок, 74 PASS, 5 находок** (детали и разбор — в
`shater-bugs-2026-07-25.md` на рабочем столе).
---
## 1. Релиз и фид
| # | Проверка | Результат |
|---|---|---|
| T1 | Публикация `apk-v0.2.6-<arch>` для обеих архитектур | PASS |
| T2 | Ассеты: 4 `.apk` + `packages.adb` + `shater-apk.pem` | PASS |
| T3 | `apk update` принимает индекс (проверка EC-подписи) | PASS |
| T4 | Пакеты видны в нужных версиях (r3/r3/r2) | PASS |
| T5 | Диагностика сборки: `kmod packages selected (=m): 0` (было 1078) | PASS |
| T6 | Собраны ровно наши 4 пакета | PASS |
| T7 | opkg-лейн v0.2.6 (24.10) тоже зелёный | PASS |
## 2. Установка
| # | Проверка | Результат |
|---|---|---|
| T8 | `apk add luci-app-shater byedpi` — 4 пакета | PASS |
| T9 | Зависимости `kmod-nft-tproxy`/`kmod-nft-socket` из базового фида | PASS |
| T10 | Целостность: `apk manifest` = sha256 файла на диске | PASS |
| T11 | Установлен именно бинарь v0.2.6 (5 491 616 Б vs 5 488 336 Б в r2) | PASS |
| T12 | init-скрипты `shater`, `shater-cron` | PASS |
| T13 | `sysctl.d/99-shater.conf`, `hotplug.d/iface/99-shater` | PASS |
| T14 | boot-линки `S99shater`, `K10shater`, `S96shater-cron` | PASS |
## 3. Дефолтное состояние (чистая установка)
| # | Проверка | Результат |
|---|---|---|
| T15 | Дефолтный конфиг создан uci-defaults (27 строк) | PASS |
| T16 | `enabled='0'` — плоскость не ставится без согласия | PASS |
| T17 | Заготовлен tproxy-inbound на LAN, пресеты выключены | PASS |
| T18 | Демон стартует, `plane=none`, `table=false` | PASS |
| T19 | Права конфига `-rw-------` (0600) | PASS |
## 4. Восстановление рабочего конфига
| # | Проверка | Результат |
|---|---|---|
| T20 | Восстановление из бэкапа (3213 UCI-строк) | PASS |
| T21 | Кэш подписок цел: 315 узлов в 4 файлах | PASS |
| T22 | `shaterd migrate` → `ok`, схема v1 | PASS |
| T23 | Старт с реальным конфигом: `active`, `engine_running`, `plane=full` | PASS |
## 5. Data plane
| # | Проверка | Результат |
|---|---|---|
| T24 | Таблица `inet shater` создана (9 цепочек/сетов) | PASS |
| T25 | 16 tproxy-правил | PASS |
| T26 | `ip rule from all fwmark 0x2000 lookup shater` | PASS |
| T27 | `accept_local=1` на `br-lan` | PASS |
| T28 | DNS-divert: `dport 53 → tproxy :12345` для LAN-интерфейсов | PASS |
| T29 | DoT заблокирован: `dport 853 reject` | PASS |
| T30 | `block_doh=1`, правила присутствуют | PASS |
| T31 | **Kill-switch fail-closed**: цепочка `forward` завершается `drop` для LAN (v4+v6) | PASS |
| T32 | fw4 и dnsmasq не тронуты (свои таблицы целы) | PASS |
## 6. Панель и API
| # | Проверка | Результат |
|---|---|---|
| T33 | SPA отдаётся на `:8088` | PASS |
| T34 | `shaterd mint-token` выдаёт одноразовый токен | PASS |
| T35 | `/api/status` без сессии → **401** | PASS |
| T36 | `/api/session` (POST, JSON) → 200 + cookie `HttpOnly; SameSite=Strict; Max-Age=28800` | PASS |
| T37 | `/api/status` по cookie отдаёт данные, совпадающие с CLI | PASS |
| T38 | `/api/config` — 340 записей узлов | PASS |
| T39 | `/api/groups/health` — 103 протестировано, 13 живых, выбран `FR-vless-8` | PASS |
| T40 | `/api/devices` — устройства с IPv4/IPv6/MAC | PASS |
| T41 | `/api/interfaces` — `ewan/eth1 10.0.0.125/24 zone=wan` | PASS |
| T42 | `/api/ruleset/status` — remote-ruleset обновлён сегодня | PASS |
| T43 | `/api/stats` — memory backend, счётчики и top-domains | PASS |
| T44 | `/api/stats/log` — query-log с доменом, qtype, rcode, сервером | PASS |
| T45 | `/api/log?range=100` — пусто (следствие `log_file='0'`, не дефект) | OK |
## 7. Жизненный цикл конфигурации
| # | Проверка | Результат |
|---|---|---|
| T46 | `shaterd apply` → `{"changed":false}`, `can_rollback=true` | PASS |
| T47 | `shaterd confirm` снимает авто-откат (`can_rollback=false`) | PASS |
| T48 | `shaterd rollback` после confirm корректно сообщает об отсутствии last-good | PASS |
| T49 | `shaterd reconcile` (SIGHUP) не роняет движок | PASS |
| T50 | `shaterd sub update all-qomar` — реально обновил 143 узла | PASS |
| T51 | `shaterd blocklist update` → reconcile signalled | PASS |
| T52 | `shaterd schedule due` → reconcile signalled | PASS |
## 8. Устойчивость
| # | Проверка | Результат |
|---|---|---|
| T53 | `kill -9` демона → procd поднимает новый PID | PASS |
| T54 | После respawn: `engine_running=true`, `plane=full` | PASS |
| T55 | `stop` снимает таблицу `inet shater` полностью | PASS |
| T56 | `stop` → пауза → `start`: плоскость восстанавливается | PASS |
| T57 | Сеть при остановленном shater не деградирует | PASS |
| T58 | Память: 253 МБ занято из 2 ГБ при работающем движке | PASS |
## 9. DNS
| # | Проверка | Результат |
|---|---|---|
| T59 | Резолв через `127.0.0.1` | PASS |
| T60 | LAN-клиенты резолвят через движок (query-log растёт) | PASS |
| T61 | `.lan`-домены остаются за dnsmasq | PASS |
| T62 | dnsmasq жив и слушает на всех адресах | PASS |
| T63 | **Резолв через LAN-адрес `10.67.0.1` после `restart`** | **FAIL — B3** |
| T64 | Тот же резолв после `stop` → пауза → `start` | PASS |
## 10. Конфигурация и логи
| # | Проверка | Результат |
|---|---|---|
| T65 | 5 правил маршрутизации, 2 профиля, активен `ethernet-uplink` | PASS |
| T66 | **Два правила `default`, оба catch-all — нижнее живое, верхнее мертво** | **FAIL — B1** |
| T67 | **`shaterd nodes` всегда возвращает `[]`** | **FAIL — B2** |
| T68 | Логи уходят в syslog (`log_syslog=1`, 22 записи) | PASS |
| T69 | **ANSI-escape коды в syslog** | **FAIL — B5** |
| T70 | `loglevel=warning` соблюдается | PASS |
| T71–T79 | Прочие проверки состояния (статус-поля, права, uptime, счётчики, целостность таблиц) | PASS |
---
## Находки
| ID | Суть | Важность |
|---|---|---|
| **B1** | Два catch-all правила `default`; одно из них не работает никогда. **Поправка к первоначальному диагнозу:** правило без условий задаёт `route.Final`, а не выпускается как match-all, поэтому выигрывает ПОСЛЕДНЕЕ (`order=100 → group:auto`) — трафик идёт через прокси, а мёртвая настройка это `order=20 → direct` | средняя |
| **B2** | `shaterd nodes` — заглушка, всегда `[]`, хотя usage обещает список узлов (в кэше 315, в `/api/config` 340) | средняя |
| **B3** | После `service shater restart` резолв к LAN-адресу роутера не работает и не восстанавливается; `stop`+пауза+`start` — работает (гонка) | средняя |
| **B4** | `PKG_RELEASE` не менялся с v0.2.1 → v0.2.2…v0.2.6 выходят как `r3` при разном содержимом; `apk upgrade` не увидит обновления | средняя |
| **B5** | ANSI-раскраска попадает в syslog | низкая |
Разбор с воспроизведением — в `shater-bugs-2026-07-25.md`.
## История CI по этому релизу
Путь до зелёной сборки apk-лейна занял четыре итерации, каждая вскрывала
следующий слой одной причины:
| Тег | Что чинили | Итог |
|---|---|---|
| v0.2.2 | — (первый прогон с фиксами аудита) | `Disk quota exceeded`, 3593 `apk mkpkg kmod-*` |
| v0.2.3 | `.config` строится с нуля, а не дописывается | 1078 kmod — SDK вообще не везёт `.config` |
| v0.2.4 | Выключены `ALL`/`ALL_KMODS`/`ALL_NONSHARED` | 1078 kmod — они выбираются не через `ALL_KMODS` |
| v0.2.5 | Второй проход: явное `is not set` для каждого kmod | 1078 kmod — kconfig игнорирует user-значение у беспромптовых символов |
| **v0.2.6** | Удаление сгенерированных блоков `config PACKAGE_*` (`default m`) из `Config-build.in` | **0 kmod, сборка зелёная** |
Корень: `target/sdk/Makefile` генерирует `Config-build.in` прогоном
`convert-config.pl` по конфигу бильдбота, где `ALL_KMODS=y` уже развернулся в
`CONFIG_PACKAGE_kmod-*=m` на каждый модуль. Фильтр `next if /^(# )?CONFIG_PACKAGE/`
в скрипте стоит в ветке `else`, куда строка со знаком `=` не попадает, поэтому
каждый kmod приезжает в SDK как безусловный `default m`.
+9 -2
View File
@@ -2,6 +2,9 @@ package libbox
import ( import (
"context" "context"
// lx:begin sec-consttime
"crypto/subtle"
// lx:end sec-consttime
"errors" "errors"
"net" "net"
"os" "os"
@@ -97,9 +100,11 @@ func unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerIn
if len(values) == 0 { if len(values) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authentication secret") return nil, status.Error(codes.Unauthenticated, "missing authentication secret")
} }
if values[0] != sCommandServerSecret { // lx:begin sec-consttime
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
return nil, status.Error(codes.Unauthenticated, "invalid authentication secret") return nil, status.Error(codes.Unauthenticated, "invalid authentication secret")
} }
// lx:end sec-consttime
return handler(ctx, req) return handler(ctx, req)
} }
@@ -115,9 +120,11 @@ func streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServe
if len(values) == 0 { if len(values) == 0 {
return status.Error(codes.Unauthenticated, "missing authentication secret") return status.Error(codes.Unauthenticated, "missing authentication secret")
} }
if values[0] != sCommandServerSecret { // lx:begin sec-consttime
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
return status.Error(codes.Unauthenticated, "invalid authentication secret") return status.Error(codes.Unauthenticated, "invalid authentication secret")
} }
// lx:end sec-consttime
return handler(srv, ss) return handler(srv, ss)
} }
+8 -2
View File
@@ -74,7 +74,11 @@ func (r *oomReporter) WriteReport(memoryUsage uint64) error {
draftInfo = nil draftInfo = nil
} }
reportsDir := filepath.Join(sWorkingPath, "oom_reports") reportsDir := filepath.Join(sWorkingPath, "oom_reports")
err = os.MkdirAll(reportsDir, 0o777) // lx:begin sec-perms
// OOM reports embed the config snapshot (server secrets, keys) and logs;
// keep the tree owner-only (0700 dirs / 0600 files) instead of 0777/0666.
err = os.MkdirAll(reportsDir, 0o700)
// lx:end sec-perms
if err != nil { if err != nil {
return err return err
} }
@@ -121,7 +125,9 @@ func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error {
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error { func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
now := time.Now().UTC() now := time.Now().UTC()
err := os.MkdirAll(destPath, 0o777) // lx:begin sec-perms
err := os.MkdirAll(destPath, 0o700)
// lx:end sec-perms
if err != nil { if err != nil {
return err return err
} }
+7 -2
View File
@@ -44,7 +44,10 @@ func baseReportMetadata() reportMetadata {
func writeReportFile(destPath string, name string, content []byte) { func writeReportFile(destPath string, name string, content []byte) {
filePath := filepath.Join(destPath, name) filePath := filepath.Join(destPath, name)
os.WriteFile(filePath, content, 0o666) // lx:begin sec-perms
// Report files may carry the config snapshot (secrets) — owner-only.
os.WriteFile(filePath, content, 0o600)
// lx:end sec-perms
chownReport(filePath) chownReport(filePath)
} }
@@ -69,7 +72,9 @@ func copyConfigSnapshot(destPath string) {
} }
func initReportDir(path string) { func initReportDir(path string) {
os.MkdirAll(path, 0o777) // lx:begin sec-perms
os.MkdirAll(path, 0o700)
// lx:end sec-perms
chownReport(path) chownReport(path)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

+11
View File
@@ -15,6 +15,17 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=byedpi 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_VERSION:=0.17.3
PKG_RELEASE:=1 PKG_RELEASE:=1
+7 -2
View File
@@ -24,8 +24,13 @@ LUCI_TITLE:=LuCI thin launcher for Shater (mini dashboard + panel handoff)
LUCI_DEPENDS:=+shater-core +rpcd LUCI_DEPENDS:=+shater-core +rpcd
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=0.2.0 # Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
PKG_RELEASE:=2 # SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
# full rationale — bug B4). The literals are the manual/offline fallback only.
# These MUST stay above the luci.mk include: luci.mk only defaults PKG_VERSION/
# PKG_RELEASE when they are still unset, and the i18n subpackages inherit them.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com> PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
+11 -2
View File
@@ -13,8 +13,13 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=shater-core PKG_NAME:=shater-core
PKG_VERSION:=0.2.0
PKG_RELEASE:=3 # Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
# SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
# full rationale — bug B4: v0.2.2…v0.2.6 all shipped as 0.2.0-r3). The literals
# are the manual/offline fallback only.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com> PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-2.0-or-later PKG_LICENSE:=GPL-2.0-or-later
@@ -75,6 +80,10 @@ define Package/shater-core/install
$(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/etc/init.d/shater $(1)/etc/init.d/shater $(INSTALL_BIN) ./files/etc/init.d/shater $(1)/etc/init.d/shater
$(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron $(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron
# START=21 one-shot that loads the persisted fail-closed plane before fw4's
# `lan -> wan ACCEPT` can be the only thing on the box (the main init is
# START=99, i.e. seconds of plaintext forwarding on every boot).
$(INSTALL_BIN) ./files/etc/init.d/shater-armor $(1)/etc/init.d/shater-armor
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface $(INSTALL_DIR) $(1)/etc/hotplug.d/iface
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater $(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
+55 -2
View File
@@ -23,6 +23,32 @@ config globals 'globals'
option kill_switch 'closed' option kill_switch 'closed'
# There is no dns_mode option: routing is decided by in-engine rule-sets and # There is no dns_mode option: routing is decided by in-engine rule-sets and
# fake-IP is a resolver type (`config resolver` with type=fakeip + pool). # fake-IP is a resolver type (`config resolver` with type=fakeip + pool).
#
# Force ALL LAN plaintext DNS (:53) into the engine, INCLUDING queries the
# client sends to the router itself (the address DHCP hands out). ON by
# default: with it off, a client using the router as its resolver is answered
# by dnsmasq and forwarded to the ISP in the clear — no blocklists, no
# per-device DNS rules, no resolver detour — while a client that hard-codes
# 8.8.8.8 IS intercepted. The obedient client leaked; the evader did not.
#
# Set to '0' to opt out (dnsmasq answers router-addressed :53 again). Your
# explicit value is never overwritten: this file is a conffile, and the daemon
# always writes the option back as '1'/'0'.
#
# .lan and the private reverse (PTR) zones keep working: with at least one
# `config resolver` present the engine gets a synthetic server pointed at
# dnsmasq on 127.0.0.1:53 plus a rule that sends those suffixes to it; with no
# resolver at all the engine falls back to the system resolver, which is
# dnsmasq too. If you changed dnsmasq's domain away from `lan`, add a
# `config dns_rule` for it (only `lan` + RFC6303 reverse zones are built in).
#
# While the engine is DOWN the LAN is NOT left without DNS: the fail-closed
# holding plane hooks `forward` only, so dnsmasq still answers router-addressed
# :53 — unfiltered and in the clear, the documented trade-off (blocking it
# would also cut the daemon's own name resolution and its chance to recover).
# Queries aimed at an EXTERNAL resolver are dropped with the rest of the LAN's
# forwarded traffic.
option dns_intercept '1'
option ipv6 '1' option ipv6 '1'
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps). # Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
option fwmark_base '0x2000' option fwmark_base '0x2000'
@@ -62,7 +88,29 @@ config inbound
# list node 'my-node' # list node 'my-node'
# #
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block. # A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
# Match on src / dst_domain / dst_ruleset / dst_ip / dst_port / proto. # Match on src / dst_ruleset / dst_port / proto. A rule with NO matcher at all is
# the default route for everything that reached it.
#
# WHERE the traffic is going is named ONLY by dst_ruleset — one or more
# `config ruleset` names; the rule matches when ANY of them matches. There is no
# inline domain or address list on a rule (`dst_domain`/`dst_ip` were removed in
# schema v2): a destination list is written once as a ruleset, compiled into a
# .srs and shared by every rule that references it. `shaterd migrate` converts
# older configs automatically, creating a `rule-<name>` ruleset per rule.
#config ruleset
# option name 'blocked-video'
# option type 'domain'
# option source 'inline'
# list entry 'youtube.com'
# list entry 'suffix:googlevideo.com'
#
#config rule
# option name 'video-via-main'
# option enabled '1'
# option order '50'
# list dst_ruleset 'blocked-video'
# option target 'group:main'
#
#config rule #config rule
# option name 'all-via-main' # option name 'all-via-main'
# option enabled '1' # option enabled '1'
@@ -83,11 +131,16 @@ config inbound
# option type 'direct' # option type 'direct'
# option dpi 'fragment' # option dpi 'fragment'
# #
#config ruleset
# option name 'youtube'
# option source 'geosite'
# list category 'youtube'
#
#config rule #config rule
# option name 'youtube-fragment' # option name 'youtube-fragment'
# option enabled '1' # option enabled '1'
# option order '50' # option order '50'
# list dst_domain 'geosite:youtube' # list dst_ruleset 'youtube'
# option target 'egress:frag' # option target 'egress:frag'
# #
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries # A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
+201 -7
View File
@@ -33,6 +33,24 @@
# be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron # be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
# reconcile ONLY while the flag is up, so an admin `stop` STICKS — no # reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
# background actor may resurrect interception behind a stopped daemon. # background actor may resurrect interception behind a stopped daemon.
# * BEING REPLACED IS NOT BEING SWITCHED OFF. `restart`, `reload` (which is
# stop+start, i.e. every LuCI Save & Apply) and every package upgrade all run
# through `stop`, and the daemon's SIGTERM teardown removes the fail-closed
# table unconditionally — it does not consult kill_switch at all. Between that
# teardown and the successor's first apply the init GUARANTEES a gap: it waits
# for the old process to exit (shater_wait_stopped), then runs `shaterd
# migrate`, then starts a daemon that still has to build an engine. So a
# restart is announced with RESTART_FLAG, which tells the outgoing daemon to
# leave the fail-closed holding plane behind instead of bare routing. A real
# `stop` raises no flag and therefore still means what it says.
# * The FAIL-CLOSED PLANE MUST ALSO EXIST BEFORE THIS SCRIPT DOES. START=99 is
# after fw4 (19) and netifd (20), so at every boot the LAN forwards to the WAN
# in the clear for as long as it takes procd to decompress the daemon off
# flash and get an engine up. /etc/init.d/shater-armor (START=21) loads
# BOOT_ARMOR — a copy of the holding plane the daemon persists on every apply
# — to close that window. This script owns the DISARM half: a deliberate
# `stop`, or a missing daemon binary, removes the armor so it cannot outlive
# the product it protects.
# * The engine must never be permanently abandoned while interception stands: # * The engine must never be permanently abandoned while interception stands:
# respawn retries are infinite (procd never gives up); a sustained-dead # respawn retries are infinite (procd never gives up); a sustained-dead
# daemon is additionally escalated by the shater-cron watchdog. # daemon is additionally escalated by the shater-cron watchdog.
@@ -47,6 +65,38 @@ PROG=/usr/bin/shaterd
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so # hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
# nothing reconciles before this init has run at boot. # nothing reconciles before this init has run at boot.
ACTIVE_FLAG=/var/run/shater.active ACTIVE_FLAG=/var/run/shater.active
# Written by `shaterd run`; the single-owner token this init waits on so a
# restart never overlaps a new data plane with the previous one's teardown.
PIDFILE=/var/run/shaterd.pid
# Raised around a restart/reload, read by the OUTGOING `shaterd run` at SIGTERM:
# present => "you are being replaced, leave the fail-closed plane standing";
# absent => "you are being switched off, take everything down". tmpfs, so a
# power cut can never make the next boot look like a restart.
RESTART_FLAG=/var/run/shater.restarting
# The persisted fail-closed holding plane. Written by the daemon on every apply,
# loaded by /etc/init.d/shater-armor at boot. Its PRESENCE is the arm token, so
# removing it here is how a deliberate stop stops the next boot from blocking.
BOOT_ARMOR=/etc/shater/boot.nft
# Seconds `start` will wait for a predecessor to finish its teardown. Must be
# >= term_timeout below (procd's hard cap on a predecessor's life after SIGTERM)
# so we never give up while procd is still letting it shut down cleanly.
STOP_WAIT_SECS=40
# WHICH ACTION rc.common was invoked with, frozen at source time.
#
# rc.common sets `action=${2:-help}` before it sources this file, and every action
# then runs as a function in THAT SAME shell — so `stop_service` can see whether it
# was reached by `stop` or as the first half of `restart`/`reload`. That is the one
# distinction procd itself does not expose (`restart` is literally `stop; start`,
# and stop_service is called identically by both).
#
# Frozen into our own variable because `action` is a short, generic name that other
# framework helpers also use as a local; a snapshot taken before any function runs
# cannot be shadowed later. An EMPTY or unexpected value degrades to "real stop",
# which is the pre-existing behaviour and the safe direction to be wrong in: it
# costs a plaintext window on restart, where the other default would leave a
# deliberately stopped router blocked.
SHATER_RC_ACTION="$action"
# --- helpers --------------------------------------------------------------- # --- helpers ---------------------------------------------------------------
@@ -66,6 +116,69 @@ _slog() {
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@" [ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
} }
# Announce/withdraw "this daemon is being replaced, not switched off". Read by
# `shaterd run` when it receives SIGTERM.
shater_mark_restart() {
mkdir -p "$(dirname "$RESTART_FLAG")" 2>/dev/null
: > "$RESTART_FLAG"
}
shater_clear_restart() { rm -f "$RESTART_FLAG"; }
# Remove the persisted boot armor, so the LAN is NOT blocked at the next boot
# before the daemon starts. Called when the operator stops the service and when
# the daemon binary is gone — in both cases nothing is going to come along and
# replace the armor with a real data plane, and a kill switch with nothing behind
# it is just a brick.
shater_disarm_boot() { rm -f "$BOOT_ARMOR"; }
# Echo the pid of a LIVE `shaterd run`, or fail. The pidfile is written by the
# daemon itself and removed only by the daemon that owns it, AFTER its teardown
# has completed — so "pidfile names a live process" is precisely "the previous
# data plane has not been dismantled yet".
shater_daemon_pid() {
local pid
pid=$(cat "$PIDFILE" 2>/dev/null) || return 1
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null || return 1
echo "$pid"
}
# Block until no predecessor daemon is left, bounded by STOP_WAIT_SECS.
#
# WHY THIS EXISTS. procd's `stop` is ASYNCHRONOUS: rc.common's `restart` is
# literally `stop; start`, and the `service delete` ubus call returns the moment
# procd has SENT SIGTERM — not when the instance is gone. `start` therefore
# re-adds the instance while the outgoing `shaterd run` is still executing its
# honest teardown (engine close, then `nft delete table`, `ip rule`/`ip route`
# removal and the per-iface sysctl restore). The result is that `restart` is NOT
# equivalent to `stop` + pause + `start`: the new plane is stood up on top of
# kernel state the old one has not finished removing, which is what B3 (DNS to
# the router's own LAN address dead after a restart, and never recovering) came
# out of. Waiting here restores the equivalence, and costs literally nothing when
# there is no predecessor — the check runs before the first sleep.
#
# Returning non-zero does NOT abort the start: the daemon carries its own
# single-owner guard and will refuse (or wait) on its side. Better to hand the
# decision to the process that can actually see the plane than to leave the box
# with no service at all.
shater_wait_stopped() {
local i=0 pid
pid=$(shater_daemon_pid) || return 0
_slog -p daemon.info \
"restart: waiting for the previous shaterd (pid $pid) to finish tearing the data plane down"
while [ "$i" -lt "$STOP_WAIT_SECS" ]; do
sleep 1
i=$((i + 1))
shater_daemon_pid >/dev/null || {
_slog -p daemon.info "restart: previous shaterd exited after ${i}s; starting a fresh one"
return 0
}
done
_slog -p daemon.warn \
"restart: previous shaterd (pid $pid) still alive after ${STOP_WAIT_SECS}s — starting anyway"
return 1
}
# --- procd lifecycle ------------------------------------------------------- # --- procd lifecycle -------------------------------------------------------
start_service() { start_service() {
@@ -81,15 +194,53 @@ start_service() {
# Guard: never claim to run without the daemon binary. A half-removed/failed # Guard: never claim to run without the daemon binary. A half-removed/failed
# shaterd upgrade must degrade to "plugin off", not to a box that thinks # shaterd upgrade must degrade to "plugin off", not to a box that thinks
# interception is live with nothing behind it. # interception is live with nothing behind it.
#
# "Plugin off" now has to include DISARMING. With the boot armor in play, a
# missing binary is the one case where the fail-closed plane could stand
# forever with nothing able to replace it: the armor loads at START=21, the
# daemon never starts, and every later boot repeats it. The product being gone
# is not a security event — it is an uninstall — so the plane comes down and
# the LAN returns to plain routing, loudly.
if [ ! -x "$PROG" ]; then if [ ! -x "$PROG" ]; then
shater_clear_restart
shater_disarm_boot
rm -f "$ACTIVE_FLAG"
nft delete table inet shater 2>/dev/null
_slog -p daemon.err \ _slog -p daemon.err \
"shaterd binary missing/not executable at $PROG — refusing to start (LAN stays on plain routing)" "shaterd binary missing/not executable at $PROG — refusing to start; the fail-closed plane and its boot armor have been REMOVED (LAN back to plain routing, unprotected). Reinstall shaterd."
return 0 return 0
fi fi
# Do not stand a new data plane up on top of one that is still being taken
# down. On `restart` procd has only just SIGTERMed the previous instance and
# returned; this is the handshake that makes `restart` == `stop` + pause +
# `start`. It also keeps `migrate` below from rewriting UCI underneath a
# daemon that is still reading it. No-op (and no delay) when nothing is
# running, which is the boot case.
shater_wait_stopped
# The predecessor is gone and has already consumed the flag (it reads it in its
# SIGTERM handler). Withdraw it now, so a LATER `stop` is unambiguous even if
# this start fails further down.
shater_clear_restart
# Bring the UCI schema forward before the daemon reads it (idempotent; # Bring the UCI schema forward before the daemon reads it (idempotent;
# refuses a newer schema) so an upgraded package never applies a stale config. # refuses a newer schema) so an upgraded package never applies a stale config.
"$PROG" migrate >/dev/null 2>&1 #
# 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."
procd_open_instance shater procd_open_instance shater
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is # shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
@@ -111,7 +262,16 @@ start_service() {
procd_set_param stderr 1 procd_set_param stderr 1
# Give the daemon room to run its honest teardown (engine.Close + netplane # Give the daemon room to run its honest teardown (engine.Close + netplane
# restore) before procd SIGKILLs it. # restore) before procd SIGKILLs it.
procd_set_param term_timeout 10 #
# 30s, not 10s: an engine holding a few hundred outbounds closes its
# urltest/observatory goroutines and flushes experimental.cache_file to FLASH
# before the netplane teardown even starts, and on eMMC/NAND that alone can
# outlast 10s. A SIGKILL there aborts the teardown at an arbitrary point and
# leaves the plane HALF removed — the nft table gone but the policy routing
# still installed, or vice versa — which is precisely the class of leftover
# state the successor's idempotent fast-path cannot see and never repairs.
# Shutdown is bounded by procd either way; we are only choosing where.
procd_set_param term_timeout 30
procd_close_instance procd_close_instance
# Mark the stack live for hotplug/cron — but ONLY when interception is # Mark the stack live for hotplug/cron — but ONLY when interception is
@@ -128,6 +288,33 @@ start_service() {
} }
stop_service() { stop_service() {
# Say WHY we are stopping before procd sends the signal, because the daemon
# cannot tell from the signal alone and the answer changes what it leaves in
# the kernel:
#
# restart / reload -> a successor is coming. Raise RESTART_FLAG so the
# outgoing daemon replaces its data plane with the
# fail-closed HOLDING plane instead of removing it. The
# gap until the successor applies is not a moment: this
# script waits out the old process, runs `shaterd
# migrate`, then starts a daemon that must build an
# engine — all of it, until now, with `lan -> wan
# ACCEPT` and nothing else.
# anything else -> a deliberate `stop`. Everything comes down, and the
# boot armor goes with it so the next boot does not
# quietly reinstate what the operator just switched off.
# An admin `stop` has to STICK; that is the same rule
# ACTIVE_FLAG has always enforced for hotplug/cron.
case "$SHATER_RC_ACTION" in
restart|reload)
shater_mark_restart
;;
*)
shater_clear_restart
shater_disarm_boot
;;
esac
# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild # Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
# what we are about to tear down. procd then sends SIGTERM to `shaterd run`, # what we are about to tear down. procd then sends SIGTERM to `shaterd run`,
# which runs its OWN honest teardown (engine.Close + netplane restore) — we # which runs its OWN honest teardown (engine.Close + netplane restore) — we
@@ -141,10 +328,17 @@ stop_service() {
reload_service() { reload_service() {
# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply / # Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
# reload_config). Simplest correct behaviour: stop + start. `stop` clears the # reload_config). Simplest correct behaviour: stop + start. `stop` clears the
# flag and SIGTERMs the daemon (honest teardown); `start` re-guards on # flag and SIGTERMs the daemon (honest teardown); `start` WAITS for that
# enabled and, if still enabled, launches a fresh `shaterd run` that reads # teardown to actually finish (shater_wait_stopped) and then launches a fresh
# the new UCI and applies it. When the stack is disabled, `start` is a no-op, # `shaterd run` that reads the new UCI and applies it. When the stack is
# so a disable+apply cleanly tears everything down. # disabled, `start` is a no-op, so a disable+apply cleanly tears everything
# down. Because the wait lives in start_service, this path gets the same
# stop-then-start ordering guarantee as `restart`.
#
# Marked EXPLICITLY as well as via SHATER_RC_ACTION: this is the path a routine
# Save & Apply takes, so it is the one that must not depend on reading an
# rc.common variable correctly. Belt and braces, one line.
shater_mark_restart
stop stop
start start
} }
@@ -0,0 +1,144 @@
#!/bin/sh /etc/rc.common
# /etc/init.d/shater-armor — the fail-closed plane, before the daemon exists.
#
# WHAT THIS CLOSES
#
# /etc/init.d/shater is START=99. By then fw4 (START=19) has long since loaded
# `lan -> wan ACCEPT` and netifd (START=20) has brought the LAN bridge up, so the
# router forwards LAN traffic to the WAN in the clear from the moment the link
# comes up until `shaterd run` has been decompressed off flash, has waited out any
# predecessor, has migrated UCI, has read the config and has installed its first
# table. On router-class hardware with a UPX-packed binary that is seconds — and
# they are exactly the seconds in which Wi-Fi finishes associating and every
# client on the network reconnects and starts talking. `kill_switch=closed` was
# configured the whole time and covered none of it.
#
# There was nothing in the package that could cover it either: no /etc/nftables.d
# include, no `nft -f` in uci-defaults. Protection existed only inside a Go
# process that had not started yet.
#
# HOW
#
# The daemon persists a copy of its fail-closed HOLDING plane (the same ruleset it
# installs when the engine is down: one forward chain, LAN-to-LAN and router
# traffic accepted, everything else from the diverted devices dropped) to
# $ARMOR on every apply. This script loads it early. When the daemon comes up it
# replaces the table atomically — the ruleset begins with `delete table` and adds
# its own in one netlink transaction — so there is never a moment with no table.
#
# `iifname` matches by NAME at packet time, not by ifindex at load time, so
# loading this before netifd has created br-lan is fine: the rules simply start
# matching when the device appears. That is why START can sit here rather than
# racing netifd.
#
# START=21: after fw4 (19) and netifd (20), because fw4's own start tears its
# table down and rebuilds it and we do not want to be in the middle of that, and
# because there is nothing to protect before the LAN device is being created. The
# residual exposure is the fraction of a second between netifd's `ifup` and this
# script, against seconds-to-a-minute before.
#
# THE ESCAPE HATCHES (a kill switch that cannot be switched off is a brick)
#
# * $ARMOR only exists while the daemon's last applied config was BOTH enabled
# and fail-closed. `globals.enabled=0`, `kill_switch=open` and a deliberate
# `/etc/init.d/shater stop` each remove it.
# * We refuse to arm when the main service is disabled in rc.d, or when the
# daemon binary is gone — in either case nothing would ever come along to
# replace the armor with a real data plane.
# * We refuse to arm when UCI can be read AND says the stack is disabled. A
# config that cannot be read is NOT a refusal: that case is precisely why the
# armor is a file rather than a query.
# * The chain hooks `forward` only, so SSH, LuCI and the admin panel (all input
# hook, to the router's own addresses) stay reachable. The operator can always
# get in and undo this.
#
# busybox ash only — no bashisms.
START=21 # after firewall (19) and network (20), long before shater (99)
STOP=89
ARMOR=/etc/shater/boot.nft
PROG=/usr/bin/shaterd
# Syslog line that honors globals.log_syslog, like the other two inits. An
# unreadable UCI leaves the option empty => ON, which is what we want here: the
# one boot where the config cannot be read is the boot worth logging.
_slog() {
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater-armor "$@"
}
# Is the MAIN service enabled at boot? Answered by looking for its rc.d symlink
# rather than by running `/etc/init.d/shater enabled`: that is a USE_PROCD script,
# so every action of it sources procd.sh, which takes a blocking flock — and this
# runs at START=21, in the middle of boot, for a question a glob answers exactly
# as well. The START number is not hardcoded; any S<NN>shater counts.
shater_service_enabled() {
local f
for f in /etc/rc.d/S[0-9][0-9]shater; do
[ -e "$f" ] && return 0
done
return 1
}
start() {
# No saved plane => the stack has never applied an enabled, fail-closed config
# (or it was explicitly switched off). Nothing to do, and nothing to say.
[ -f "$ARMOR" ] || return 0
[ -s "$ARMOR" ] || {
_slog -p daemon.err "$ARMOR is empty — NOT arming; the LAN is unprotected until shaterd starts"
return 0
}
# Never arm something nothing can disarm.
[ -x "$PROG" ] || {
_slog -p daemon.err \
"$PROG is missing — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
return 0
}
shater_service_enabled || {
_slog -p daemon.warn \
"the shater service is disabled in rc.d — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
return 0
}
# A READABLE config that says "off" wins over the saved plane (it means the
# daemon was stopped before it could disarm). An UNREADABLE config does not:
# that is the case this whole mechanism exists for.
en=$(uci -q get shater.globals.enabled 2>/dev/null)
if [ -n "$en" ] && [ "$en" != "1" ]; then
rm -f "$ARMOR"
_slog -p daemon.info "globals.enabled=$en — boot armor removed, not arming"
return 0
fi
command -v nft >/dev/null 2>&1 || {
_slog -p daemon.err "nft is not installed — cannot arm; the LAN is unprotected until shaterd starts"
return 0
}
# Validate before loading: a truncated/incompatible snapshot must not leave a
# half-built table behind on the one boot it is needed.
if ! nft -c -f "$ARMOR" >/dev/null 2>&1; then
_slog -p daemon.err \
"$ARMOR did not validate (nft -c) — NOT arming; the LAN is unprotected until shaterd starts"
return 0
fi
if nft -f "$ARMOR" >/dev/null 2>&1; then
_slog -p daemon.warn \
"fail-closed plane armed from $ARMOR: LAN->WAN forwarding is BLOCKED until shaterd applies. SSH, LuCI and the admin panel stay reachable."
else
_slog -p daemon.err \
"could not load $ARMOR — the LAN is unprotected until shaterd starts"
fi
return 0
}
stop() {
# Deliberately a NO-OP. By the time anything stops this service the daemon owns
# `inet shater`, and deleting the table here would dismantle a LIVE data plane
# on the strength of a service that only ever ran for one second at boot. The
# disarm paths that matter live where the decision is actually made:
# /etc/init.d/shater stop (operator switched it off) and the daemon itself
# (globals.enabled=0 / kill_switch=open).
return 0
}
@@ -110,6 +110,14 @@ SHATER_BRINGUP='
done done
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable [ -x /etc/init.d/shater ] && /etc/init.d/shater enable
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron enable [ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron enable
# The boot-time fail-closed armor. `enable` only — it is a one-shot that loads
# the persisted holding plane at START=21, and running it NOW would install a
# block on a live box moments before the daemon replaces it anyway. It has to
# be enabled here regardless of whether the stack is on: the file it loads only
# exists while the daemon wants it to, so an enabled-but-unarmed service is a
# no-op, and enabling it later would mean the first boot after an upgrade is
# the one boot still exposed.
[ -x /etc/init.d/shater-armor ] && /etc/init.d/shater-armor enable
[ -x /etc/init.d/shater ] && /etc/init.d/shater restart [ -x /etc/init.d/shater ] && /etc/init.d/shater restart
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart [ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart
exit 0 exit 0
+13 -4
View File
@@ -34,8 +34,17 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=shaterd PKG_NAME:=shaterd
PKG_VERSION:=0.2.0
PKG_RELEASE:=3 # VERSIONING — derived from the git tag, NOT hand-maintained here (bug B4).
# ci/version.sh turns `git describe` into SHATER_PKG_VERSION/SHATER_PKG_RELEASE
# (tag vX.Y.Z -> X.Y.Z + r1; off-tag -> last tag + r<commits+1>), and
# ci/build-feed-apk.sh exports them into the SDK build env. ci/sdk-build-apk.sh
# then ASSERTS that the produced .apk really carries that version, so a lost env
# can never silently ship a stale one again.
# The literals below are ONLY the manual/offline fallback (no CI, no git) — they
# are not "the release version"; releases are named by the tag.
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
PKG_MAINTAINER:=Shater <maqrota@icloud.com> PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE:=GPL-3.0-or-later
@@ -95,8 +104,8 @@ define Package/shaterd/install
$(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd $(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd
endef endef
# This package ships ONLY the binary — no init script — so opkg's default # This package ships ONLY the binary — no init script — so the package manager's
# postinst never touches the running service. On `opkg upgrade shaterd` the new # postinst never touches the running service. On `apk upgrade shaterd` the new
# ELF lands at /usr/bin/shaterd while the OLD image keeps running from its # ELF lands at /usr/bin/shaterd while the OLD image keeps running from its
# unlinked inode: the upgrade silently has no effect until the next reboot, and # unlinked inode: the upgrade silently has no effect until the next reboot, and
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by # meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
+26
View File
@@ -18,6 +18,32 @@ type URLTestOutboundOptions struct {
// lx: SPEC 019 v2 — load-balancing. // lx: SPEC 019 v2 — load-balancing.
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"` Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
// lx: health board §5.C — SelfCheck stands the group's OWN background
// health-check up or down. nil/absent == true, so every existing config keeps
// today's behaviour.
//
// Why this exists at all: a urltest group probes its members BY ITSELF — a
// warm-up sweep at PostStart and a ticker for as long as traffic keeps
// touching it — and it dials the members' outbounds DIRECTLY, from the
// router, over whatever the default WAN route is. For a group that traffic
// actually flows through, that is exactly right: the probe travels the same
// path the connections do. But for a group NO routing rule reaches, that
// same probe measures a path nothing uses — and it stores the result under
// the members' BASE tags, which every health consumer then reads as "the
// node's health". A node that is blocked on the direct WAN and perfectly
// alive behind a tunnel therefore reads "dead" the moment such a group
// probes it; the reading is not merely stale, it is FALSE, and it poisons
// the shared board for everyone (selection, the panel, the observatory's
// freshness gate). SelfCheck=false is how the control plane stands such a
// group's own schedule down: the shater engine computes which groups the
// applied rules actually reach (the observatory's used-set) and disables
// the self-check on the rest, so the ONLY prober left is the observatory —
// which probes along the real dial paths and nothing else.
//
// The flag suppresses only the group's own SCHEDULE (the PostStart warm-up
// and the Touch ticker). An EXPLICIT CheckOutbounds/URLTest call — the
// adapter interface a human or an API invokes on purpose — still works.
SelfCheck *bool `json:"self_check,omitempty"`
} }
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily // URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "node --test src/*.test.ts"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
+221
View File
@@ -262,6 +262,150 @@
} }
} }
/* ---- fixture band (dev builds only; see App.tsx MockBanner) ----
Deliberately outside the crit/amber vocabulary: nothing is wrong with the
router, there is no router. The hazard hatch is the service-sticker language a
piece of network hardware already uses for "this unit is not in service". */
.mock-band {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
margin-top: calc(var(--u, 8px) * 2);
padding: 10px 14px;
border: 1px dashed var(--faint);
border-radius: 9px;
background: repeating-linear-gradient(
-45deg,
var(--sink),
var(--sink) 9px,
var(--panel) 9px,
var(--panel) 18px
);
}
.mock-band-tag {
flex-shrink: 0;
align-self: flex-start;
padding: 3px 7px;
border: 1px solid var(--faint);
border-radius: 4px;
background: var(--raised);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--dim);
}
.mock-band-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mock-band-headline {
font-family: var(--font-mono);
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
}
.mock-band-detail {
font-size: 12.5px;
line-height: 1.5;
color: var(--dim);
max-width: 76ch;
}
.mock-band-detail code {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--ink);
}
/* ---- commit-confirm band (every page except Apply, which has the full panel) ----
Same plate as the protection banner so the two read as one family; the seconds
are the loud element because they are the only thing that is running out. */
.cfm-band {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
margin-top: calc(var(--u, 8px) * 2);
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--groove));
border-radius: 9px;
background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 10%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
.cfm-band-count {
display: flex;
align-items: baseline;
gap: 2px;
flex-shrink: 0;
font-family: var(--font-mono);
color: var(--amber);
}
.cfm-band-num {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.cfm-band-unit {
font-size: 11px;
letter-spacing: 0.06em;
}
.cfm-band-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.cfm-band-headline {
font-family: var(--font-mono);
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
}
.cfm-band-detail {
font-size: 12.5px;
line-height: 1.5;
color: var(--dim);
max-width: 76ch;
}
.cfm-band-actions {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1);
flex-shrink: 0;
}
.cfm-band-link {
padding: 6px 11px;
border: 1px solid var(--groove);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: none;
color: var(--ink);
background: var(--raised);
}
.cfm-band-link:hover {
border-color: var(--accent);
color: var(--accent);
}
@media (max-width: 720px) {
.cfm-band {
flex-wrap: wrap;
}
.cfm-band-actions {
width: 100%;
justify-content: flex-end;
}
}
/* ---- last-apply findings (Overview) ---- /* ---- last-apply findings (Overview) ----
Severity carries the colour; the accent is reserved for interactive controls. */ Severity carries the colour; the accent is reserved for interactive controls. */
.findings { .findings {
@@ -312,6 +456,13 @@
.finding--warning { .finding--warning {
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove)); border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
} }
/* The daemon's "the list is capped" disclosure. Dashed, because the row is about
what ISN'T here — it must not read as one more finding to work through. */
.finding--truncated {
border-style: dashed;
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
background: var(--panel);
}
.finding-copy { .finding-copy {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -405,3 +556,73 @@
color: var(--dim); color: var(--dim);
max-width: 74ch; max-width: 74ch;
} }
/* ---- inline rename (shared) ----
The pencil-in-the-row interaction: click the ✎ beside a name, type over it,
Enter commits / Esc cancels / blur commits. Lifted out of Devices.css when
Nodes grew the same affordance — one interaction, one set of rules, so the two
pages can never drift apart. `--locked` is the same control with the action
withheld: it stays visible and focusable-looking so a missing rename reads as
a stated rule, not a dead button. */
.inline-rename {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 5px;
background: none;
color: var(--faint);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.inline-rename:hover:not(:disabled) {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.inline-rename:focus-visible {
color: var(--accent);
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.inline-rename:disabled {
opacity: 0.5;
cursor: default;
}
/* Withheld, not broken: keep the glyph readable and let the cursor say "there is
a reason" rather than dimming it into invisibility. */
.inline-rename--locked {
opacity: 0.75;
cursor: help;
}
.inline-rename--locked:hover {
color: var(--dim);
background: none;
}
.inline-rename-input {
min-width: 0;
max-width: 24ch;
padding: 4px 8px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--sink);
color: var(--ink);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 1px 2px var(--shadow) inset;
}
.inline-rename-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.inline-rename-input:disabled {
opacity: 0.55;
}
+118 -7
View File
@@ -1,12 +1,14 @@
import './App.css' import './App.css'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Faceplate, FaceplateHeader, Led, Module } from './components' import { Button, Faceplate, FaceplateHeader, Led, Module } from './components'
import type { LedVariant } from './components' import type { LedVariant } from './components'
import { ApiError, MOCK, getStatus } from './api' import { ApiError, MOCK, confirm as apiConfirm, getStatus } from './api'
import type { Status } from './api' import type { Status } from './api'
import { usePendingConfirm } from './pendingConfirm'
import { bootstrapSession } from './session' import { bootstrapSession } from './session'
import { ROUTES, navigate, useRoute } from './router' import { ROUTES, navigate, useRoute } from './router'
import { protectionState } from './planeState' import { engineState, protectionState } from './planeState'
import { truncationNote } from './findings'
import type { Route } from './router' import type { Route } from './router'
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages' import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
@@ -99,12 +101,102 @@ export function App() {
footer={<StatusBar status={status} />} footer={<StatusBar status={status} />}
> >
<Nav route={route} /> <Nav route={route} />
<MockBanner />
<PlaneBanner status={status} route={route} /> <PlaneBanner status={status} route={route} />
<ConfirmBand route={route} onChanged={() => void refreshStatus()} />
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} /> <Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
</Faceplate> </Faceplate>
) )
} }
/**
* The commit-confirm countdown, on every page.
*
* The daemon arms an auto-rollback on EVERY apply, but only the Apply page ever
* said so: press Apply on Routing, read "Applied", walk away, and the router
* reverts a minute later with nothing on screen having mentioned it. This band
* carries that deadline — and the button that stops it — to wherever the operator
* actually is.
*
* Suppressed on Apply, which renders the full control room for the same window
* (and reads the same record, so a reload no longer loses the countdown there
* either).
*/
function ConfirmBand({ route, onChanged }: { route: Route; onChanged: () => void }) {
const armed = usePendingConfirm()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Keeping the config is the only action offered here; rolling back early is a
// deliberate act with its own before/after readout, and that lives on Apply.
const keep = useCallback(async () => {
setBusy(true)
setError(null)
try {
const r = await apiConfirm()
if (r.error) setError(r.error)
} catch (e) {
setError(e instanceof Error ? e.message : 'request failed')
} finally {
setBusy(false)
onChanged()
}
}, [onChanged])
if (!armed || route === 'apply') return null
return (
<div className="cfm-band" role="alert">
<Led variant="amber" pulse />
<div className="cfm-band-count" role="timer" aria-label={`${armed.remaining} seconds until auto-rollback`}>
<span className="cfm-band-num">{armed.remaining}</span>
<span className="cfm-band-unit">s</span>
</div>
<div className="cfm-band-copy">
<span className="cfm-band-headline">This config is live but not kept</span>
<span className="cfm-band-detail">
{error
? `Couldn’t keep it — ${error}. Try again, or open Apply.`
: 'Every apply arms an auto-rollback. Keep this config before the timer runs out, or the router reverts to the last-good one.'}
</span>
</div>
<div className="cfm-band-actions">
<Button variant="primary" onClick={() => void keep()} disabled={busy}>
{busy ? 'Keeping…' : 'Keep this config'}
</Button>
<a className="cfm-band-link" href="#/apply" onClick={() => navigate('apply')}>
Apply page
</a>
</div>
</div>
)
}
/**
* Says, on every page, that nothing on screen came from a router.
*
* Only a DEV build can ever render this — the fixtures are not in a production
* bundle (api.ts initMockBackend), so an operator cannot reach this state at all.
* It is here for the person who CAN: a footer line reading "DEMO DATA" is easy to
* work past for an afternoon and then screenshot into a bug report, and every
* number above it is invented.
*/
function MockBanner() {
if (!MOCK) return null
return (
<div className="mock-band" role="status">
<span className="mock-band-tag">FIXTURES</span>
<div className="mock-band-copy">
<span className="mock-band-headline">No router is being read</span>
<span className="mock-band-detail">
Every reading on this page is invented by <code>src/mock.ts</code> for offline
development. Drop <code>?mock</code> from the address to talk to a daemon.
</span>
</div>
</div>
)
}
/** /**
* The protection state, pinned under the nav on every page EXCEPT Overview * The protection state, pinned under the nav on every page EXCEPT Overview
* (which shows the same state as its own headline readout — see planeState.ts). * (which shows the same state as its own headline readout — see planeState.ts).
@@ -130,12 +222,15 @@ function PlaneBanner({ status, route }: { status: Status | null; route: Route })
const criticals = (status.warnings ?? []).filter((w) => w.severity === 'critical').length const criticals = (status.warnings ?? []).filter((w) => w.severity === 'critical').length
const state = protectionState(status) const state = protectionState(status)
// The published list is capped at 50, so with a note attached the count is a
// floor. Say "at least" rather than quoting a total the daemon didn't send.
const atLeast = truncationNote(status.warnings) ? 'At least ' : ''
// Wording comes from the shared source of truth so the banner and Overview can // Wording comes from the shared source of truth so the banner and Overview can
// never describe the same router differently. // never describe the same router differently.
const headline = state.alarm const headline = state.alarm
? state.headline ? state.headline
: `${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply` : `${atLeast}${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply`
const detail = state.alarm const detail = state.alarm
? state.detail ? state.detail
: 'Something you configured isn’t in effect. Review the findings before relying on it.' : 'Something you configured isn’t in effect. Review the findings before relying on it.'
@@ -225,14 +320,30 @@ function StatusBar({ status }: { status: Status | null }) {
) )
} }
/**
* The one lamp that is on screen no matter which page you are on.
*
* It used to read `status.running`, which the daemon hardcoded to `true` — so the
* "Offline" branch could never be reached and the plate said "Online" through an
* engine that had failed to start. It now asks {@link engineState}, whose whole
* job is to be able to answer "down", and refuses to guess when nothing has been
* reported: an unlit socket, not a green light.
*/
function masterIndicator( function masterIndicator(
phase: Phase, phase: Phase,
status: Status | null, status: Status | null,
): { label: string; variant: LedVariant; pulse?: boolean } { ): { label: string; variant: LedVariant; pulse?: boolean } {
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' } if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
if (status.running && status.active) return { label: 'Online', variant: 'on', pulse: true } switch (engineState(status)) {
if (status.running) return { label: 'Standby', variant: 'amber' } case 'down':
return { label: 'Offline', variant: 'crit' } return { label: 'Engine down', variant: 'crit' }
case 'up':
return status.active
? { label: 'Online', variant: 'on', pulse: true }
: { label: 'Standby', variant: 'amber' }
default:
return { label: 'Unknown', variant: 'off' }
}
} }
function UnauthPlate() { function UnauthPlate() {
+379 -48
View File
@@ -13,8 +13,13 @@
// serves in-memory fixtures instead of hitting the network, so `npm run dev` // serves in-memory fixtures instead of hitting the network, so `npm run dev`
// and screenshot runs render without a live backend. A real backend in dev is // and screenshot runs render without a live backend. A real backend in dev is
// reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch). // reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch).
//
// THE FIXTURES ARE A DEV-BUILD-ONLY ARTEFACT — see initMockBackend below. They
// used to be a plain static import, decided at RUNTIME off `location.search`, so
// the invented router shipped inside the binary that goes on real hardware and a
// link ending in `?dev` painted a healthy appliance without making one request.
import * as mock from './mock' import { armPendingConfirm, clearPendingConfirm, noteConfirmTimeout } from './pendingConfirm'
// --- error type ------------------------------------------------------------- // --- error type -------------------------------------------------------------
@@ -51,6 +56,38 @@ export class ApiError extends Error {
*/ */
export type Plane = 'full' | 'hold' | 'none' export type Plane = 'full' | 'hold' | 'none'
/**
* Where the router's traffic actually ENDS UP, decided by the daemon from the
* engine config it is running (apply.Status.traffic ← generate.TrafficOf).
*
* tunnel — the default route goes into a tunnel: everything not matched by a
* more specific rule is proxied.
* split — the default leaves directly, but some rules do tunnel their traffic.
* direct — the default leaves directly and nothing is tunnelled at all.
* blocked — the default is the fail-closed backstop: unmatched traffic is
* dropped, not let out. Nothing leaks.
*
* `plane` DOES NOT ANSWER THIS and must never be read as if it did. `plane` says
* how much of the data plane is installed (nft table, policy routing, engine up);
* a router whose only rule is `default → direct` has all of it and sends the whole
* LAN out the plain WAN with its real address. That combination — plane "full",
* traffic "direct" — was live on a user's router under a green "Protected" LED.
*/
export type TrafficVerdict = 'tunnel' | 'split' | 'direct' | 'blocked'
export interface Traffic {
// '' or absent ⇒ not known (daemon that predates this field, nothing applied
// yet, or the plane is on hold). NEVER treat unknown as 'tunnel'.
verdict?: TrafficVerdict | ''
// The outbound tag the engine's default route names, in the engine's own
// vocabulary ("direct", "block", a node/group tag). Diagnostic — wording is
// driven by `verdict`, never by parsing this.
default?: string
// How many of the engine's route rules send their matched traffic into a tunnel.
// Separates "some of your traffic is protected" from "none of it is".
tunnel_rules?: number
}
/** /**
* One thing the last apply could not do. Deliberately fail-OPEN with a warning * One thing the last apply could not do. Deliberately fail-OPEN with a warning
* rather than refusing the whole config (the alternative was taking the network * rather than refusing the whole config (the alternative was taking the network
@@ -88,6 +125,10 @@ export interface Status {
// How much of the data plane is installed. Absent on older daemons ⇒ unknown, // How much of the data plane is installed. Absent on older daemons ⇒ unknown,
// in which case the UI shows nothing rather than guessing "full". // in which case the UI shows nothing rather than guessing "full".
plane?: Plane plane?: Plane
// Where the traffic actually goes under the running config. Absent on older
// daemons ⇒ unknown; see TrafficVerdict for why this is a separate question
// from `plane`.
traffic?: Traffic
// Findings from the last apply. ALWAYS an array from the daemon (never null); // Findings from the last apply. ALWAYS an array from the daemon (never null);
// empty means the last apply was clean. Pre-sorted critical-first and capped at // empty means the last apply was clean. Pre-sorted critical-first and capped at
// 50, where a truncated list ends with an `info` entry saying "suppressed". // 50, where a truncated list ends with an `info` entry saying "suppressed".
@@ -354,19 +395,142 @@ export interface GroupHealth {
* any more and nothing to report here beyond the groups themselves. * any more and nothing to report here beyond the groups themselves.
*/ */
/**
* One hop of one chain, measured where that hop actually sits in the path.
*
* This is the reading the daemon always took and never showed. A chain is not a
* target with a single health — it is an ordered series of them, and the only
* question an operator ever asks about a broken chain is WHICH hop broke. The
* end-to-end exit reading cannot answer that: it says "the path is dead" for a
* four-hop chain and leaves the person to guess between four suspects.
*
* WIRE ORDER. `index` is 1-based and counts hops in the order the router dials
* them: hop 1 is the first physical hop, and each later hop is dialled THROUGH
* the ones before it. The hop carrying `exit: true` — always the largest index —
* is where traffic leaves for the internet. A leading `egress:` in the chain's
* configured Hops is NOT a numbered hop: the daemon lifts it into the entry
* detour of hop 1, so a chain written `egress:ewan → node:awgout → group:sub0`
* reports two hops, not three. Anything zipping this against the model's Hops
* must drop that leading egress first and give up on labelling entirely if the
* counts still disagree — a chain that splices sub-chains gets flattened here,
* and a confidently WRONG hop name is worse than no name.
*
* `tag` is the engine-side outbound (`chain-<name>-h2`). Debugging and tooltips
* only; it is never a label to put in front of a person.
*
* ORDERED WALK — THE READING STOPS AT THE FIRST DEAD HOP. Hops are NOT measured
* independently, and never were measurable that way: hop 3 is dialled THROUGH
* hop 2, so probing hop 3 while hop 2 is down measures hop 2 a second time and
* learns nothing about hop 3. The daemon therefore walks the path in wire order
* and stops at the first hop that does not answer. Every hop below that one is
* left undialled and reported `state: "untested"` — no measurement exists —
* carrying {@link ChainHopBlock} in `blocked_by` to name the hop that stopped the
* walk. So a chain never reports a dead hop with a live hop below it; that shape
* is not a rare case, it is unreachable.
*
* NODE HOP vs GROUP HOP. For `kind: "node"` the hop IS the measurement: `total`
* is 1, the counters follow its own state, and `selected` is ''. For
* `kind: "group"` the counters roll up that hop's per-hop member COPIES — the
* copies dialled through the hops in front of it, which is exactly why they can
* read alive here while the same group's standalone card reads dead. Both
* readings are true; they measure different dial paths. `selected` is the node
* NAME the hop routes through right now, and `delay_ms` / `age_seconds` belong
* to that selected member (or the freshest alive one).
*
* Invariants the daemon guarantees — never re-derive them, just read them:
* `tested === alive + dead` and `alive + dead + untested === total`.
*
* `state` is a closed set of THREE. `untested` is NEVER "dead" and never
* "healthy": it means nothing fresh enough is known. Without `blocked_by` that is
* a matter of timing — for a used chain it resolves on its own within seconds.
* With `blocked_by` it will not resolve until the named hop is fixed. There is no
* fourth state for that; the state stays `untested` because that is what it is.
* `age_seconds: -1` means the age is unknown.
*/
export interface ChainHopHealth {
/** 1-based WIRE order. Hop 1 is dialled first; see the note above. */
index: number
/** Engine outbound tag (`chain-<name>-h2`) — tooltips/debugging, never a label. */
tag: string
/** `node` ⇒ the hop is the measurement. `group` ⇒ the counters roll up members. */
kind: 'node' | 'group'
/** This hop is where traffic leaves for the internet. Always the largest index. */
exit: boolean
/** Closed set — switch on it exhaustively. `untested` is never "dead". */
state: 'alive' | 'dead' | 'untested'
/** RTT of the selected/freshest alive member; 0 (meaningless) when not alive. */
delay_ms: number
/** Age of that measurement in seconds; -1 when unknown. */
age_seconds: number
/** Node name this GROUP hop routes through right now; '' for a node hop. */
selected: string
total: number
tested: number
alive: number
dead: number
untested: number
/**
* PRESENT ONLY on a hop the ordered walk never reached — i.e. a hop sitting
* below one the prober found `dead`. The key is omitted otherwise; absent is
* the normal case and means "this hop was actually dialled".
*
* Its presence is the daemon's own statement that this hop has NO measurement,
* and it comes with the rest of that statement already filled in: `state` is
* `untested`, `delay_ms` is 0, `age_seconds` is -1, and the counters are
* `alive: 0, dead: 0, tested: 0, untested: total`. Read those; do not re-derive
* a verdict from them, and do not infer a block from zeroed counters either —
* an unprobed-yet hop has the same numbers and a very different meaning.
* `selected` MAY still be non-empty: the wrapper does have a pick, it simply
* was not measured, so it says which node the hop would use, not which node is
* carrying traffic.
*/
blocked_by?: ChainHopBlock
}
/**
* The hop that stopped the ordered walk, as reported on every hop below it.
*
* This exists because "no reading" and "no reading, and here is whose fault that
* is" are different answers to the operator's actual question. Without it a
* blocked hop is indistinguishable from one the observatory has not come round to
* yet, and the interface can only shrug.
*
* `index` is the 1-based WIRE index of the blocking hop and is ALWAYS smaller
* than the index of the hop carrying it, so it points at a hop already on screen.
* `tag` is that hop's engine outbound (`chain-<name>-h3`) — debugging and
* tooltips only, never a label to put in front of a person, exactly as on
* {@link ChainHopHealth}.tag.
*/
export interface ChainHopBlock {
/** 1-based wire index of the hop that did not answer. Always < this hop's index. */
index: number
/** That hop's engine outbound tag — tooltips/debugging, never a label. */
tag: string
}
/** Per-chain reachability, the chain analogue of {@link GroupHealth}.used (plan /** Per-chain reachability, the chain analogue of {@link GroupHealth}.used (plan
* §5.E): a chain no enabled routing rule routes through is outside the * §5.E): a chain no enabled routing rule routes through is outside the
* observatory's plan, so its exit is never probed and the Targets card renders it * observatory's plan, so nothing probes it and the Targets card says so instead
* "unused" instead of an exit-test readout. A chain has no membership counters — * of rendering a health reading. A chain has no membership counters of its own —
* it is a fixed path, and its end-to-end health is the exit test's job. */ * it is a fixed path, and its health lives on its {@link ChainHopHealth} hops. */
export interface ChainHealth { export interface ChainHealth {
name: string name: string
/** An enabled routing rule (the Final target, a DNS-resolver detour, a device /** An enabled routing rule (the Final target, a DNS-resolver detour, a device
* target, …) reaches this chain, so the observatory probes its exit in the * target, …) reaches this chain, so the observatory probes its hops in the
* background. false ⇒ nothing routes through the chain: it is skipped by the * background. false ⇒ nothing routes through the chain: it is skipped by the
* background probing and its end-to-end health stays untested. That is an * background probing and its health stays untested. That is an "unused" note
* "unused" note about the ROUTING CONFIG, never a health problem. */ * about the ROUTING CONFIG, never a health problem. */
used: boolean used: boolean
/**
* Per-hop health in wire order (see {@link ChainHopHealth}).
*
* MAY BE ABSENT, and absent does not mean "this chain has no hops". It means
* the engine never materialised per-hop outbounds for it: the chain is unused,
* or it collapses to a single hop and the daemon points traffic straight at
* that target instead of building a copy of it. Read a missing key as "nothing
* measured per hop", never as an empty path or as a fault.
*/
hops?: ChainHopHealth[]
} }
export interface GroupsHealth { export interface GroupsHealth {
@@ -809,9 +973,17 @@ export interface Rule {
Enabled: boolean Enabled: boolean
Order: number Order: number
Src?: string[] | null Src?: string[] | null
DstDomain?: string[] | null /**
* WHERE the traffic is going — the rule's only destination matcher. Each entry
* names a {@link Ruleset}; the rule matches when ANY of them matches.
*
* There is no inline domain or address list on a rule. `dst_domain`/`dst_ip`
* were removed in schema v2, and `shaterd migrate` folds every existing one
* into a generated `rule-<name>` ruleset, so a destination list is written and
* edited in exactly one place and compiled once into a .srs that every rule
* referencing it shares.
*/
DstRuleset?: string[] | null DstRuleset?: string[] | null
DstIP?: string[] | null
DstPort?: string DstPort?: string
/** /**
* Narrow the rule to one transport or one sniffed application protocol. A * Narrow the rule to one transport or one sniffed application protocol. A
@@ -952,12 +1124,59 @@ export interface Model {
// --- transport -------------------------------------------------------------- // --- transport --------------------------------------------------------------
/** True when the URL asks for the offline fixture backend (?mock or ?dev). */ // --- the offline fixture backend (dev builds only) ---------------------------
export const MOCK: boolean = (() => {
/**
* True when the in-memory fixtures are serving this session instead of the
* daemon. ALWAYS false in a production build — see {@link initMockBackend}.
*
* A live binding, not a constant: it is decided once during boot, before the
* first render, and every importer sees the same value for the whole session.
*/
export let MOCK = false
/** The loaded fixture module. `null` unless a dev build was asked for `?mock`. */
let fixtures: typeof import('./mock') | null = null
/**
* Load the fixture backend, if this build has one and the URL asks for it.
* Call ONCE from the entry point and await it before the first render — the
* pages read {@link MOCK} while they render, so flipping it afterwards would
* leave a half-mocked screen.
*
* Two gates, and the order matters. `import.meta.env.DEV` is folded to a literal
* `false` by Vite at build time, so in a production build the whole body is
* unreachable, `import('./mock')` is tree-shaken out of the module graph, and the
* fixtures are not in the emitted bundle AT ALL — not lazily, not behind a flag.
* `vite.config.ts` fails the build if that ever stops being true.
*
* This is deliberately stronger than "hide the mock behind a query flag". The
* flag was the bug: `?dev` on a production URL rendered an invented healthy
* router — 119 of 122 nodes alive, "Protected" — with no request made and one
* line of small print in the footer to say so. A person cannot audit a bundle;
* the only honest guarantee is that the invented data is not in it.
*/
export async function initMockBackend(): Promise<boolean> {
if (import.meta.env.DEV && mockRequested()) {
fixtures = await import('./mock')
MOCK = true
}
return MOCK
}
/** Does the URL ask for the offline fixture backend (`?mock` or `?dev`)? */
function mockRequested(): boolean {
if (typeof location === 'undefined') return false if (typeof location === 'undefined') return false
const q = new URLSearchParams(location.search) const q = new URLSearchParams(location.search)
return q.has('mock') || q.has('dev') return q.has('mock') || q.has('dev')
})() }
/** The fixture backend, for the `MOCK ? … : …` branches below. Throws rather
* than inventing data if it is ever reached without having been loaded. */
function mock(): NonNullable<typeof fixtures> {
if (!fixtures) throw new Error('mock backend not loaded — call initMockBackend() first')
return fixtures
}
/** A decoded response plus the raw Headers, for endpoints whose contract puts /** A decoded response plus the raw Headers, for endpoints whose contract puts
* pagination metadata outside the JSON body (see the stats log endpoints). */ * pagination metadata outside the JSON body (see the stats log endpoints). */
@@ -1006,33 +1225,54 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
// --- endpoints -------------------------------------------------------------- // --- endpoints --------------------------------------------------------------
export function getStatus(): Promise<Status> { export function getStatus(): Promise<Status> {
return MOCK ? mock.getStatus() : req<Status>('api/status') return MOCK ? mock().getStatus() : req<Status>('api/status')
} }
export function getConfig(): Promise<Model> { export async function getConfig(): Promise<Model> {
return MOCK ? mock.getConfig() : req<Model>('api/config') const m = await (MOCK ? mock().getConfig() : req<Model>('api/config'))
// Every page reads the config, and the commit-confirm window's length is the
// only thing needed to arm a countdown — so it is captured here once instead of
// being threaded through eight pages. See pendingConfirm.ts.
noteConfirmTimeout(m.Globals?.ConfirmTimeout)
return m
} }
export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }> { export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }> {
return MOCK return MOCK
? mock.putConfig(m) ? mock().putConfig(m)
: req('api/config', { method: 'PUT', body: JSON.stringify(m) }) : req('api/config', { method: 'PUT', body: JSON.stringify(m) })
} }
export function apply(): Promise<ApplyResult> { /**
return MOCK ? mock.apply() : req<ApplyResult>('api/apply', { method: 'POST' }) * POST /api/apply.
*
* The daemon arms an auto-rollback on EVERY successful apply that changed
* something (panel/api.go handleApply → ArmRollback), whichever page's button was
* pressed. Recording it here — the one place every one of those buttons goes
* through — is what lets the countdown and the "Keep this config" control follow
* the operator around the panel instead of living in the Apply page's local
* state. See pendingConfirm.ts.
*/
export async function apply(): Promise<ApplyResult> {
const r = await (MOCK ? mock().apply() : req<ApplyResult>('api/apply', { method: 'POST' }))
if (!r.error && r.changed) armPendingConfirm()
return r
} }
export function confirm(): Promise<ApplyResult> { export async function confirm(): Promise<ApplyResult> {
return MOCK ? mock.confirm() : req<ApplyResult>('api/confirm', { method: 'POST' }) const r = await (MOCK ? mock().confirm() : req<ApplyResult>('api/confirm', { method: 'POST' }))
if (!r.error) clearPendingConfirm()
return r
} }
export function rollback(): Promise<ApplyResult> { export async function rollback(): Promise<ApplyResult> {
return MOCK ? mock.rollback() : req<ApplyResult>('api/rollback', { method: 'POST' }) const r = await (MOCK ? mock().rollback() : req<ApplyResult>('api/rollback', { method: 'POST' }))
if (!r.error) clearPendingConfirm()
return r
} }
export function getStats(): Promise<Stats> { export function getStats(): Promise<Stats> {
return MOCK ? mock.getStats() : req<Stats>('api/stats') return MOCK ? mock().getStats() : req<Stats>('api/stats')
} }
// --- daemon log download ------------------------------------------------------ // --- daemon log download ------------------------------------------------------
@@ -1084,7 +1324,7 @@ function saveBlob(blob: Blob, filename: string): void {
*/ */
export async function downloadLog(range: LogRange): Promise<void> { export async function downloadLog(range: LogRange): Promise<void> {
if (MOCK) { if (MOCK) {
saveBlob(new Blob([mock.getLogText(range)], { type: 'text/plain' }), `shater-log-${range}.txt`) saveBlob(new Blob([mock().getLogText(range)], { type: 'text/plain' }), `shater-log-${range}.txt`)
return return
} }
let res: Response let res: Response
@@ -1163,14 +1403,14 @@ function logPage<T>(env: { body: T[] | null; headers: Headers }): StatsLogPage<T
/** GET /api/stats/log — one page of the DNS query log with its cursor metadata. */ /** GET /api/stats/log — one page of the DNS query log with its cursor metadata. */
export function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> { export function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> {
return MOCK return MOCK
? mock.getStatsLogPage(q) ? mock().getStatsLogPage(q)
: reqFull<QueryLogEntry[] | null>(`api/stats/log${statsLogQS(q)}`).then(logPage) : reqFull<QueryLogEntry[] | null>(`api/stats/log${statsLogQS(q)}`).then(logPage)
} }
/** GET /api/stats/conns — one page of the connection log with its cursor metadata. */ /** GET /api/stats/conns — one page of the connection log with its cursor metadata. */
export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> { export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> {
return MOCK return MOCK
? mock.getStatsConnsPage(q) ? mock().getStatsConnsPage(q)
: reqFull<ConnLogEntry[] | null>(`api/stats/conns${statsLogQS(q)}`).then(logPage) : reqFull<ConnLogEntry[] | null>(`api/stats/conns${statsLogQS(q)}`).then(logPage)
} }
@@ -1178,18 +1418,73 @@ export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<C
* Rows only; callers that tail the stream want {@link getStatsLogPage} instead. */ * Rows only; callers that tail the stream want {@link getStatsLogPage} instead. */
export function getStatsLog(q: number | StatsLogQuery = {}): Promise<QueryLogEntry[]> { export function getStatsLog(q: number | StatsLogQuery = {}): Promise<QueryLogEntry[]> {
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
return MOCK ? mock.getStatsLog(o) : req<QueryLogEntry[]>(`api/stats/log${statsLogQS(o)}`) return MOCK ? mock().getStatsLog(o) : req<QueryLogEntry[]>(`api/stats/log${statsLogQS(o)}`)
} }
/** GET /api/stats/conns — the live connection-event log (device→dest), newest first. */ /** GET /api/stats/conns — the live connection-event log (device→dest), newest first. */
export function getStatsConns(q: number | StatsLogQuery = {}): Promise<ConnLogEntry[]> { export function getStatsConns(q: number | StatsLogQuery = {}): Promise<ConnLogEntry[]> {
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
return MOCK ? mock.getStatsConns(o) : req<ConnLogEntry[]>(`api/stats/conns${statsLogQS(o)}`) return MOCK ? mock().getStatsConns(o) : req<ConnLogEntry[]>(`api/stats/conns${statsLogQS(o)}`)
}
/**
* One routing rule's reachability verdict — the rule analogue of
* {@link ChainHealth}.used: a quiet note about the ROUTING CONFIG, never a health
* signal.
*
* `unreachable` means the rule can NEVER take effect, whatever the traffic. Today
* the daemon reports exactly one certain case, and it is a subtle one: a rule with
* no conditions at all is not matched in sequence — it becomes the router's
* default. Two such rules therefore retire each other, and the LAST one by Order
* wins, so an earlier "default → direct" is dead even though it sorts first. A
* condition-less rule never retires a rule that HAS conditions: those are matched
* ahead of the default whatever their Order.
*
* `index` is the rule's position in GET /api/config's `Rules`, which is how a
* verdict is matched to a row — rule names are not unique, and the config that
* prompted this had two rules both called `default`. `name`/`order` are echoed so
* a page holding a verdict fetched before an edit can check it still describes the
* row it is about to badge, and drop it silently otherwise.
*/
export interface RuleReach {
index: number
name: string
order: number
unreachable: boolean
/** The rule that supersedes this one; absent when `unreachable` is false. */
shadowed_by?: string
/** Its index in `Rules`, or -1 when there is none. */
shadowed_by_index: number
shadowed_by_order?: number
/** Operator-facing sentence; absent when `unreachable` is false. */
reason?: string
/**
* Whether the rule is IN FORCE right now — `Rule.Enabled` after the active WAN
* profile's overrides. This is NOT `GET /api/config`'s `Enabled`: that one is
* the desired state the page PUTs back, and on a router with profiles the two
* legitimately disagree. Draw rows from this; keep the switch on the other.
*/
effective_enabled: boolean
/** The active profile that CHANGED this rule's state; absent when none did. */
overridden_by?: string
/** Which way it went. Absent together with `overridden_by`. */
override?: 'enabled' | 'disabled'
}
/** GET /api/rules/reachability. `rules` is ALWAYS an array, one entry per rule in
* the same order as GET /api/config's `Rules`. */
export interface RulesReachability {
rules: RuleReach[]
}
/** GET /api/rules/reachability — which routing rules can never fire, and why. */
export function getRulesReachability(): Promise<RulesReachability> {
return MOCK ? mock().getRulesReachability() : req<RulesReachability>('api/rules/reachability')
} }
/** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */ /** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */
export function getRulesetStatus(): Promise<RulesetStatus[]> { export function getRulesetStatus(): Promise<RulesetStatus[]> {
return MOCK ? mock.getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status') return MOCK ? mock().getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status')
} }
/** /**
@@ -1200,7 +1495,7 @@ export function getRulesetStatus(): Promise<RulesetStatus[]> {
*/ */
export function updateRuleset(tag: string): Promise<RulesetStatus | { ok: boolean }> { export function updateRuleset(tag: string): Promise<RulesetStatus | { ok: boolean }> {
return MOCK return MOCK
? mock.updateRuleset(tag) ? mock().updateRuleset(tag)
: req('api/ruleset/update', { method: 'POST', body: JSON.stringify({ tag }) }) : req('api/ruleset/update', { method: 'POST', body: JSON.stringify({ tag }) })
} }
@@ -1228,7 +1523,7 @@ export interface RulesetCheck {
*/ */
export function checkRulesetCategory(source: string, category: string): Promise<RulesetCheck> { export function checkRulesetCategory(source: string, category: string): Promise<RulesetCheck> {
return MOCK return MOCK
? mock.checkRulesetCategory(source, category) ? mock().checkRulesetCategory(source, category)
: req<RulesetCheck>('api/ruleset/check', { : req<RulesetCheck>('api/ruleset/check', {
method: 'POST', method: 'POST',
body: JSON.stringify({ source, category }), body: JSON.stringify({ source, category }),
@@ -1257,18 +1552,18 @@ export interface RulesetCategories {
*/ */
export function getRulesetCategories(source: string): Promise<RulesetCategories> { export function getRulesetCategories(source: string): Promise<RulesetCategories> {
return MOCK return MOCK
? mock.getRulesetCategories(source) ? mock().getRulesetCategories(source)
: req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`) : req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`)
} }
/** GET /api/devices — discovered LAN clients merged with per-device config. */ /** GET /api/devices — discovered LAN clients merged with per-device config. */
export function getDevices(): Promise<DiscoveredDevice[]> { export function getDevices(): Promise<DiscoveredDevice[]> {
return MOCK ? mock.getDevices() : req<DiscoveredDevice[]>('api/devices') return MOCK ? mock().getDevices() : req<DiscoveredDevice[]>('api/devices')
} }
/** GET /api/interfaces — the router's UCI network interfaces for the egress picker. */ /** GET /api/interfaces — the router's UCI network interfaces for the egress picker. */
export function getInterfaces(): Promise<Interface[]> { export function getInterfaces(): Promise<Interface[]> {
return MOCK ? mock.getInterfaces() : req<Interface[]>('api/interfaces') return MOCK ? mock().getInterfaces() : req<Interface[]>('api/interfaces')
} }
/** POST /api/session — exchange a single-use handoff token for a session cookie. */ /** POST /api/session — exchange a single-use handoff token for a session cookie. */
@@ -1304,7 +1599,7 @@ export function importWg(conf: string): Promise<{ uri: string; name: string }> {
*/ */
export function updateSubscription(name: string): Promise<{ added: number }> { export function updateSubscription(name: string): Promise<{ added: number }> {
return MOCK return MOCK
? mock.updateSubscription(name) ? mock().updateSubscription(name)
: req('api/subscription/update', { method: 'POST', body: JSON.stringify({ name }) }) : req('api/subscription/update', { method: 'POST', body: JSON.stringify({ name }) })
} }
@@ -1324,7 +1619,7 @@ export function updateSubscription(name: string): Promise<{ added: number }> {
export function getGroupsHealth( export function getGroupsHealth(
opts: { group?: string; members?: boolean } = {}, opts: { group?: string; members?: boolean } = {},
): Promise<GroupsHealth> { ): Promise<GroupsHealth> {
if (MOCK) return mock.getGroupsHealth(opts) if (MOCK) return mock().getGroupsHealth(opts)
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.group) p.set('group', opts.group) if (opts.group) p.set('group', opts.group)
if (opts.members) p.set('members', '1') if (opts.members) p.set('members', '1')
@@ -1333,8 +1628,21 @@ export function getGroupsHealth(
} }
/** /**
* One group's (or chain's) last test: which member the balancer picked, how fast * What the OBSERVATORY measured for one group or chain — not a dial the panel
* it answered, and what the internet saw as the source address. * made.
*
* This shape used to come from a fresh connection opened on demand, straight at
* the target. That was a lie on any router whose proxies are blocked when dialled
* directly and work only as a hop behind a tunnel: the card reported dead for a
* path that carries traffic all day. The daemon now has exactly one thing that
* measures — the background observatory, which probes along the REAL dial path,
* per-hop copies and all — and this endpoint reports what it found. There is no
* second measurement anywhere, and the panel never opens a connection of its own.
*
* So read the fields as a READ, not as a test run: `ok` and `delay_ms` are the
* observatory's verdict for the path traffic actually takes, and `tested_unix`
* (router clock, seconds) is when the OBSERVATORY took that measurement — which
* can be a few seconds before the refresh was asked for.
* *
* `ok:true` with an EMPTY `exit_ip`/`exit_country` is a valid, successful result, * `ok:true` with an EMPTY `exit_ip`/`exit_country` is a valid, successful result,
* not a partial failure: the delay was measured but the exit address could not be * not a partial failure: the delay was measured but the exit address could not be
@@ -1343,10 +1651,23 @@ export function getGroupsHealth(
* *
* Chains ride the same endpoint. For a chain row, `group` carries the CHAIN's * Chains ride the same endpoint. For a chain row, `group` carries the CHAIN's
* name and `selected` the node its last group hop picked ('' when the exit hop * name and `selected` the node its last group hop picked ('' when the exit hop
* isn't a group). Everything else reads the same way. * isn't a group). Per-hop detail is a different read: {@link ChainHopHealth}.
* *
* `ok:false` ⇒ the test failed and `error` carries the human reason; every other * `ok:false` ⇒ there is no usable measurement and `error` carries the human
* field is meaningless. `tested_unix` is the router's clock, in seconds. * reason; every other field is meaningless. Four of those reasons are about the
* observatory rather than the path, and must not be rendered as "your target is
* broken":
*
* "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"
* "the observatory's probe through this path failed"
*
* Only the last one is a health finding. The first three say the measurement
* does not exist, which is a different thing and a different fix.
*/ */
export interface GroupTestResult { export interface GroupTestResult {
group: string // group name — or a chain name for a chain row group: string // group name — or a chain name for a chain row
@@ -1363,7 +1684,11 @@ export interface GroupTestResult {
* GET /api/groups/test — progress plus every result so far. `results` is ALWAYS * GET /api/groups/test — progress plus every result so far. `results` is ALWAYS
* an array (never null); `done`/`total` count finished vs targeted groups and * an array (never null); `done`/`total` count finished vs targeted groups and
* chains while `running` is true. Idle reads `{running:false}` with the last * chains while `running` is true. Idle reads `{running:false}` with the last
* run's results still attached, so a reload after a test still shows what it found. * run's results still attached, so a reload still shows what was last read.
*
* "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.
*/ */
export interface GroupTestStatus { export interface GroupTestStatus {
running: boolean running: boolean
@@ -1396,18 +1721,24 @@ export interface GroupTestStart {
} }
/** /**
* POST /api/groups/test — measure a target's delay and exit address. Pass a * POST /api/groups/test — ask the observatory for an out-of-turn refresh pass,
* group or chain name to test one; pass nothing (or '') to test every group * then report what it measured. Pass a group or chain name to refresh one; pass
* and every chain. Singleton: a second call while a run is in flight resolves * nothing (or '') for every group and every chain.
* to `{started:false, reason:'already running'}` rather than failing. *
* 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.
*/ */
export function postGroupsTest(name = ''): Promise<GroupTestStart> { export function postGroupsTest(name = ''): Promise<GroupTestStart> {
return MOCK return MOCK
? mock.postGroupsTest(name) ? mock().postGroupsTest(name)
: req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) }) : req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) })
} }
/** GET /api/groups/test — progress + results of the current/last group test. */ /** GET /api/groups/test — progress + results of the current/last group test. */
export function getGroupsTest(): Promise<GroupTestStatus> { export function getGroupsTest(): Promise<GroupTestStatus> {
return MOCK ? mock.getGroupsTest() : req<GroupTestStatus>('api/groups/test') return MOCK ? mock().getGroupsTest() : req<GroupTestStatus>('api/groups/test')
} }
+15 -1
View File
@@ -1,4 +1,5 @@
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange. */ /* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange;
* .btn.crit is the solid-red destructive commit. */
.btn { .btn {
display: inline-block; display: inline-block;
padding: 7px 12px; padding: 7px 12px;
@@ -30,3 +31,16 @@
color: #fff; color: #fff;
filter: brightness(1.05); filter: brightness(1.05);
} }
/* Destructive commit. The fill is crit stepped a little toward black so white
* label text clears 4.5:1 in BOTH themes — the raw --crit is bright enough in
* dark mode to fall under it. Red here always means "this removes something". */
.btn.crit {
border-color: transparent;
background: color-mix(in srgb, var(--crit) 88%, #000);
color: #fff;
}
.btn.crit:hover {
color: #fff;
filter: brightness(1.08);
}
+15 -7
View File
@@ -1,19 +1,27 @@
import './Button.css' import './Button.css'
import { forwardRef } from 'react'
import type { ButtonHTMLAttributes } from 'react' import type { ButtonHTMLAttributes } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** `primary` is the solid-orange call to action; `ghost` is the default. */ /**
variant?: 'ghost' | 'primary' * `primary` is the solid-orange call to action; `crit` is the solid-red
* destructive commit (delete, remove) — semantic crit, never the accent;
* `ghost` is the default.
*/
variant?: 'ghost' | 'primary' | 'crit'
} }
export function Button({ variant = 'ghost', className, type, ...rest }: ButtonProps) { /** Ref-forwarding so a dialog can park focus on a specific button. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'ghost', className, type, ...rest },
ref,
) {
return ( return (
<button <button
ref={ref}
type={type ?? 'button'} type={type ?? 'button'}
className={['btn', variant === 'primary' ? 'primary' : '', className] className={['btn', variant === 'ghost' ? '' : variant, className].filter(Boolean).join(' ')}
.filter(Boolean)
.join(' ')}
{...rest} {...rest}
/> />
) )
} })
+49 -3
View File
@@ -1,11 +1,49 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
/**
* The panel's wall clock, in the SAME timezone as every timestamp under it.
*
* It used to read `getUTCHours()` and print "UTC", while `format.ts` renders every
* log line, connection event and date through `toLocaleTimeString` — i.e. the
* browser's zone. In Moscow that put two clocks three hours apart on one plate,
* and the header was the one nobody could reconcile: the router's "started" time
* read later than the current time while the uptime said it had been up for hours.
*
* So the clock follows the rest of the panel — local, and it SAYS which offset
* that is, because a bare "12:41:07" beside a router in another zone is the
* ambiguity that started this. The zone label is the browser's UTC offset, not an
* abbreviation: "MSK"/"CEST" are not derivable everywhere, an offset always is.
*
* This is the BROWSER's clock, not the router's — the appliance has no RTC. Every
* router-sourced instant in the panel is converted to this clock before it is
* shown, which is what makes one label at the top honest for the whole page.
*/
function zoneLabel(d: Date): string {
// getTimezoneOffset() is minutes WEST of UTC, so the sign is inverted.
const min = -d.getTimezoneOffset()
if (min === 0) return 'UTC'
const sign = min < 0 ? '−' : '+'
const a = Math.abs(min)
const h = Math.floor(a / 60)
const m = a % 60
return `UTC${sign}${h}${m ? `:${String(m).padStart(2, '0')}` : ''}`
}
function format(d: Date): string { function format(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0') const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} UTC` return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${zoneLabel(d)}`
} }
/** Live UTC readout, tabular digits, ticking once a second. */ /** The full zone name, for the title — "Europe/Moscow" says more than "+3" does. */
function zoneName(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
} catch {
return ''
}
}
/** Live local readout, tabular digits, ticking once a second. */
export function Clock({ className }: { className?: string }) { export function Clock({ className }: { className?: string }) {
const [now, setNow] = useState(() => format(new Date())) const [now, setNow] = useState(() => format(new Date()))
@@ -14,5 +52,13 @@ export function Clock({ className }: { className?: string }) {
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, []) }, [])
return <span className={['clock', className].filter(Boolean).join(' ')}>{now}</span> const zone = zoneName()
return (
<span
className={['clock', className].filter(Boolean).join(' ')}
title={zone ? `Your device's clock — ${zone}. Every time in the panel is shown in this zone.` : undefined}
>
{now}
</span>
)
} }
+162
View File
@@ -0,0 +1,162 @@
/* <ConfirmDialog> — the safety interlock plate.
*
* This replaces the browser's native confirm dialog, which a browser can mute for
* good ("prevent this page from creating additional dialogs"): after that it
* returns false with no dialog at all, so every delete button in the panel goes
* dead and silent with no way to recover short of a page reload. We draw the
* plate ourselves, so nothing can suppress it.
*
* Faceplate language: a small rack module lifted off the panel — corner screws
* (reused from Faceplate.css), an engraved label, a groove above the actions.
* Destructive intent is carried by the crit semantic, never by the orange accent:
* accent means "this control is active", crit means "this destroys something".
*/
/* The veil is a fixed dark wash in both themes — a light scrim over a light
* panel would not read as "the panel is out of reach". Follows the tokens.css
* pattern: light base, dark via media query, data-theme overrides win both ways. */
.cfm-scrim {
--cfm-veil: rgba(33, 29, 21, 0.52);
}
@media (prefers-color-scheme: dark) {
.cfm-scrim {
--cfm-veil: rgba(0, 0, 0, 0.66);
}
}
:root[data-theme='light'] .cfm-scrim {
--cfm-veil: rgba(33, 29, 21, 0.52);
}
:root[data-theme='dark'] .cfm-scrim {
--cfm-veil: rgba(0, 0, 0, 0.66);
}
.cfm-scrim {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
/* Short viewports: the plate scrolls with the veil instead of being clipped. */
overflow-y: auto;
padding: calc(var(--u, 8px) * 2);
background: var(--cfm-veil);
animation: cfm-veil-in 0.14s ease-out;
}
.cfm-card {
position: relative;
width: min(32rem, 100%);
max-height: calc(100dvh - var(--u, 8px) * 4);
overflow-y: auto;
padding: calc(var(--u, 8px) * 3.25);
border: 1px solid var(--groove);
border-radius: 12px;
/* same brushed plate as <Faceplate>, one step brighter so it reads as lifted */
background:
repeating-linear-gradient(
90deg,
transparent 0 2px,
color-mix(in srgb, var(--edge) 30%, transparent) 2px 3px
),
linear-gradient(180deg, var(--raised), color-mix(in srgb, var(--raised) 82%, var(--panel)));
box-shadow:
0 1px 0 var(--edge) inset,
0 30px 60px -22px var(--shadow),
0 4px 12px var(--shadow);
animation: cfm-card-in 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.cfm-card:focus {
outline: none;
}
/* `still` is set from usePrefersReducedMotion — the plate appears, it never
* travels. (The global reduced-motion rule in tokens.css also neutralises the
* duration; this keeps the intent explicit at the component.) */
.cfm-scrim.still,
.cfm-scrim.still .cfm-card {
animation: none;
}
@keyframes cfm-veil-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes cfm-card-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.99);
}
to {
opacity: 1;
transform: none;
}
}
/* ---- header: engraved label + state LED ---- */
.cfm-hd {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: calc(var(--u, 8px) * 1.5);
}
.cfm-label {
flex: 1;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: var(--track-label-wide, 0.24em);
color: var(--dim);
text-transform: uppercase;
}
/* ---- copy ---- */
.cfm-title {
margin: 0;
font-family: var(--font-mono);
font-weight: 700;
font-size: 17px;
line-height: 1.35;
color: var(--ink);
/* names can be long and unbroken — wrap rather than push the plate wide */
overflow-wrap: anywhere;
}
.cfm-body {
margin: calc(var(--u, 8px) * 1.5) 0 0;
max-width: 52ch;
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.6;
color: var(--dim);
overflow-wrap: anywhere;
}
/* ---- action bar ---- */
.cfm-actions {
display: flex;
justify-content: flex-end;
gap: calc(var(--u, 8px));
margin-top: calc(var(--u, 8px) * 3);
padding-top: calc(var(--u, 8px) * 2);
border-top: 1px solid var(--groove);
}
@media (max-width: 420px) {
.cfm-card {
padding: calc(var(--u, 8px) * 2.5);
}
.cfm-actions {
flex-wrap: wrap;
}
.cfm-actions .btn {
flex: 1 1 auto;
text-align: center;
}
/* screws crowd a small plate — drop them rather than collide with the copy */
.cfm-card > .screw {
display: none;
}
}
+281
View File
@@ -0,0 +1,281 @@
import './ConfirmDialog.css'
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button } from './Button'
import { Led } from './Led'
import { usePrefersReducedMotion } from './usePrefersReducedMotion'
/**
* How the confirming button is painted.
*
* crit — the action destroys something. Semantic crit, never the accent.
* neutral — the action is a normal commit the operator should read first
* (a warning before saving); the accent's call-to-action is correct.
*/
export type ConfirmTone = 'crit' | 'neutral'
export interface ConfirmOptions {
/** Engraved eyebrow, e.g. "DELETE RULE". Names the operation, not the object. */
label?: string
/** The question. One line, ends in "?". */
title: string
/** The consequence — what changes on the router if this goes through. */
body?: ReactNode
/** Verb on the confirming button. Defaults to "Delete". */
confirmLabel?: string
/** Verb on the dismissing button. Defaults to "Cancel". */
cancelLabel?: string
/** Defaults to `crit` — the overwhelmingly common case is a delete. */
tone?: ConfirmTone
}
export interface ConfirmDialogProps extends ConfirmOptions {
open: boolean
/** Called exactly once per dialog, with the operator's answer. */
onResolve: (confirmed: boolean) => void
}
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* The modal plate itself. Normally reached through `useConfirm()`; exported so a
* page that wants to own the open state can render it directly.
*
* Keyboard contract:
* - focus moves to Cancel on open, so a reflex Enter dismisses, never deletes;
* - Tab / Shift+Tab cycle inside the plate and cannot reach the page behind it;
* - Esc answers "no";
* - on close, focus returns to whatever opened the dialog.
*/
export function ConfirmDialog({
open,
onResolve,
label,
title,
body,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
tone = 'crit',
}: ConfirmDialogProps) {
const titleId = useId()
const bodyId = useId()
const cardRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const openerRef = useRef<HTMLElement | null>(null)
const reduced = usePrefersReducedMotion()
// Take the page out of the tab order, park focus on Cancel, and hand focus
// back to the opener when the plate goes away.
useEffect(() => {
if (!open) return
const opener = document.activeElement
openerRef.current = opener instanceof HTMLElement ? opener : null
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
// Cancel is the resting place: an Enter or a Space meant for the page lands
// on "no". The destructive button is one Tab away, deliberately.
;(cancelRef.current ?? cardRef.current)?.focus()
return () => {
document.body.style.overflow = prevOverflow
const back = openerRef.current
openerRef.current = null
if (back && document.contains(back)) back.focus()
}
}, [open])
// Esc answers no; Tab is caged. Capture phase so a page-level key handler
// never sees keys aimed at the dialog.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onResolve(false)
return
}
if (e.key !== 'Tab') return
const card = cardRef.current
if (!card) return
const list = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE))
if (list.length === 0) {
e.preventDefault()
card.focus()
return
}
const first = list[0]
const last = list[list.length - 1]
const active = document.activeElement as HTMLElement | null
if (!active || !card.contains(active)) {
e.preventDefault()
;(e.shiftKey ? last : first).focus()
} else if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [open, onResolve])
if (!open) return null
return createPortal(
<div
className={['cfm-scrim', reduced ? 'still' : ''].filter(Boolean).join(' ')}
// A click on the field around the plate means "not now". Mousedown (not
// click) so a text selection dragged out of the plate can't dismiss it.
onMouseDown={(e) => {
if (e.target === e.currentTarget) onResolve(false)
}}
>
<div
className={`cfm-card tone-${tone}`}
ref={cardRef}
tabIndex={-1}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={body != null ? bodyId : undefined}
>
<i className="screw tl" aria-hidden="true" />
<i className="screw tr" aria-hidden="true" />
<i className="screw bl" aria-hidden="true" />
<i className="screw br" aria-hidden="true" />
{/* Lamp first, then the engraved label — the way a real panel reads, and
it keeps the LED off the corner screw. */}
<div className="cfm-hd">
<Led variant={tone === 'crit' ? 'crit' : 'amber'} />
<span className="cfm-label">{label ?? (tone === 'crit' ? 'Confirm delete' : 'Confirm')}</span>
</div>
<h2 className="cfm-title" id={titleId}>
{title}
</h2>
{body != null && (
<p className="cfm-body" id={bodyId}>
{body}
</p>
)}
<div className="cfm-actions">
<Button ref={cancelRef} onClick={() => onResolve(false)}>
{cancelLabel}
</Button>
<Button variant={tone === 'crit' ? 'crit' : 'primary'} onClick={() => onResolve(true)}>
{confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
)
}
// ---- provider + hook --------------------------------------------------------
interface Request extends ConfirmOptions {
id: number
resolve: (v: boolean) => void
}
const ConfirmCtx = createContext<((o: ConfirmOptions) => Promise<boolean>) | null>(null)
/**
* Mount once at the app root. Everything below can then ask a question and await
* the answer.
*/
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [req, setReq] = useState<Request | null>(null)
const pending = useRef<Request | null>(null)
const seq = useRef(0)
const confirm = useCallback(
(opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
// A second question while one is open answers the first with "no" rather
// than leaving its promise — and its caller — hanging forever.
pending.current?.resolve(false)
seq.current += 1
const next: Request = { ...opts, id: seq.current, resolve }
pending.current = next
setReq(next)
}),
[],
)
const settle = useCallback((confirmed: boolean) => {
const open = pending.current
pending.current = null
setReq(null)
open?.resolve(confirmed)
}, [])
// Teardown must not strand a caller mid-await.
useEffect(
() => () => {
pending.current?.resolve(false)
pending.current = null
},
[],
)
// A question belongs to the page that asked it. The provider outlives the
// hash router, so a navigation would otherwise leave a stale plate floating
// over a page it has nothing to do with — answer it "no" and clear it.
useEffect(() => {
const onNav = () => {
if (pending.current) settle(false)
}
window.addEventListener('hashchange', onNav)
return () => window.removeEventListener('hashchange', onNav)
}, [settle])
return (
<ConfirmCtx.Provider value={confirm}>
{children}
{req !== null && <ConfirmDialog key={req.id} open onResolve={settle} {...req} />}
</ConfirmCtx.Provider>
)
}
/**
* Ask the operator, get a definite answer:
*
* const confirm = useConfirm()
* if (!(await confirm({ title: 'Delete rule "x"?', body: '…' }))) return
*
* The returned function is stable, so it is safe in a useCallback dep list. It
* always settles — cancel, Esc, click-outside and teardown all resolve `false`;
* only the confirming button resolves `true`.
*
* Name it `confirm` at the call site on purpose: the local binding shadows the
* global one inside that component, so an accidental bare `confirm(...)` cannot
* reach the suppressible native dialog.
*/
export function useConfirm(): (o: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmCtx)
if (!ctx) {
// Loud on purpose. A fallback that quietly resolved false would rebuild the
// exact bug this component exists to kill.
throw new Error('useConfirm() needs <ConfirmProvider> above it (mounted in main.tsx)')
}
return ctx
}
+2
View File
@@ -17,6 +17,8 @@ export { Button } from './Button'
export type { ButtonProps } from './Button' export type { ButtonProps } from './Button'
export { Select } from './Select' export { Select } from './Select'
export type { SelectProps, SelectOption } from './Select' export type { SelectProps, SelectOption } from './Select'
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog'
export type { ConfirmDialogProps, ConfirmOptions, ConfirmTone } from './ConfirmDialog'
export { Clock } from './Clock' export { Clock } from './Clock'
export { CatSuggest } from './CatSuggest' export { CatSuggest } from './CatSuggest'
export { SrcPicker } from './SrcPicker' export { SrcPicker } from './SrcPicker'
+127
View File
@@ -0,0 +1,127 @@
// findings.ts — which apply-time finding is shown where.
//
// Run with `npm test` (node's built-in test runner + native TypeScript
// stripping; no test dependency is added to the SPA, which ships inside the
// daemon binary).
//
// Two defects are pinned here.
//
// 1. THE TRUNCATION NOTE WAS UNREACHABLE. The daemon caps Status.warnings at 50
// and overwrites the last slot with an `info` note counting what it dropped.
// Overview filtered `info` away wholesale, and the settings-page route keys on
// a section (`generate`) that no page owns — so the single line telling the
// operator "you are not seeing all of it" reached no screen at all.
//
// 2. FINDINGS ABOUT AN ENTITY NEVER REACHED THAT ENTITY'S PAGE. The generator
// drops a node it cannot build and names it; the Nodes page rendered that node
// as an ordinary row with a green toggle, because it never read the findings.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
attentionFindings,
entityFindings,
findingsByName,
sectionNotes,
truncationNote,
worstSeverity,
} from './findings.ts'
import type { StatusWarning } from './api.ts'
const crit = (section: string, name: string, message = 'broken'): StatusWarning => ({
severity: 'critical',
section,
name,
message,
})
const warn = (section: string, name: string, message = 'degraded'): StatusWarning => ({
severity: 'warning',
section,
name,
message,
})
const info = (section: string, name: string, message: string): StatusWarning => ({
severity: 'info',
section,
name,
message,
})
/** Verbatim from apply/warnings.go finalizeWarnings. */
const SUPPRESSED = info(
'generate',
'',
'7 further warning(s) suppressed; run `logread -e shater` for the full list',
)
// --- the truncation note ----------------------------------------------------
test('the truncation note is found, whatever else is in the list', () => {
const note = truncationNote([crit('rule', 'a'), warn('node', 'b'), SUPPRESSED])
assert.notEqual(note, null)
assert.match(note!.message, /7 further warning/)
})
test('a whole list has no truncation note', () => {
assert.equal(truncationNote([crit('rule', 'a'), warn('node', 'b')]), null)
assert.equal(truncationNote([]), null)
assert.equal(truncationNote(undefined), null)
})
test('an ordinary info note is not mistaken for the truncation note', () => {
const notes = [info('untunnelable', 'block', 'Ping and traceroute do not work…')]
assert.equal(truncationNote(notes), null)
})
test('the truncation note is kept out of the settings-page notes it would pollute', () => {
const all = [info('generate', '', 'cache: moved to /overlay'), SUPPRESSED]
const notes = sectionNotes(all, 'generate')
assert.equal(notes.length, 1)
assert.match(notes[0].message, /cache:/)
})
test('the attention list still carries only critical and warning', () => {
const all = [crit('rule', 'a'), warn('node', 'b'), info('untunnelable', 'block', 'x'), SUPPRESSED]
const attention = attentionFindings(all)
assert.equal(attention.length, 2)
assert.ok(attention.every((w) => w.severity !== 'info'))
})
// --- per-entity findings ----------------------------------------------------
test('a page takes only the sections it owns', () => {
const all = [
crit('node', 'tokyo-01', 'parse share-link: bad scheme (skipped)'),
warn('subscription', 'qomar', 'fetch failed'),
crit('rule', 'default', 'never applies'),
info('generate', '', 'cache: x'),
]
const mine = entityFindings(all, ['node', 'subscription'])
assert.deepEqual(
mine.map((w) => w.name),
['tokyo-01', 'qomar'],
)
})
test('entity findings never include info notes', () => {
const all = [info('node', 'tokyo-01', 'just a note'), SUPPRESSED]
assert.equal(entityFindings(all, ['node', 'generate']).length, 0)
})
test('findings index by name, and global (unnamed) ones are left out', () => {
const all = [
crit('node', 'tokyo-01', 'first'),
warn('node', 'tokyo-01', 'second'),
crit('node', '', 'global to the section'),
]
const byName = findingsByName(entityFindings(all, ['node']))
assert.equal(byName.size, 1)
assert.equal(byName.get('tokyo-01')!.length, 2)
})
test('one lamp per row takes the loudest severity', () => {
assert.equal(worstSeverity([warn('node', 'a'), crit('node', 'a')]), 'critical')
assert.equal(worstSeverity([warn('node', 'a')]), 'warning')
assert.equal(worstSeverity([]), null)
})
+91 -2
View File
@@ -6,7 +6,8 @@
// //
// critical / warning — something needs attention: a protection promise is // critical / warning — something needs attention: a protection promise is
// broken, or something you configured isn't in effect. These belong on // broken, or something you configured isn't in effect. These belong on
// Overview, where the operator looks first. // Overview, where the operator looks first — and, when they name an entity,
// ALSO on the page that owns that entity (see `entityFindings`).
// //
// info — a statement ABOUT the configuration, not a problem. It never clears, // info — a statement ABOUT the configuration, not a problem. It never clears,
// because nothing is wrong: it is simply describing a choice that was made. // because nothing is wrong: it is simply describing a choice that was made.
@@ -16,9 +17,49 @@
// page that never goes away and never asks for anything trains people to skim // page that never goes away and never asks for anything trains people to skim
// the list — which is exactly how a real critical finding gets missed. Anything // the list — which is exactly how a real critical finding gets missed. Anything
// standing in the findings list should be something you could act on. // standing in the findings list should be something you could act on.
//
// The one exception is carved out below: the daemon's own note that it dropped
// findings to fit the cap. It is `info` by severity and unactionable by nature,
// and it is the single most important line in the list, because it is the list
// telling you it is not the whole list.
import type { StatusWarning } from './api' import type { StatusWarning } from './api'
/**
* The daemon's truncation disclosure, verbatim from apply/warnings.go
* finalizeWarnings:
*
* "%d further warning(s) suppressed; run `logread -e shater` for the full list"
*
* Matched on the stable clause rather than the whole sentence so a reworded tail
* still registers. If this ever stops matching, the failure mode is a list that
* silently claims to be complete — which is why `truncationNote` is tested.
*/
const SUPPRESSED_RE = /further warning\(s\) suppressed/
/**
* The daemon's "this list is incomplete" note, or null when the list is whole.
*
* Status.warnings is capped at 50, sorted critical-first, and the last slot is
* REPLACED by an `info` note counting what was dropped. That note therefore
* arrives on the one channel the panel filtered away wholesale: `info` never
* reached Overview, and the settings-page route (`sectionNotes`) keys on
* section `generate`, which no page owns. So the single line saying "there are
* findings you are not being shown" was the only one guaranteed to be invisible.
*
* Callers must render this WITH the attention list, not instead of it.
*/
export function truncationNote(warnings: StatusWarning[] | undefined): StatusWarning | null {
return (
(warnings ?? []).find((w) => w.severity === 'info' && SUPPRESSED_RE.test(w.message)) ?? null
)
}
/** Is this the truncation disclosure rather than an ordinary note? */
function isTruncationNote(w: StatusWarning): boolean {
return w.severity === 'info' && SUPPRESSED_RE.test(w.message)
}
/** Findings that need attention — the Overview list. Info notes are excluded. */ /** Findings that need attention — the Overview list. Info notes are excluded. */
export function attentionFindings(warnings: StatusWarning[] | undefined): StatusWarning[] { export function attentionFindings(warnings: StatusWarning[] | undefined): StatusWarning[] {
return (warnings ?? []).filter((w) => w.severity === 'critical' || w.severity === 'warning') return (warnings ?? []).filter((w) => w.severity === 'critical' || w.severity === 'warning')
@@ -29,10 +70,58 @@ export function attentionFindings(warnings: StatusWarning[] | undefined): Status
* (e.g. `untunnelable` → the Networks page's "Other traffic" section). Only info: * (e.g. `untunnelable` → the Networks page's "Other traffic" section). Only info:
* a critical/warning is an attention item and stays on Overview, so it can't be * a critical/warning is an attention item and stays on Overview, so it can't be
* quietly buried on a settings page instead. * quietly buried on a settings page instead.
*
* The truncation note is excluded: it is about the LIST, not about any section,
* and it has its own home beside the list ({@link truncationNote}).
*/ */
export function sectionNotes( export function sectionNotes(
warnings: StatusWarning[] | undefined, warnings: StatusWarning[] | undefined,
section: string, section: string,
): StatusWarning[] { ): StatusWarning[] {
return (warnings ?? []).filter((w) => w.severity === 'info' && w.section === section) return (warnings ?? []).filter(
(w) => w.severity === 'info' && w.section === section && !isTruncationNote(w),
)
}
/**
* The attention findings about entities ONE page owns — for that page to show
* beside the entities themselves.
*
* Overview is where you look when you already suspect something; a page like
* Nodes is where you look when you don't. The generator drops a node it cannot
* build — an unparseable share link, a WireGuard key materialised twice — and
* says so by name ("node \"x\": parse share-link: … (skipped)"), yet that node
* kept rendering as an ordinary row with a green toggle, because the page never
* read the findings at all. The switch says on; the engine has no such outbound.
*
* This does NOT move anything off Overview: the same finding appears in both
* places, which is correct — one list is "what is wrong with this router", the
* other is "what is wrong with this node".
*/
export function entityFindings(
warnings: StatusWarning[] | undefined,
sections: readonly string[],
): StatusWarning[] {
const want = new Set(sections)
return attentionFindings(warnings).filter((w) => want.has(w.section))
}
/** Index attention findings by entity name, for badging a row directly. Entries
* with an empty `name` are global to their section and are left out. */
export function findingsByName(findings: StatusWarning[]): Map<string, StatusWarning[]> {
const out = new Map<string, StatusWarning[]>()
for (const f of findings) {
if (!f.name) continue
const list = out.get(f.name)
if (list) list.push(f)
else out.set(f.name, [f])
}
return out
}
/** The loudest severity in a set — for a row badge that has room for one lamp. */
export function worstSeverity(findings: StatusWarning[]): 'critical' | 'warning' | null {
if (findings.some((f) => f.severity === 'critical')) return 'critical'
if (findings.length > 0) return 'warning'
return null
} }
+33
View File
@@ -77,6 +77,39 @@ export function fmtDateTime(unix: number): string {
return d && t ? `${d}, ${t}` : d || t return d && t ? `${d}, ${t}` : d || t
} }
// --- remote-list freshness ---------------------------------------------------
// A url/geo-sourced list re-fetches on a cadence and the engine reports when it
// last pulled (GET /api/ruleset/status). Routing shows this for rule-sets and DNS
// shows it for blocklists, so the two readings live here and cannot drift apart.
/** "updated 3h ago" / "never updated" for a remote list's last fetch (RFC3339). */
export function relFetch(iso: string): string {
if (!iso) return 'never updated'
const t = Date.parse(iso)
if (Number.isNaN(t)) return 'never updated'
const s = Math.max(0, Math.floor((Date.now() - t) / 1000))
if (s < 45) return 'updated just now'
const m = Math.floor(s / 60)
if (m < 60) return `updated ${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `updated ${h}h ago`
const d = Math.floor(h / 24)
return `updated ${d}d ago`
}
/** "every 24h" for an auto-update cadence in seconds ("" when there is none). */
export function everyLabel(sec: number): string {
if (!sec || sec <= 0) return ''
if (sec % 3600 === 0) {
const h = sec / 3600
if (h < 48) return `every ${h}h`
if (sec % 86400 === 0) return `every ${sec / 86400}d`
return `every ${h}h`
}
if (sec % 60 === 0) return `every ${sec / 60}m`
return `every ${sec}s`
}
/** /**
* A coarse "how long until / since" reading for a unix deadline, relative to now. * A coarse "how long until / since" reading for a unix deadline, relative to now.
* *
+26 -5
View File
@@ -2,12 +2,33 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './tokens.css' import './tokens.css'
import { App } from './App' import { App } from './App'
import { initMockBackend } from './api'
import { ConfirmProvider } from './components'
const rootEl = document.getElementById('root') const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('#root not found') if (!rootEl) throw new Error('#root not found')
createRoot(rootEl).render( // Settle the fixture question BEFORE the first render: pages read `MOCK` while
<StrictMode> // they render, so a backend that arrives afterwards would paint half a screen
<App /> // from the daemon and half from fixtures. In a production build this resolves
</StrictMode>, // immediately and to `false` — the fixtures are not in the bundle to load (see
) // api.ts initMockBackend and the assertNoMockFixtures plugin in vite.config.ts).
function mount() {
// ConfirmProvider sits ABOVE <App> so it survives App's early returns (the
// unauth / no-link plates) — useConfirm() can never find itself without a host.
createRoot(rootEl!).render(
<StrictMode>
<ConfirmProvider>
<App />
</ConfirmProvider>
</StrictMode>,
)
}
// A fixture module that fails to load is a broken dev checkout, not a reason to
// hand the operator a blank plate — mount anyway and let the shell report that it
// cannot reach a daemon, which by then is the truth.
void initMockBackend().then(mount, (e) => {
console.error('mock backend failed to load; continuing against the real API', e)
mount()
})
+327 -36
View File
@@ -6,7 +6,7 @@
// state mutates in-memory so the Apply / Confirm / Rollback flow is exercisable. // state mutates in-memory so the Apply / Confirm / Rollback flow is exercisable.
// //
// Type-only imports from api.ts (erased at build) keep this free of a runtime cycle. // Type-only imports from api.ts (erased at build) keep this free of a runtime cycle.
import type { ApplyResult, ChainHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, QueryLogEntry, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning } from './api' 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'
let armed = false // a pending commit-confirm auto-rollback let armed = false // a pending commit-confirm auto-rollback
let hasLastGood = false // a predecessor config exists to roll back to (post-apply) let hasLastGood = false // a predecessor config exists to roll back to (post-apply)
@@ -129,9 +129,25 @@ const CONFIG: Model = {
{ Name: 'via-tunnel', Source: 'subscription', Subscription: 'primary', Strategy: 'leastping', Egress: 'awg' }, { Name: 'via-tunnel', Source: 'subscription', Subscription: 'primary', Strategy: 'leastping', Egress: 'awg' },
{ Name: 'fallback', Source: 'subscription', Subscription: 'backup', Strategy: 'roundrobin', Egress: '' }, { Name: 'fallback', Source: 'subscription', Subscription: 'backup', Strategy: 'roundrobin', Egress: '' },
], ],
// One multi-hop chain so `?mock` exercises the chain card's Test button and // Three chains, one per state the hop readout has to render.
// its result readout: enters through the awg tunnel, exits via the auto group. Chains: [
Chains: [{ Name: 'relay', Hops: ['egress:awg', 'group:auto'] }], // The owner's real production shape: leave through a WAN interface, cross an
// AmneziaWG node, then three subscription groups in series. The leading
// `egress:` is NOT a numbered hop — the daemon lifts it into hop 1's entry
// detour — so this reports FOUR hops, and hop 3 is dead while its neighbours
// answer. That single red notch in the middle of a live path is the entire
// reason per-hop health exists, so `?mock` must show it at a glance.
{
Name: 'ewan-wg-subs',
Hops: ['egress:wan', 'node:home-wg', 'group:auto', 'group:stealth', 'group:via-tunnel'],
},
// Used, but the observatory hasn't come round yet — every hop untested. Not
// dead and not healthy: the state the panel most easily renders as a fault.
{ Name: 'sub-fresh', Hops: ['node:home-wg', 'group:fallback'] },
// No enabled rule targets it, so the observatory skips it entirely and the
// daemon never materialises its hops: `used:false` and NO `hops` key.
{ Name: 'relay', Hops: ['egress:awg', 'group:auto'] },
],
Egresses: [ Egresses: [
{ Name: 'wan', Type: 'interface', Interface: 'wan' }, { Name: 'wan', Type: 'interface', Interface: 'wan' },
// An AmneziaWG tunnel — the whole point of a group-level egress binding. // An AmneziaWG tunnel — the whole point of a group-level egress binding.
@@ -145,7 +161,20 @@ const CONFIG: Model = {
Rules: [ Rules: [
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' }, { Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
{ Name: 'ru-bypass', Enabled: true, Order: 20, DstRuleset: ['ru-inside'], Target: 'direct' }, { Name: 'ru-bypass', Enabled: true, Order: 20, DstRuleset: ['ru-inside'], Target: 'direct' },
// These two are what make the chains USED — the observatory probes only the
// paths an enabled rule can reach, so without them every chain card would
// read "not routed" and the hop rail would never appear in `?mock`. Kept
// ABOVE the condition-less rule at Order 40, which would otherwise swallow
// everything below it and mark them "never applies".
{ Name: 'media-via-chain', Enabled: true, Order: 22, DstRuleset: ['yt-geosite'], Target: 'chain:ewan-wg-subs' },
{ Name: 'spare-via-chain', Enabled: true, Order: 24, DstRuleset: ['ad-hosts'], Target: 'chain:sub-fresh' },
{ Name: 'private-direct', Enabled: true, Order: 30, DstRuleset: ['private-nets'], Target: 'direct' }, { Name: 'private-direct', Enabled: true, Order: 30, DstRuleset: ['private-nets'], Target: 'direct' },
// A SECOND condition-less rule, above the real default. It reads like a working
// rule and does nothing: a rule with no conditions becomes the router's default,
// and the last such rule by Order wins — so this one never applies. It is in the
// fixture on purpose, to exercise the "never applies" badge; the field config
// that prompted it had two rules BOTH named `default` (orders 20 and 100).
{ Name: 'default-bypass', Enabled: true, Order: 40, Target: 'direct' },
{ Name: 'default-tunnel', Enabled: true, Order: 900, Target: 'group:auto' }, { Name: 'default-tunnel', Enabled: true, Order: 900, Target: 'group:auto' },
], ],
// Named match-lists a rule points DstRuleset at. url + geosite + geoip are remote // Named match-lists a rule points DstRuleset at. url + geosite + geoip are remote
@@ -165,6 +194,7 @@ const CONFIG: Model = {
// to an official remote list; the others are the usual url / inline lists. // to an official remote list; the others are the usual url / inline lists.
Blocklists: [ Blocklists: [
{ Name: 'StevenBlack', Enabled: true, Source: 'url', URL: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', Response: 'nxdomain', UpdateInterval: '24h' }, { 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' }, { Name: 'telegram-block', Enabled: false, Source: 'geosite', Categories: ['telegram'], Response: 'nxdomain', UpdateInterval: '24h' },
], ],
Resolvers: [ Resolvers: [
@@ -294,8 +324,116 @@ const RULESET_STATUS: RulesetStatus[] = [
rule_count: 903, rule_count: 903,
}, },
{ tag: 'rs-ru-geoip-ru', name: 'ru-geoip', category: 'ru', kind: 'ruleset', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 }, { tag: 'rs-ru-geoip-ru', name: 'ru-geoip', category: 'ru', kind: 'ruleset', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
// Blocklists report through the same endpoint under `bl-<name>`, which the DNS
// page never asked for — so a list that has NEVER been fetched still read
// "filtering". StevenBlack is that case here; oisd-basic is the healthy one, so
// both readings are exercisable offline.
{ tag: 'bl-StevenBlack', name: 'StevenBlack', category: '', kind: 'blocklist', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
{
tag: 'bl-oisd-basic',
name: 'oisd-basic',
category: '',
kind: 'blocklist',
remote: true,
last_updated: new Date(Date.now() - 6 * 3600_000).toISOString(),
interval_seconds: 86_400,
rule_count: 218_431,
},
// 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 },
] ]
/** GET /api/rules/reachability. Mirrors the daemon's analysis over CONFIG.Rules:
* a rule with no conditions is the router's default, and the LAST such rule by
* Order wins — every earlier one can never apply. It reads the live CONFIG so
* edits made in `?mock` keep the badge honest.
*
* It also mirrors model.ResolveActiveProfile + ApplyProfileRuleOverrides, because
* `effective_enabled` is the whole point of the endpoint: CONFIG's `mobile-uplink`
* is active and both enables and disables rules, so `?mock` shows the same
* desired-vs-effective split the field config does. */
export async function getRulesReachability(): Promise<RulesReachability> {
await wait(60)
const rules = CONFIG.Rules ?? []
// A pin naming an existing, ENABLED profile wins outright. Otherwise auto-select:
// highest Priority among enabled profiles, ties by Name, skipping any with an
// iface condition (the WAN watcher owns those and expresses its verdict as the pin).
const profiles = CONFIG.Profiles ?? []
const pinned = String(CONFIG.Globals?.ActiveProfile ?? '').trim()
let prof: Profile | null = profiles.find((p) => p.Enabled && p.Name === pinned) ?? null
if (!prof) {
for (const p of profiles) {
if (!p.Enabled || (p.MatchIface ?? []).length > 0) continue
const pp = p.Priority ?? 0
const bp = prof?.Priority ?? 0
if (!prof || pp > bp || (pp === bp && p.Name < prof.Name)) prof = p
}
}
// Enable first, then Disable, so a name in both ends up disabled (Disable wins).
const effective = rules.map((r) => Boolean(r.Enabled))
if (prof) {
const force = (names: string[] | null | undefined, on: boolean) => {
for (const raw of names ?? []) {
const n = raw.trim()
rules.forEach((r, i) => {
if (r.Name === n) effective[i] = on
})
}
}
force(prof.EnableRules, true)
force(prof.DisableRules, false)
}
const activeProfile = prof
const out: RuleReach[] = rules.map((r, index) => ({
index,
name: String(r.Name ?? ''),
order: Number(r.Order ?? 0),
unreachable: false,
shadowed_by_index: -1,
effective_enabled: effective[index],
// Annotate only where the profile actually FLIPPED the outcome — a profile that
// disables an already-off rule has overridden nothing the operator can see.
...(activeProfile && effective[index] !== Boolean(r.Enabled)
? {
overridden_by: activeProfile.Name,
override: effective[index] ? ('enabled' as const) : ('disabled' as const),
}
: {}),
}))
const conditionless = (r: (typeof rules)[number]): boolean =>
!(r.Src ?? []).length &&
!(r.DstRuleset ?? []).length &&
!String(r.DstPort ?? '').trim() &&
!String(r.Proto ?? '').trim()
const target = (r: (typeof rules)[number]): string =>
String(r.Target ?? '').trim() || (r.Egress ? `egress:${String(r.Egress).trim()}` : '')
const defaults = rules
.map((r, index) => ({ r, index }))
// The EFFECTIVE flag, not the configured one: a rule the active profile
// switched off is not in force and cannot retire anything (model's
// RuleReachability runs over the effective set for the same reason).
.filter(({ r, index }) => effective[index] && conditionless(r) && target(r))
.sort((a, b) => Number(a.r.Order ?? 0) - Number(b.r.Order ?? 0) || a.index - b.index)
const winner = defaults[defaults.length - 1]
if (winner) {
for (const { index } of defaults.slice(0, -1)) {
out[index].unreachable = true
out[index].shadowed_by = String(winner.r.Name ?? '')
out[index].shadowed_by_index = winner.index
out[index].shadowed_by_order = Number(winner.r.Order ?? 0)
out[index].reason =
`this rule has no conditions, so it sets the default for all traffic — but rule ` +
`"${winner.r.Name}" (order ${winner.r.Order}) has none either and comes after it, so ` +
`"${target(winner.r)}" is the default the router uses and this rule's target ` +
`"${target(rules[index])}" is never applied`
}
}
return { rules: out }
}
export async function getRulesetStatus(): Promise<RulesetStatus[]> { export async function getRulesetStatus(): Promise<RulesetStatus[]> {
await wait(90) await wait(90)
return RULESET_STATUS.map((r) => ({ ...r })) return RULESET_STATUS.map((r) => ({ ...r }))
@@ -357,7 +495,20 @@ export async function getRulesetCategories(source: string): Promise<RulesetCateg
// ?mock&warn=1 → a full warning set (critical + warning + info) on top // ?mock&warn=1 → a full warning set (critical + warning + info) on top
// ?mock&ks=open → healthy plane but a FAIL-OPEN kill-switch, which is what // ?mock&ks=open → healthy plane but a FAIL-OPEN kill-switch, which is what
// makes the untunnelable policy inert (F8 case 4) // makes the untunnelable policy inert (F8 case 4)
function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSwitch: string } { // ?mock&traffic=… → with the plane FULL, where the traffic actually ends up:
// split | direct | blocked | blackout | unknown. `direct` is
// the field case the readout used to call "Protected" (one
// rule, `default → direct`); `unknown` is a daemon too old to
// report. Default: tunnel.
// ?mock&plane=unreported → a daemon that sends NO `plane` field. The panel then
// knows nothing about what is installed, which is the state
// the Kill-switch module used to render as a green "ARMED"
// (`undefined !== 'none'` is true).
function mockPlane(): {
plane: 'full' | 'hold' | 'none' | undefined
engine: boolean
killSwitch: string
} {
const q = typeof location === 'undefined' ? '' : location.search const q = typeof location === 'undefined' ? '' : location.search
const params = new URLSearchParams(q) const params = new URLSearchParams(q)
const killSwitch = params.get('ks') === 'open' ? 'open' : 'closed' const killSwitch = params.get('ks') === 'open' ? 'open' : 'closed'
@@ -365,9 +516,34 @@ function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSw
if (p === 'hold') return { plane: 'hold', engine: false, killSwitch: 'closed' } if (p === 'hold') return { plane: 'hold', engine: false, killSwitch: 'closed' }
if (p === 'none') return { plane: 'none', engine: false, killSwitch } if (p === 'none') return { plane: 'none', engine: false, killSwitch }
if (p === 'open') return { plane: 'none', engine: false, killSwitch: 'open' } if (p === 'open') return { plane: 'none', engine: false, killSwitch: 'open' }
if (p === 'unreported') return { plane: undefined, engine: true, killSwitch }
return { plane: 'full', engine: true, killSwitch } return { plane: 'full', engine: true, killSwitch }
} }
// The daemon's verdict on where traffic goes (apply.Status.traffic). Only
// meaningful with the plane installed: with the engine down there is no running
// config to judge, and the daemon reports the unknown/zero value — so do the same
// here rather than leaving a stale "tunnel" behind a dead engine.
function mockTraffic(plane: 'full' | 'hold' | 'none' | undefined): Traffic | undefined {
if (plane !== 'full') return { verdict: '', default: '', tunnel_rules: 0 }
const params = new URLSearchParams(typeof location === 'undefined' ? '' : location.search)
switch (params.get('traffic')) {
case 'split':
return { verdict: 'split', default: 'direct', tunnel_rules: 3 }
case 'direct':
return { verdict: 'direct', default: 'direct', tunnel_rules: 0 }
case 'blocked':
return { verdict: 'blocked', default: 'block', tunnel_rules: 2 }
case 'blackout':
return { verdict: 'blocked', default: 'block', tunnel_rules: 0 }
case 'unknown':
// A daemon that predates the field sends no `traffic` at all.
return undefined
default:
return { verdict: 'tunnel', default: 'auto', tunnel_rules: 1 }
}
}
const MOCK_WARNINGS: StatusWarning[] = [ const MOCK_WARNINGS: StatusWarning[] = [
{ {
severity: 'critical', severity: 'critical',
@@ -393,6 +569,29 @@ const MOCK_WARNINGS: StatusWarning[] = [
name: 'fakeip-pool', name: 'fakeip-pool',
message: 'fake-IP resolver cannot be used as a fallback; the failover chain was not built', message: 'fake-IP resolver cannot be used as a fallback; the failover chain was not built',
}, },
// Two findings the generator attributes to a NODE by name — the class that the
// Nodes page never showed, leaving a node the engine threw away rendered as an
// ordinary row with a green toggle. Both name real fixture nodes so the row
// badge, the collapsed-bucket "N flagged" count and the per-row strip all fire.
{
severity: 'warning',
section: 'node',
name: 'fi-trojan',
message: 'parse share-link: unsupported scheme "trojan+ws" (skipped)',
},
{
severity: 'warning',
section: 'node',
name: 'home-wg',
message:
'this WireGuard node is materialised twice in the engine config — as "home-wg" and as "group-stealth-m1-home-wg" — and traffic can reach both. A WireGuard peer keeps ONE session per public key, so two devices built from one private key evict each other continuously and NEITHER tunnel passes traffic. Only "home-wg" is kept; everything that routed through "group-stealth-m1-home-wg" is fail-closed (blocked) instead of leaving over the plain WAN',
},
{
severity: 'warning',
section: 'subscription',
name: 'backup',
message: 'fetch failed: dial tcp 203.0.113.9:443: i/o timeout — serving the nodes cached earlier',
},
{ {
severity: 'info', severity: 'info',
section: 'generate', section: 'generate',
@@ -401,6 +600,19 @@ const MOCK_WARNINGS: StatusWarning[] = [
}, },
] ]
/**
* The daemon's truncation disclosure, exactly as apply/warnings.go writes it when
* the published set overflows the 50-entry cap. Served under `?mock&trunc` so the
* "this list is incomplete" rendering is exercisable — it used to be dropped
* wholesale by the panel's `info` filter and reached no screen at all.
*/
const MOCK_TRUNCATION: StatusWarning = {
severity: 'info',
section: 'generate',
name: '',
message: '7 further warning(s) suppressed; run `logread -e shater` for the full list',
}
/** /**
* The standing `untunnelable` note the daemon reports. It is INFO, never a * The standing `untunnelable` note the daemon reports. It is INFO, never a
* problem: it states a correct, chosen configuration. Two shapes, mirroring the * problem: it states a correct, chosen configuration. Two shapes, mirroring the
@@ -447,9 +659,12 @@ function mockWarnings(killSwitch: string): StatusWarning[] {
const params = new URLSearchParams(q) const params = new URLSearchParams(q)
const mode = (CONFIG.Globals as { Untunnelable?: string }).Untunnelable ?? 'block' const mode = (CONFIG.Globals as { Untunnelable?: string }).Untunnelable ?? 'block'
const notes = untunnelableNote(mode, killSwitch) const notes = untunnelableNote(mode, killSwitch)
// `?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 }] : []
// A degraded plane always comes with the findings that explain it. // A degraded plane always comes with the findings that explain it.
if (params.has('warn') || params.get('plane')) { if (params.has('warn') || params.get('plane') || trunc.length > 0) {
return [...MOCK_WARNINGS.map((w) => ({ ...w })), ...notes] return [...MOCK_WARNINGS.map((w) => ({ ...w })), ...notes, ...trunc]
} }
return notes return notes
} }
@@ -470,6 +685,7 @@ export async function getStatus(): Promise<Status> {
can_rollback: armed || hasLastGood, can_rollback: armed || hasLastGood,
engine_running: engine, engine_running: engine,
plane, plane,
traffic: mockTraffic(plane),
warnings: mockWarnings(killSwitch), warnings: mockWarnings(killSwitch),
// Process uptime. Anchored to when this tab loaded plus a fixed head start, so // Process uptime. Anchored to when this tab loaded plus a fixed head start, so
// the reading ticks forward across polls exactly like the real daemon's does. // the reading ticks forward across polls exactly like the real daemon's does.
@@ -1046,20 +1262,59 @@ function healthList(): GroupHealth[] {
return (CONFIG.Groups ?? []).map((g) => summarise(g.Name, GROUP_MEMBERS.get(g.Name) ?? [])) return (CONFIG.Groups ?? []).map((g) => summarise(g.Name, GROUP_MEMBERS.get(g.Name) ?? []))
} }
/** Per-chain reachability for the Targets page's "unused" badge (plan §5.E) — the /**
* chain analogue of healthList's `used` field. The mock's single chain `relay` is * Per-hop health, keyed by chain name — what the observatory measured at each
* NOT referenced by any rule in CONFIG.Rules (they target group:auto / block / * position of the path, in WIRE order.
* direct), so it reads used=false and its card renders "unused" — exactly the case *
* the badge exists to surface. A stopped engine reports no chains. */ * `ewan-wg-subs` is the fixture that matters, and it encodes the ORDERED WALK.
* Hop 1 is the WireGuard node and answers; hop 2 is a subscription group whose
* copies answer THROUGH it — 119 of 122 tested alive, which is the reading only a
* per-hop probe can produce, since the same members are dialled differently on
* their own card. Hop 3 is a group whose members all time out at that position,
* and the walk STOPS there: hop 4 is dialled through hop 3, so it was never
* dialled at all. It comes back `untested` with `blocked_by` naming hop 3, its
* counters zeroed, and `selected` still set — the wrapper has a pick, nothing
* crossed it to measure. A dead hop with a live hop under it is not in this
* fixture because the daemon can no longer produce one.
*
* `sub-fresh` is used but never yet reached: every hop untested, nothing dead,
* no block — the other reason a lamp is unlit, and the one that fixes itself.
* `relay` is absent from this map on purpose — an unused chain is never
* materialised, so the daemon sends no `hops` key at all, which is "nothing
* measured", not "no hops".
*/
const CHAIN_HOPS: Record<string, ChainHopHealth[]> = {
'ewan-wg-subs': [
{ index: 1, tag: 'chain-ewan-wg-subs-h1', kind: 'node', exit: false, state: 'alive', delay_ms: 41, age_seconds: 22, selected: '', total: 1, tested: 1, alive: 1, dead: 0, untested: 0 },
{ index: 2, tag: 'chain-ewan-wg-subs-h2', kind: 'group', exit: false, state: 'alive', delay_ms: 96, age_seconds: 18, selected: '🇳🇱 Amsterdam-01', total: 298, tested: 122, alive: 119, dead: 3, untested: 176 },
{ index: 3, tag: 'chain-ewan-wg-subs-h3', kind: 'group', exit: false, state: 'dead', delay_ms: 0, age_seconds: 15, selected: '', total: 2, tested: 2, alive: 0, dead: 2, untested: 0 },
{ index: 4, tag: 'chain-ewan-wg-subs-h4', kind: 'group', exit: true, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '🇸🇬 Singapore-09', total: 6, tested: 0, alive: 0, dead: 0, untested: 6, blocked_by: { index: 3, tag: 'chain-ewan-wg-subs-h3' } },
],
'sub-fresh': [
{ index: 1, tag: 'chain-sub-fresh-h1', kind: 'node', exit: false, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '', total: 1, tested: 0, alive: 0, dead: 0, untested: 1 },
{ index: 2, tag: 'chain-sub-fresh-h2', kind: 'group', exit: true, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '', total: 24, tested: 0, alive: 0, dead: 0, untested: 24 },
],
}
/** Per-chain reachability plus per-hop health for the Targets page. `used` is the
* chain analogue of healthList's field; `hops` is OMITTED (never null, never []),
* exactly like the daemon, for a chain the engine never materialised. A stopped
* engine reports no chains at all. */
function chainHealthList(): ChainHealth[] { function chainHealthList(): ChainHealth[] {
if (!mockPlane().engine) return [] if (!mockPlane().engine) return []
return (CONFIG.Chains ?? []).map((c) => ({ name: c.Name, used: chainUsed(c.Name) })) return (CONFIG.Chains ?? []).map((c) => {
const hops = CHAIN_HOPS[c.Name]
const h: ChainHealth = { name: c.Name, used: chainUsed(c.Name) }
if (hops) h.hops = hops.map((x) => ({ ...x }))
return h
})
} }
/** A chain is "used" when some enabled routing rule (or Final, or a DNS detour) /** A chain is "used" when some enabled routing rule (or Final, or a DNS detour)
* targets `chain:<name>` — the same reachability the daemon's observatory derives. * targets `chain:<name>` — the same reachability the daemon's observatory derives.
* The mock's rules never target a chain, so every chain reads used=false; a real * Two of the mock's rules do (`media-via-chain` → ewan-wg-subs, `spare-via-chain`
* config would mark the ones rules point at used=true. */ * → sub-fresh), so those two chains read used=true and get a hop rail; `relay`
* is targeted by nothing and reads used=false, which is the unused note. */
function chainUsed(name: string): boolean { function chainUsed(name: string): boolean {
const target = `chain:${name}` const target = `chain:${name}`
return (CONFIG.Rules ?? []).some( return (CONFIG.Rules ?? []).some(
@@ -1111,15 +1366,23 @@ class ApiErrorLike extends Error {
} }
} }
// Mock group/chain test. Deliberately covers every state the UI has to render, // Mock refresh results. The endpoint no longer dials anything: it asks the
// one per target, so a single offline run exercises all of them: // observatory to measure out of turn and reports what the observatory found, so
// auto → ok WITH an exit address // every row here is a READ of a background measurement. Deliberately covers every
// stealth → ok WITHOUT one (delay measured, address undeterminable) — a // state the UI has to render, one per target, so a single offline run exercises
// SUCCESS, and the case the UI most easily gets wrong // all of them:
// relay → the chain: same wire shape, `group` carries the CHAIN's name and // auto → ok WITH an exit address
// `selected` the node its exit group picked // stealth → ok WITHOUT one (delay measured, address undeterminable) — a
// fallback → a failure carrying a human reason // SUCCESS, and the case the UI most easily gets wrong
// Results land one per GET poll, so the running/progress state is visible too. // ewan-wg-subs → the chain: same wire shape, `group` carries the CHAIN's name
// and `selected` the node its exit hop picked
// via-tunnel → the one honest health FAILURE: a probe that ran and failed
// fallback,
// relay → not routed at all, so no measurement exists to report
// sub-fresh → routed, but the observatory hasn't come round yet
// 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.
const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = { const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = {
auto: { auto: {
selected: 'nl-reality-2', selected: 'nl-reality-2',
@@ -1137,32 +1400,60 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
ok: true, ok: true,
error: '', error: '',
}, },
// The chain — Selected is the node the chain's exit group (auto) picked. // The chain, and the pairing that makes the whole feature worth building. A
relay: { // chain is one series path, so with hop 3 dead the end-to-end probe is never
selected: 'nl-reality-2', // even attempted — the daemon stops walking there. This row and the hop rail
delay_ms: 61, // therefore have to tell one story, not two: both name hop 3, and neither
exit_ip: '185.12.34.56', // offers hop 4 as a second suspect. Note the row does NOT say "the probe
exit_country: 'NL', // failed" — no probe of this chain's exit ran at all — which is why the daemon
ok: true, // has a separate message for it.
error: '', 'ewan-wg-subs': {
selected: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error:
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
}, },
// Dead through its tunnel, exactly as its membership health says — the exit // The one real health failure in the fixture: the observatory's probe ran along
// test and the member health tell the same story about the same group. // this path and did not come back.
'via-tunnel': { 'via-tunnel': {
selected: '', selected: '',
delay_ms: 0, delay_ms: 0,
exit_ip: '', exit_ip: '',
exit_country: '', exit_country: '',
ok: false, ok: false,
error: 'no member answered through egress awg (6 of 6 timed out)', error: 'the observatory’s probe through this path failed',
}, },
// Not a health verdict — nothing routes here, so no measurement of it exists.
fallback: { fallback: {
selected: '', selected: '',
delay_ms: 0, delay_ms: 0,
exit_ip: '', exit_ip: '',
exit_country: '', exit_country: '',
ok: false, ok: false,
error: 'no reachable node in the group (all 3 members timed out)', error:
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
},
relay: {
selected: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error:
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
},
// Routed, materialised, simply not reached yet. Untested is not dead.
'sub-fresh': {
selected: '',
delay_ms: 0,
exit_ip: '',
exit_country: '',
ok: false,
error:
'the observatory has not reached this target yet — it refreshes on the global probe interval',
}, },
} }
+443
View File
@@ -0,0 +1,443 @@
/* Alerts section (rendered on Settings) — inherits the Faceplate tokens and the
* shared page chrome from App.css (.toast, .mono). Every rule below is a
* one-to-one copy of the DNS.css rule the markup used before the section moved
* here, renamed `dns-*` → `alr-*` so nothing collides. Orange stays an accent. */
/* ---- section shell (matches the Settings group plates one-to-one) ---- */
.alr-section {
margin-top: calc(var(--u, 8px) * 3.5);
}
.alr-sec-hd {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--groove);
}
.alr-sec-title {
margin: 0;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--dim);
}
.alr-sec-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--faint);
}
.alr-sec-note {
margin: 10px 2px 0;
font-family: var(--font-sans);
font-size: 12.5px;
line-height: 1.55;
color: var(--dim);
max-width: 56ch;
}
/* ---- add form ---- */
.alr-add {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: calc(var(--u, 8px) * 2);
}
.alr-add-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.alr-input {
min-width: 0;
padding: 9px 12px;
border: 1px solid var(--groove);
border-radius: 7px;
background: var(--sink);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px var(--shadow) inset;
transition: border-color 0.15s, box-shadow 0.15s;
}
.alr-input::placeholder {
color: var(--faint);
}
.alr-input:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.alr-input:disabled {
opacity: 0.55;
}
.alr-input--name {
flex: 0 1 14rem;
}
/* segmented type picker */
.alr-seg {
display: inline-flex;
border: 1px solid var(--groove);
border-radius: 7px;
overflow: hidden;
background: var(--sink);
}
.alr-seg-btn {
padding: 8px 14px;
border: 0;
background: transparent;
color: var(--dim);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.alr-seg-btn + .alr-seg-btn {
border-left: 1px solid var(--groove);
}
.alr-seg-btn.on {
background: var(--accent);
color: #fff;
}
.alr-seg-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.alr-resp {
display: inline-flex;
align-items: center;
gap: 8px;
}
.alr-resp-label {
font-size: 10px;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--faint);
}
.alr-select {
padding: 8px 10px;
border: 1px solid var(--groove);
border-radius: 7px;
background: var(--sink);
color: var(--ink);
font-family: var(--font-mono);
font-size: 11.5px;
letter-spacing: 0.04em;
cursor: pointer;
}
.alr-select:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.alr-add-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.alr-field-err {
flex: 1;
min-width: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.5;
color: var(--crit);
}
/* ---- rows ---- */
.alr-rows {
list-style: none;
margin: calc(var(--u, 8px) * 2) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.alr-row {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
padding: 12px 14px;
border: 1px solid var(--groove);
border-radius: 8px;
background: linear-gradient(
180deg,
var(--raised),
color-mix(in srgb, var(--raised) 82%, var(--panel))
);
box-shadow: 0 1px 0 var(--edge) inset;
}
.alr-row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.alr-row-l1 {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.alr-row-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 24ch;
}
.alr-row-l2 {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 11.5px;
letter-spacing: 0.02em;
}
.alr-row-detail {
color: var(--dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40ch;
}
/* badge — groove-bordered, not orange (accent stays reserved) */
.alr-badge {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--groove);
border-radius: 5px;
background: color-mix(in srgb, var(--sink) 60%, transparent);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--dim);
white-space: nowrap;
}
.alr-badge--accent {
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
color: var(--accent);
}
.alr-masked {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--faint);
text-transform: uppercase;
cursor: help;
}
.alr-del {
flex: none;
padding: 6px 12px;
font-size: 10.5px;
}
/* ---- empty plate ---- */
.alr-empty {
margin-top: calc(var(--u, 8px) * 2);
padding: calc(var(--u, 8px) * 3);
border: 1px dashed var(--groove);
border-radius: 9px;
background: color-mix(in srgb, var(--raised) 55%, transparent);
text-align: center;
}
.alr-empty-title {
display: block;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--dim);
}
.alr-empty-body {
margin: 8px auto 0;
max-width: 48ch;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
color: var(--dim);
}
/* ---- loading skeleton ---- */
.alr-skel {
height: 62px;
border: 1px solid var(--groove);
border-radius: 8px;
background: linear-gradient(90deg, var(--raised), var(--sink), var(--raised));
background-size: 200% 100%;
animation: alr-skel-shift 1.4s ease-in-out infinite;
}
@keyframes alr-skel-shift {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* the per-row delivery picker sits inline in the row */
.alr-detour {
flex: none;
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.alr-detour-label {
font-size: 10px;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--faint);
}
.alr-detour-select {
max-width: 22rem;
}
/* current delivery-path readout on the row */
.alr-path {
color: var(--faint);
white-space: nowrap;
}
.alr-path[data-active='on'] {
color: var(--dim);
}
.alr-path-name {
color: var(--led-on);
font-weight: 600;
}
.alr-path[data-missing='y'] .alr-path-name {
color: var(--amber);
}
.alr-path-flag {
color: var(--amber);
}
/* alert delivery: deliver-via picker + fallback toggle + caution note */
.alr-delivery {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 20px;
}
.alr-fallback {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.alr-fallback-label {
font-size: 10px;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--faint);
}
/* the per-row delivery controls sit inline in the row (like .alr-detour) */
.alr-ctl {
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.alr-note {
margin: 0;
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.5;
color: var(--amber);
max-width: 56ch;
}
.alr-note--row {
margin-top: 2px;
}
/* alert event checkboxes */
.alr-events {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0;
padding: 0;
border: 0;
}
.alr-event {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fp-text, inherit);
cursor: pointer;
}
.alr-event input {
accent-color: var(--fp-accent, currentColor);
}
/* ---- responsive ---- */
@media (max-width: 640px) {
.alr-row {
flex-wrap: wrap;
}
.alr-row-main {
flex-basis: calc(100% - 90px);
}
.alr-del {
margin-left: auto;
}
.alr-input--name {
flex-basis: 100%;
}
.alr-detour {
flex-basis: 100%;
order: 3;
flex-wrap: wrap;
}
/* A <select> won't shrink below its widest option unless it's allowed to:
without min-width:0 the long detour labels push the page into a horizontal
scroll at 390px. Let them fill the row and clip instead. */
.alr-detour-select,
.alr-resp .alr-select {
max-width: 100%;
width: 100%;
min-width: 0;
}
.alr-resp {
display: flex;
flex-wrap: wrap;
max-width: 100%;
}
.alr-ctl {
flex-basis: 100%;
order: 3;
}
}
@media (prefers-reduced-motion: reduce) {
.alr-skel {
animation: none;
}
.alr-input,
.alr-seg-btn {
transition: none;
}
}
+690
View File
@@ -0,0 +1,690 @@
import './Alerts.css'
import { useCallback, useMemo, useState } from 'react'
import { Button, Toggle, useConfirm } from '../components'
import type { Alert, Model } from '../api'
// The Alerts section — out-of-band notifications (Telegram bot / webhook) for
// kill-switch trips, apply failures, new devices and subscription expiry. It
// lived at the bottom of the DNS page, which is the last place an operator
// looking for "tell me when the tunnel dies" would think to look; it now renders
// as a group on Settings. The component owns no I/O: every mutation goes through
// the `onSave` prop so Settings keeps a single dirty banner and a single toast.
//
// NOTE on duplication: the detour helpers below (DetourCatalog, canonDetour,
// detourValues, describeDetour, DetourSelect) plus asArray / uniqueName /
// maskUrl / EmptyPlate are deliberate copies of the ones in DNS.tsx. DNS keeps
// its own for resolvers and DNS rules; extracting a shared module would couple
// two pages that otherwise share nothing, and that refactor is out of scope
// here. If a third consumer ever appears, promote them then.
// ---- local Model extension --------------------------------------------------
/** The Model with the Alerts slice surfaced (index-signature passthrough). */
type AlertsModel = Model & { Alerts?: Alert[] | null }
/**
* Every event the daemon actually sends. A retired health-probe event was left
* out on purpose: nothing ever fired it, so a channel that subscribed to it would
* just stay quiet forever — the one failure mode an alert must not have. Only
* events with a live firing path are offered here.
*/
const ALERT_EVENTS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'killswitch', label: 'Kill-switch' },
{ id: 'apply_fail', label: 'Apply failure' },
{ id: 'new_device', label: 'New device' },
{ id: 'sub_expiry', label: 'Subscription expiring' },
]
// Shown when an alert routes through a detour with no direct fallback — the exact
// case where a tunnel-down alert could fail to send. The user asked for this.
const VIA_NO_FALLBACK_NOTE =
'A kill-switch/tunnel-down alert may not send if it routes through the affected tunnel — enable fallback.'
// ---- helpers (copies of DNS.tsx — see the header 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 {
const u = new URL(url)
return { host: u.host, masked: u.search !== '' || u.pathname.replace(/\/+$/, '') !== '' }
} catch {
return { host: url || '—', masked: false }
}
}
function uniqueName(base: string, taken: Set<string>): string {
const seed = base.trim() || 'alert'
if (!taken.has(seed)) return seed
let i = 2
while (taken.has(`${seed}-${i}`)) i++
return `${seed}-${i}`
}
/** The live targets an alert's delivery can be pinned to (the picker). */
interface DetourCatalog {
groups: string[]
chains: string[]
egresses: { name: string; type: string }[]
nodes: string[]
}
/**
* Normalise a stored `Via` to a picker option value. Empty/`direct` ⇒
* `direct`; already-prefixed values (`group:`/`chain:`/`egress:`/`node:`) pass
* through; a bare legacy name is resolved against the catalog so a still-valid
* setup isn't mislabelled; anything unresolved is kept verbatim (shown stale).
*/
function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
const d = (raw ?? '').trim()
if (!d || d.toLowerCase() === 'direct') return 'direct'
if (/^(node|group|chain|egress):/i.test(d)) return d
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
if (cat.groups.includes(d)) return `group:${d}`
if (cat.chains.includes(d)) return `chain:${d}`
if (cat.nodes.includes(d)) return `node:${d}`
return d
}
/** Every valid option value for a catalog, including `direct`. */
function detourValues(cat: DetourCatalog): Set<string> {
const s = new Set<string>(['direct'])
for (const g of cat.groups) s.add(`group:${g}`)
for (const c of cat.chains) s.add(`chain:${c}`)
for (const e of cat.egresses) s.add(`egress:${e.name}`)
for (const n of cat.nodes) s.add(`node:${n}`)
return s
}
/** Describe a canonical detour value for the row readout. */
function describeDetour(
canon: string,
cat: DetourCatalog,
valid: Set<string>,
): { direct: boolean; prefix: string; name: string; missing: boolean } {
if (canon === 'direct') return { direct: true, prefix: '', name: '', missing: false }
const i = canon.indexOf(':')
const kind = i === -1 ? '' : canon.slice(0, i)
const name = i === -1 ? canon : canon.slice(i + 1)
const missing = !valid.has(canon)
let prefix = 'via'
if (kind === 'group') prefix = 'via group'
else if (kind === 'chain') prefix = 'via chain'
else if (kind === 'node') prefix = 'via node'
else if (kind === 'egress') {
const eg = cat.egresses.find((e) => e.name === name)
prefix = eg?.type === 'interface' ? 'via interface' : 'via egress'
}
return { direct: false, prefix, name, missing }
}
// ---- section ----------------------------------------------------------------
export function AlertsSection({
config,
busy,
loading,
onSave,
}: {
/** Full desired-state model; null until it has loaded. */
config: Model | null
/** A save/apply is in flight — controls lock. */
busy: boolean
/** The config is still loading — show a skeleton row. */
loading: boolean
/** Persist the whole next model; resolves true on success (Settings' `save`). */
onSave: (next: Model, okMsg: string) => Promise<boolean>
}): JSX.Element {
const confirm = useConfirm()
const model = config as AlertsModel | null
const alerts = useMemo<Alert[]>(() => asArray(model?.Alerts), [model])
// Alerts route through Direct/group/node/egress only (no chains) — the contract
// vocabulary for Alert.Via. Built straight from the Model with chains dropped.
const alertCatalog = useMemo<DetourCatalog>(
() => ({
groups: asArray(config?.Groups).map((g) => g.Name),
chains: [],
egresses: asArray(config?.Egresses).map((e) => ({ name: e.Name, type: e.Type })),
nodes: asArray(config?.Nodes).map((n) => n.Name),
}),
[config],
)
const alertValid = useMemo(() => detourValues(alertCatalog), [alertCatalog])
const alertNames = useMemo(() => new Set(alerts.map((a) => a.Name)), [alerts])
const alertsOn = alerts.filter((a) => a.Enabled).length
// ---- mutations — all writes go through onSave -----------------------------
const addAlert = useCallback(
(draft: Alert): Promise<boolean> => {
if (!model) return Promise.resolve(false)
const taken = new Set(alerts.map((a) => a.Name))
const a: Alert = { ...draft, Name: uniqueName(draft.Name, taken) }
return onSave({ ...model, Alerts: [...alerts, a] }, `Added ${a.Name}`)
},
[model, alerts, onSave],
)
const toggleAlert = useCallback(
(idx: number, on: boolean) => {
if (!model) return
const next = alerts.map((a, i) => (i === idx ? { ...a, Enabled: on } : a))
void onSave({ ...model, Alerts: next }, `${next[idx].Name} ${on ? 'enabled' : 'disabled'}`)
},
[model, alerts, onSave],
)
const removeAlert = useCallback(
async (idx: number) => {
if (!model) return
const target = alerts[idx]
const ok = await confirm({
label: 'Delete alert',
title: `Delete alert “${target.Name}”?`,
body: 'This removes it from the config.',
})
if (!ok) return
const next = alerts.filter((_, i) => i !== idx)
void onSave({ ...model, Alerts: next }, `Deleted ${target.Name}`)
},
[model, alerts, onSave, confirm],
)
const setAlertVia = useCallback(
(idx: number, v: string) => {
if (!model) return
const via = v === 'direct' ? '' : v
const next = alerts.map((a, i) => (i === idx ? { ...a, Via: via || undefined } : a))
void onSave(
{ ...model, Alerts: next },
via ? `${next[idx].Name} delivers via ${via}` : `${next[idx].Name} delivers direct`,
)
},
[model, alerts, onSave],
)
const setAlertFallback = useCallback(
(idx: number, on: boolean) => {
if (!model) return
const next = alerts.map((a, i) => (i === idx ? { ...a, Fallback: on || undefined } : a))
void onSave(
{ ...model, Alerts: next },
`${next[idx].Name} direct fallback ${on ? 'on' : 'off'}`,
)
},
[model, alerts, onSave],
)
return (
<div className="alr-section" aria-label="Alerts">
<header className="alr-sec-hd">
<h2 className="alr-sec-title">Alerts</h2>
<span className="alr-sec-count mono">
{alertsOn} / {alerts.length} on
</span>
</header>
<p className="alr-sec-note">
Out-of-band notifications. Delivered <strong>direct to the internet</strong> by default — so a
kill-switch or engine-down alert still reaches you when the proxy is down. You can route one
through a group, node or egress instead, with a direct fallback if that detour fails.
</p>
<AddAlertForm
busy={busy}
disabled={!config}
taken={alertNames}
catalog={alertCatalog}
valid={alertValid}
onAdd={addAlert}
/>
{loading ? (
<ul className="alr-rows" aria-hidden="true">
<li className="alr-skel" />
</ul>
) : alerts.length === 0 ? (
<EmptyPlate
title="No alerts"
body="Add a Telegram bot or a webhook above to get notified when the kill-switch trips, a new device joins, or an apply fails."
/>
) : (
<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)}
/>
))}
</ul>
)}
</div>
)
}
// ---- alert add form + row ----------------------------------------------------
function AddAlertForm({
busy,
disabled,
taken,
catalog,
valid,
onAdd,
}: {
busy: boolean
disabled: boolean
taken: Set<string>
catalog: DetourCatalog
valid: Set<string>
onAdd: (a: Alert) => Promise<boolean>
}) {
const [name, setName] = useState('')
const [type, setType] = useState<'telegram' | 'webhook'>('telegram')
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 [err, setErr] = useState<string | null>(null)
const reset = () => {
setName('')
setType('telegram')
setToken('')
setChatId('')
setUrl('')
setEvents(['killswitch'])
setVia('direct')
setFallback(false)
}
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.')
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 routed = via !== 'direct'
return (
<form
className="alr-add"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<div className="alr-add-top">
<input
className="alr-input alr-input--name"
type="text"
spellCheck={false}
autoComplete="off"
placeholder="Alert name"
aria-label="Alert name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
<div className="alr-seg" role="group" aria-label="Alert type">
<button
type="button"
className={type === 'telegram' ? 'alr-seg-btn on' : 'alr-seg-btn'}
aria-pressed={type === 'telegram'}
onClick={() => setType('telegram')}
disabled={busy || disabled}
>
Telegram
</button>
<button
type="button"
className={type === 'webhook' ? 'alr-seg-btn on' : 'alr-seg-btn'}
aria-pressed={type === 'webhook'}
onClick={() => setType('webhook')}
disabled={busy || disabled}
>
Webhook
</button>
</div>
</div>
{type === 'telegram' ? (
<>
<input
className="alr-input"
type="password"
spellCheck={false}
autoComplete="off"
placeholder="Bot token (kept secret)"
aria-label="Telegram bot token"
value={token}
onChange={(e) => {
setToken(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
<input
className="alr-input"
type="text"
spellCheck={false}
autoComplete="off"
placeholder="Chat ID (e.g. -1001234567890)"
aria-label="Telegram chat ID"
value={chatId}
onChange={(e) => {
setChatId(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
</>
) : (
<input
className="alr-input"
type="text"
inputMode="url"
spellCheck={false}
autoComplete="off"
placeholder="https://hooks.example.com/…"
aria-label="Webhook URL"
value={url}
onChange={(e) => {
setUrl(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
)}
<fieldset className="alr-events" aria-label="Events to notify on">
{ALERT_EVENTS.map((ev) => (
<label key={ev.id} className="alr-event">
<input
type="checkbox"
checked={events.includes(ev.id)}
onChange={() => toggleEvent(ev.id)}
disabled={busy || disabled}
/>
<span>{ev.label}</span>
</label>
))}
</fieldset>
<div className="alr-delivery">
<label className="alr-resp">
<span className="alr-resp-label mono">Deliver via</span>
<DetourSelect
value={via}
catalog={catalog}
valid={valid}
busy={busy}
disabled={disabled}
ariaLabel="Deliver alert via"
onChange={setVia}
directLabel="Direct (default)"
/>
</label>
<label className="alr-fallback">
<Toggle
pressed={fallback}
onChange={setFallback}
label={fallback ? 'Disable direct fallback' : 'Enable direct fallback'}
disabled={busy || disabled || !routed}
/>
<span className="alr-fallback-label mono">Fallback to direct</span>
</label>
</div>
{routed && !fallback && (
<p className="alr-note" role="note">
{VIA_NO_FALLBACK_NOTE}
</p>
)}
<div className="alr-add-actions">
{err && (
<p className="alr-field-err" role="alert">
{err}
</p>
)}
<Button type="submit" variant="primary" disabled={busy || disabled}>
{busy ? 'Saving…' : 'Add alert'}
</Button>
</div>
</form>
)
}
function AlertRow({
alert,
busy,
catalog,
valid,
onToggle,
onVia,
onFallback,
onDelete,
}: {
alert: Alert
busy: boolean
catalog: DetourCatalog
valid: Set<string>
onToggle: (on: boolean) => void
onVia: (v: string) => void
onFallback: (on: boolean) => void
onDelete: () => void
}) {
// Never render the token/URL in clear — show a masked descriptor only.
const detail = useMemo(() => {
if (alert.Type === 'telegram') {
return { text: `chat ${alert.ChatID || '—'}`, masked: !!alert.Token }
}
const { host, masked } = maskUrl(alert.URL ?? '')
return { text: host, masked: masked || !!alert.URL }
}, [alert.Type, alert.ChatID, alert.Token, alert.URL])
const events = asArray(alert.Events)
const canon = useMemo(() => canonDetour(alert.Via, catalog), [alert.Via, catalog])
const route = useMemo(() => describeDetour(canon, catalog, valid), [canon, catalog, valid])
const routed = canon !== 'direct'
const fallback = alert.Fallback ?? false
return (
<li className="alr-row">
<Toggle
pressed={alert.Enabled}
onChange={onToggle}
label={`${alert.Enabled ? 'Disable' : 'Enable'} alert ${alert.Name}`}
disabled={busy}
/>
<div className="alr-row-main">
<div className="alr-row-l1">
<span className="alr-row-name">{alert.Name}</span>
<span className="alr-badge">{alert.Type}</span>
{events.map((e) => (
<span key={e} className="alr-badge alr-badge--accent">
{e}
</span>
))}
</div>
<div className="alr-row-l2 mono">
<span className="alr-row-detail">{detail.text}</span>
{detail.masked && (
<span className="alr-masked" title="Secret is stored but hidden here">
secret hidden
</span>
)}
{route.direct ? (
<span className="alr-path">direct</span>
) : (
<span className="alr-path" data-active="on" data-missing={route.missing ? 'y' : undefined}>
{route.prefix} <strong className="alr-path-name">{route.name}</strong>
{route.missing && <span className="alr-path-flag"> (missing)</span>}
{fallback ? ' · +direct fallback' : ' · no fallback'}
</span>
)}
</div>
{routed && !fallback && <p className="alr-note alr-note--row">{VIA_NO_FALLBACK_NOTE}</p>}
</div>
<div className="alr-ctl">
<label className="alr-detour">
<span className="alr-detour-label mono">Deliver via</span>
<DetourSelect
value={canon}
catalog={catalog}
valid={valid}
busy={busy}
disabled={false}
ariaLabel={`Deliver alert ${alert.Name} via`}
onChange={onVia}
directLabel="Direct (default)"
/>
</label>
<label className="alr-fallback">
<Toggle
pressed={fallback}
onChange={onFallback}
label={`${fallback ? 'Disable' : 'Enable'} direct fallback for ${alert.Name}`}
disabled={busy || !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>
</li>
)
}
/** The live delivery picker: option list built from the Model's targets. */
function DetourSelect({
value,
catalog,
valid,
busy,
disabled,
ariaLabel,
onChange,
directLabel = 'Direct (no proxy)',
}: {
value: string // canonical value
catalog: DetourCatalog
valid: Set<string>
busy: boolean
disabled: boolean
ariaLabel: string
onChange: (v: string) => void
directLabel?: string
}) {
const missing = value !== 'direct' && !valid.has(value)
return (
<select
className="alr-select alr-detour-select"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy || disabled}
aria-label={ariaLabel}
>
<option value="direct">{directLabel}</option>
{catalog.groups.length > 0 && (
<optgroup label="Groups">
{catalog.groups.map((g) => (
<option key={g} value={`group:${g}`}>
Group {g} (balancer)
</option>
))}
</optgroup>
)}
{catalog.chains.length > 0 && (
<optgroup label="Chains">
{catalog.chains.map((c) => (
<option key={c} value={`chain:${c}`}>
Chain {c}
</option>
))}
</optgroup>
)}
{catalog.egresses.length > 0 && (
<optgroup label="Interfaces / egresses">
{catalog.egresses.map((e) => (
<option key={e.name} value={`egress:${e.name}`}>
Interface/egress {e.name}
{e.type ? ` (${e.type})` : ''}
</option>
))}
</optgroup>
)}
{catalog.nodes.length > 0 && (
<optgroup label="Nodes">
{catalog.nodes.map((n) => (
<option key={n} value={`node:${n}`}>
Node {n}
</option>
))}
</optgroup>
)}
{missing && <option value={value}>{value} (missing)</option>}
</select>
)
}
function EmptyPlate({ title, body }: { title: string; body: string }) {
return (
<div className="alr-empty">
<span className="alr-empty-title mono">{title}</span>
<p className="alr-empty-body">{body}</p>
</div>
)
}
+108 -53
View File
@@ -11,6 +11,8 @@ import {
ApiError, ApiError,
} from '../api' } from '../api'
import type { Globals, Status } from '../api' import type { Globals, Status } from '../api'
import { engineReadout, killSwitchReadout } from '../planeState'
import { onPendingConfirmExpire, usePendingConfirm } from '../pendingConfirm'
// Short, readable config hash — drops the "sha256:" prefix like the footer does. // Short, readable config hash — drops the "sha256:" prefix like the footer does.
function short(hash: string): string { function short(hash: string): string {
@@ -25,13 +27,6 @@ function msg(e: unknown): string {
type Busy = 'apply' | 'confirm' | 'rollback' | null type Busy = 'apply' | 'confirm' | 'rollback' | null
/** A pending commit-confirm window: the daemon has armed an auto-rollback. */
interface Armed {
total: number // the ConfirmTimeout the window started with
remaining: number // seconds left before the daemon reverts
appliedHash: string // the hash that went live on apply (the "after" of apply)
}
type ActionKind = 'apply' | 'confirm' | 'rollback' | 'expire' type ActionKind = 'apply' | 'confirm' | 'rollback' | 'expire'
interface ActionResult { interface ActionResult {
kind: ActionKind kind: ActionKind
@@ -57,7 +52,11 @@ export default function Apply() {
const [configError, setConfigError] = useState<string | null>(null) const [configError, setConfigError] = useState<string | null>(null)
const [busy, setBusy] = useState<Busy>(null) const [busy, setBusy] = useState<Busy>(null)
const [armed, setArmed] = useState<Armed | null>(null) // The armed window is app-wide state, not this page's: it is recorded by the
// api layer on every apply and survives a reload. Keeping it local is what made
// refreshing this tab lose both the countdown and the only button that could
// stop it. See pendingConfirm.ts.
const armed = usePendingConfirm()
const [result, setResult] = useState<ActionResult | null>(null) const [result, setResult] = useState<ActionResult | null>(null)
const [confirmingRollback, setConfirmingRollback] = useState(false) const [confirmingRollback, setConfirmingRollback] = useState(false)
@@ -104,32 +103,64 @@ export default function Apply() {
void loadConfig() void loadConfig()
}, [loadConfig]) }, [loadConfig])
// ---- commit-confirm countdown: a calm 1s numeric tick, effect-scoped so the // The window running out does NOT mean the daemon rolled back.
// timer is always cleared on unmount / confirm / rollback (no leaked intervals) ---- //
// apply.ArmRollback captures the data-plane generation when it arms, and on
// expiry it compares. If anything re-applied the plane in between — another
// panel apply, SIGHUP, a hotplug or the once-a-minute cron reconcile, the WAN
// profile auto-switch — it disarms and KEEPS the running config, logging "NOT
// rolling back" and nothing else. That is the common case on a production
// router, and this page used to print "daemon auto-rolled back to last-good
// config" for it: a confident report of an event that did not happen, with a
// hash pair underneath that quietly said "unchanged".
//
// The panel cannot see which branch ran — the daemon says so only in its log.
// So it reports the one thing it CAN observe, the live config hash, and waits
// for the revert to land before reading it (a rollback is a full re-apply and
// does not complete the instant the timer fires).
const liveHashRef = useRef('')
liveHashRef.current = status?.hash ?? ''
useEffect(() => { useEffect(() => {
if (!armed) return let cancelled = false
if (armed.remaining <= 0) { const off = onPendingConfirmExpire(() => {
// Window elapsed — the daemon reverts to last-good on its own. Observe it. const before = liveHashRef.current
const before = armed.appliedHash flash('Confirm window elapsed')
setArmed(null) setResult({
flash('Auto-rolled back') kind: 'expire',
tone: 'warn',
text: 'Confirm window elapsed. Reading what the daemon did…',
before,
after: before,
})
void (async () => { void (async () => {
const after = (await refreshStatus())?.hash ?? '' let after = before
for (let i = 0; i < 4 && !cancelled; i++) {
await new Promise((r) => window.setTimeout(r, 1500))
if (cancelled) return
after = (await refreshStatus())?.hash ?? after
if (after !== before) break
}
if (cancelled) return
setResult({ setResult({
kind: 'expire', kind: 'expire',
tone: 'warn', tone: 'warn',
text: 'Confirm window elapsed — daemon auto-rolled back to last-good config.', text:
after !== before
? 'Confirm window elapsed and the live config changed — the daemon reverted to its last-good config.'
: 'Confirm window elapsed and the live config has not changed, so this config is still running. ' +
'The daemon only reverts if nothing else re-applied the data plane while the window was open; ' +
'otherwise it stands down and keeps what is live. Which one happened is in the daemon log — ' +
'download it from Settings, or run `logread -e shater`.',
before, before,
after, after,
}) })
})() })()
return })
return () => {
cancelled = true
off()
} }
const id = window.setTimeout(() => { }, [flash, refreshStatus])
setArmed((a) => (a ? { ...a, remaining: a.remaining - 1 } : a))
}, 1000)
return () => window.clearTimeout(id)
}, [armed, flash, refreshStatus])
const confirmWindow = globals?.ConfirmTimeout ?? 0 const confirmWindow = globals?.ConfirmTimeout ?? 0
@@ -146,8 +177,9 @@ export default function Apply() {
return return
} }
const after = (await refreshStatus())?.hash ?? before const after = (await refreshStatus())?.hash ?? before
// The window itself was recorded by api.apply(); this branch only writes the
// readout for it.
if (r.changed && confirmWindow > 0) { if (r.changed && confirmWindow > 0) {
setArmed({ total: confirmWindow, remaining: confirmWindow, appliedHash: after })
setResult({ setResult({
kind: 'apply', kind: 'apply',
tone: 'good', tone: 'good',
@@ -179,7 +211,8 @@ export default function Apply() {
const doConfirm = useCallback(async () => { const doConfirm = useCallback(async () => {
const before = status?.hash ?? '' const before = status?.hash ?? ''
setBusy('confirm') setBusy('confirm')
setArmed(null) // stop the countdown immediately; confirm cancels the auto-rollback // api.confirm() clears the shared window on success — the countdown stops the
// moment the daemon agrees, not the moment we asked.
try { try {
const r = await apiConfirm() const r = await apiConfirm()
if (r.error) { if (r.error) {
@@ -208,7 +241,7 @@ export default function Apply() {
const before = status?.hash ?? '' const before = status?.hash ?? ''
setConfirmingRollback(false) setConfirmingRollback(false)
setBusy('rollback') setBusy('rollback')
setArmed(null) // rolling back also cancels any pending confirm window // api.rollback() clears the shared window on success (rolling back ends it).
try { try {
const r = await apiRollback() const r = await apiRollback()
if (r.error) { if (r.error) {
@@ -237,19 +270,37 @@ export default function Apply() {
}, [status, flash, refreshStatus]) }, [status, flash, refreshStatus])
// ---- derived display state (mirrors Overview's LED semantics) ---- // ---- derived display state (mirrors Overview's LED semantics) ----
const killArmed = globals ? globals.KillSwitch === 'closed' : false //
const engineVariant: LedVariant = !status // The LIVE kill-switch wins over the saved one, exactly as on Overview: this row
? 'off' // is a status readout, and the config on disk can already differ from what is
: status.running && status.active // installed. Falls back to the config only while /api/status is unread.
? 'on' const killArmed = (status?.kill_switch ?? globals?.KillSwitch ?? 'closed') === 'closed'
: status.running // Whether that setting is actually installed — same three-plus-unknown reading
? 'amber' // as Overview, so the two pages cannot disagree about the same router.
: 'crit' const kill = killSwitchReadout(status, globals?.KillSwitch)
const dataVariant: LedVariant = status?.table ? 'on' : status?.running ? 'amber' : 'off' const killWord =
kill.state === 'open'
? 'open'
: kill.state === 'armed'
? 'fail-closed'
: kill.state === 'inert'
? 'closed · not in effect'
: 'closed · not reported'
// Every engine mark on this page comes from ONE reading, and that reading is
// able to say "stopped" — see planeState.engineState for why `status.running`
// could not. This page is where someone lands when the network is down; three
// green lamps here were the difference between "I broke it" and "nothing broke".
const engine = engineReadout(status)
const engineVariant: LedVariant = engine.variant
// No nft table means there is no data plane at all. Under a fail-closed switch
// 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.
const dataVariant: LedVariant = status?.table ? 'on' : !status ? 'off' : killArmed ? 'crit' : 'amber'
const configVariant: LedVariant = status?.enabled ? 'on' : 'amber' const configVariant: LedVariant = status?.enabled ? 'on' : 'amber'
const liveHash = short(status?.hash ?? '') const liveHash = short(status?.hash ?? '')
const pct = armed ? Math.max(0, Math.round((armed.remaining / armed.total) * 100)) : 0 const pct = armed ? Math.max(0, Math.round((armed.remaining / armed.pending.total) * 100)) : 0
// Only offer rollback when the daemon says one would revert something: an armed // Only offer rollback when the daemon says one would revert something: an armed
// commit-confirm snapshot, or an engine last-good predecessor. When false there // commit-confirm snapshot, or an engine last-good predecessor. When false there
@@ -264,9 +315,7 @@ export default function Apply() {
label="Engine" label="Engine"
variant={engineVariant} variant={engineVariant}
pulse={engineVariant === 'on'} pulse={engineVariant === 'on'}
value={ value={engine.word}
!status ? 'checking…' : status.running ? (status.active ? 'active' : 'idle') : 'stopped'
}
/> />
<StatusPip <StatusPip
label="Config" label="Config"
@@ -278,11 +327,7 @@ export default function Apply() {
variant={dataVariant} variant={dataVariant}
value={status?.table ? 'nft installed' : 'no table'} value={status?.table ? 'nft installed' : 'no table'}
/> />
<StatusPip <StatusPip label="Kill-switch" variant={kill.variant} value={killWord} />
label="Kill-switch"
variant={killArmed ? 'on' : 'amber'}
value={killArmed ? 'fail-closed' : 'open'}
/>
</div> </div>
{statusError && ( {statusError && (
@@ -310,9 +355,13 @@ export default function Apply() {
unit="· sha256" unit="· sha256"
led={{ variant: configVariant }} led={{ variant: configVariant }}
rows={[ rows={[
{ k: 'engine', v: status?.running ? 'running' : 'stopped', hot: !status?.running }, { k: 'engine', v: engine.word, hot: engineVariant === 'crit' },
{ k: 'data plane', v: status?.table ? 'nft installed' : 'no table' }, {
{ k: 'kill-switch', v: killArmed ? 'fail-closed' : 'open', hot: !killArmed }, k: 'data plane',
v: status?.table ? 'nft installed' : 'no table',
hot: dataVariant === 'crit',
},
{ k: 'kill-switch', v: killWord, hot: kill.variant === 'crit' || !killArmed },
]} ]}
/> />
<Module <Module
@@ -325,7 +374,7 @@ export default function Apply() {
} }
led={{ variant: engineVariant }} led={{ variant: engineVariant }}
rows={[ rows={[
{ k: 'state', v: !status ? 'checking…' : status.active ? 'active' : 'idle' }, { k: 'state', v: engine.word, hot: engineVariant === 'crit' },
{ k: 'config', v: status?.enabled ? 'enabled' : 'disabled' }, { k: 'config', v: status?.enabled ? 'enabled' : 'disabled' },
{ k: 'schema', v: globals ? `v${globals.SchemaVersion}` : '—' }, { k: 'schema', v: globals ? `v${globals.SchemaVersion}` : '—' },
]} ]}
@@ -362,9 +411,13 @@ export default function Apply() {
</div> </div>
<div className="cc-info"> <div className="cc-info">
<p className="cc-copy"> <p className="cc-copy">
Applied config <span className="mono">{short(armed.appliedHash)}</span> is live but {/* The live hash IS the applied one while a window is open — that
not yet kept. Confirm to keep it — otherwise the daemon rolls back to the last-good is what "live but not kept" means — so the readout survives a
config when the timer hits zero. reload instead of depending on what this tab remembers. */}
Applied config <span className="mono">{liveHash}</span> is live but not yet kept.
Confirm to keep it. At zero the daemon rolls back to the last-good config — unless
something else re-applies the data plane first, in which case it stands down and
keeps whatever is live.
</p> </p>
<div className="cc-bar" aria-hidden="true"> <div className="cc-bar" aria-hidden="true">
<span className="cc-bar-fill" style={{ width: `${pct}%` }} /> <span className="cc-bar-fill" style={{ width: `${pct}%` }} />
@@ -481,7 +534,9 @@ function labelFor(kind: ActionKind): string {
case 'rollback': case 'rollback':
return 'Rollback' return 'Rollback'
case 'expire': case 'expire':
return 'Auto-rollback' // NOT "Auto-rollback": on expiry the daemon either reverts or stands down,
// and this page cannot tell which. Name the event it did observe.
return 'Window elapsed'
} }
} }
+80 -68
View File
@@ -387,6 +387,79 @@
.dns-row-state[data-active='on'] { .dns-row-state[data-active='on'] {
color: var(--led-on); color: var(--led-on);
} }
/* A list that is switched on but has nothing loaded is not "off" and is certainly
not "filtering" — warn semantics, the same amber the badges use. */
.dns-row-state[data-active='warn'] {
color: var(--amber);
}
/* ---- remote-list freshness (mirrors the rule-set rows on Routing) ---- */
.dns-row-sync {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 3px;
}
.dns-sync-fresh {
font-family: var(--font-mono);
font-size: 11px;
color: var(--dim);
}
.dns-sync-fresh[data-never='y'] {
color: var(--amber);
}
.dns-sync-every,
.dns-sync-rules {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.02em;
color: var(--faint);
}
.dns-sync-every::before {
content: '↻ ';
}
.dns-sync-update {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--accent-soft);
border-radius: 5px;
background: var(--raised);
color: var(--accent);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: var(--track-label);
text-transform: uppercase;
cursor: pointer;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.dns-sync-update:hover:not(:disabled) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.dns-sync-update:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dns-sync-update:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dns-sync-spin {
width: 10px;
height: 10px;
border: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-top-color: var(--accent);
border-radius: 50%;
animation: dns-sync-spin 0.7s linear infinite;
}
@keyframes dns-sync-spin {
to {
transform: rotate(360deg);
}
}
/* badge — groove-bordered, not orange (accent stays reserved) */ /* badge — groove-bordered, not orange (accent stays reserved) */
.dns-badge { .dns-badge {
@@ -575,80 +648,19 @@
} }
} }
/* alert delivery: deliver-via picker + fallback toggle + caution note */
.dns-alert-delivery {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 20px;
}
.dns-fallback {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.dns-fallback-label {
font-size: 10px;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--faint);
}
/* the per-alert-row delivery controls sit inline in the row (like .dns-detour) */
.dns-alert-ctl {
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.dns-alert-note {
margin: 0;
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.5;
color: var(--amber);
max-width: 56ch;
}
.dns-alert-note--row {
margin-top: 2px;
}
@media (max-width: 640px) {
.dns-alert-ctl {
flex-basis: 100%;
order: 3;
}
}
/* alert event checkboxes */
.dns-events {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0;
padding: 0;
border: 0;
}
.dns-event {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fp-text, inherit);
cursor: pointer;
}
.dns-event input {
accent-color: var(--fp-accent, currentColor);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.dns-skel { .dns-skel {
animation: none; animation: none;
} }
/* No spin under reduced motion — the static ring + "Updating…" label carry it. */
.dns-sync-spin {
animation: none;
border-top-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.dns-chip, .dns-chip,
.dns-input, .dns-input,
.dns-seg-btn { .dns-seg-btn,
.dns-sync-update {
transition: none; transition: none;
} }
} }
+237 -499
View File
@@ -1,8 +1,16 @@
import './DNS.css' import './DNS.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, CatSuggest, Led, SrcPicker, Toggle } from '../components' import { Button, CatSuggest, Led, SrcPicker, Toggle, useConfirm } from '../components'
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api' import {
import type { Alert, DNSRule, Model, Resolver } from '../api' apply as apiApply,
getConfig,
getRulesetStatus,
putConfig,
updateRuleset as apiUpdateRuleset,
ApiError,
} from '../api'
import type { DNSRule, Model, Resolver, RulesetStatus } from '../api'
import { everyLabel, relFetch } from '../format'
// The DNS / Blocklists page is a thin editor over the desired-state Model — // The DNS / Blocklists page is a thin editor over the desired-state Model —
// exactly like Nodes.tsx. Every edit rewrites the relevant slice in-place, PUTs // exactly like Nodes.tsx. Every edit rewrites the relevant slice in-place, PUTs
@@ -52,28 +60,9 @@ type GlobalsX = Model['Globals'] & { DNSFilter?: boolean }
type DNSModel = Model & { type DNSModel = Model & {
Blocklists?: Blocklist[] | null Blocklists?: Blocklist[] | null
Allowlists?: Allowlist[] | null Allowlists?: Allowlist[] | null
Alerts?: Alert[] | null
DNSRules?: DNSRule[] | null DNSRules?: DNSRule[] | null
} }
/**
* Every event the daemon actually sends. A retired health-probe event was left
* out on purpose: nothing ever fired it, so a channel that subscribed to it would
* just stay quiet forever — the one failure mode an alert must not have. Only
* events with a live firing path are offered here.
*/
const ALERT_EVENTS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'killswitch', label: 'Kill-switch' },
{ id: 'apply_fail', label: 'Apply failure' },
{ id: 'new_device', label: 'New device' },
{ id: 'sub_expiry', label: 'Subscription expiring' },
]
// Shown when an alert routes through a detour with no direct fallback — the exact
// case where a tunnel-down alert could fail to send. The user asked for this.
const VIA_NO_FALLBACK_NOTE =
'A kill-switch/tunnel-down alert may not send if it routes through the affected tunnel — enable fallback.'
// ---- helpers --------------------------------------------------------------- // ---- helpers ---------------------------------------------------------------
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : []) const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
@@ -220,6 +209,7 @@ function describeDetour(
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function DNS() { export default function DNS() {
const confirm = useConfirm()
const [config, setConfig] = useState<DNSModel | null>(null) const [config, setConfig] = useState<DNSModel | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -301,7 +291,6 @@ export default function DNS() {
const blocklists = useMemo(() => asArray(config?.Blocklists), [config]) const blocklists = useMemo(() => asArray(config?.Blocklists), [config])
const allowlists = useMemo(() => asArray(config?.Allowlists), [config]) const allowlists = useMemo(() => asArray(config?.Allowlists), [config])
const resolvers = useMemo<Resolver[]>(() => asArray(config?.Resolvers), [config]) const resolvers = useMemo<Resolver[]>(() => asArray(config?.Resolvers), [config])
const alerts = useMemo<Alert[]>(() => asArray(config?.Alerts), [config])
// Ascending Order — the engine evaluates DNS rules first-match, so the list is // Ascending Order — the engine evaluates DNS rules first-match, so the list is
// shown and edited in the order it actually runs. // shown and edited in the order it actually runs.
const dnsRules = useMemo<DNSRule[]>( const dnsRules = useMemo<DNSRule[]>(
@@ -309,6 +298,68 @@ export default function DNS() {
[config], [config],
) )
// ---- did the lists actually LOAD? -----------------------------------------
//
// A blocklist row said "filtering" whenever the list and the master switch were
// both on. Neither of those is evidence that anything is being blocked: a
// url/geosite list is fetched by the engine, the daemon treats a failed fetch as
// a CRITICAL apply finding, and the row went on saying "filtering" through it.
// The Routing page had already been given this reading for rule-sets — the same
// endpoint, the same tags (`bl-<name>` / `al-<name>`) — and the DNS page never
// asked. Slow poll: lists refresh on a ~24h cadence, so 15s only has to catch a
// manual Update-now. Grouped by NAME because a geosite list with N categories
// reports N records.
const [listStatus, setListStatus] = useState<Map<string, RulesetStatus[]>>(new Map())
const [updatingLists, setUpdatingLists] = useState<Set<string>>(new Set())
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. The row falls
// back to "load not reported", which claims nothing either way.
}
}, [])
useEffect(() => {
void loadListStatus()
const id = window.setInterval(() => void loadListStatus(), 15000)
return () => window.clearInterval(id)
}, [loadListStatus])
const updateList = useCallback(
async (kind: 'blocklist' | 'allowlist', name: string) => {
const key = `${kind}:${name}`
setUpdatingLists((prev) => new Set(prev).add(key))
try {
// One geo list can hold several categories, each its own engine tag.
const recs = listStatus.get(key) ?? []
const tags = recs.length
? recs.map((r) => r.tag)
: [`${kind === 'blocklist' ? 'bl' : 'al'}-${name}`]
for (const tag of tags) await apiUpdateRuleset(tag)
await loadListStatus()
flash(`${name} refreshed`)
} catch (e) {
flash(`Refresh failed — ${errText(e)}`)
} finally {
setUpdatingLists((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
},
[listStatus, loadListStatus, flash],
)
const blOn = blocklists.filter((b) => b.Enabled).length const blOn = blocklists.filter((b) => b.Enabled).length
const alOn = allowlists.filter((a) => a.Enabled).length const alOn = allowlists.filter((a) => a.Enabled).length
const blNames = useMemo(() => new Set(blocklists.map((b) => b.Name)), [blocklists]) const blNames = useMemo(() => new Set(blocklists.map((b) => b.Name)), [blocklists])
@@ -422,15 +473,19 @@ export default function DNS() {
) )
const removeBlocklist = useCallback( const removeBlocklist = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = blocklists[idx] const target = blocklists[idx]
if (!window.confirm(`Delete blocklist “${target.Name}”? This removes it from the config.`)) const ok = await confirm({
return label: 'Delete blocklist',
title: `Delete blocklist “${target.Name}”?`,
body: 'This removes it from the config.',
})
if (!ok) return
const next = blocklists.filter((_, i) => i !== idx) const next = blocklists.filter((_, i) => i !== idx)
void save({ ...config, Blocklists: next }, `Deleted ${target.Name}`) void save({ ...config, Blocklists: next }, `Deleted ${target.Name}`)
}, },
[config, blocklists, save], [config, blocklists, save, confirm],
) )
// ---- allowlist mutations -------------------------------------------------- // ---- allowlist mutations --------------------------------------------------
@@ -457,15 +512,19 @@ export default function DNS() {
) )
const removeAllowlist = useCallback( const removeAllowlist = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = allowlists[idx] const target = allowlists[idx]
if (!window.confirm(`Delete allowlist “${target.Name}”? This removes it from the config.`)) const ok = await confirm({
return label: 'Delete allowlist',
title: `Delete allowlist “${target.Name}”?`,
body: 'This removes it from the config.',
})
if (!ok) return
const next = allowlists.filter((_, i) => i !== idx) const next = allowlists.filter((_, i) => i !== idx)
void save({ ...config, Allowlists: next }, `Deleted ${target.Name}`) void save({ ...config, Allowlists: next }, `Deleted ${target.Name}`)
}, },
[config, allowlists, save], [config, allowlists, save, confirm],
) )
// ---- resolver mutations --------------------------------------------------- // ---- resolver mutations ---------------------------------------------------
@@ -491,15 +550,58 @@ export default function DNS() {
[config, resolvers, save], [config, resolvers, save],
) )
/**
* Delete a resolver, saying what it was still wired into.
*
* The three GLOBAL slots (default, fallback, endpoint) are cleared here, because
* a global pointing at nothing is never what anyone meant. The DNS RULES are a
* different matter: each one is a decision about which queries go where, and
* silently deleting or repointing them would change where a device's DNS goes
* without saying so. So they are named instead and left alone — the dialog is
* where the operator finds out they exist, which is precisely what this page
* used to skip: it cleared the two globals without a word and never mentioned
* the rules at all.
*/
const removeResolver = useCallback( const removeResolver = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = resolvers[idx] const target = resolvers[idx]
if (!window.confirm(`Delete resolver “${target.Name}”? This removes it from the config.`))
return
const next = resolvers.filter((_, i) => i !== idx)
// Don't leave default/fallback pointing at a resolver that no longer exists.
const g = { ...config.Globals } const g = { ...config.Globals }
const slots: string[] = []
if (g.ResolverDefault === target.Name) slots.push('the default resolver')
if (g.ResolverFallback === target.Name) slots.push('the fallback resolver')
if (g.EndpointResolver === target.Name) slots.push('the endpoint resolver')
const usedBy = dnsRules.filter((r) => r.Resolver === target.Name)
const parts: string[] = []
if (slots.length > 0) {
parts.push(
`It is ${slots.join(' and ')} — ${
slots.length === 1 ? 'that slot is' : 'those slots are'
} cleared, so DNS falls back to the engine's built-in resolution.`,
)
}
if (usedBy.length === 1) {
parts.push(
`One DNS rule still sends queries to it (order ${usedBy[0].Order}). It is left as it is and will have nowhere to resolve — repoint it before you apply.`,
)
} else if (usedBy.length > 1) {
parts.push(
`${usedBy.length} DNS rules still send queries to it (orders ${usedBy
.map((r) => r.Order)
.join(', ')}). They are left as they are and will have nowhere to resolve — repoint them before you apply.`,
)
}
if (parts.length === 0) parts.push('Nothing else in the config points at it.')
const ok = await confirm({
label: 'Delete resolver',
title: `Delete resolver “${target.Name}”?`,
body: parts.join(' '),
})
if (!ok) return
const next = resolvers.filter((_, i) => i !== idx)
// Don't leave default/fallback/endpoint pointing at a resolver that's gone.
const cleared: string[] = [] const cleared: string[] = []
if (g.ResolverDefault === target.Name) { if (g.ResolverDefault === target.Name) {
g.ResolverDefault = '' g.ResolverDefault = ''
@@ -509,12 +611,16 @@ export default function DNS() {
g.ResolverFallback = '' g.ResolverFallback = ''
cleared.push('fallback') cleared.push('fallback')
} }
if (g.EndpointResolver === target.Name) {
g.EndpointResolver = ''
cleared.push('endpoint')
}
const msg = cleared.length const msg = cleared.length
? `Deleted ${target.Name} — cleared ${cleared.join(' & ')}` ? `Deleted ${target.Name} — cleared ${cleared.join(' & ')}`
: `Deleted ${target.Name}` : `Deleted ${target.Name}`
void save({ ...config, Globals: g, Resolvers: next }, msg) void save({ ...config, Globals: g, Resolvers: next }, msg)
}, },
[config, resolvers, save], [config, resolvers, dnsRules, save, confirm],
) )
const setResolverDefault = useCallback( const setResolverDefault = useCallback(
@@ -578,84 +684,21 @@ export default function DNS() {
) )
const removeDNSRule = useCallback( const removeDNSRule = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = dnsRules[idx] const target = dnsRules[idx]
if (!window.confirm(`Delete this DNS rule? Matching queries fall back to the default resolver.`)) const ok = await confirm({
return label: 'Delete DNS rule',
title: 'Delete this DNS rule?',
body: 'Matching queries fall back to the default resolver.',
})
if (!ok) return
const next = dnsRules.filter((_, i) => i !== idx) const next = dnsRules.filter((_, i) => i !== idx)
void save({ ...config, DNSRules: next }, `Deleted DNS rule → ${target.Resolver}`) void save({ ...config, DNSRules: next }, `Deleted DNS rule → ${target.Resolver}`)
}, },
[config, dnsRules, save], [config, dnsRules, save, confirm],
) )
// ---- alert mutations ------------------------------------------------------
const addAlert = useCallback(
(draft: Alert): Promise<boolean> => {
if (!config) return Promise.resolve(false)
const taken = new Set(alerts.map((a) => a.Name))
const a: Alert = { ...draft, Name: uniqueName(draft.Name, taken) }
return save({ ...config, Alerts: [...alerts, a] }, `Added ${a.Name}`)
},
[config, alerts, save],
)
const toggleAlert = useCallback(
(idx: number, on: boolean) => {
if (!config) return
const next = alerts.map((a, i) => (i === idx ? { ...a, Enabled: on } : a))
void save({ ...config, Alerts: next }, `${next[idx].Name} ${on ? 'enabled' : 'disabled'}`)
},
[config, alerts, save],
)
const removeAlert = useCallback(
(idx: number) => {
if (!config) return
const target = alerts[idx]
if (!window.confirm(`Delete alert “${target.Name}”? This removes it from the config.`)) return
const next = alerts.filter((_, i) => i !== idx)
void save({ ...config, Alerts: next }, `Deleted ${target.Name}`)
},
[config, alerts, save],
)
const setAlertVia = useCallback(
(idx: number, v: string) => {
if (!config) return
const via = v === 'direct' ? '' : v
const next = alerts.map((a, i) => (i === idx ? { ...a, Via: via || undefined } : a))
void save(
{ ...config, Alerts: next },
via ? `${next[idx].Name} delivers via ${via}` : `${next[idx].Name} delivers direct`,
)
},
[config, alerts, save],
)
const setAlertFallback = useCallback(
(idx: number, on: boolean) => {
if (!config) return
const next = alerts.map((a, i) => (i === idx ? { ...a, Fallback: on || undefined } : a))
void save(
{ ...config, Alerts: next },
`${next[idx].Name} direct fallback ${on ? 'on' : 'off'}`,
)
},
[config, alerts, save],
)
// Alerts route through Direct/group/node/egress only (no chains) — the contract
// vocabulary for Alert.Via. Reuse the resolver detour catalog with chains dropped.
const alertCatalog = useMemo<DetourCatalog>(
() => ({ ...detourCatalog, chains: [] }),
[detourCatalog],
)
const alertValid = useMemo(() => detourValues(alertCatalog), [alertCatalog])
const alertNames = useMemo(() => new Set(alerts.map((a) => a.Name)), [alerts])
const alertsOn = alerts.filter((a) => a.Enabled).length
const loading = config === null && loadError === null const loading = config === null && loadError === null
return ( return (
@@ -883,8 +926,11 @@ export default function DNS() {
categories={b.Categories} categories={b.Categories}
response={b.Response} response={b.Response}
filterOn={dnsFilterOn} filterOn={dnsFilterOn}
statuses={listStatus.get(`blocklist:${b.Name}`) ?? null}
updating={updatingLists.has(`blocklist:${b.Name}`)}
busy={busy} busy={busy}
onToggle={(on) => toggleBlocklist(i, on)} onToggle={(on) => toggleBlocklist(i, on)}
onUpdateNow={() => void updateList('blocklist', b.Name)}
onDelete={() => removeBlocklist(i)} onDelete={() => removeBlocklist(i)}
/> />
))} ))}
@@ -942,9 +988,13 @@ export default function DNS() {
url={a.URL} url={a.URL}
path={a.Path} path={a.Path}
entries={a.Entries} entries={a.Entries}
categories={a.Categories}
filterOn={dnsFilterOn} filterOn={dnsFilterOn}
statuses={listStatus.get(`allowlist:${a.Name}`) ?? null}
updating={updatingLists.has(`allowlist:${a.Name}`)}
busy={busy} busy={busy}
onToggle={(on) => toggleAllowlist(i, on)} onToggle={(on) => toggleAllowlist(i, on)}
onUpdateNow={() => void updateList('allowlist', a.Name)}
onDelete={() => removeAllowlist(i)} onDelete={() => removeAllowlist(i)}
/> />
))} ))}
@@ -1069,57 +1119,6 @@ export default function DNS() {
)} )}
</div> </div>
{/* ---- 5. ALERTS ---- */}
<div className="dns-section" aria-label="Alerts">
<header className="dns-sec-hd">
<h2 className="dns-sec-title">Alerts</h2>
<span className="dns-sec-count mono">
{alertsOn} / {alerts.length} on
</span>
</header>
<p className="dns-sec-note">
Out-of-band notifications. Delivered <strong>direct to the internet</strong> by default — so a
kill-switch or engine-down alert still reaches you when the proxy is down. You can route one
through a group, node or egress instead, with a direct fallback if that detour fails.
</p>
<AddAlertForm
busy={busy}
disabled={!config}
taken={alertNames}
catalog={alertCatalog}
valid={alertValid}
onAdd={addAlert}
/>
{loading ? (
<ul className="dns-rows" aria-hidden="true">
<li className="dns-skel" />
</ul>
) : alerts.length === 0 ? (
<EmptyPlate
title="No alerts"
body="Add a Telegram bot or a webhook above to get notified when the kill-switch trips, a new device joins, or an apply fails."
/>
) : (
<ul className="dns-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)}
/>
))}
</ul>
)}
</div>
{toast && ( {toast && (
<div className="toast" role="status"> <div className="toast" role="status">
{toast} {toast}
@@ -1129,342 +1128,6 @@ export default function DNS() {
) )
} }
// ---- alert add form + row --------------------------------------------------
function AddAlertForm({
busy,
disabled,
taken,
catalog,
valid,
onAdd,
}: {
busy: boolean
disabled: boolean
taken: Set<string>
catalog: DetourCatalog
valid: Set<string>
onAdd: (a: Alert) => Promise<boolean>
}) {
const [name, setName] = useState('')
const [type, setType] = useState<'telegram' | 'webhook'>('telegram')
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 [err, setErr] = useState<string | null>(null)
const reset = () => {
setName('')
setType('telegram')
setToken('')
setChatId('')
setUrl('')
setEvents(['killswitch'])
setVia('direct')
setFallback(false)
}
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.')
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 routed = via !== 'direct'
return (
<form
className="dns-add"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<div className="dns-add-top">
<input
className="dns-input dns-input--name"
type="text"
spellCheck={false}
autoComplete="off"
placeholder="Alert name"
aria-label="Alert name"
value={name}
onChange={(e) => {
setName(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
<div className="dns-seg" role="group" aria-label="Alert type">
<button
type="button"
className={type === 'telegram' ? 'dns-seg-btn on' : 'dns-seg-btn'}
aria-pressed={type === 'telegram'}
onClick={() => setType('telegram')}
disabled={busy || disabled}
>
Telegram
</button>
<button
type="button"
className={type === 'webhook' ? 'dns-seg-btn on' : 'dns-seg-btn'}
aria-pressed={type === 'webhook'}
onClick={() => setType('webhook')}
disabled={busy || disabled}
>
Webhook
</button>
</div>
</div>
{type === 'telegram' ? (
<>
<input
className="dns-input"
type="password"
spellCheck={false}
autoComplete="off"
placeholder="Bot token (kept secret)"
aria-label="Telegram bot token"
value={token}
onChange={(e) => {
setToken(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
<input
className="dns-input"
type="text"
spellCheck={false}
autoComplete="off"
placeholder="Chat ID (e.g. -1001234567890)"
aria-label="Telegram chat ID"
value={chatId}
onChange={(e) => {
setChatId(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
</>
) : (
<input
className="dns-input"
type="text"
inputMode="url"
spellCheck={false}
autoComplete="off"
placeholder="https://hooks.example.com/…"
aria-label="Webhook URL"
value={url}
onChange={(e) => {
setUrl(e.target.value)
if (err) setErr(null)
}}
disabled={busy || disabled}
/>
)}
<fieldset className="dns-events" aria-label="Events to notify on">
{ALERT_EVENTS.map((ev) => (
<label key={ev.id} className="dns-event">
<input
type="checkbox"
checked={events.includes(ev.id)}
onChange={() => toggleEvent(ev.id)}
disabled={busy || disabled}
/>
<span>{ev.label}</span>
</label>
))}
</fieldset>
<div className="dns-alert-delivery">
<label className="dns-resp">
<span className="dns-resp-label mono">Deliver via</span>
<DetourSelect
value={via}
catalog={catalog}
valid={valid}
busy={busy}
disabled={disabled}
ariaLabel="Deliver alert via"
onChange={setVia}
directLabel="Direct (default)"
/>
</label>
<label className="dns-fallback">
<Toggle
pressed={fallback}
onChange={setFallback}
label={fallback ? 'Disable direct fallback' : 'Enable direct fallback'}
disabled={busy || disabled || !routed}
/>
<span className="dns-fallback-label mono">Fallback to direct</span>
</label>
</div>
{routed && !fallback && (
<p className="dns-alert-note" role="note">
{VIA_NO_FALLBACK_NOTE}
</p>
)}
<div className="dns-add-actions">
{err && (
<p className="dns-field-err" role="alert">
{err}
</p>
)}
<Button type="submit" variant="primary" disabled={busy || disabled}>
{busy ? 'Saving…' : 'Add alert'}
</Button>
</div>
</form>
)
}
function AlertRow({
alert,
busy,
catalog,
valid,
onToggle,
onVia,
onFallback,
onDelete,
}: {
alert: Alert
busy: boolean
catalog: DetourCatalog
valid: Set<string>
onToggle: (on: boolean) => void
onVia: (v: string) => void
onFallback: (on: boolean) => void
onDelete: () => void
}) {
// Never render the token/URL in clear — show a masked descriptor only.
const detail = useMemo(() => {
if (alert.Type === 'telegram') {
return { text: `chat ${alert.ChatID || '—'}`, masked: !!alert.Token }
}
const { host, masked } = maskUrl(alert.URL ?? '')
return { text: host, masked: masked || !!alert.URL }
}, [alert.Type, alert.ChatID, alert.Token, alert.URL])
const events = asArray(alert.Events)
const canon = useMemo(() => canonDetour(alert.Via, catalog), [alert.Via, catalog])
const route = useMemo(() => describeDetour(canon, catalog, valid), [canon, catalog, valid])
const routed = canon !== 'direct'
const fallback = alert.Fallback ?? false
return (
<li className="dns-row">
<Toggle
pressed={alert.Enabled}
onChange={onToggle}
label={`${alert.Enabled ? 'Disable' : 'Enable'} alert ${alert.Name}`}
disabled={busy}
/>
<div className="dns-row-main">
<div className="dns-row-l1">
<span className="dns-row-name">{alert.Name}</span>
<span className="dns-badge">{alert.Type}</span>
{events.map((e) => (
<span key={e} className="dns-badge dns-badge--accent">
{e}
</span>
))}
</div>
<div className="dns-row-l2 mono">
<span className="dns-row-detail">{detail.text}</span>
{detail.masked && (
<span className="dns-masked" title="Secret is stored but hidden here">
secret hidden
</span>
)}
{route.direct ? (
<span className="dns-path">direct</span>
) : (
<span className="dns-path" data-active="on" data-missing={route.missing ? 'y' : undefined}>
{route.prefix} <strong className="dns-path-name">{route.name}</strong>
{route.missing && <span className="dns-path-flag"> (missing)</span>}
{fallback ? ' · +direct fallback' : ' · no fallback'}
</span>
)}
</div>
{routed && !fallback && <p className="dns-alert-note dns-alert-note--row">{VIA_NO_FALLBACK_NOTE}</p>}
</div>
<div className="dns-alert-ctl">
<label className="dns-detour">
<span className="dns-detour-label mono">Deliver via</span>
<DetourSelect
value={canon}
catalog={catalog}
valid={valid}
busy={busy}
disabled={false}
ariaLabel={`Deliver alert ${alert.Name} via`}
onChange={onVia}
directLabel="Direct (default)"
/>
</label>
<label className="dns-fallback">
<Toggle
pressed={fallback}
onChange={onFallback}
label={`${fallback ? 'Disable' : 'Enable'} direct fallback for ${alert.Name}`}
disabled={busy || !routed}
/>
<span className="dns-fallback-label mono">Fallback to direct</span>
</label>
</div>
<Button
className="dns-del"
onClick={onDelete}
disabled={busy}
aria-label={`Delete alert ${alert.Name}`}
>
Delete
</Button>
</li>
)
}
// ---- add form -------------------------------------------------------------- // ---- add form --------------------------------------------------------------
interface AddDraft { interface AddDraft {
@@ -1695,8 +1358,11 @@ function ListRow({
categories, categories,
response, response,
filterOn, filterOn,
statuses,
updating,
busy, busy,
onToggle, onToggle,
onUpdateNow,
onDelete, onDelete,
}: { }: {
name: string name: string
@@ -1708,8 +1374,14 @@ function ListRow({
categories?: string[] | null categories?: string[] | null
response?: BlockResponse response?: BlockResponse
filterOn: boolean filterOn: boolean
/** What the running engine reports about this list, one record per geo category.
* null/[] ⇒ nothing reported: an older daemon, a stopped engine, or a list that
* has not been applied yet. The row then says so instead of guessing. */
statuses: RulesetStatus[] | null
updating: boolean
busy: boolean busy: boolean
onToggle: (on: boolean) => void onToggle: (on: boolean) => void
onUpdateNow: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const detail = useMemo<{ text: string; masked: boolean; title?: string }>(() => { const detail = useMemo<{ text: string; masked: boolean; title?: string }>(() => {
@@ -1733,8 +1405,45 @@ function ListRow({
} }
}, [source, url, path, entries, categories]) }, [source, url, path, entries, categories])
// A list only actually filters when both it and the master switch are on. // url and geosite lists are FETCHED by the engine; inline and file ones are read
const active = enabled && filterOn // straight from the config and are loaded the moment they are applied.
const remote = source === 'url' || source === 'geosite'
const recs = statuses ?? []
const hasStatus = recs.length > 0
// A geo list with several categories: the OLDEST fetch (so a category that never
// arrived is never hidden behind a fresh sibling) and the SUM of the counts.
let ruleCount = 0
let neverAny = false
let oldestIso = ''
for (const s of recs) {
ruleCount += s.rule_count
if (!s.last_updated) neverAny = true
else if (!oldestIso || Date.parse(s.last_updated) < Date.parse(oldestIso)) oldestIso = s.last_updated
}
const interval = everyLabel(recs[0]?.interval_seconds ?? 0)
/**
* Whether this list is BLOCKING ANYTHING, which is a different question from
* whether it is switched on — and the one the row used to answer wrongly.
*
* "filtering" is now only said when the engine reports rules loaded for it. A
* remote list that has never been fetched (the daemon raises this as a critical
* apply finding) reads "not loaded", and one that fetched an empty list reads
* "empty". Nothing reported at all is "load not reported": unknown, not green.
*/
const state: { text: string; tone: 'on' | 'off' | 'warn' } = !enabled
? { text: 'off', tone: 'off' }
: !filterOn
? { text: 'inactive', tone: 'off' }
: !remote
? { text: 'filtering', tone: 'on' }
: !hasStatus
? { text: 'load not reported', tone: 'off' }
: neverAny
? { text: 'not loaded — nothing blocked', tone: 'warn' }
: ruleCount === 0
? { text: 'loaded empty — nothing blocked', tone: 'warn' }
: { text: 'filtering', tone: 'on' }
return ( return (
<li className="dns-row"> <li className="dns-row">
@@ -1764,10 +1473,39 @@ function ListRow({
token hidden token hidden
</span> </span>
)} )}
<span className="dns-row-state" data-active={active ? 'on' : 'off'}> <span className="dns-row-state" data-active={state.tone}>
{active ? 'filtering' : 'inactive'} {state.text}
</span> </span>
</div> </div>
{remote && (
<div className="dns-row-sync">
<span className="dns-sync-fresh" data-never={hasStatus && neverAny ? 'y' : undefined}>
{hasStatus ? relFetch(oldestIso) : 'status pending'}
</span>
{interval && <span className="dns-sync-every">{interval}</span>}
{ruleCount > 0 && (
<span className="dns-sync-rules">
{ruleCount.toLocaleString('en-US')} rule{ruleCount === 1 ? '' : 's'}
</span>
)}
<button
type="button"
className="dns-sync-update"
onClick={onUpdateNow}
disabled={busy || updating}
aria-label={`Update ${name} now`}
>
{updating ? (
<>
<span className="dns-sync-spin" aria-hidden="true" />
<span>Updating…</span>
</>
) : (
'Update now'
)}
</button>
</div>
)}
</div> </div>
<Button <Button
className="dns-del" className="dns-del"
+3 -51
View File
@@ -138,57 +138,9 @@
/* inline rename: a quiet pencil affordance beside the name, and the mono input /* inline rename: a quiet pencil affordance beside the name, and the mono input
it swaps to — in the same sink/groove tone as the domain editors. */ it swaps to — in the same sink/groove tone as the domain editors. */
.dev-rename { /* The pencil button and the name input now live in App.css as .inline-rename /
flex: none; .inline-rename-input — Nodes grew the same affordance and the two pages must
display: inline-flex; not drift. */
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 5px;
background: none;
color: var(--faint);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.dev-rename:hover:not(:disabled) {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.dev-rename:focus-visible {
color: var(--accent);
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.dev-rename:disabled {
opacity: 0.5;
cursor: default;
}
.dev-name-input {
min-width: 0;
max-width: 24ch;
padding: 4px 8px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--sink);
color: var(--ink);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 1px 2px var(--shadow) inset;
}
.dev-name-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.dev-name-input:disabled {
opacity: 0.55;
}
.dev-id-l2 { .dev-id-l2 {
display: flex; display: flex;
align-items: center; align-items: center;
+13 -7
View File
@@ -1,6 +1,6 @@
import './Devices.css' import './Devices.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Module, Toggle } from '../components' import { Button, Led, Module, Toggle, useConfirm } from '../components'
import type { LedVariant } from '../components' import type { LedVariant } from '../components'
import { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api' import { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api'
import type { Device, DiscoveredDevice, Model } from '../api' import type { Device, DiscoveredDevice, Model } from '../api'
@@ -81,6 +81,7 @@ function networkLabel(row: DeviceRow): string {
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Devices() { export default function Devices() {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -251,17 +252,22 @@ export default function Devices() {
const nameOf = (row: DeviceRow) => row.cfg?.Name || row.hostname || row.ip || 'device' const nameOf = (row: DeviceRow) => row.cfg?.Name || row.hostname || row.ip || 'device'
const removeControl = useCallback( const removeControl = useCallback(
(row: DeviceRow) => { async (row: DeviceRow) => {
if (!config) return if (!config) return
const devs = asArray(config.Devices) const devs = asArray(config.Devices)
const idx = matchDevice(devs, row.mac, row.ip) const idx = matchDevice(devs, row.mac, row.ip)
if (idx < 0) return if (idx < 0) return
const nm = devs[idx].Name || nameOf(row) const nm = devs[idx].Name || nameOf(row)
if (!window.confirm(`Stop managing “${nm}”? Its per-device rules are removed; it falls back to network defaults.`)) const ok = await confirm({
return label: 'Stop managing device',
title: `Stop managing “${nm}”?`,
body: 'Its per-device rules are removed; it falls back to network defaults.',
confirmLabel: 'Stop managing',
})
if (!ok) return
void save({ ...config, Devices: devs.filter((_, i) => i !== idx) }, `Removed control for ${nm}`) void save({ ...config, Devices: devs.filter((_, i) => i !== idx) }, `Removed control for ${nm}`)
}, },
[config, save], [config, save, confirm],
) )
const loading = config === null && loadError === null && devices === null && devError === null const loading = config === null && loadError === null && devices === null && devError === null
@@ -471,7 +477,7 @@ function DeviceCard({
{renaming ? ( {renaming ? (
<input <input
ref={nameInput} ref={nameInput}
className="dev-name-input mono" className="inline-rename-input mono"
type="text" type="text"
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
@@ -497,7 +503,7 @@ function DeviceCard({
</span> </span>
<button <button
type="button" type="button"
className="dev-rename" className="inline-rename"
onClick={beginRename} onClick={beginRename}
disabled={busy} disabled={busy}
aria-label={`Rename ${name}`} aria-label={`Rename ${name}`}
+39 -19
View File
@@ -1,6 +1,6 @@
import './Networks.css' import './Networks.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Select, Toggle } from '../components' import { Button, Led, Select, Toggle, useConfirm } from '../components'
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api' import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
import type { Inbound, Interface, Model, Status } from '../api' import type { Inbound, Interface, Model, Status } from '../api'
import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions' import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions'
@@ -85,6 +85,19 @@ const DEFAULT_TPROXY_PORT = 12345
* Collapsing those into one switch would make "I want ping to work" silently mean * Collapsing those into one switch would make "I want ping to work" silently mean
* "I permit a parallel VPN bypass", so the middle option exists to remove that * "I permit a parallel VPN bypass", so the middle option exists to remove that
* false choice — and the labels push anyone who wants diagnostics to `icmp`. * false choice — and the labels push anyone who wants diagnostics to `icmp`.
*
* WHY THIS COPY WAS REWRITTEN. `block` used to say "Nothing leaves except through
* the tunnel", and it was not true. The daemon let untunnelable traffic out toward
* every destination the ROUTING RULES send direct, on the argument that such a host
* already has your address from ordinary TCP. Under the commonest setup here —
* "tunnel what's blocked, send the rest direct" — the routing default IS direct, so
* that covered everything: `block` behaved exactly like `direct`, including ESP/GRE,
* i.e. the parallel-VPN case the middle rung exists to exclude. The daemon now drops
* unconditionally under `block`, and this copy states the price instead of hiding it
* (the owner's call: this router does not do ping and does not do IPTV).
*
* `icmp` still carries that destination-dependence for its NON-ping half, so its
* cost line says so rather than claiming "nothing else gets out".
*/ */
type Untunnelable = 'block' | 'icmp' | 'direct' type Untunnelable = 'block' | 'icmp' | 'direct'
@@ -98,7 +111,10 @@ function normUntunnelable(raw: string | undefined): Untunnelable {
const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'block', label: 'Block everything — most private' }, { value: 'block', label: 'Block everything — most private' },
{ value: 'icmp', label: 'Allow ping only — for diagnostics' }, // Not "Allow ping only": the rung also lets the other untunnelable protocols
// out toward directly-routed addresses, and the cost line below says so. A
// label that promised "only" would be contradicted two lines under itself.
{ value: 'icmp', label: 'Allow ping — for diagnostics' },
{ value: 'direct', label: 'Allow everything — most compatible' }, { value: 'direct', label: 'Allow everything — most compatible' },
] ]
@@ -110,13 +126,18 @@ interface PolicyCopy {
const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = { const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = {
block: { block: {
works: 'Nothing leaves except through the tunnel.', // Scoped to "this traffic" on purpose. The old line — "Nothing leaves except
cost: 'Ping and traceroute won’t work from your devices, and neither will multicast IPTV or connecting to a VPN from a device on your network.', // through the tunnel" — was doubly loose: it was false (see the note above),
// and even read charitably it collides with directly-routed TCP, which does
// leave outside the tunnel by design.
works:
'None of this traffic leaves the router — it’s dropped, whatever your routing rules say. It’s the only setting whose promise doesn’t depend on how the rules are written.',
cost: 'Ping and traceroute stop working from your devices. So do IPsec and PPTP VPN connections made from a device on your network, multicast IPTV, and SCTP. VPNs that run over UDP — WireGuard, OpenVPN-UDP, and IPsec through NAT (IKEv2/NAT-T) — are unaffected: they go through the tunnel like everything else.',
tone: 'good', tone: 'good',
}, },
icmp: { icmp: {
works: 'Ping and traceroute work, so you can check whether something is reachable.', works: 'Ping and traceroute work everywhere, so you can check whether something is reachable.',
cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. Only for hosts you deliberately ping, and nothing else gets out — IPTV and VPN connections stay blocked.', cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. IPsec, PPTP and IPTV 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', tone: 'warn',
}, },
direct: { direct: {
@@ -242,6 +263,7 @@ function computeWarnings(inbounds: Inbound[], ifaces: Interface[]): Warning[] {
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Networks({ status }: { status?: Status | null }) { export default function Networks({ status }: { status?: Status | null }) {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const ifaces = useInterfaces() const ifaces = useInterfaces()
@@ -392,23 +414,21 @@ export default function Networks({ status }: { status?: Status | null }) {
) )
const removeInbound = useCallback( const removeInbound = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = inbounds[idx] const target = inbounds[idx]
if ( const ok = await confirm({
!window.confirm( label: 'Delete inbound',
`Delete inbound “${target.Name}”?${ title: `Delete inbound “${target.Name}”?`,
intercepts(target) body: intercepts(target)
? ` ${target.Network || 'Its network'} stops going through the tunnel.` ? `${target.Network || 'Its network'} stops going through the tunnel.`
: '' : undefined,
}`, })
) if (!ok) return
)
return
const next = inbounds.filter((_, i) => i !== idx) const next = inbounds.filter((_, i) => i !== idx)
void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`) void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`)
}, },
[config, inbounds, save], [config, inbounds, save, confirm],
) )
return ( return (
@@ -552,7 +572,7 @@ export default function Networks({ status }: { status?: Status | null }) {
{untunnelable === 'block' && ( {untunnelable === 'block' && (
<p className="nw-sec-note nw-policy-hint"> <p className="nw-sec-note nw-policy-hint">
If you just want to check whether a site is reachable, choose <strong>Allow ping only</strong>{' '} If you just want to check whether a site is reachable, choose <strong>Allow ping</strong>{' '}
rather than allowing everything — it’s the narrower of the two. rather than allowing everything — it’s the narrower of the two.
</p> </p>
)} )}
+110
View File
@@ -196,6 +196,52 @@
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove)); border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
color: var(--amber); color: var(--amber);
} }
/* "not built" — the saved switch says on and the engine has no such outbound. */
.badge--crit {
border-color: color-mix(in srgb, var(--crit) 55%, var(--groove));
color: var(--crit);
}
/* ---- last-apply findings, attached to the row they are about ----
Sits under the row's own two lines, inside the row plate, so a node the
generator threw away cannot read as an ordinary enabled node. Severity carries
the colour; the accent stays reserved for controls. */
.row-findings {
margin: 6px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
}
.row-finding {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--amber) 40%, var(--groove));
border-radius: 6px;
background: color-mix(in srgb, var(--sink) 35%, transparent);
}
.row-finding--critical {
border-color: color-mix(in srgb, var(--crit) 45%, var(--groove));
}
.row-finding-msg {
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.5;
color: var(--ink);
max-width: 82ch;
overflow-wrap: anywhere;
}
/* Findings that belong to no single row (see Nodes.tsx globalFindings). */
.node-findings {
margin-bottom: calc(var(--u, 8px) * 2);
}
.node-findings .row-findings {
margin-top: 0;
}
/* masked-credential marker */ /* masked-credential marker */
.masked { .masked {
@@ -641,6 +687,20 @@ select.fp-input {
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--faint); color: var(--faint);
} }
/* A collapsed bucket has to carry its own bad news: a 300-node subscription is
closed by default, and the per-row findings inside it are otherwise unreachable
without knowing to look. */
.group-flagged {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--amber);
}
.group-rows { .group-rows {
margin-top: 8px; margin-top: 8px;
} }
@@ -727,3 +787,53 @@ select.fp-input {
width: 9rem; width: 9rem;
} }
} }
/* ---- inline node rename ----
The pencil / input pair itself is shared (.inline-rename[-input] in App.css);
only the row-local sizing and the refusal message live here. A node name is
longer than a device name (it carries a protocol and a host), so the field is
given more room than the shared 24ch default. */
.node-name-input {
max-width: 32ch;
font-family: var(--font-mono);
font-size: 12.5px;
}
/* Why a rename was refused, pinned under the row it was typed in. Semantic crit:
the name did not change, and that must not be mistaken for a saved edit. */
.row-err {
margin: 2px 0 0;
font-size: 11.5px;
line-height: 1.45;
color: var(--crit);
max-width: 68ch;
}
/* Stated once per subscription bucket: the same rule the locked control in every
row carries, so the absent rename is explained before it is looked for. */
.group-note {
margin: 0;
padding: 8px 12px;
border: 1px solid var(--groove);
border-top: 0;
background: color-mix(in srgb, var(--sink) 25%, transparent);
font-size: 11.5px;
line-height: 1.5;
color: var(--faint);
}
/* The optional name sits beside the link input on a wide row and drops onto its
own line when the row can no longer hold both. */
.add-name {
flex: 0 1 22ch;
min-width: 12ch;
}
.add-row--conf .add-name {
flex: none;
align-self: stretch;
}
@media (max-width: 640px) {
.add-row {
flex-wrap: wrap;
}
.add-name {
flex: 1 1 100%;
}
}
+638 -17
View File
@@ -2,16 +2,18 @@ import './Nodes.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { LedVariant } from '../components' import type { LedVariant } from '../components'
import { Button, Led, Toggle } from '../components' import { Button, Led, Toggle, useConfirm } from '../components'
import { import {
apply as apiApply, apply as apiApply,
getConfig, getConfig,
getStatus,
putConfig, putConfig,
importWg, importWg,
updateSubscription, updateSubscription,
ApiError, ApiError,
} from '../api' } from '../api'
import type { Model, Node as NodeCfg, Subscription } from '../api' import type { Model, Node as NodeCfg, StatusWarning, Subscription } from '../api'
import { entityFindings, findingsByName } from '../findings'
import { fmtBytes, fmtDate, fmtUntil } from '../format' import { fmtBytes, fmtDate, fmtUntil } from '../format'
// The whole page is a thin editor over the desired-state Model: every mutation // The whole page is a thin editor over the desired-state Model: every mutation
@@ -158,6 +160,219 @@ function uniqueName(base: string, taken: Set<string>): string {
return `${seed}-${i}` return `${seed}-${i}`
} }
// ---- node names are identity, not a caption --------------------------------
//
// A node's Name IS its sing-box outbound tag and the only thing every reference
// to it spells: a rule target `node:<name>`, a chain hop, a manual group's member
// list, a resolver detour, an alert delivery, a subscription fetch detour. Rename
// the node alone and every one of those points at nothing — and an unresolved
// target does NOT fall back to the default route, the daemon BLOCKS that traffic.
// So the rename either carries every reference with it, or it is refused.
/** Reserved outbound tags. A node called this is skipped by the generator entirely. */
const RESERVED_TAGS = ['direct', 'block']
/**
* Prefixes that `model.SplitTarget` reads as a KIND, not as part of a name. A
* name starting with one of them makes every bare reference to it ambiguous with
* a real `kind:name` reference, so it is refused rather than half-supported.
*/
const KIND_PREFIXES = ['node', 'group', 'egress', 'chain', 'direct', 'block']
/** Names are rendered into a line-oriented `uci export`; control chars are stripped there. */
function hasControlChar(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
if (c < 0x20 || c === 0x7f) return true
}
return false
}
/**
* Why `name` cannot be a node name here, or null if it can.
*
* Every rule mirrors something the daemon actually does with the name, not a
* house style: reserved tags make generate skip the node; a duplicate makes two
* outbounds share a tag and the manager silently keeps the last one; a group of
* the same name is dropped by buildGroups ("rename the group"); an
* `egress-<name>` collision takes over a real egress outbound; and a control
* character is rewritten to a space by sanitizeUCIValue on write, so the saved
* name would not be the one you typed.
*/
function nodeNameError(
raw: string,
m: Model | null,
self: string | null,
): string | null {
const name = raw.trim()
if (!name) return 'A node needs a name.'
if (hasControlChar(name))
return 'Names can’t contain line breaks or control characters — they’re stripped when the config is written.'
if (RESERVED_TAGS.some((t) => t.toLowerCase() === name.toLowerCase()))
return `“${name}” is a reserved target name — a node called that is skipped by the engine. Pick another.`
const head = name.includes(':') ? name.slice(0, name.indexOf(':')).toLowerCase() : ''
if (head && KIND_PREFIXES.includes(head))
return `A name starting with “${head}:” reads as a ${head} reference everywhere it’s used. Pick another.`
if (!m) return null
const clash = asArray(m.Nodes).find((n) => n.Name === name && n.Name !== self)
if (clash)
return clash.FromSub
? `“${name}” is already a node from subscription “${clash.FromSub}”. Two nodes with one name share a single outbound — pick another.`
: `“${name}” is already another node. Pick another.`
if (asArray(m.Groups).some((g) => g.Name === name))
return `A group is already named “${name}”. The engine drops the group when a node takes its name — pick another.`
const egressClash = asArray(m.Egresses).find((e) => `egress-${e.Name}` === name)
if (egressClash)
return `“${name}” is the outbound tag of egress “${egressClash.Name}”. Pick another.`
return null
}
/** One place a node name is written, as a short label for the rename summary. */
interface NodeRefSite {
/** Which section — drives the "N rules, M groups" count. */
kind: 'rule' | 'group' | 'chain' | 'resolver' | 'alert' | 'subscription' | 'egress'
label: string
}
/** A target/detour string naming this node in its prefixed form (`node:<name>`). */
const isNodeRef = (v: string | undefined | null, name: string): boolean =>
(v ?? '') === `node:${name}`
/** …or in the bare form the engine also resolves (a group member, a bare hop/target). */
const isBareRef = (v: string | undefined | null, name: string): boolean => (v ?? '') === name
/**
* Every place `name` is written outside the node itself. Both spellings count:
* `resolveTarget` falls through to a bare node lookup, and a manual group's
* member list is bare by contract.
*/
function findNodeReferences(m: Model, name: string): NodeRefSite[] {
const out: NodeRefSite[] = []
for (const r of asArray(m.Rules)) {
if (isNodeRef(r.Target, name) || isBareRef(r.Target, name))
out.push({ kind: 'rule', label: `rule “${r.Name}” target` })
}
for (const g of asArray(m.Groups)) {
if (asArray(g.Nodes).some((n) => n === name))
out.push({ kind: 'group', label: `group “${g.Name}” member` })
}
for (const c of asArray(m.Chains)) {
if (asArray(c.Hops).some((h) => isNodeRef(h, name) || isBareRef(h, name)))
out.push({ kind: 'chain', label: `chain “${c.Name}” hop` })
}
for (const r of asArray(m.Resolvers)) {
if (isNodeRef(r.Detour, name)) out.push({ kind: 'resolver', label: `resolver “${r.Name}” DNS path` })
}
for (const a of asArray(m.Alerts)) {
if (isNodeRef(a.Via, name)) out.push({ kind: 'alert', label: `alert “${a.Name}” delivery` })
}
for (const s of asArray(m.Subscriptions)) {
if (isNodeRef(s.FetchDetour, name))
out.push({ kind: 'subscription', label: `subscription “${s.Name}” fetch` })
}
for (const e of asArray(m.Egresses)) {
if (isNodeRef(e.Target, name)) out.push({ kind: 'egress', label: `egress “${e.Name}” target` })
}
return out
}
/**
* What makes a rename impossible to carry rather than merely wide.
*
* A BARE reference is just a name; the engine resolves it node-first, then group.
* If something else already answers to the old name, we cannot tell which object
* a bare reference meant, and rewriting it would move a reference the operator
* never pointed at this node. That is a half-done cascade, so the rename is
* refused instead — with the collision named, so it can be fixed.
*/
function bareAmbiguity(m: Model, name: string): string | null {
const group = asArray(m.Groups).find((g) => g.Name === name)
if (!group) return null
const bare = [
...asArray(m.Rules)
.filter((r) => isBareRef(r.Target, name))
.map((r) => `rule “${r.Name}”`),
...asArray(m.Chains)
.filter((c) => asArray(c.Hops).some((h) => isBareRef(h, name)))
.map((c) => `chain “${c.Name}”`),
]
if (bare.length === 0) return null
return `A group is also named “${name}”, and ${bare.join(', ')} point${bare.length === 1 ? 's' : ''} at that bare name — there is no way to tell which of the two is meant. Rename the group first, then this node.`
}
/**
* Rewrite every reference from `from` to `to`. Returns a NEW Model with only the
* touched sections replaced; the Nodes section is the caller's business.
*
* Bare references are rewritten too — that is the whole point for a manual
* group's member list — which is safe only because `bareAmbiguity` has already
* refused the one case where a bare name could mean something else.
*/
function renameNodeReferences(m: Model, from: string, to: string): Model {
if (from === to) return m
/** Prefixed-only sites (a detour is never spelled bare). */
const pfx = (v: string | undefined) => (isNodeRef(v, from) ? `node:${to}` : v)
/** Sites that accept either spelling — each is rewritten in the spelling it already uses. */
const either = (v: string | undefined) => {
if (isNodeRef(v, from)) return `node:${to}`
if (isBareRef(v, from)) return to
return v
}
const next: Model = { ...m }
if (m.Rules) next.Rules = m.Rules.map((r) => ({ ...r, Target: either(r.Target) }))
if (m.Groups)
next.Groups = m.Groups.map((g) => ({
...g,
Nodes: g.Nodes ? g.Nodes.map((n) => (n === from ? to : n)) : g.Nodes,
}))
if (m.Chains)
next.Chains = m.Chains.map((c) => ({
...c,
Hops: c.Hops ? c.Hops.map((h) => either(h) ?? h) : c.Hops,
}))
if (m.Resolvers) next.Resolvers = m.Resolvers.map((r) => ({ ...r, Detour: pfx(r.Detour) }))
if (m.Alerts) next.Alerts = m.Alerts.map((a) => ({ ...a, Via: pfx(a.Via) }))
if (m.Subscriptions)
next.Subscriptions = m.Subscriptions.map((s) => ({ ...s, FetchDetour: pfx(s.FetchDetour) }))
if (m.Egresses) next.Egresses = m.Egresses.map((e) => ({ ...e, Target: pfx(e.Target) }))
return next
}
/** "3 rules, 1 group and 2 chains" — what the rename is about to rewrite. */
function refSummary(refs: NodeRefSite[]): string {
const plural: Record<NodeRefSite['kind'], [string, string]> = {
rule: ['rule', 'rules'],
group: ['group', 'groups'],
chain: ['chain', 'chains'],
resolver: ['resolver', 'resolvers'],
alert: ['alert', 'alerts'],
subscription: ['subscription', 'subscriptions'],
egress: ['egress', 'egresses'],
}
const order: NodeRefSite['kind'][] = [
'rule', 'group', 'chain', 'resolver', 'alert', 'subscription', 'egress',
]
const parts = order
.map((k) => [k, refs.filter((r) => r.kind === k).length] as const)
.filter(([, n]) => n > 0)
.map(([k, n]) => `${n} ${plural[k][n === 1 ? 0 : 1]}`)
if (parts.length === 1) return parts[0]
return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
}
/**
* The name a rename just committed to, waiting for its row to come back.
*
* A row is keyed by the node's NAME, so committing a rename unmounts the row and
* mounts a different one — carrying the focused element away with it. This baton
* survives that remount: the row that reappears under the new name claims it and
* puts the keyboard back on its own rename button, instead of dropping the user
* on <body> halfway down a list of 300 nodes.
*/
let pendingRenameFocus: string | null = null
// A subscription with more than this many nodes starts collapsed so the list // A subscription with more than this many nodes starts collapsed so the list
// doesn't become one endless scroll; an active search overrides it. // doesn't become one endless scroll; an active search overrides it.
const LARGE_GROUP = 20 const LARGE_GROUP = 20
@@ -289,6 +504,7 @@ function DetourSelect({
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Nodes() { export default function Nodes() {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -305,6 +521,43 @@ export default function Nodes() {
void loadConfig() void loadConfig()
}, [loadConfig]) }, [loadConfig])
// ---- what the last apply said about these nodes ---------------------------
//
// The generator drops a node it cannot build and names it: an unparseable
// share link (generate/outbound.go), a name colliding with a reserved tag, a
// WireGuard private key materialised twice (generate/wgdedup.go). Until now
// this page never read /api/status, so a node the engine had thrown away
// rendered as an ordinary row with a green toggle — the switch said on and
// there was no such outbound anywhere in the running config.
//
// Findings are attached to the ROWS, not summarised at the top: a 300-node
// subscription makes a list of names useless, and the row is where the false
// reassurance was.
const [findings, setFindings] = useState<StatusWarning[]>([])
const loadFindings = useCallback(async () => {
try {
const s = await getStatus()
setFindings(entityFindings(s.warnings, ['node', 'subscription']))
} catch {
// Status is a supplement here, not the page. Keep the last set rather than
// clearing it — a dropped poll is not the same as "the problem is fixed".
}
}, [])
useEffect(() => {
void loadFindings()
}, [loadFindings])
const nodeFindings = useMemo(
() => findingsByName(findings.filter((w) => w.section === 'node')),
[findings],
)
const subFindings = useMemo(
() => findingsByName(findings.filter((w) => w.section === 'subscription')),
[findings],
)
// Findings about nodes/subscriptions in general, which belong to no single row.
const globalFindings = useMemo(() => findings.filter((w) => !w.name), [findings])
// ---- toast + persistent apply banner -------------------------------------- // ---- toast + persistent apply banner --------------------------------------
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
const toastTimer = useRef<number | undefined>(undefined) const toastTimer = useRef<number | undefined>(undefined)
@@ -359,8 +612,11 @@ export default function Nodes() {
flash(`Apply failed — ${errText(e)}`) flash(`Apply failed — ${errText(e)}`)
} finally { } finally {
setApplying(false) setApplying(false)
// An apply is exactly what rewrites the findings — including clearing the
// ones the operator just fixed.
void loadFindings()
} }
}, [flash, loadConfig]) }, [flash, loadConfig, loadFindings])
// ---- node mutations ------------------------------------------------------- // ---- node mutations -------------------------------------------------------
const nodes = useMemo(() => asArray(config?.Nodes), [config]) const nodes = useMemo(() => asArray(config?.Nodes), [config])
@@ -385,6 +641,9 @@ export default function Nodes() {
const [nodeInput, setNodeInput] = useState('') const [nodeInput, setNodeInput] = useState('')
const [nodeErr, setNodeErr] = useState<string | null>(null) const [nodeErr, setNodeErr] = useState<string | null>(null)
const [addMode, setAddMode] = useState<'link' | 'conf'>('link') const [addMode, setAddMode] = useState<'link' | 'conf'>('link')
// Optional. Empty keeps the old behaviour (a name derived from the server
// address), so "paste a link, press Add" stays a two-step path.
const [nodeName, setNodeName] = useState('')
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
// ---- node search + collapsible grouping ----------------------------------- // ---- node search + collapsible grouping -----------------------------------
@@ -445,8 +704,12 @@ export default function Nodes() {
try { try {
const { uri, name } = await importWg(conf) const { uri, name } = await importWg(conf)
const taken = new Set(nodes.map((n) => n.Name)) const taken = new Set(nodes.map((n) => n.Name))
// A typed name is used AS TYPED — uniqueName would silently turn a
// collision into "name-2", which is the confusion this field exists to
// end. It is validated instead, and a clash is refused out loud above.
const wanted = nodeName.trim()
const node: NodeCfg = { const node: NodeCfg = {
Name: uniqueName(name || 'wireguard', taken), Name: wanted || uniqueName(name || 'wireguard', taken),
Enabled: true, Enabled: true,
URI: uri, URI: uri,
FromSub: '', FromSub: '',
@@ -455,6 +718,7 @@ export default function Nodes() {
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`) const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
if (ok) { if (ok) {
setNodeInput('') setNodeInput('')
setNodeName('')
setAddMode('link') setAddMode('link')
} }
} catch (e) { } catch (e) {
@@ -463,11 +727,20 @@ export default function Nodes() {
setImporting(false) setImporting(false)
} }
}, },
[config, nodes, save, flash], [config, nodes, nodeName, save, flash],
) )
const addNode = useCallback(async () => { const addNode = useCallback(async () => {
if (!config) return if (!config) return
// The name is checked BEFORE the import round-trip, so a bad name costs
// nothing and the message lands in the form next to the field.
if (nodeName.trim()) {
const bad = nodeNameError(nodeName, config, null)
if (bad) {
setNodeErr(bad)
return
}
}
// Auto-detect a pasted config, whichever input it landed in. // Auto-detect a pasted config, whichever input it landed in.
if (nodeInput.includes(WG_MARKER)) { if (nodeInput.includes(WG_MARKER)) {
await addWgConf(nodeInput) await addWgConf(nodeInput)
@@ -486,10 +759,20 @@ export default function Nodes() {
const parsed = parseShareLink(uri) const parsed = parseShareLink(uri)
const taken = new Set(nodes.map((n) => n.Name)) const taken = new Set(nodes.map((n) => n.Name))
const base = parsed.suggested || `${parsed.proto.toLowerCase()}-${parsed.host}`.replace(/[^\w.:-]+/g, '-') const base = parsed.suggested || `${parsed.proto.toLowerCase()}-${parsed.host}`.replace(/[^\w.:-]+/g, '-')
const node: NodeCfg = { Name: uniqueName(base, taken), Enabled: true, URI: uri, FromSub: '', Egress: '' } const wanted = nodeName.trim()
const node: NodeCfg = {
Name: wanted || uniqueName(base, taken),
Enabled: true,
URI: uri,
FromSub: '',
Egress: '',
}
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`) const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
if (ok) setNodeInput('') if (ok) {
}, [config, nodeInput, nodes, save, addMode, addWgConf]) setNodeInput('')
setNodeName('')
}
}, [config, nodeInput, nodeName, nodes, save, addMode, addWgConf])
const toggleNode = useCallback( const toggleNode = useCallback(
(idx: number, on: boolean) => { (idx: number, on: boolean) => {
@@ -500,15 +783,110 @@ export default function Nodes() {
[config, nodes, save], [config, nodes, save],
) )
/**
* Delete a node, naming everything that still points at it.
*
* `findNodeReferences` was already here and already right — it just wasn't asked
* on the one path where the answer matters. A RENAME carried its references and
* said so; a DELETE said "This removes it from the config", which is true of the
* node and silent about the rule, group member, chain hop or resolver detour
* left spelling a name nothing answers to. That is not a cosmetic dangle: an
* unresolved target does not fall through to the default route, so the traffic
* aimed at it is blocked.
*/
const removeNode = useCallback( const removeNode = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = nodes[idx] const target = nodes[idx]
if (!window.confirm(`Delete node “${target.Name}”? This removes it from the config.`)) return const refs = findNodeReferences(config, target.Name)
const shown = refs.slice(0, 4).map((r) => r.label)
const more = refs.length - shown.length
const ok = await confirm({
label: 'Delete node',
title: `Delete node “${target.Name}”?`,
body:
refs.length === 0
? 'Nothing else in the config points at it.'
: `${refSummary(refs)} still ${refs.length === 1 ? 'points' : 'point'} at it — ${shown.join(
', ',
)}${
more > 0 ? `, and ${more} more` : ''
}. Nothing rewrites them, and a target that no longer resolves does not fall through to the default route: the traffic aimed at it is blocked.`,
})
if (!ok) return
const next = nodes.filter((_, i) => i !== idx) const next = nodes.filter((_, i) => i !== idx)
void save({ ...config, Nodes: next }, `Deleted ${target.Name}`) void save({ ...config, Nodes: next }, `Deleted ${target.Name}`)
}, },
[config, nodes, save], [config, nodes, save, confirm],
)
/**
* Rename a manual node, carrying every reference with it.
*
* The name is this node's identity: its outbound tag, and the exact string a
* rule target, a chain hop, a manual group's member list, a resolver detour, an
* alert delivery and a subscription fetch detour all spell. So the rename is one
* atomic save of the Nodes section AND every referencing section, or it does not
* happen at all:
*
* - an invalid or colliding name is refused with the reason (`nodeNameError`);
* - a name a GROUP also answers to, with bare references pointing at it, is
* refused too — there is no way to know which object those meant, and
* guessing would move a reference the operator never pointed here;
* - anything else is shown exactly what it will rewrite, and only then saved.
*
* Errors surface through `onError` so they land in the row that was edited.
*/
const renameNode = useCallback(
async (idx: number, raw: string, onError: (msg: string) => void): Promise<boolean> => {
if (!config) return false
const target = nodes[idx]
const from = target.Name
const to = raw.trim()
if (to === from) return true
// Subscription names come back from the feed on the next update; renaming
// one would be undone without warning, so this path is manual-only.
if (target.FromSub) {
onError(`“${from}” is named by subscription “${target.FromSub}” — the feed rewrites it on the next update.`)
return false
}
const bad = nodeNameError(to, config, from)
if (bad) {
onError(bad)
return false
}
const blocked = bareAmbiguity(config, from)
if (blocked) {
onError(blocked)
return false
}
const refs = findNodeReferences(config, from)
if (refs.length > 0) {
const shown = refs.slice(0, 4).map((r) => r.label)
const more = refs.length - shown.length
const ok = await confirm({
tone: 'neutral',
label: 'Rename node',
title: `Rename “${from}” to “${to}”?`,
body: `This also updates ${refSummary(refs)} that point at it — ${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}. They are saved together, so nothing is left pointing at the old name.`,
confirmLabel: 'Rename',
})
if (!ok) return false
}
// One PUT: the node and every reference move in the same write, so no
// intermediate state exists where a reference dangles.
const carried = renameNodeReferences(config, from, to)
const next = asArray(carried.Nodes).map((n, i) => (i === idx ? { ...n, Name: to } : n))
return save(
{ ...carried, Nodes: next },
refs.length > 0
? `Renamed to ${to} — updated ${refs.length} reference${refs.length === 1 ? '' : 's'}`
: `Renamed to ${to}`,
)
},
[config, nodes, save, confirm],
) )
// Pin (or clear) one node's dial egress. Same optimistic save→apply path as // Pin (or clear) one node's dial egress. Same optimistic save→apply path as
@@ -563,16 +941,20 @@ export default function Nodes() {
) )
const removeSub = useCallback( const removeSub = useCallback(
(idx: number) => { async (idx: number) => {
if (!config) return if (!config) return
const target = subs[idx] const target = subs[idx]
const hasCache = nodes.some((n) => n.FromSub === target.Name) const hasCache = nodes.some((n) => n.FromSub === target.Name)
const extra = hasCache ? ' Its cached nodes stay until you next apply.' : '' const ok = await confirm({
if (!window.confirm(`Delete subscription “${target.Name}”?${extra}`)) return label: 'Delete subscription',
title: `Delete subscription “${target.Name}”?`,
body: hasCache ? 'Its cached nodes stay until you next apply.' : undefined,
})
if (!ok) return
const next = subs.filter((_, i) => i !== idx) const next = subs.filter((_, i) => i !== idx)
void save({ ...config, Subscriptions: next }, `Deleted ${target.Name}`) void save({ ...config, Subscriptions: next }, `Deleted ${target.Name}`)
}, },
[config, subs, nodes, save], [config, subs, nodes, save, confirm],
) )
// Commit an options edit for one subscription. The editor hands back a fully // Commit an options edit for one subscription. The editor hands back a fully
@@ -654,6 +1036,14 @@ export default function Nodes() {
</div> </div>
)} )}
{/* Findings about nodes in general — no single row owns them, so they sit
above the lists rather than being dropped for having no name. */}
{globalFindings.length > 0 && (
<div className="node-findings">
<RowFindings findings={globalFindings} />
</div>
)}
{/* ---- NODES ---- */} {/* ---- NODES ---- */}
<div className="node-section" aria-label="Nodes"> <div className="node-section" aria-label="Nodes">
<header className="sec-hd"> <header className="sec-hd">
@@ -736,6 +1126,20 @@ export default function Nodes() {
disabled={busy || importing || !config} disabled={busy || importing || !config}
/> />
)} )}
<input
className="fp-input add-name"
type="text"
spellCheck={false}
autoComplete="off"
placeholder="Name (optional)"
aria-label="Node name — optional"
value={nodeName}
onChange={(e) => {
setNodeName(e.target.value)
if (nodeErr) setNodeErr(null)
}}
disabled={busy || importing || !config}
/>
<Button type="submit" variant="primary" disabled={busy || importing || !config}> <Button type="submit" variant="primary" disabled={busy || importing || !config}>
{importing ? 'Importing…' : saving ? 'Saving…' : addMode === 'conf' ? 'Import' : 'Add node'} {importing ? 'Importing…' : saving ? 'Saving…' : addMode === 'conf' ? 'Import' : 'Add node'}
</Button> </Button>
@@ -743,7 +1147,8 @@ export default function Nodes() {
<p className="add-hint"> <p className="add-hint">
{addMode === 'conf' {addMode === 'conf'
? 'Paste a wg-quick / AmneziaWG .conf — it starts with [Interface].' ? 'Paste a wg-quick / AmneziaWG .conf — it starts with [Interface].'
: 'vless://, ss://, trojan://, hysteria2://… A pasted [Interface] config is imported automatically.'} : 'vless://, ss://, trojan://, hysteria2://… A pasted [Interface] config is imported automatically.'}{' '}
Leave the name empty and it’s taken from the server address; you can rename it later.
</p> </p>
</div> </div>
{nodeErr && ( {nodeErr && (
@@ -794,12 +1199,14 @@ export default function Nodes() {
<NodeGroup <NodeGroup
key={g.key || '__manual__'} key={g.key || '__manual__'}
group={g} group={g}
findings={nodeFindings}
open={isGroupOpen(g)} open={isGroupOpen(g)}
busy={busy} busy={busy}
egressNames={egressNames} egressNames={egressNames}
onToggle={() => toggleGroup(g)} onToggle={() => toggleGroup(g)}
onToggleNode={toggleNode} onToggleNode={toggleNode}
onRemoveNode={removeNode} onRemoveNode={removeNode}
onRenameNode={renameNode}
onSetEgress={setNodeEgress} onSetEgress={setNodeEgress}
/> />
))} ))}
@@ -882,6 +1289,7 @@ export default function Nodes() {
busy={busy} busy={busy}
catalog={detourCatalog} catalog={detourCatalog}
valid={detourValid} valid={detourValid}
findings={subFindings.get(s.Name) ?? EMPTY_FINDINGS}
onToggle={(on) => toggleSub(i, on)} onToggle={(on) => toggleSub(i, on)}
onDelete={() => removeSub(i)} onDelete={() => removeSub(i)}
onEdit={(patch) => editSub(i, patch)} onEdit={(patch) => editSub(i, patch)}
@@ -908,23 +1316,35 @@ function NodeGroup({
open, open,
busy, busy,
egressNames, egressNames,
findings,
onToggle, onToggle,
onToggleNode, onToggleNode,
onRemoveNode, onRemoveNode,
onRenameNode,
onSetEgress, onSetEgress,
}: { }: {
group: NodeGroupData group: NodeGroupData
open: boolean open: boolean
busy: boolean busy: boolean
egressNames: string[] egressNames: string[]
/** Last-apply findings per node name (findings.ts findingsByName). */
findings: Map<string, StatusWarning[]>
onToggle: () => void onToggle: () => void
onToggleNode: (idx: number, on: boolean) => void onToggleNode: (idx: number, on: boolean) => void
onRemoveNode: (idx: number) => void onRemoveNode: (idx: number) => void
onRenameNode: (idx: number, name: string, onError: (msg: string) => void) => Promise<boolean>
onSetEgress: (idx: number, egress: string) => Promise<boolean> onSetEgress: (idx: number, egress: string) => Promise<boolean>
}) { }) {
const panelId = `node-group-${group.key || 'manual'}` const panelId = `node-group-${group.key || 'manual'}`
// The same inventory count as the section header, scoped to this bucket. // The same inventory count as the section header, scoped to this bucket.
const count = useMemo(() => fmtEnabled(group.items.map((i) => i.node)), [group.items]) const count = useMemo(() => fmtEnabled(group.items.map((i) => i.node)), [group.items])
// How many nodes in this bucket the last apply had something to say about —
// shown on the COLLAPSED header, because a subscription of 300 nodes is
// collapsed by default and the row badge below would never be seen otherwise.
const flagged = useMemo(
() => group.items.filter(({ node }) => findings.has(node.Name)).length,
[group.items, findings],
)
return ( return (
<section className={`node-group${open ? ' node-group--open' : ''}`}> <section className={`node-group${open ? ' node-group--open' : ''}`}>
<h3 className="group-hd-wrap"> <h3 className="group-hd-wrap">
@@ -938,8 +1358,20 @@ function NodeGroup({
<span className="group-caret" aria-hidden="true" /> <span className="group-caret" aria-hidden="true" />
<span className="group-name">{group.label}</span> <span className="group-name">{group.label}</span>
<span className="group-count mono">{count}</span> <span className="group-count mono">{count}</span>
{flagged > 0 && (
<span className="group-flagged" title="Findings from the last apply">
<Led variant="amber" />
{flagged} flagged
</span>
)}
</button> </button>
</h3> </h3>
{open && group.key !== '' && (
<p className="group-note">
Names come from the subscription feed and are rewritten on every update, so nodes in this
list can’t be renamed here.
</p>
)}
{open && ( {open && (
<ul id={panelId} className="rows-list group-rows"> <ul id={panelId} className="rows-list group-rows">
{group.items.map(({ node, idx }) => ( {group.items.map(({ node, idx }) => (
@@ -948,8 +1380,10 @@ function NodeGroup({
node={node} node={node}
busy={busy} busy={busy}
egressNames={egressNames} egressNames={egressNames}
findings={findings.get(node.Name) ?? EMPTY_FINDINGS}
onToggle={(on) => onToggleNode(idx, on)} onToggle={(on) => onToggleNode(idx, on)}
onDelete={() => onRemoveNode(idx)} onDelete={() => onRemoveNode(idx)}
onRename={(name, onError) => onRenameNode(idx, name, onError)}
onSetEgress={(egress) => onSetEgress(idx, egress)} onSetEgress={(egress) => onSetEgress(idx, egress)}
/> />
))} ))}
@@ -959,19 +1393,42 @@ function NodeGroup({
) )
} }
/** One shared empty array, so a clean row doesn't get a fresh identity per render. */
const EMPTY_FINDINGS: StatusWarning[] = []
/**
* Did the generator say it left this entity OUT of the engine config?
*
* The producers all end the sentence with the same word — "(skipped)" for an
* unparseable share link or a bad WireGuard endpoint (generate/outbound.go),
* "skipped" for a name colliding with a reserved tag — and wgdedup says only one
* of the duplicates "is kept". Read the daemon's word rather than inventing a
* verdict: a finding that does NOT say this may well be about a node that is
* running perfectly, and badging it "not built" would be a new lie in place of
* the old one.
*/
function skipped(findings: StatusWarning[]): boolean {
return findings.some((f) => /\bskipped\b|\bis kept\b/i.test(f.message))
}
function NodeRow({ function NodeRow({
node, node,
busy, busy,
egressNames, egressNames,
findings,
onToggle, onToggle,
onDelete, onDelete,
onRename,
onSetEgress, onSetEgress,
}: { }: {
node: NodeCfg node: NodeCfg
busy: boolean busy: boolean
egressNames: string[] egressNames: string[]
/** What the last apply said about THIS node; empty when it said nothing. */
findings: StatusWarning[]
onToggle: (on: boolean) => void onToggle: (on: boolean) => void
onDelete: () => void onDelete: () => void
onRename: (name: string, onError: (msg: string) => void) => Promise<boolean>
onSetEgress: (egress: string) => Promise<boolean> onSetEgress: (egress: string) => Promise<boolean>
}) { }) {
const { proto, host, hasCreds } = useMemo(() => parseShareLink(node.URI), [node.URI]) const { proto, host, hasCreds } = useMemo(() => parseShareLink(node.URI), [node.URI])
@@ -982,6 +1439,68 @@ function NodeRow({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const panelId = `node-egress-${node.FromSub || 'manual'}-${node.Name}` const panelId = `node-egress-${node.FromSub || 'manual'}-${node.Name}`
// ---- inline rename (same interaction as a device row) ---------------------
// Enter commits, Esc cancels, blur commits; a ref-guard keeps Esc-then-blur
// from committing twice. Unlike a device, the commit can be REFUSED (a name
// collision, or references that can't be carried), so the input stays open
// with the reason under it instead of closing on a change that never happened.
const [renaming, setRenaming] = useState(false)
const [draft, setDraft] = useState(node.Name)
const [renameErr, setRenameErr] = useState<string | null>(null)
const nameInput = useRef<HTMLInputElement>(null)
const renameBtn = useRef<HTMLButtonElement>(null)
const finished = useRef(false)
const beginRename = () => {
setDraft(node.Name)
setRenameErr(null)
finished.current = false
setRenaming(true)
}
const finishRename = async (commit: boolean) => {
if (finished.current) return
finished.current = true
const nm = draft.trim()
if (!commit || !nm || nm === node.Name) {
setRenaming(false)
setRenameErr(null)
return
}
// Armed BEFORE the save: the renamed row remounts the moment the config
// state lands, which is before this await resolves. Arming afterwards would
// always miss it.
pendingRenameFocus = nm
const ok = await onRename(nm, (msg) => setRenameErr(msg))
if (ok) {
setRenaming(false)
setRenameErr(null)
} else {
if (pendingRenameFocus === nm) pendingRenameFocus = null
// Refused — hold the field open on the rejected text so it can be fixed.
finished.current = false
nameInput.current?.focus()
}
}
useEffect(() => {
if (renaming) {
nameInput.current?.focus()
nameInput.current?.select()
}
}, [renaming])
// Claim the baton if this row is the one the rename produced. The row remounts
// while the PUT is still in flight, so on that first pass the button is still
// disabled and focus() would be a silent no-op — the baton is held until the
// save settles and this effect re-runs with a focusable button.
useEffect(() => {
if (pendingRenameFocus !== node.Name) return
const btn = renameBtn.current
if (!btn || btn.disabled) return
pendingRenameFocus = null
btn.focus()
}, [node.Name, busy])
return ( return (
<li className={`row-item node-row${open ? ' node-row--open' : ''}`}> <li className={`row-item node-row${open ? ' node-row--open' : ''}`}>
<div className="row-head"> <div className="row-head">
@@ -993,10 +1512,83 @@ function NodeRow({
/> />
<div className="row-main"> <div className="row-main">
<div className="row-line1"> <div className="row-line1">
<span className="row-name">{node.Name}</span> {renaming ? (
<input
ref={nameInput}
className="inline-rename-input node-name-input mono"
type="text"
spellCheck={false}
autoComplete="off"
value={draft}
aria-label={`Rename node ${node.Name}`}
aria-invalid={renameErr ? true : undefined}
onChange={(e) => {
setDraft(e.target.value)
if (renameErr) setRenameErr(null)
}}
onBlur={() => void finishRename(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void finishRename(true)
} else if (e.key === 'Escape') {
e.preventDefault()
void finishRename(false)
}
}}
disabled={busy}
/>
) : (
<>
<span className="row-name" title={node.Name}>
{node.Name}
</span>
{managed ? (
// Not hidden — withheld, with the reason attached. A control
// that quietly isn't there reads as a bug; this one states the
// rule, and the same sentence is on the group header above.
<button
type="button"
className="inline-rename inline-rename--locked"
disabled
aria-label={`Can’t rename ${node.Name} — its name comes from subscription “${node.FromSub}” and is rewritten on the next update`}
title={`Named by subscription “${node.FromSub}” — the feed rewrites this name on the next update. Rename it in the subscription, or add the node manually.`}
>
🔒
</button>
) : (
<button
ref={renameBtn}
type="button"
className="inline-rename"
onClick={beginRename}
disabled={busy}
aria-label={`Rename node ${node.Name}`}
title="Rename"
>
✎
</button>
)}
</>
)}
<span className="badge">{proto}</span> <span className="badge">{proto}</span>
{node.Stale && <span className="badge badge--warn">stale</span>} {node.Stale && <span className="badge badge--warn">stale</span>}
{/* The toggle above is the SAVED state. When the last apply couldn't
build this node the engine has no such outbound, and the two
disagree — so the row says which, rather than leaving a green
switch to imply the node is carrying traffic. The word is the
daemon's own where it used one. */}
{findings.length > 0 && (
<span className={`badge badge--${skipped(findings) ? 'crit' : 'warn'}`}>
{skipped(findings) ? 'not built' : 'flagged'}
</span>
)}
</div> </div>
{renameErr && (
<p className="row-err" role="alert">
{renameErr}
</p>
)}
<div className="row-line2 mono"> <div className="row-line2 mono">
<span className="row-host">{host}</span> <span className="row-host">{host}</span>
{hasCreds && ( {hasCreds && (
@@ -1014,6 +1606,7 @@ function NodeRow({
</span> </span>
)} )}
</div> </div>
<RowFindings findings={findings} />
</div> </div>
<div className="row-actions"> <div className="row-actions">
{canPin && ( {canPin && (
@@ -1133,6 +1726,7 @@ function SubRow({
busy, busy,
catalog, catalog,
valid, valid,
findings,
onToggle, onToggle,
onDelete, onDelete,
onEdit, onEdit,
@@ -1143,6 +1737,8 @@ function SubRow({
busy: boolean busy: boolean
catalog: DetourCatalog catalog: DetourCatalog
valid: Set<string> valid: Set<string>
/** What the last apply said about THIS subscription; empty when it said nothing. */
findings: StatusWarning[]
onToggle: (on: boolean) => void onToggle: (on: boolean) => void
onDelete: () => void onDelete: () => void
onEdit: (patch: Subscription) => Promise<boolean> onEdit: (patch: Subscription) => Promise<boolean>
@@ -1167,6 +1763,7 @@ function SubRow({
<span className="row-name">{sub.Name}</span> <span className="row-name">{sub.Name}</span>
{sub.Format && sub.Format !== 'auto' && <span className="badge">{sub.Format}</span>} {sub.Format && sub.Format !== 'auto' && <span className="badge">{sub.Format}</span>}
{sub.FetchVia === 'proxy' && <span className="badge">via proxy</span>} {sub.FetchVia === 'proxy' && <span className="badge">via proxy</span>}
{findings.length > 0 && <span className="badge badge--warn">flagged</span>}
</div> </div>
<div className="row-line2 mono"> <div className="row-line2 mono">
<span className="row-host">{host}</span> <span className="row-host">{host}</span>
@@ -1179,6 +1776,7 @@ function SubRow({
every {interval} · {count} node{count === 1 ? '' : 's'} every {interval} · {count} node{count === 1 ? '' : 's'}
</span> </span>
</div> </div>
<RowFindings findings={findings} />
</div> </div>
<div className="row-actions"> <div className="row-actions">
<Button <Button
@@ -1650,6 +2248,29 @@ function HeaderRows({
) )
} }
/**
* What the last apply said about THIS row, under the row it is about.
*
* Deliberately inside the row rather than in a list at the top of the page: the
* failure being fixed is a node that looks fine, and a name in a summary three
* screens up does not fix that. The wording is the daemon's own — these messages
* already name the entity and say what was done about it ("(skipped)", "only X
* is kept"), so paraphrasing them here would only invent a second vocabulary.
*/
function RowFindings({ findings }: { findings: StatusWarning[] }) {
if (findings.length === 0) return null
return (
<ul className="row-findings" aria-label="Findings from the last apply">
{findings.map((f, i) => (
<li key={i} className={`row-finding row-finding--${f.severity}`}>
<Led variant={f.severity === 'critical' ? 'crit' : 'amber'} />
<span className="row-finding-msg">{f.message}</span>
</li>
))}
</ul>
)
}
function EmptyPlate({ title, body }: { title: string; body: string }) { function EmptyPlate({ title, body }: { title: string; body: string }) {
return ( return (
<div className="empty-plate"> <div className="empty-plate">
+171 -54
View File
@@ -4,17 +4,18 @@ import type { LedVariant } from '../components'
import { fmtDateTime, fmtDuration } from '../format' import { fmtDateTime, fmtDuration } from '../format'
import { import {
apply as apiApply, apply as apiApply,
confirm as apiConfirm,
rollback as apiRollback, rollback as apiRollback,
getConfig, getConfig,
getRulesReachability,
getStats, getStats,
ApiError, ApiError,
} from '../api' } from '../api'
import type { Model, Stats, Status, StatusWarning } from '../api' import type { Model, Stats, Status, StatusWarning } from '../api'
import { confirmTimeout } from '../pendingConfirm'
import { navigate } from '../router' import { navigate } from '../router'
import type { Route } from '../router' import type { Route } from '../router'
import { attentionFindings } from '../findings' import { attentionFindings, truncationNote } from '../findings'
import { protectionState } from '../planeState' import { engineReadout, killSwitchReadout, protectionState } from '../planeState'
// null-safe length for a Go slice that may arrive as null. // null-safe length for a Go slice that may arrive as null.
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0) const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
@@ -27,7 +28,8 @@ function short(hash: string): string {
return h.length > 12 ? h.slice(0, 12) : h return h.length > 12 ? h.slice(0, 12) : h
} }
type ControlKind = 'apply' | 'confirm' | 'rollback' // Confirm is no longer one of them — see the note beside the controls row.
type ControlKind = 'apply' | 'rollback'
/** /**
* Live service uptime in seconds, ticking between status polls. * Live service uptime in seconds, ticking between status polls.
@@ -42,17 +44,32 @@ type ControlKind = 'apply' | 'confirm' | 'rollback'
* Returns null when the daemon doesn't report uptime (older builds) — the caller * Returns null when the daemon doesn't report uptime (older builds) — the caller
* then renders nothing rather than inventing a number. * then renders nothing rather than inventing a number.
*/ */
function useUptime(status: Status | null): number | null { function useUptime(status: Status | null): { seconds: number; startedUnix: number } | null {
const base = useRef<{ uptime: number; at: number } | null>(null) const base = useRef<{ uptime: number; at: number } | null>(null)
// The instant the daemon came up, ON THE BROWSER'S CLOCK.
//
// `status.started_unix` is the router's own clock, and the router has no RTC —
// it runs on UTC with no tzdata. Rendering it through the browser's timezone
// printed a start time three hours in the FUTURE for a Moscow operator, beside
// an uptime of "2 h 41 min". Deriving it instead as now-minus-uptime is a
// difference of two client timestamps, so it is skew-proof and can never land
// ahead of the clock in the header.
const started = useRef<number | null>(null)
const [, forceTick] = useState(0) const [, forceTick] = useState(0)
const reported = status?.uptime_seconds const reported = status?.uptime_seconds
useEffect(() => { useEffect(() => {
if (typeof reported !== 'number' || !Number.isFinite(reported)) { if (typeof reported !== 'number' || !Number.isFinite(reported)) {
base.current = null base.current = null
started.current = null
return return
} }
base.current = { uptime: reported, at: Date.now() } const now = Date.now()
base.current = { uptime: reported, at: now }
// Re-baselining every poll would jitter the displayed second back and forth;
// only move it when the estimate has genuinely drifted (a daemon restart).
const est = Math.round(now / 1000 - reported)
if (started.current === null || Math.abs(started.current - est) > 5) started.current = est
forceTick((n) => n + 1) forceTick((n) => n + 1)
}, [reported]) }, [reported])
@@ -64,8 +81,11 @@ function useUptime(status: Status | null): number | null {
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, []) }, [])
if (!base.current) return null if (!base.current || started.current === null) return null
return base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000) return {
seconds: base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000),
startedUnix: started.current,
}
} }
export function Overview({ export function Overview({
@@ -93,6 +113,29 @@ export function Overview({
void loadConfig() void loadConfig()
}, [loadConfig]) }, [loadConfig])
// ---- how many rules are actually IN FORCE ----------------------------------
//
// `Rule.Enabled` from /api/config is the DESIRED state; the active WAN profile
// overrides it in either direction, and the daemon reports the result as
// `effective_enabled`. Counting the saved switches told a router running one
// chain that it had "2 / 2" — the Routing page had already been fixed to read
// the verdicts, and the home page kept summing the config beside it.
//
// null ⇒ no verdicts (older daemon, engine stopped, endpoint unreachable). The
// module then says so rather than passing the saved count off as the live one.
const [inForce, setInForce] = useState<number | null>(null)
const loadReach = useCallback(async () => {
try {
const { rules } = await getRulesReachability()
setInForce(rules.filter((r) => r.effective_enabled).length)
} catch {
setInForce(null)
}
}, [])
useEffect(() => {
void loadReach()
}, [loadReach])
// ---- live filter stats: poll the aggregate snapshot, degrade to honest empty states ---- // ---- live filter stats: poll the aggregate snapshot, degrade to honest empty states ----
const [stats, setStats] = useState<Stats | null>(null) const [stats, setStats] = useState<Stats | null>(null)
useEffect(() => { useEffect(() => {
@@ -121,7 +164,7 @@ export function Overview({
} }
}, []) }, [])
// ---- apply / confirm / rollback ---- // ---- apply / rollback ----
const [busy, setBusy] = useState<ControlKind | null>(null) const [busy, setBusy] = useState<ControlKind | null>(null)
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null) const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
@@ -139,22 +182,25 @@ export function Overview({
setBusy(kind) setBusy(kind)
setResult(null) setResult(null)
try { try {
const fn = kind === 'apply' ? apiApply : kind === 'confirm' ? apiConfirm : apiRollback const r = kind === 'apply' ? await apiApply() : await apiRollback()
const r = await fn()
if (r.error) { if (r.error) {
setResult({ ok: false, msg: r.error }) setResult({ ok: false, msg: r.error })
flash(`${kind} failed`) flash(`${kind} failed`)
} else { } else {
// An apply that changed something armed an auto-rollback, and saying
// "data plane reconciled" while a timer runs is how someone walks away
// from a config that then reverts. Name the window when there is one.
const window = confirmTimeout()
const msg = const msg =
kind === 'apply' kind === 'apply'
? r.changed ? r.changed
? 'Applied — data plane reconciled' ? window > 0
? `Applied — keep this config within ${window}s or it rolls back`
: 'Applied — data plane reconciled'
: 'Applied — already up to date' : 'Applied — already up to date'
: kind === 'confirm' : 'Rolled back to last-good config'
? 'Confirmed — auto-rollback cancelled'
: 'Rolled back to last-good config'
setResult({ ok: true, msg }) setResult({ ok: true, msg })
flash(kind === 'apply' ? 'Applied' : kind === 'confirm' ? 'Confirmed' : 'Rolled back') flash(kind === 'apply' ? 'Applied' : 'Rolled back')
} }
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'request failed' const msg = e instanceof Error ? e.message : 'request failed'
@@ -163,16 +209,20 @@ export function Overview({
} finally { } finally {
setBusy(null) setBusy(null)
onStatusChange() onStatusChange()
if (kind !== 'confirm') void loadConfig() void loadConfig()
// An apply or a rollback is exactly what changes which rules are in force.
void loadReach()
} }
}, },
[flash, loadConfig, onStatusChange], [flash, loadConfig, loadReach, onStatusChange],
) )
// ---- service uptime (PROCESS uptime, not "time since the last apply") ---- // ---- service uptime (PROCESS uptime, not "time since the last apply") ----
const uptime = useUptime(status) const uptime = useUptime(status)
const uptimeText = uptime === null ? '' : fmtDuration(uptime) const uptimeText = uptime === null ? '' : fmtDuration(uptime.seconds)
const startedAt = status?.started_unix ? fmtDateTime(status.started_unix) : '' // On YOUR clock, derived from the uptime — never `status.started_unix`, which is
// the router's clock and has no timezone to convert from. See useUptime.
const startedAt = uptime === null ? '' : fmtDateTime(uptime.startedUnix)
// ---- derived display state ---- // ---- derived display state ----
const g = config?.Globals const g = config?.Globals
@@ -251,24 +301,27 @@ export function Overview({
? `${worstGroup.group} — no answer` ? `${worstGroup.group} — no answer`
: `${worstGroup.group} — ${worstGroup.dead} down` : `${worstGroup.group} — ${worstGroup.dead} down`
const engineVariant: LedVariant = !status // One reading for the engine, and it is able to say "stopped": `status.running`
? 'off' // was a constant `true` on the daemon, so this LED could never go crit and the
: status.running && status.active // Engine module was green through a process that had failed to start. See
? 'on' // planeState.engineState.
: status.running const engine = engineReadout(status)
? 'amber' const engineVariant: LedVariant = engine.variant
: 'crit'
const protection = protectionState(status) const protection = protectionState(status)
// Configured fail-closed AND actually enforcing it. `none` means nothing is // Configured fail-closed, actually enforcing it, or not known — three answers,
// installed, so the setting is inert no matter what it says. // and the third is not folded into the first. See planeState.killSwitchReadout.
const killInEffect = killArmed && status?.plane !== 'none' const kill = killSwitchReadout(status, g?.KillSwitch)
// Findings that need attention. `info` notes are statements about the config, // Findings that need attention. `info` notes are statements about the config,
// not problems, so they live beside the setting they describe (see findings.ts) // not problems, so they live beside the setting they describe (see findings.ts)
// — keeping this list to things someone could actually act on. // — keeping this list to things someone could actually act on.
const warnings = attentionFindings(status?.warnings) const warnings = attentionFindings(status?.warnings)
const criticalCount = warnings.filter((w) => w.severity === 'critical').length const criticalCount = warnings.filter((w) => w.severity === 'critical').length
// The daemon caps the published list at 50 and says so in an `info` note — the
// one channel this page filters away. Carried separately so the list can admit
// it is not the whole list. See findings.ts truncationNote.
const truncated = truncationNote(status?.warnings)
return ( return (
<section className="page" aria-label="Overview"> <section className="page" aria-label="Overview">
@@ -291,7 +344,7 @@ export function Overview({
</p> </p>
)} )}
<Findings warnings={warnings} criticalCount={criticalCount} /> <Findings warnings={warnings} criticalCount={criticalCount} truncated={truncated} />
<div className="grid"> <div className="grid">
{/* Groups, not nodes: a group is where a dial path is defined, so it is the {/* Groups, not nodes: a group is where a dial path is defined, so it is the
@@ -334,14 +387,31 @@ export function Overview({
/> />
)} )}
{/* "N / M in force", the same reading the Routing page shows — never the
count of saved switches. The lamp follows the same rule: a table of
rules none of which are in force routes exactly nothing, and it used
to sit under a green light saying "0 / 7". */}
<Module <Module
name="Routing" name="Routing"
value={String(enabledCount(config?.Rules))} value={inForce === null ? String(enabledCount(config?.Rules)) : String(inForce)}
unit={`/ ${len(config?.Rules)} rules`} unit={
led={{ variant: len(config?.Rules) ? 'on' : 'amber' }} inForce === null
? `/ ${len(config?.Rules)} rules saved`
: `/ ${len(config?.Rules)} in force`
}
led={{
variant:
len(config?.Rules) === 0
? 'amber'
: inForce === null
? 'off'
: inForce === 0
? 'amber'
: 'on',
}}
rows={[ rows={[
{ k: 'egresses', v: String(len(config?.Egresses)) }, { k: 'egresses', v: String(len(config?.Egresses)) },
{ k: 'default', v: defaultTarget(config), hot: true }, { k: 'default', v: defaultTarget(status, config), hot: true },
]} ]}
/> />
@@ -381,17 +451,17 @@ export function Overview({
/> />
{/* A kill-switch set to fail-closed is only ARMED if something is actually {/* A kill-switch set to fail-closed is only ARMED if something is actually
installed to enforce it. With no plane it is configured but inert, and installed to enforce it, and "we haven't been told" is neither. With no
saying "ARMED" there would be a false reassurance next to a readout plane it is configured but inert; with no reading the lamp stays unlit
that says nothing is protected. */} rather than joining the healthy branch by default. */}
<Module <Module
name="Kill-switch" name="Kill-switch"
value={killInEffect ? 'ARMED' : killArmed ? 'NOT IN EFFECT' : 'OPEN'} value={kill.value}
led={{ variant: killInEffect ? 'on' : killArmed ? 'crit' : 'amber' }} led={{ variant: kill.variant }}
rows={[ rows={[
{ k: 'setting', v: killArmed ? 'fail-closed' : 'fail-open', hot: !killArmed }, { k: 'setting', v: killArmed ? 'fail-closed' : 'fail-open', hot: !killArmed },
...(killArmed && !killInEffect ...(kill.blockingNow
? [{ k: 'blocking now', v: 'no — nothing installed', hot: true }] ? [{ k: 'blocking now', v: kill.blockingNow, hot: kill.hot }]
: [{ k: 'ipv6', v: g?.IPv6 ? 'covered' : 'off' }]), : [{ k: 'ipv6', v: g?.IPv6 ? 'covered' : 'off' }]),
{ k: 'confirm', v: g?.ConfirmTimeout ? `${g.ConfirmTimeout}s window` : 'no auto-rollback' }, { k: 'confirm', v: g?.ConfirmTimeout ? `${g.ConfirmTimeout}s window` : 'no auto-rollback' },
]} ]}
@@ -414,6 +484,7 @@ export function Overview({
unit={status?.version?.includes('-') ? '· ' + status.version.split('-').slice(1).join('-') : ''} unit={status?.version?.includes('-') ? '· ' + status.version.split('-').slice(1).join('-') : ''}
led={{ variant: engineVariant }} led={{ variant: engineVariant }}
rows={[ rows={[
{ k: 'process', v: engine.word, hot: engineVariant === 'crit' },
{ k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> }, { k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> },
// Uptime of the daemon PROCESS. "started" is the moment it came up, // Uptime of the daemon PROCESS. "started" is the moment it came up,
// by the router's clock — not the moment a config was applied. // by the router's clock — not the moment a config was applied.
@@ -431,9 +502,14 @@ export function Overview({
<Button variant="primary" onClick={() => void run('apply')} disabled={busy !== null}> <Button variant="primary" onClick={() => void run('apply')} disabled={busy !== null}>
{busy === 'apply' ? 'Applying…' : 'Apply config'} {busy === 'apply' ? 'Applying…' : 'Apply config'}
</Button> </Button>
<Button onClick={() => void run('confirm')} disabled={busy !== null}> {/* A "Confirm" button used to sit here permanently, and pressing it
{busy === 'confirm' ? 'Confirming…' : 'Confirm'} always printed "Confirmed — auto-rollback cancelled": `apply.Confirm()`
</Button> returns nil whether or not a window was ever armed, so the message was
a success report for an event that usually had not happened.
Keeping a config is now offered only while a window is actually open,
and that is announced by the app-wide band directly above this page —
which is where the button lives, beside the countdown it belongs to,
rather than duplicated here. */}
{canRollback && ( {canRollback && (
<Button onClick={() => void run('rollback')} disabled={busy !== null}> <Button onClick={() => void run('rollback')} disabled={busy !== null}>
{busy === 'rollback' ? 'Rolling back…' : 'Rollback'} {busy === 'rollback' ? 'Rolling back…' : 'Rollback'}
@@ -464,11 +540,23 @@ const SECTION_ROUTE: Record<string, Route> = {
rule: 'routing', rule: 'routing',
ruleset: 'routing', ruleset: 'routing',
blocklist: 'dns', blocklist: 'dns',
allowlist: 'dns',
resolver: 'dns', resolver: 'dns',
dns_rule: 'dns',
device: 'devices', device: 'devices',
chain: 'targets', chain: 'targets',
group: 'targets', group: 'targets',
// A node the generator dropped (unparseable share link, duplicate WireGuard
// key, name colliding with a reserved tag) is reported under `node` — and had
// nowhere to jump to, so the one page that could show it a green toggle was
// also the one page the finding could not reach.
node: 'nodes',
subscription: 'nodes',
egress: 'targets',
inbound: 'networks',
interface: 'networks', interface: 'networks',
profile: 'profiles',
alert: 'settings',
// The standing note about non-TCP/UDP traffic — its control lives on Networks. // The standing note about non-TCP/UDP traffic — its control lives on Networks.
untunnelable: 'networks', untunnelable: 'networks',
} }
@@ -486,11 +574,14 @@ const SECTION_ROUTE: Record<string, Route> = {
function Findings({ function Findings({
warnings, warnings,
criticalCount, criticalCount,
truncated,
}: { }: {
warnings: StatusWarning[] warnings: StatusWarning[]
criticalCount: number criticalCount: number
/** The daemon's "N further suppressed" note, when the list was capped. */
truncated: StatusWarning | null
}) { }) {
if (warnings.length === 0) return null if (warnings.length === 0 && !truncated) return null
const rank = { critical: 0, warning: 1, info: 2 } as const const rank = { critical: 0, warning: 1, info: 2 } as const
const sorted = [...warnings].sort((a, b) => rank[a.severity] - rank[b.severity]) const sorted = [...warnings].sort((a, b) => rank[a.severity] - rank[b.severity])
@@ -500,6 +591,10 @@ function Findings({
<header className="findings-hd"> <header className="findings-hd">
<h2 className="findings-title">Last apply</h2> <h2 className="findings-title">Last apply</h2>
<span className="findings-count mono"> <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
on a config with fifty criticals the thing it drops is a critical. */}
{truncated ? 'at least ' : ''}
{criticalCount > 0 {criticalCount > 0
? `${criticalCount} critical · ${warnings.length} total` ? `${criticalCount} critical · ${warnings.length} total`
: `${warnings.length} note${warnings.length === 1 ? '' : 's'}`} : `${warnings.length} note${warnings.length === 1 ? '' : 's'}`}
@@ -538,6 +633,20 @@ function Findings({
</li> </li>
) )
})} })}
{/* The list saying it is not the whole list. Last, because it is about
everything above it — and never filtered out with the other `info`
notes, which is where it used to disappear. */}
{truncated && (
<li className="finding finding--truncated">
<Led variant="amber" />
<div className="finding-copy">
<span className="finding-where mono">list truncated</span>
<span className="finding-msg">
Some findings are missing from this list. {truncated.message}
</span>
</div>
</li>
)}
</ul> </ul>
</section> </section>
) )
@@ -563,12 +672,20 @@ const NAV_LABEL: Record<Route, string> = {
// for the apply/rollback flow, where the individual flags are the actual // for the apply/rollback flow, where the individual flags are the actual
// subject of the page.) // subject of the page.)
function defaultTarget(config: Model | null): string { /** Where everything not matched by a rule goes — the engine's route `final`.
const rules = config?.Rules ?? [] *
if (rules.length === 0) return '—' * Taken from the daemon (status.traffic.default), which reads it off the config
// The highest Order enabled rule is the effective catch-all. * it is running. The guess this replaced was "the highest-Order enabled rule",
const enabled = rules.filter((r) => r.Enabled) * and that is not what the default is: a rule only becomes the default by having
if (enabled.length === 0) return 'none' * NO conditions at all, whatever its Order (model.IsCatchAll), so a specific
const last = enabled.reduce((a, b) => (b.Order >= a.Order ? b : a)) * high-Order rule was routinely printed here as the router's default. It also
return last.Target || last.Egress || last.Name * described the config on disk rather than the one running, and could not see a
* target that failed to resolve and fell back.
*
* Falls back to the rule count only when the daemon has not reported — never to
* a guess about where traffic goes. */
function defaultTarget(status: Status | null, config: Model | null): string {
const d = status?.traffic?.default
if (d) return d
return len(config?.Rules) === 0 ? '—' : 'not reported'
} }
+10 -4
View File
@@ -1,6 +1,6 @@
import './Profiles.css' import './Profiles.css'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Toggle } from '../components' import { Button, Led, Toggle, useConfirm } from '../components'
import { apply as apiApply, getConfig, getInterfaces, putConfig, ApiError } from '../api' import { apply as apiApply, getConfig, getInterfaces, putConfig, ApiError } from '../api'
import type { Interface, Model, Profile } from '../api' import type { Interface, Model, Profile } from '../api'
@@ -36,6 +36,7 @@ function namesOf(v: unknown): string[] {
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Profiles() { export default function Profiles() {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -192,9 +193,14 @@ export default function Profiles() {
) )
const deleteProfile = useCallback( const deleteProfile = useCallback(
(name: string) => { async (name: string) => {
if (!config) return if (!config) return
if (!window.confirm(`Delete profile “${name}”? Its overrides stop applying.`)) return const ok = await confirm({
label: 'Delete profile',
title: `Delete profile “${name}”?`,
body: 'Its overrides stop applying.',
})
if (!ok) return
const next = profiles.filter((p) => p.Name !== name) const next = profiles.filter((p) => p.Name !== name)
const g = const g =
config.Globals.ActiveProfile === name config.Globals.ActiveProfile === name
@@ -202,7 +208,7 @@ export default function Profiles() {
: config.Globals : config.Globals
void save({ ...config, Profiles: next, Globals: g }, `Deleted ${name}`) void save({ ...config, Profiles: next, Globals: g }, `Deleted ${name}`)
}, },
[config, profiles, save], [config, profiles, save, confirm],
) )
// ---- expansion (only one profile editor open at a time) ------------------- // ---- expansion (only one profile editor open at a time) -------------------
+123 -6
View File
@@ -58,6 +58,45 @@
color: var(--ink); color: var(--ink);
} }
/* ---- active-profile banner ----
*
* Deliberately NOT the accent plate the save→apply bar wears above. Orange is
* "there is something for you to do" on this faceplate, and an active WAN profile
* is a standing condition, not a pending action. A quiet plate with an amber tag
* reads as "note the state" — and it is the SAME amber the overridden rows below
* carry, so the banner and its rows are visibly one story rather than two
* unrelated oddities. */
.rt-prof-banner {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 0 calc(var(--u, 8px) * 2.5);
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--amber) 35%, var(--groove));
border-radius: 8px;
background: color-mix(in srgb, var(--amber) 7%, transparent);
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.55;
color: var(--dim);
}
.rt-prof-banner strong {
color: var(--ink);
font-weight: 600;
}
.rt-prof-tag {
flex: none;
margin-top: 1px;
padding: 2px 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);
font-size: 9px;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--amber);
}
/* ---- empty state ---- */ /* ---- empty state ---- */
.rt-empty { .rt-empty {
padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3); padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3);
@@ -252,6 +291,88 @@
color: var(--faint); color: var(--faint);
} }
/* ---- a rule that can never fire (superseded by a later condition-less rule) ----
*
* Warn semantics only: --amber, never --accent. Orange is the ACTIVE state on this
* faceplate, and a rule the router ignores is the opposite of active — painting it
* orange is what made two `default` rows look equally live. It is a dashed amber
* frame, an amber order chip, and a dimmed target, so the row reads as "wired but
* not connected" without shouting: nothing is broken, one setting is just inert. */
.rt-rule.dead {
border-style: dashed;
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
background: var(--panel);
box-shadow: none;
}
.rt-ord.dead {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 45%, var(--groove));
}
.rt-badge.dead {
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-dead-note {
font-family: var(--font-sans);
font-size: 11.5px;
line-height: 1.45;
color: var(--dim);
}
/* 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 {
border-style: dashed;
opacity: 0.62;
}
/* ---- a rule the active WAN profile overrides ----
*
* The row itself needs no new paint: an overridden-off rule already wears `.off`
* (it is off, whatever its switch says) and an overridden-on rule wears nothing
* (it is on). What was missing was never colour — it was the sentence naming who
* decided. So this is the per-row twin of the banner and borrows .rt-dead-note's
* type wholesale: same voice, same size, one <p> margin to reset. */
/* The same pill as .rt-badge.dead, so the two override states read as one pair,
* but in accent — a rule the profile forces ON is active, and active is orange on
* this faceplate. The pill is also what keeps it from running into the plain
* "default route · final" badge beside it, where "final on · by profile" read as
* one phrase. */
.rt-badge.prof-on {
padding: 1px 7px;
border: 1px solid var(--accent-soft);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.rt-prof-note {
margin: 0;
}
.rt-prof-note strong {
color: var(--ink);
font-weight: 600;
}
/* Switch + its legend. The caption shows ONLY while a profile overrides the rule,
* and it is what keeps the control honest: the plate says what the router is
* doing, this says the switch is about the saved setting. A legend under the
* control it names is the faceplate's own idiom. */
.rt-switch {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.rt-switch-note {
font-family: var(--font-mono);
font-size: 8.5px;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--faint);
}
/* ---- target chip (styled like the artifact's group:auto mono chips) ---- */ /* ---- target chip (styled like the artifact's group:auto mono chips) ---- */
.rt-target { .rt-target {
display: inline-flex; display: inline-flex;
@@ -359,9 +480,6 @@
gap: 5px; gap: 5px;
min-width: 0; min-width: 0;
} }
.rt-field-wide {
grid-column: span 2;
}
.rt-flabel { .rt-flabel {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
@@ -478,6 +596,8 @@ select.rt-input {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 1px 0 var(--edge) inset, 0 0 0 1px var(--accent-soft); box-shadow: 0 1px 0 var(--edge) inset, 0 0 0 1px var(--accent-soft);
} }
/* "no matchers" flag in the plate foot — shared by BOTH rule forms (add and
* edit), so the same non-blocking warning reads identically in either. */
.rt-edit-warn { .rt-edit-warn {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11.5px; font-size: 11.5px;
@@ -800,9 +920,6 @@ select.rt-input {
justify-content: flex-start; justify-content: flex-start;
align-self: start; align-self: start;
} }
.rt-field-wide {
grid-column: auto;
}
.rt-rs-row { .rt-rs-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
row-gap: 10px; row-gap: 10px;
File diff suppressed because it is too large Load Diff
+58 -4
View File
@@ -1,7 +1,8 @@
import './Settings.css' import './Settings.css'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Button, Led, Select, Toggle } from '../components' import { Button, Led, Select, Toggle, useConfirm } from '../components'
import { AlertsSection } from './Alerts'
import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api' import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api'
import type { Globals, LogRange, Model } from '../api' import type { Globals, LogRange, Model } from '../api'
@@ -125,6 +126,7 @@ const STATS_BACKENDS: ReadonlyArray<{ value: string; label: string }> = [
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Settings() { export default function Settings() {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -257,6 +259,48 @@ export default function Settings() {
const groupHealthOn = globals?.GroupHealth !== false const groupHealthOn = globals?.GroupHealth !== false
const killSwitch = globals?.KillSwitch === 'open' ? 'open' : 'closed' const killSwitch = globals?.KillSwitch === 'open' ? 'open' : 'closed'
/**
* The master switch, which is the most destructive control in the panel and was
* the only one that asked nothing.
*
* Turning it off is not "pausing the proxy": apply.go runs Teardown() — the nft
* table goes, the policy routing goes, `plane` becomes `none`. The kill-switch
* does not save you, because a kill-switch is a rule in a table that no longer
* exists. Everything on the LAN then leaves through the plain WAN, unproxied and
* unfiltered. Deleting a rule-set asked for confirmation; this did not.
*
* Turning it back ON is not destructive and is not gated.
*/
const toggleService = useCallback(
async (on: boolean) => {
if (!on) {
const ok = await confirm({
label: 'Turn off the service',
title: 'Turn the proxy engine off?',
body: (
<>
This tears the whole data plane down — the firewall table, the policy routing and the
DNS interception are removed, not paused. Nothing is proxied, filtered or blocked, and
every device leaves through your normal internet connection with its real address.{' '}
{killSwitch === 'closed' ? (
<>
The kill-switch does not hold here: with nothing installed there is nothing left
to block with.
</>
) : (
<>The kill-switch is already open, so nothing changes about that.</>
)}
</>
),
confirmLabel: 'Turn it off',
})
if (!ok) return
}
setGlobal('Enabled', on, on ? 'Engine enabled' : 'Engine disabled')
},
[confirm, killSwitch, setGlobal],
)
const killNote = const killNote =
killSwitch === 'open' killSwitch === 'open'
? 'Fail-open — if the engine stops, traffic falls back to the direct WAN. Stays online, but unprotected.' ? 'Fail-open — if the engine stops, traffic falls back to the direct WAN. Stays online, but unprotected.'
@@ -296,10 +340,13 @@ export default function Settings() {
<div className="set-groups"> <div className="set-groups">
{/* ---- SERVICE ---- */} {/* ---- SERVICE ---- */}
<Group title="Service" count={globals?.Enabled ? 'enabled' : 'disabled'}> <Group title="Service" count={globals?.Enabled ? 'enabled' : 'disabled'}>
<Field label="Proxy engine" note="Master on/off for the whole appliance."> <Field
label="Proxy engine"
note="Master on/off for the whole appliance. Off removes the firewall table and the policy routing — every device goes out directly, with no kill-switch to catch it."
>
<Toggle <Toggle
pressed={globals?.Enabled ?? false} pressed={globals?.Enabled ?? false}
onChange={(on) => setGlobal('Enabled', on, on ? 'Engine enabled' : 'Engine disabled')} onChange={(on) => void toggleService(on)}
label={globals?.Enabled ? 'Disable proxy engine' : 'Enable proxy engine'} label={globals?.Enabled ? 'Disable proxy engine' : 'Enable proxy engine'}
size="md" size="md"
disabled={busy || !ready} disabled={busy || !ready}
@@ -360,7 +407,7 @@ export default function Settings() {
<Field <Field
label="Log level" label="Log level"
note="Verbosity of the daemon log. “none” silences the engine and drops the control-plane to panic-only — a turn-down, not a true off: even warnings and errors are hidden. The toggles below decide where whatever is emitted gets written; turning both off is the only full silence. Failures still raise alerts regardless of this level." note="Verbosity of the daemon log. “none” silences the engine and drops the control-plane to panic-only — a turn-down, not a true off: even warnings and errors are hidden. The toggles below decide where whatever is emitted gets written; turning both off is the only full silence. Failures still raise alerts regardless of this level — set up where they go in the Alerts section below."
> >
<Select <Select
value={globals?.LogLevel || 'warning'} value={globals?.LogLevel || 'warning'}
@@ -574,6 +621,13 @@ export default function Settings() {
</Field> </Field>
</Group> </Group>
{/* ---- ALERTS ---- */}
{/* Extracted from the DNS page — out-of-band notifications belong with
the appliance-wide knobs, next to the log level whose note points
here. Renders its own section header (same plate as a Group); all
writes go through `save`, so the dirty banner and toast stay one. */}
<AlertsSection config={config} busy={busy} loading={loading} onSave={save} />
{/* ---- STATISTICS & LOGGING ---- */} {/* ---- STATISTICS & LOGGING ---- */}
<Group <Group
title="Statistics &amp; logging" title="Statistics &amp; logging"
+250
View File
@@ -704,6 +704,13 @@
.tg-test--bad .tg-test-msg { .tg-test--bad .tg-test-msg {
color: var(--crit); color: var(--crit);
} }
/* "Nothing measured this" is not a failure and must never be dressed as one: an
unlit lamp and the faintest text on the card, the same register the group
readout uses for its unmeasured state. */
.tg-test--none .tg-test-msg {
font-family: var(--font-sans);
color: var(--faint);
}
.tg-test--wait .tg-test-msg { .tg-test--wait .tg-test-msg {
color: var(--amber); color: var(--amber);
} }
@@ -1038,6 +1045,235 @@
} }
/* ---- responsive ---- */ /* ---- responsive ---- */
/* ---- chain hop rail ----
* The chain section's signature, and the one place this card spends any
* boldness: the path is drawn as a CONDUCTOR with a numbered lamp at each hop,
* and the conductor is SEVERED below the first hop that was probed and did not
* answer. A chain is a single series path, so the question is never "how many
* hops are green", it is "where does my traffic stop" — and a broken line answers
* that before a word has been read.
*
* The two marks carry two different facts and must not be conflated:
* - the LAMP is that hop's own measurement (good / warn / crit / unlit). Below
* the break there is no measurement to draw: the daemon stops walking at the
* first dead hop, so those lamps are UNLIT and the row says which hop stopped
* the walk. Unlit is never a shade of red — it claims nothing, which is the
* truth about a hop nobody dialled;
* - the CONDUCTOR is reachability through the path, which really does stop.
*
* Orange is untouched here. Semantics carry every colour, and everything that is
* not a lamp is groove-grey. No transitions and no animation anywhere in the
* rail, so there is nothing for reduced-motion to switch off. */
.ch-rail {
gap: 8px;
}
.ch-eyebrow {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--faint);
}
.ch-hops {
--ch-num: 1.8ch; /* the engraved hop number's gutter */
--ch-gap: 8px;
--ch-led: 10px; /* must match .led's width */
--ch-lampy: 14px; /* row top → lamp centre; the conductor's anchor */
/* x of the conductor: the number gutter, one gap, then the lamp's centre */
--ch-spine: calc(var(--ch-num) + var(--ch-gap) + var(--ch-led) / 2);
list-style: none;
margin: 0;
padding: 0;
}
.ch-hop {
position: relative;
display: grid;
grid-template-columns: var(--ch-num) var(--ch-led) minmax(0, 1fr);
column-gap: var(--ch-gap);
align-items: start;
}
/* the conductor — two halves per row, so a break lands on one link only */
.ch-hop::before,
.ch-hop::after {
content: '';
position: absolute;
left: var(--ch-spine);
width: 2px;
margin-left: -1px;
/* Brighter than a plain groove: this line IS the readout, and at groove
strength it disappeared into the panel and took the whole idea with it. */
background: color-mix(in srgb, var(--dim) 55%, var(--groove));
}
.ch-hop::before {
top: 0;
height: calc(var(--ch-lampy) - var(--ch-led) / 2 - 3px);
}
.ch-hop::after {
top: calc(var(--ch-lampy) + var(--ch-led) / 2 + 3px);
bottom: 0;
}
/* Nothing feeds hop 1 from above, and nothing leaves the exit downward — the
path starts and ends inside this rail. */
.ch-hop--first::before {
display: none;
}
.ch-hop--exit::after {
bottom: auto;
height: 9px;
}
/* …the exit ends on a crossbar instead of trailing off: end of line. */
.ch-hop--exit .ch-socket {
position: relative;
}
.ch-hop--exit .ch-socket::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
top: calc(var(--ch-lampy) + var(--ch-led) / 2 + 12px);
width: 11px;
height: 2px;
background: color-mix(in srgb, var(--dim) 55%, var(--groove));
}
/* THE SEVER. Everything from the dead hop's outgoing link downward is drawn as a
broken conductor: unmistakably not-a-line at a glance, and unmistakably not a
colour, because a colour here would compete with the lamps that carry health. */
.ch-hop--dead::after,
.ch-hop--severed::before,
.ch-hop--severed::after {
background: repeating-linear-gradient(
to bottom,
color-mix(in srgb, var(--dim) 45%, var(--groove)) 0 3px,
transparent 3px 7px
);
}
.ch-num {
font-size: 10px;
line-height: calc(var(--ch-lampy) * 2);
text-align: right;
color: var(--faint);
}
.ch-socket {
display: flex;
align-items: center;
height: calc(var(--ch-lampy) * 2);
}
.ch-body {
min-width: 0;
/* Separates one hop from the next. The conductor runs through this space, so
too little of it and two hops read as one wrapped row. */
padding-bottom: 8px;
}
.ch-l1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: calc(var(--ch-lampy) * 2);
}
.ch-name {
font-size: 12px;
color: var(--ink);
overflow-wrap: anywhere;
}
.ch-hop--untested .ch-name {
color: var(--dim);
}
/* The exit marker is NEUTRAL on purpose. The config path above this rail tags its
exit green, which is free there — but in here green means "answering", and a
green badge on the last hop would read as a health claim about it. */
.ch-tag {
font-family: var(--font-mono);
font-size: 8.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--faint);
}
.ch-delay {
font-size: 11.5px;
font-weight: 700;
color: var(--ink);
}
.ch-quiet {
font-family: var(--font-sans);
font-size: 12px;
color: var(--faint);
}
/* A blocked hop's phrase carries a tooltip with the blocking hop's engine
outbound, so it takes the same help cursor as .ch-dead. No colour of its own:
the finding is red once, on the hop that actually failed. */
.ch-blocked {
cursor: help;
}
.ch-age {
margin-left: auto;
font-size: 10.5px;
color: var(--faint);
white-space: nowrap;
}
/* The counters read exactly as they do on a group card — alive out of TESTED,
with the untested remainder as a quiet aside only when there is one. Same
register, same weights, deliberately not a second dialect. */
.ch-l2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 1px;
font-size: 11.5px;
letter-spacing: 0.02em;
color: var(--dim);
}
.ch-count {
font-size: 12px;
color: var(--dim);
white-space: nowrap;
}
.ch-count b {
font-size: 14px;
font-weight: 700;
color: var(--ink);
}
.ch-hop--dead .ch-count b {
color: var(--crit);
}
.ch-word {
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--faint);
}
.ch-dead {
padding: 1px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--crit) 12%, transparent);
font-size: 10.5px;
color: var(--crit);
white-space: nowrap;
cursor: help;
}
.ch-rest {
font-size: 10.5px;
color: var(--faint);
}
/* On a blocked hop this chip says "set to", not "now": a pick nothing crossed.
It steps back to faint so it can't be mistaken for a live reading. */
.ch-hop--blocked .ch-now {
color: var(--faint);
}
.ch-now {
max-width: 28ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10.5px;
color: var(--dim);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.tg-sec-hd { .tg-sec-hd {
flex-wrap: wrap; flex-wrap: wrap;
@@ -1060,6 +1296,20 @@
.gh-now { .gh-now {
max-width: 100%; max-width: 100%;
} }
/* On a phone the age stamp stops being pushed to a lonely right edge and just
joins the end of the hop's line; the selected node gets the full width
instead of an ellipsis it doesn't need. */
.ch-age {
margin-left: 0;
}
.ch-now {
max-width: 100%;
}
/* Every field of a hop wraps onto its own line at this width, so the gap
between hops has to grow with them or the rail reads as one block of text. */
.ch-body {
padding-bottom: 12px;
}
.gh-mems { .gh-mems {
max-height: 260px; max-height: 260px;
} }
+458 -100
View File
@@ -1,6 +1,6 @@
import './Targets.css' import './Targets.css'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Led, Toggle } from '../components' import { Button, Led, Toggle, useConfirm } from '../components'
import type { LedVariant } from '../components' import type { LedVariant } from '../components'
import { import {
apply as apiApply, apply as apiApply,
@@ -22,6 +22,8 @@ import type {
GroupTestResult, GroupTestResult,
GroupTestStatus, GroupTestStatus,
Chain, Chain,
ChainHealth,
ChainHopHealth,
Egress, Egress,
Interface, Interface,
Node, Node,
@@ -161,18 +163,18 @@ function renameReferences(m: Model, kind: RefKind, from: string, to: string): Mo
} }
/** /**
* The sentence a delete confirmation appends: what still points at this target, * The body of a delete confirmation: what still points at this target, and what
* and what happens to it. Empty list ⇒ an explicit "nothing references it", so * happens to it. Empty list ⇒ an explicit "nothing references it", so the
* the operator can delete a stray with confidence instead of guessing. * operator can delete a stray with confidence instead of guessing.
*/ */
function refWarning(refs: RefSite[]): string { function refWarning(refs: RefSite[]): string {
if (refs.length === 0) return ' Nothing references it.' if (refs.length === 0) return 'Nothing references it.'
const shown = refs.slice(0, 4).map((r) => r.label) const shown = refs.slice(0, 4).map((r) => r.label)
const more = refs.length - shown.length const more = refs.length - shown.length
const list = `${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}` const list = `${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}`
return refs.length === 1 return refs.length === 1
? ` It is referenced by ${list}, whose traffic will be blocked (an unresolved target never falls through to the default route).` ? `It is referenced by ${list}, whose traffic will be blocked (an unresolved target never falls through to the default route).`
: ` It is referenced by ${refs.length} places — ${list} — whose traffic will be blocked (an unresolved target never falls through to the default route).` : `It is referenced by ${refs.length} places — ${list} — whose traffic will be blocked (an unresolved target never falls through to the default route).`
} }
/** /**
@@ -436,14 +438,14 @@ const normalizeTest = (st: GroupTestStatus): GroupTestStatus => ({
}) })
/** /**
* How the header names the reach of a running exit test. The name matters more * How the header names the reach of a running refresh pass. The name matters more
* than the number when there is only one: "auto" tells the operator which button * than the number when there is only one: "auto" tells the operator which button
* they pressed; "1 target" tells them nothing they didn't already know. * they pressed; "1 target" tells them nothing they didn't already know.
*/ */
function scopeLabel(scope: string[], targetCount: number): string { function scopeLabel(scope: string[], targetCount: number): string {
if (scope.length === 1) return scope[0] if (scope.length === 1) return scope[0]
if (scope.length === 0) return 'exits' // pre-scope daemon — say nothing false if (scope.length === 0) return 'targets' // pre-scope daemon — say nothing false
return scope.length >= targetCount ? 'every exit' : `${scope.length} exits` return scope.length >= targetCount ? 'every target' : `${scope.length} targets`
} }
/** Which editor (add or edit-by-name) is open within a section. */ /** Which editor (add or edit-by-name) is open within a section. */
@@ -457,6 +459,7 @@ interface Opt {
// ---- page ------------------------------------------------------------------ // ---- page ------------------------------------------------------------------
export default function Targets() { export default function Targets() {
const confirm = useConfirm()
const [config, setConfig] = useState<Model | null>(null) const [config, setConfig] = useState<Model | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -589,10 +592,12 @@ export default function Targets() {
[health], [health],
) )
// ---- group/chain exit test: how fast, through which node, out which address -- // ---- out-of-turn refresh: how fast, through which node, out which address ----
// The POST only kicks a run off, and a 2 s poll of the GET carries progress // The POST does NOT dial. It asks the observatory — the only thing in the daemon
// plus every result so far. One endpoint covers groups and chains alike: // that measures anything, and it measures along the real dial path — to come
// POST with a group or chain name tests that one; an empty name tests them all. // round out of turn; a 2 s poll of the GET carries progress plus every reading
// so far. One endpoint covers groups and chains alike: POST with a name refreshes
// that one, an empty name refreshes them all.
const [gtest, setGtest] = useState<GroupTestStatus>(IDLE_TEST) const [gtest, setGtest] = useState<GroupTestStatus>(IDLE_TEST)
const [gtestErr, setGtestErr] = useState<string | null>(null) const [gtestErr, setGtestErr] = useState<string | null>(null)
const [polling, setPolling] = useState(false) const [polling, setPolling] = useState(false)
@@ -635,7 +640,7 @@ export default function Targets() {
void readTest().then((st) => { void readTest().then((st) => {
if (!alive || !st || st.running) return if (!alive || !st || st.running) return
setPolling(false) setPolling(false)
flash('Group test complete') flash('Readings refreshed')
}) })
}, 2000) }, 2000)
return () => { return () => {
@@ -651,16 +656,16 @@ export default function Targets() {
if (r.started) { if (r.started) {
setGtestErr(null) setGtestErr(null)
setPolling(true) setPolling(true)
flash(name ? `Testing ${name}…` : 'Testing every exit…') flash(name ? `Refreshing ${name}…` : 'Refreshing every reading…')
void readTest() void readTest()
} else if (r.reason === 'already running') { } else if (r.reason === 'already running') {
setPolling(true) // pick up the run someone else started setPolling(true) // pick up the pass someone else started
flash('A group test is already running') flash('The prober is already refreshing')
} else { } else {
flash(`Couldn’t start the test — ${r.reason || 'the daemon refused it'}`) flash(`Couldn’t ask for a refresh — ${r.reason || 'the daemon refused it'}`)
} }
} catch (e) { } catch (e) {
flash(`Couldn’t start the test — ${errText(e)}`) flash(`Couldn’t ask for a refresh — ${errText(e)}`)
} }
}, },
[flash, readTest], [flash, readTest],
@@ -787,13 +792,18 @@ export default function Targets() {
) )
const removeGroup = useCallback( const removeGroup = useCallback(
(name: string) => { async (name: string) => {
if (!config) return if (!config) return
const refs = findReferences(config, 'group', name) const refs = findReferences(config, 'group', name)
if (!window.confirm(`Delete group “${name}”?${refWarning(refs)}`)) return const ok = await confirm({
label: 'Delete group',
title: `Delete group “${name}”?`,
body: refWarning(refs),
})
if (!ok) return
void save({ ...config, Groups: groups.filter((g) => g.Name !== name) }, `Deleted ${name}`) void save({ ...config, Groups: groups.filter((g) => g.Name !== name) }, `Deleted ${name}`)
}, },
[config, groups, save], [config, groups, save, confirm],
) )
// ---- chain mutations ------------------------------------------------------ // ---- chain mutations ------------------------------------------------------
@@ -820,13 +830,18 @@ export default function Targets() {
) )
const removeChain = useCallback( const removeChain = useCallback(
(name: string) => { async (name: string) => {
if (!config) return if (!config) return
const refs = findReferences(config, 'chain', name) const refs = findReferences(config, 'chain', name)
if (!window.confirm(`Delete chain “${name}”?${refWarning(refs)}`)) return const ok = await confirm({
label: 'Delete chain',
title: `Delete chain “${name}”?`,
body: refWarning(refs),
})
if (!ok) return
void save({ ...config, Chains: chains.filter((c) => c.Name !== name) }, `Deleted ${name}`) void save({ ...config, Chains: chains.filter((c) => c.Name !== name) }, `Deleted ${name}`)
}, },
[config, chains, save], [config, chains, save, confirm],
) )
// ---- egress mutations ----------------------------------------------------- // ---- egress mutations -----------------------------------------------------
@@ -853,13 +868,18 @@ export default function Targets() {
) )
const removeEgress = useCallback( const removeEgress = useCallback(
(name: string) => { async (name: string) => {
if (!config) return if (!config) return
const refs = findReferences(config, 'egress', name) const refs = findReferences(config, 'egress', name)
if (!window.confirm(`Delete egress “${name}”?${refWarning(refs)}`)) return const ok = await confirm({
label: 'Delete egress',
title: `Delete egress “${name}”?`,
body: refWarning(refs),
})
if (!ok) return
void save({ ...config, Egresses: egresses.filter((e) => e.Name !== name) }, `Deleted ${name}`) void save({ ...config, Egresses: egresses.filter((e) => e.Name !== name) }, `Deleted ${name}`)
}, },
[config, egresses, save], [config, egresses, save, confirm],
) )
const busy = saving || applying const busy = saving || applying
@@ -894,10 +914,11 @@ export default function Targets() {
<h2 className="tg-sec-title">Groups</h2> <h2 className="tg-sec-title">Groups</h2>
<span className="tg-sec-count mono">{groups.length} configured</span> <span className="tg-sec-count mono">{groups.length} configured</span>
{/* The observatory's background probing is invisible by design — it {/* The observatory's background probing is invisible by design — it
keeps every used group's and chain's numbers fresh on its own. The keeps every used group's and chain's numbers fresh on its own, along
one manual run left is the exit test: it is scoped to the groups the path traffic actually takes. The one manual control left does
and chains it names, so its progress says WHICH, and its badge not measure anything itself: it asks that prober to come round out
lands only on those cards. */} of turn. It is scoped to the groups and chains it names, so its
progress says WHICH, and its badge lands only on those cards. */}
<div className="tg-sec-ctl"> <div className="tg-sec-ctl">
{groupHealthOn && ( {groupHealthOn && (
<> <>
@@ -905,11 +926,11 @@ export default function Targets() {
<span <span
className="tg-run tg-run--exit" className="tg-run tg-run--exit"
role="status" role="status"
title="An exit test sends one connection through each group or chain it covers and reports the delay and the address the internet sees." title="The background prober is measuring the targets this refresh covers, along the path each one's traffic really takes."
> >
<Led variant="amber" pulse /> <Led variant="amber" pulse />
<span className="tg-run-what"> <span className="tg-run-what">
exit test · {scopeLabel(asArray(gtest.scope), groups.length + chains.length)} refreshing · {scopeLabel(asArray(gtest.scope), groups.length + chains.length)}
</span> </span>
<span className="tg-run-n mono"> <span className="tg-run-n mono">
{gtest.done}/{gtest.total} {gtest.done}/{gtest.total}
@@ -919,9 +940,9 @@ export default function Targets() {
<Button <Button
onClick={() => void runTest()} onClick={() => void runTest()}
disabled={busy || !config || (groups.length === 0 && chains.length === 0) || gtest.running} disabled={busy || !config || (groups.length === 0 && chains.length === 0) || gtest.running}
title="Send one connection through each group and chain and report the delay and the exit address the internet sees" title="Ask the background prober to measure every group and chain out of turn, then show what it measured. The panel opens no connection of its own."
> >
{gtest.running ? 'Testing…' : 'Test every exit'} {gtest.running ? 'Refreshing…' : 'Refresh every reading'}
</Button> </Button>
</> </>
)} )}
@@ -941,6 +962,13 @@ export default function Targets() {
dials out through a tunnel measures them through that tunnel, so the same node can be alive dials out through a tunnel measures them through that tunnel, so the same node can be alive
in one group and dead in another. in one group and dead in another.
</p> </p>
<p className="tg-sec-note">
One thing measures, and the panel is not it. A background prober walks every path your
rules use — hop by hop, exactly as traffic goes — and every number on this page is a read
of what it found. <strong>Refresh every reading</strong> asks it to come round out of turn
instead of waiting for the next pass; it opens no connection of its own, so a target no
rule routes through has nothing to report and says so.
</p>
{groupHealthOn && healthErr && ( {groupHealthOn && healthErr && (
<p className="tg-test-err" role="alert"> <p className="tg-test-err" role="alert">
@@ -951,7 +979,7 @@ export default function Targets() {
{groupHealthOn && gtestErr && ( {groupHealthOn && gtestErr && (
<p className="tg-test-err" role="alert"> <p className="tg-test-err" role="alert">
Couldn’t read the test results — {gtestErr}.{' '} Couldn’t read the refreshed numbers — {gtestErr}.{' '}
<button className="linkish" onClick={() => void readTest()}> <button className="linkish" onClick={() => void readTest()}>
Retry Retry
</button> </button>
@@ -1094,7 +1122,9 @@ export default function Targets() {
chain={c} chain={c}
busy={busy} busy={busy}
showHealth={groupHealthOn} showHealth={groupHealthOn}
used={healthByChain.get(c.Name)?.used} // 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={testByGroup.get(c.Name)}
// The badge is this card's business only when the run names it. // The badge is this card's business only when the run names it.
testing={gtest.running && testScope.has(c.Name)} testing={gtest.running && testScope.has(c.Name)}
@@ -1216,7 +1246,7 @@ function GroupRow({
group: Group group: Group
busy: boolean busy: boolean
/** Group health checks are on (Settings). When false, the card drops its health /** Group health checks are on (Settings). When false, the card drops its health
* readout, its exit-test readout and its Test button — it is config only. */ * readout, its end-to-end reading and its Refresh button — it is config only. */
showHealth: boolean showHealth: boolean
/** This group's membership health, or undefined when the engine hasn't built /** This group's membership health, or undefined when the engine hasn't built
* it (not applied yet, or dropped for having no usable members). */ * it (not applied yet, or dropped for having no usable members). */
@@ -1226,14 +1256,14 @@ function GroupRow({
healthKnown: boolean healthKnown: boolean
test?: GroupTestResult test?: GroupTestResult
/** /**
* A group exit test covering THIS group is in flight. * A refresh pass covering THIS group is in flight.
* *
* Deliberately not "a test is running": the caller resolves it against the run's * Deliberately not "a test is running": the caller resolves it against the run's
* scope. There is no per-card equivalent for the health run — that one measures * scope. There is no per-card equivalent for the health run — that one measures
* every group at once and is reported once, in the section header. * every group at once and is reported once, in the section header.
*/ */
testing: boolean testing: boolean
/** Any exit test is in flight; the daemon runs one at a time. */ /** Any refresh pass is in flight; the daemon runs one at a time. */
testBusy: boolean testBusy: boolean
onTest: () => void onTest: () => void
onEdit: () => void onEdit: () => void
@@ -1293,7 +1323,11 @@ function GroupRow({
health={health} health={health}
healthKnown={healthKnown} healthKnown={healthKnown}
/> />
<GroupTestReadout test={test} pending={testing && !test} /> <GroupTestReadout
test={test}
pending={testing && !test}
hideAbsence={health?.used === false}
/>
</> </>
)} )}
</div> </div>
@@ -1304,7 +1338,7 @@ function GroupRow({
editLabel={`Edit group ${group.Name}`} editLabel={`Edit group ${group.Name}`}
deleteLabel={`Delete group ${group.Name}`} deleteLabel={`Delete group ${group.Name}`}
onTest={showHealth ? onTest : undefined} onTest={showHealth ? onTest : undefined}
testLabel={showHealth ? `Test the exit of group ${group.Name}` : undefined} testLabel={showHealth ? `Refresh the reading for group ${group.Name}` : undefined}
testDisabled={testBusy} testDisabled={testBusy}
/> />
</li> </li>
@@ -1363,21 +1397,7 @@ function GroupHealthReadout({
// its members would stay "untested" forever. That is a fact about the ROUTING // its members would stay "untested" forever. That is a fact about the ROUTING
// CONFIG, not about the members — so instead of counters that could only ever // CONFIG, not about the members — so instead of counters that could only ever
// read as a permanent unknown, the card says so, quietly: unused, not unwell. // read as a permanent unknown, the card says so, quietly: unused, not unwell.
if (!health.used) { if (!health.used) return <NotRoutedNote kind="group" />
return (
<div className="gh gh--unused">
<div className="gh-line">
<span
className="gh-unused"
title="No enabled rule routes through this group, so its members are not probed. Add it to a rule to see health."
>
unused
</span>
<span className="gh-quiet">not probed — no enabled rule routes through this group</span>
</div>
</div>
)
}
const v = verdictOf(health) const v = verdictOf(health)
const { total, tested, alive, dead, untested } = health const { total, tested, alive, dead, untested } = health
@@ -1540,6 +1560,56 @@ function GroupHealthReadout({
) )
} }
/**
* The card's answer when NOTHING ROUTES THROUGH THIS TARGET. Shared by the group
* card and the chain card, because it is the same misunderstanding on both.
*
* It has to carry two statements, and the old one-liner ("not probed — no enabled
* rule routes through this group") only carried the first. Read fast it still
* landed as a verdict: a card that normally shows health and today shows a grey
* pill reads as "the health is bad". So the two meanings are now separated, on
* purpose and in this order:
*
* 1. the ROUTING FACT — nothing routes here, so nothing measures it;
* 2. the NON-FACT — this is not a health reading at all. Absent numbers are
* absence of measurement, never failure.
*
* For a GROUP there is a third line, and it is the confusion this whole change
* exists to end: a group used only as a hop inside a chain is never routed to
* DIRECTLY, so it correctly reads unused here while carrying real traffic as a
* hop. Its health is measured at that hop, on the chain's card.
*
* Unused is neutral — groove-grey, never amber, never crit. It is a state of the
* config, and the config is not sick.
*/
function NotRoutedNote({ kind }: { kind: 'group' | 'chain' }) {
return (
<div className="gh gh--unused">
<div className="gh-line">
<span className="gh-unused">unused</span>
<span className="gh-quiet">
No enabled rule routes through this {kind}, so the observatory never probes it.
</span>
</div>
<p className="gh-say">
That is a routing fact, not a health reading. There are no numbers here because nothing
measured this {kind} — not because it failed.
</p>
{kind === 'group' ? (
<p className="gh-say">
A group used only as a hop inside a chain reads unused here on purpose: the rules point at
the chain, not at the group. Its members are measured at that hop, so its real health is on
that chain’s card, hop by hop.
</p>
) : (
<p className="gh-say">
Point a rule at this chain and the observatory starts measuring every hop within seconds.
</p>
)}
</div>
)
}
/** /**
* One group's member rows, fetched on demand. * One group's member rows, fetched on demand.
* *
@@ -1645,7 +1715,9 @@ function MemberRow({ member }: { member: GroupMemberHealth }) {
} }
/** /**
* What a group test found, in the four states it actually has. * What the OBSERVATORY measured for this target end to end, in the four states it
* actually has. Nothing here was dialled by the panel — it is a read of the
* background prober's own measurement along the real path.
* *
* The one worth spelling out: `ok` with an EMPTY `exit_ip` is a SUCCESS. The * The one worth spelling out: `ok` with an EMPTY `exit_ip` is a SUCCESS. The
* delay was measured; only the address lookup came back empty. Rendering that as * delay was measured; only the address lookup came back empty. Rendering that as
@@ -1653,15 +1725,45 @@ function MemberRow({ member }: { member: GroupMemberHealth }) {
* traffic, so it reads as a result with the address slot marked unknown — dim, * traffic, so it reads as a result with the address slot marked unknown — dim,
* not red, and the LED stays green. * not red, and the LED stays green.
*/ */
function GroupTestReadout({ test, pending }: { test?: GroupTestResult; pending: boolean }) { /**
* 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 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.
*
* 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.
*/
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,
}: {
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
}) {
if (pending) { if (pending) {
// "testing", never "measuring": the health run owns that word and covers every // Names who is working and on what: the prober, on this target. The badge is
// group at once. Two runs that read the same on a card is how one group's test // scoped to the cards the run covers, so it can say "this one" honestly.
// came to look like all four were busy.
return ( return (
<div className="tg-test tg-test--wait" role="status"> <div className="tg-test tg-test--wait" role="status">
<Led variant="amber" pulse /> <Led variant="amber" pulse />
<span className="tg-test-msg">testing this exit…</span> <span className="tg-test-msg">waiting for the prober to measure this…</span>
</div> </div>
) )
} }
@@ -1670,10 +1772,22 @@ function GroupTestReadout({ test, pending }: { test?: GroupTestResult; pending:
const at = test.tested_unix ? fmtClock(test.tested_unix) : '' const at = test.tested_unix ? fmtClock(test.tested_unix) : ''
if (!test.ok) { 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 (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>}
</div>
)
}
return ( return (
<div className="tg-test tg-test--bad" role="status"> <div className="tg-test tg-test--bad" role="status">
<Led variant="crit" /> <Led variant="crit" />
<span className="tg-test-msg">{test.error || 'the test failed'}</span> <span className="tg-test-msg">{test.error || 'the probe failed'}</span>
{at && <span className="tg-test-at mono">{at}</span>} {at && <span className="tg-test-at mono">{at}</span>}
</div> </div>
) )
@@ -2098,7 +2212,7 @@ function ChainRow({
chain, chain,
busy, busy,
showHealth, showHealth,
used, health,
test, test,
testing, testing,
testBusy, testBusy,
@@ -2109,31 +2223,41 @@ function ChainRow({
chain: Chain chain: Chain
busy: boolean busy: boolean
/** Group health checks are on (Settings). When false, the card drops its /** Group health checks are on (Settings). When false, the card drops its
* exit-test readout and Test button — it is config only. */ * health readout and Refresh button — it is config only. */
showHealth: boolean showHealth: boolean
/** This chain's reachability (GroupHealth.Used's chain analogue, plan §5.E). /** Everything the observatory knows about this chain: whether any enabled rule
* undefined ⇒ the health endpoint hasn't reported this chain (not applied yet, or * routes through it, and the per-hop measurements along it.
* a daemon version without chains): no badge. false ⇒ no enabled rule routes * undefined ⇒ the health endpoint hasn't reported this chain at all (not
* through the chain, so the observatory never probes it and the card renders * applied yet, or a daemon version without chains): the card says nothing
* "unused" instead of an exit-test readout. */ * rather than guessing. */
used?: boolean health?: ChainHealth
test?: GroupTestResult test?: GroupTestResult
/** An exit test covering THIS chain is in flight (the caller resolves it /** A refresh pass covering THIS chain is in flight (the caller resolves it
* against the run's scope, exactly as for a group card). */ * against the run's scope, exactly as for a group card). */
testing: boolean testing: boolean
/** Any exit test is in flight; the daemon runs one at a time. */ /** Any refresh pass is in flight; the daemon runs one at a time. */
testBusy: boolean testBusy: boolean
onTest: () => void onTest: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const hops = asArray(chain.Hops) const hops = asArray(chain.Hops)
// A LEADING `egress:` is not a hop and the rail below already knows it: the
// daemon lifts it into hop 1's entry detour (see hopLabels), so it is tagged
// "entry" and never numbered. The badge counted it anyway, which is how a chain
// drawn with four hops came to be labelled "5 hops" directly above them.
const entryEgress = hops.length > 0 && hops[0].startsWith('egress:')
const numbered = entryEgress ? hops.length - 1 : hops.length
return ( return (
<li className="tg-row"> <li className="tg-row">
<div className="tg-row-main"> <div className="tg-row-main">
<div className="tg-row-l1"> <div className="tg-row-l1">
<span className="tg-row-name">{chain.Name}</span> <span className="tg-row-name">{chain.Name}</span>
<span className="tg-badge">{hops.length} hop{hops.length === 1 ? '' : 's'}</span> <span className="tg-badge">
{numbered === 0 && entryEgress
? 'entry only · no exit'
: `${numbered} hop${numbered === 1 ? '' : 's'}`}
</span>
</div> </div>
<div className="tg-row-l2"> <div className="tg-row-l2">
{hops.length === 0 ? ( {hops.length === 0 ? (
@@ -2165,25 +2289,21 @@ function ChainRow({
{showHealth && ( {showHealth && (
<> <>
{/* A chain no enabled rule routes through is never probed (the {/* A chain no enabled rule routes through is never probed (the
observatory walks only reachable paths), so instead of an exit-test observatory walks only reachable paths), so instead of a health
readout the card says so, quietly — the same "unused" pattern the readout the card says so — the same "unused" note the group card
group card uses (GroupHealthReadout), not a new design. `used` is uses, not a new design. `health` is undefined until the endpoint
undefined until the health endpoint reports this chain (or from a reports this chain (or on a daemon without chains): say nothing
daemon version without chains): no badge then. */} then rather than guess. */}
{used === false && ( {health?.used === false ? (
<div className="gh gh--unused"> <NotRoutedNote kind="chain" />
<div className="gh-line"> ) : health?.used ? (
<span <ChainHopRail chain={chain.Name} defs={hops} hops={health.hops} />
className="gh-unused" ) : null}
title="No enabled rule routes through this chain, so its exit is not probed. Add it to a rule to see health." <GroupTestReadout
> test={test}
unused pending={testing && !test}
</span> hideAbsence={health?.used === false}
<span className="gh-quiet">not probed — no enabled rule routes through this chain</span> />
</div>
</div>
)}
<GroupTestReadout test={test} pending={testing && !test} />
</> </>
)} )}
</div> </div>
@@ -2194,13 +2314,250 @@ function ChainRow({
editLabel={`Edit chain ${chain.Name}`} editLabel={`Edit chain ${chain.Name}`}
deleteLabel={`Delete chain ${chain.Name}`} deleteLabel={`Delete chain ${chain.Name}`}
onTest={showHealth ? onTest : undefined} onTest={showHealth ? onTest : undefined}
testLabel={showHealth ? `Test the exit of chain ${chain.Name}` : undefined} testLabel={showHealth ? `Refresh the reading for chain ${chain.Name}` : undefined}
testDisabled={testBusy} testDisabled={testBusy}
/> />
</li> </li>
) )
} }
// ---- chain hop rail --------------------------------------------------------
/**
* The API gives hops an index and no name. The page already knows the names — the
* model's own `Hops` strings ("egress:ewan", "node:awgout", "group:sub0") — so
* zip the two by POSITION.
*
* Two things make that safe rather than clever. A LEADING `egress:` is not a
* numbered hop: the daemon lifts it into hop 1's entry detour, so it is dropped
* before counting. And if the counts still disagree — a chain that splices
* sub-chains gets FLATTENED by the daemon, producing more wire hops than the
* config lists — every label is dropped. A hop labelled with its neighbour's name
* is worse than a hop with no name at all: it would send someone to fix the wrong
* target.
*/
function hopLabels(defs: string[], hops: ChainHopHealth[]): (string | undefined)[] {
const numbered = defs.length > 0 && defs[0].startsWith('egress:') ? defs.slice(1) : defs
if (numbered.length !== hops.length) return hops.map(() => undefined)
return hops.map((h) => (h.index >= 1 && h.index <= numbered.length ? numbered[h.index - 1] : undefined))
}
/**
* One hop's lamp.
*
* `dead` is crit and `untested` is an UNLIT socket — never red, because nothing
* has been measured and an unlit lamp is this panel's way of saying "no verdict".
* That covers a hop the walk never reached (`blocked_by`) too: it is neither
* healthy nor broken, and unlit is the only mark that claims neither.
* The fourth case is the page's existing house reading, applied here for
* consistency rather than invented: a group hop that is carrying traffic but has
* confirmed failures on its board is amber. `state` stays the daemon's word for
* "can this hop carry traffic"; the amber only qualifies HOW WELL.
*/
function hopLed(h: ChainHopHealth): LedVariant {
if (h.state === 'dead') return 'crit'
if (h.state === 'untested') return 'off'
return h.dead > 0 ? 'amber' : 'on'
}
/**
* What the observatory measured at each position of a chain — the reading the
* daemon always took and the panel never showed.
*
* THE DESIGN RISK, and the one place this card spends any boldness: the rail
* draws the CONDUCTOR as well as the lamps, and severs it below the first dead
* hop. A chain is a single series path, so the operator's real question is never
* "how many hops are green" — it is "where does my traffic stop". Four lamps in a
* column answer the first question and leave the second to arithmetic. A broken
* conductor answers the second one before you have read a single word, which is
* the whole reason this feature exists.
*
* It stays honest by keeping two different facts on two different marks. The
* CONDUCTOR is reachability through the path, and that genuinely does stop at the
* break. The LAMPS are measurements — and there are none below the break to show:
* the daemon walks the path in order and stops at the first hop that does not
* answer, because every later hop is dialled THROUGH that one. Those hops arrive
* `untested` with `blocked_by` naming the hop that stopped the walk, so their
* lamps stay UNLIT: not a soft red, not a pale green, just this panel's way of
* saying no verdict exists about a hop nobody reached. The row says so in words
* too, naming that hop, because "why is this row empty" is the question the shape
* alone cannot answer.
*
* Nothing here is re-derived from the daemon's counters — the block, the zeroed
* numbers and the state all come off the wire. The only thing the panel adds is
* what a chain structurally is.
*
* Everything around the rail is deliberately quiet: no colour but the semantic
* lamps, no motion at all, the orange accent untouched.
*/
function ChainHopRail({
chain,
defs,
hops,
}: {
chain: string
/** The chain's configured hops, straight off the model — the only source of names. */
defs: string[]
/** Absent ⇒ the engine never materialised per-hop outbounds. NOT "no hops". */
hops?: ChainHopHealth[]
}) {
const ordered = useMemo(() => [...asArray(hops)].sort((a, b) => a.index - b.index), [hops])
const labels = useMemo(() => hopLabels(defs, ordered), [defs, ordered])
// The first hop that was probed and did not answer. Everything after it is
// unreachable THROUGH THIS CHAIN, whatever its own lamp says. `untested` is
// never a break: nothing was measured, so nothing is known to be severed.
const breakAt = ordered.findIndex((h) => h.state === 'dead')
if (ordered.length === 0) {
// Say why, in one line, instead of an empty rail. The daemon collapses a
// single-target chain into a plain alias and never builds copies to measure,
// so we can tell the two absences apart from the config alone.
const numbered = defs.filter((d, i) => !(i === 0 && d.startsWith('egress:')))
return (
<div className="gh gh--absent">
<span className="gh-absent-msg">
{numbered.length <= 1
? 'This chain has a single hop, so the engine points traffic straight at that target instead of building a path to measure. Its health is on that target’s own card.'
: 'The engine hasn’t built this chain’s hops yet, so there is nothing measured per hop. They appear once it is running with this config applied.'}
</span>
</div>
)
}
return (
<div className="gh ch-rail">
<span className="ch-eyebrow">measured, hop by hop</span>
<ol className="ch-hops">
{ordered.map((h, i) => {
const label = labels[i]
const severed = breakAt >= 0 && i > breakAt
// Straight off the wire: present ⇒ the walk never reached this hop, so
// there is nothing measured here and the daemon has already named the
// hop that stopped it. Never inferred from the counters.
const blocked = h.blocked_by
const cls = [
'ch-hop',
`ch-hop--${h.state}`,
blocked ? 'ch-hop--blocked' : '',
severed ? 'ch-hop--severed' : '',
h.exit ? 'ch-hop--exit' : '',
i === 0 ? 'ch-hop--first' : '',
]
.filter(Boolean)
.join(' ')
const age = fmtAge(h.age_seconds)
return (
<li key={h.tag || h.index} className={cls}>
<span className="ch-num mono" aria-hidden="true">
{h.index}
</span>
<span className="ch-socket">
<Led variant={hopLed(h)} />
</span>
<div className="ch-body">
<div className="ch-l1">
<span className="ch-name mono" title={`engine outbound ${h.tag}`}>
{label ?? (h.kind === 'group' ? 'a group hop' : 'a node hop')}
</span>
{h.exit && <span className="ch-tag">exit</span>}
{h.state === 'alive' && h.delay_ms > 0 && (
<span className="ch-delay mono">{h.delay_ms} ms</span>
)}
{/* Two different silences. A plain untested hop is a timing
gap that fills in by itself; a BLOCKED one never will,
because the walk stopped above it — so it says which hop
stopped it instead of implying someone should wait. */}
{blocked ? (
<span
className="ch-quiet ch-blocked"
title={`hop ${blocked.index} did not answer, so nothing was dialled through it (engine outbound ${blocked.tag})`}
>
no reading — the probe stopped at hop {blocked.index}
</span>
) : (
h.state === 'untested' && <span className="ch-quiet">not measured yet</span>
)}
{age && <span className="ch-age mono">{age}</span>}
</div>
{/* A node hop IS its own measurement (total 1), so counters would
only restate the lamp. A group hop rolls up its per-hop member
copies, and those read exactly as they do everywhere else in
this app: alive out of TESTED, with the untested remainder as a
quiet aside only when there is one. A blocked hop has those
counters zeroed by the daemon, so it lands in the tested === 0
branch — and there it must say the members were never REACHED,
not that they are still waiting their turn. */}
{h.kind === 'group' && h.total > 0 && (
<div className="ch-l2">
{h.tested === 0 ? (
<span className="ch-rest mono">
{h.total} member{h.total === 1 ? '' : 's'},{' '}
{blocked ? 'none of them reached' : 'none measured'}
</span>
) : (
<>
<span className="ch-count mono">
<b>{h.alive}</b> / {h.tested}
</span>
<span className="ch-word">alive</span>
{h.dead > 0 && (
<span
className="ch-dead mono"
title={`${h.dead} member${h.dead === 1 ? '' : 's'} were probed at this hop and did not answer`}
>
{h.dead} not answering
</span>
)}
{h.untested > 0 && (
<span className="ch-rest mono">
tested {h.tested} of {h.total}
</span>
)}
</>
)}
{/* The wrapper keeps its pick even when nothing crossed it,
so on a blocked hop this is the node it WOULD use — say
that, rather than "now", which claims live traffic. */}
{h.selected && (
<span
className="ch-now mono"
title={
blocked
? `Hop ${h.index} of “${chain}” is set to ${h.selected}; nothing crossed it to measure`
: `Traffic crossing hop ${h.index} of “${chain}” is on ${h.selected}`
}
>
{blocked ? 'set to' : 'now'} → {h.selected}
</span>
)}
</div>
)}
</div>
</li>
)
})}
</ol>
{/* The sentence the rail's shape implies, written out — because the break is
the answer someone came here for, and a graphic alone should never be the
only place a finding exists. It names the dead hop, since that is the one
thing here anybody can act on. */}
{breakAt >= 0 && (
<p className="gh-say gh-say--bad">
Hop {ordered[breakAt].index}
{labels[breakAt] ? ` (${labels[breakAt]})` : ''} was probed and did not answer, so traffic
stops there
{breakAt < ordered.length - 1
? ' — and the hops below it are dialled through it, so nothing reached them and nothing is known about them.'
: '.'}
</p>
)}
</div>
)
}
function ChainEditor({ function ChainEditor({
initial, initial,
hopOptions, hopOptions,
@@ -2675,8 +3032,8 @@ function RowActions({
busy: boolean busy: boolean
editLabel: string editLabel: string
deleteLabel: string deleteLabel: string
// Only groups and chains can be tested, so the control is optional and absent // Only groups and chains are probed, so the refresh control is optional and
// everywhere else rather than a disabled stub on every row. // absent everywhere else rather than a disabled stub on every row.
onTest?: () => void onTest?: () => void
testLabel?: string testLabel?: string
testDisabled?: boolean testDisabled?: boolean
@@ -2689,8 +3046,9 @@ function RowActions({
onClick={onTest} onClick={onTest}
disabled={busy || testDisabled} disabled={busy || testDisabled}
aria-label={testLabel} aria-label={testLabel}
title="Ask the background prober to measure this target out of turn. It does not open a connection from the panel."
> >
Test Refresh
</Button> </Button>
)} )}
<Button className="tg-act" onClick={onEdit} disabled={busy} aria-label={editLabel}> <Button className="tg-act" onClick={onEdit} disabled={busy} aria-label={editLabel}>
+91
View File
@@ -0,0 +1,91 @@
// pendingConfirm — the record of an armed auto-rollback, shared by the whole panel.
//
// Run with `npm test`. The module imports React only for its hook; the plain
// functions exercised here touch neither React nor the DOM, and `localStorage` is
// absent under node, which is itself one of the cases worth pinning (the panel
// must still work, it just forgets on reload).
//
// What these protect:
// - arming when commit-confirm is OFF must record nothing. The daemon does not
// arm a window then, and a countdown for a rollback that will never happen is
// the same class of lie as the "Confirmed" message this module replaced.
// - a window that has elapsed reads as gone, so nothing renders "0 s left".
// - expiry notifies exactly once even though several components watch it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
armPendingConfirm,
clearPendingConfirm,
confirmTimeout,
noteConfirmTimeout,
onPendingConfirmExpire,
readPendingConfirm,
} from './pendingConfirm.ts'
test('commit-confirm off ⇒ arming records nothing', () => {
noteConfirmTimeout(0)
assert.equal(confirmTimeout(), 0)
armPendingConfirm()
assert.equal(readPendingConfirm(), null)
})
test('a window is recorded with the timeout the config reported', () => {
noteConfirmTimeout(90)
armPendingConfirm()
const p = readPendingConfirm()
assert.notEqual(p, null)
assert.equal(p!.total, 90)
// Deadline is in the future and within a second of now + the window.
const left = (p!.until - Date.now()) / 1000
assert.ok(left > 89 && left <= 90, `expected ~90s left, got ${left}`)
clearPendingConfirm()
assert.equal(readPendingConfirm(), null)
})
test('switching commit-confirm off drops a window that was already armed', () => {
noteConfirmTimeout(60)
armPendingConfirm()
assert.notEqual(readPendingConfirm(), null)
noteConfirmTimeout(0)
assert.equal(readPendingConfirm(), null)
})
test('an elapsed window reads as gone, never as a countdown at zero', () => {
noteConfirmTimeout(1)
armPendingConfirm()
const p = readPendingConfirm()
assert.notEqual(p, null)
// Wind the clock forward rather than sleeping through the window.
const realNow = Date.now
Date.now = () => realNow() + 5000
try {
assert.equal(readPendingConfirm(), null)
} finally {
Date.now = realNow
}
clearPendingConfirm()
})
test('confirming does NOT fire the expiry listeners', () => {
noteConfirmTimeout(30)
let fired = 0
const off = onPendingConfirmExpire(() => {
fired++
})
armPendingConfirm()
clearPendingConfirm()
off()
assert.equal(fired, 0)
})
test('a nonsense timeout is ignored rather than taken as "off"', () => {
noteConfirmTimeout(45)
noteConfirmTimeout(Number.NaN)
noteConfirmTimeout(-1)
noteConfirmTimeout(undefined)
assert.equal(confirmTimeout(), 45)
clearPendingConfirm()
noteConfirmTimeout(0)
})
+186
View File
@@ -0,0 +1,186 @@
// The commit-confirm window, as ONE fact the whole panel can see.
//
// WHY THIS EXISTS. `POST /api/apply` always arms an auto-rollback for
// `Globals.ConfirmTimeout` seconds (shater/panel/api.go handleApply →
// ArmRollback) — EVERY Apply button does that, not just the one on the Apply
// page. But the countdown, and the button that stops it, lived in one component's
// local state. So:
//
// - pressing Apply on Routing/DNS/Nodes/Settings/Devices/Profiles/Targets said
// "Applied" and nothing else; the operator walked away and the router quietly
// reverted a minute later;
// - reloading the tab wiped the countdown AND the "Keep this config" button, so
// there was no way left to confirm from the panel at all.
//
// The daemon does not report a deadline, so this module records the one the panel
// itself armed, in `localStorage`. That is a deliberately modest claim — it knows
// about windows THIS BROWSER opened and says nothing about one opened elsewhere —
// but it survives a reload, a new tab and a navigation, which is what the two
// failures above needed.
//
// It is also the answer to "is there anything to confirm?". `apply.Confirm()`
// returns nil unconditionally, so a Confirm button that is always live can only
// ever report success. Gating it on a record here means the panel offers the
// action when it knows a window is open, and then reports an outcome it knows.
import { useEffect, useState } from 'react'
/** A live commit-confirm window the panel armed. */
export interface PendingConfirm {
/** Epoch ms at which the daemon auto-rolls back if nobody confirms. */
until: number
/** The window it started with, in seconds — the progress bar's denominator. */
total: number
}
const KEY = 'shater.pendingConfirm'
type Listener = () => void
const listeners = new Set<Listener>()
const expiryListeners = new Set<Listener>()
/** localStorage is absent under SSR/tests and throws in some privacy modes. A
* panel that cannot remember a window must still work — it just forgets on
* reload, which is exactly the old behaviour and no worse. */
function store(): Storage | null {
try {
return typeof localStorage === 'undefined' ? null : localStorage
} catch {
return null
}
}
function load(): PendingConfirm | null {
const s = store()
if (!s) return null
try {
const raw = s.getItem(KEY)
if (!raw) return null
const v = JSON.parse(raw) as Partial<PendingConfirm>
if (typeof v.until !== 'number' || typeof v.total !== 'number') return null
if (!Number.isFinite(v.until)) return null
return { until: v.until, total: v.total }
} catch {
return null
}
}
// The single in-process copy. Storage is the durable mirror, not the source of
// truth for a running tab: a `storage` event re-hydrates it when another tab
// writes.
let armed: PendingConfirm | null = load()
function emit() {
for (const l of [...listeners]) l()
}
function write(v: PendingConfirm | null) {
armed = v
const s = store()
if (s) {
try {
if (v) s.setItem(KEY, JSON.stringify(v))
else s.removeItem(KEY)
} catch {
// Storage full or blocked — the in-process copy still drives this tab.
}
}
emit()
}
/** The armed window, or null when there is none or it has already elapsed. */
export function readPendingConfirm(): PendingConfirm | null {
if (!armed) return null
return armed.until > Date.now() ? armed : null
}
// ---- the window's length ----------------------------------------------------
// `Globals.ConfirmTimeout` is all that is needed to arm a window, and every page
// reads the config anyway — so api.getConfig() feeds it here rather than each
// caller threading it through. 0 (or never seen) means commit-confirm is off, and
// arming then does nothing: an apply on such a router really is immediate.
let timeout = 0
export function noteConfirmTimeout(seconds: number | undefined) {
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return
timeout = Math.floor(seconds)
// A window armed before commit-confirm was switched off is no longer real —
// drop it rather than count down to an event that will not happen.
if (timeout === 0 && armed) write(null)
}
export function confirmTimeout(): number {
return timeout
}
/** Record the window an apply just opened. Call only when the apply CHANGED
* something: an unchanged apply reconciles nothing and arms nothing. */
export function armPendingConfirm() {
if (timeout <= 0) return
write({ until: Date.now() + timeout * 1000, total: timeout })
}
/** Confirm and rollback both end the window. */
export function clearPendingConfirm() {
if (armed) write(null)
}
/** Fires when a window ran out on its own — i.e. the daemon has reverted — and
* NOT when it was confirmed or rolled back. Returns an unsubscribe. */
export function onPendingConfirmExpire(fn: Listener): () => void {
expiryListeners.add(fn)
return () => {
expiryListeners.delete(fn)
}
}
/** Idempotent: several mounted countdowns race to notice the same deadline, and
* only the first one gets to announce it. */
function expire() {
if (!armed) return
write(null)
for (const l of [...expiryListeners]) l()
}
// Another tab confirming, rolling back or applying is the same event as this one
// doing it.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== KEY && e.key !== null) return
armed = load()
emit()
})
}
/**
* The armed window and its remaining seconds, ticking once a second.
*
* Returns null when nothing is armed. While non-null `remaining` is at least 1:
* reaching zero clears the record and notifies {@link onPendingConfirmExpire}, so
* no component ever renders "0 s left" for a window that is already over.
*/
export function usePendingConfirm(): { pending: PendingConfirm; remaining: number } | null {
const [, tick] = useState(0)
useEffect(() => {
const sync = () => tick((n) => n + 1)
listeners.add(sync)
sync()
return () => {
listeners.delete(sync)
}
}, [])
useEffect(() => {
const id = window.setInterval(() => {
if (armed && armed.until <= Date.now()) expire()
else if (armed) tick((n) => n + 1)
}, 1000)
return () => window.clearInterval(id)
}, [])
const pending = readPendingConfirm()
if (!pending) return null
return { pending, remaining: Math.max(1, Math.ceil((pending.until - Date.now()) / 1000)) }
}
+253
View File
@@ -0,0 +1,253 @@
// protectionState — the one sentence the whole panel shows about "am I protected".
//
// Run with `npm test` (node's built-in test runner + native TypeScript stripping;
// no test dependency is added to the SPA, which ships inside the daemon binary).
//
// The case this file was written for is "plane full, traffic direct": the exact
// state of a live router — one enabled rule, `default → direct`, no groups, no
// rule-sets — where every part of the data plane was installed and the readout
// therefore said "Protected — traffic from your network is going through the
// tunnel", under a green LED, while the whole LAN went out the plain WAN.
//
// planeState.ts has no runtime imports (both of its imports are `import type`),
// so this runs against the real module with nothing stubbed.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { engineReadout, engineState, killSwitchReadout, protectionState } from './planeState.ts'
import type { Status, Traffic } from './api.ts'
/** A healthy, fully-installed router; `traffic` is what each case varies. */
function status(over: Partial<Status> = {}): Status {
return {
running: true,
enabled: true,
active: true,
table: true,
hash: 'abc',
version: '1.11.0-shater',
kill_switch: 'closed',
engine_running: true,
plane: 'full',
warnings: [],
...over,
}
}
function withTraffic(traffic: Traffic | undefined): Status {
return status({ traffic })
}
// --- the field case ---------------------------------------------------------
test('plane full + default direct is NOT reported as protected', () => {
const s = protectionState(withTraffic({ verdict: 'direct', default: 'direct', tunnel_rules: 0 }))
assert.notEqual(s.headline, 'Protected')
assert.equal(s.variant, 'crit')
assert.equal(s.alarm, true)
// The claim that was false must not survive anywhere in the copy.
assert.doesNotMatch(s.detail, /going through the tunnel/)
// ...and the honest consequence must be stated, not implied.
assert.match(s.detail, /real address/)
})
// --- the other verdicts under a full plane ----------------------------------
test('plane full + default into a tunnel is protected', () => {
const s = protectionState(withTraffic({ verdict: 'tunnel', default: 'auto', tunnel_rules: 1 }))
assert.equal(s.variant, 'on')
assert.equal(s.headline, 'Protected')
assert.equal(s.alarm, false)
})
test('plane full + direct default with tunnelling rules is split, not protected', () => {
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 3 }))
assert.equal(s.variant, 'amber')
assert.notEqual(s.headline, 'Protected')
// Says how much is protected, and that the default is not.
assert.match(s.detail, /3 rules/)
assert.match(s.detail, /normal internet connection/)
// A working selective setup must not raise a banner on every other page.
assert.equal(s.alarm, false)
})
test('split names a single rule in the singular', () => {
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 1 }))
assert.match(s.detail, /^One rule sends traffic/)
})
test('plane full + blocked default with rules leaks nothing and is never crit', () => {
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 2 }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, false)
assert.match(s.detail, /nothing is leaving unprotected/)
})
test('plane full + blocked default with no rules says the network has no way out', () => {
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 0 }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
assert.doesNotMatch(s.detail, /going through the tunnel/)
})
test('plane full with no verdict claims nothing either way', () => {
for (const t of [undefined, { verdict: '' as const }]) {
const s = protectionState(withTraffic(t))
assert.notEqual(s.headline, 'Protected')
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, false)
}
})
// --- the branches that were already correct ---------------------------------
test('no status yet', () => {
const s = protectionState(null)
assert.equal(s.variant, 'off')
assert.equal(s.alarm, false)
})
test('service switched off is a deliberate state, not a fault', () => {
const s = protectionState(status({ enabled: false }))
assert.equal(s.variant, 'amber')
assert.equal(s.headline, 'Turned off')
assert.equal(s.alarm, false)
})
test('hold: the kill-switch caught it — protected, offline', () => {
const s = protectionState(status({ plane: 'hold', engine_running: false, active: false }))
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
assert.match(s.headline, /blocked/)
})
test('none + fail-closed is the leak, and it is crit', () => {
const s = protectionState(status({ plane: 'none', table: false, engine_running: false }))
assert.equal(s.variant, 'crit')
assert.equal(s.alarm, true)
})
test('none + fail-open is the operator’s documented choice, stated not alarmed at', () => {
const s = protectionState(
status({ plane: 'none', table: false, engine_running: false, kill_switch: 'open' }),
)
assert.equal(s.variant, 'amber')
assert.equal(s.alarm, true)
})
test('daemon too old to send `plane` keeps its own fallback', () => {
// Nothing here may depend on `traffic`: a daemon with no `plane` has no
// `traffic` either, and this branch reads what it can observe instead.
const { plane, ...noPlane } = status()
void plane
assert.equal(protectionState(noPlane as Status).headline, 'Protected')
assert.equal(protectionState({ ...noPlane, running: false } as Status).headline, 'Service stopped')
assert.equal(
protectionState({ ...noPlane, active: false } as Status).headline,
'Starting up',
)
})
// --- engineState: the reading that could not say "down" ----------------------
//
// `apply.Status.running` was a hardcoded `true` on the daemon, so every panel LED
// derived from it was lit before it was read: App's master indicator could not
// reach its "Offline" branch, and Apply's "engine: running / stopped" row had one
// reachable value. These pin the three answers, and that "up" needs agreement.
test('engine_running:false is down even while the daemon claims it is running', () => {
assert.equal(engineState(status({ running: true, engine_running: false })), 'down')
assert.equal(engineReadout(status({ running: true, engine_running: false })).variant, 'crit')
assert.equal(engineReadout(status({ running: true, engine_running: false })).word, 'stopped')
})
test('a daemon that reports itself stopped is down whatever engine_running says', () => {
assert.equal(engineState(status({ running: false, engine_running: true })), 'down')
})
test('up needs both, and then active/idle splits the lamp', () => {
assert.equal(engineState(status({ running: true, engine_running: true })), 'up')
assert.equal(engineReadout(status({ active: true })).variant, 'on')
assert.equal(engineReadout(status({ active: true })).word, 'active')
assert.equal(engineReadout(status({ active: false })).variant, 'amber')
assert.equal(engineReadout(status({ active: false })).word, 'idle')
})
test('an older daemon with no engine_running is unknown — an unlit lamp, never green', () => {
const { engine_running, ...old } = status()
void engine_running
assert.equal(engineState(old as Status), 'unknown')
const r = engineReadout(old as Status)
assert.equal(r.variant, 'off')
assert.notEqual(r.variant, 'on')
assert.equal(r.word, 'not reported')
})
test('no status at all is unknown, not down', () => {
assert.equal(engineState(null), 'unknown')
assert.equal(engineReadout(null).variant, 'off')
assert.equal(engineReadout(null).word, 'checking…')
})
// --- killSwitchReadout: "I don't know" is not "it's armed" -------------------
//
// The Overview module read `killArmed && status?.plane !== 'none'`, and
// `undefined !== 'none'` is true — so a daemon that never reported `plane`, and
// the seconds before the first status arrives, both lit a green lamp over the
// word ARMED. These pin the fourth answer that expression could not express.
test('a daemon that does not report `plane` reads as not reported, never ARMED', () => {
const { plane, ...noPlane } = status()
void plane
const k = killSwitchReadout(noPlane as Status)
assert.equal(k.state, 'unknown')
assert.notEqual(k.value, 'ARMED')
assert.equal(k.variant, 'off')
assert.notEqual(k.variant, 'on')
assert.equal(k.blockingNow, 'not known')
})
test('no status at all is unknown too, and says there is no reading', () => {
const k = killSwitchReadout(null)
assert.equal(k.state, 'unknown')
assert.equal(k.variant, 'off')
assert.equal(k.blockingNow, 'no reading yet')
})
test('fail-closed with a plane installed is armed', () => {
for (const plane of ['full', 'hold'] as const) {
const k = killSwitchReadout(status({ plane }))
assert.equal(k.state, 'armed')
assert.equal(k.value, 'ARMED')
assert.equal(k.variant, 'on')
assert.equal(k.blockingNow, null)
}
})
test('fail-closed with no plane is configured but blocking nothing', () => {
const k = killSwitchReadout(status({ plane: 'none', table: false }))
assert.equal(k.state, 'inert')
assert.equal(k.value, 'NOT IN EFFECT')
assert.equal(k.variant, 'crit')
assert.equal(k.hot, true)
})
test('fail-open is the operator’s choice — amber, and never a plane question', () => {
for (const plane of ['full', 'none', undefined] as const) {
const k = killSwitchReadout(status({ kill_switch: 'open', plane }))
assert.equal(k.state, 'open')
assert.equal(k.value, 'OPEN')
assert.equal(k.variant, 'amber')
}
})
test('the live kill_switch wins over the saved one; the saved one only fills a gap', () => {
const { kill_switch, ...noKill } = status()
void kill_switch
// Live says open, config says closed → live wins.
assert.equal(killSwitchReadout(status({ kill_switch: 'open' }), 'closed').state, 'open')
// Nothing live → fall back to the saved policy.
assert.equal(killSwitchReadout(noKill as Status, 'open').state, 'open')
assert.equal(killSwitchReadout(noKill as Status, 'closed').state, 'armed')
})
+245 -17
View File
@@ -11,7 +11,7 @@
// same router differently. // same router differently.
import type { LedVariant } from './components' import type { LedVariant } from './components'
import type { Status } from './api' import type { Status, Traffic } from './api'
export interface ProtectionState { export interface ProtectionState {
variant: LedVariant variant: LedVariant
@@ -21,6 +21,135 @@ export interface ProtectionState {
alarm: boolean alarm: boolean
} }
// ---------------------------------------------------------------------------
// Is the engine actually up?
// ---------------------------------------------------------------------------
/**
* Three answers, and "unknown" is one of them.
*
* up — the sing-box process is running.
* down — it is not. Nothing is being proxied or filtered.
* unknown — nobody has told us. Never paint this green.
*
* THIS EXISTS BECAUSE `status.running` COULD NOT SAY "down". It is the DAEMON's
* own liveness, and on the daemons this panel shipped against it was a hardcoded
* `true` (apply.go) — so `running ? 'running' : 'stopped'` had exactly one
* reachable branch, and every LED derived from it was lit before it was read. A
* router whose engine failed to start, with a fail-closed holding plan installed
* and no internet on the LAN, showed three green lamps on the page people go to
* when they are trying to fix it.
*
* `engine_running` is the field that answers the question honestly, so it decides
* `up`. Either field may still prove a NEGATIVE — a daemon that reports itself
* stopped cannot be running an engine — and a negative always wins, so "up" needs
* both to agree. Neither field asserting anything leaves `unknown`.
*/
export type EngineState = 'up' | 'down' | 'unknown'
export function engineState(status: Status | null): EngineState {
if (!status) return 'unknown'
if (!status.running) return 'down'
if (typeof status.engine_running === 'boolean') return status.engine_running ? 'up' : 'down'
return 'unknown'
}
/** How the engine's lamp is painted and what the readout beside it says.
*
* `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. */
export function engineReadout(status: Status | null): { variant: LedVariant; word: string } {
switch (engineState(status)) {
case 'down':
return { variant: 'crit', word: 'stopped' }
case 'up':
return status?.active ? { variant: 'on', word: 'active' } : { variant: 'amber', word: 'idle' }
default:
return { variant: 'off', word: status ? 'not reported' : 'checking…' }
}
}
// ---------------------------------------------------------------------------
// Is the kill-switch actually blocking anything?
// ---------------------------------------------------------------------------
/**
* Four answers, and "unknown" is one of them.
*
* 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.
* 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 interface KillSwitchReadout {
state: KillSwitchState
/** The word the module puts in its readout. */
value: string
variant: LedVariant
/** Is it blocking right now — the row under the readout. `null` ⇒ nothing to add. */
blockingNow: string | null
/** True when `blockingNow` is bad news and should be drawn hot. */
hot: boolean
}
/**
* THE UNKNOWN BRANCH IS THE WHOLE POINT. This used to be
*
* killArmed && status?.plane !== 'none'
*
* and `undefined !== 'none'` is true — so a daemon that had not reported `plane`
* at all, and a panel that had not yet received its first status, both landed in
* the "ARMED" branch under a green lamp. Every other unknown in this file is an
* unlit socket for exactly this reason (see engineReadout): the kill-switch is
* the last thing standing between the LAN and the plain WAN, and "I don't know
* whether it is installed" must never be dressed as "it is".
*
* `configured` is the SAVED policy from /api/config, used only while
* /api/status has not reported one. The live value wins wherever it exists, as
* everywhere else in the panel: this is a status readout, and the config on disk
* can already differ from what is installed.
*/
export function killSwitchReadout(
status: Status | null,
configured?: string,
): KillSwitchReadout {
const closed = (status?.kill_switch ?? configured ?? 'closed') === 'closed'
if (!closed) {
return { state: 'open', value: 'OPEN', variant: 'amber', blockingNow: null, hot: false }
}
switch (status?.plane) {
case 'full':
case 'hold':
// Something is installed, so the fail-closed guard is really in the path.
return { state: 'armed', value: 'ARMED', variant: 'on', blockingNow: null, hot: false }
case 'none':
return {
state: 'inert',
value: 'NOT IN EFFECT',
variant: 'crit',
blockingNow: 'no — nothing installed',
hot: true,
}
default:
return {
state: 'unknown',
value: 'NOT REPORTED',
variant: 'off',
// Terse on purpose: this is a two-column readout row, and the long form
// wrapped onto three lines beside a one-word key.
blockingNow: status ? 'not known' : 'no reading yet',
hot: false,
}
}
}
/** /**
* `plane` + `engine_running` express the state more precisely than the three * `plane` + `engine_running` express the state more precisely than the three
* booleans the old status strip exposed (engine active / config enabled / nft * booleans the old status strip exposed (engine active / config enabled / nft
@@ -31,6 +160,24 @@ export interface ProtectionState {
* hold — the kill-switch caught it. Protected, but offline. * hold — the kill-switch caught it. Protected, but offline.
* none (fail-closed) — there is no protection at all. Online, and exposed. * none (fail-closed) — there is no protection at all. Online, and exposed.
* Collapsing them would erase the only difference that matters. * Collapsing them would erase the only difference that matters.
*
* PLANE IS NOT THE WHOLE ANSWER, AND THAT USED TO BE A LIE. `plane: 'full'`
* returned "Protected — traffic from your network is going through the tunnel",
* which is a claim `plane` cannot support: it only says the nft table, the policy
* routing and the engine are all installed. Where the diverted packets go once the
* engine has them is decided by the engine's default route, and a router in the
* field ran with one rule — `default → direct`, no groups, no rule-sets. Fully
* installed plane, zero tunnel, whole LAN out the plain WAN with its real address,
* green LED, "Protected".
*
* So `full` now branches on `status.traffic`, the daemon's verdict on the config
* it is actually running (see api.ts TrafficVerdict). It is computed on the daemon
* because only the daemon knows what was GENERATED and STARTED: the panel's
* /api/config is desired state, which diverges from the running one whenever edits
* are unapplied or a rollback is pending, and re-deriving the default route from it
* would mean a second implementation of the generator's rule loop — schedules,
* shadowed catch-alls, targets that failed to resolve and fell back to direct —
* drifting against the first.
*/ */
export function protectionState(status: Status | null): ProtectionState { export function protectionState(status: Status | null): ProtectionState {
if (!status) { if (!status) {
@@ -56,12 +203,7 @@ export function protectionState(status: Status | null): ProtectionState {
switch (status.plane) { switch (status.plane) {
case 'full': case 'full':
return { return fullPlaneState(status.traffic)
variant: 'on',
headline: 'Protected',
detail: 'Traffic from your network is going through the tunnel.',
alarm: false,
}
case 'hold': case 'hold':
return { return {
variant: 'amber', variant: 'amber',
@@ -88,8 +230,18 @@ export function protectionState(status: Status | null): ProtectionState {
} }
} }
// Older daemon with no `plane` field: fall back to what we can observe. // Older daemon with no `plane` field: fall back to what we can observe. The
if (status.running && status.active && status.table) { // engine's own state is asked FIRST — "stopped" is the answer that matters, and
// reading it off `running` is what used to make it unreachable (see engineState).
if (engineState(status) === 'down') {
return {
variant: 'crit',
headline: 'Service stopped',
detail: 'The engine isn’t running, so traffic isn’t being proxied or filtered.',
alarm: true,
}
}
if (status.active && status.table) {
return { return {
variant: 'on', variant: 'on',
headline: 'Protected', headline: 'Protected',
@@ -97,14 +249,6 @@ export function protectionState(status: Status | null): ProtectionState {
alarm: false, alarm: false,
} }
} }
if (!status.running) {
return {
variant: 'crit',
headline: 'Service stopped',
detail: 'The service isn’t running, so traffic isn’t being handled.',
alarm: true,
}
}
return { return {
variant: 'amber', variant: 'amber',
headline: 'Starting up', headline: 'Starting up',
@@ -112,3 +256,87 @@ export function protectionState(status: Status | null): ProtectionState {
alarm: false, alarm: false,
} }
} }
/**
* The plane is fully installed — now say where the traffic it carries ends up.
*
* Only `tunnel` earns "Protected". The other verdicts each describe a real router
* someone can be sitting in front of, and they are kept apart because the thing to
* DO about them differs:
*
* split — deliberate for most people who reach it, accidental for the rest
* (a default rule that was never pointed anywhere). Amber, but no
* alarm: raising a banner on every page of a working selective setup
* is how a banner stops being read.
* direct — the engine is running and forwarding every connection out the plain
* WAN. The traffic outcome is identical to `plane: 'none'` under a
* closed kill-switch, so it gets the same weight: crit, and it
* interrupts. The wording differs because the fix does — nothing
* failed here, the routing simply says "direct".
* blocked — the fail-closed default. Nothing is leaking, so this is never crit;
* with no tunnelling rules at all it means the network has no way out
* and someone should be told why.
* unknown — an older daemon, or the seconds between this daemon starting and its
* first apply. We do not know, so we do not claim. Saying "Protected"
* here is the exact bug being removed.
*/
function fullPlaneState(traffic: Traffic | undefined): ProtectionState {
const tunnelRules = traffic?.tunnel_rules ?? 0
switch (traffic?.verdict) {
case 'tunnel':
return {
variant: 'on',
headline: 'Protected',
detail: 'Traffic from your network is going through the tunnel.',
alarm: false,
}
case 'split':
return {
variant: 'amber',
headline: 'Partly protected — the rest goes out directly',
detail: `${ruleCount(tunnelRules)} through the tunnel. Everything they don’t match leaves through your normal internet connection, with your real address.`,
alarm: false,
}
case 'direct':
return {
variant: 'crit',
headline: 'Not protected — nothing is going through the tunnel',
detail:
'The service is running, but your routing sends every connection straight out your normal internet connection, with your real address. On the Routing page, point the default rule at a group or a node.',
alarm: true,
}
case 'blocked':
return tunnelRules > 0
? {
variant: 'amber',
headline: 'Partly protected — everything else is blocked',
detail: `${ruleCount(tunnelRules)} through the tunnel. Anything they don’t match is blocked instead of being let out, so nothing is leaving unprotected.`,
alarm: false,
}
: {
variant: 'amber',
headline: 'Nothing is getting out',
detail:
'No rule sends traffic anywhere, so every connection from your network is being blocked rather than let out unprotected. Add a default rule on the Routing page.',
alarm: true,
}
default:
return {
variant: 'amber',
headline: 'Checking where traffic goes',
detail:
'The router is up and handling your traffic. It hasn’t reported yet whether that traffic is going through the tunnel.',
alarm: false,
}
}
}
/** "One rule sends traffic" / "4 rules send traffic", so the detail lines above
* can name a number the operator can go and count on the Routing page. Falls
* back to the vague form only if the daemon sent a verdict without a count. */
function ruleCount(n: number): string {
if (n <= 0) return 'Some traffic goes'
if (n === 1) return 'One rule sends traffic'
return `${n} rules send traffic`
}
+6 -1
View File
@@ -18,5 +18,10 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "vite.config.ts"],
// *.test.ts runs under node's built-in test runner (`npm test`), which strips
// types rather than checking them. They are excluded here because they import
// node:test / node:assert, and the SPA deliberately carries no @types/node — it
// is embedded in the daemon binary, so every devDependency is weight on a router.
"exclude": ["src/**/*.test.ts"]
} }
+48 -1
View File
@@ -1,15 +1,62 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// Minimal ambient for the dev-proxy target override — avoids pulling in @types/node // Minimal ambient for the dev-proxy target override — avoids pulling in @types/node
// just for one env read. Vite runs this file under Node where `process` exists. // just for one env read. Vite runs this file under Node where `process` exists.
declare const process: { env: Record<string, string | undefined> } declare const process: { env: Record<string, string | undefined> }
/** `src/mock.ts`, as the module graph spells it (POSIX-normalised for Windows). */
const MOCK_MODULE = 'src/mock.ts'
/**
* Refuse to emit a production bundle that contains the offline fixture backend.
*
* `src/mock.ts` describes an invented, healthy router: a full config, 122 nodes
* with 119 of them alive, "Protected". It exists so `npm run dev` renders without
* a daemon. It shipped inside the binary that goes on real hardware, switched on
* by nothing more than a `?dev` on the end of the URL — so a link someone was
* sent, or a bookmark they saved, showed an appliance in perfect health while
* making no request to the appliance at all.
*
* api.ts now loads it behind `import.meta.env.DEV`, which Vite folds to a literal
* `false` for a build, so Rollup drops the dynamic import and the module never
* enters the graph. That is a property of a build tool's optimiser, and an
* optimiser is not a promise: one refactor that makes the condition non-static
* silently puts the fixtures back. So the property is CHECKED rather than
* trusted — if `src/mock.ts` reaches any emitted chunk, the build fails here
* instead of shipping.
*/
function assertNoMockFixtures(): Plugin {
return {
name: 'shater:assert-no-mock-fixtures',
apply: 'build',
generateBundle(_options, bundle) {
const guilty: string[] = []
for (const [file, output] of Object.entries(bundle)) {
if (output.type !== 'chunk') continue
for (const id of output.moduleIds) {
if (id.replace(/\\/g, '/').endsWith(MOCK_MODULE)) guilty.push(`${file} ← ${id}`)
}
}
if (guilty.length > 0) {
this.error(
`the offline fixture backend (${MOCK_MODULE}) reached the production bundle:\n ` +
guilty.join('\n ') +
`\nFixtures describe a router that does not exist. Keep every path to them behind ` +
`\`import.meta.env.DEV\` so Rollup can drop them, and never gate them on a runtime ` +
`flag such as a query parameter.`,
)
}
},
}
}
// The SPA is embedded in the forked sing-box binary and served by the daemon on // The SPA is embedded in the forked sing-box binary and served by the daemon on
// its own port. Relative base so it works under any mount path; single small // its own port. Relative base so it works under any mount path; single small
// bundle (no code-splitting) keeps the embed simple and the flash budget low. // bundle (no code-splitting) keeps the embed simple and the flash budget low.
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), assertNoMockFixtures()],
base: './', base: './',
build: { build: {
outDir: 'dist', outDir: 'dist',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

-93
View File
@@ -1,93 +0,0 @@
// lx:begin awg
package group
import (
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
)
// suspendAmneziaWGConsumersOnWireGuardSwitch is called from Selector.SelectOutbound
// BEFORE the switch is committed. If the member about to be selected is — or chains
// down via detour to — a WireGuard-based endpoint (type "wireguard", covering plain
// WG and AmneziaWG), it walks UP from this group to every AmneziaWG endpoint that
// detours through it and suspends each one (brings its device down). Rationale:
// AmneziaWG traffic encapsulated inside a WireGuard tunnel hangs the kernel on
// Android; the static Start-guard cannot cover this because a selector's chosen
// member is only known at runtime.
//
// Called before s.selected is updated, so the race is closed: once the group
// points at the WireGuard member, the AmneziaWG consumers are already suspended
// (started=false) and a concurrent reconnect fails with "not ready" instead of
// sending a junk handshake into WireGuard.
func suspendAmneziaWGConsumersOnWireGuardSwitch(outboundManager adapter.OutboundManager, groupTag string, selected adapter.Outbound) {
if outboundManager == nil || groupTag == "" {
return
}
if !chainReachesWireGuard(outboundManager, selected, make(map[string]bool)) {
return
}
suspendAmneziaWGConsumers(outboundManager, groupTag, make(map[string]bool))
}
// chainReachesWireGuard reports whether outbound is — or transitively detours
// down to, or (being a group) contains a member that is — a WireGuard-based
// endpoint. visited guards against cycles.
func chainReachesWireGuard(outboundManager adapter.OutboundManager, outbound adapter.Outbound, visited map[string]bool) bool {
if outbound == nil {
return false
}
tag := outbound.Tag()
if tag != "" {
if visited[tag] {
return false
}
visited[tag] = true
}
if outbound.Type() == C.TypeWireGuard {
return true
}
// Down the detour chain (vless -> ... -> wireguard).
for _, dependency := range outbound.Dependencies() {
if member, loaded := outboundManager.Outbound(dependency); loaded {
if chainReachesWireGuard(outboundManager, member, visited) {
return true
}
}
}
// A nested group: any member reaching WireGuard counts.
if group, isGroup := outbound.(adapter.OutboundGroup); isGroup {
for _, memberTag := range group.All() {
if member, loaded := outboundManager.Outbound(memberTag); loaded {
if chainReachesWireGuard(outboundManager, member, visited) {
return true
}
}
}
}
return false
}
// suspendAmneziaWGConsumers walks UP from tag via the reverse-dependency ledger
// (ConsumersOf) and suspends every AmneziaWG endpoint that detours through it,
// directly or transitively (e.g. AWG -> vless -> group). visited guards cycles.
func suspendAmneziaWGConsumers(outboundManager adapter.OutboundManager, tag string, visited map[string]bool) {
for _, consumerTag := range outboundManager.ConsumersOf(tag) {
if visited[consumerTag] {
continue
}
visited[consumerTag] = true
consumer, loaded := outboundManager.Outbound(consumerTag)
if !loaded {
continue
}
if awg, isAWG := consumer.(adapter.AmneziaWGSuspendable); isAWG && awg.IsAmneziaWG() {
awg.SuspendAmneziaWG()
}
// Keep walking up: a non-AWG hop (vless) or a parent group may itself have
// an AmneziaWG consumer above it.
suspendAmneziaWGConsumers(outboundManager, consumerTag, visited)
}
}
// lx:end awg

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