Commit Graph
3151 Commits
Author SHA1 Message Date
omarandClaude Opus 4.8 a1ac74f735 fix(apply): log config warnings only when the set changes
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
2026-07-20 17:09:51 +03:00
omarandClaude Opus 4.8 1b4ed3e3da docs(decisions): D17 — audit outcomes that constrain future work
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
2026-07-20 16:59:49 +03:00
omarandClaude Opus 4.8 0e899a5170 audit(v0.2): full-stack hardening pass — release blockers, silent failures, dead knobs
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
2026-07-20 16:43:56 +03:00
omarandClaude Fable 5 cd6721155a feat(daemon+panel): device policy simplification, inline rename, Block-DoH
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
2026-07-20 09:43:50 +03:00
omarandClaude Opus 4.8 e3aebcefec chore: gitignore the throwaway trafgen dir so it stops being committed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 21:30:20 +03:00
omarandClaude Opus 4.8 0062859478 feat(daemon+panel): "Intercept all DNS" — force every client through the engine
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
2026-07-19 21:28:55 +03:00
omarandClaude Opus 4.8 b1f432e307 fix(daemon+panel): Source picker lists LAN networks by firewall zone, not IP
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
2026-07-19 20:59:34 +03:00
omarandClaude Opus 4.8 31d77244f4 feat(panel): rule Source becomes a picker — LAN networks, devices, custom
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
2026-07-19 20:50:30 +03:00
omarandClaude Opus 4.8 2711225712 feat(panel): New-rule form drops the free-text Domain(s) matcher
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
2026-07-19 18:24:48 +03:00
omarandClaude Opus 4.8 0cb5f8643a chore: drop trafgen — a throwaway VM traffic generator, not part of the product
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 18:22:17 +03:00
omarandClaude Opus 4.8 e14dd96aaf feat(daemon+panel): multi-category geo rule-sets with chip input
- 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
2026-07-19 18:21:16 +03:00
omarandClaude Opus 4.8 24b3fafb89 feat(panel): Faceplate-native CatSuggest replaces the OS datalist
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
2026-07-19 18:04:10 +03:00
omarandClaude Opus 4.8 14bf7124c7 feat(daemon+panel): geo category auto-suggest + routing rule editing
- 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
2026-07-19 17:26:53 +03:00
omarandClaude Opus 4.8 490834f3a4 feat(daemon+panel): verify geosite/geoip categories before save
- 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
2026-07-19 17:07:35 +03:00
omarandClaude Opus 4.8 bffb62fc7c feat(daemon+panel): multi-WAN egress binding, test-all-nodes probe, live geosite/geoip
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
2026-07-18 21:28:11 +03:00
omarandClaude Opus 4.8 9dc9540296 fix(panel): honest node counters, egress-findable target picker, one log timezone
- 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
2026-07-18 20:56:46 +03:00
omarandClaude Opus 4.8 e84166ab8a feat(daemon+panel): SQLite persistent stats backend (Phase 3)
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
2026-07-18 19:47:38 +03:00
omarandClaude Opus 4.8 28cff360d5 feat(daemon+panel): seq cursor — true pagination + live logs (Phase 2)
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
2026-07-18 19:02:03 +03:00
omarandClaude Opus 4.8 594a602ffe feat(daemon+panel): StatsStore interface + OFF/MEMORY logging backend switch (Phase 1)
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
2026-07-18 18:38:07 +03:00
omarandClaude Opus 4.8 9c01ae18c3 fix(stats): raise log-page cap 200->5000 so unlimited/large rings are pageable
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
2026-07-18 17:58:49 +03:00
omarandClaude Opus 4.8 ee04900f2a feat(daemon+panel): stats log/aggregate sizes support 0 = unlimited
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
2026-07-18 17:51:52 +03:00
omarandClaude Opus 4.8 53db1c14ce fix(panel/insights): hide Load more when there's nothing more to load
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
2026-07-18 17:08:13 +03:00
omarandClaude Opus 4.8 2a656312d2 fix(panel/insights): stat tiles overflowing into the sparkline in the 3-col band
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
2026-07-18 16:11:49 +03:00
omarandClaude Opus 4.8 e24a2c1c77 fix(panel/insights): Filtered SegMeter dots no longer overflow the module border
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
2026-07-18 15:39:12 +03:00
omarandClaude Opus 4.8 28c85a497c feat(daemon+panel): DNS log shows the source device (LAN client or 'router')
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
2026-07-18 15:12:43 +03:00
omarandClaude Opus 4.8 3a08387ef0 polish(panel/insights): tidy log action bars, unify DNS log with Connections, fix toggle clip + Filtered meter overflow
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
2026-07-18 14:39:48 +03:00
omarandClaude Opus 4.8 ddb29c662d fix(stats): filter connections by LAN-subnet source, not Metadata.Inbound
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
2026-07-18 13:47:57 +03:00
omarandClaude Opus 4.8 cb2b66bdd0 feat(daemon+panel): Insights connections log + top hosts by IP (device->dest, UDP/TCP)
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
2026-07-18 13:38:55 +03:00
omarandClaude Opus 4.8 39738d6dd7 fix(panel/insights): make the query log scroll (was clipped at 210px)
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
2026-07-18 12:56:44 +03:00
omarandClaude Opus 4.8 233a9cd985 feat(shater/daemon): WAN-based profile auto-switch watcher (Wave D2, #4.1)
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
2026-07-18 03:41:48 +03:00
omarandClaude Opus 4.8 da8d2b638c feat(daemon+panel): detour-HTTP — alerts via proxy/egress + fetch-via-tunnel (Wave D1, #1+#8)
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
2026-07-18 03:29:54 +03:00
omarandClaude Opus 4.8 265fb5efaa feat(daemon+panel): rule-set management — status + manual update (Wave C, #9)
Remote .srs rule-sets already auto-update (24h, cache.db, ETag) but the panel
showed nothing. Now: RemoteRuleSet exports LastUpdated()/UpdateInterval()/
RuleCount()/Update(ctx) (an updateMu serialises manual refresh vs the 24h loop;
lastUpdated writes moved under access lock). route.Router.RuleSets() enumerates
live sets. Engine.RuleSetStatus()/UpdateRuleSet(tag) via Instance().Router();
Applier exposes both (engine stays private). New GET /api/ruleset/status
([{tag,name,kind,remote,last_updated,interval_seconds,rule_count}]) + POST
/api/ruleset/update {tag|name+kind}. Tag map: ruleset X<->rs-X, blocklist
Y<->bl-Y, allowlist Z<->al-Z. Routing rulesets rows show relative last-update +
'every 24h' + rule count + an Update-now button (in-flight state, toast); inline
sets show nothing extra; install/remove unchanged (config PUT).

Verified: root+route+shater build (router tags)/vet 0, go test ok (tag-map,
status projection, engine nil-safety + real box.New apply), panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 03:04:45 +03:00
omarandClaude Opus 4.8 a968795229 feat(daemon+panel): per-device domains from connection events + retention (Wave B2b, #2)
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
2026-07-18 02:44:58 +03:00
omarandClaude Opus 4.8 d0d224642c fix(model): accept log_level as alias for the canonical loglevel UCI key
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
2026-07-18 02:10:55 +03:00
omarandClaude Opus 4.8 7d22ac711f feat(panel): Insights page — traffic & DNS stats (Wave B2a, #2 + per-rule/endpoint)
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
2026-07-18 01:31:52 +03:00
omarandClaude Opus 4.8 37fa520c88 feat(panel+daemon): real per-node health (alive+latency) from urltest (Wave B1, #3)
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
2026-07-18 01:21:22 +03:00
omarandClaude Opus 4.8 a6148b82ac feat(panel+daemon): Wave A UX fixes (rollback-hide, devices, iface picker, WG import, nodes search)
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
2026-07-18 01:01:53 +03:00
omarandClaude Opus 4.8 b5049a82c6 feat(panel/profiles): WAN-mode/failover profiles + preset-pack toggles
Profiles page (was a Placeholder): active-profile selector (Globals.
ActiveProfile, manual pin vs auto-by-condition), profiles CRUD with
conditions (uplink iface / probe up|down / schedule) and overrides
(enable/disable rules matrix, default target + egress pickers), plus the
three built-in preset packs (block-ads/ru-bypass/private) as fixed toggle
rows with target overrides. Reuses the DNS/Settings save->apply banner,
toasts, target-picker and schedule idioms; masks probe-URL hosts. Wired in
App.tsx <Page> + pages/index.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 05:27:19 +03:00
omarandClaude Opus 4.8 ff3965a0c3 feat(shater/generate): evaluate profiles + preset packs at gen-time
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
2026-07-16 05:27:19 +03:00
omarandClaude Opus 4.8 0faac4c3d5 feat(panel/routing): rulesets management + dst_ruleset rule picker
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
2026-07-16 05:10:41 +03:00
omarandClaude Opus 4.8 6a63503d2b feat(shater/generate): real multi-hop chains (router->L1->..->Ln->exit)
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
2026-07-16 05:10:41 +03:00
omarandClaude Opus 4.8 5c1547d03a test(shater/generate): fix imports for the device-DNS block-rule helper
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
2026-07-16 04:53:37 +03:00
omarandClaude Opus 4.8 c383f20cf4 test(shater/generate): fix device-DNS test to assert the block rule, not the first
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
2026-07-16 04:52:30 +03:00
omarandClaude Opus 4.8 5734369356 feat(shater/generate): materialize routing rule-sets (dst_ruleset) — domain/geo split
A routing rule's dst_ruleset now matches (config ruleset was never
materialized — referencing one aborted box.New). Mirrors the DNS-filter
rule-set builder.

- ruleset.go: for each ruleset referenced by an enabled rule's DstRuleset,
  materialize an option.RuleSet tagged rs-<name> -> Route.RuleSet. inline
  (domain -> DomainSuffix/Domain/DomainKeyword classification; ipcidr ->
  IPCIDR), url -> remote (http_client detour=direct + UpdateInterval),
  file -> local, geosite/geoip -> inert+warn (no geodata). Unused rulesets
  not emitted.
- route.go ruleMatchers: rule.DstRuleset -> raw.RuleSet=[rs-<name>], counts
  as an engine matcher (a dst_ruleset-only rule is now emitted). Undefined
  ruleset -> warn + skip (never aborts box.New).
- rs-/bl-/al- prefixes keep routing + DNS-filter rule-sets collision-free
  (test asserts a blocklist 'ads' and a ruleset 'ads' coexist).

Verified: generate tests (inline domain+ipcidr, undefined skip, coexistence)
+ box.New validation on the OpenWrt VM. (Flagged separately: a pre-existing
Phase-6 device-DNS test failure, unrelated — investigating next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 04:50:54 +03:00
omarandClaude Opus 4.8 5f3b8ac0bf feat(panel): Targets + Settings pages + full subscription options (v0.1 parity, wave 1)
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
2026-07-16 04:39:45 +03:00
omarandClaude Opus 4.8 478b450b21 feat(panel): editable DNS resolvers + DNS-path (detour) picker
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
2026-07-16 03:24:25 +03:00
omarandClaude Opus 4.8 0263a4e507 fix(shater/generate): resolve source=subscription group members from FromSub
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
2026-07-16 01:41:25 +03:00
omarandClaude Opus 4.8 f489a9dc8f feat(shater): subscription fetch — real 'sub update' verb
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
2026-07-16 01:27:43 +03:00
omarandClaude Opus 4.8 7258922fa0 docs(roadmap): Phase 8 (ship) DONE — v0.2 feature-complete through the roadmap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:29:48 +03:00
omarandClaude Opus 4.8 be07e3ba57 ci(shater): Gitea feed build + usign-signed opkg release (Phase 8 ship — complete)
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
2026-07-16 00:29:35 +03:00