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:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user