On a healthy router the warning set is reprinted by every reconcile — once a
minute from cron plus every hotplug event — so logread filled with the same
line forever and buried the warnings that matter (a blocklist that failed to
load, an interface the kill-switch does not cover, a missing data plane). On a
router logread is an in-memory ring buffer, so this also evicted the history
needed to investigate an incident.
Warnings are still returned in full by GET /api/status on every request; only
the logging is deduplicated, keyed on a fingerprint of the set. Message texts
carry volatile parts (free MiB on /overlay, compiled domain counts, the address
inside a network error), so digits are normalised for comparison only — the
logged and API-returned text is untouched. A restart reprints the full set, and
clearing the last warning logs one line saying so.
Also fixes the severity-to-syslog mapping: an [info] warning was being emitted
at WARN, so anyone filtering on WARN saw noise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Records the positions the audit changed: fail-closed must cover "engine never
started" (holding plane), engine start must not depend on the network (remote
rule-set preflight, with the deferred cache-seed fix noted), BlockDoH needs no
route-plane upstream exclusion (engine dials bypass route rules), DNSMode is
unimplementable and its control was removed, TPROXY's inability to carry
ICMP/IGMP/ESP/GRE is now an explicit 3-way policy, and fail-open degradations
must surface in the panel rather than only in logread.
Also flags the contradiction left open: D15 promises seeding StevenBlack/OISD/
AdGuard while blocklist source=url accepts only compiled .srs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
A ground-up audit of the whole v0.2 stack by 8 parallel agents (DNS generate,
routing generate, model/parse/subscribe, netplane/apply/engine/alert, stats +
panel API, panel frontend, OpenWrt packaging), with every finding reproduced or
verified on the OpenWrt QEMU testbed. ~60 defects fixed, each with a regression
test that was checked to FAIL against the old behaviour.
RELEASE BLOCKERS
* Engine-start failure left the data plane ABSENT: with kill_switch=closed the
router silently degraded to a plain OpenWrt box — no tunnel, no filtering, no
kill-switch — while the panel looked healthy. Reproduced live. Now any
engine-start failure installs a fail-closed holding plane (forward blocked,
LAN-to-LAN and management preserved) and reports plane=hold/none.
* An unreachable remote rule-set aborted engine start entirely, so a router that
booted before its ISP link came up ended with a dead LAN and no way to recover.
Remote lists are now preflighted and skipped with a loud warning instead.
* `geosite:` in a routing rule hard-errored box.New — one legacy rule took the
whole LAN down. Same class: unvalidated CIDR / port / regexp, and marker-only
list entries ("." / "keyword:"). A lone `keyword:` also silently NXDOMAINed
the entire internet.
* Fail-closed drop only covered tproxy inbounds, not interfaces diverted by rule
sources — engine down leaked those networks to WAN in plaintext (4f618140 redux).
* UCI injection: a newline in a subscription-supplied node name broke out of the
line-oriented config and wrote attacker-controlled sections.
* Bootstrap deadlock: the daemon refused to start while disabled, but the panel
IS the daemon — a fresh install could never be configured from the UI.
SILENT FAILURES (the audit's main theme)
* per-device DNS block ignored the `suffix:` prefix — parental control that
quietly didn't block. Unknown `word:` prefixes now warn instead of vanishing.
* sqlite reused `seq` after retention wiped rows, stalling the live log forever.
* `after=` cursor returned the NEWEST rows, permanently skipping bursts.
* Stats emitted null arrays on a freshly booted router, blanking Overview.
* Alerts fired twice per incident; new_device alerts swallowed all but the first
device in a 60s window.
* Disabled subscription nodes were silently re-enabled on every refresh.
* Invalid Include/Exclude regexes failed OPEN, disabling the whole filter.
DEAD KNOBS — wired or honestly removed
ru-bypass preset (emitted an unsupported geoip: matcher) -> real geoip rule-set
Globals.ResolverFallback -> implemented via evaluate + match_response chain
Globals.DNSMode -> unimplementable by design; control removed, fake-IP
documented via a type=fakeip resolver instead
Rule.Kill -> implemented (default | closed | open)
Rule.Egress -> was read by nobody; multi-WAN binding silently no-op
ExpireAlertDays + quota -> subscription-userinfo parsed, persisted, alerted
StatsBackend hot-switch -> store is re-created on change
Inbound.Sniff -> documented as vestigial (sniffing is a route action)
NEW
* Globals.Untunnelable (block | icmp | direct): TPROXY can only carry TCP/UDP, so
ICMP/IGMP/ESP/GRE were dropped with no explanation — ping simply didn't work.
Now an explicit policy, defaulting to the previous behaviour, and explained in
the UI by consequence rather than by protocol.
* apply now surfaces its warnings through /api/status (severity/section/name), so
fail-open degradations are visible in the panel instead of only in logread.
* Panel: Networks page (which LAN networks are intercepted + inbound editor),
DNS-rules editor, subscription quota/expiry, plane banner and findings list.
* Control-socket client got per-verb timeouts — a wedged daemon used to pile up
one stuck `shaterd status` per minute until OOM.
* cache.db is now bounded (8 MiB, tmpfs fallback below 24 MiB free): on a 98 MB
rootfs with ~33 MB free it could otherwise grow past what an upgrade needs.
* OpenWrt packaging: nftables-json + ca-bundle deps, postinst restart on binary
upgrade, idempotent rt_tables seeding, cron gated correctly.
VERIFIED ON THE TESTBED
fail-closed holds with the engine frozen; offline boot now starts the engine;
RU destinations go direct while the rest goes through a node (per-connection
proof); ads NXDOMAIN with allowlist override; DoH blocked while the configured
upstream still resolves; sqlite history survives a daemon restart; a failed
apply restores the previous config without dropping the engine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Devices lose per-device Proxy/Target (routing is expressed with ordinary
routing rules whose Source picker targets a device); a device is now pure
DNS policy: identity + Enabled + Block/Allow. The panel drops the
"Route through proxy" toggle and Exit picker, and gains inline rename
(pencil -> input; renaming an unmanaged device upserts it into managed).
New Globals.BlockDoH (uci block_doh, default off): engine-level block of
known public DoH resolvers so clients fall back to plaintext :53 that the
engine intercepts. DNS layer answers the DoH hostnames + the Firefox
canary use-application-dns.net with NXDOMAIN; route layer rejects :443
(tcp+udp, HTTP/3 covered) to the hostnames and dedicated resolver IPs.
Hostnames/IPs that are themselves configured upstream resolvers are
excluded with a warning (never the canary). Reject rules set
Method=default explicitly - a directly constructed "" bypasses the
UnmarshalJSON normalisation and panics the engine at first match
(found live on the VM, regression-tested).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Per-device DNS block/allow and the DNS filter only caught DNS that the
tproxy forward-divert steals (client -> external resolver). A client using
the router itself as DNS hit dnsmasq directly (fib daddr type local bypass)
and slipped every filter. New Globals.DNSIntercept (uci dns_intercept):
when set, nft diverts all LAN :53 (tcp+udp, v4+v6, source-IP preserved)
into the engine ABOVE the fib-local bypass, so even DNS addressed to the
router is hijacked and per-device rules apply to everyone. DoT/DoQ :853
stays rejected (clients fall back to plaintext); DoH :443 can't be
intercepted (stated in the UI). .lan + private reverse zones are forwarded
back to dnsmasq (127.0.0.1:53, direct detour) so local names still resolve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
A WAN uplink on a private DHCP address (provider double-NAT) was wrongly
offered as a LAN source. Interfaces() now tags each interface with its
firewall zone (one `uci export firewall` pass, reusing the zone scanner),
and the picker treats an interface as a LAN network when it has a subnet
and its zone is not wan* — falling back to the private-subnet heuristic
only when the zone is unknown. VPN tunnels self-filter (no subnet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The blind free-text Source input in both rule forms is now SrcPicker: a
chip slot whose popover offers the router's real private subnets (from
/api/interfaces, host bits normalized to the network address), the
discovered devices by name (the bare IP is what's stored), and a
validated custom IP/CIDR input. Empty = "everyone · all LAN clients".
Existing hand-typed Src values classify back into device/network/custom
chips by value, never rewritten. Shared module cache: one
interfaces+devices fetch per session across all open forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Domain matching belongs to rulesets (that's what they are for) — the Match
picker is now rulesets only / ip-cidr / port, with the value input hidden
for rulesets-only. The edit form keeps a "Domain(s) — legacy" field ONLY
when a rule already carries free-text domains, so old rules stay visible
and clearable instead of silently preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
- model: Ruleset/Blocklist/Allowlist Category -> Categories []string
(uci `list category`; a legacy lone `option category` still reads as a
one-element list and migrates to the list form on the next write)
- generate: one remote .srs per category, tag rs-<name>-<category>
(bl-/al- for the DNS filter); a rule referencing the ruleset matches
every category's set; non-geo sources keep their old single tags
- panel status: rows gain `category`; tag->(name,category) resolved from
the model, not string parsing (names/categories may contain dashes)
- CatSuggest is now a chip multi-select: pick from the SagerNet base ->
chip with a status LED (green = from base/verified, amber = added
offline "anyway"), duplicates flash the existing chip, Backspace/×
remove, and free unpicked text never survives blur or save
- Routing/DNS forms save Categories (>=1 chip required); ruleset rows
show `geosite · youtube +2`, freshness groups per name (oldest wins,
Update now refreshes every category's tag)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The native <datalist> popup is an unstyleable browser widget that clashed
with the panel. CatSuggest is an instrument-styled readout docked flush
under the Category input: SAGERNET BASE · n shelf label, sunken dense mono
list, matched substring lit in the accent, LED bar on the active row,
green exact-match footer, amber not-in-base warning. Keyboard: arrows /
Enter / Esc; combobox ARIA; both themes via tokens; reduced-motion safe.
Fix along the way: the row is a flex container with a gap, so bare text
nodes around <mark> became separate flex items and the gap split the
category name itself — the name now renders inside one span.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
- GET /api/ruleset/categories?source=geosite|geoip — daemon lists the real
SagerNet rule-set branch via the GitHub git-trees API (UA set, 24h in-memory
cache, stale-on-error); panel drives a native <datalist> on the Category
inputs (Routing ruleset form + DNS geosite blocklist), lazy one fetch per
source per session
- Routing rules gained Edit: inline form (all three matchers shown at once —
domains/IPs/port — so nothing is silently dropped), preserves Order/Enabled/
Kill/Egress and off-form fields verbatim, reorder/toggle/delete frozen while
editing, "no matchers — matches everything" hint for catch-alls
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
- generate.GeoRuleSetURL(source, category) — single source of truth for the
SagerNet .srs URL (routing rulesets, DNS filter, and the checker)
- POST /api/ruleset/check: daemon-side HEAD (GET+Range fallback) existence
probe of the exact URL the engine would fetch; {ok} / {not_found} /
{network} — plain client, router-own output is never tproxy-diverted
- Panel: geosite/geoip Save now checks first (Checking…); not_found blocks
with a form error; network failure offers explicit "Save anyway" so an
offline router can still be configured; url/inline/file flows untouched
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Three features from the second feedback pass:
- Node.Egress: a node can dial its OWN upstream through a named egress
(DialerOptions.Detour on the node outbound/WG endpoint; inherited by
groups/chains/rules; fail-open on unknown egress). Panel: per-row "via"
expander + "via <name>" chip on Nodes.
- Chains: egress:<name> allowed as the ENTRY hop only (hop 0) — lifted into
the first hop's detour; mid/last egress hops warn+drop. Panel: entry-hop
optgroup + "entry" badge in the chain editor (Targets).
- Test all nodes: engine.TestAllNodes force-probes every node outbound
(concurrency 16, 5s timeout) into the shared urltest history; failures
stored as ProbeFailDelay=0xFFFF sentinel (slowest, never poisons
least_test) and surfaced as DOWN, not untested. POST/GET /api/nodes/test;
"Test all" button with N/M progress on Nodes.
- geosite/geoip rule-sets are LIVE: source=geosite|geoip + category emit
official SagerNet remote .srs rule-sets (24h auto-update, direct fetch),
for routing rulesets AND DNS block/allowlists; freshness + Update now UI
applies to them; "inert" badge removed. New Category field (model+UCI).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
- Nodes/Overview: counters now tally live probe health (up/down/untested/off)
instead of counting Enabled as "up" (was: 329/329 up with 5 tested)
- Routing: Target select bucketed into optgroups (Groups/Chains/Interfaces-
egresses/Nodes-last) so egress:* is no longer buried under 300+ nodes
- Insights/Overview logs: single fmtClock(unix) helper, browser-local time in
BOTH logs (DNS log was server-UTC next to local-time connections)
- Overview query-log badge no longer says "waiting for engine stats" while
persisted rows are on screen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The stats log rings move behind a logRing seam: memRing (extracted RAM ring,
byte-identical to before) + sqliteRing (new, modernc.org/sqlite v1.38.2 pure-Go,
CGO-free musl-static). backend=sqlite persists the query/conn LOG rows to
/etc/shater/stats.db (WAL, tmpfs fallback /tmp/shater-stats.db, own lock) via an
async batched writer off the DNS/conn hot path; seq = the PK (monotonic, resumes
from the persisted max after restart). Cursor reads = WHERE seq</> ? ORDER BY
seq DESC LIMIT. Retention: keep <= StatsRingSize rows/table + a StatsDiskLimitMB
disk cap (0=unlimited) with prune + wal_checkpoint/VACUUM. Aggregates
(top-domains/timeline/hosts/devices/node-health) stay in RAM (bounded, rebuild
fast) — only the unbounded LOGS persist. Open failure → warn + memRing fallback.
Globals.StatsDiskLimitMB (0=unlimited, intOptAlways round-trip); Settings shows
it when backend=sqlite. Size: +1.2MB UPX (10.5->11.7MB), static/musl OK.
Verified: build (router tags)/vet 0, go test + -race ok (sqliteRing cursor
parity, retention, disk-cap, PERSIST-across-reopen, async no-loss, memory
regression); panel tsc/build clean. VM: backend=sqlite → /etc/shater/stats.db
created, 75 conns logged, **survive daemon restart** (75 rows intact, seq
resumes 76->79), disk cap set 16MB. box.New applies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Log rows gain a monotonic Seq (uint64, per-log counters on the Aggregator,
stamped under mu on append; survives box swaps + ring wrap). StatsStore read
API becomes cursor-based: Queries(LogQuery{Limit,Before,After})/Conns(...) —
neither cursor = newest Limit; Before=<seq> = next older page (seq<before);
After=<seq> = new rows (seq>after); always newest-first. Endpoints
/api/stats/{log,conns} accept limit/before/after (legacy n = limit, so Overview
is unchanged); bare array, rows carry seq (client derives newest/oldest).
Panel: Insights logs (Connections + DNS) now accumulate a seq-desc deduped
list — Load more APPENDS the next older page (not refetch-all, scroll
preserved, hides when exhausted), a ~1.5s after=<newest> poll PREPENDS new rows
(slide-in keyed by seq), a Pause/Live toggle buffers arrivals into an 'N new'
pill, ~3000-row DOM cap re-arms Load more, poll gated on backend!=off + tab
visible. Stable seq keys.
Verified: build (router tags)/vet 0, go test ok (seq monotonic bounded+
unlimited, before/after paging no overlap/gap, wrapped-ring, clamp), panel
tsc/build clean, ?mock drive (paginate+live+pause). VM live-verify pending
(ssh-manager MCP disconnected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
First phase of a pluggable stats-storage backend. New stats.StatsStore
interface (Start/Close/Resubscribe/Snapshot/RecentQueries/RecentConns); the
existing in-memory *Aggregator implements it unchanged, plus a noopStore for
OFF that never subscribes (so the HasSubscribers-gated DNS emit path skips all
per-query work). stats.NewStore(backend,...) selects off->noop, memory->agg,
sqlite->agg+warn (persistent backend lands in Phase 3). Snapshot gains a
'backend' field (off|memory|sqlite = effective). Globals.StatsBackend
(off|memory|sqlite, default memory) via the KillSwitch string-enum pattern
(model/uci/render + round-trip). Daemon + panel decouple from *Aggregator to
the interface. Settings gets a 3-way Logging-backend Select; Insights shows an
honest 'logging is off' state when backend=off.
Verified: build (router tags)/vet 0, go test ok (noopStore contract, NewStore
selection, StatsBackend round-trip), VM box.New PASS (stats verb reports
backend=memory); panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
maxStatsLogN capped /api/stats/log and /api/stats/conns responses at 200, so an
unlimited (StatsRingSize=0) or large ring still returned only 200 rows. Raised
the safety ceiling to 5000 (RecentQueries/RecentConns still return only what's
buffered) and lifted the Insights Load-more ceiling 500->5000 (step +200) to
match, so a big/unlimited log can actually be paged out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Retention size knobs (StatsRingSize / StatsTimelineMinutes / StatsMaxDomains)
now mean: 0 = UNLIMITED (no trim, grows with RAM), N = fixed limit. Query-log
AND connection-log rings gain a growable append-only mode when RingSize==0
(fixed-ring modulo path kept for N>0). Per-field 0 skips that aggregate's prune
(domains) / trim (timeline); RetentionDisabled stays the master switch.
Absent-vs-explicit-0 round-trip fixed: DefaultGlobals seeds safe bounded
defaults (200/60/5000) so an unset UCI option is never accidentally unlimited;
render intOptAlways writes these three fields even at 0 so an explicit 0
survives WriteUCI->ReadUCI; daemon passes no Config on a read error (→ bounded
defaults, not a zero-value=unlimited Config). Settings reframes the three
inputs as '0 = unlimited' with a per-field grows-with-memory warning.
Verified: build (router tags)/vet 0, go test ok (round-trip 0/200/5000;
RingSize=0 grows to 500 q+conn; bounded at 200; MaxDomains=0 keeps 6000;
Timeline=0 no trim), VM box.New PASS; panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The Connections/DNS LogShell only DISABLED 'Load more' when the page wasn't
full — so it stayed visible (and looked clickable) even with e.g. 9 rows. Now
the button is HIDDEN unless a full page came back (rows.length >= n && n < 500),
so it only appears when there may actually be more; disabled only while busy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The real 600-905px 'crossing' was NOT the SegMeter dots (fixed in e24a2c1c) but
the 3 QUERIES/BLOCKED/ALLOWED tiles forced 3-across in the ~227px first column
of the 3-col overview: each value's min-content (~86px) → 277px overflowed the
cell by ~50px, painting the 3rd tile's number ~17-30px into the sparkline.
(Round 1 missed it: Playwright's 15px scrollbar turned physical 901 into an
886 single-col layout, so the tight 3-col band was never measured.) Fix:
.ins-tiles flex-wrap + .ins-tile{flex:1 1 90px;min-width:0} → reflow 2+1 when
narrow, 3-across when wide, robust to any digit count, no viewport breakpoint.
Also: per-device header total wraps to its own line; Connections/DNS rows fit
their scroll box at <=560px. Scoped to Insights; verified 0 crossings 360-1440.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The prior min-width:0/max-width:100% capped the .segs BOX but the 28 flex
segments still painted outside it (their ~305px min-content spilled past the
right border at nearly every width). Now .ins-filter .segs uses overflow:hidden
(drops the min-content contribution + clips sub-pixel) + tighter gap, and the
Filtered meter passes segments={20} so dots fit their column without
compressing past the ~8px floor. Also fixed a secondary body h-scroll ≤404px:
.ins-overview single-col → minmax(0,1fr), .ins-tiles reflow to 2-col ≤400px,
.ins-grid minmax(min(100%,320px),1fr). Scoped to Insights — shared SegMeter
(Overview) untouched. Verified 0 overflow + no body h-scroll across 360–1440px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
LogEntry.Device was always '' — but the client address IS on the DNS
resolution context. dnstrack.QueryEvent gains Client netip.Addr, populated at
all three dns/client_log.go emit sites from adapter.ContextFrom(ctx).Source.Addr
(same context processInfoFromContext already reads). stats deviceLabel: LAN
source (a.lanNets.isLAN) -> DHCP hostname or IP; loopback/non-LAN/unknown ->
'router' (the appliance's own urltest/sub/DoH lookups). Insights DNS log now
shows device -> domain · resolver · action (mirrors the Connections log), with
a dimmed 'router' chip for router-originated lookups; falls back to '—' on
older data.
Verified: root build (dns tree + box, router tags)/vet 0, go test ok
(deviceLabel: LAN+lease/LAN+IP/loopback->router), panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
User frontend polish pass: (1) Connections/DNS action buttons were glued —
now a left-grouped .ins-log-btns (gap) + right-aligned count + separating
groove. (2) DNS log now shares a LogShell (scroll body + actions) with
Connections so they look 1:1 (differing only in columns: time·domain·resolver·
action); dropped the old <QueryLog> ticker here (still used on Overview).
(3) 'Blocked' toggle was clipped to 'Blocke' — .ins-toggle overflow:hidden
collapsed its flex min-width; added flex:none/nowrap + header flex-wrap.
(4) Filtered SegMeter's 28 segments overflowed the module's right edge —
scoped min-width:0/max-width:100% under .ins-filter (SegMeter elsewhere
untouched). (5) tabular-nums, consistent spacing, removed dead .ins-logwrap/
.qrows + unused imports. tsc/build clean; verified ?mock at 1280 & 380px, no
horizontal body scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The conn-log/top-hosts LAN filter required a non-empty Metadata.Inbound, but
tproxy connection events carry an empty Inbound on this engine — so it dropped
every client connection (live: 0 conns / empty top_hosts despite real traffic).
Now filters by source IP being inside a LAN subnet: devices.LANNets() (new
exported helper reusing the #10 /etc/config/network parser) + a 30s-cached
lanNetCache. Keeps LAN clients (192.168.1.77) and drops the router's own
WAN-side node dials (Source 10.0.2.x) — which are both RFC1918, so only the
iface config distinguishes them. Fallback (no config): private routable
sources. Loopback/unspecified/multicast always dropped.
Verified: build (router tags)/vet 0, go test ok (KEEP 192.168.1.77 / DROP
10.0.2.15 subnet test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
DNS events carry no client IP, so the query log couldn't show which device
went where. Now the stats aggregator folds connection events (trafficcontrol)
into: a connection ring (RecentConns → GET /api/stats/conns: src device ->
dest domain|IP + tcp/udp + sniffed proto + exit) and a top-hosts map keyed by
domain-else-IP (Snapshot.top_hosts) so raw-IP UDP/TCP destinations surface
(host==ip = the by-IP case). LAN-source filter (non-empty inbound + routable
src) keeps the router's own node/probe dials out. Insights gains a scrollable
Connections log (device->dest+proto, Refresh/Load more) + a Top-hosts section
(net/proto badge, IP tag for domain-less); the DNS log is relabelled
'DNS log · decisions'.
Verified: build (router tags)/vet 0, go test ok (LAN fold, IP-only host,
non-LAN skip, Closed adds bytes), panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The shared <QueryLog> is a fixed-height ticker (overflow:hidden) for Overview,
but on Insights it's a full paginated log — Load more fetched rows that were
clipped and invisible. Scoped override: .ins-logwrap .qrows now scrolls
(max-height min(60vh,540px), overflow-y:auto). Overview ticker unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
A 25s watchActiveProfile loop (mirrors watchNewDevices) reads the active
default-route dev (ip route show default, lowest metric), matches it against
enabled profiles' MatchIface (via netplane.IfaceDevice, so UCI-name OR device
lists work), and pins the highest-Priority match into Globals.ActiveProfile +
Reconcile — generate already applies an explicit ActiveProfile, so no generate
change. Anti-flap (write only on change), no-op when no MatchIface profiles
exist, releases a stale iface-pin but preserves a manual non-iface pin,
fail-safe on every error. Pure helpers pickIfaceProfile/desiredActiveProfile/
parseDefaultRouteDev unit-tested (failover-flip, tie-break, stale-release).
Schedule-window check deferred (unexported in generate). VM: build + matcher
tests PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Shared foundation: Engine.HTTPClient(via) dials through a running-box outbound
(OutboundManager.Outbound(tag).DialContext); via->tag map direct/group:/node:/
egress:/chain:. #1: Alert gains Via + Fallback — notifier sends through the
chosen detour via an injected client factory (daemon wires eng.HTTPClient);
on detour failure retries direct iff Fallback (else surfaces error). Direct
stays the default + the always-available safety path (killswitch/apply_fail
should keep Via empty or set Fallback). #8: Subscription gains FetchDetour;
new POST /api/subscription/update {name} makes the DAEMON fetch a sub through
the tunnel (FetchVia=proxy → Fetch(sub, HTTPClient(FetchDetour))) → update →
WriteUCI → reconcile; panel gets a per-sub Detour picker (under Proxy) + an
Update-now button. CLI 'sub update' stays direct.
Verified: root+shater build (router tags)/vet 0, go test ok (via->tag map,
alert Via+Fallback fallback-to-direct, detour-fail-no-fallback drops, model
round-trip w/ via/fallback/fetch_detour), VM box.New + engine tests PASS.
panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
DNS events carry no client IP, so per-device DOMAIN stats need connection
events. box.go now builds+registers the trafficcontrol.Manager + AppendTracker
UNCONDITIONALLY (moved out of the needObservable gate) — an in-process
connection observable with NO clash/api port opened (Emit is non-blocking, so
an unsubscribed tracker never stalls the hot path). Engine.ConnManager()
exposes it (box-owned; pointer changes each Apply swap). stats connLoop
subscribes (pointer-identity resubscribe like dnsLoop), folding
{Source.Addr, Domain||Destination.Fqdn} into deviceDomains (bounded 512
clients / 200 domains-each). Snapshot gains device_domains
[{ip,name,domains:[{domain,count}]}]; Insights shows a per-device domain view.
Configurable retention: Globals StatsRingSize/StatsTimelineMinutes/
StatsMaxDomains/StatsRetentionDisabled (0=built-in defaults 200/60/5000);
stats.New resolves them, RetentionDisabled skips all pruning (RAM-bounded);
Settings gains a Statistics-retention section with a disable-trim toggle.
Verified: root+shater build (router tags)/vet 0, go test ok (conn-event fold,
retention, TestEngineConnManagerWired drives a real proxied conn + asserts a
live ConnectionEventNew + pointer-change-on-swap), VM box.New still Applies
with the tracker wired. panel tsc/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Globals reads the canonical 'loglevel' key; a natural 'log_level' misspelling
was silently ignored, so 'log_level=debug' produced no debug output (found
while diagnosing node-health telemetry on the VM). applyGlobals now accepts
log_level as an alias, loglevel keeping priority. +TestLogLevelAlias.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
New Insights nav page surfacing the /api/stats Snapshot that was collected but
never shown: traffic overview (queries/blocked/allowed + timeline sparkline +
filtered%), top domains ranked with blocked portion (all/blocked toggle —
replaces the anemic 'top blocked = none'), per-rule traffic bars (bytes/packets
per routing rule), per-endpoint (outbounds + resolvers by count), per-device
bytes, and the query log (block/proxy/pass) via getStatsLog pagination. Answers
the user's ask: how often & how much traffic goes to which domain/IP, per rule
and per endpoint. Pure frontend — all data already in the aggregator. Polls
getStats every 3s; honest empty states; responsive (no horizontal body scroll).
Reuses SegMeter/QueryLog/Led/Button. Wired via router ROUTES + App Page switch
+ pages/index. Verified: tsc --noEmit clean, npm build ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
The Overview 'N/N up' was cosmetic (enabled-count/total). Now the engine
pre-registers a shared urltest.HistoryStorage in the box ctx (mirrors the
dnstrack.Manager pattern; box.go reuses a ctx-provided store), exposes it via
Engine.URLTestHistory(), and stats collectNodeHealth() reads per-node
Delay/alive into a new Snapshot.NodeHealth ([]{tag,delay_ms,alive,tested,
age_seconds}, tag==node name). Panel joins it by name: Overview shows an
honest alive/tested/total readout + status LED; Nodes rows get a latency chip
+ alive/down/untested LED (untested = node not in any probing group, shown
'—' not 'down'). Falls back to the old count when node_health is absent.
Only urltest/least_test groups populate history (selector/single/manual do
not); a failed probe deletes the entry, so tested=false conflates never-probed
and last-probe-failed — both reported untested, never a false 'down'.
Verified: go build (router tags)/vet 0, go test engine+stats ok (new
TestNodeHealthFromURLTestHistory + nil-safe test), panel tsc/build clean.
VM live-verify pending (ssh-manager MCP disconnected mid-session).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Post-test feedback batch, 4 parallel Opus agents:
#6 rollback-hide: apply.Status gains can_rollback (armed commit-confirm
snapshot OR engine.HasLastGood(), a new non-mutating engine probe). Apply/
Overview hide the Roll back button when nothing to revert; the 'Nothing to
roll back' dead-end is gone.
#10 devices: parseNeigh now captures the 'dev' token and drops non-LAN
rows (WAN device + a fail-open 10.0.2.0/24 slirp guard), so QEMU WAN IPs
10.0.2.2/.3 no longer masquerade as devices. Discovered gains network/iface
labels (IP matched against /etc/config/network subnets); UI shows 'LAN·br-lan'.
#5 egress iface picker: new GET /api/interfaces (netplane.Interfaces via
ubus network.interface dump); Targets EGRESS interface field is now a select
of real UCI interfaces (degrades to free-text when empty).
#11 WG/AWG import: parse.WGToURI serializes a *Proxy back to a canonical
wireguard:// URI (round-trips ParseWGConf, all AmneziaWG knobs); new
POST /api/import-wg converts a pasted .conf; Nodes add-node accepts a
multi-line [Interface] config and imports it as a node.
#7 nodes search/grouping: live search (name/proto/host/sub) + collapsible
per-subscription and Manual groups (large groups collapsed by default,
search auto-expands matches).
Verified: go build (router tags)/vet/test 0; panel tsc/build clean; new
tests TestCanRollback, TestEngineHasLastGood, TestDiscoverDropsWANNeigh,
TestWGToURIRoundTrip, import-wg + interfaces api tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Profiles/presets were modelled but ignored. Now buildRoute applies them to
an EFFECTIVE rule set (input model never mutated). Active profile: explicit
Globals.ActiveProfile (existing+enabled) always wins; else auto-select the
highest-Priority enabled profile whose schedule window holds (via b.now,
sharing scheduleWindowActive with rule schedules); iface/probe-conditioned
profiles are skipped by auto-select (warn, control-plane Phase-2b) but
honored when pinned. Overrides: EnableRules/DisableRules (disable wins),
DefaultTarget/DefaultEgress on Final (target wins = leak-safe). Preset packs
block-ads(15 domains->block)/ru-bypass(geoip:ru->direct, inert w/o geodata)/
private(RFC1918+ll+lo->direct) inject rules through the SAME rule loop,
Preset.Order/Target overridable. Fail-open throughout; profile/preset-free
models byte-identical (regression-guarded). Verified build/vet 0, host tests,
VM box.New (TestProfilePresetAppliesCleanly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Routing page now manages rule-sets (domain/ipcidr match sources): add/edit/
delete with source inline(entries)|url|file|geosite, and a dst_ruleset
checkbox picker in the rule form so a rule matches one or more rulesets
(matcher chip shows 'ruleset: ..'). Deleting a ruleset strips it from every
referencing rule. Reuses the existing save->apply machinery; URL tokens
masked; honest empty state. Backend materialisation landed in 57343693.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
resolveChainExit collapsed a chain to its exit hop, losing the multi-hop.
Now buildChain materialises per-chain hop-outbound copies Detour-linked
backward (h_n exits, h_n.Detour=h_{n-1}, .. h1.Detour=direct) so traffic
traverses L1..Ln and egresses at Ln; a rule/egress routing chain:<name>
targets the chain ENTRY tag. Per-chain copies keep base node/group
outbounds standalone and preserve the 0xff loop-guard mark. Group hops =
chain-local urltest over detoured member copies; WireGuard hops = detoured
endpoint copies. 1-hop == that hop; undefined/empty/unresolvable warns +
rule skipped (never aborts box.New); only referenced chains materialise.
Verified: build/vet 0, host tests + VM box.New (TestChainMultiHopApplies).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Move dnsBlockRuleForSrc to devices_test.go (imports option/C, untagged) and
drop the now-unused constant import from devices_linux_test.go, fixing the
cross-compile of the previous test fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
TestDeviceRulesValidate used dnsRuleForSrc (first source match) which
returned the device's ALLOW rule (route action, emitted first) while
asserting Predefined — a false failure. The generation was correct
(Phase-6 verified block works E2E). Now asserts the predefined-NXDOMAIN
block rule for the src specifically via dnsBlockRuleForSrc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Reaches v0.1's config-surface parity for the backend-ready features. Nav
gains Targets, Profiles, Settings (Profiles is a placeholder until its
backend lands).
- Targets page (groups/chains/egresses — was UCI-only): GROUPS editor
(source subscription|manual, subscription/member-node pickers, strategy,
include/exclude/proto/country filters + dedup, probe url/interval); CHAINS
editor (ordered hop list from group:/node:, signal-path viz); EGRESSES
editor (type interface|proxy|direct|block|byedpi, interface/target/port +
native DPI preset off|fragment|record|spoof). All pickers derive from live
config.
- Settings page (globals — was UCI-only): enabled, log level, kill-switch,
DNS mode, IPv6, confirm timeout, panel port, health probe url/interval,
fwmark/table base (hex, advanced), read-only schema/active-profile.
- Nodes page: per-subscription options expander — update interval, fetch-via,
format, UA, HWID + device fields, extra headers, include/exclude/proto/
country filters, dedup, expire-alert days (secrets masked, reuses save
machinery).
- api.ts: full types (Subscription/Group filters, Chain, Ruleset, Preset,
Profile, Inbound, Globals.PanelPort) + Model slices; router nav.
All save→apply like the other pages. tsc clean; build ok (82 kB gzip).
Remaining for full parity (next waves): generate for rulesets/chains(real
multi-hop)/profiles/presets, and the Profiles + Rulesets panel pages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Lets the operator choose which proxy path DNS goes through — a group
(balancer/urltest), a chain, an interface/egress, a specific node, or
direct. The backend already resolved resolver.Detour via resolveTarget
(group:/chain:/egress:/node:/direct); this exposes it in the panel (the
RESOLVERS section was read-only).
- Per-resolver detour <select> built live from the Model: Direct + a
Groups optgroup (balancer) + Chains + Interfaces/egresses (with type) +
a Nodes optgroup. Current path rendered as 'via group/chain/node/
interface <name>' or 'direct'.
- Add resolver (name + type doh/dot/plain/tcp/local/fakeip + conditional
address + fakeip pool + detour), edit, delete (repoints/clears default+
fallback), and editable default/fallback role selects (were read-only).
- Stale/missing detour target stays selectable + flagged '(missing)';
legacy bare-name detours normalized against the catalog. Secrets masked.
save->apply banner like the other sections.
Verified: tsc --noEmit clean; npm run build ok (71 kB gzip JS); Playwright
(mock) — the detour select shows Direct/Group auto (balancer)/egresses/
Nodes optgroup, changing it + add/delete + default/fallback all work with
the save->apply banner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
A group with source=subscription produced NO members (groupMembers only
read the explicit g.Nodes list, empty for sub-backed groups), so the group
was skipped and any rule targeting it never routed — the whole proxy path
was dead on a subscription setup.
Fix: for source=subscription, gather members from all nodes where
FromSub==g.Subscription (enabled + emitted), in config order, deduped; apply
Include/Exclude name regexes (case-insensitive, bad pattern warns+ignored)
and FilterProto/FilterCountry/Dedup via parse.ParseShareLink +
FilterSpecFromGroup + ApplyFilters. Manual/single/'' sources unchanged.
Verified: generate tests (sub group gathers exactly its FromSub nodes not
others; include/exclude; bad-regex-ignored; proto filter; manual unchanged).
Found live on the VM: 376-node subscription group 'auto' was 'no usable
members, skipped' before this fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Makes shaterd sub update real (was a Phase-2b stub): fetch a subscription
URL, parse+filter into nodes, persist them, reconcile.
- shater/subscribe: Fetch(sub, client) — HTTP GET with UA (sub.UA or
Shater/0.2 default), HAPP-style x-hwid/x-device-* headers, raw Headers
override, 20s timeout, 8MiB cap, non-2xx/empty = error. UpdateSubscription
(pure): parse.ParseSubURIs re-serializes ALL formats (clash/xray/sing-box/
links) to canonical share-links, pairs each with its *Proxy, ApplyFilters
(Include/Exclude/proto/country/dedup), builds []Node FromSub=<name> (name
from #fragment, collision-suffixed), REPLACES only that sub's cache. Zero
usable nodes -> error + leave the cache intact (a provider hiccup never
empties the config).
- cmd/shaterd: 'sub update [<name>]' — fetch each enabled sub (or one),
fold in, WriteUCI, best-effort SIGHUP reconcile; works with/without the
daemon; fetch_via=proxy warns + falls back to direct (MVP).
- model: sub-cache nodes now persist in UCI (config node + from_sub/
fingerprint/stale) so the fetched set survives restarts and the panel sees
them; ReadUCI reads them back; manual nodes unaffected. (v0.1 used a
separate JSON cache; UCI persistence matches v0.2's model<->UCI design.)
Verified: subscribe+model unit tests (FromSub tagging, filters, zero-node
safety, cache replace-keep-others, name collisions, UA/header/non-2xx); VM
E2E with the real feed https://pro.qomar.pw/sub/... -> 'sub update' fetched
376 nodes (261 vless/71 ss/29 vmess/15 trojan), all from_sub='default',
persisted to UCI, and box.New/Apply accepted all 376 (status running/active).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
Ports the v0.1 Gitea release flow to the v0.2 single-binary + 4-package
layout, so a tag publishes a signed opkg feed the routers install from.
- .gitea/workflows/release.yml: on tag v* (+ dispatch), matrix over
{x86_64, aarch64_cortex-a53}. Per arch: setup Go 1.24/Node 20/UPX ->
scripts/build-shaterd.sh (SPA-embedded shaterd, stages the .upx) ->
ci/build-feed.sh (OpenWrt SDK container builds all 4 packages ->
usign-signed Packages index). A release job merges both arches into one
signed feed + publishes the rolling 'latest'/tag release via the Gitea API.
- ci/sdk-build.sh: in-SDK build — add openwrt/ as the 'shater' feed, feeds
update/install, make package/{shaterd,shater-core,byedpi,luci-app-shater}/
compile (shaterd validates+installs the staged prebuilt; byedpi cross-
compiles from source). ci/make-index.sh: opkg Packages(.gz) + usign sign
with KEY_BUILD (keyfile umask 077, no secret hardcoded), verifiable by
dist/shater-feed.pub. ci/install-usign.sh + ci/gitea-release.sh ported.
- INSTALL.md: add the signed feed src/gz line + import dist/shater-feed.pub
to /etc/opkg/keys; apk (25.12) path noted.
Key kept: usign feed key 5ac4b177689cb8e0 (public dist/shater-feed.pub,
secret Gitea repo secret KEY_BUILD). Decision: opkg (24.10 uses opkg; apk
is 25.12) — matches the existing usign trust anchor.
Verified structurally (no live runner here): release.yml is valid YAML, all
ci/*.sh are bash -n clean, no hardcoded secrets, and every package name/
path/arch/artifact/secret reference cross-checks against openwrt/, scripts/
build-shaterd.sh, and dist/shater-feed.pub. Live-runner unknowns (full SDK
compile of the 4 packages, router-side signature verify) flagged in-agent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE