docs: stop the docs promising a safety net that ships disarmed

Every install recipe walked the reader through `shaterd apply` + `shaterd
confirm` as if commit-confirm were armed. It is not: DefaultGlobals() never
seeds ConfirmTimeout, the shipped config carries confirm_timeout '0', and
ArmRollback returns at once on a non-positive timeout. A reader following the
README believed an apply that cut their SSH would undo itself. It would not.
README/README.en/INSTALL now arm it in the recipe and say what 0 means; the
apply-flow diagram gained the edge it always took on a stock box.

The boot armor was documented nowhere at all (`grep -rli armor --include=*.md`
returned zero) while shipping enabled and blocking LAN->WAN on every boot.
INSTALL 4 now says what it is, why SSH/LuCI stay up on purpose, every condition
under which it refuses to arm, and how to switch it off.

Also removed or corrected, each checked against the code, not inherited:

* MASQUE/CONNECT-IP is advertised in both READMEs and absent from parse,
  generate and model -- registry names it among the types deliberately left
  unregistered. Dropped, with the fork-vs-product distinction spelled out.
  The inverse too: Hysteria2/TUIC/XHTTP were tagged [T1] while shipped under
  with_quic/with_xhttp; ShadowTLS is generate+registry only, no parser.
* `direct (flow-offload on)` -- no offload/flowtable/flow_offloading anywhere
  in openwrt/, shater/ or panel/src. The product does not do this.
* shater-core deps were two releases stale in two places, one of which vouched
  for a config.buildinfo check that never covered kmod-tun. Ruling narrowed to
  what was actually checked.
* PORTING's "Full schema" -- the shipped config points at it -- was missing
  l3_tunnel and untunnelable_egress (UCI is their only path; the panel does not
  show them) and the blocklist/allowlist/device/alert sections, while listing a
  `config preset` that ReadUCI has no branch for.
* ARCHITECTURE had no L3 ingress and no kernel egress at all, though both are
  [MVP] and one creates an fw4 zone in the user's firewall config. New 3a.
* nftset-for-routing in the DNS diagram: that is the v0.1 mechanism, gone in v0.2.
* CONTEXT described a pre-Phase-1 repo and a 24.10.3 testbed. The testbed is
  ImmortalWrt 25.12.1 r37978-cd0a06bfd3fd (read off the box), which is not a
  detail: .apk does not install on 24.10 at all.
* The gate existed and no .md mentioned it. README/README.en/CONTEXT now do.
* release.yml's header still described publishing as either/or after the rolling
  pointer became unconditional. Comment only.
* Shipped /etc/config/shater: schema_version '1' against CurrentSchemaVersion=2;
  a pointer to a dns_filter line that was not in the globals block (added, '0');
  and `option sniff '1'` on the inbound -- an option the model deliberately does
  not have, which the first panel save would have silently washed out.
* lx-changelog pointed at a D25 heading that does not exist.
* ROADMAP 2b and 5 were done and unmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
This commit is contained in:
2026-07-26 23:35:06 +03:00
co-authored by Claude Opus 5
parent 9e6dda22b3
commit 32aac89139
11 changed files with 331 additions and 66 deletions
+9 -5
View File
@@ -35,11 +35,15 @@
# invalidates every deployed router's trust.
#
# AUTO-RELEASE
# push a tag `vX.Y.Z` -> versioned per-arch releases `apk-vX.Y.Z-<arch>`.
# workflow_dispatch -> rolling per-arch `apk-latest-<arch>` (always-fresh
# feed). Publish uses the Gitea API via curl (ci/gitea-release.sh) — no
# external action needed. NOTE: the apk release tags deliberately do NOT start
# with `v` so publishing them cannot re-trigger this workflow's `v*` filter.
# The rolling per-arch `apk-latest-<arch>` is published on EVERY run — tag runs
# included — and then read back over the API to assert it really serves the
# version just built. A tag push `vX.Y.Z` publishes the pinnable per-arch
# `apk-vX.Y.Z-<arch>` IN ADDITION. It is not an either/or: it used to be, and
# the rolling pointer then froze at 0.2.0 while v0.2.9/v0.2.10 shipped (see the
# long comment above the `release-apk` job). Publish uses the Gitea API via curl
# (ci/gitea-release.sh) — no external action needed. NOTE: the apk release tags
# deliberately do NOT start with `v` so publishing them cannot re-trigger this
# workflow's `v*` filter.
#
# PACKAGE VERSIONING (bug B4)
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
+30 -5
View File
@@ -24,7 +24,8 @@ The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — exactly what
`shater/parse` can read and `shater/registry` registers in the engine.
A thin **LuCI launcher** (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
@@ -39,8 +40,8 @@ single-use token into the standalone SPA the daemon serves on its own port
selector / chain / direct / block; node groups with balancer/observatory;
multi-hop chains; per-rule egress.
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
`inet shater` nft table; atomic apply with `nft -c` validation and commit-confirm
auto-rollback.
`inet shater` nft table; atomic apply with `nft -c` validation. Commit-confirm
auto-rollback exists but **ships OFF** (`confirm_timeout=0`) — arm it yourself.
- **DNS filtering & blocklists** with flexible sources (inline / file / url /
geosite), compiled `.srs` matcher; Block-DoH/DoT to stop filter bypass.
- Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
@@ -68,8 +69,23 @@ keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
shater ships **inert** (globals off) so install never breaks connectivity. After
configuring nodes/rules: `uci set shater.globals.enabled=1 && uci commit shater`,
then `shaterd apply` and `shaterd confirm`.
configuring nodes/rules:
```sh
uci set shater.globals.enabled=1
uci set shater.globals.confirm_timeout=120 # commit-confirm ships OFF — arm it
uci commit shater
shaterd apply && shaterd confirm
```
Without that middle line `shaterd apply` arms no auto-rollback (and says so), so an
apply that costs you SSH/LuCI access has to be undone by hand.
Once an enabled, fail-closed config has been applied, `/etc/init.d/shater-armor`
loads a saved fail-closed plane at **boot**, before the daemon exists: LAN→WAN
forwarding is blocked until `shaterd` applies, while SSH/LuCI/the panel stay
reachable on purpose (the chain hooks `forward` only). What arms it, what refuses
to arm, and how to switch it off — `INSTALL.md` §4.
## Build from source
@@ -78,6 +94,15 @@ then `shaterd apply` and `shaterd confirm`.
into `openwrt/shaterd/files/`. Details in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
`bash scripts/run-tests.sh` is the test gate: the whole suite under the **shipped**
build tags (`scripts/router-tags.sh`), on linux (it re-execs in Docker from a
non-linux host), with `-race`, plus three machine checks against a silent skip —
the tag set may only add test files, every package with tests must report `ok` by
name, and every `TestIntegration*` must produce a verdict by name.
`scripts/check-router-tags.sh` separately proves no feature declared in
`FEATURES.md` lost a build tag it needs. A green gate is necessary but not
sufficient: it does not see the kernel, procd or nftables seams.
## Repository layout
| Path | What |
+56 -8
View File
@@ -27,7 +27,8 @@ BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он проз
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — ровно то, что умеет разобрать
`shater/parse` и что регистрирует `shater/registry` в движке.
Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
@@ -50,8 +51,10 @@ Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром,
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
трогаем.
- Атомарный apply с валидацией движком и `nft -c`, **commit-confirm** с
авто-откатом к последней рабочей конфигурации.
- Атомарный apply с валидацией движком и `nft -c`. **Commit-confirm** с
авто-откатом к последней рабочей конфигурации есть, но **на стоковой установке
выключен**: `confirm_timeout` поставляется нулём, и apply не вооружает ничего,
пока вы не зададите окно (см. «Включение»).
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
(SSH/LuCI/LAN) всегда в обход.
@@ -121,8 +124,11 @@ flowchart TB
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
(проксировано) · direct (flow-offload) · block. Подробные диаграммы (auth-handoff,
data-plane, DNS-flow, apply-flow) — в [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
(проксировано) · direct (обычный маршрут, без туннеля) · block. TPROXY несёт
только TCP и UDP; ICMP и остальные протоколы — через отдельные опциональные
механизмы (`l3_tunnel`, `untunnelable_egress`, ARCHITECTURE §3a). Подробные
диаграммы (auth-handoff, data-plane, DNS-flow, apply-flow) — в
[`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
---
@@ -195,15 +201,32 @@ shater ставится **инертным** (globals выключены), чт
```sh
uci set shater.globals.enabled=1
# Предохранитель: commit-confirm поставляется ВЫКЛЮЧЕННЫМ (confirm_timeout=0),
# и без этой строки apply ничем не подстрахован. 120 с — окно на проверку связи.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply # apply + вооружить commit-confirm на живом демоне
shaterd confirm # подтвердить (отменяет авто-откат)
shaterd apply # применить и вооружить авто-откат на 120 с
shaterd confirm # подтвердить в пределах окна (отменяет авто-откат)
```
`shaterd apply` печатает, вооружил ли он что-нибудь, и почему нет: при
`confirm_timeout=0` он прямо говорит, что автоматического отката НЕТ. Оставить
ноль — сознательный выбор: тогда apply, отрезавший вам SSH/LuCI, придётся
откатывать руками.
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
панель (`:8088` по умолчанию).
После первого же применённого включённого fail-closed конфига появляется
**загрузочная защита**: `/etc/init.d/shater-armor` (START=21) грузит сохранённый
fail-closed план ещё до старта демона, закрывая те секунды между поднятием LAN и
первым apply, когда роутер форвардил трафик в WAN открытым. Форвардинг LAN→WAN
заблокирован, пока `shaterd` не применит конфиг; SSH, LuCI и панель при этом
доступны **намеренно** — цепочка вешается только на `forward`. Чем защита
вооружается, когда отказывается вооружаться и как её снять —
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §4.
---
## Сборка из исходников
@@ -226,6 +249,28 @@ arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), s
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Проверка
```sh
bash scripts/run-tests.sh # полный гейт
bash scripts/run-tests.sh --no-race # без -race, для локального цикла
```
Гейт гоняет весь набор **под теми же build-тегами, с которыми собирается
роутерный бинарь** (`scripts/router-tags.sh`), на Linux (с не-Linux хоста — сам
перезапускается в Docker), с `-race`, и содержит три машинные проверки против
молчаливого скипа: набор тегов может только ДОБАВЛЯТЬ тест-файлы; каждый пакет с
тестами обязан отчитаться `ok` поимённо; каждый `TestIntegration*` обязан выдать
вердикт по имени. Причина такая: до 2026-07 релизный тракт не гонял почти ничего
— 115 тест-файлов из 116 под `shater/**` в CI не исполнялись ни разу.
Отдельно `scripts/check-router-tags.sh` проверяет, что ни одна заявленная в
`FEATURES.md` фича не потеряла нужный ей build-тег.
Зелёный гейт — необходимое, но не достаточное условие: он не видит стыков с
ядром, procd и nftables. Это проверяется на стенде (см.
[`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md)).
---
## Структура репозитория
@@ -274,7 +319,10 @@ CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает вс
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Форк
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Это набор
самого форка, а не shater: MASQUE/CONNECT-IP мы намеренно **не регистрируем** —
`shater/generate` его не порождает, а отказ от него и остального незадействованного
зоопарка экономит ~6 МБ бинаря и столько же RAM на роутере (`shater/registry`). Форк
разрабатывается по Spec Kit; неизменяемые принципы — в
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md), справочник фич движка — в
[`docs-lx/lx-config.ru.md`](docs-lx/lx-config.ru.md).
+2 -1
View File
@@ -48,7 +48,8 @@ never left the router.
address that a WireGuard endpoint never has. Fixing it inside `JudgeFlow`
is NOT possible — both consumers call it with identical arguments and the
working path needs the concrete address. Full chain, the two viable fixes and
the trap are in `docs-shater/DECISIONS.md` D25, "KNOWN HOLE".
the trap are in `docs-shater/DECISIONS.md` D25, under "What is still NOT
covered, said plainly", item 2.
* **Rebase cost: two small marked blocks** (`lx:begin/end l3-honest-drop`, a
wrapper function in `route/route.go` and one branch body in
`adapter/router.go`) plus the two self-contained test files — carried across
+63 -8
View File
@@ -62,16 +62,61 @@ flowchart LR
C["LAN client"] -->|"nft tproxy, mark → tproxy port"| IN["sing-box tproxy inbound (sniff SNI/Host/QUIC)"]
IN --> R{"route: rule match — src / dst / list / geo / client"}
R -->|"proxied"| OUT["outbound / selector (balancer, chain)"]
R -->|"direct"| DIR["direct (flow-offload on)"]
R -->|"direct"| DIR["direct (out the normal route, untunnelled)"]
R -->|"blocked"| BLK["block"]
OUT --> NET["exit — VLESS/Reality/AmneziaWG2/Hysteria2/…"]
```
Reliability (ported from v0.1): own nft table `inet shater` + own marks/tables
(never touch fw4); atomic validate→stage→swap; commit-confirm rollback;
idempotent reconcile under flock; management-bypass always; fail-closed
(never touch fw4); atomic validate→stage→swap; commit-confirm rollback (opt-in —
see §5); idempotent reconcile under flock; management-bypass always; fail-closed
kill-switch (dead group → block, not a silent direct leak).
Only TCP and UDP reach that path — TPROXY carries nothing else. What happens to
the rest is §3a.
### 3a. L3 ingress and kernel egress — what TPROXY cannot carry
Two opt-in globals cover the protocols the tproxy plane leaves on the floor.
Both are off in a stock config, and both are configured through UCI only (the
panel does not expose them).
**`globals.l3_tunnel` — LAN ICMP through the tunnel.** The generator adds a
synthetic `tun` inbound tagged `l3-in` (gVisor stack, `auto_route` **off**, MTU
65535, `shater/generate/inbound.go`), so ICMP is routed by the engine's own rules
instead of being dropped or answered by a forged local reply. The device is not
one fixed name: the generator emits a stable placeholder (so a no-op reconcile
still hashes identical and does not rebuild the engine once a minute), and
`shater/engine/l3slot.go` substitutes one of the two slots `shater-l3a` /
`shater-l3b` (`netplane/l3.go`) just before `box.New` — a new generation must
never reopen the name the outgoing one still holds
(`TUNSETIFF: device or resource busy` took the whole LAN down once). The routing half is scoped and lives entirely outside
the main table: our nft prerouting chain stamps LAN `icmp`/`ipv6-icmp` with
`L3Mark` (`fwmark_base + 0x80`), and `netplane.addL3Routing` binds that mark to
`L3Table` (`table_base + 8`), whose only content is a default route out the live
slot. Because the daemon creates the device at runtime, netifd never learns about
it and fw4 would reject the forward on its own account — so `30_shater-core`
seeds a **`shater_l3` zone in the user's `/etc/config/firewall`**, matching
`list device 'shater-l3*'` (a string match that is valid before the TUN exists
and covers both slots). Ceiling: ICMP echo only, and only for L3-capable
egresses; see `DECISIONS.md` D25 for what is still not covered.
**`globals.untunnelable_egress` — everything else, carried by the kernel.** It
names an existing interface/tunnel egress. Whatever the L3 block above did not
claim — ESP/AH, GRE, IGMP, SCTP, and ICMP too when `l3_tunnel` is off — is
stamped in prerouting with **that egress's own mark** (`netplane/nft.go`,
`UntunnelableEgressBinding`) and accepted; the `fwmark → table` pair
`addEgressRouting` already installed for the egress then routes it out the
egress's device. No new mark, no new table, and the engine never sees a byte —
which is why any IP protocol works here while the L3 TUN is narrow. Order is
load-bearing: this sweep runs **after** the L3 marking (first match wins) and
**after** the local-plane accepts, so LAN-to-LAN, router-addressed traffic and
IPv6 neighbour discovery never leave through an uplink. With `ipv6=0` the mark
is scoped to `nfproto ipv4`, because `addEgressRouting` installs the `-6`
rule/table pair only when IPv6 is on and marked v6 without it would fall through
to the main table past the kill-switch. `globals.untunnelable` (block | icmp |
direct) stays in charge of whatever neither mechanism carries.
## 4. DNS + filtering + stats
```mermaid
@@ -79,7 +124,7 @@ flowchart LR
C["client :53"] -->|"hijack"| DNS["sing-box DNS (in-process)"]
DNS --> FILT{"shater filter: blocklists + allowlist + per-device policy"}
FILT -->|"blocked"| NX["NXDOMAIN / 0.0.0.0"]
FILT -->|"allowed"| RES["resolvers (DoH/DoT/plain/FakeIP) + nftset for routing"]
FILT -->|"allowed"| RES["resolvers (DoH/DoT/plain/local/FakeIP), per-rule detour"]
DNS -->|"query events (engine observability)"| AGG["shater stats aggregator"]
AGG --> PANEL["panel: top domains · per-device · allowed/blocked · timeline"]
```
@@ -87,9 +132,10 @@ flowchart LR
Because the engine's DNS runs **in our process**, every query (domain, client,
verdict, latency) is available to the stats aggregator without log-scraping —
this is the payoff of embedding. Blocklist matching uses an efficient compiled
matcher, not dnsmasq megalists (see `DECISIONS.md` D5). Per-device blocking =
engine route/DNS rule keyed by client, or nftset(device) × nftset(blocked-domain)
→ drop.
matcher, not dnsmasq megalists (see `DECISIONS.md` D5). Per-device blocking is an
engine route/DNS rule keyed by client. Routing decisions come from in-engine
rule-sets: the v0.1 mechanism where dnsmasq populated nft sets does not exist in
v0.2 (`generate/dns.go`).
## 5. Config & apply flow
@@ -100,11 +146,20 @@ stateDiagram-v2
Render --> Validate: engine config check + nft -c
Validate --> KeepOld: fail
Validate --> Apply: ok (atomic swap: engine reload + nft/route reconcile)
Apply --> ConfirmWindow
Apply --> Committed: confirm_timeout = 0 (SHIPPED DEFAULT — nothing armed)
Apply --> ConfirmWindow: confirm_timeout > 0
ConfirmWindow --> Committed: confirmed
ConfirmWindow --> Rollback: timeout
Rollback --> LastGood
```
**The confirm window is opt-in and ships closed.** `model.DefaultGlobals()` leaves
`ConfirmTimeout` at zero, the shipped `/etc/config/shater` says
`option confirm_timeout '0'`, and `apply.ArmRollback` returns immediately on a
non-positive timeout — so on a stock install every apply takes the left edge above
and there is no net under it. `shaterd apply` reports which edge it took
(`reason: commit-confirm-off` vs an armed window). Set
`globals.confirm_timeout` to arm it.
## 6. Roadmap tiers
See `ROADMAP.md` for the phased plan and `FEATURES.md` for the full feature list.
+29 -16
View File
@@ -51,6 +51,9 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
MASQUE/WARP, and gRPC observability (DNS queries / rules / outbounds). Upstream
sing-box brings VLESS/VMess/Trojan/Shadowsocks/WireGuard/Reality + Hysteria2/
TUIC. It is library-first (`libbox`) and **GPL-3.0** (compatible with us).
That list is what the FORK can build, not what shater ships: `shater/registry`
registers only what `shater/generate` can emit, and MASQUE is one of the types
deliberately left out (~6 MB of binary and resident RAM). See `FEATURES.md`.
- We **fork it** (not just depend on it) so we can embed literally everything —
control-plane, admin panel, DNS filter — and integrate tightly with the
engine internals (DNS, routing, stats). This is a deliberate, decided
@@ -84,13 +87,13 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
## Repository model
- **`shater` `main` = our fork of sing-box-lx.** After Phase 1 it contains the
full sing-box-lx tree PLUS our additive overlay (`shater/`, `panel/`,
`openwrt/`, `docs-shater/`). Upstream is tracked via a git remote and merged by tag.
- **`shater` `main` = our fork of sing-box-lx.** It contains the full sing-box-lx
tree PLUS our additive overlay (`shater/`, `panel/`, `openwrt/`, `docs-shater/`,
`scripts/`, `ci/`). Upstream is tracked via a git remote and merged by tag.
Phase 1 merged the engine in on 2026-07-14 (`v1.14.0-lx.3`); `main` has not been
a docs-only seed since.
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
from).
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
are reading now (LICENSE, README, `docs-shater/`, the feed signing key).
## What to port from v0.1 (don't rewrite these ideas)
@@ -119,20 +122,30 @@ filter/stats engine wired into sing-box's DNS.
v0.2 fork; branch `v0.1` = the working xray-based version.
- **Upstream to track:** `https://github.com/Leadaxe/sing-box-lx` (which tracks
`https://github.com/SagerNet/sing-box`).
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
`main`; new CI is added when the v0.2 build exists.
- **CI:** Gitea Actions (act_runner + Docker), `.gitea/workflows/release.yml` —
builds the four packages through the ImmortalWrt 25.12.1 SDK and publishes the
signed per-arch apk repo. The opkg/`.ipk` lane was deleted, not disabled (D22).
- **Test gate:** `bash scripts/run-tests.sh` — the whole suite under the SHIPPED
build tags, on linux (in Docker from a non-linux host), with `-race`, and with
three anti-silent-skip checks. Not optional reading before touching `shater/`.
- **Feed signing:** EC (prime256v1) key for the apk index; secret in the repo
secret `KEY_APK`; public key `dist/shater-apk.pem`, installed on routers as
`/etc/apk/keys/shater-apk.pem`. Never regenerate it (D22).
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt`
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
drivable with the Playwright MCP.
- **Test VM:** **ImmortalWrt 25.12.1** (`r37978-cd0a06bfd3fd`) x86_64 in Docker
(`docker ps --filter name=openwrt-vm`), apk-tools 3.0.5 — deliberately the same
revision as `mini_router`, and required: the only package format we publish is
`.apk`, which does not install on 24.10 at all. SSH via the ssh-manager MCP
server `local_openwrt` (localhost:2222, root/openwrt). LuCI at
`http://127.0.0.1:8080` (root/openwrt), drivable with the Playwright MCP.
- **Routers:** `mini_router` (BPi-R3 Mini, ImmortalWrt 25.12.1) carries the real
home traffic; `main_router` (BPi-R4, OpenWrt 25.12.0). Both `aarch64_cortex-a53`,
both apk-tools 3.0.5 — see the table in D22.
## Current status
Repo reset done: v0.1 preserved on its branch; `main` cleaned to this docs-first
scaffold. Next is Phase 1 in `ROADMAP.md` — fork sing-box-lx into `main`
(add upstream remote, merge a pinned tag), stand up the embedding prototype
(prove AmneziaWG 2.0, measure binary size with feature-trim + `-s -w` + UPX)
before building the control plane and panel.
**v0.2 is feature-complete and running on real hardware.** ROADMAP Phases 0–8 are
done and VM-verified; the product ships as a signed apk feed and is installed on
`mini_router`. Read `ROADMAP.md` for what each phase delivered, `FEATURES.md` for
the honest MVP/T1/T2 state of each feature (including what is declared but not
shipped), and `DECISIONS.md` for why. Work since Phase 8 has been correctness and
honesty passes rather than new phases.
+17 -3
View File
@@ -6,7 +6,17 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
## Proxy engine & protocols (from the sing-box fork)
- **[MVP]** VLESS, VMess, Trojan, Shadowsocks, WireGuard, Reality/XTLS.
- **[MVP]** **AmneziaWG 2.0** (I1–I5 CPS decoy packets) — a driving requirement.
- **[T1]** Hysteria2, TUIC, ShadowTLS, XHTTP, MASQUE/CONNECT-IP (Cloudflare WARP).
- **[MVP]** Hysteria2, TUIC (`hysteria2://`/`hy2://`/`tuic://`, `shater/parse`),
XHTTP transport — all shipped: the router tag set carries `with_quic` and
`with_xhttp` and `shater/registry` registers them (`scripts/router-tags.sh`,
`buildtags.Features`).
- **[T1]** ShadowTLS — half-built: `shater/generate` emits it and `shater/registry`
registers it, but no parser produces one (there is no `shadowtls://` share link
and no subscription path), so a config cannot reach it today.
- **NOT SHIPPED** MASQUE/CONNECT-IP (Cloudflare WARP). `masque` appears nowhere in
`shater/parse`, `shater/generate` or `shater/model`, and `shater/registry` names
it among the upstream types it deliberately does not register (~6 MB of binary
and resident RAM). The engine fork can build it; this product does not.
- **[MVP]** Transports: TCP/WS/gRPC/HTTPUpgrade/H2/QUIC as upstream provides.
## Transparent proxying & routing
@@ -109,8 +119,12 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
## Reliability ("железно")
- **[MVP]** Fail-closed kill-switch (dead group → block, never silent direct leak);
IPv6 dropped when disabled.
- **[MVP]** Atomic apply with engine + `nft -c` validation; commit-confirm
auto-rollback to last-good.
- **[MVP]** Atomic apply with engine + `nft -c` validation. Commit-confirm
auto-rollback to last-good is built and works, but it is **opt-in and ships
OFF**: `DefaultGlobals()` leaves `ConfirmTimeout` at 0, the shipped
`/etc/config/shater` says `confirm_timeout '0'`, and `apply.ArmRollback` returns
at once on a non-positive timeout. Until an operator sets a window, an apply on
a stock box has no net under it — and `shaterd apply` says so.
- **[MVP]** Idempotent reconcile from hotplug/boot under flock; restart engine only
on real config change; management-bypass (SSH/LuCI/LAN) always exempt.
- **[MVP]** Own nft table `inet shater` + own marks/tables; never touch fw4.
+79 -8
View File
@@ -88,7 +88,7 @@ Four OpenWrt packages live under `openwrt/`:
| Package | Arch | What it ships |
|--------------------|-----------|---------------|
| `shaterd` | per-arch | **Prebuilt** static `shaterd` binary → `/usr/bin/shaterd` (this is the ship artifact from step 1). |
| `shater-core` | all | procd init (supervises `shaterd run`), cron, hotplug, sysctl, inert default UCI. `DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full`. |
| `shater-core` | all | procd init (supervises `shaterd run`), the boot armor (§4), cron, hotplug, sysctl, inert default UCI. `DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle`. |
| `luci-app-shater` | all | Thin LuCI launcher: mini dashboard + token-handoff "Open panel" button. `DEPENDS:=+shater-core +rpcd`. |
| `byedpi` | per-arch | *Optional* ByeDPI (`ciadpi`) local desync SOCKS proxy for a `type='byedpi'` egress. |
@@ -176,11 +176,27 @@ install. Configure nodes/rules (via the LuCI panel or `uci`), then enable and ap
```sh
uci set shater.globals.enabled=1
# The safety net is NOT on by default — see below. 120 s is a window wide enough
# to re-open SSH/LuCI and decide whether the new config is any good.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply # apply + arm commit-confirm on the running daemon
shaterd confirm # confirm (cancels the auto-rollback)
shaterd apply # apply + arm the auto-rollback for 120 s
shaterd confirm # confirm inside that window (cancels the auto-rollback)
```
> **Commit-confirm ships OFF.** `model.DefaultGlobals()` does not seed
> `ConfirmTimeout`, the shipped `/etc/config/shater` carries
> `option confirm_timeout '0'`, and `apply.ArmRollback` returns immediately on a
> non-positive timeout — so on a stock box `shaterd apply` arms **nothing** and an
> apply that costs you SSH/LuCI access simply stays. The daemon says so rather
> than implying otherwise: the `commit-confirm-off` outcome of `shaterd apply`
> prints *"globals.confirm_timeout is 0, so commit-confirm is switched OFF: this
> apply armed NO automatic rollback"*, and the panel's Overview reads
> `confirm: no auto-rollback`. Set a window (UCI as above, or Settings in the
> panel) if you want the net. Non-obvious detail: the option is written back only
> when non-zero, so an explicit `0` disappears from `/etc/config/shater` on the
> first write — absent and `0` mean the same thing.
`/etc/init.d/shater enable && /etc/init.d/shater start` brings up the procd-supervised
daemon (`shaterd run`), which owns the engine, the `inet shater` data plane, policy
routing, in-process DNS, and the admin panel (default `:8088`). The LuCI app's
@@ -218,6 +234,55 @@ Your `0` is kept: `/etc/config/shater` is a conffile (upgrades never replace it)
the daemon always writes the option back explicitly, so it is never re-enabled by a
default.
### The boot-time fail-closed armor
`shater-core` installs a **third** init script, `/etc/init.d/shater-armor`, and
`30_shater-core` enables it at install time. It exists because `/etc/init.d/shater`
is `START=99`: by then fw4 (19) has loaded `lan -> wan ACCEPT` and netifd (20) has
brought the LAN bridge up, so between link-up and the daemon's first apply the
router forwards LAN traffic to the WAN in the clear — on router hardware with a
UPX-packed binary that is the seconds in which Wi-Fi associates and every client
reconnects. `kill_switch=closed` covered none of it, because the protection lived
inside a process that had not started.
**How it works.** On every apply the daemon persists a copy of its fail-closed
*holding plane* — the same ruleset it installs when the engine is down — to
`/etc/shater/boot.nft`. `shater-armor` runs at `START=21` (after fw4 and netifd),
validates that file with `nft -c` and loads it. When the daemon comes up it
replaces the table atomically, so there is never a moment with no table. Its
`stop()` is deliberately a no-op.
**LAN forwarding is blocked until the daemon applies — management access is not.**
The chain hooks `forward` only, so SSH, LuCI and the admin panel (all `input` hook,
to the router's own addresses) stay reachable **on purpose**: a kill switch you
cannot switch off is a brick. If you see the syslog line
```
fail-closed plane armed from /etc/shater/boot.nft: LAN->WAN forwarding is BLOCKED
until shaterd applies. SSH, LuCI and the admin panel stay reachable.
```
that is the mechanism working, not a fault.
**When it refuses to arm** — each is a state check made at boot, never a record of
something that happened on the way down:
| Condition | Behaviour |
|---|---|
| `/etc/shater/boot.nft` absent | Nothing to do, silent. The file exists only while the last applied config was **both** `enabled=1` **and** `kill_switch=closed`; either being off removes it at the next apply, and an operator-typed `/etc/init.d/shater stop` removes it there and then. Powering off does **not** — and neither does the `stop` a package upgrade issues while the service stays enabled, so being replaced cannot leave the next boot unprotected. |
| the file is empty, or fails `nft -c` | Refuses, logs an error — the LAN is unprotected until `shaterd` starts. |
| `/usr/bin/shaterd` missing, or no `S??shater` symlink in `/etc/rc.d` | Refuses: nothing would ever come along to replace the block with a working data plane. This is what makes an uninstalled or disabled product safe regardless of what the file says. |
| UCI is readable **and** says `globals.enabled` is not `1` | Removes `boot.nft` and does not arm. An **unreadable** UCI is not a refusal — that case is exactly why the armor is a file rather than a query. |
| `nft` not installed | Refuses, logs an error. |
**Turning it off.** The durable off-states are the two the script itself asks
about — `uci set shater.globals.enabled=0 && uci commit shater && shaterd apply`
(the next apply removes `boot.nft`), or `/etc/init.d/shater disable`. A bare
`/etc/init.d/shater stop` typed at the shell also removes the file, but it is not
durable: `S99shater` is still linked, so procd starts the daemon again on the next
boot. To remove just the armor and keep the stack: `/etc/init.d/shater-armor
disable`.
## 5. The signed apk repo (the normal install path)
OpenWrt/ImmortalWrt **25.12** packages with Alpine's **apk**: `.apk` files, a
@@ -321,8 +386,14 @@ The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
`downloads.immortalwrt.org/releases/25.12-SNAPSHOT` — so packages built with the
vanilla ImmortalWrt 25.12 filogic SDK install cleanly; no SuperKali-special SDK
is needed. We ship **no kmods** (shaterd is a static Go binary, byedpi plain C),
so the vendor 6.6 kernel is irrelevant to our packages; the kmod *dependencies*
of shater-core (`kmod-nft-tproxy`, `kmod-nft-socket`, plus `ip-full`) are
already **baked into the BananaWRT mtk-vendor image** (verified in its
`config.buildinfo`). On a self-built 25.12 image, make sure those kmods come
from the image's own kernel build.
so the vendor 6.6 kernel is irrelevant to our packages.
What was actually checked in the BananaWRT mtk-vendor `config.buildinfo` is
`kmod-nft-tproxy`, `kmod-nft-socket` and `ip-full` — those three are baked into
the image. `shater-core` also depends on `kmod-tun`, `nftables-json` and
`ca-bundle` (added later; see the annotated `DEPENDS` in
`openwrt/shater-core/Makefile`), and **those were not part of that check**. They
are ordinarily present on a stock image — apk will pull whatever is missing from
the distfeeds — but if you install offline or from a slimmed image, verify them
yourself. On a self-built 25.12 image, make sure the kmods come from the image's
own kernel build.
+24 -5
View File
@@ -273,10 +273,12 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
| `block_doh` | `0` | NXDOMAIN the known public DoH hostnames + the Firefox canary and reject `:443` to their IPs, so clients fall back to `:53` (which the engine catches) |
| `group_health` | `1` | OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group |
| `untunnelable` | `block` | policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): `block` \| `icmp` (echo out, rest dropped) \| `direct` (all out, bypassing the tunnel) |
| `l3_tunnel` | `0` | **opt-in**, UCI-only (the panel does not expose it). Opens the synthetic `l3-in` TUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LAN `icmp`/`ipv6-icmp` with `fwmark_base+0x80` and a scoped `ip rule` sends it to table `table_base+8`. Absent option ⇒ OFF; only an explicit `1` opens it. See D25 and `ARCHITECTURE.md` §3a |
| `untunnelable_egress` | unset | **opt-in**, UCI-only. Names a `config egress`; everything the L3 block did not claim (ESP/AH, GRE, IGMP, SCTP, and ICMP when `l3_tunnel=0`) is stamped with that egress's OWN mark and routed out its device by the kernel — no new mark, no new table, engine not in the path. Empty ⇒ `untunnelable` above stays in sole charge (D26) |
| `geo_provider` | unset = auto | `sagernet` \| `loyalsoldier` \| `metacubex` \| `custom`; auto = country codes from SagerNet, everything else from Loyalsoldier |
| `geosite_url` / `geoip_url` | unset | `{category}` templates, honoured only when `geo_provider=custom` |
| `geosite_index_url` / `geoip_index_url` | unset | git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions |
| `stats_backend` | `memory` | `off` (no aggregation at all) \| `memory` (RAM, lost on restart) \| `sqlite` (aggregates in RAM + query/connection log on disk) |
| `stats_backend` | `memory` | `off` (no aggregation at all) \| `memory` (RAM, lost on restart) \| `sqlite` (aggregates in RAM + query/connection log on disk). The value NAME is historical: the on-disk store is **bbolt**, not SQLite, since the migration — a leftover sqlite-era `stats.db` is detected by its file magic and replaced (`shater/stats/boltring.go`) |
| `stats_ring_size` / `stats_timeline_minutes` / `stats_max_domains` | `200` / `60` / `5000` | live-log length, sparkline minutes, domain-map cap. **`0` = UNLIMITED** (grows with traffic), which is why these three are always emitted |
| `stats_disk_limit_mb` | `64` | on-disk cap of `stats.db`; only meaningful for `stats_backend=sqlite`; `0` = unlimited |
| `stats_retention_disabled` | `0` | master switch that turns OFF all trimming/pruning — every aggregate then grows unbounded |
@@ -285,7 +287,12 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
Deleted options still parse (unknown keys are ignored) and drain out on the next
render: `dns_mode` (D17 — fake-IP is a resolver TYPE), `sweep_interval` (D19).
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff.
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp.
**No `sniff`.** Since sing-box 1.11 sniffing is a leading route ACTION rule with no
inbound matcher, so every inbound is sniffed always; the flag was read by nothing but
its own UCI round-trip. Re-adding it would be a regression, not a restored feature —
the hijack-dns rule matches the SNIFFED `dns` protocol, so a per-inbound toggle is a
DNS-leak switch wearing a performance label (`model.go`, `Inbound`).
- `config subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
@@ -296,8 +303,19 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
destination is a `config ruleset` and nothing else. `shaterd migrate` folds each legacy
list into a generated `rule-<name>` (and `rule-<name>-ip`) inline ruleset; see
`DECISIONS.md` D21 for the entry-by-entry conversion table.
- `config preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
- `config blocklist`: name, enabled, source(inline|file|url|geosite), url, path, list category, list entry, response(nxdomain), update_interval.
- `config allowlist`: the same minus `response` (an allowlist has no verdict to render); it overrides every blocklist.
- `config device`: name, mac, ip, enabled, list block, list allow.
- `config alert`: name, enabled, type(telegram), token, chat_id, url, list event, via, fallback.
- **`config preset` is NOT a section type.** `ReadUCI`'s type switch has no `preset`
branch, so such a section is parsed by nothing and reaches no part of the model.
It survives only because `30_shater-core` still seeds three of them
(`block_ads`, `ru_bypass`, `private`) with the comment "so the LuCI Rules page
renders their toggles" — and v0.2's LuCI app is a thin launcher with no Rules
page. Those `uci set` calls should be dropped from the uci-defaults script; until
they are, three inert sections appear in every fresh `/etc/config/shater`.
### Subscriptions & HAPP fetch
Schemes: `vless:// vmess:// trojan:// ss:// wireguard:// wg://`. Body formats (`DetectSubFormat`): clash-YAML, xray-JSON, singbox-JSON, base64/plain link list. All converge to URIs re-parsed by `ParseShareLink`. HAPP fetch: UA default `Happ/3.13.0`; headers `x-hwid` (auto UUIDv4/sub), `x-device-os`, `x-ver-os`, `x-device-model`, custom. `fetch_via=proxy` dials local socks. Quota/expiry from `Subscription-Userinfo` (`upload;download;total;expire`). Reconcile by `Fingerprint` (sha256 of proto|addr|port|id|net|sec|sni|path) → new/keep/stale (drop after 3 stale refreshes).
@@ -305,10 +323,11 @@ Schemes: `vless:// vmess:// trojan:// ss:// wireguard:// wg://`. Body formats (`
---
## PART B — v0.1 packaging (`shater-core/`, branch `v0.1`)
Pure scripts+config, `PKGARCH:=all`. v0.1 DEPENDS: `+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full`. → **v0.2 deps: `+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full`** (engine does DNS in-process, so dnsmasq-full may be droppable — confirm the :53 listener is our engine). `/etc/config/shater` is a conffile.
Pure scripts+config, `PKGARCH:=all`. v0.1 DEPENDS: `+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full`. → **v0.2 deps (authoritative: `openwrt/shater-core/Makefile`, which annotates each one): `+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle`** — `dnsmasq-full` is gone (the engine owns the `:53` hijack listener); `kmod-tun` is `/dev/net/tun` for the L3 ingress, `nftables-json` is the `nft -j` output `netplane/stats.go` parses, `ca-bundle` is the cert store a `CGO_ENABLED=0` binary has no host fallback for. `/etc/config/shater` is a conffile.
- **init.d/shater** (procd, START=99/STOP=10): v0.1 supervised `xray run -c /etc/xray/run.json`; → v0.2 supervises `shaterd`. `respawn 3600 5 0` (infinite). **No `procd_set_param file` watch** (would bounce tunnel on commit). Inert unless `globals.enabled=1`. `ACTIVE_FLAG=/var/run/shater.active` gates hotplug/cron. `stop` clears flag + tears down nft table + reserved routing tables. `reload_service`→start/stop. trigger `procd_add_reload_trigger "shater"`.
- **init.d/shater-cron** (START=96): supervised `loop`; per-item due-check, runs sub/ruleset update + reconcile + schedule due; watchdog: engine dead 5 ticks ⇒ kill_switch=open stops stack (fail-open), closed logs crit.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), enable both inits, seed preset packs (disabled), run migrate, apply sysctl.
- **init.d/shater-armor** (START=21/STOP=89, v0.2-only — no v0.1 counterpart): the fail-closed plane BEFORE the daemon exists. `/etc/init.d/shater` is START=99, so from netifd's `ifup` until the daemon's first apply the router forwarded LAN→WAN in the clear. The daemon persists its holding plane to `/etc/shater/boot.nft` on every apply; this loads it after fw4 (19) and netifd (20), `nft -c`-validated. Four state checks refuse to arm (no/empty/invalid file, missing `shaterd`, no `S??shater` rc-link, readable UCI saying `enabled≠1`) — asked ON THE WAY UP, deliberately not recorded on the way down. Hooks `forward` only, so SSH/LuCI/panel stay reachable. `stop()` is a NO-OP. Operator-facing writeup: `INSTALL.md` §4.
- **uci-defaults/30_shater-core**: seed `rt_tables` (8192 shater), `mkdir /etc/shater`, seed the `shater_l3` fw4 zone + `lan→shater_l3` forwarding (named sections, `list device 'shater-l3*'`) and migrate a legacy exact-name entry to the wildcard, run `shaterd migrate`, apply sysctl, then a DETACHED bring-up (enable+restart `shater`/`shater-cron`, enable `shater-armor`, conditional `firewall reload`) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock. Also still seeds three `config preset` sections, which nothing parses (see the schema note above); those calls should go.
- **hotplug.d/iface/99-shater**: ifup/ifdown → debounced (2s) `reconcile` (netifd wipes ip rules on reload). Guarded by enabled + ACTIVE_FLAG.
- **sysctl.d/99-shater.conf**: `ip_forward=1`, `rp_filter=0` (all+default), `lo.route_localnet=1`, `lo.accept_local=1`, `all.src_valid_mark=1`, `ipv6.all.forwarding=1`.
+2 -2
View File
@@ -49,7 +49,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
the gate: fail-closed forward drop (4f618140), engine apply-swap close-first
fallback (9b6b9406), DNS hijack-dns per D14 (86194ce6).
## Phase 2b — DPI-bypass egress = ByeDPI (D13)
## Phase 2b — DPI-bypass egress = ByeDPI (D13) ✅ DONE
- The one external desync tool is **ByeDPI (ciadpi)** — chosen over zapret because
it *is* a SOCKS egress (fits shater's "routing picks the egress" model with zero
packet-plane conflict); zapret is explicitly rejected (see D13).
@@ -86,7 +86,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
well-known lists (StevenBlack/OISD/AdGuard).
- **Gate:** ad/tracker domains blocked network-wide; big list loads fast; RAM sane.
## Phase 5 — Statistics (per-domain / client / device)
## Phase 5 — Statistics (per-domain / client / device) ✅ DONE
- Stats aggregator consuming the engine's DNS/routing/stats observability + nft
counters: top domains, allowed vs blocked, per-device breakdown, timelines,
per-node/per-rule traffic, live query log with one-click block.
+20 -5
View File
@@ -53,13 +53,29 @@ config globals 'globals'
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
option fwmark_base '0x2000'
option table_base '0x2000'
# Seconds to auto-rollback an unconfirmed apply (0 = commit-confirm off).
# Seconds to auto-rollback an unconfirmed apply. SHIPPED AS 0, i.e.
# commit-confirm is OFF: `shaterd apply` arms nothing, and an apply that costs
# you SSH/LuCI access stays until you undo it by hand. Set a window (e.g.
# '120') to arm it, and run `shaterd confirm` inside that window to keep the
# new config. Note the option is written back only when NON-zero, so an
# explicit '0' disappears from this file on the first write by the daemon or
# the panel — absent and 0 are the same thing.
option confirm_timeout '0'
option schema_version '1'
# Master enable of the DNS blocklist/allowlist filter (D15). OFF by default;
# it needs at least one `config resolver` to have a DNS plane to filter with.
# See the "DNS filter" section at the end of this file.
option dns_filter '0'
option schema_version '2'
# LAN interception inbound. `network` is a UCI interface name; shaterd resolves
# it to its device (e.g. 'lan' -> br-lan) for the nft TPROXY plane. Enable
# globals above and adjust `network` to the interface(s) you want proxied.
#
# There is no per-inbound `sniff` option: since sing-box 1.11 sniffing is a
# leading route ACTION rule with no inbound matcher, so EVERY inbound is sniffed,
# always. Do not add one back — the hijack-dns rule matches the SNIFFED `dns`
# protocol, so a per-inbound sniff toggle would be a DNS-leak switch (D14, and
# the long argument at shater/model/model.go Inbound).
config inbound
option name 'lan'
option enabled '1'
@@ -68,7 +84,6 @@ config inbound
option tproxy_port '12345'
option tcp '1'
option udp '1'
option sniff '1'
# --- Commented examples (copy, uncomment, adjust, then enable globals) -------
#
@@ -153,8 +168,8 @@ config inbound
#
# --- DNS filter (D15) -------------------------------------------------------
# Network-wide domain blocking, built on sing-box rule-sets + reject DNS rules.
# Turn it ON by setting `option dns_filter '1'` in `config globals` above (it is
# OFF by default). Filtering needs at least one `config resolver` (the in-engine
# Turn it ON by flipping `option dns_filter` to '1' in `config globals` above (it
# is shipped '0'). Filtering needs at least one `config resolver` (the in-engine
# DNS plane). A blocklist answers matched domains with NXDOMAIN; an allowlist
# always OVERRIDES the blocklists (allowlisted domains resolve normally).
#