Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0ea55054 | ||
|
|
625942834b | ||
|
|
4bf4ad8aa2 | ||
|
|
be1cdbfc63 | ||
|
|
bbb493ea91 | ||
|
|
4869d62e02 | ||
|
|
efb2177f43 | ||
|
|
815011dfb0 | ||
|
|
0a34e64c2c | ||
|
|
1708159ecf | ||
|
|
9d7f0dc92f | ||
|
|
c407771cf2 | ||
|
|
26e1d38924 | ||
|
|
42d84ac74c | ||
|
|
a05ad21b39 | ||
|
|
43bb8913ea | ||
|
|
89571bcdb1 | ||
|
|
6ced96fafa | ||
|
|
42536675e2 | ||
|
|
a4ea5dba44 | ||
|
|
ea34e744bd | ||
|
|
e5dfd74b61 | ||
|
|
17843be5ad | ||
|
|
dac2f85c84 | ||
|
|
e065786a32 | ||
|
|
46a2d4aaad | ||
|
|
67290ec2b6 | ||
|
|
9339e8e70d | ||
|
|
a67f51c22c | ||
|
|
56c9ea56e6 | ||
|
|
cae5655dbe | ||
|
|
fbcf211d19 | ||
|
|
419eaf7bbe | ||
|
|
a794fbe374 | ||
|
|
735aa5428f | ||
|
|
d1f43dbbe6 | ||
|
|
67c4829f03 | ||
|
|
b43f673ad2 | ||
|
|
da7411e29a | ||
|
|
73bd02dd71 | ||
|
|
ffe4d8726f | ||
|
|
7f84ea9496 | ||
|
|
fd8b424d5a | ||
|
|
a9ec36e053 | ||
|
|
d40eeede0c | ||
|
|
7c93019e81 | ||
|
|
bc7ea359c8 | ||
|
|
447cd8cf7d | ||
|
|
65db309e3c | ||
|
|
d63f1d896d | ||
|
|
55dea4e729 | ||
|
|
2dfd7caf2b | ||
|
|
6f84d0ca7b | ||
|
|
e38108a7c4 | ||
|
|
06c04c157d | ||
|
|
3654acf7fb | ||
|
|
3a9b3f523d | ||
|
|
201aa7c168 | ||
|
|
78bb6a1be8 | ||
|
|
ea3a4c518e | ||
|
|
0f69880150 | ||
|
|
164b703a7d | ||
|
|
de6fa8ebf4 | ||
|
|
b91fba1295 | ||
|
|
df078c3205 | ||
|
|
d0471b2418 | ||
|
|
3314927bef | ||
|
|
6801146240 | ||
|
|
2f8c692c39 | ||
|
|
c788425cad | ||
|
|
0144282f5e | ||
|
|
b642e5d8fe | ||
|
|
35e4900769 | ||
|
|
d3e33294c1 | ||
|
|
32aac89139 | ||
|
|
9e6dda22b3 | ||
|
|
fde4bed571 | ||
|
|
cb26936ebf | ||
|
|
8db29b6267 | ||
|
|
564033cd10 | ||
|
|
add90b5b2f | ||
|
|
a571bd0e1a | ||
|
|
1267d20fb8 | ||
|
|
35f697ed08 | ||
|
|
d0fb6befb1 | ||
|
|
81c96019b5 | ||
|
|
baed8ff8f2 | ||
|
|
f80fb4dd1b | ||
|
|
b71b793681 | ||
|
|
61c87ad1d9 | ||
|
|
4dee508e12 | ||
|
|
76da5134ef | ||
|
|
4c630c9a13 | ||
|
|
d8dbefcd07 | ||
|
|
974208fc05 | ||
|
|
2eb71e8244 | ||
|
|
668cccbf24 | ||
|
|
4ea4585402 | ||
|
|
dc6d102473 | ||
|
|
683afc0a47 | ||
|
|
2c3e20512e | ||
|
|
51b2f04672 | ||
|
|
f190c8251e | ||
|
|
1945404eaa | ||
|
|
6476722372 | ||
|
|
4078334d85 | ||
|
|
0a6689b29e | ||
|
|
ef22167b1a | ||
|
|
cbda0fee0a | ||
|
|
7234817adb | ||
|
|
f1c36d6eea | ||
|
|
bb21ceb7f5 | ||
|
|
a8970b8ace | ||
|
|
4996bc0984 | ||
|
|
a0de597d69 | ||
|
|
544da29863 | ||
|
|
daaa0fda41 | ||
|
|
56a276bcc1 | ||
|
|
754bbcf1fa | ||
|
|
63e6b709f8 | ||
|
|
8612b0a9e9 | ||
|
|
96d9cfaa63 | ||
|
|
3c7536dba0 | ||
|
|
3c92e1cbfd | ||
|
|
bcc9df9282 | ||
|
|
ee3641fe45 | ||
|
|
439f62238f | ||
|
|
d971eb85ee | ||
|
|
a0f6083e28 | ||
|
|
77369aedfe | ||
|
|
515ae6d1b7 | ||
|
|
a2ffbb1292 | ||
|
|
1746d4d0ef | ||
|
|
f86501bf77 | ||
|
|
eccfc6136c | ||
|
|
244b7c4199 | ||
|
|
a8ef887c56 | ||
|
|
8fd5c52488 | ||
|
|
32e8f8ff0b | ||
|
|
c562579ef3 | ||
|
|
6f89acbae7 | ||
|
|
88a82c7297 | ||
|
|
a8f2b0f068 | ||
|
|
0b32a6d58b | ||
|
|
893fdc500c | ||
|
|
02c266188f | ||
|
|
024e9308c9 | ||
|
|
eab1db2c3f | ||
|
|
22d7161c08 | ||
|
|
24c5a1615d | ||
|
|
4492f0599c | ||
|
|
bc2b53069a | ||
|
|
c257d6c5cc | ||
|
|
225cce5397 | ||
|
|
84d2766592 |
+267
-275
@@ -1,51 +1,65 @@
|
||||
# Shater v0.2 — build the 4-package signed opkg feed and publish it as a rolling
|
||||
# Gitea release consumable as an `src/gz` feed.
|
||||
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
|
||||
# per-arch Gitea releases consumable as an apk repository.
|
||||
#
|
||||
# WHAT CHANGED FROM v0.1
|
||||
# v0.1 shipped 3 packages: xrayctl (SDK-compiled Go) + shater-core +
|
||||
# luci-app-shater (hand-packed data .ipk). v0.2 collapses the runtime into ONE
|
||||
# forked binary and ships 4 packages, all built the canonical SDK way:
|
||||
# WHAT WE SHIP
|
||||
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
|
||||
# SDK way:
|
||||
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
|
||||
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
|
||||
# and staged into openwrt/shaterd/files/ BEFORE the SDK
|
||||
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
|
||||
# the arch-matched artifact. (arch-specific .ipk)
|
||||
# the arch-matched artifact. (arch-specific .apk)
|
||||
# - shater-core data glue, PKGARCH=all
|
||||
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
|
||||
# - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
|
||||
#
|
||||
# TARGET HARDWARE / ARCH MATRIX
|
||||
# x86_64 -> the QEMU testbed VM (generic x86-64).
|
||||
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 + BPI-R4, mediatek/filogic).
|
||||
# Only shaterd + byedpi are arch-specific; shater-core + luci-app-shater are
|
||||
# PKGARCH=all, so one build of each covers every device. opkg filters by
|
||||
# Architecture at install time, so a single combined feed URL serves all.
|
||||
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 mini + BPI-R4,
|
||||
# mediatek/filogic), both on 25.12 with apk-tools 3.
|
||||
# Only shaterd is arch-specific; shater-core + luci-app-shater are
|
||||
# PKGARCH=all, so one build of each covers every device — but the RELEASES
|
||||
# are still per-arch (see the release-apk job for why).
|
||||
#
|
||||
# FEED SIGNING (opkg / usign — OpenWrt 24.10 is opkg, not apk; apk lands at 25.12)
|
||||
# The feed index (Packages) is usign-signed with the SECRET key in the Gitea
|
||||
# repo secret KEY_BUILD; routers verify it with the committed public key
|
||||
# dist/shater-feed.pub (fingerprint 5ac4b177689cb8e0). Do NOT regenerate the
|
||||
# key — that invalidates every deployed router's trust.
|
||||
# FORMAT: apk ONLY (25.12+)
|
||||
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
|
||||
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
|
||||
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
|
||||
# device we serve has an opkg binary at all, so building and signing a second
|
||||
# feed served nobody.
|
||||
#
|
||||
# FEED SIGNING (EC / apk)
|
||||
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
|
||||
# secret KEY_APK; routers verify it with the committed public key
|
||||
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
|
||||
# invalidates every deployed router's trust.
|
||||
#
|
||||
# AUTO-RELEASE
|
||||
# push a tag `vX.Y.Z` -> versioned release. workflow_dispatch / (optional) main
|
||||
# -> rolling `latest` pre-release (always-fresh feed). Publish uses the Gitea
|
||||
# API via curl (ci/gitea-release.sh) — no external action needed.
|
||||
# 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.
|
||||
#
|
||||
# APK LANE (25.12+, ADDITIVE — T2)
|
||||
# The fleet is migrating to BananaWRT 25.12-mtk-vendor (= ImmortalWrt 25.12
|
||||
# base), where opkg is replaced by Alpine apk (.apk, binary packages.adb
|
||||
# index, EC keys in /etc/apk/keys/). The `build-apk` + `release-apk` jobs
|
||||
# below build the SAME 4 packages through the ImmortalWrt 25.12 apk-SDK and
|
||||
# publish PER-ARCH apk repos as releases `apk-latest-<arch>` (rolling) /
|
||||
# `apk-<tag>-<arch>` (versioned). Per-arch because apk filenames carry no
|
||||
# architecture (shaterd-0.2.0-r1.apk would collide across arches in one flat
|
||||
# release) and apk fetches packages relative to the packages.adb URL.
|
||||
# Signed with the EC key in the Gitea secret KEY_APK; trust anchor
|
||||
# dist/shater-apk.pem (ci/gen-apk-key.sh). The usign/opkg lane above is
|
||||
# UNCHANGED and keeps serving the 24.10 fleet. NOTE: the apk release tags
|
||||
# deliberately do NOT start with `v` so publishing them cannot re-trigger
|
||||
# this workflow's `v*` tag filter.
|
||||
# PACKAGE VERSIONING (bug B4)
|
||||
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
|
||||
# used to be, and nobody bumped them: v0.2.2…v0.2.6 all shipped as
|
||||
# `shaterd 0.2.0-r3` with different binaries inside, so `apk update` never saw
|
||||
# a new version and routers could not be updated at all. Now `ci/version.sh`
|
||||
# derives them from the git tag ONCE per job (the "Compute version" step,
|
||||
# exported via $GITHUB_ENV):
|
||||
# tag `vX.Y.Z` -> X.Y.Z-r1
|
||||
# anything else -> <nearest tag>-r<commits since it + 1>
|
||||
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
|
||||
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
|
||||
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
|
||||
# built .apk really carry that version, so the failure can never be silent
|
||||
# again. This is also why the build job checks out with fetch-depth: 0
|
||||
# — `git describe` needs tags and ancestry. Every package this repo ships is
|
||||
# versioned from the tag; there is no longer an exception to remember.
|
||||
|
||||
# CACHING (T3 — fast CI)
|
||||
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
|
||||
@@ -63,34 +77,33 @@
|
||||
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
|
||||
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
|
||||
# prefix fallback is allowed.
|
||||
# - Go module + build cache — key = hash of go.sum; shared by all 4 build
|
||||
# - Go module + build cache — key = hash of go.sum; shared by both build
|
||||
# jobs (each builds both GOARCHes).
|
||||
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
|
||||
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
|
||||
# - apt .deb archives for the apk lane's debian:bookworm host-deps
|
||||
# (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list is in it).
|
||||
# - usign binary (.cache/tools) — static helper, fixed key.
|
||||
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
|
||||
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
|
||||
# is in it).
|
||||
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
|
||||
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
|
||||
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
|
||||
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
|
||||
# container from the workspace cache; `feeds update` on an existing clone
|
||||
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
|
||||
# always checks out feeds.conf's pins, and ci/sdk-build*.sh wipes the
|
||||
# always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
|
||||
# cache + re-clones fresh if update ever fails on a cached checkout.
|
||||
# Key = lane + SDK release (shared across the two arch jobs of a lane —
|
||||
# same release pins identical feed revs; the sequential runner means the
|
||||
# second arch restores what the first saved). restore-keys lets an SDK
|
||||
# version bump start from the old clones (git fetch delta, not re-clone).
|
||||
# Key = lane + SDK release (shared across the two arch jobs — the same
|
||||
# release pins identical feed revs; the sequential runner means the second
|
||||
# arch restores what the first saved). restore-keys lets an SDK version
|
||||
# bump start from the old clones (git fetch delta, not re-clone).
|
||||
# Act_runner facts this design leans on (verified in run 51 logs):
|
||||
# - the cache backend works: restores/saves confirmed, hashFiles() works;
|
||||
# - docker images (openwrt/sdk, debian:bookworm, runner-images) live on the
|
||||
# PERSISTENT host daemon — "Image is up to date" each run, no re-download;
|
||||
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
|
||||
# host daemon — "Image is up to date" each run, no re-download;
|
||||
# - each actions/cache SAVE is followed by an exact 3-minute act_runner
|
||||
# stall (node process lingers; hit→no-save→no stall). Steady state saves
|
||||
# nothing, so adding cache entries is fine, but keys that change every
|
||||
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
|
||||
|
||||
name: release
|
||||
|
||||
on:
|
||||
@@ -108,41 +121,49 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.arch }}
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
|
||||
# stops the release instead of shipping with it.
|
||||
#
|
||||
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
|
||||
# workflow cannot block another one — they run side by side and a red `test`
|
||||
# workflow would have published anyway. Only a `needs:` edge inside THIS
|
||||
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
|
||||
# call the same scripts/run-tests.sh so they cannot drift.
|
||||
#
|
||||
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
|
||||
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
|
||||
# three named tests. 115 of the 116 test files under shater/** had never run in
|
||||
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
|
||||
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
|
||||
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
|
||||
#
|
||||
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
|
||||
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
|
||||
# transport/wireguard compiles 1 test file without the tag set and 7 with it
|
||||
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
|
||||
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
|
||||
# the named, reasoned exclusions are in scripts/run-tests.sh.
|
||||
test:
|
||||
name: test gate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, sdk: x86_64-24.10.4 } # testbed VM (generic x86-64)
|
||||
- { arch: aarch64_cortex-a53, sdk: mediatek-filogic-24.10.4 } # BPI-R3 + BPI-R4 (mediatek/filogic)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# scripts/build-shaterd.sh builds the engine via a go.mod
|
||||
# `replace => ./submodules/wireguard-go` (AmneziaWG fork), so that submodule
|
||||
# must be present or `go build` dies with "no such file or directory".
|
||||
# actions/checkout does not fetch submodules by default; init ONLY this one
|
||||
# (clients/apple+android are large and unused here).
|
||||
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
|
||||
# this even `go list` fails. Same step/reason as in build-apk below.
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
# Toolchain for scripts/build-shaterd.sh: Go (daemon), Node (Vite SPA), UPX.
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod # pins Go 1.24.7 (go.mod `go` line)
|
||||
cache: false # explicit actions/cache@v3.3.2 below (setup-go's
|
||||
# built-in cache uses the new API act_runner lacks)
|
||||
go-version-file: go.mod
|
||||
cache: false # explicit actions/cache@v3.3.2 below
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # Vite 5 needs Node 18+; 20 LTS
|
||||
|
||||
# ---- caches (see the header comment for keys + version pin rationale) ----
|
||||
# Same cache key as build-apk: this job runs first, so it warms the module
|
||||
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
@@ -153,94 +174,36 @@ jobs:
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
|
||||
# TypeScript run directly by `node --test`, and type stripping only exists
|
||||
# from 22.6 — on node 20 `npm test` dies before running a single case.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache panel node_modules
|
||||
id: npm-cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: panel/node_modules
|
||||
key: npm-${{ hashFiles('panel/package-lock.json') }}
|
||||
# NO restore-keys: node_modules must exactly match the lockfile;
|
||||
# on any lockfile change this misses and `npm ci` runs fresh.
|
||||
|
||||
- name: Cache SDK dl/ (package sources)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/dl
|
||||
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
|
||||
restore-keys: |
|
||||
dl-
|
||||
- name: Panel tests
|
||||
run: bash scripts/run-panel-tests.sh
|
||||
|
||||
# feeds git checkouts (see header): both 24.10.4 arch jobs share one entry
|
||||
# (same release = same feeds.conf.default pins), so derive the release
|
||||
# from the matrix sdk tag (x86_64-24.10.4 -> 24.10.4).
|
||||
- name: Compute feeds cache key
|
||||
id: feedskey
|
||||
run: echo "ver=$(echo '${{ matrix.sdk }}' | sed 's/.*-//')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache SDK feeds checkouts
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/feeds
|
||||
key: feeds-opkg-${{ steps.feedskey.outputs.ver }}
|
||||
restore-keys: |
|
||||
feeds-opkg-
|
||||
|
||||
- name: Cache CI tools (usign)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/tools
|
||||
key: tools-usign-v1
|
||||
|
||||
- name: Install UPX
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
|
||||
|
||||
# Build the SPA-embedded, static-musl, UPX'd shaterd for BOTH arches and
|
||||
# stage dist/shaterd-<a>.upx into openwrt/shaterd/files/. MUST run before
|
||||
# the SDK package build (the openwrt/shaterd package installs the staged
|
||||
# artifact). VERSION is stamped into constant.Version. On an exact
|
||||
# node_modules cache hit, --fast skips the redundant `npm ci`.
|
||||
- name: Build & stage shaterd artifact
|
||||
env:
|
||||
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
||||
V="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
V="v0.2.0-dev"
|
||||
fi
|
||||
FAST=""
|
||||
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
|
||||
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
|
||||
bash scripts/build-shaterd.sh "$V" $FAST
|
||||
|
||||
# Compile the 4 packages through the arch-matched OpenWrt SDK and produce a
|
||||
# signed per-arch opkg feed (Packages + Packages.gz + Packages.sig + .ipk).
|
||||
- name: Build signed feed (SDK)
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/build-feed.sh "${{ matrix.arch }}" "${{ matrix.sdk }}" "out/${{ matrix.arch }}"
|
||||
|
||||
- name: Show feed
|
||||
run: ls -l "out/${{ matrix.arch }}" && cat "out/${{ matrix.arch }}/Packages"
|
||||
|
||||
- name: Upload feed artifact
|
||||
# v4 uses an artifact backend Gitea Actions does not implement
|
||||
# (GHESNotSupportedError); v3 works on Gitea's act_runner.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: shater-${{ matrix.arch }}
|
||||
path: out/${{ matrix.arch }}/*
|
||||
if-no-files-found: error
|
||||
- name: Go tests (shipped tags, linux, + race)
|
||||
run: bash scripts/run-tests.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# APK lane (additive): the same 4 packages through the ImmortalWrt 25.12
|
||||
# apk-SDK for the 25.12/apk fleet (BananaWRT 25.12-mtk-vendor routers + the
|
||||
# future 25.12 VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
||||
# packages.adb + shater-apk.pem. Artifact prefix `apkfeed-` (NOT `shater-`)
|
||||
# so the opkg release job's `artifacts/shater-*` glob never picks these up.
|
||||
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
|
||||
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
|
||||
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
||||
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
|
||||
build-apk:
|
||||
name: apk ${{ matrix.arch }}
|
||||
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
|
||||
# (with the guards in release-apk) leaves nothing published.
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -253,8 +216,14 @@ jobs:
|
||||
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
|
||||
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
|
||||
steps:
|
||||
# fetch-depth: 0 — the package version is DERIVED from the git tag
|
||||
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
|
||||
# shallow checkout has neither tags nor ancestry, so `git describe` would
|
||||
# fail and every dispatch build would fall back to 0.0.0.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
|
||||
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
|
||||
@@ -263,6 +232,13 @@ jobs:
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
# THE version step (bug B4). One computation, used by both the binary
|
||||
# (constant.Version) and the three tag-versioned packages, exported to
|
||||
# every later step of this job:
|
||||
# tag vX.Y.Z -> X.Y.Z-r1 ; off-tag -> <last tag>-r<commits+1>
|
||||
- name: Compute version from git tag
|
||||
run: bash ci/version.sh --env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -336,25 +312,36 @@ jobs:
|
||||
restore-keys: |
|
||||
feeds-apk-
|
||||
|
||||
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
|
||||
# everything else in CI builds with the full upstream set, so without this
|
||||
# step the one combination we actually ship is never exercised. That is how
|
||||
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
|
||||
# binary answered a WireGuard node with "gVisor is not included in this
|
||||
# build" (2026-07-25). The check runs the declared-feature/tag comparison
|
||||
# and then constructs one node of every declared protocol through box.New
|
||||
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
|
||||
# that breaks a feature fails the release instead of shipping.
|
||||
- name: Verify the shipped build-tag set (D23)
|
||||
run: bash scripts/check-router-tags.sh
|
||||
|
||||
- name: Install UPX
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
|
||||
|
||||
# Same artifact-order contract as the opkg lane: the SPA-embedded shaterd
|
||||
# binary is built OUT of the SDK and staged before the package build.
|
||||
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
|
||||
# the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
|
||||
# (the openwrt/shaterd package only installs the staged artifact).
|
||||
# $SHATER_VERSION (from the version step above) is stamped into
|
||||
# constant.Version, so the binary and the package agree. On an exact
|
||||
# node_modules cache hit, --fast skips the redundant `npm ci`.
|
||||
- name: Build & stage shaterd artifact
|
||||
env:
|
||||
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
||||
V="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
V="v0.2.0-dev"
|
||||
fi
|
||||
FAST=""
|
||||
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
|
||||
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
|
||||
bash scripts/build-shaterd.sh "$V" $FAST
|
||||
echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
|
||||
bash scripts/build-shaterd.sh $FAST
|
||||
|
||||
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
|
||||
# sign the per-arch packages.adb with the EC key (secret KEY_APK).
|
||||
@@ -377,120 +364,34 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publish once both arches are built. Rolling `latest` on dispatch, a versioned
|
||||
# release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API).
|
||||
release:
|
||||
name: release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all arch feeds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Assemble release assets
|
||||
id: assets
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p release
|
||||
# For each downloaded arch feed: one ready-to-serve tarball + loose ipks.
|
||||
for d in artifacts/shater-*; do
|
||||
[ -d "$d" ] || continue
|
||||
arch="${d#artifacts/shater-}"
|
||||
tar -C "$d" -czf "release/shater-feed-${arch}.tar.gz" .
|
||||
# loose .ipk for direct `opkg install <url>` (dedupe shared _all ipks by name)
|
||||
for ipk in "$d"/*.ipk; do
|
||||
[ -e "$ipk" ] || continue
|
||||
cp -n "$ipk" "release/$(basename "$ipk")"
|
||||
done
|
||||
done
|
||||
# ship the feed's public key so routers can verify (see docs-shater/INSTALL.md)
|
||||
cp -f dist/shater-feed.pub release/shater-feed.pub
|
||||
ls -l release
|
||||
echo "count=$(ls release | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# restore the prebuilt usign binary (skips apt + cmake + clone + build)
|
||||
- name: Cache CI tools (usign)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/tools
|
||||
key: tools-usign-v1
|
||||
|
||||
- name: Install usign (feed signer)
|
||||
run: bash ci/install-usign.sh
|
||||
|
||||
- name: Build & sign combined opkg feed index
|
||||
# One Packages/Packages.gz over ALL loose .ipk (every arch + arch=all),
|
||||
# with basename Filenames. opkg filters by Architecture, so a single
|
||||
# release URL serves every device: BPI routers pick aarch64_cortex-a53 +
|
||||
# all, the x86 testbed picks x86_64 + all. Signed with KEY_BUILD so
|
||||
# routers keep check_signature on. This is what makes the release directly
|
||||
# consumable as an `src/gz` feed (see docs-shater/INSTALL.md).
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/make-index.sh release
|
||||
|
||||
- name: Determine release identity
|
||||
id: rel
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater ${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater latest (main)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
NAME: ${{ steps.rel.outputs.name }}
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
BODY: |
|
||||
Automated build. Packages: shaterd + byedpi (per-arch), shater-core +
|
||||
luci-app-shater (arch=all).
|
||||
Targets: x86_64 (testbed) and aarch64_cortex-a53 (BPI-R3 + BPI-R4, mediatek/filogic).
|
||||
|
||||
── Add as an opkg feed (recommended — then `opkg upgrade` just works) ──
|
||||
This release is itself a SIGNED package feed; opkg filters by
|
||||
architecture, so the same lines work on every device:
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
|
||||
opkg update
|
||||
opkg install luci-app-shater # pulls shater-core + shaterd too
|
||||
The public-key install is one-time; after it, `opkg update/upgrade`
|
||||
verify the signature with check_signature left on. Full guide: docs-shater/INSTALL.md.
|
||||
|
||||
── Or install the loose .ipk directly / from the tarball feed ──
|
||||
wget -O /tmp/f.tgz <this release>/shater-feed-aarch64_cortex-a53.tar.gz
|
||||
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
|
||||
opkg install /tmp/shater/luci-app-shater_*_all.ipk
|
||||
run: bash ci/gitea-release.sh release/*
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publish the apk lane: ONE release PER ARCH (apk package filenames carry no
|
||||
# arch, and apk fetches `<name>-<ver>.apk` relative to the packages.adb URL —
|
||||
# a flat multi-arch release would collide). Rolling `apk-latest-<arch>` on
|
||||
# dispatch, `apk-<tag>-<arch>` on a version tag. The tags do NOT match the
|
||||
# workflow's `v*` trigger, so publishing them cannot re-trigger the build.
|
||||
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
|
||||
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
|
||||
# multi-arch release would collide). Every run refreshes the ROLLING pointer
|
||||
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
|
||||
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
|
||||
# publishing them cannot re-trigger the build.
|
||||
#
|
||||
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
|
||||
# it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
|
||||
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
|
||||
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
|
||||
# while v0.2.9/v0.2.10 published fine, and every router whose
|
||||
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
|
||||
# successful, silent `apk update` with nothing new. Rolling is the whole point
|
||||
# of that URL, so it is now written unconditionally and asserted afterwards.
|
||||
release-apk:
|
||||
name: release apk
|
||||
needs: build-apk
|
||||
needs: [test, build-apk]
|
||||
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
|
||||
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
|
||||
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
|
||||
if: ${{ !cancelled() }}
|
||||
#
|
||||
# `needs.test.result == 'success'` is the second half of the gate. Without
|
||||
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
|
||||
# skipped), this job runs with no artifacts at all, and — see the guard at
|
||||
# the end of the publish step — used to exit 0 having published nothing. Red
|
||||
# tests must SKIP this job, not "succeed" through it.
|
||||
if: ${{ !cancelled() && needs.test.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -501,6 +402,9 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
# Identity of the VERSIONED release only. The rolling pointer is published
|
||||
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
|
||||
# from here.
|
||||
- name: Determine release identity
|
||||
id: rel
|
||||
run: |
|
||||
@@ -522,25 +426,113 @@ jobs:
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
run: |
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
|
||||
# was the step's whole body: with no artifacts the glob stayed
|
||||
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
|
||||
# ended and the step exited 0 — "release apk" went GREEN having
|
||||
# published absolutely nothing. Any upstream failure (all arches
|
||||
# failing to build, an artifact-name change, a download-artifact
|
||||
# hiccup) therefore looked like a successful release.
|
||||
published=0
|
||||
for d in artifacts/apkfeed-*; do
|
||||
[ -d "$d" ] || continue
|
||||
arch="${d#artifacts/apkfeed-}"
|
||||
if [ "$VER" = latest ]; then TAG="apk-latest-$arch"; else TAG="apk-$VER-$arch"; fi
|
||||
ROLL="apk-latest-$arch"
|
||||
|
||||
# The version we just built, read straight off the artifact
|
||||
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
|
||||
# this job checks out shallow, so it has no tags to describe from.
|
||||
pkg=""
|
||||
for a in "$d"/shaterd-*.apk; do
|
||||
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
|
||||
done
|
||||
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
|
||||
want="${pkg#shaterd-}"; want="${want%.apk}"
|
||||
echo "[release-apk] arch=$arch built version=$want"
|
||||
|
||||
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
|
||||
Packages: shaterd + byedpi (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
This build: \`$want\`.
|
||||
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
|
||||
|
||||
── Add as an apk repository (auto-updates via \`apk upgrade\`) ──
|
||||
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$TAG/shater-apk.pem
|
||||
── Add as an apk repository (rolling — install once, then just update) ──
|
||||
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
|
||||
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
|
||||
apk update
|
||||
apk add luci-app-shater # pulls shater-core + shaterd too
|
||||
apk add byedpi # optional: ByeDPI desync egress
|
||||
Update: apk update && apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||
Full guide: docs-shater/INSTALL.md §6. The opkg/24.10 feed lives in the \`latest\` release."
|
||||
echo "[release-apk] publishing $TAG from $d"
|
||||
TAG="$TAG" NAME="shater apk $VER ($arch)" BODY="$BODY" \
|
||||
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
|
||||
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
|
||||
assets, so the same repo line keeps serving the newest build. To pin a
|
||||
version instead, point the repo line at
|
||||
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
|
||||
file must be edited by hand for each upgrade.
|
||||
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
A bare \`apk upgrade\` reconciles EVERY installed package against every
|
||||
configured repo and can downgrade unrelated system packages; naming them
|
||||
upgrades only those (apk-tools 3: \"If list of packages is provided, only
|
||||
those packages are upgraded along with needed dependencies\").
|
||||
Full guide: docs-shater/INSTALL.md §5."
|
||||
|
||||
# 1) the pinnable versioned release (tag runs only)
|
||||
if [ "$VER" != latest ]; then
|
||||
echo "[release-apk] publishing apk-$VER-$arch from $d"
|
||||
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
|
||||
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
|
||||
bash ci/gitea-release.sh "$d"/*
|
||||
fi
|
||||
|
||||
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
|
||||
# deletes the existing release before recreating it, so the old
|
||||
# version's assets are REPLACED, never accumulated (two versions of
|
||||
# one package in one index would let apk choose, not us).
|
||||
echo "[release-apk] publishing $ROLL from $d"
|
||||
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
|
||||
PRERELEASE=true ROLLING=true \
|
||||
bash ci/gitea-release.sh "$d"/*
|
||||
|
||||
# 3) ASSERT the rolling release really serves THIS build — same class
|
||||
# of check as ci/sdk-build-apk.sh's package-version assert, and for
|
||||
# the same reason: the previous failure mode was silent. Reads the
|
||||
# published release back over the API and requires our three
|
||||
# tag-versioned packages at $want, the index, the key — and NO
|
||||
# left-over package asset at any other version.
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
|
||||
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
|
||||
| tr '{},' '\n\n\n' \
|
||||
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
|
||||
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
|
||||
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
|
||||
# here-string, NOT `printf | grep -q`: under `pipefail` the early
|
||||
# exit of grep -q can SIGPIPE the writer and fail a passing check.
|
||||
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
|
||||
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
|
||||
grep -qxF "$f" <<<"$got" || {
|
||||
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
|
||||
echo " A router pinned to the rolling URL would have silently"
|
||||
echo " stayed on its old version with a successful apk update."
|
||||
exit 13; }
|
||||
done
|
||||
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
|
||||
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
|
||||
-e "luci-app-shater-$want.apk" || true)"
|
||||
[ -z "$stale" ] || {
|
||||
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
|
||||
printf ' %s\n' $stale
|
||||
echo " Two versions of one package in one feed = apk picks by its"
|
||||
echo " own rules, not by our intent."
|
||||
exit 14; }
|
||||
echo "[release-apk] OK — $ROLL serves $want"
|
||||
published=$((published + 1))
|
||||
done
|
||||
|
||||
# The assert the loop above never had. Zero feeds published is a failed
|
||||
# release, not a quiet success — say so with a non-zero exit.
|
||||
if [ "$published" -eq 0 ]; then
|
||||
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
|
||||
echo " NOTHING was published. Downloaded tree:"
|
||||
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
|
||||
exit 10
|
||||
fi
|
||||
echo "[release-apk] published $published arch feed(s)"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Shater — the test gate, on every push to `main`.
|
||||
#
|
||||
# WHY THIS FILE EXISTS (2026-07-26)
|
||||
# The fork had a full suite and no CI that ran it. Upstream's
|
||||
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
|
||||
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
|
||||
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
|
||||
# the 116 test files under shater/** had never once executed in CI, and
|
||||
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
|
||||
# releases.
|
||||
#
|
||||
# RELATIONSHIP TO release.yml
|
||||
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
|
||||
# gate: a separate workflow cannot block another one. The gate is the `test`
|
||||
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
|
||||
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
|
||||
# drift apart.
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs-shater/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: go + panel tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
|
||||
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
|
||||
# starts. Same step, same reason, as in release.yml's build job.
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false # explicit actions/cache@v3.3.2 below
|
||||
|
||||
# v3.3.2 is the last release speaking the cache API act_runner implements
|
||||
# (see the header of release.yml). Same key as the release build job, so
|
||||
# whichever runs first warms the other.
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
|
||||
# through `node --test`, and type stripping only exists from 22.6. On
|
||||
# node 20 `npm test` dies with a syntax error before running anything.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache panel node_modules
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: panel/node_modules
|
||||
key: npm-${{ hashFiles('panel/package-lock.json') }}
|
||||
|
||||
- name: Panel tests
|
||||
run: bash scripts/run-panel-tests.sh
|
||||
|
||||
- name: Go tests (shipped tags, linux, + race)
|
||||
run: bash scripts/run-tests.sh
|
||||
+20
-14
@@ -1,28 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
|
||||
|
||||
set -e -o pipefail
|
||||
set -euo pipefail
|
||||
|
||||
ARCH=$1
|
||||
DEB_SRC=$2
|
||||
OUT_IPK=$3
|
||||
|
||||
PROJECT=$(dirname "$0")/../..
|
||||
TMP_PATH=`mktemp -d`
|
||||
cp $2 $TMP_PATH
|
||||
pushd $TMP_PATH
|
||||
TMP_PATH=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_PATH"' EXIT
|
||||
|
||||
DEB_NAME=`ls *.deb`
|
||||
ar x $DEB_NAME
|
||||
cp "$DEB_SRC" "$TMP_PATH"/
|
||||
pushd "$TMP_PATH" >/dev/null
|
||||
|
||||
# Derive the name from the file we copied — do not glob-parse `ls *.deb`.
|
||||
DEB_NAME=$(basename "$DEB_SRC")
|
||||
ar x "$DEB_NAME"
|
||||
|
||||
mkdir control
|
||||
pushd control
|
||||
pushd control >/dev/null
|
||||
tar xf ../control.tar.gz
|
||||
rm md5sums
|
||||
sed "s/Architecture:\\ \w*/Architecture:\\ $1/g" ./control -i
|
||||
rm -f md5sums
|
||||
sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
|
||||
cat control
|
||||
tar czf ../control.tar.gz ./*
|
||||
popd
|
||||
popd >/dev/null
|
||||
|
||||
DEB_NAME=${DEB_NAME%.deb}
|
||||
tar czf $DEB_NAME.ipk control.tar.gz data.tar.gz debian-binary
|
||||
popd
|
||||
tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
|
||||
popd >/dev/null
|
||||
|
||||
cp $TMP_PATH/$DEB_NAME.ipk $3
|
||||
rm -r $TMP_PATH
|
||||
cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
|
||||
|
||||
+8
-1
@@ -36,8 +36,15 @@ nul
|
||||
# playwright MCP screenshots/snapshots
|
||||
.playwright-mcp/
|
||||
|
||||
# working-session screenshots dropped in the repo root (not shipped docs)
|
||||
/*.png
|
||||
|
||||
# throwaway build binaries / scratch staged under tmp/
|
||||
/tmp/
|
||||
|
||||
# -- upstream sing-box-lx -----------------------------------------
|
||||
/.idea/
|
||||
.idea/
|
||||
/vendor/
|
||||
/*.json
|
||||
/*.srs
|
||||
@@ -56,7 +63,7 @@ nul
|
||||
/venv/
|
||||
/test/cache.db
|
||||
|
||||
# feed artifacts (tracked public key dist/shater-feed.pub is force-added)
|
||||
# feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
|
||||
/dist/
|
||||
|
||||
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
|
||||
|
||||
+7
-1
@@ -7,4 +7,10 @@
|
||||
[submodule "submodules/wireguard-go"]
|
||||
path = submodules/wireguard-go
|
||||
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
|
||||
branch = lx
|
||||
# The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
|
||||
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
|
||||
# conn/bind_std.go at all, so a `git submodule update --remote` against it
|
||||
# would silently restore the bug where ClientBind/StdNetBind shred the
|
||||
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
|
||||
# the line the pin is actually on.
|
||||
branch = lx-awg2-v005
|
||||
|
||||
@@ -6,61 +6,155 @@
|
||||
## Правила делегирования
|
||||
|
||||
1. ЛЮБАЯ реализация (код, тесты, конфиги, рефакторинг, отладка) выполняется
|
||||
субагентами через инструмент Agent с `model: "opus"`. Сам ты правишь файлы
|
||||
только в одном случае: тривиальная правка в 1–2 строки, где постановка
|
||||
задачи дороже самой правки.
|
||||
субагентами через инструмент Agent. Сам ты правишь файлы только в одном
|
||||
случае: тривиальная правка в 1–2 строки, где постановка задачи дороже самой
|
||||
правки.
|
||||
|
||||
2. Перед делегированием ты сам исследуешь код настолько, чтобы написать
|
||||
точное ТЗ. В каждом задании субагенту обязательно указывай:
|
||||
- контекст: что это за проект и над чем идёт работа;
|
||||
- конкретные файлы и функции, которые нужно менять (пути, а не «найди сам»);
|
||||
2. **Модель выбирает исполнитель задачи, а не привычка.** `fable` — быстрый и
|
||||
дешёвый, годится для механической работы с ясным контрактом. `opus` — для
|
||||
всего, где нужно рассуждение: поиск причины, аудит, дизайн, работа в чужом
|
||||
коде. Если у `fable` кончилась квота — молча переходи на `opus`, это не повод
|
||||
останавливать работу. Не спрашивай владельца, какую модель брать.
|
||||
|
||||
3. Перед делегированием ты сам исследуешь код настолько, чтобы написать точное
|
||||
ТЗ. В каждом задании субагенту обязательно указывай:
|
||||
- контекст: что за проект и над чем идёт работа;
|
||||
- конкретные файлы и функции (пути, а не «найди сам»);
|
||||
- контракт: сигнатуры, форматы данных, инварианты, что менять НЕЛЬЗЯ;
|
||||
- definition of done: как проверить, что задача выполнена
|
||||
(какие команды/тесты прогнать и какой ожидается результат);
|
||||
- что вернуть в финальном ответе: список изменённых файлов, результаты
|
||||
проверок, найденные проблемы и принятые решения.
|
||||
- definition of done: какие команды прогнать и какой ждать результат;
|
||||
- что вернуть: изменённые файлы, результаты проверок, найденные проблемы,
|
||||
принятые решения.
|
||||
|
||||
3. Скиллы: при постановке задачи посмотри список доступных скиллов и ЯВНО
|
||||
перечисли в ТЗ, какие скиллы субагент обязан вызвать через инструмент Skill
|
||||
до начала работы (например: «сначала вызови Skill "openwrt-procd-services"
|
||||
и следуй ему»). Субагент не видит наш диалог и сам не догадается — пиши
|
||||
названия скиллов прямо в текст задания.
|
||||
4. **Скиллы использовать по максимуму — и тебе, и агентам.** Это не
|
||||
формальность: в них лежит выстраданное знание по ровно тем предметным
|
||||
областям, в которых мы работаем, и игнорировать их — значит переоткрывать
|
||||
чужие грабли. См. раздел «Скиллы» ниже.
|
||||
|
||||
4. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
|
||||
сообщении, каждый с `model: "opus"`. Зависимые — последовательно, передавая
|
||||
в следующее ТЗ результаты предыдущего.
|
||||
5. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
|
||||
сообщении. Зависимые — последовательно, передавая результаты предыдущего.
|
||||
**Делишь файлы между параллельными агентами явно** и пишешь каждому, кто ещё
|
||||
работает в дереве и что трогать нельзя. Запрещай им `git stash`,
|
||||
`git checkout <файл>`, `git reset` — в этом проекте агент уже сносил правки
|
||||
соседа через `git stash push`.
|
||||
|
||||
5. Приёмка: результат каждого субагента ты проверяешь сам (читаешь diff
|
||||
ключевых мест, гоняешь проверки из definition of done). Если результат
|
||||
не принят — не переделывай сам, а верни задачу: доработку заказывай тому же
|
||||
агенту через SendMessage (у него сохранён контекст), а не новым спавном.
|
||||
6. Приёмка: результат каждого субагента ты проверяешь сам — читаешь diff
|
||||
ключевых мест, гоняешь проверки из definition of done. Не принимай отчёт на
|
||||
слово: сегодня отчёт «тесты зелёные» дважды сопровождался тестом, который
|
||||
ничего не прибивал. Если результат не принят — не переделывай сам, а верни
|
||||
задачу тому же агенту через SendMessage (у него сохранён контекст).
|
||||
|
||||
6. Финальный отчёт пользователю: что сделано, кем (сколько агентов),
|
||||
что проверено, что осталось.
|
||||
7. Финальный отчёт владельцу: что сделано, сколько агентов, что проверено,
|
||||
**что осталось непроверенным и почему** — последнее так же важно.
|
||||
|
||||
## Инженерные стандарты
|
||||
|
||||
Это не пожелания. Каждый пункт здесь появился после того, как его отсутствие
|
||||
стоило рабочего дня.
|
||||
|
||||
- **Тест обязан быть проверен мутацией.** Откатить фикс → показать, что тест
|
||||
падает, и с каким текстом → вернуть фикс. Тест, не падающий на сломанном коде,
|
||||
не тест, а украшение.
|
||||
|
||||
- **Прибор без контроля не доказывает ничего.** Отрицательный результат чего-то
|
||||
стоит, только если показано, что этот же прибор умеет дать положительный.
|
||||
«Утечки не нашли» прибором, который не мог её увидеть, — это не результат.
|
||||
|
||||
- **Опровержение ценнее согласия.** В каждом ТЗ прямо разрешай субагенту
|
||||
сказать «твоя версия неверна» и требуй доказательства, а не вежливости.
|
||||
Лучшие результаты этого проекта приходили именно так.
|
||||
|
||||
- **Не обещать непроверенного.** Комментарий, предупреждение и текст в панели —
|
||||
это утверждения о поведении. Если поведение не проверено, так и писать.
|
||||
Формально верная фраза, которая читается как «работает», — тоже ложь.
|
||||
|
||||
- **Умолчание падает в восстановимую сторону.** Открытый `default:` в разборе
|
||||
вариантов — источник целого класса дефектов: неучтённое значение уходит туда,
|
||||
где дороже всего ошибиться. Списки делать положительными и закрытыми.
|
||||
|
||||
- **Проверка присутствия обязана покрывать всё, что ставит её Apply-двойник.**
|
||||
Иначе идемпотентный быстрый путь становится ловушкой: «всё на месте» при
|
||||
отсутствующем маршруте.
|
||||
|
||||
- **Никакого молчаливого скипа.** Тест, который не выполнился, обязан быть
|
||||
назван поимённо в выводе гейта. Однажды CI гонял два теста из 116 файлов, и
|
||||
все считали, что покрыто.
|
||||
|
||||
## Скиллы
|
||||
|
||||
**Правило: если задача касается области, по которой есть скилл, — скилл
|
||||
вызывается ДО начала работы, а не после того, как что-то не заработало.**
|
||||
Это относится и к тебе, и к каждому субагенту.
|
||||
|
||||
Субагент не видит наш диалог и сам не догадается, что скиллы существуют.
|
||||
Поэтому **в каждом ТЗ перечисляй поимённо**, какие скиллы он обязан вызвать
|
||||
через инструмент Skill: «сначала вызови Skill "openwrt-nftables" и Skill
|
||||
"openwrt-networking", следуй им». Требуй в отчёте сказать, что именно из скилла
|
||||
он применил, — так видно, вызвал он его или упомянул.
|
||||
|
||||
Соответствие областей этого проекта и скиллов:
|
||||
|
||||
| Трогаешь | Обязательные скиллы |
|
||||
|---|---|
|
||||
| `/etc/config/*`, `uci`, uci-defaults, парсер модели | `openwrt-uci` |
|
||||
| nftables, fw4, зоны, метки, tproxy, kill-switch | `openwrt-nftables` |
|
||||
| интерфейсы, мосты, VLAN, policy routing, `ip rule`, sysctl, dnsmasq | `openwrt-networking` |
|
||||
| init-скрипты, procd, respawn, service triggers, boot armor | `openwrt-procd-services` |
|
||||
| перехват трафика целиком (tproxy + маршрутизация + DNS) | `openwrt-transparent-proxy` |
|
||||
| сборка пакетов, SDK, фид, CI, подпись, `apk`/`opkg` | `openwrt-package-build-ci`, `openwrt-native-packages` |
|
||||
| LuCI-приложение, ubus/rpcd, ucode | `openwrt-luci-plugin`, `openwrt-ubus-rpcd`, `openwrt-ucode` |
|
||||
| панель (React/TS) | `react-expert`, `frontend-design:frontend-design` |
|
||||
| Go: конкурентность, каналы, профилирование, идиоматика | `fullstack-dev-skills:golang-pro` |
|
||||
| TypeScript | `fullstack-dev-skills:typescript-pro` |
|
||||
| стратегия тестирования, покрытие, тестовые данные | `fullstack-dev-skills:test-master` |
|
||||
| поиск причины по логам и трассам | `fullstack-dev-skills:debugging-wizard` |
|
||||
| проверка в браузере, скриншоты | `fullstack-dev-skills:playwright-expert` |
|
||||
| ревью | `review`, `fullstack-dev-skills:code-reviewer` |
|
||||
| безопасность | `security-review`, `fullstack-dev-skills:security-reviewer` |
|
||||
| графики и визуализация данных | `dataviz` |
|
||||
|
||||
Список неполный — **смотри доступные скиллы под задачу**, а не только в эту
|
||||
таблицу. Если скилл выглядит смежным, дешевле вызвать его и не воспользоваться,
|
||||
чем не вызвать и потом отлаживать то, что там уже описано.
|
||||
|
||||
## Проверки
|
||||
|
||||
- **Гейт:** `bash scripts/run-tests.sh` — Linux в Docker, боевой набор тегов,
|
||||
`-race`, и шаг, требующий вердикта по имени для привилегированных тестов.
|
||||
Зелёный гейт — необходимое условие, но не достаточное: он не видит стыков с
|
||||
ядром, procd и nftables.
|
||||
- **Стенд:** сервер `local_openwrt` в ssh-manager — ImmortalWrt 25.12.1 той же
|
||||
ревизии, что боевой роутер. Сюда — всё, что касается init-скриптов, nft,
|
||||
policy routing, TUN.
|
||||
- **Боевой роутер:** `mini_router` (BPI-R3), через него идёт весь домашний
|
||||
трафик. Перед изменением конфигурации — резервная копия. Проверять приборно,
|
||||
а не по логу: лог может печатать одно и то же в честном и в ложном случае.
|
||||
|
||||
## Релиз и деплой
|
||||
|
||||
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
|
||||
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
|
||||
`apk upgrade shaterd shater-core luci-app-shater`.
|
||||
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
|
||||
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
|
||||
иначе.
|
||||
|
||||
## Фронтенд (admin panel)
|
||||
|
||||
Дизайн-направление ЗАФИКСИРОВАНО: **Faceplate** (панель сетевого железа).
|
||||
Полная спека, токены, компоненты и ссылка на живой эталон — в
|
||||
[`docs-shater/DESIGN.md`](docs-shater/DESIGN.md). Эталон:
|
||||
https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
|
||||
Спека, токены и компоненты — в [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md).
|
||||
Эталон: https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
|
||||
|
||||
- **Стек:** Vite + React + TypeScript, лёгкий (SPA встраивается в бинарь —
|
||||
без тяжёлых зависимостей). Расположение: папка `panel/` в корне.
|
||||
- **Порядок работ:**
|
||||
1. Сам (оркестратор) скаффолдишь `panel/`, переносишь токены из
|
||||
`docs-shater/DESIGN.md` в `panel/src/tokens.css` один-в-один и задаёшь каркас
|
||||
компонентов. Это фундамент — делай аккуратно сам или отдай ОДНОМУ агенту.
|
||||
2. Дизайн-систему в компоненты: `<Faceplate> <Module> <Toggle> <Led>
|
||||
<SegMeter> <QueryLog>` + кнопки — строго по эталону.
|
||||
3. Страницы раздаёшь ПАРАЛЛЕЛЬНО Opus-агентам (`model: "opus"`), по одной на
|
||||
агента: Overview, Nodes/Subscriptions, Routing rules, DNS/Blocklists,
|
||||
Devices, Apply/Rollback.
|
||||
- **В КАЖДОМ ТЗ агенту обязательно:** ссылка на `docs-shater/DESIGN.md` и на эталон;
|
||||
требование сначала вызвать Skill `react-expert` и Skill
|
||||
`frontend-design:frontend-design` и следовать им; список готовых компонентов,
|
||||
которые он ДОЛЖЕН переиспользовать (не изобретать заново); какие токены и
|
||||
семантические цвета применять; DoD — страница совпадает с языком эталона,
|
||||
адаптив + фокус + reduced-motion соблюдены.
|
||||
- **Не отходить от Faceplate.** Любой новый экран наследует ту же визуальную
|
||||
систему. Оранжевый — только акцент; семантика good/warn/crit — отдельно.
|
||||
- **Стек:** Vite + React + TypeScript в `panel/`. SPA встраивается в бинарь —
|
||||
тяжёлые зависимости недопустимы.
|
||||
- **Панель целиком на английском.** Ни одного символа кириллицы в `panel/src`.
|
||||
- **В КАЖДОМ ТЗ на панель:** ссылка на `DESIGN.md` и на эталон; требование
|
||||
сначала вызвать Skill `react-expert` и Skill
|
||||
`frontend-design:frontend-design`; список существующих компонентов, которые
|
||||
надо ПЕРЕИСПОЛЬЗОВАТЬ (`<Faceplate> <Module> <Toggle> <Led> <SegMeter>
|
||||
<QueryLog>` и кнопки), а не изобретать заново; какие токены и семантические
|
||||
цвета применять; DoD — совпадение с языком эталона, адаптив, фокус,
|
||||
`prefers-reduced-motion`.
|
||||
- Оранжевый — только акцент; семантика good/warn/crit — отдельно.
|
||||
- **Панель не должна врать про состояние.** Значение, которое движок примет,
|
||||
не может рисоваться как «never matches»; настройка, которой управляет другая
|
||||
подсистема, не может описываться так, будто управляет ею.
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<!-- Language: [Русский](README.md) · **English** -->
|
||||
|
||||
# shater
|
||||
|
||||
**A self-hosted internet-control appliance for OpenWrt routers.** One box turns a
|
||||
home or office network into a transparent VPN gateway, a network-wide
|
||||
ad/tracker/malware blocker, per-device parental control, and a live traffic
|
||||
dashboard — all local, all configured from a rich built-in web panel.
|
||||
|
||||
> The primary README is Russian — [README.md](README.md). This is a condensed
|
||||
> English mirror.
|
||||
|
||||
[](LICENSE)
|
||||

|
||||
|
||||
## What it is
|
||||
|
||||
shater is a network proxy stack for **OpenWrt / ImmortalWrt / BananaWRT** routers
|
||||
(Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic
|
||||
through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and
|
||||
is managed from a built-in web panel.
|
||||
|
||||
The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
|
||||
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
|
||||
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
|
||||
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
|
||||
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — 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
|
||||
(default `:8088`).
|
||||
|
||||
## Highlights
|
||||
|
||||
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks
|
||||
— `:53` interception is on by default and covers the queries a client sends to the
|
||||
router itself, not just the ones aimed around it (`globals.dns_intercept`, D24).
|
||||
- First-match routing by source / destination / list / geo / client → outbound /
|
||||
selector / chain / direct / block; node groups with balancer/observatory;
|
||||
multi-hop chains; per-rule egress.
|
||||
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
|
||||
`inet shater` nft table; atomic apply with `nft -c` validation. 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.
|
||||
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow,
|
||||
schedules) and per-domain/client/device statistics from in-process DNS events.
|
||||
|
||||
Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
|
||||
|
||||
## Install
|
||||
|
||||
One signed **apk** feed (OpenWrt / ImmortalWrt / BananaWRT **25.12+**), one
|
||||
release per arch. Verbatim commands, the manual `.apk` install and the
|
||||
rolling-vs-pinned choice are in
|
||||
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
```sh
|
||||
wget -O /etc/apk/keys/shater-apk.pem "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" > /etc/apk/repositories.d/shater.list
|
||||
apk update && apk add luci-app-shater # -> shater-core -> shaterd
|
||||
```
|
||||
|
||||
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
|
||||
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
|
||||
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:
|
||||
|
||||
```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
|
||||
|
||||
`scripts/build-shaterd.sh [VERSION] [--fast]` builds the SPA (Vite), embeds it via
|
||||
`//go:embed`, cross-builds musl-static `{amd64, arm64}` and UPX-packs the artifact
|
||||
into `openwrt/shaterd/files/`. Details in
|
||||
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
`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 |
|
||||
|------|------|
|
||||
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
|
||||
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
|
||||
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater` |
|
||||
| `docs-shater/` | Product documentation |
|
||||
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
|
||||
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
|
||||
| `docs/`, `mkdocs.yml` | **Upstream** sing-box docs (mkdocs) — kept as-is |
|
||||
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | sing-box-lx engine tree |
|
||||
|
||||
## CI, upstream & license
|
||||
|
||||
CI (`.gitea/workflows/release.yml`) builds all 4 packages and publishes a signed
|
||||
per-arch apk repo (EC key `shater-apk.pem`). A `vX.Y.Z` tag → the pinnable
|
||||
`apk-vX.Y.Z-<arch>`; every run also refreshes the rolling `apk-latest-<arch>` and
|
||||
asserts over the API that it really serves the version just built.
|
||||
|
||||
The engine is the **sing-box-lx** fork — a thin downstream of upstream sing-box that
|
||||
lives by **rebase, never merge**; its constitution is
|
||||
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md). Licensed under
|
||||
[GPL-3.0](LICENSE), like upstream sing-box. Unofficial fork, not affiliated with
|
||||
SagerNet.
|
||||
@@ -1,71 +1,364 @@
|
||||
<!-- Язык: **Русский** · [English](README.en.md) -->
|
||||
|
||||
# shater
|
||||
|
||||
**A self-hosted internet-control appliance for OpenWrt.** One box turns your
|
||||
network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker,
|
||||
per-device parental control, and a live traffic dashboard — configured from a rich
|
||||
web admin panel, all local.
|
||||
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
|
||||
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
|
||||
трекеров и вредоносных доменов, средство родительского контроля по устройствам
|
||||
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
|
||||
настраивается из богатой веб-панели.
|
||||
|
||||
[](LICENSE)
|
||||

|
||||

|
||||

|
||||
|
||||
> ⚠️ **v0.2 is under active development on a new foundation.** The previous,
|
||||
> complete and VM-verified xray-based version lives on the **[`v0.1`](../../src/branch/v0.1)**
|
||||
> branch and still installs from the signed feed.
|
||||
---
|
||||
|
||||
## What v0.2 is
|
||||
## Что это
|
||||
|
||||
shater v0.2 is built as a **fork of [sing-box](https://github.com/SagerNet/sing-box)
|
||||
(via [sing-box-lx](https://github.com/Leadaxe/sing-box-lx))** with our whole
|
||||
product embedded in the one binary: the proxy engine, a control plane, a DNS
|
||||
filter, and a full admin panel. Riding sing-box gives a broad, up-to-date protocol
|
||||
set — VLESS/VMess/Trojan/Shadowsocks, Reality, **AmneziaWG 2.0**, Hysteria2, TUIC —
|
||||
without reinventing the anti-DPI arms race.
|
||||
**shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
|
||||
BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
|
||||
клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
|
||||
гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
|
||||
веб-панели.
|
||||
|
||||
The UI is split for both integration and a great experience: a **thin LuCI app**
|
||||
(a small dashboard + an "Open panel" button) hands a short-lived token to a
|
||||
**standalone admin panel** the daemon serves on its own port — so panel auth is
|
||||
bootstrapped from LuCI's existing login, and the real UX is a modern SPA we fully
|
||||
own.
|
||||
Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
|
||||
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
|
||||
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
|
||||
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
|
||||
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
|
||||
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — ровно то, что умеет разобрать
|
||||
`shater/parse` и что регистрирует `shater/registry` в движке.
|
||||
|
||||
## Highlights (planned)
|
||||
Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
|
||||
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
|
||||
поднятую демоном на собственном порту (по умолчанию `:8088`).
|
||||
|
||||
- Transparent TPROXY proxy (TCP+UDP), split by domain/geo/client, no DNS leaks.
|
||||
- Broad protocols incl. **AmneziaWG 2.0**, Reality, Hysteria2, TUIC.
|
||||
- Network-wide **DNS blocklists** with flexible sources (inline / file / url /
|
||||
geosite) and an efficient matcher for million-entry lists.
|
||||
- **Per-domain, per-client, per-device statistics** — fed by the engine's DNS
|
||||
events in-process (no log scraping).
|
||||
- **Per-device control**: block a site for one device or everyone; per-device
|
||||
exit/proxy toggles; schedules; alerts.
|
||||
- Fail-closed kill-switch, atomic apply with commit-confirm rollback, signed opkg
|
||||
feed.
|
||||
---
|
||||
|
||||
See **[`docs-shater/FEATURES.md`](docs-shater/FEATURES.md)** for the full list.
|
||||
## Ключевые возможности
|
||||
|
||||
## Documentation
|
||||
**Прозрачный прокси и маршрутизация**
|
||||
- TPROXY data-plane для нескольких LAN-интерфейсов (TCP + UDP), сниффинг
|
||||
SNI/Host/QUIC, без утечек DNS.
|
||||
- Правила маршрутизации по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
|
||||
(domain/suffix/keyword/geosite), спискам, порту, протоколу →
|
||||
outbound / selector / chain / direct / block.
|
||||
- Группы узлов с балансировщиком/обсерваторией (least-ping / failover /
|
||||
round-robin), **мульти-хоп цепочки** и выбор egress по правилу.
|
||||
|
||||
| Doc | What |
|
||||
|-----|------|
|
||||
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Start here** — project context, v0.1→v0.2 history, decisions in brief, testbed/infra |
|
||||
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Phased plan (Phase 1 = fork + embedding prototype) |
|
||||
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Full feature list with MVP/T1/T2 tags |
|
||||
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary design, auth handoff, data/DNS/apply flow (diagrams) |
|
||||
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Why sing-box, why fork, why the panel split, license, etc. |
|
||||
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Admin-panel visual system — the "Faceplate" direction, tokens, components, north-star prototype |
|
||||
**Надёжность («железно»)**
|
||||
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
|
||||
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
|
||||
трогаем.
|
||||
- Атомарный apply с валидацией движком и `nft -c`. **Commit-confirm** с
|
||||
авто-откатом к последней рабочей конфигурации есть, но **на стоковой установке
|
||||
выключен**: `confirm_timeout` поставляется нулём, и apply не вооружает ничего,
|
||||
пока вы не зададите окно (см. «Включение»).
|
||||
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
|
||||
(SSH/LuCI/LAN) всегда в обход.
|
||||
|
||||
## Status
|
||||
**DNS, фильтрация, блокировки**
|
||||
- Перехват `:53`, DNS движка sing-box в процессе; резолверы DoH/DoT/plain/FakeIP,
|
||||
выбор резолвера по домену.
|
||||
- **Блок-листы с гибкими источниками**: `inline` / `file` / `url` (авто-обновление) /
|
||||
категория `geosite`; hosts-файл, plain-список или AdBlock-стиль `||domain^`
|
||||
компилируются в локальный `.srs`. Эффективный компилированный матчер вместо
|
||||
dnsmasq-мегасписков.
|
||||
- **Block-DoH/DoT** — не даёт устройствам обходить фильтр через свой шифрованный DNS.
|
||||
|
||||
Foundation reset complete: v0.1 preserved on its branch, `main` reset for v0.2.
|
||||
Next is **Phase 1** — fork sing-box-lx into `main` and stand up the embedding
|
||||
prototype (prove AmneziaWG 2.0, measure binary size). Follow `docs-shater/ROADMAP.md`.
|
||||
**Подписки и узлы**
|
||||
- Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
|
||||
интервал обновления + вручную + на загрузке; стабильная идентичность узла между
|
||||
обновлениями; квоты/срок из `subscription-userinfo`.
|
||||
- Ручные узлы: share-ссылки, импорт файла, `wg-quick`/AmneziaWG `.conf`.
|
||||
- Health board: TCP + реальная проба через прокси-путь, exit-IP, «протестировать
|
||||
все».
|
||||
|
||||
## Hardware
|
||||
**Контроль по устройствам**
|
||||
- Авто-обнаружение устройств (dhcp.leases + `ip neigh`), имена, живой статус/трафик.
|
||||
- Тумблеры на устройство: прокси on/off, блок-листы on/off, страна/узел выхода;
|
||||
блок/allow домена для одного устройства или для всех; расписания.
|
||||
|
||||
`aarch64_cortex-a53` covers Banana Pi **BPI-R3** (MT7986/Filogic 830) and **BPI-R4**
|
||||
(MT7988/Filogic 880), both the OpenWrt `mediatek/filogic` target. `x86_64` is the
|
||||
QEMU test VM.
|
||||
**Статистика и видимость**
|
||||
- Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
|
||||
устройствам, таймлайны — из DNS-событий движка в процессе (без скрейпинга логов).
|
||||
- Трафик по клиенту/узлу/правилу (байты) из nft-счётчиков; живой query-log.
|
||||
|
||||
## License
|
||||
**Панель и профили**
|
||||
- Встроенная SPA-панель (собственный порт, вшита в бинарь): overview, узлы и
|
||||
подписки, правила маршрутизации, DNS/блок-листы, устройства, apply/rollback.
|
||||
- Именованные профили/сцены и WAN-профили (условные оверрайды).
|
||||
|
||||
[GPL-3.0](LICENSE) (sing-box is GPL-3.0). See `docs-shater/DECISIONS.md` D6.
|
||||
Полный список с тегами MVP/T1/T2 — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
Один бинарь `shaterd` держит движок, control-plane, DNS-фильтр и веб-сервер панели
|
||||
в одном процессе; OpenWrt-обвязка (тонкий LuCI + procd/system glue) оборачивает его.
|
||||
Конфиг — UCI desired-state; демон рендерит его в конфиг движка и применяет;
|
||||
телеметрия течёт обратно в панель.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph BIN["shaterd — один бинарь (форк sing-box-lx)"]
|
||||
ENG["движок sing-box\nпротоколы · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
|
||||
CTRL["control-plane (shater/)\nUCI-модель · генерация конфига · apply/rollback · nft/routing"]
|
||||
FILT["DNS-фильтр + блок-листы + политика по устройствам (shater/)"]
|
||||
STAT["агрегатор статистики (shater/)"]
|
||||
PANEL["веб-сервер панели + вшитая SPA (свой порт, токен-auth)"]
|
||||
end
|
||||
subgraph WRT["OpenWrt-обвязка (openwrt/)"]
|
||||
LUCI["тонкий LuCI — мини-дашборд + кнопка «Открыть панель»"]
|
||||
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
|
||||
end
|
||||
LUCI -->|"ubus: mint token"| PANEL
|
||||
PROCD --> BIN
|
||||
CTRL --> ENG
|
||||
FILT --> ENG
|
||||
ENG --> STAT
|
||||
STAT --> PANEL
|
||||
```
|
||||
|
||||
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
|
||||
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
|
||||
(проксировано) · direct (обычный маршрут, без туннеля) · 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).
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
shater поставляется одним подписанным **apk-фидом** (OpenWrt / ImmortalWrt /
|
||||
BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/apk/keys/`).
|
||||
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
|
||||
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
|
||||
|
||||
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
|
||||
`shaterd` подтягивается автоматически как зависимость.
|
||||
|
||||
### Фид apk
|
||||
|
||||
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
|
||||
|
||||
```sh
|
||||
# 1) доверяем ключу apk-фида (любое имя *.pem под /etc/apk/keys подходит).
|
||||
wget -O /etc/apk/keys/shater-apk.pem \
|
||||
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
|
||||
|
||||
# 2) добавляем репозиторий — строка указывает на сам ФАЙЛ-ИНДЕКС packages.adb.
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
|
||||
> /etc/apk/repositories.d/shater.list
|
||||
|
||||
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
|
||||
apk update
|
||||
apk add luci-app-shater # -> shater-core -> shaterd
|
||||
```
|
||||
|
||||
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
|
||||
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
|
||||
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
|
||||
пакеты.
|
||||
|
||||
```sh
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
# эквивалент, дополнительно закрепляющий пакеты в world:
|
||||
# apk add -u shaterd shater-core luci-app-shater
|
||||
```
|
||||
|
||||
Документация apk-tools 3 про `apk upgrade`: *«If list of packages is provided,
|
||||
only those packages are upgraded along with needed dependencies»*. Проверить
|
||||
установленные версии: `apk list -I shaterd shater-core luci-app-shater`.
|
||||
|
||||
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
|
||||
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
|
||||
> «поставил и забыл»: `apk update` сам видит новую сборку. `apk-vX.Y.Z-<arch>` —
|
||||
> фиксация на конкретной сборке: роутер не получит ничего нового, пока
|
||||
> `/etc/apk/repositories.d/shater.list` не отредактируют руками — на каждом
|
||||
> роутере и на каждый релиз. На `mini_router` сознательно прописан
|
||||
> версионированный URL, и ручная правка — его цена. Подробнее —
|
||||
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §5.1.
|
||||
|
||||
> Версии пакетов CI берёт из git-тега (`vX.Y.Z` → `X.Y.Z-r1`, сборка вне тега →
|
||||
> `X.Y.Z-r<коммитов+1>`), поэтому каждая новая сборка действительно видна
|
||||
> менеджеру пакетов как новая. Подробности — `docs-shater/INSTALL.md` §2.1.
|
||||
|
||||
> Полные инструкции — ручная установка из `.apk`, фиксация версии
|
||||
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
|
||||
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
### Включение
|
||||
|
||||
shater ставится **инертным** (globals выключены), чтобы установка не рвала связь.
|
||||
Настройте узлы/правила (через панель или `uci`), затем включите и примените:
|
||||
|
||||
```sh
|
||||
uci set shater.globals.enabled=1
|
||||
# Предохранитель: commit-confirm поставляется ВЫКЛЮЧЕННЫМ (confirm_timeout=0),
|
||||
# и без этой строки apply ничем не подстрахован. 120 с — окно на проверку связи.
|
||||
uci set shater.globals.confirm_timeout=120
|
||||
uci commit shater
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Сборка из исходников
|
||||
|
||||
Ship-артефакт — бинарь `shaterd` со вшитой SPA. Собирается вне дерева SDK скриптом
|
||||
`scripts/build-shaterd.sh`:
|
||||
|
||||
```sh
|
||||
scripts/build-shaterd.sh [VERSION] [--fast]
|
||||
```
|
||||
|
||||
Что он делает: (1) собирает панель — `cd panel && npm ci && npm run build` (Vite →
|
||||
`panel/dist`); (2) копирует `panel/dist/*` в `shater/panel/webroot/`, откуда
|
||||
`//go:embed` вшивает **реальную** SPA в бинарь; (3) кросс-собирает под `{amd64,
|
||||
arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), stripped/trimmed;
|
||||
(4) прогоняет UPX `--lzma --best` (~42 МБ → ~8–11 МБ); (5) стейджит артефакт в
|
||||
`openwrt/shaterd/files/` для пакета.
|
||||
|
||||
Затем OpenWrt-пакеты из `openwrt/` собираются каноническим путём SDK. Детали
|
||||
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
|
||||
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
### Проверка
|
||||
|
||||
```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)).
|
||||
|
||||
---
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
|
||||
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
|
||||
|
||||
| Путь | Что это |
|
||||
|------|---------|
|
||||
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
|
||||
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
|
||||
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
|
||||
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
|
||||
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
|
||||
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
|
||||
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанный apk-фид |
|
||||
| `SPECS/` | Конституция форка движка и спеки (Spec Kit) |
|
||||
| `docs-lx/` | Справочник конфигурации фич движка (`lx-config.md`, `.ru.md`) |
|
||||
| `lx-test/`, `submodules/` | Примеры конфигов движка и submodule AmneziaWG-рантайма |
|
||||
| `docs/`, `mkdocs.yml` | **Апстрим** документация sing-box (mkdocs) — как есть |
|
||||
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | Дерево движка sing-box-lx |
|
||||
|
||||
---
|
||||
|
||||
## CI и релизы
|
||||
|
||||
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
|
||||
публикует **подписанные фиды**:
|
||||
|
||||
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
|
||||
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
|
||||
Gitea-secret `KEY_APK`).
|
||||
|
||||
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
|
||||
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
|
||||
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
|
||||
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
|
||||
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
|
||||
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
|
||||
развёрнутых роутерах.
|
||||
|
||||
---
|
||||
|
||||
## Связь с upstream и движок
|
||||
|
||||
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
|
||||
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
|
||||
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
|
||||
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Это набор
|
||||
самого форка, а не 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).
|
||||
|
||||
История: **v0.1** (движок на xray-core, полностью рабочая и VM-проверенная версия)
|
||||
сохранена на ветке **[`v0.1`](../../src/branch/v0.1)**. v0.2 схлопнула runtime в
|
||||
один форкнутый бинарь.
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
|
||||
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка apk-фида (роллинг/фиксация) |
|
||||
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary дизайн, auth-handoff, data/DNS/apply-потоки (диаграммы) |
|
||||
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
|
||||
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Фазовый план |
|
||||
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия |
|
||||
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Визуальная система панели — «Faceplate», токены, компоненты |
|
||||
| [`docs-shater/PORTING.md`](docs-shater/PORTING.md) | Порт проверенных кусков из v0.1 |
|
||||
|
||||
Индекс папки — [`docs-shater/README.md`](docs-shater/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Оборудование
|
||||
|
||||
Арка `aarch64_cortex-a53` покрывает Banana Pi **BPI-R3** (MT7986/Filogic 830) и
|
||||
**BPI-R4** (MT7988/Filogic 880) — оба таргет OpenWrt `mediatek/filogic`. `x86_64` —
|
||||
QEMU-стенд для тестов.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
[GPL-3.0](LICENSE) — как у upstream sing-box. Подробности — в
|
||||
[`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) (D6). Неофициальный форк, не
|
||||
аффилирован с SagerNet.
|
||||
|
||||
+15
-235
@@ -1,239 +1,19 @@
|
||||
[English](README.md) · **Русский**
|
||||
# shater — этот файл переехал
|
||||
|
||||
# sing-box-lx
|
||||
Лицо этого репозитория — продукт **shater** (управляемый интернет-шлюз для
|
||||
роутеров на OpenWrt). Основной README на русском — **[README.md](README.md)**;
|
||||
краткая английская версия — **[README.en.md](README.en.md)**.
|
||||
|
||||
> **Тонкий downstream-форк [SagerNet/sing-box](https://github.com/SagerNet/sing-box).**
|
||||
> Небольшой набор клиентских фич поверх upstream — транспорт **XHTTP**, **AmneziaWG 2.0**, **MASQUE** (CONNECT-IP / Cloudflare WARP), расширения **наблюдаемости** (CommandClient) и балансировка нагрузки **round_robin** — каждая за своим build-tag.
|
||||
> Набор может расти, философия — нет: жить ребейзом на каждый upstream-тег, а не отдельной жизнью.
|
||||
Раньше здесь лежал README форка движка **sing-box-lx**, который shater
|
||||
вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
|
||||
|
||||
> 📄 README самого upstream sing-box — **[на GitHub](https://github.com/SagerNet/sing-box/blob/main/README.md)** (всегда актуальный).
|
||||
- **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)** — справочник конфигурации
|
||||
фич движка (XHTTP, AmneziaWG 2.0, MASQUE).
|
||||
- **[SPECS/CONSTITUTION.md](SPECS/CONSTITUTION.md)** — конституция тонкого форка
|
||||
(принципы, build-tag изоляция, ребейз-модель).
|
||||
- **[SPECS/README.md](SPECS/README.md)** — формат задач Spec Kit.
|
||||
- Апстрим-README самого sing-box —
|
||||
[на GitHub](https://github.com/Leadaxe/sing-box-lx).
|
||||
|
||||
Это не отдельный проект и не «улучшенный sing-box». Это upstream sing-box **плюс несколько фич**, реализованных так, чтобы их можно было переносить на новые версии sing-box годами, почти без конфликтов. Со временем фич может становиться больше — другие протоколы, новые возможности, — но каждая обязана жить по тем же правилам тонкого форка ([CONSTITUTION](SPECS/CONSTITUTION.md)).
|
||||
|
||||
---
|
||||
|
||||
## Уникальное позиционирование
|
||||
|
||||
В экосистеме sing-box форки, добавляющие XHTTP/AmneziaWG, делятся на два лагеря — и `sing-box-lx` не входит ни в один:
|
||||
|
||||
| Форк | Фичи | Подход | Синк с upstream |
|
||||
|------|------|--------|-----------------|
|
||||
| **SagerNet/sing-box** (upstream) | базовый | — | — |
|
||||
| **shtorm-7/sing-box-extended** | десятки (WARP, MASQUE, MTProxy, XHTTP, AWG2, …) | «комбайн», правки повсюду | отдельная ветка, без ребейза на теги |
|
||||
| **amnezia-vpn/amnezia-box**, **hoaxisr/amnezia-box** | только AWG | толстый форк, правки in-place | синк по веткам (`dev-next`/`stable-next`) |
|
||||
| **➡ sing-box-lx** (этот репозиторий) | **малый набор (XHTTP, AWG2, наблюдаемость, round_robin)** | **тонкий: новые файлы за build-tag, минимум касаний upstream** | **ребейз атомарных `// lx`-коммитов на upstream-теги** |
|
||||
|
||||
**Чем мы отличаемся:**
|
||||
|
||||
- **Минимальная дивергенция.** Новый код живёт в новых файлах. Существующие upstream-файлы трогаются только в крошечных помеченных швах `// lx:begin … // lx:end`. → дешёвые ребейзы.
|
||||
- **Изоляция за build-tag.** Фичи включаются тегами `with_xhttp` / `with_awg`. Сборка **без** них байт-в-байт повторяет поведение upstream — фичи ничего не ломают по умолчанию.
|
||||
- **Идентичность сохранена.** Go-модуль остаётся `github.com/sagernet/sing-box`, бинарь называется `sing-box`. Суффикс `-lx` есть только в строке версии (`1.13.13-lx.N`).
|
||||
- **Build-tag — родная конвенция sing-box**, а не наше изобретение (`with_quic`, `with_wireguard`, …). Мы просто применяем её с максимальной дисциплиной.
|
||||
|
||||
> Готовые форки-комбайны мы **не тянем как зависимость**, а используем только как референс wire-протокола.
|
||||
|
||||
---
|
||||
|
||||
## Фичи и статус
|
||||
|
||||
| # | Фича | Что это | Статус |
|
||||
|---|------|---------|--------|
|
||||
| **XHTTP** | клиентский транспорт | Xray-совместимый «splithttp» (режимы `auto`/`packet-up`/`stream-up`/`stream-one`) поверх Reality/TLS/h2c | ✅ **проверен живым Xray (3x-ui) сервером** (packet-up/auto): handshake + DNS + HTTPS + скачивание. `stream-one` — известный баг framing |
|
||||
| **AmneziaWG 2.0** | клиентский endpoint | обфускация WireGuard: `Jc/Jmin/Jmax`, `S1–S4`, `H1–H4` + **2.0**: `I1–I5` (CPS — кастомные пакеты-приманки) | ✅ собирается, проходит `check`; зависимость **активирована** ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация); **проверено живым AWG2-сервером**: handshake + keepalive + трафик наружу |
|
||||
| **Маскировка `id/ip/ib`** | сахар над AWG | WireSock-стиль: декларативная маскировка поверх `I1` — домен (`id`) + протокол (`ip`: `quic`/`dns`/`stun`/`sip`) + браузер (`ib`), ядро строит клиент-инициированную `I1`-приманку: `quic` = out-of-order фрагментированный Initial (i1+i2), `dns`/`stun`/`sip` = query/Binding-Request/INVITE | ✅ **`ip=quic` device-проверен на реальном LTE/WARP DPI** (~330 мс, упрощает Cloudflare WARP); `dns`/`stun`/`sip` собираются и проходят `check`, но режутся как класс протокола к WARP-edge — для других провайдеров |
|
||||
| **Наблюдаемость** (расширения CommandClient) | live-стрим для UI | нативные расширения libbox gRPC за `with_lx_command`: `URLTestOutbound`, `GetRules`, `GetGroups`, `GetOutbounds`, `GetPool`, плюс `Connection.detourList` (хвост detour'а отдельным полем, SPEC 017) и `SubscribeDNSQueries` — структурный live-поток DNS (домен, qtype, rcode `-1`=ошибка, CNAME-цепочка, привязка к процессу, `dnsServer`/`dnsServerType`/`outbound`, SPEC 018) | ✅ в rc-серии, потребляется **LxBox**. SPEC 014–018: [`014`](SPECS/014-CLASH_API_TO_COMMANDCLIENT_MIGRATION/SPEC.md) · [`015`](SPECS/015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md) · [`017`](SPECS/017-CONNECTION_DETOUR_CHAIN/SPEC.md) · [`018`](SPECS/018-DNS_QUERY_STREAM/SPEC.md) |
|
||||
| **round_robin** (балансировка нагрузки) | режим `urltest` | пул-балансировка на `urltest` за `with_lx_command` (для `GetPool`): `mode` `least_test` (дефолт) \| `round_robin`; `balancer{pool (дефолт 3), pool_tolerance (0=держать живые / >0=топ по задержке), sticky_hash}`. Sticky-ключ: пропущен/`[]` → дефолт `["process","domain"]`, `["none"]` → выкл; компоненты `process`/`domain`/`source_ip`/`dest_ip`/`dest_port`. Фиксированные слоты `slot[hash(key)%pool]` (FNV-64a), замена в слоте; `GetPool` отдаёт слоты | ✅ локально равномерно (10/10/10, sticky off); rc.15 починил схлопывание `domain`-ключа (теперь читается `metadata.Domain`, переживающий resolve домен→IP, а не пустой `destination.Fqdn`) — на устройстве равномерность 0.27 → 0.95+. SPEC [`019`](SPECS/019-URLTEST_MODE_STICKY/SPEC.md), конфиг — [docs/.../urltest.md](docs/configuration/outbound/urltest.md) |
|
||||
| **MASQUE** (`type: masque`) | клиентский outbound | CONNECT-IP (RFC 9484) поверх HTTP/3 **или** HTTP/2 для **Cloudflare WARP** (SPEC 021): туннелирует целые IP-пакеты через userspace gVisor-стек; `profile` (`cloudflare`/`standard`), `network` (`h3`/`h2`), pinning ECDSA public key, idle-suspend + самовосстановление. h2 — ручной фреймер поверх `x/net/http2` (без доп. зависимостей); `connect-ip-go` вкопан | ✅ **device-verified на Wi-Fi и LTE** (`warp=on`, реальный трафик на `h3` и `h2`); на сетях, режущих входящий UDP:443, `h3`-handshake виснет — там `network: h2` (TCP:443) |
|
||||
|
||||
Подробные отчёты — в [`SPECS/002-…`](SPECS/002-XHTTP_CLIENT_TRANSPORT/IMPLEMENTATION_REPORT.md), [`SPECS/003-…`](SPECS/003-AWG2_CLIENT_ENDPOINT/IMPLEMENTATION_REPORT.md) и [`SPECS/009-…`](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/IMPLEMENTATION_REPORT.md). Полный справочник конфига — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**.
|
||||
|
||||
> **Не поддерживается (слой Reality, отложено):** post-quantum Reality (`pqv` / ML-DSA-65) и `spiderX` из Xray. Это Xray-специфичные фичи Reality, которых нет в sing-box, а Reality — upstream-слой TLS, который мы держим нетронутым (это не одна из наших фич). Классический X25519 Reality работает; сервер, который **требует** post-quantum Reality, не подключится. Это ограничение sing-box — правильнее решать в upstream (получим на ребейзе).
|
||||
|
||||
---
|
||||
|
||||
## Сборка
|
||||
|
||||
Сборка идёт через отдельный **`Makefile.lx`** (upstream `Makefile` не трогаем):
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/Leadaxe/sing-box-lx
|
||||
make -f Makefile.lx lx-build
|
||||
# → бинарь ./sing-box с версией вида 1.13.13-lx.1
|
||||
```
|
||||
|
||||
> `--recurse-submodules` обязателен для `with_awg`: рантайм AmneziaWG подключён submodule'ом `submodules/wireguard-go` → [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx).
|
||||
|
||||
Под капотом — стандартный `go build` с набором тегов (единственный источник истины — `make -f Makefile.lx lx-print-tags`):
|
||||
|
||||
```
|
||||
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg
|
||||
```
|
||||
|
||||
Это клиентский feature-set upstream **минус** серверные/нерелевантные теги — `with_acme` (серверный выпуск сертов), `with_tailscale`, `with_ccm`/`with_ocm` (AI-прокси) — **плюс** `with_purego` (CGO-free кросс-сборка, чтобы `with_naive_outbound`/cronet собирался при `CGO=0` на любом desktop-таргете, кроме Windows 7 / 32-бит legacy-сборки, где naive выкинут — у `cronet-go` нет windows/386) и наши фичи `with_xhttp` / `with_awg`. Всё остальное — ровно как upstream.
|
||||
|
||||
Проверка конфигов:
|
||||
|
||||
```bash
|
||||
./sing-box check -c lx-test/config/xhttp_reality.json
|
||||
./sing-box check -c lx-test/config/awg2_basic.json
|
||||
```
|
||||
|
||||
> `lx-test/config/` — наши примеры (upstream `test/` — отдельный Go-модуль, его не используем).
|
||||
|
||||
**Android (`libbox.aar`).** `make lib_install && make lib_android` собирает gomobile-AAR — `libbox.aar` (SDK 23) + `libbox-legacy.aar` (SDK 21) — с зашитыми `with_xhttp`/`with_awg` (и без `tailscale`), для встраивания в Android-приложение-потребитель (нужны NDK r28 + OpenJDK 17). `Libbox.version()` отдаёт `…-lx.N`.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация фич
|
||||
|
||||
> Полные таблицы полей, дефолты и `awg-quick`→JSON маппинг — **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)**. Здесь — кратко.
|
||||
|
||||
### XHTTP (outbound transport)
|
||||
|
||||
```jsonc
|
||||
"transport": {
|
||||
"type": "xhttp",
|
||||
"host": "example.com",
|
||||
"path": "/xhttp",
|
||||
"mode": "auto" // auto | packet-up | stream-up | stream-one
|
||||
}
|
||||
```
|
||||
|
||||
### AmneziaWG 2.0 (endpoint)
|
||||
|
||||
Поля AWG промотированы прямо в `WireGuardEndpointOptions`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "wireguard",
|
||||
// … стандартные поля wireguard (private_key, address, peers, …) …
|
||||
"jc": 10, "jmin": 50, "jmax": 100,
|
||||
"s1": 20, "s2": 20, "s3": 60, "s4": 60,
|
||||
"h1": 1, "h2": 2, "h3": 3, "h4": 4,
|
||||
"i1": "<b 0x...><r 12>", "i2": "", "i3": "", "i4": "", "i5": "" // 2.0 CPS
|
||||
}
|
||||
```
|
||||
|
||||
> `I1–I5` — это конфиг (не согласуется по сети), значения должны **совпадать на клиенте и сервере**, регистрозависимы.
|
||||
|
||||
**Сахар-маскировка (`id`/`ip`/`ib`).** Вместо ручного `i1` задаёшь домен, протокол и
|
||||
браузер — ядро само собирает `I1`-приманку (стиль WireSock). Удобно для упрощения
|
||||
коннекта к **Cloudflare WARP**:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "wireguard",
|
||||
// … стандартные поля wireguard …
|
||||
"id": "www.google.com", "ip": "quic", "ib": "chrome" // quic: id идёт как SNI в ClientHello
|
||||
// или: "ip": "dns", "id": "www.google.com" // dns/sip: id идёт как QNAME/host
|
||||
}
|
||||
```
|
||||
|
||||
`ip` ∈ `quic|dns|stun|sip`; `id` обязателен только для `quic` (SNI); для `dns`/`sip` опционален (без него генерится псевдо-имя), `stun` игнорирует. Где задан — идёт на провод (SNI / QNAME / host)
|
||||
и опционален для `sip` (без него генерится псевдо-host) и `stun`; `ib` ∈ `chrome|firefox|curl`
|
||||
(только quic, эффект минимальный — без JA3-fingerprint). Взаимоисключается с явным `i1`.
|
||||
|
||||
Для **`quic`** ядро генерит out-of-order фрагментированный QUIC Initial (RFC 9001) — реальный
|
||||
ClientHello, нарезанный на CRYPTO-фреймы в перемешанном порядке, так что line-rate DPI парсит
|
||||
мусор и пропускает. Раскладка рандомизируется на каждый вызов (нет межюзерной сигнатуры), и
|
||||
`ip=quic` теперь шлёт **два** независимых Initial (i1+i2) — поток читается как развивающаяся
|
||||
QUIC-сессия. Это **единственный профиль, device-проверенный на реальном LTE/WARP DPI** (~330 мс).
|
||||
`dns`/`stun`/`sip` реализованы как корректные клиент-инициированные запросы, но режутся как класс
|
||||
протокола к WARP-edge (raw DNS/STUN/SIP к дата-центровому IP сам по себе аномален) — сохранены
|
||||
для других провайдеров, чей DPI проверяет лишь корректность пакета. См.
|
||||
[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) и [примеры SPECS/009](SPECS/009-WIRESOCK_MASQUERADE_PROFILES/EXAMPLES.md).
|
||||
|
||||
### MASQUE (outbound — Cloudflare WARP)
|
||||
|
||||
Outbound `masque` туннелирует целые IP-пакеты через **CONNECT-IP (RFC 9484)**, HTTP/3 или HTTP/2,
|
||||
к **Cloudflare WARP**. Не путать с AWG-сахаром *masquerade* `id/ip/ib` выше — разные фичи, одно слово.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "masque",
|
||||
"tag": "warp",
|
||||
"server": "162.159.198.2",
|
||||
"server_port": 443,
|
||||
"profile": "cloudflare", // cloudflare (WARP) | standard (RFC 9484)
|
||||
"network": "h3", // ТРАНСПОРТ: h3 (QUIC) | h2 (HTTP/2). НЕ tcp/udp — это network_list
|
||||
"sni": "www.microsoft.com", // domain-fronting; endpoint аутентифицируется пиннингом public key, не по SNI
|
||||
"private_key": "<base64 DER EC>",
|
||||
"public_key": "<base64 DER PKIX>",
|
||||
"ip": "172.16.0.2/32", "ipv6": "2606:4700:110:...::/128"
|
||||
}
|
||||
```
|
||||
|
||||
Ключевой материал (`private_key`/`public_key`/`ip`/`ipv6`) берётся готовым из конфига — регистрацию
|
||||
устройства в WARP делает клиент. На сетях, режущих входящий UDP:443, `h3`-handshake виснет —
|
||||
переключите узел на `network: h2` (TCP:443). Полный справочник —
|
||||
[docs-lx/lx-config.ru.md §4](docs-lx/lx-config.ru.md) и [SPECS/021](SPECS/021-MASQUE_CONNECT_IP_OUTBOUND/CONFIG.md).
|
||||
|
||||
---
|
||||
|
||||
## Модель сопровождения
|
||||
|
||||
```
|
||||
upstream tag (vX.Y.Z)
|
||||
│
|
||||
└─► ветка lx = upstream + N атомарных // lx-коммитов
|
||||
├─ FORK_BOOTSTRAP (Makefile.lx, CI, версия)
|
||||
├─ XHTTP client transport
|
||||
├─ AWG2 client endpoint
|
||||
└─ … (новые фичи — такими же атомарными // lx-коммитами)
|
||||
```
|
||||
|
||||
- **Только ребейз, никогда merge.** На новый upstream-тег ветка `lx` ребейзится поверх него.
|
||||
- Каждая фича — атомарный коммит(ы), помеченный `// lx`. Новые файлы конфликтов не дают; швы в upstream-файлах малы и переносятся вручную.
|
||||
- Разработка ведётся по **Spec Kit** (`SPECS/NNN-T-S-NAME/`: SPEC → PLAN → TASKS → IMPLEMENTATION_REPORT).
|
||||
|
||||
### Remotes
|
||||
|
||||
```bash
|
||||
origin git@github.com:Leadaxe/sing-box-lx.git # ветка по умолчанию: lx
|
||||
upstream https://github.com/SagerNet/sing-box.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура lx-специфики
|
||||
|
||||
| Путь | Назначение |
|
||||
|------|------------|
|
||||
| `Makefile.lx` | сборка с lx-тегами и версией `-lx` |
|
||||
| `.github/workflows/lx-ci.yml` | CI: матрица фич (baseline/xhttp/awg/full) + negative-check + кросс-платформа + android AAR |
|
||||
| `.github/workflows/lx-release.yml` | релиз на `v*-lx.*`: desktop ×6 + `libbox.aar` → GitHub Release |
|
||||
| `SPECS/` | Spec Kit (конституция, задачи, отчёты) |
|
||||
| `lx-test/config/` | примеры конфигов для `sing-box check` |
|
||||
| `transport/v2rayxhttp/` | XHTTP-клиент (новый пакет) |
|
||||
| `transport/wireguard/device_awg.go` | AWG IpcSet-параметры (за `with_awg`) |
|
||||
| `submodules/wireguard-go` | submodule: merged-форк AmneziaWG-рантайма ([Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx)) |
|
||||
| `option/v2ray_xhttp.go`, `option/wireguard_awg.go` | опции фич |
|
||||
| `include/v2rayxhttp.go` | регистрация транспорта за build-tag |
|
||||
|
||||
Поиск всех правок upstream-файлов: `grep -rn "// lx"`.
|
||||
|
||||
---
|
||||
|
||||
## Потребитель
|
||||
|
||||
Ядро собирается для десктоп-лаунчера **singbox-launcher** (бандлит `bin/sing-box`). На Android потребитель встраивает **`libbox.aar`** (gomobile) вместо бинаря — конфиг-JSON тот же. Маппинг `type=xhttp` и AWG-полей в визарде — задачи на стороне потребителя, не здесь.
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Upstream | [SagerNet/sing-box](https://github.com/SagerNet/sing-box) · [документация](https://sing-box.sagernet.org/) |
|
||||
| Этот форк | [Leadaxe/sing-box-lx](https://github.com/Leadaxe/sing-box-lx) |
|
||||
| AmneziaWG-рантайм | [Leadaxe/wireguard-go-awg2-lx](https://github.com/Leadaxe/wireguard-go-awg2-lx) — sagernet-база + обфускация (3-way merge) |
|
||||
| AmneziaWG upstream | [amnezia-vpn/amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) · [docs.amnezia.org](https://docs.amnezia.org/documentation/amnezia-wg/) |
|
||||
| XHTTP (исток) | [XTLS/Xray-core](https://github.com/XTLS/Xray-core) — `transport/internet/splithttp` |
|
||||
| Конфиг фич | [docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md) |
|
||||
| Spec Kit | [SPECS/](SPECS/) — [README](SPECS/README.md) · [CONSTITUTION](SPECS/CONSTITUTION.md) · [IMPLEMENTATION_PROMPT](SPECS/IMPLEMENTATION_PROMPT.md) |
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Наследует лицензию upstream sing-box (**GPL-3.0**). Все правки помечены `// lx` и распространяются под той же лицензией. Это неофициальный форк, не аффилирован с SagerNet.
|
||||
> Файл оставлен как указатель, чтобы у репозитория был один основной русский
|
||||
> README (`README.md`), а не два конкурирующих.
|
||||
|
||||
@@ -3,7 +3,33 @@
|
||||
| Поле | Значение |
|
||||
|------|----------|
|
||||
| Тип | B (bug) |
|
||||
| Статус | C (complete) |
|
||||
| Статус | C (complete) — guard **снят** (см. баннер ниже) |
|
||||
|
||||
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
|
||||
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
|
||||
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
|
||||
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
|
||||
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
|
||||
>
|
||||
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
|
||||
> возвращал управление). Android для shater не платформа и ей не станет —
|
||||
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
|
||||
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
|
||||
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
|
||||
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
|
||||
> на его применение.
|
||||
>
|
||||
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
|
||||
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
|
||||
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
|
||||
> отклонённая, а как «нода почему-то не работает».
|
||||
>
|
||||
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
|
||||
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
|
||||
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
|
||||
> тест краснел.
|
||||
>
|
||||
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
|
||||
|
||||
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
|
||||
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// lx:begin l3-honest-drop
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-tun"
|
||||
"github.com/sagernet/sing-tun/gtcpip/header"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// judgeFlowRouter answers PreMatch with a canned verdict; JudgeFlow reads
|
||||
// nothing else off the Router.
|
||||
type judgeFlowRouter struct {
|
||||
Router
|
||||
result PreMatchResult
|
||||
}
|
||||
|
||||
func (r *judgeFlowRouter) PreMatch(InboundContext, []byte) PreMatchResult { return r.result }
|
||||
|
||||
// judgeFlowPort is the tun.Port half of a FlowOutbound. inet4 is what
|
||||
// PortAddresses reports for IPv4 — the one field the two ICMP consumers in
|
||||
// sing-tun disagree about (see the comment on
|
||||
// TestJudgeFlowICMPToBoundPortStaysAFlow).
|
||||
type judgeFlowPort struct {
|
||||
Outbound
|
||||
inet4 netip.Addr
|
||||
}
|
||||
|
||||
func (o *judgeFlowPort) Tag() string { return "wg-out" }
|
||||
func (o *judgeFlowPort) Type() string { return "wireguard" }
|
||||
func (o *judgeFlowPort) PortAddresses() (netip.Addr, netip.Addr) {
|
||||
return o.inet4, netip.Addr{}
|
||||
}
|
||||
func (o *judgeFlowPort) PortMTU() uint32 { return 1420 }
|
||||
func (o *judgeFlowPort) AttachReturn(tun.Return) error { return nil }
|
||||
func (o *judgeFlowPort) DetachReturn(tun.Return) error { return nil }
|
||||
func (o *judgeFlowPort) WritePackets(packets [][]byte) error { return nil }
|
||||
|
||||
// judgeFlowNonPort is a FlowOutbound-shaped result that is NOT a tun.Port — the
|
||||
// interface drift the second line of defense in JudgeFlow exists for.
|
||||
type judgeFlowNonPort struct {
|
||||
Outbound
|
||||
}
|
||||
|
||||
func (o *judgeFlowNonPort) Tag() string { return "drifted" }
|
||||
func (o *judgeFlowNonPort) Type() string { return "drifted" }
|
||||
|
||||
func judgeFlow(t *testing.T, protocol uint8, result PreMatchResult) tun.FlowVerdict {
|
||||
t.Helper()
|
||||
return JudgeFlow(
|
||||
&judgeFlowRouter{result: result},
|
||||
"l3-in", "tun", protocol,
|
||||
netip.MustParseAddrPort("192.168.1.2:1234"),
|
||||
netip.MustParseAddrPort("1.1.1.1:1234"),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
judgeFlowICMP = uint8(header.ICMPv4ProtocolNumber)
|
||||
judgeFlowTCP = uint8(header.TCPProtocolNumber)
|
||||
)
|
||||
|
||||
// TestJudgeFlowICMPToBoundPortStaysAFlow is the guard on the ONE fix that must
|
||||
// not be made here.
|
||||
//
|
||||
// sing-tun has two ICMP consumers with different requirements on the port:
|
||||
//
|
||||
// - ForwardDispatcher.createFlow (flow_dispatch.go) needs only a VALID port
|
||||
// address — it NATs the echo identifier and rewrites the source to that
|
||||
// address. This is the path every unfragmented LAN ping takes, and it is
|
||||
// what makes ping-through-WireGuard/AWG work at all.
|
||||
// - ICMPForwarder.installFlow (stack_gvisor_icmp.go) additionally requires the
|
||||
// address to be UNSPECIFIED, because it writes the packet to the port
|
||||
// unmodified. A WireGuard endpoint reports its concrete interface address
|
||||
// (transport/wireguard/port.go), so installFlow declines and HandlePacket
|
||||
// falls through to forging the echo reply.
|
||||
//
|
||||
// The tempting fix — "for ICMP, refuse ActionFlow when PortAddresses() is not
|
||||
// unspecified, so the verdict becomes a drop and the forgery is unreachable" —
|
||||
// is applied HERE, in the one function both consumers share, with byte-identical
|
||||
// arguments from either. It would therefore kill the working path too: every
|
||||
// ping through WireGuard/AWG, fragmented or not, would drop, and l3_tunnel would
|
||||
// carry nothing but `direct`. Keep this test failing loudly if anyone tries.
|
||||
func TestJudgeFlowICMPToBoundPortStaysAFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
port := &judgeFlowPort{inet4: netip.MustParseAddr("10.2.0.2")}
|
||||
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: port})
|
||||
require.Equal(t, tun.ActionFlow, verdict.Action,
|
||||
"ICMP to a WireGuard/AWG endpoint must stay a flow: the forward dispatcher NATs it by echo identifier and this is the whole point of l3_tunnel")
|
||||
require.Same(t, tun.Port(port), verdict.Port)
|
||||
}
|
||||
|
||||
// The `direct` shape: an unspecified port address. Both consumers accept it.
|
||||
func TestJudgeFlowICMPToUnspecifiedPortStaysAFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
port := &judgeFlowPort{inet4: netip.IPv4Unspecified()}
|
||||
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: port})
|
||||
require.Equal(t, tun.ActionFlow, verdict.Action)
|
||||
require.Same(t, tun.Port(port), verdict.Port)
|
||||
}
|
||||
|
||||
// PreMatchDrop is the honest verdict and must arrive as ActionDrop: it is the
|
||||
// only value (besides Reject) that stops ICMPForwarder.HandlePacket before the
|
||||
// Echo -> EchoReply rewrite.
|
||||
func TestJudgeFlowICMPDropReachesTheStackAsDrop(t *testing.T) {
|
||||
t.Parallel()
|
||||
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchDrop})
|
||||
require.Equal(t, tun.ActionDrop, verdict.Action)
|
||||
}
|
||||
|
||||
// The second line of defense: a PreMatchFlow whose outbound is not a tun.Port
|
||||
// must not degrade ICMP to ActionAccept, because Accept is the forged reply.
|
||||
func TestJudgeFlowICMPNonPortOutboundDrops(t *testing.T) {
|
||||
t.Parallel()
|
||||
verdict := judgeFlow(t, judgeFlowICMP, PreMatchResult{Action: PreMatchFlow, Outbound: &judgeFlowNonPort{}})
|
||||
require.Equal(t, tun.ActionDrop, verdict.Action,
|
||||
"FlowOutbound and tun.Port are distinct interfaces; a drift between them must not silently re-enable the echo forger")
|
||||
}
|
||||
|
||||
func TestJudgeFlowTCPNonPortOutboundAccepts(t *testing.T) {
|
||||
t.Parallel()
|
||||
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchFlow, Outbound: &judgeFlowNonPort{}})
|
||||
require.Equal(t, tun.ActionAccept, verdict.Action,
|
||||
"for TCP, falling back to Accept is upstream behaviour and must stay untouched")
|
||||
}
|
||||
|
||||
// TCP keeps every mapping it had, including the Continue -> Accept default that
|
||||
// is a forgery only for ICMP.
|
||||
func TestJudgeFlowTCPContinueStaysAccept(t *testing.T) {
|
||||
t.Parallel()
|
||||
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchContinue})
|
||||
require.Equal(t, tun.ActionAccept, verdict.Action)
|
||||
}
|
||||
|
||||
func TestJudgeFlowTCPBypassStaysBypass(t *testing.T) {
|
||||
t.Parallel()
|
||||
verdict := judgeFlow(t, judgeFlowTCP, PreMatchResult{Action: PreMatchBypass})
|
||||
require.Equal(t, tun.ActionBypass, verdict.Action)
|
||||
}
|
||||
|
||||
// lx:end l3-honest-drop
|
||||
@@ -45,30 +45,8 @@ type OutboundManager interface {
|
||||
Default() Outbound
|
||||
Remove(tag string) error
|
||||
Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error
|
||||
// lx:begin awg
|
||||
// ConsumersOf returns the tags of outbounds that depend on (detour through)
|
||||
// the given tag — the reverse of Dependencies(). Used by the selector guard to
|
||||
// walk up to AmneziaWG consumers when a group switches to a WireGuard member.
|
||||
ConsumersOf(tag string) []string
|
||||
// lx:end awg
|
||||
}
|
||||
|
||||
// lx:begin awg
|
||||
// AmneziaWGSuspendable is implemented by an AmneziaWG endpoint so the selector
|
||||
// guard can suspend it (bring its device down) when a group it detours through
|
||||
// switches to a WireGuard member — AmneziaWG inside a WireGuard tunnel hangs the
|
||||
// kernel on Android. The marker lives in adapter so protocol/group can act on it
|
||||
// without importing protocol/wireguard.
|
||||
type AmneziaWGSuspendable interface {
|
||||
// IsAmneziaWG reports whether this endpoint runs AmneziaWG (has AWG params).
|
||||
IsAmneziaWG() bool
|
||||
// SuspendAmneziaWG brings the device down so no junk handshake is sent. It is
|
||||
// idempotent and safe to call on a not-yet-started or already-suspended endpoint.
|
||||
SuspendAmneziaWG()
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
|
||||
// lx:begin idle-suspend
|
||||
// IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick
|
||||
// (SPEC 020) can suspend it when it is idle and unreachable, without importing
|
||||
|
||||
@@ -208,21 +208,6 @@ func (m *Manager) Outbound(tag string) (adapter.Outbound, bool) {
|
||||
return m.endpoint.Get(tag)
|
||||
}
|
||||
|
||||
// lx:begin awg
|
||||
// ConsumersOf returns a copy of the tags that detour through tag (reverse of
|
||||
// Dependencies()), built from the dependByTag ledger populated at Create time.
|
||||
func (m *Manager) ConsumersOf(tag string) []string {
|
||||
m.access.RLock()
|
||||
defer m.access.RUnlock()
|
||||
consumers := m.dependByTag[tag]
|
||||
if len(consumers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), consumers...)
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
|
||||
func (m *Manager) Default() adapter.Outbound {
|
||||
m.access.RLock()
|
||||
defer m.access.RUnlock()
|
||||
|
||||
@@ -75,7 +75,18 @@ func JudgeFlow(router Router, inbound string, inboundType string, network uint8,
|
||||
case PreMatchFlow:
|
||||
port, isPort := result.Outbound.(tun.Port)
|
||||
if !isPort {
|
||||
// lx:begin l3-honest-drop
|
||||
// Second line of defense behind route.(*Router).preMatchFlow: a
|
||||
// PreMatchFlow result already implies the outbound is an
|
||||
// adapter.FlowOutbound, but FlowOutbound and tun.Port are distinct
|
||||
// interfaces, and a drift between them must not degrade ICMP to
|
||||
// ActionAccept — the TUN stack would then forge the echo reply
|
||||
// itself instead of admitting the tunnel cannot carry the packet.
|
||||
if networkName == N.NetworkICMP {
|
||||
return tun.FlowVerdict{Action: tun.ActionDrop}
|
||||
}
|
||||
return tun.FlowVerdict{Action: tun.ActionAccept}
|
||||
// lx:end l3-honest-drop
|
||||
}
|
||||
verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker}
|
||||
if result.Destination.IsValid() {
|
||||
|
||||
Binary file not shown.
+33
-17
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (the 25.12
|
||||
# lane — additive next to ci/build-feed.sh, which stays the opkg/24.10 lane).
|
||||
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (25.12+;
|
||||
# the only packaging lane shater has — see docs-shater/DECISIONS.md D22).
|
||||
#
|
||||
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
|
||||
# e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \
|
||||
@@ -10,14 +10,14 @@
|
||||
# This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls.
|
||||
# It runs on the CI RUNNER and:
|
||||
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
|
||||
# scripts/build-shaterd.sh (same artifact-order contract as the opkg lane);
|
||||
# 2. drives a plain `debian:bookworm` container (workspace shared via
|
||||
# `--volumes-from`, same trick as ci/build-feed.sh) that downloads the
|
||||
# ImmortalWrt 25.12 apk-SDK tarball and runs ci/sdk-build-apk.sh in it:
|
||||
# compile the 4 packages as .apk, then `apk mkndx --sign` the per-arch
|
||||
# `packages.adb` index. Unlike the usign lane (index signed on the runner),
|
||||
# apk indexing NEEDS the SDK's host `apk` tool, so index+sign happen inside
|
||||
# the container.
|
||||
# scripts/build-shaterd.sh (the artifact-order contract);
|
||||
# 2. drives a plain `debian:bookworm` container (the job's workspace volume is
|
||||
# shared into it with `--volumes-from $(hostname)`; a bare `-v $PWD:...`
|
||||
# points at a host path that does not exist under act_runner's DinD) that
|
||||
# downloads the ImmortalWrt 25.12 apk-SDK tarball and runs
|
||||
# ci/sdk-build-apk.sh in it: compile the 4 packages as .apk, then
|
||||
# `apk mkndx --sign` the per-arch `packages.adb` index. Indexing NEEDS the
|
||||
# SDK's host `apk` tool, so index+sign happen inside the container.
|
||||
#
|
||||
# Why the ImmortalWrt SDK (not openwrt/sdk images): the 25.12 fleet runs
|
||||
# BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base (target mediatek/filogic,
|
||||
@@ -25,9 +25,9 @@
|
||||
# mediatek-filogic 25.12 tag — hence the official SDK tarball.
|
||||
#
|
||||
# Env:
|
||||
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret — the apk analog
|
||||
# of KEY_BUILD). If set, packages.adb carries an embedded signature
|
||||
# verifiable by dist/shater-apk.pem (routers: /etc/apk/keys/).
|
||||
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret). If set,
|
||||
# packages.adb carries an embedded signature verifiable by
|
||||
# dist/shater-apk.pem (routers: /etc/apk/keys/).
|
||||
# If unset, an UNSIGNED index is produced (warning; not shippable —
|
||||
# apk signatures are effectively mandatory).
|
||||
set -eu
|
||||
@@ -55,6 +55,15 @@ fi
|
||||
|
||||
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
|
||||
|
||||
# --- 0.4) package version from the git tag ------------------------------------
|
||||
# The workflow puts these in the job env via `ci/version.sh --env >>
|
||||
# $GITHUB_ENV`; recompute here when run standalone. Passed into the container
|
||||
# below and re-exported to the unprivileged build user in ci/sdk-build-apk.sh.
|
||||
if [ -z "${SHATER_PKG_VERSION:-}" ] || [ -z "${SHATER_PKG_RELEASE:-}" ]; then
|
||||
eval "$(sh "$REPO/ci/version.sh" --env)"
|
||||
fi
|
||||
echo "[apk-feed] package version: ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
|
||||
|
||||
# --- 0.5) runner-side caches --------------------------------------------------
|
||||
# All under $REPO/.cache so (a) actions/cache in the workflow can persist them
|
||||
# between runs and (b) the nested container sees them via --volumes-from.
|
||||
@@ -63,7 +72,7 @@ chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
|
||||
# SDK; PKG_HASH still verifies every file, so stale = re-downloaded.
|
||||
# apt/ debian:bookworm .deb archives for the host-deps install.
|
||||
# The nested container runs the build as an unprivileged user -> must be writable
|
||||
# (same reason as the chmod 0777 "$OUT" in ci/build-feed.sh).
|
||||
# (same reason as the chmod 0777 "$OUT" above).
|
||||
CACHE="$REPO/.cache"
|
||||
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
|
||||
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
|
||||
@@ -86,20 +95,27 @@ sh "$REPO/ci/fetch-sdk.sh" "$SDK_URL" "$SDK_TAR"
|
||||
|
||||
# --- 1) SDK build + index + sign inside a debian container -------------------
|
||||
# `--volumes-from $(hostname)` shares THIS job container's workspace volume into
|
||||
# the nested container (see ci/build-feed.sh for why a bare -v does not work on
|
||||
# the act_runner DinD setup).
|
||||
# the nested container: a bare `-v $PWD:...` points at a host path that does not
|
||||
# exist under the act_runner DinD setup.
|
||||
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
|
||||
docker pull -q debian:bookworm
|
||||
docker run --rm --volumes-from "$(hostname)" \
|
||||
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e SDK_URL="$SDK_URL" \
|
||||
-e SDK_TAR="$SDK_TAR" -e DL_DIR="$CACHE/dl" -e APT_CACHE="$CACHE/apt" \
|
||||
-e FEEDS_CACHE="$FEEDS_CACHE" -e KEY_APK="${KEY_APK:-}" \
|
||||
-e SHATER_PKG_VERSION="$SHATER_PKG_VERSION" \
|
||||
-e SHATER_PKG_RELEASE="$SHATER_PKG_RELEASE" \
|
||||
debian:bookworm bash "$REPO/ci/sdk-build-apk.sh"
|
||||
|
||||
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
|
||||
[ -s "$OUT/packages.adb" ] || { echo "[apk-feed] ERROR: $OUT/packages.adb missing/empty" >&2; exit 4; }
|
||||
apks=$(find "$OUT" -maxdepth 1 -name '*.apk' | wc -l)
|
||||
[ "$apks" -ge 4 ] || { echo "[apk-feed] ERROR: expected >=4 .apk in $OUT, found $apks" >&2; exit 5; }
|
||||
# Three, since D29 removed byedpi: shaterd, shater-core, luci-app-shater. The
|
||||
# count lives in TWO scripts — sdk-build-apk.sh asserts what it collected out of
|
||||
# bin/, this one asserts what reached the feed dir. v0.2.22 shipped with only the
|
||||
# first one updated and the aarch64 lane died here on `found 3`, so if the set of
|
||||
# packages ever changes again, change it in both.
|
||||
[ "$apks" -ge 3 ] || { echo "[apk-feed] ERROR: expected >=3 .apk in $OUT, found $apks" >&2; exit 5; }
|
||||
if [ -n "${KEY_APK:-}" ] && [ ! -s "$OUT/shater-apk.pem" ]; then
|
||||
echo "[apk-feed] ERROR: signed feed but shater-apk.pem missing from $OUT" >&2; exit 6
|
||||
fi
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ci/build-feed.sh — build the signed opkg feed for ONE arch.
|
||||
#
|
||||
# Usage: ci/build-feed.sh <ARCH> <SDK_DOCKER_TAG> <OUTDIR>
|
||||
# e.g. ci/build-feed.sh x86_64 x86_64-24.10.4 out/x86_64
|
||||
# ci/build-feed.sh aarch64_cortex-a53 mediatek-filogic-24.10.4 out/aarch64_cortex-a53
|
||||
#
|
||||
# This is the reusable per-arch entrypoint the Gitea workflow calls. It runs on
|
||||
# the CI RUNNER and:
|
||||
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
|
||||
# scripts/build-shaterd.sh (into openwrt/shaterd/files/) — proving artifact
|
||||
# order: SPA+shaterd build BEFORE the SDK package build;
|
||||
# 2. drives the arch-matched `openwrt/sdk` docker image to compile all 4
|
||||
# packages (ci/sdk-build.sh) and collect their .ipk into OUTDIR;
|
||||
# 3. builds + usign-signs the opkg `Packages` index over OUTDIR
|
||||
# (ci/install-usign.sh + ci/make-index.sh; signs iff $KEY_BUILD is set).
|
||||
#
|
||||
# Env:
|
||||
# KEY_BUILD usign SECRET key (Gitea repo secret). If set, the feed index is
|
||||
# signed and verifiable by dist/shater-feed.pub (fp 5ac4b177689cb8e0).
|
||||
# If unset, an UNSIGNED feed is produced (make-index warns).
|
||||
set -eu
|
||||
|
||||
ARCH="${1:?arch required (x86_64 | aarch64_cortex-a53)}"
|
||||
SDK_TAG="${2:?sdk docker tag required (e.g. x86_64-24.10.4)}"
|
||||
OUT="${3:?output dir required}"
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
mkdir -p "$OUT"; OUT="$(cd "$OUT" && pwd)"
|
||||
# $OUT is created here as ROOT on the runner, but the nested `openwrt/sdk`
|
||||
# container runs as the unprivileged `buildbot` (uid 1000) — so it must be able
|
||||
# to write the collected .ipk into $OUT. World-writable is set HERE (a chmod
|
||||
# from inside the container, as buildbot, cannot fix a root-owned dir).
|
||||
chmod 0777 "$OUT"
|
||||
|
||||
# --- 0) the prebuilt shaterd binary must already be staged for this arch ------
|
||||
case "$ARCH" in
|
||||
x86_64) sfx=amd64 ;;
|
||||
aarch64_cortex-a53) sfx=arm64 ;;
|
||||
*) echo "[feed] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
|
||||
esac
|
||||
if [ ! -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" ]; then
|
||||
echo "[feed] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
|
||||
echo " Run scripts/build-shaterd.sh BEFORE ci/build-feed.sh." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
|
||||
|
||||
# --- 0.5) persistent dl/ (package source tarballs) ----------------------------
|
||||
# Workspace dir restored/saved by actions/cache in the workflow and shared into
|
||||
# the nested SDK container via --volumes-from; becomes CONFIG_DOWNLOAD_FOLDER
|
||||
# there (ci/sdk-build.sh). PKG_HASH still verifies every file, so a stale cache
|
||||
# can never produce a wrong build. Must be writable by the container's
|
||||
# unprivileged buildbot user (same reason as the $OUT chmod above).
|
||||
DL_DIR="$REPO/.cache/dl"
|
||||
mkdir -p "$DL_DIR"
|
||||
chmod -R a+rwX "$DL_DIR" 2>/dev/null || true
|
||||
|
||||
# --- 0.6) persistent feeds/ git checkouts -------------------------------------
|
||||
# Workspace dir restored/saved by actions/cache (key: feeds-opkg-<release>) and
|
||||
# symlinked over the SDK's feeds/ inside the container (ci/sdk-build.sh), so
|
||||
# `scripts/feeds update -a` fetches deltas instead of re-cloning base+packages+
|
||||
# luci from scratch (~7 min/run on this runner's slow github.com link).
|
||||
# Top-level chmod only: the contents are created by the container's uid-1000
|
||||
# build user and restored with the same ownership (tar-as-root preserves it).
|
||||
FEEDS_CACHE="$REPO/.cache/feeds/opkg"
|
||||
mkdir -p "$FEEDS_CACHE"
|
||||
chmod a+rwX "$REPO/.cache" "$REPO/.cache/feeds" "$FEEDS_CACHE" 2>/dev/null || true
|
||||
|
||||
# --- 1) SDK package build (4 packages) in the arch-matched SDK image ----------
|
||||
# We drive the `openwrt/sdk` docker image directly (not openwrt/gh-action-sdk):
|
||||
# on a self-hosted Gitea act_runner the marketplace action fetch can be
|
||||
# unavailable, and we need a CLEAN single-feed layout. `--volumes-from
|
||||
# $(hostname)` shares THIS job container's workspace volume into the nested SDK
|
||||
# container — a bare `-v $PWD:...` points at a host path that does not exist
|
||||
# under the act_runner DinD setup. (Requires the job to run inside a container,
|
||||
# which Gitea Actions does by default.)
|
||||
echo "[feed] SDK build arch=$ARCH image=openwrt/sdk:$SDK_TAG"
|
||||
docker pull "openwrt/sdk:$SDK_TAG"
|
||||
docker run --rm --volumes-from "$(hostname)" \
|
||||
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e DL_DIR="$DL_DIR" \
|
||||
-e FEEDS_CACHE="$FEEDS_CACHE" \
|
||||
"openwrt/sdk:$SDK_TAG" \
|
||||
sh "$REPO/ci/sdk-build.sh"
|
||||
|
||||
# --- 2) index + sign the per-arch feed (usign, KEY_BUILD passed through) -------
|
||||
sh "$REPO/ci/install-usign.sh"
|
||||
KEY_BUILD="${KEY_BUILD:-}" bash "$REPO/ci/make-index.sh" "$OUT"
|
||||
|
||||
echo "[feed] done arch=$ARCH -> $OUT"
|
||||
ls -l "$OUT"
|
||||
+7
-10
@@ -2,24 +2,21 @@
|
||||
# ci/gen-apk-key.sh — generate the Shater **apk** feed signing keypair (25.12 lane).
|
||||
#
|
||||
# apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys
|
||||
# (prime256v1 PEM), NOT usign — the existing usign identity
|
||||
# (dist/shater-feed.pub, fp 5ac4b177689cb8e0) keeps signing the opkg/24.10 feed
|
||||
# and is NOT touched by this script. This generates a SEPARATE, second identity:
|
||||
# (prime256v1 PEM). This is the ONLY feed identity shater has since the opkg
|
||||
# lane was removed (D22) — the old usign key is history, not a second lane.
|
||||
#
|
||||
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
|
||||
# Paste its full PEM contents into the Gitea repo secret
|
||||
# KEY_APK (the apk analog of the usign secret KEY_BUILD).
|
||||
# Then delete the local file (or keep it in a password
|
||||
# manager as the offline backup — losing it means every
|
||||
# deployed router must re-trust a new key).
|
||||
# dist/shater-apk.pem PUBLIC key. Commit it next to shater-feed.pub:
|
||||
# KEY_APK. Then delete the local file (or keep it in a
|
||||
# password manager as the offline backup — losing it
|
||||
# means every deployed router must re-trust a new key).
|
||||
# dist/shater-apk.pem PUBLIC key. Commit it:
|
||||
# git add -f dist/shater-apk.pem
|
||||
# (-f because /dist/ is gitignored). Routers install it
|
||||
# as /etc/apk/keys/shater-apk.pem.
|
||||
#
|
||||
# Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of
|
||||
# every router that already installed shater-apk.pem (same rule as D7 for the
|
||||
# usign key).
|
||||
# every router that already installed shater-apk.pem (see D22).
|
||||
set -eu
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Make `usign` available on the CI runner so ci/make-index.sh can sign the opkg
|
||||
# feed index. The OpenWrt SDK ships usign, but the index/signing step runs on the
|
||||
# bare runner (outside the SDK container), so we build the tiny standalone tool
|
||||
# from source (no libubox — it is intentionally dependency-free so it can
|
||||
# bootstrap a build system). No-op if usign is already on PATH.
|
||||
#
|
||||
# Ported unchanged from Shater v0.1 (ci/install-usign.sh): usign is
|
||||
# format-agnostic and the signing story is identical for the v0.2 4-package feed.
|
||||
#
|
||||
# CI cache: a previously-built binary is reused from $USIGN_CACHE (default:
|
||||
# <repo>/.cache/tools — a workspace dir the workflow persists via actions/cache),
|
||||
# skipping the apt + cmake + clone + build (~1 min). After a fresh build the
|
||||
# binary is copied there so the NEXT run hits the cache. usign is a tiny static
|
||||
# helper with no versioned protocol — a stale cached binary cannot mis-sign.
|
||||
set -eu
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TOOLS="${USIGN_CACHE:-$REPO_ROOT/.cache/tools}"
|
||||
|
||||
# place <binary> — install onto PATH (system-wide if we can, else ~/bin)
|
||||
place() {
|
||||
local SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
|
||||
if $SUDO install -m0755 "$1" /usr/local/bin/usign 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
mkdir -p "$HOME/bin"
|
||||
install -m0755 "$1" "$HOME/bin/usign"
|
||||
echo "$HOME/bin" >> "${GITHUB_PATH:-/dev/null}"
|
||||
export PATH="$HOME/bin:$PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v usign >/dev/null 2>&1; then
|
||||
echo "[usign] already present: $(command -v usign)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -x "$TOOLS/usign" ]; then
|
||||
place "$TOOLS/usign"
|
||||
echo "[usign] restored from cache: $(command -v usign || echo "$HOME/bin/usign")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
|
||||
if ! command -v cmake >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq cmake gcc git
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
# Canonical source; fall back to the GitHub mirror if git.openwrt.org is flaky.
|
||||
git clone --depth 1 https://git.openwrt.org/project/usign.git "$tmp/usign" \
|
||||
|| git clone --depth 1 https://github.com/openwrt/usign.git "$tmp/usign"
|
||||
( cd "$tmp/usign" && cmake -DCMAKE_BUILD_TYPE=Release . >/dev/null && make >/dev/null )
|
||||
|
||||
place "$tmp/usign/usign"
|
||||
# seed the cache for the next run (best-effort)
|
||||
mkdir -p "$TOOLS" 2>/dev/null && install -m0755 "$tmp/usign/usign" "$TOOLS/usign" 2>/dev/null || true
|
||||
echo "[usign] built: $(command -v usign || echo "$HOME/bin/usign")"
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the opkg feed index (Packages + Packages.gz) with SHA256 for a dir of
|
||||
# .ipk files, then optionally usign-sign it if $KEY_BUILD (the Gitea repo secret)
|
||||
# is set and usign is present. Arg $1 = feed dir.
|
||||
#
|
||||
# Ported from Shater v0.1 (ci/make-index.sh), unchanged. It is package-count and
|
||||
# package-name agnostic: it indexes whatever .ipk are in the dir, so it serves
|
||||
# BOTH the per-arch feed built by ci/build-feed.sh AND the combined release feed
|
||||
# assembled in the release job (shaterd + byedpi per-arch, shater-core +
|
||||
# luci-app-shater = _all). opkg filters by Architecture at install time, so one
|
||||
# combined URL serves every device.
|
||||
#
|
||||
# Feed format: opkg `src/gz` (.ipk + text Packages index, usign signature).
|
||||
# OpenWrt 24.10 (our SDK) still uses opkg; apk arrives at 25.12. The committed
|
||||
# trust anchor dist/shater-feed.pub is a usign (Ed25519) key, matching this.
|
||||
set -e
|
||||
OUT="${1:?feed dir required}"; cd "$OUT"
|
||||
: > Packages
|
||||
for ipk in *.ipk; do
|
||||
[ -e "$ipk" ] || continue
|
||||
ctrl=$(tar -xzOf "$ipk" ./control.tar.gz | tar -xzO ./control)
|
||||
sz=$(wc -c < "$ipk"); sha=$(sha256sum "$ipk" | cut -d' ' -f1)
|
||||
printf '%s\n' "$ctrl" | sed '/^[[:space:]]*$/d' >> Packages
|
||||
printf 'Filename: %s\nSize: %s\nSHA256sum: %s\n\n' "$ipk" "$sz" "$sha" >> Packages
|
||||
done
|
||||
gzip -kf Packages
|
||||
|
||||
if [ -n "${KEY_BUILD:-}" ]; then
|
||||
# Signing was requested — a missing/broken signer must FAIL the build, not
|
||||
# silently ship an unsigned feed that routers with check_signature on reject.
|
||||
command -v usign >/dev/null 2>&1 || { echo "[index] ERROR: KEY_BUILD set but usign not found" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$KEY_BUILD" > /tmp/usign.sec
|
||||
usign -S -m Packages -s /tmp/usign.sec || { rm -f /tmp/usign.sec; echo "[index] ERROR: usign signing failed" >&2; exit 1; }
|
||||
rm -f /tmp/usign.sec
|
||||
echo "[index] signed -> Packages.sig ($(head -1 Packages.sig))"
|
||||
else
|
||||
echo "[index] no KEY_BUILD -> UNSIGNED feed (opkg needs check_signature off, or set the secret)"
|
||||
fi
|
||||
echo "[index] contents:"; ls -l
|
||||
+254
-11
@@ -10,7 +10,6 @@
|
||||
# the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its
|
||||
# distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is
|
||||
# ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves.
|
||||
# Same --volumes-from workspace-sharing pattern as ci/sdk-build.sh (opkg lane).
|
||||
#
|
||||
# The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs
|
||||
# as an unprivileged `build` user created here.
|
||||
@@ -28,11 +27,16 @@ SDK_URL="${SDK_URL:?SDK_URL env required}"
|
||||
|
||||
echo "[apk-sdk] arch=$ARCH repo=$REPO out=$OUT"
|
||||
echo "[apk-sdk] sdk=$SDK_URL"
|
||||
# Package version derived from the git tag by ci/version.sh (bug B4). Forwarded
|
||||
# to the unprivileged build user on the `su` line at the bottom of this file;
|
||||
# openwrt/{shaterd,shater-core,luci-app-shater}/Makefile pick it up from the
|
||||
# environment. All three are versioned from the tag — there is no exception.
|
||||
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
|
||||
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
||||
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
||||
|
||||
# The prebuilt shaterd artifact must already be staged for this arch (same
|
||||
# contract as the opkg lane — scripts/build-shaterd.sh runs first).
|
||||
# The prebuilt shaterd artifact must already be staged for this arch
|
||||
# (artifact-order contract — scripts/build-shaterd.sh runs first).
|
||||
case "$ARCH" in
|
||||
x86_64) sfx=amd64 ;;
|
||||
aarch64_cortex-a53) sfx=arm64 ;;
|
||||
@@ -108,7 +112,7 @@ export HOME=/home/build
|
||||
cd "$SDKDIR"
|
||||
|
||||
# Register this repo's openwrt/ as a src-link feed named `shater` (absolute
|
||||
# path required) — identical to the opkg lane (ci/sdk-build.sh).
|
||||
# path required).
|
||||
cp -f feeds.conf.default feeds.conf
|
||||
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
|
||||
|
||||
@@ -131,9 +135,104 @@ if ! ./scripts/feeds update -a; then
|
||||
./scripts/feeds update -a
|
||||
fi
|
||||
echo "[apk-sdk] feeds install (prefer shater feed)"
|
||||
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
|
||||
./scripts/feeds install -p shater shaterd shater-core luci-app-shater
|
||||
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
# --- strip the SDK's generated per-package `default m` blocks ----------------
|
||||
# Run 60 settled the question that runs 58 and 59 left open. Writing an explicit
|
||||
# `# CONFIG_PACKAGE_kmod-x is not set` for all 1126 of them and re-running
|
||||
# defconfig deselected exactly nothing: the count came back 1078, unchanged.
|
||||
# Meanwhile the very same explicit form DID stick for CONFIG_ALL/ALL_KMODS/
|
||||
# ALL_NONSHARED. The difference is prompts. kconfig only honours a user value for
|
||||
# a symbol that has one (sym_calc_value ignores S_DEF_USER for a promptless
|
||||
# symbol and falls back to its `default`), and the ALL* symbols carry prompts in
|
||||
# the SDK's own Config.in while these generated blocks are bare:
|
||||
#
|
||||
# config PACKAGE_kmod-mlx5-core
|
||||
# tristate
|
||||
# default m
|
||||
#
|
||||
# So no value we write into .config can ever turn them off — the fix has to
|
||||
# remove the `default m` itself. That is what this does: drop every generated
|
||||
# `config PACKAGE_*` block from the SDK's Config-build.in before the first
|
||||
# defconfig. Nothing is lost by it — these blocks only replay which packages the
|
||||
# BUILDBOT happened to build; the packages themselves are still declared, with
|
||||
# prompts, by the package tree (tmp/.config-package.in), which is what makes our
|
||||
# four selectable and what `select` acts on. KERNEL_*/LIBC/TOOLCHAIN blocks are
|
||||
# left untouched, so the SDK still reproduces its own toolchain settings.
|
||||
CB=$(find . -maxdepth 2 -name 'Config-build.in' -print -quit 2>/dev/null || true)
|
||||
if [ -n "$CB" ] && command -v perl >/dev/null 2>&1; then
|
||||
pkg_before=$(grep -c '^config PACKAGE_' "$CB" || true)
|
||||
# Paragraph-wise delete: a block is `config PACKAGE_x`, its indented body, and
|
||||
# the blank line that ends it. Anchored per-line (/m) so nothing else matches.
|
||||
perl -0777 -pi -e 's/^config PACKAGE_\S+\n(?:[ \t]+\S[^\n]*\n)+\n//gm' "$CB"
|
||||
pkg_after=$(grep -c '^config PACKAGE_' "$CB" || true)
|
||||
echo "[apk-sdk] $CB: stripped $((pkg_before - pkg_after)) generated PACKAGE default blocks ($pkg_before -> $pkg_after)"
|
||||
else
|
||||
echo "[apk-sdk] WARNING: no Config-build.in found (or no perl) — per-package"
|
||||
echo "[apk-sdk] 'default m' blocks stay; the kmod tripwire will catch it"
|
||||
fi
|
||||
|
||||
# --- .config: turn OFF the SDK's mass-select defaults ------------------------
|
||||
# Symptom (v0.2.2, and still v0.2.3 run 58): the SDK ran `apk mkpkg` on ~1100
|
||||
# kmod-* packages — mlx5, amdgpu, ata, isdn, none of which we ship — and died
|
||||
# with `Disk quota exceeded` on the runner's 64 GB ZFS quota. Our kmod deps pull
|
||||
# in `package/kernel/linux/compile`, which packs every module marked =m.
|
||||
#
|
||||
# Why they are =m has nothing to do with anything we write here. An OpenWrt SDK
|
||||
# carries its OWN top-level Config.in (target/sdk/files/Config.in), and it reads:
|
||||
#
|
||||
# config ALL_NONSHARED
|
||||
# bool "Select all target specific packages by default"
|
||||
# default ALL
|
||||
# config ALL_KMODS
|
||||
# bool "Select all kernel module packages by default"
|
||||
# default ALL
|
||||
# config ALL
|
||||
# bool "Select all userspace packages by default"
|
||||
# default y <-- y, not n, and ONLY inside the SDK
|
||||
#
|
||||
# In the main tree those three default to n; the SDK flips ALL to y so that
|
||||
# `make world` in a bare SDK builds something useful. So `make defconfig` on ANY
|
||||
# .config — empty or not — selects the entire kernel. This is stock OpenWrt, not
|
||||
# an ImmortalWrt quirk: openwrt/openwrt's target/sdk/files/Config.in is identical.
|
||||
# (It also means the reference we copied, Slava-Shchipunov/awg-openwrt, builds
|
||||
# every kmod too — it just never hits a disk quota on GitHub's runners.)
|
||||
#
|
||||
# Fix: state all three explicitly. They carry prompts in the SDK's Config.in, so
|
||||
# they are user-settable and an explicit value beats the `default`. Note the FORM:
|
||||
# kconfig writes a false bool as `# CONFIG_X is not set` and `CONFIG_X=n` is not
|
||||
# reliably honoured, so `is not set` is the only form used here. All three are set
|
||||
# rather than just the root `ALL`, so this keeps working whichever symbol a future
|
||||
# SDK makes the root of the chain.
|
||||
# Stash anything the SDK shipped (see below — today there is nothing) and start
|
||||
# from a known-empty file, so what we build here is exactly what we intended.
|
||||
if [ -s .config ]; then mv -f .config .config.sdk; fi
|
||||
: > .config
|
||||
for s in ALL ALL_KMODS ALL_NONSHARED; do
|
||||
echo "# CONFIG_$s is not set" >> .config
|
||||
done
|
||||
|
||||
# About that stash: an SDK tarball ships NO top-level .config (run 58 logged
|
||||
# `grep: .config: No such file or directory` — the only `.config` inside the
|
||||
# tarball is the prebuilt KERNEL's, under the linux dir). This is also why the
|
||||
# first version of this fix was aimed at the wrong thing: there was never a
|
||||
# buildbot .config here to append to. Nothing needs carrying over from it either,
|
||||
# because
|
||||
# target/sdk/Makefile bakes the buildbot's non-package settings — every
|
||||
# CONFIG_KERNEL_* included — into the SDK's generated Config-build.in as kconfig
|
||||
# `default`s (target/sdk/convert-config.pl). defconfig therefore reproduces the
|
||||
# exact toolchain/kernel settings the SDK was built with, on its own; an earlier
|
||||
# attempt to copy those lines by hand was redundant and is gone.
|
||||
# Should a future SDK start shipping a .config, this keeps the two things that
|
||||
# would then be worth honouring — the target identity and the package format —
|
||||
# and still lets the lines above override the mass-select.
|
||||
if [ -s .config.sdk ]; then
|
||||
echo "[apk-sdk] SDK shipped a .config — carrying over target identity + format:"
|
||||
grep -E '^CONFIG_TARGET_[a-z0-9_]+=y$|^CONFIG_TARGET_(BOARD|SUBTARGET|ARCH_PACKAGES)=|^CONFIG_USE_APK=' \
|
||||
.config.sdk | tee -a .config | sed 's/^/[apk-sdk] /' || true
|
||||
fi
|
||||
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
echo "CONFIG_PACKAGE_$p=m" >> .config
|
||||
done
|
||||
# Route source downloads through OpenWrt's fast CDN mirror FIRST. Some upstreams
|
||||
@@ -151,28 +250,172 @@ fi
|
||||
echo "[apk-sdk] defconfig"
|
||||
make defconfig >/dev/null
|
||||
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
# --- second pass: deselect the kernel, keep only what our packages select -----
|
||||
# Turning ALL/ALL_KMODS/ALL_NONSHARED off (above) provably worked — run 59 shows
|
||||
# all three as `is not set` after defconfig — and changed the kmod count by
|
||||
# exactly zero, 1078 both times. The kmods are not selected through ALL_KMODS at
|
||||
# all. They are selected one by one, and here is where from:
|
||||
#
|
||||
# target/sdk/Makefile:
|
||||
# ./convert-config.pl $(TOPDIR)/.config > $(SDK_BUILD_DIR)/Config-build.in
|
||||
#
|
||||
# The SDK's Config-build.in is GENERATED from the buildbot's .config — a config
|
||||
# in which ALL_KMODS=y had already expanded into a `CONFIG_PACKAGE_kmod-*=m` line
|
||||
# per module. convert-config.pl turns every `CONFIG_X=<val>` line into a kconfig
|
||||
# symbol carrying an unconditional `default <val>`; its `next if
|
||||
# /^(# )?CONFIG_PACKAGE/` filter sits in the `else` branch, which a line with an
|
||||
# `=` in it never reaches. So the SDK ships, verbatim, 1078 blocks of:
|
||||
#
|
||||
# config PACKAGE_kmod-mlx5-core
|
||||
# tristate
|
||||
# default m
|
||||
#
|
||||
# Nothing there consults ALL_KMODS, which is why switching it off was inert.
|
||||
#
|
||||
# Fix: give those symbols an explicit user value. We cannot do it before the
|
||||
# first defconfig — the list of names only exists once kconfig has expanded the
|
||||
# tree — so this is a second pass: rewrite every selected kmod to `is not set`
|
||||
# and re-run defconfig. Two kconfig rules make the result exactly what we want,
|
||||
# and both are already demonstrated in our own logs:
|
||||
# * an explicit value in .config beats a `default` (this is precisely why the
|
||||
# `# CONFIG_ALL* is not set` lines survived defconfig in run 59), so the
|
||||
# ~1078 kmods we do not need stay off;
|
||||
# * `select` is a reverse dependency, OR-ed into the symbol's value AFTER the
|
||||
# user value in sym_calc_value(), so it cannot be overridden by an explicit
|
||||
# `n`. shater-core's `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` becomes
|
||||
# `select PACKAGE_kmod-nft-tproxy` (scripts/package-metadata.pl: a `+` flag
|
||||
# sets `$m = "select"`, and it re-emits the dependency's own depends too, so
|
||||
# transitive kmods follow). Those come back on their own.
|
||||
# Net effect: we build the handful of kmods our packages actually pull in.
|
||||
#
|
||||
# Rejected alternatives:
|
||||
# * limiting what `package/kernel/linux/compile` packs — that target has no
|
||||
# such knob; it iterates the selected set, so the selection IS the knob;
|
||||
# * `package/kernel/linux/clean` + a targeted build — the kernel package would
|
||||
# simply be rebuilt in full as a dependency of shater-core, same cost;
|
||||
# * copying OpenWrt's own feed CI (openwrt/gh-action-sdk) — it does nothing
|
||||
# about this; it just runs `make defconfig` and builds. Its one disk-related
|
||||
# setting, CONFIG_AUTOREMOVE=y, is already the SDK's default;
|
||||
# * editing the SDK's generated Config-build.in to strip the offending blocks —
|
||||
# it would work, but it means parsing a generated kconfig file by hand and a
|
||||
# format change would corrupt it silently. The two-pass approach uses only
|
||||
# kconfig's documented semantics and leaves the evidence in .config.
|
||||
kmods_all=$(grep -c '^CONFIG_PACKAGE_kmod-[^=]*=[my]$' .config || true)
|
||||
if [ "$kmods_all" -gt 0 ]; then
|
||||
echo "[apk-sdk] deselecting $kmods_all kmod packages, then defconfig again"
|
||||
sed -i -E 's/^CONFIG_(PACKAGE_kmod-[^=]*)=[my]$/# CONFIG_\1 is not set/' .config
|
||||
make defconfig >/dev/null
|
||||
fi
|
||||
|
||||
# --- post-defconfig sanity + disk-cost readout -------------------------------
|
||||
# A failed run leaves a ~27 MB log; digging the cause out of it is miserable, so
|
||||
# print the handful of numbers that decide whether this run survives the
|
||||
# runner's disk quota BEFORE anything is compiled.
|
||||
kmods=$(grep -c '^CONFIG_PACKAGE_kmod.*=m' .config || true)
|
||||
echo "[apk-sdk] target: board=$(sed -n 's/^CONFIG_TARGET_BOARD=//p' .config)" \
|
||||
"subtarget=$(sed -n 's/^CONFIG_TARGET_SUBTARGET=//p' .config)" \
|
||||
"arch_packages=$(sed -n 's/^CONFIG_TARGET_ARCH_PACKAGES=//p' .config)"
|
||||
echo "[apk-sdk] kmod packages selected (=m): $kmods"
|
||||
# Proof the mass-select stayed off: these three must come back out of defconfig
|
||||
# as `is not set`. If any reads `=y`, the SDK's `default ALL`/`default y` won and
|
||||
# the kmod count above will be in the four digits.
|
||||
echo "[apk-sdk] mass-select symbols after defconfig:"
|
||||
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/[apk-sdk] /' || true
|
||||
# After the second pass the only kmods left are the ones shater-core's
|
||||
# `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` turns into kconfig `select`s, plus
|
||||
# whatever those select in turn — a handful. Worth printing verbatim while the
|
||||
# list is short. A count of 0 is NOT fatal: those kmods ship in the router's own
|
||||
# base feed, so apk resolves them there; but it would mean the selects did not
|
||||
# fire, and that is something we want to see in the log rather than guess at.
|
||||
if [ "$kmods" -le 30 ]; then
|
||||
grep '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/[apk-sdk] /' || true
|
||||
fi
|
||||
# The two cache knobs are written before the first defconfig and have to survive
|
||||
# both of them — losing DOWNLOAD_FOLDER silently costs us the dl/ cache, and
|
||||
# losing LOCALMIRROR brings back the sourceware.org stalls. Cheap to just look.
|
||||
echo "[apk-sdk] cache settings after defconfig:"
|
||||
grep -E '^CONFIG_(LOCALMIRROR|DOWNLOAD_FOLDER)=' .config | sed 's/^/[apk-sdk] /' || true
|
||||
echo "[apk-sdk] our packages after defconfig:"
|
||||
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|luci-app-shater)=' .config \
|
||||
| sed 's/^/[apk-sdk] /' || true
|
||||
|
||||
# Each of our 3 must have SURVIVED defconfig. If kconfig dropped one, it is
|
||||
# because a symbol it `select`s (a DEPENDS entry) does not exist in the installed
|
||||
# feeds — with the old append-everything .config that was masked by the SDK
|
||||
# pre-selecting half the distro. `make package/<p>/compile` would then die with a
|
||||
# cryptic "No rule to make target", far from the real cause.
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
grep -q "^CONFIG_PACKAGE_$p=m" .config || {
|
||||
echo "[apk-sdk] ERROR: $p is NOT selected after defconfig."
|
||||
echo " kconfig dropped it -> one of its DEPENDS is missing from the"
|
||||
echo " installed feeds (check the 'feeds install' step above)."; exit 10; }
|
||||
done
|
||||
|
||||
# Only our two nft kmods (+ whatever they themselves depend on) have any business
|
||||
# being selected here — a dozen at the very most. A count in the hundreds means an
|
||||
# ALL_KMODS-style mass-select crept back in, and the run would spend ~40 min
|
||||
# packing the kernel before dying on `Disk quota exceeded`. Fail now instead.
|
||||
[ "$kmods" -le 200 ] || {
|
||||
echo "[apk-sdk] ERROR: $kmods kmod packages selected — that is the whole kernel."
|
||||
echo " Aborting before this fills the runner's disk. Two causes are"
|
||||
echo " possible, and the lines below tell them apart:"
|
||||
echo " (a) the mass-select is back on -> a CONFIG_ALL* line reads =y;"
|
||||
echo " (b) the second pass did not take -> ALL* are 'is not set' but the"
|
||||
echo " kmods returned anyway, i.e. the per-kmod 'default m' from the"
|
||||
echo " SDK's generated Config-build.in outlived our explicit 'n'."
|
||||
grep -E '^(# )?CONFIG_ALL(_KMODS|_NONSHARED)?[ =]' .config | sed 's/^/ /' || true
|
||||
echo " first few kmods still selected:"
|
||||
grep -m5 '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/ /' || true
|
||||
exit 11; }
|
||||
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
echo "[apk-sdk] === build $p ==="
|
||||
make "package/$p/compile" V=s -j"$(nproc)"
|
||||
done
|
||||
|
||||
# What the build actually cost on disk. The runner's 64 GB ZFS quota is the
|
||||
# binding constraint on this lane, so record it while the tree still exists.
|
||||
echo "[apk-sdk] disk usage after compile:"
|
||||
du -sh build_dir staging_dir bin 2>/dev/null || true
|
||||
df -h /home/build || true
|
||||
|
||||
# A 25.12 apk-SDK must emit .apk — finding only .ipk means a wrong SDK was fed in.
|
||||
anyapk=$(find bin -type f -name '*.apk' | wc -l)
|
||||
[ "$anyapk" -gt 0 ] || {
|
||||
echo "[apk-sdk] ERROR: no .apk produced under bin/ (wrong/older SDK? found $(find bin -type f -name '*.ipk' | wc -l) .ipk)";
|
||||
find bin -maxdepth 4 -type d || true; exit 6; }
|
||||
# Collect ONLY our 4 packages' .apk (apk filenames carry NO arch:
|
||||
# Collect ONLY our 3 packages' .apk (apk filenames carry NO arch:
|
||||
# `<name>-<ver>-r<rel>.apk`). NOT a blanket `*.apk` copy — the SDK bin/ can hold
|
||||
# prebuilt base/kmod .apk that would bloat the index and be signed under our key.
|
||||
found=0
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
for a in $(find bin -type f -name "${p}-*.apk"); do
|
||||
cp -f "$a" "$OUT/"; found=$((found+1))
|
||||
done
|
||||
done
|
||||
[ "$found" -ge 4 ] || { echo "[apk-sdk] ERROR: expected >=4 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
|
||||
[ "$found" -ge 3 ] || { echo "[apk-sdk] ERROR: expected >=3 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
|
||||
echo "[apk-sdk] collected $found of our .apk"
|
||||
|
||||
# --- assert the tag-derived version actually reached the packages -------------
|
||||
# B4's failure mode is a wrong-but-plausible version shipping silently, so the
|
||||
# env -> make hand-off is verified, not trusted: each of our three tag-versioned
|
||||
# packages must be named `<name>-<ver>-r<rel>.apk`. Every package this repo ships
|
||||
# is tag-versioned, so the check covers all of them with no exception to
|
||||
# remember. This runs BEFORE `apk mkndx`, so a stale version can never even
|
||||
# reach the index.
|
||||
if [ -n "${SHATER_PKG_VERSION:-}" ] && [ -n "${SHATER_PKG_RELEASE:-}" ]; then
|
||||
want="${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
[ -f "$OUT/${p}-${want}.apk" ] || {
|
||||
echo "[apk-sdk] ERROR: $p was not built as version '$want'."
|
||||
echo " SHATER_PKG_VERSION/SHATER_PKG_RELEASE did not reach the package"
|
||||
echo " Makefile — the build would have shipped a stale version (bug B4)."
|
||||
echo "[apk-sdk] collected:"; ls -1 "$OUT" | sed 's/^/ /'
|
||||
exit 12; }
|
||||
done
|
||||
echo "[apk-sdk] version check OK — our 3 packages are $want"
|
||||
fi
|
||||
|
||||
# --- index + sign: exactly how the OpenWrt 25.12 buildsystem does it ---------
|
||||
# apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \
|
||||
# --output packages.adb *.apk
|
||||
@@ -204,7 +447,7 @@ INNER
|
||||
chmod 0644 /home/build/inner.sh
|
||||
|
||||
su build -s /bin/bash -c \
|
||||
"ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' bash /home/build/inner.sh"
|
||||
"ARCH='$ARCH' REPO='$REPO' OUT='$OUT' SDKDIR='$SDKDIR' KEYFILE='${KEYFILE:-}' DL_DIR='${DL_DIR:-}' FEEDS_CACHE='${FEEDS_CACHE:-}' SHATER_PKG_VERSION='${SHATER_PKG_VERSION:-}' SHATER_PKG_RELEASE='${SHATER_PKG_RELEASE:-}' bash /home/build/inner.sh"
|
||||
|
||||
chmod -R a+rwX "$OUT" 2>/dev/null || true
|
||||
echo "[apk-sdk] OK arch=$ARCH — apk feed dir:"
|
||||
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Runs INSIDE an `openwrt/sdk:<target>-<ver>` container (CWD = SDK root
|
||||
# /builder). The job's workspace is shared into this container via
|
||||
# `docker run --volumes-from`, so the repo is visible at $REPO and output goes
|
||||
# to $OUT (a dir under the repo, hence also visible to the runner afterwards).
|
||||
#
|
||||
# Unlike Shater v0.1 (which compiled ONLY xrayctl in the SDK and hand-packed the
|
||||
# pure-data packages with tar), v0.2 builds ALL FOUR packages the canonical way,
|
||||
# via the SDK feed + `make package/<p>/compile`:
|
||||
#
|
||||
# shaterd prebuilt binary — Build/Compile only VALIDATES that
|
||||
# openwrt/shaterd/files/shaterd-<amd64|arm64>.upx was staged
|
||||
# by scripts/build-shaterd.sh on the runner BEFORE this ran.
|
||||
# (arch-specific .ipk: RSTRIP/STRIP disabled — packed ELF.)
|
||||
# shater-core PKGARCH=all data glue (procd init, sysctl, uci-defaults).
|
||||
# luci-app-shater PKGARCH=all LuCI thin launcher — its Makefile does
|
||||
# `include $(TOPDIR)/feeds/luci/luci.mk`, so the `luci` feed
|
||||
# MUST be updated first (that is what creates feeds/luci/luci.mk).
|
||||
# byedpi arch-specific C — the SDK cross-compiles ciadpi from the
|
||||
# upstream tarball (needs network for PKG_SOURCE_URL).
|
||||
#
|
||||
# Env (required): ARCH, REPO, OUT.
|
||||
set -eu
|
||||
ARCH="${ARCH:?ARCH env required}"
|
||||
REPO="${REPO:?REPO env required}"
|
||||
OUT="${OUT:?OUT env required}"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
echo "[sdk] arch=$ARCH repo=$REPO out=$OUT"
|
||||
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
||||
echo "[sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
||||
|
||||
# The prebuilt shaterd artifact must already be staged for this arch.
|
||||
case "$ARCH" in
|
||||
x86_64) sfx=amd64 ;;
|
||||
aarch64_cortex-a53) sfx=arm64 ;;
|
||||
*) echo "[sdk] ERROR: unsupported ARCH '$ARCH'"; exit 2 ;;
|
||||
esac
|
||||
test -f "$REPO/openwrt/shaterd/files/shaterd-$sfx.upx" || {
|
||||
echo "[sdk] ERROR: openwrt/shaterd/files/shaterd-$sfx.upx not staged."
|
||||
echo " scripts/build-shaterd.sh must run on the runner before the SDK build."; exit 3; }
|
||||
|
||||
# --- register this repo's openwrt/ as a src-link feed named `shater` ---------
|
||||
# src-link REQUIRES an absolute path; $REPO/openwrt is exactly a feed root (it
|
||||
# contains the 4 package dirs and nothing else that looks like a package).
|
||||
cp -f feeds.conf.default feeds.conf
|
||||
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
|
||||
|
||||
# Update metadata for ALL feeds: our `shater` feed + the SDK defaults (base,
|
||||
# luci, packages, routing, telephony). We need `luci` for feeds/luci/luci.mk and
|
||||
# `base`/`packages` for the runtime deps (kmod-nft-tproxy, kmod-nft-socket,
|
||||
# ip-full, rpcd, luci-base) to resolve.
|
||||
#
|
||||
# Persistent feeds checkouts: $FEEDS_CACHE (a workspace dir the runner restores
|
||||
# via actions/cache, shared into this container via --volumes-from) replaces
|
||||
# the SDK's ephemeral feeds/ dir, so `feeds update` git-fetches deltas instead
|
||||
# of re-cloning base+packages+luci every run (~7 min on the runner's slow
|
||||
# github.com link). Correctness-safe: update always checks out feeds.conf's
|
||||
# pinned revisions; if it ever fails on a cached checkout (e.g. a force-pushed
|
||||
# upstream), the cache is wiped and the update retried with fresh clones.
|
||||
if [ -n "${FEEDS_CACHE:-}" ] && mkdir -p "$FEEDS_CACHE" 2>/dev/null; then
|
||||
rm -rf feeds
|
||||
ln -s "$FEEDS_CACHE" feeds
|
||||
echo "[sdk] feeds/ -> $FEEDS_CACHE (persistent cache)"
|
||||
fi
|
||||
echo "[sdk] feeds update -a"
|
||||
if ! ./scripts/feeds update -a; then
|
||||
[ -L feeds ] || { echo "[sdk] ERROR: feeds update failed"; exit 8; }
|
||||
echo "[sdk] WARNING: feeds update failed on cached checkouts — wiping cache, cloning fresh"
|
||||
find "$FEEDS_CACHE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
|
||||
./scripts/feeds update -a
|
||||
fi
|
||||
|
||||
echo "[sdk] feeds install (prefer shater feed)"
|
||||
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
|
||||
|
||||
# Select our packages, then defconfig. `make package/<p>/compile` builds the
|
||||
# explicit target regardless, but selecting first makes deps visible to defconfig.
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
echo "CONFIG_PACKAGE_$p=m" >> .config
|
||||
done
|
||||
# Route source downloads through OpenWrt's fast CDN mirror FIRST — sourceware.org
|
||||
# (elfutils) and other upstreams intermittently stall mid-transfer, and curl's
|
||||
# --connect-timeout doesn't cover a stalled stream, so the SDK download hangs the
|
||||
# build. LOCALMIRROR is tried before each package's own PKG_SOURCE_URL. (lx CI)
|
||||
echo 'CONFIG_LOCALMIRROR="https://sources.cdn.openwrt.org"' >> .config
|
||||
# Persistent dl/ across runs: $DL_DIR is a workspace dir the runner restores via
|
||||
# actions/cache (see ci/build-feed.sh). Correctness-safe: the buildroot verifies
|
||||
# PKG_HASH on every file already in dl/ and re-downloads on mismatch, so a stale
|
||||
# cache can never leak a wrong source into the build.
|
||||
if [ -n "${DL_DIR:-}" ]; then
|
||||
echo "CONFIG_DOWNLOAD_FOLDER=\"$DL_DIR\"" >> .config
|
||||
fi
|
||||
echo "[sdk] defconfig"
|
||||
make defconfig >/dev/null
|
||||
|
||||
# --- compile the 4 packages --------------------------------------------------
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
echo "[sdk] === build $p ==="
|
||||
make "package/$p/compile" V=s -j"$(nproc)"
|
||||
done
|
||||
|
||||
# --- collect ONLY our 4 packages' .ipk (per-arch shaterd/byedpi + _all core/luci)
|
||||
# NOT `find bin -name '*.ipk'`: the openwrt/sdk image ships HUNDREDS of prebuilt
|
||||
# kmod/base .ipk under bin/, which a blanket copy would pull into the feed and
|
||||
# get signed under OUR key. Match each package's own `<name>_<ver>_<arch>.ipk`.
|
||||
found=0
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for ipk in $(find bin -type f -name "${p}_*.ipk"); do
|
||||
cp -f "$ipk" "$OUT/"; found=$((found+1))
|
||||
done
|
||||
done
|
||||
[ "$found" -ge 4 ] || { echo "[sdk] ERROR: expected >=4 of OUR .ipk, collected $found"; echo "[sdk] (all .ipk under bin/:)"; find bin -type f -name '*.ipk' | head -20; exit 4; }
|
||||
chmod -R a+rwX "$OUT" 2>/dev/null || true
|
||||
echo "[sdk] OK arch=$ARCH — collected $found of our .ipk:"
|
||||
ls -l "$OUT"
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/sh
|
||||
# ci/version.sh — the SINGLE source of truth for "what version is this build?".
|
||||
#
|
||||
# WHY THIS EXISTS (bug B4)
|
||||
# -----------------------
|
||||
# PKG_VERSION/PKG_RELEASE used to be hand-written literals in the four package
|
||||
# Makefiles, and nobody remembered to bump them: v0.2.2 … v0.2.6 all shipped as
|
||||
# `shaterd 0.2.0-r3` with DIFFERENT binaries inside (v0.2.6's ELF is 5 491 616 B
|
||||
# vs r2's 5 488 336 B). Since apk offers an upgrade only when the feed's version
|
||||
# string differs from the installed one, `apk update` saw nothing new and the
|
||||
# routers could not be updated through the normal path at all.
|
||||
#
|
||||
# So the version is now DERIVED, in CI, from the git tag, and the package
|
||||
# Makefiles only carry a fallback for manual/offline builds.
|
||||
#
|
||||
# THE SCHEME
|
||||
# ----------
|
||||
# tag push `vX.Y.Z` -> PKG_VERSION=X.Y.Z PKG_RELEASE=1
|
||||
# any other build -> PKG_VERSION=X.Y.Z of the NEAREST reachable tag,
|
||||
# (workflow_dispatch, PKG_RELEASE=<commits since that tag> + 1
|
||||
# rolling `latest`)
|
||||
# no tag / no git at all -> PKG_VERSION=0.0.0 PKG_RELEASE=1 (+ warning)
|
||||
#
|
||||
# apk compares `<upstream>-r<rel>` as: the dotted upstream part first
|
||||
# (numerically, component by component), the `r<rel>` only as a tie-break.
|
||||
# Verified against the real tool, not from memory —
|
||||
# apk-tools 3.0.3 (`apk version -t`) and apk-tools 2.14.6:
|
||||
# 0.2.6-r1 > 0.2.0-r3 0.2.6-r12 > 0.2.6-r1
|
||||
# 0.2.7-r1 > 0.2.6-r12 0.0.0-r1 < 0.2.0-r3
|
||||
# That is exactly the ordering this scheme needs:
|
||||
# * a release always outranks every rolling build that preceded it
|
||||
# (0.2.7-r1 > 0.2.6-rN for any N — the dotted part decides), and
|
||||
# * rolling builds between two releases grow monotonically (r2 < r10 < r11),
|
||||
# so a rolling build can never look newer than the next release, and the
|
||||
# `latest` feed still moves forward on every dispatch.
|
||||
#
|
||||
# +1 on the commit count (rather than the raw count) only avoids `-r0` and makes
|
||||
# a dispatch build of the tagged commit itself identical to the release build of
|
||||
# that same commit — which is the truth: same tree, same binary.
|
||||
#
|
||||
# Every package this repo ships is versioned from the tag. There used to be one
|
||||
# exception (an external tool carrying its upstream's own version); it is gone
|
||||
# with the package, and nothing here has to remember it any more.
|
||||
#
|
||||
# USAGE
|
||||
# ci/version.sh # or --env: eval-able / $GITHUB_ENV-able lines
|
||||
# ci/version.sh --pkg-version # X.Y.Z
|
||||
# ci/version.sh --pkg-release # R
|
||||
# ci/version.sh --binary # vX.Y.Z-rR[-g<sha>] for constant.Version
|
||||
#
|
||||
# Env:
|
||||
# SHATER_REF / GITHUB_REF when it is `refs/tags/<tag>` that tag wins and no
|
||||
# git history is needed (the tag-push path is exact
|
||||
# even on a shallow checkout).
|
||||
set -eu
|
||||
|
||||
REPO="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
|
||||
TAG=""
|
||||
EXACT=0
|
||||
N=0
|
||||
SHA=""
|
||||
|
||||
# --- 1) an explicit tag ref is authoritative (and needs no git) --------------
|
||||
REF="${SHATER_REF:-${GITHUB_REF:-}}"
|
||||
case "$REF" in
|
||||
refs/tags/*) TAG="${REF#refs/tags/}"; EXACT=1 ;;
|
||||
esac
|
||||
|
||||
# --- 2) otherwise ask git for the nearest reachable release tag --------------
|
||||
# `--match 'v[0-9]*'` keeps non-release tags (latest, sdk-cache, apk-latest-*,
|
||||
# musl-toolchain-cache) out. This repo is a sing-box FORK and therefore also
|
||||
# carries upstream's v1.x tags — `git describe` picks the CLOSEST tag by commit
|
||||
# distance, so our own v0.2.x (a handful of commits back) always wins over
|
||||
# upstream's v1.x (thousands of commits back). The tag it picked is logged
|
||||
# below, so a surprise is visible in the CI log rather than silently shipped.
|
||||
if [ "$EXACT" -eq 0 ]; then
|
||||
if D="$(git -C "$REPO" describe --tags --long --match 'v[0-9]*' 2>/dev/null)"; then
|
||||
# `v0.2.6-1-g02c266188` -> TAG=v0.2.6 N=1 SHA=g02c266188.
|
||||
# `%` strips the SHORTEST matching suffix, so a tag that itself contains a
|
||||
# dash (`v0.2.0-healthplan`) survives intact.
|
||||
TAG="${D%-*-g*}"
|
||||
REST="${D#"$TAG"-}"
|
||||
N="${REST%%-*}"
|
||||
SHA="${REST#*-}"
|
||||
if [ "$N" -eq 0 ]; then EXACT=1; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3) tag -> numeric PKG_VERSION ------------------------------------------
|
||||
# Keep the leading dotted-numeric run only: `v0.2.0-healthplan` -> `0.2.0`.
|
||||
VER=""
|
||||
if [ -n "$TAG" ]; then
|
||||
VER="$(printf '%s' "${TAG#v}" | sed -n 's/^\([0-9][0-9.]*\).*/\1/p' | sed 's/\.*$//')"
|
||||
fi
|
||||
|
||||
if [ -z "$VER" ]; then
|
||||
# No release tag anywhere (shallow clone with no tags, a tarball export, a
|
||||
# fresh fork). 0.0.0 is BELOW every version we have ever published, so such a
|
||||
# build can never masquerade as an upgrade on a real router; the commit count
|
||||
# still makes successive dev builds distinguishable.
|
||||
VER="0.0.0"
|
||||
EXACT=0
|
||||
N="$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 0)"
|
||||
SHA="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||
[ -z "$SHA" ] || SHA="g$SHA"
|
||||
echo "[version] WARNING: no reachable vX.Y.Z tag (and/or no git) -> $VER" >&2
|
||||
fi
|
||||
|
||||
# --- 4) PKG_RELEASE + the string stamped into the binary --------------------
|
||||
if [ "$EXACT" -eq 1 ]; then
|
||||
REL=1
|
||||
FULL="v${VER}-r${REL}"
|
||||
else
|
||||
REL=$((N + 1))
|
||||
FULL="v${VER}-r${REL}${SHA:+-$SHA}"
|
||||
fi
|
||||
|
||||
echo "[version] tag='${TAG:-none}' commits_since=$N exact=$EXACT -> ${VER}-r${REL} (binary: $FULL)" >&2
|
||||
|
||||
case "${1:---env}" in
|
||||
--env|"")
|
||||
printf 'SHATER_PKG_VERSION=%s\n' "$VER"
|
||||
printf 'SHATER_PKG_RELEASE=%s\n' "$REL"
|
||||
printf 'SHATER_VERSION=%s\n' "$FULL"
|
||||
;;
|
||||
--pkg-version) printf '%s\n' "$VER" ;;
|
||||
--pkg-release) printf '%s\n' "$REL" ;;
|
||||
--binary|--version) printf '%s\n' "$FULL" ;;
|
||||
*)
|
||||
echo "usage: $0 [--env|--pkg-version|--pkg-release|--binary]" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build darwin
|
||||
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
|
||||
// (control.DisableUDPFragment sets IP_DONTFRAG=1 on darwin).
|
||||
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
|
||||
t.Helper()
|
||||
rawConn, err := sysConn.SyscallConn()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var (
|
||||
value int
|
||||
sockErr error
|
||||
ctrlErr error
|
||||
)
|
||||
ctrlErr = rawConn.Control(func(fd uintptr) {
|
||||
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_DONTFRAG)
|
||||
})
|
||||
if ctrlErr != nil {
|
||||
t.Fatal(ctrlErr)
|
||||
}
|
||||
if sockErr != nil {
|
||||
t.Fatal(sockErr)
|
||||
}
|
||||
return value != 0
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build linux
|
||||
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// udpSocketDFSet reports whether the socket has "don't fragment" forced on
|
||||
// (control.DisableUDPFragment sets IP_MTU_DISCOVER=IP_PMTUDISC_DO on linux,
|
||||
// the same flag the user-visible failure was traced to on android).
|
||||
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
|
||||
t.Helper()
|
||||
rawConn, err := sysConn.SyscallConn()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var (
|
||||
value int
|
||||
sockErr error
|
||||
ctrlErr error
|
||||
)
|
||||
ctrlErr = rawConn.Control(func(fd uintptr) {
|
||||
value, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_MTU_DISCOVER)
|
||||
})
|
||||
if ctrlErr != nil {
|
||||
t.Fatal(ctrlErr)
|
||||
}
|
||||
if sockErr != nil {
|
||||
t.Fatal(sockErr)
|
||||
}
|
||||
return value == unix.IP_PMTUDISC_DO
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func udpSocketDFSet(t *testing.T, _ syscall.Conn) bool {
|
||||
t.Helper()
|
||||
t.Skip("DF socket-flag introspection implemented for darwin, linux and windows only")
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// IP_MTU_DISCOVER on windows (ws2ipdef.h); control.DisableUDPFragment sets it to
|
||||
// IP_PMTUDISC_DO, the same "don't fragment" state the linux helper checks.
|
||||
const (
|
||||
windowsIPMTUDiscover = 71
|
||||
windowsPMTUDiscDo = 1
|
||||
)
|
||||
|
||||
// udpSocketDFSet reports whether the socket has "don't fragment" forced on.
|
||||
// shater addition: upstream ships linux + darwin only, so the whole suite
|
||||
// skipped on the dev host — where it is the one platform we can actually run it
|
||||
// on before the router build.
|
||||
func udpSocketDFSet(t *testing.T, sysConn syscall.Conn) bool {
|
||||
t.Helper()
|
||||
rawConn, err := sysConn.SyscallConn()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var (
|
||||
value int
|
||||
sockErr error
|
||||
)
|
||||
ctrlErr := rawConn.Control(func(fd uintptr) {
|
||||
value, sockErr = windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, windowsIPMTUDiscover)
|
||||
})
|
||||
if ctrlErr != nil {
|
||||
t.Fatal(ctrlErr)
|
||||
}
|
||||
if sockErr != nil {
|
||||
t.Skip("IP_MTU_DISCOVER is not readable on this host: ", sockErr)
|
||||
}
|
||||
return value == windowsPMTUDiscDo
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// lx: regression tests for the udp_fragment / UDPFragmentDefault
|
||||
// plumbing. The WireGuard endpoint (and MASQUE outbound) rely on
|
||||
// UDPFragmentDefault=true reaching the real UDP socket as "DF clear": with DF
|
||||
// set, an outer datagram larger than the path MTU is silently dropped instead
|
||||
// of fragmented, which blackholes nested tunnels (AWG-over-AWG, MASQUE-over-AWG)
|
||||
// and AWG s4 transport junk. These tests assert the socket flag itself, on both
|
||||
// paths a WireGuard bind can take: the dialer (ClientBind, detour case) and the
|
||||
// listener control (StdNetBind via WireGuardControl, no-detour case).
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func dialUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
|
||||
t.Helper()
|
||||
d, err := NewDefault(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := d.DialContext(context.Background(), N.NetworkUDP, M.ParseSocksaddr("127.0.0.1:9"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
sysConn, isSysConn := conn.(syscall.Conn)
|
||||
if !isSysConn {
|
||||
t.Fatalf("dialed UDP conn %T does not expose SyscallConn", conn)
|
||||
}
|
||||
return sysConn
|
||||
}
|
||||
|
||||
func listenUDPForDF(t *testing.T, options option.DialerOptions) syscall.Conn {
|
||||
t.Helper()
|
||||
d, err := NewDefault(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// WireGuardControl() is the listener control conn.StdNetBind installs on the
|
||||
// socket a no-detour WireGuard endpoint sends its outer datagrams from — the
|
||||
// exact socket the DF default decides the fate of.
|
||||
listenConfig := net.ListenConfig{Control: d.WireGuardControl()}
|
||||
packetConn, err := listenConfig.ListenPacket(context.Background(), "udp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = packetConn.Close() })
|
||||
sysConn, isSysConn := packetConn.(syscall.Conn)
|
||||
if !isSysConn {
|
||||
t.Fatalf("listened UDP conn %T does not expose SyscallConn", packetConn)
|
||||
}
|
||||
return sysConn
|
||||
}
|
||||
|
||||
// Upstream default: no UDPFragmentDefault, no udp_fragment → DF is set on both
|
||||
// the dial and listener paths. Pins the baseline the endpoint fix opts out of.
|
||||
func TestUDPFragmentDFByDefault_LX(t *testing.T) {
|
||||
if !udpSocketDFSet(t, dialUDPForDF(t, option.DialerOptions{})) {
|
||||
t.Fatal("default dialer must set DF on dialed UDP sockets")
|
||||
}
|
||||
if !udpSocketDFSet(t, listenUDPForDF(t, option.DialerOptions{})) {
|
||||
t.Fatal("default dialer must set DF on listener-control UDP sockets")
|
||||
}
|
||||
}
|
||||
|
||||
// UDPFragmentDefault=true (what the WireGuard endpoint and MASQUE outbound now
|
||||
// set) → DF clear on both paths, so oversize outer datagrams fragment instead
|
||||
// of vanishing.
|
||||
func TestUDPFragmentDefaultClearsDF_LX(t *testing.T) {
|
||||
options := option.DialerOptions{UDPFragmentDefault: true}
|
||||
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
|
||||
t.Fatal("UDPFragmentDefault=true must leave DF clear on dialed UDP sockets")
|
||||
}
|
||||
if udpSocketDFSet(t, listenUDPForDF(t, options)) {
|
||||
t.Fatal("UDPFragmentDefault=true must leave DF clear on listener-control UDP sockets")
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit user config always wins over the protocol default, in both
|
||||
// directions.
|
||||
func TestUDPFragmentExplicitOverride_LX(t *testing.T) {
|
||||
fragmentOff := false
|
||||
options := option.DialerOptions{UDPFragment: &fragmentOff, UDPFragmentDefault: true}
|
||||
if !udpSocketDFSet(t, dialUDPForDF(t, options)) {
|
||||
t.Fatal("udp_fragment=false must set DF even when the protocol default allows fragmentation")
|
||||
}
|
||||
fragmentOn := true
|
||||
options = option.DialerOptions{UDPFragment: &fragmentOn}
|
||||
if udpSocketDFSet(t, dialUDPForDF(t, options)) {
|
||||
t.Fatal("udp_fragment=true must leave DF clear even without a protocol default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//go:build with_quic
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
sbTLS "github.com/sagernet/sing-box/common/tls"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
// raceProbePayload is large enough that it cannot ride along in the response
|
||||
// headers: the caller has to read the body off the QUIC stream AFTER
|
||||
// roundTripHTTP3Race has returned. That is the whole point of the test.
|
||||
const raceProbePayload = 64 * 1024
|
||||
|
||||
var _ N.Dialer = (*plainDialer)(nil)
|
||||
|
||||
type plainDialer struct{}
|
||||
|
||||
func (d *plainDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
|
||||
}
|
||||
|
||||
func (d *plainDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return net.ListenUDP("udp", nil)
|
||||
}
|
||||
|
||||
// splitDialer sends the HTTP/3 racer and the HTTP/2 racer to two different
|
||||
// listeners, so a test can decide which one of them wins without having to bind
|
||||
// a TCP and a UDP socket on the same port number.
|
||||
type splitDialer struct {
|
||||
udp M.Socksaddr
|
||||
tcp M.Socksaddr
|
||||
}
|
||||
|
||||
func (d *splitDialer) DialContext(ctx context.Context, network string, _ M.Socksaddr) (net.Conn, error) {
|
||||
destination := d.tcp
|
||||
if network == N.NetworkUDP {
|
||||
destination = d.udp
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, destination.String())
|
||||
}
|
||||
|
||||
func (d *splitDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return net.ListenUDP("udp", nil)
|
||||
}
|
||||
|
||||
func startH3Server(t *testing.T, handler http.Handler) M.Socksaddr {
|
||||
t.Helper()
|
||||
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := quic.ListenAddrEarly("127.0.0.1:0", &stdTLS.Config{
|
||||
Certificates: []stdTLS.Certificate{*certificate},
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: stdTLS.VersionTLS13,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http3.Server{Handler: handler}
|
||||
go server.ServeListener(listener)
|
||||
t.Cleanup(func() {
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
return M.ParseSocksaddr(listener.Addr().String())
|
||||
}
|
||||
|
||||
func newRaceProbeTransport(t *testing.T, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
|
||||
return newRaceProbeTransportWithDialer(t, &plainDialer{}, serverAddr)
|
||||
}
|
||||
|
||||
func newRaceProbeTransportWithDialer(t *testing.T, dialer N.Dialer, serverAddr M.Socksaddr) (*http3FallbackTransport, string) {
|
||||
t.Helper()
|
||||
baseTLSConfig, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Insecure: true,
|
||||
ServerName: "localhost",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2Fallback, err := newHTTP2FallbackTransport(dialer, baseTLSConfig, option.HTTP2Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner, err := newHTTP3FallbackTransport(dialer, baseTLSConfig, h2Fallback, option.QUICOptions{}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inner.Close() })
|
||||
return inner.(*http3FallbackTransport), "https://" + serverAddr.String() + "/probe"
|
||||
}
|
||||
|
||||
// TestHTTP3RaceWinnerBodyStaysReadable pins that the response handed back by the
|
||||
// HTTP/3 race is a LIVE response: its body must still be readable after
|
||||
// roundTripHTTP3Race returns. Cancelling the context the winner was issued on
|
||||
// resets its QUIC stream, so a "successful" round trip would hand the caller a
|
||||
// response it can never read.
|
||||
func TestHTTP3RaceWinnerBodyStaysReadable(t *testing.T) {
|
||||
payload := make([]byte, raceProbePayload)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
serverAddr := startH3Server(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(payload)
|
||||
}))
|
||||
transport, url := newRaceProbeTransport(t, serverAddr)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No cached HTTP/3 connection yet and a bodyless GET is replayable, so this
|
||||
// takes the racing path.
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
t.Fatal("round trip: ", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.ProtoMajor != 3 {
|
||||
t.Fatalf("expected the HTTP/3 racer to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("the race winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
|
||||
}
|
||||
if len(body) != len(payload) {
|
||||
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled covers the
|
||||
// other half of the race: the HTTP/2 fallback wins, so its body must survive the
|
||||
// race, and the HTTP/3 racer that lost must be torn down instead of being left
|
||||
// to run to completion on the caller's behalf.
|
||||
func TestHTTP3RaceFallbackWinnerBodyStaysReadableAndH3LoserIsCancelled(t *testing.T) {
|
||||
payload := make([]byte, raceProbePayload)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
h3Started := make(chan struct{}, 1)
|
||||
h3Cancelled := make(chan struct{}, 1)
|
||||
// The HTTP/3 handler never answers, so the fallback wins on the timer.
|
||||
h3Addr := startH3Server(t, http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||
select {
|
||||
case h3Started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-request.Context().Done()
|
||||
select {
|
||||
case h3Cancelled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}))
|
||||
|
||||
h2Server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(payload)
|
||||
}))
|
||||
h2Server.EnableHTTP2 = true
|
||||
h2Server.StartTLS()
|
||||
t.Cleanup(h2Server.Close)
|
||||
|
||||
transport, _ := newRaceProbeTransportWithDialer(t, &splitDialer{
|
||||
udp: h3Addr,
|
||||
tcp: M.ParseSocksaddr(h2Server.Listener.Addr().String()),
|
||||
}, h3Addr)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://localhost:443/probe", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
t.Fatal("round trip: ", err)
|
||||
}
|
||||
if response.ProtoMajor != 2 {
|
||||
t.Fatalf("expected the HTTP/2 fallback to win, got HTTP/%d.%d", response.ProtoMajor, response.ProtoMinor)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("the fallback winner's body died with the race: %v (read %d of %d bytes)", err, len(body), len(payload))
|
||||
}
|
||||
response.Body.Close()
|
||||
if len(body) != len(payload) {
|
||||
t.Fatalf("short body: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
|
||||
select {
|
||||
case <-h3Started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the HTTP/3 racer never reached the server, the test proves nothing about cancelling it")
|
||||
}
|
||||
select {
|
||||
case <-h3Cancelled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the losing HTTP/3 request was left running after the fallback won")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
stdTLS "crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -168,32 +169,65 @@ func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Re
|
||||
return t.roundTripHTTP3Race(request, authority)
|
||||
}
|
||||
|
||||
// cancelOnBodyClose releases a racer's context when the caller is done with the
|
||||
// response it won. The race cannot release it on the way out: the body is read
|
||||
// after RoundTrip returns, and the context the request was issued on is what
|
||||
// keeps its stream alive.
|
||||
type cancelOnBodyClose struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
cancelOnce sync.Once
|
||||
}
|
||||
|
||||
func (b *cancelOnBodyClose) Close() error {
|
||||
err := b.ReadCloser.Close()
|
||||
b.cancelOnce.Do(b.cancel)
|
||||
return err
|
||||
}
|
||||
|
||||
func withCancelOnBodyClose(response *http.Response, cancel context.CancelFunc) *http.Response {
|
||||
if response == nil || response.Body == nil {
|
||||
cancel()
|
||||
return response
|
||||
}
|
||||
response.Body = &cancelOnBodyClose{ReadCloser: response.Body, cancel: cancel}
|
||||
return response
|
||||
}
|
||||
|
||||
func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, authority string) (*http.Response, error) {
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
type result struct {
|
||||
response *http.Response
|
||||
err error
|
||||
h3 bool
|
||||
}
|
||||
results := make(chan result, 2)
|
||||
startRoundTrip := func(request *http.Request, useH3 bool) {
|
||||
request = request.WithContext(ctx)
|
||||
var (
|
||||
response *http.Response
|
||||
err error
|
||||
)
|
||||
if useH3 {
|
||||
response, err = t.h3Transport.RoundTrip(request)
|
||||
} else {
|
||||
response, err = t.h2FallbackRoundTrip(request)
|
||||
}
|
||||
results <- result{response: response, err: err, h3: useH3}
|
||||
// Each racer runs on a context of its own. A context shared by both cannot be
|
||||
// cancelled when one of them wins: quic-go and net/http reset the winner's
|
||||
// stream on cancellation, so the caller would be handed a response whose body
|
||||
// stops mid-read with H3_REQUEST_CANCELLED. Only losers are cancelled here;
|
||||
// the winner's cancel travels with its body and fires on Close.
|
||||
startRoundTrip := func(useH3 bool) context.CancelFunc {
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
raceRequest := cloneRequestForRetry(request).WithContext(ctx)
|
||||
go func() {
|
||||
var (
|
||||
response *http.Response
|
||||
err error
|
||||
)
|
||||
if useH3 {
|
||||
response, err = t.h3Transport.RoundTrip(raceRequest)
|
||||
} else {
|
||||
response, err = t.h2FallbackRoundTrip(raceRequest)
|
||||
}
|
||||
results <- result{response: response, err: err, h3: useH3}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
goroutines := 1
|
||||
received := 0
|
||||
var fallbackCancel context.CancelFunc
|
||||
h3Cancel := startRoundTrip(true)
|
||||
drainRemaining := func() {
|
||||
cancel()
|
||||
for range goroutines - received {
|
||||
go func() {
|
||||
loser := <-results
|
||||
@@ -203,7 +237,6 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
}()
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), true)
|
||||
timer := time.NewTimer(t.fallbackDelay)
|
||||
defer timer.Stop()
|
||||
var (
|
||||
@@ -215,20 +248,28 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
case <-timer.C:
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
fallbackCancel = startRoundTrip(false)
|
||||
}
|
||||
case raceResult := <-results:
|
||||
received++
|
||||
if raceResult.err == nil {
|
||||
winnerCancel := fallbackCancel
|
||||
if raceResult.h3 {
|
||||
t.clearH3Broken(authority)
|
||||
winnerCancel = h3Cancel
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
} else {
|
||||
h3Cancel()
|
||||
}
|
||||
drainRemaining()
|
||||
return raceResult.response, nil
|
||||
return withCancelOnBodyClose(raceResult.response, winnerCancel), nil
|
||||
}
|
||||
if raceResult.h3 {
|
||||
t.markH3Broken(authority)
|
||||
h3Err = raceResult.err
|
||||
h3Cancel()
|
||||
if goroutines == 1 {
|
||||
goroutines++
|
||||
if !timer.Stop() {
|
||||
@@ -237,14 +278,21 @@ func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request, autho
|
||||
default:
|
||||
}
|
||||
}
|
||||
go startRoundTrip(cloneRequestForRetry(request), false)
|
||||
fallbackCancel = startRoundTrip(false)
|
||||
}
|
||||
} else {
|
||||
fallbackErr = raceResult.err
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
}
|
||||
if received < goroutines {
|
||||
continue
|
||||
}
|
||||
h3Cancel()
|
||||
if fallbackCancel != nil {
|
||||
fallbackCancel()
|
||||
}
|
||||
drainRemaining()
|
||||
switch {
|
||||
case h3Err != nil && fallbackErr != nil:
|
||||
|
||||
+227
-18
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +49,33 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
}()
|
||||
serverName := IndexTLSServerName(b)
|
||||
if serverName != nil {
|
||||
// The SNI extension carries a LIST of names; MyServerName.Length is
|
||||
// the length of the FIRST entry while MyServerName.ServerName is
|
||||
// everything left in the extension. Plan the cuts over the first
|
||||
// entry only: a second entry would otherwise be handed to the
|
||||
// public suffix list as if it were part of the name.
|
||||
name := serverName.ServerName
|
||||
if serverName.Length >= 0 && serverName.Length < len(name) {
|
||||
name = name[:serverName.Length]
|
||||
}
|
||||
// Packet fragmentation pays half a second per cut, record
|
||||
// fragmentation pays microseconds — see the budget constants.
|
||||
budget := recordCutBudget
|
||||
if c.splitPacket {
|
||||
budget = packetCutBudget
|
||||
}
|
||||
splitIndexes := cutOffsets(name, budget, rand.Intn)
|
||||
if len(splitIndexes) == 0 {
|
||||
// Nothing inside this name can be cut — it is empty or a single
|
||||
// byte, so there is no offset that leaves a non-empty piece on
|
||||
// both sides. Write the ClientHello as it stands: the loop
|
||||
// below reads b[:splitIndexes[0]] unconditionally and would
|
||||
// panic on an empty plan.
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
for i := range splitIndexes {
|
||||
splitIndexes[i] += serverName.Index
|
||||
}
|
||||
if c.splitPacket {
|
||||
if c.tcpConn != nil {
|
||||
err = c.tcpConn.SetNoDelay(true)
|
||||
@@ -55,24 +84,6 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
splits := strings.Split(serverName.ServerName, ".")
|
||||
currentIndex := serverName.Index
|
||||
if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" {
|
||||
splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
|
||||
}
|
||||
if len(splits) > 1 && splits[0] == "..." {
|
||||
currentIndex += len(splits[0]) + 1
|
||||
splits = splits[1:]
|
||||
}
|
||||
var splitIndexes []int
|
||||
for i, split := range splits {
|
||||
splitAt := rand.Intn(len(split))
|
||||
splitIndexes = append(splitIndexes, currentIndex+splitAt)
|
||||
currentIndex += len(split)
|
||||
if i != len(splits)-1 {
|
||||
currentIndex++
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
for i := 0; i <= len(splitIndexes); i++ {
|
||||
var payload []byte
|
||||
@@ -133,6 +144,204 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
// labelSpan is the half-open byte range [start, end) of one DNS label inside a
|
||||
// server name, relative to the first byte of that name.
|
||||
type labelSpan struct {
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// How many cuts Write may spend on one ClientHello. The two numbers differ
|
||||
// because the two modes cost completely different things per cut — MEASURED
|
||||
// 2026-07-27 on this tree, loopback peer, product default fallbackDelay
|
||||
// (500 ms), one ClientHello per row:
|
||||
//
|
||||
// cuts tls_fragment (*net.TCPConn) tls_fragment (proxy conn) tls_record_fragment
|
||||
// 1 502 ms 500 ms <1 ms
|
||||
// 2 1.004 s 1.001 s <1 ms
|
||||
// 4 2.008 s 2.002 s <1 ms
|
||||
// 8 4.015 s 4.003 s 539 µs
|
||||
// 21 10.540 s 10.509 s 525 µs
|
||||
//
|
||||
// So a cut in the PACKET modes costs half a second of connection setup, and it
|
||||
// costs that on BOTH branches: writeAndWaitAck sleeps the whole fallbackDelay
|
||||
// anyway whenever the ACK comes back inside 20 ms (its "under transparent
|
||||
// proxy" case), and N.UnwrapReader only reaches the *net.TCPConn when nothing
|
||||
// in the chain transforms the stream — which a proxy protocol conn always does,
|
||||
// so a proxied egress takes the flat-500 ms branch regardless of RTT. The
|
||||
// number of labels is chosen by whoever picked the hostname, so "a cut in every
|
||||
// label" made a 253-byte SNI worth ~42 s of one connection's setup.
|
||||
//
|
||||
// In tls_record_fragment nothing waits at all: the whole ClientHello leaves in
|
||||
// ONE write, split into more TLS records. 21 cuts cost 525 µs and 105 bytes of
|
||||
// record headers, and a real server (1.1.1.1) completed the handshake with the
|
||||
// ClientHello in 22 records in the same 77 ms it took with 2. That mode is
|
||||
// where "cut every label" was always affordable — and it is the mode the field
|
||||
// measurement that started this was taken in.
|
||||
//
|
||||
// recordCutBudget is therefore not a cost limit but a shape limit: real names
|
||||
// have one to three labels outside the public suffix, so 4 never binds on real
|
||||
// traffic, while a hostile 253-byte name cannot turn one ClientHello into 85
|
||||
// records that no ordinary client would ever emit.
|
||||
const (
|
||||
packetCutBudget = 1
|
||||
recordCutBudget = 4
|
||||
)
|
||||
|
||||
// cutOffsets plans where the ClientHello must be cut, in byte offsets relative
|
||||
// to the FIRST BYTE OF THE SERVER NAME, spending at most budget cuts. randIntn
|
||||
// is math/rand's Intn in production; a test hands in its own to make the plan
|
||||
// deterministic.
|
||||
//
|
||||
// A cut is only a cut if it lands STRICTLY INSIDE a label. Offset 0 of a label
|
||||
// is that label's own boundary: it leaves the label — the very string the DPI
|
||||
// box matches on — whole in the following segment. That is not theory. The old
|
||||
// code drew rand.Intn(len(label)), so a one-byte label could only ever produce
|
||||
// offset 0, and on the measured provider (blocks by name in the handshake)
|
||||
// m.youtube.com, tv.youtube.com and www.youtube.com were all blocked with the
|
||||
// cut sitting uselessly at the start of "m"/"tv"/"www", while the name itself
|
||||
// travelled intact in one segment. Hence a label shorter than two bytes carries
|
||||
// no cut at all.
|
||||
func cutOffsets(name string, budget int, randIntn func(n int) int) []int {
|
||||
var offsets []int
|
||||
for _, span := range cutCandidates(name) {
|
||||
if span.end-span.start < 2 {
|
||||
continue // no interior offset exists
|
||||
}
|
||||
offsets = append(offsets, cutInside(span, randIntn))
|
||||
if len(offsets) >= budget {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(offsets) == 0 && len(name) >= 2 {
|
||||
// No candidate label was long enough to cut on its own (a.b.co.uk).
|
||||
// Cut the name somewhere rather than hand it over in one piece: a
|
||||
// matcher looking for the whole FQDN still fails across the split, even
|
||||
// though no single label was severed.
|
||||
offsets = append(offsets, cutInside(labelSpan{start: 0, end: len(name)}, randIntn))
|
||||
}
|
||||
slices.Sort(offsets) // candidates are returned by priority, the wire wants order
|
||||
return offsets
|
||||
}
|
||||
|
||||
// cutInside draws an offset strictly inside span, from its MIDDLE THIRD.
|
||||
//
|
||||
// Every interior offset severs the label, but not equally well: a cut one byte
|
||||
// in leaves "outube" of "youtube", and a matcher keyed on a suffix or on a
|
||||
// six-byte substring still reads it. The middle leaves two short, unremarkable
|
||||
// halves. The draw stays random inside that third — a fixed point (say, exactly
|
||||
// the middle of the longest label) would be a constant a middlebox vendor can
|
||||
// special-case in one line, and the whole family of fragmentation tricks lives
|
||||
// on making reassembly the only counter.
|
||||
func cutInside(span labelSpan, randIntn func(n int) int) int {
|
||||
lo, hi := span.start+1, span.end-1 // the interior offsets, both inclusive
|
||||
if margin := (span.end - span.start - 1) / 3; margin > 0 {
|
||||
lo += margin
|
||||
hi -= margin
|
||||
}
|
||||
return lo + randIntn(hi-lo+1)
|
||||
}
|
||||
|
||||
// cutCandidates returns the labels of name that a cut may land in, MOST WORTH
|
||||
// CUTTING FIRST — which matters because the budget above is small.
|
||||
//
|
||||
// First is the registrable label: the one immediately left of the public
|
||||
// suffix. That is the label a name-based blocklist keys on ("youtube" of
|
||||
// youtube.com, www.youtube.com and studio.youtube.com alike, "ytimg" of
|
||||
// i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also breaks
|
||||
// any match on the whole FQDN, so one cut covers both matchers. It is chosen by
|
||||
// STRUCTURE, from the public suffix list — not by length, which is the trap the
|
||||
// old code fell into from the other side: in cdn-static-assets.youtube.com the
|
||||
// longest label is not the blocked one.
|
||||
//
|
||||
// The rest follow longest-first: among labels we have no structural reason to
|
||||
// rank, a long one is likelier to be a distinctive token than "www", "m" or
|
||||
// "tv". They are only reached when the budget allows more than one cut, or when
|
||||
// the registrable label is too short to cut.
|
||||
//
|
||||
// The public suffix itself is dropped because it is shared by everything under
|
||||
// it and carries none of the blocked word. WIDENING this set needs no proof,
|
||||
// NARROWING it does, so an input the public suffix list has no opinion about (a
|
||||
// trailing dot, an unmanaged TLD, a name that IS a suffix) keeps every label.
|
||||
// No branch here ends up with nothing to cut except the empty name, which has
|
||||
// nothing to cut by construction.
|
||||
func cutCandidates(name string) []labelSpan {
|
||||
spans := labelSpans(name)
|
||||
suffix := publicsuffix.List.PublicSuffix(name)
|
||||
switch {
|
||||
case len(spans) == 0:
|
||||
// name == "". Nothing to cut; Write sends the ClientHello unchanged.
|
||||
|
||||
case isIPLiteral(name):
|
||||
// An IP literal is not a name (RFC 6066 forbids it in SNI) and its dots
|
||||
// do not separate labels, so the public suffix list has nothing to say
|
||||
// about it — it returns the literal itself. Treat the whole literal as
|
||||
// one token: there is no name for a DPI box to read here, but the
|
||||
// caller asked for a fragmented handshake and gets one.
|
||||
return []labelSpan{{start: 0, end: len(name)}}
|
||||
|
||||
case suffix != "" && len(suffix) < len(name) && strings.HasSuffix(name, "."+suffix):
|
||||
// The ordinary case, and the one the old arithmetic got wrong: it
|
||||
// subtracted the number of dots in the WHOLE NAME, which — labels being
|
||||
// always one more than dots — left exactly one label, the FIRST, for
|
||||
// every name in existence. Subtract the number of labels in the SUFFIX
|
||||
// instead: "com" is one ("www.youtube.com" keeps www + youtube),
|
||||
// "co.uk" is two ("a.b.co.uk" keeps a + b).
|
||||
if keep := len(spans) - strings.Count(suffix, ".") - 1; keep > 0 {
|
||||
spans = spans[:keep]
|
||||
}
|
||||
|
||||
// Everything else — suffix == "" (a trailing dot, which the list
|
||||
// declines to parse), suffix == name (the name IS a public suffix:
|
||||
// "com", "co.uk", "localhost"), or a suffix that is somehow not a tail
|
||||
// of the name — keeps every label. Cutting inside a suffix costs a
|
||||
// segment and hides nothing that was not already hidden; NOT cutting is
|
||||
// the expensive mistake.
|
||||
}
|
||||
return byCutPriority(spans)
|
||||
}
|
||||
|
||||
// byCutPriority puts the registrable label first and orders the rest
|
||||
// longest-first. It never drops a span, so the budget — not this — decides how
|
||||
// many labels are actually cut.
|
||||
func byCutPriority(spans []labelSpan) []labelSpan {
|
||||
if len(spans) < 2 {
|
||||
return spans
|
||||
}
|
||||
out := make([]labelSpan, 0, len(spans))
|
||||
out = append(out, spans[len(spans)-1])
|
||||
rest := make([]labelSpan, len(spans)-1)
|
||||
copy(rest, spans[:len(spans)-1])
|
||||
slices.SortStableFunc(rest, func(a, b labelSpan) int {
|
||||
return (b.end - b.start) - (a.end - a.start)
|
||||
})
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// labelSpans splits name on '.' and returns the byte range of each label.
|
||||
// Empty labels (a leading, trailing or doubled dot) come back as zero-width
|
||||
// spans and are dropped by cutOffsets, which is what keeps a name like
|
||||
// ".youtube.com" away from rand.Intn(0) — that combination panicked.
|
||||
func labelSpans(name string) []labelSpan {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var spans []labelSpan
|
||||
start := 0
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if i == len(name) || name[i] == '.' {
|
||||
spans = append(spans, labelSpan{start: start, end: i})
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func isIPLiteral(name string) bool {
|
||||
_, err := netip.ParseAddr(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Conn) ReaderReplaceable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
package tf
|
||||
|
||||
// Cut planning: which label of the SNI gets a cut, where inside it, and how
|
||||
// many cuts one ClientHello is allowed to cost.
|
||||
//
|
||||
// WHY THIS FILE EXISTS (2026-07-27)
|
||||
// Conn.Write used to compute the labels to cut as
|
||||
//
|
||||
// splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
|
||||
//
|
||||
// which is identically splits[:1] for EVERY name, labels being always one
|
||||
// more than dots. Exactly one label was ever cut — the LEFTMOST — so on a
|
||||
// provider that blocks by the name in the handshake, youtube.com passed (its
|
||||
// first label IS the blocked word) while m./tv./www./music./studio.youtube.com
|
||||
// were all blocked, the cut sitting inside "m"/"tv"/"www" while "youtube"
|
||||
// travelled whole in the next segment. Measured on the router.
|
||||
//
|
||||
// Two more halves of the same defect:
|
||||
// - the offset came from rand.Intn(len(label)), whose 0 is the label's own
|
||||
// boundary and severs nothing. For a one-byte label that is the ONLY
|
||||
// value it can take;
|
||||
// - an EMPTY label reached rand.Intn(0) and panicked the process. Reachable
|
||||
// from the LAN: route/conn.go wraps the outbound with this and the
|
||||
// ClientHello it fragments is the client's. See
|
||||
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN.
|
||||
//
|
||||
// Every test below fails on the old expressions — see the mutation log.
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- deterministic draws -----------------------------------------------------
|
||||
|
||||
// minRand takes the lowest offset a label allows, maxRand the highest. Between
|
||||
// them they pin BOTH ends of the range, which is where the interesting failures
|
||||
// live: it is the ends that decide whether the label is severed or only touched.
|
||||
func minRand(int) int { return 0 }
|
||||
func maxRand(n int) int { return n - 1 }
|
||||
|
||||
func fixedRand(v int) func(int) int {
|
||||
return func(n int) int {
|
||||
if v >= n {
|
||||
return n - 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// severedLabel names the label that a cut at offset o splits in two, or says
|
||||
// why it splits none. This is the assertion vocabulary of the whole file: the
|
||||
// question is never "which number came out" but "which word did we break".
|
||||
func severedLabel(name string, o int) string {
|
||||
switch {
|
||||
case o <= 0 || o >= len(name):
|
||||
return "!outside the name"
|
||||
case name[o] == '.' || name[o-1] == '.':
|
||||
return "!a label boundary, nothing severed"
|
||||
}
|
||||
start := strings.LastIndexByte(name[:o], '.') + 1
|
||||
end := len(name)
|
||||
if i := strings.IndexByte(name[o:], '.'); i >= 0 {
|
||||
end = o + i
|
||||
}
|
||||
return name[start:end]
|
||||
}
|
||||
|
||||
func severedLabels(name string, offsets []int) []string {
|
||||
var out []string
|
||||
for _, o := range offsets {
|
||||
out = append(out, severedLabel(name, o))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- which label is cut ------------------------------------------------------
|
||||
|
||||
func TestCutOffsetsCutTheRegistrableLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
packet []string // labels severed with the packet budget (1 cut)
|
||||
record []string // ... and with the record budget (4 cuts), in wire order
|
||||
why string
|
||||
}{
|
||||
{name: "youtube.com", packet: []string{"youtube"}, record: []string{"youtube"}},
|
||||
{name: "www.youtube.com", packet: []string{"youtube"}, record: []string{"www", "youtube"},
|
||||
why: "THE regression: the old code cut www and shipped youtube whole"},
|
||||
{name: "m.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "a one-byte label has no interior offset and carries no cut"},
|
||||
{name: "tv.youtube.com", packet: []string{"youtube"}, record: []string{"tv", "youtube"}},
|
||||
{name: "music.youtube.com", packet: []string{"youtube"}, record: []string{"music", "youtube"}},
|
||||
{name: "studio.youtube.com", packet: []string{"youtube"}, record: []string{"studio", "youtube"}},
|
||||
{name: "cdn-static-assets.youtube.com", packet: []string{"youtube"}, record: []string{"cdn-static-assets", "youtube"},
|
||||
why: "the LONGEST label is not the blocked one — structure decides, not length"},
|
||||
{name: "foo.bar.baz.youtube.com", packet: []string{"youtube"}, record: []string{"foo", "bar", "baz", "youtube"},
|
||||
why: "four candidates, four cuts, and the budget stops there"},
|
||||
{name: "a.b.c.d.e.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "five one-byte labels: the budget is never even reached"},
|
||||
{name: "i9.ytimg.com", packet: []string{"ytimg"}, record: []string{"i9", "ytimg"}},
|
||||
{name: "example.co.uk", packet: []string{"example"}, record: []string{"example"},
|
||||
why: "co.uk is TWO labels of public suffix"},
|
||||
{name: "a.b.example.co.uk", packet: []string{"example"}, record: []string{"example"}},
|
||||
{name: "example.com.br", packet: []string{"example"}, record: []string{"example"}},
|
||||
{name: "site.pp.ru", packet: []string{"site"}, record: []string{"site"},
|
||||
why: "pp.ru is a private two-label suffix"},
|
||||
{name: "localhost", packet: []string{"localhost"}, record: []string{"localhost"},
|
||||
why: "unmanaged TLD: the list returns the whole name, so cut it"},
|
||||
{name: "com", packet: []string{"com"}, record: []string{"com"}},
|
||||
{name: "co.uk", packet: []string{"uk"}, record: []string{"co", "uk"},
|
||||
why: "the name IS the suffix: keep every label rather than cut nothing"},
|
||||
{name: ".youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "leading dot: the empty label is skipped, NOT fed to rand.Intn(0)"},
|
||||
{name: "youtube.com.", packet: []string{"youtube"}, record: []string{"youtube", "com"},
|
||||
why: "trailing dot: the list declines to parse it, so every label stays a candidate"},
|
||||
{name: "WWW.YouTube.COM", packet: []string{"YouTube"}, record: []string{"WWW", "YouTube"}},
|
||||
{name: "ab", packet: []string{"ab"}, record: []string{"ab"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, draw := range []struct {
|
||||
label string
|
||||
fn func(int) int
|
||||
}{{"lowest", minRand}, {"highest", maxRand}} {
|
||||
got := severedLabels(tc.name, cutOffsets(tc.name, packetCutBudget, draw.fn))
|
||||
require.Equal(t, tc.packet, got, "%s draw, packet budget: %s", draw.label, tc.why)
|
||||
got = severedLabels(tc.name, cutOffsets(tc.name, recordCutBudget, draw.fn))
|
||||
require.Equal(t, tc.record, got, "%s draw, record budget: %s", draw.label, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsSeverTheBlockedLabel is the field measurement turned into an
|
||||
// instrument. On the measured provider these names differ only in the label in
|
||||
// front of "youtube", and five of the six were blocked. What has to hold — for
|
||||
// every draw and both budgets, not for most of them — is that the byte range of
|
||||
// the blocked word straddles a cut.
|
||||
func TestCutOffsetsSeverTheBlockedLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, blocked string }{
|
||||
{"youtube.com", "youtube"},
|
||||
{"m.youtube.com", "youtube"},
|
||||
{"tv.youtube.com", "youtube"},
|
||||
{"www.youtube.com", "youtube"},
|
||||
{"music.youtube.com", "youtube"},
|
||||
{"studio.youtube.com", "youtube"},
|
||||
{"cdn-static-assets.youtube.com", "youtube"},
|
||||
{"i9.ytimg.com", "ytimg"},
|
||||
{"a.b.example.co.uk", "example"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
start := strings.Index(tc.name, tc.blocked)
|
||||
require.GreaterOrEqual(t, start, 0)
|
||||
end := start + len(tc.blocked)
|
||||
// Every draw the label can take, not a sample: the range is small
|
||||
// enough to enumerate, so there is no "it passed 1000 times" here.
|
||||
for draw := 0; draw < len(tc.name); draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(tc.name, budget, fixedRand(draw))
|
||||
severed := false
|
||||
for _, o := range offsets {
|
||||
if o > start && o < end {
|
||||
severed = true
|
||||
}
|
||||
}
|
||||
require.True(t, severed,
|
||||
"draw %d, budget %d: %q got cuts at %v (%v), none inside %q [%d,%d)",
|
||||
draw, budget, tc.name, offsets, severedLabels(tc.name, offsets), tc.blocked, start, end)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsStayInTheMiddleThird: every interior offset severs the label,
|
||||
// but not equally well — one byte in leaves "outube" of "youtube", which a
|
||||
// matcher keyed on a substring still reads. Both halves must keep at least
|
||||
// (width-1)/3 + 1 bytes.
|
||||
func TestCutOffsetsStayInTheMiddleThird(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{
|
||||
"youtube.com", "www.youtube.com", "cdn-static-assets.youtube.com",
|
||||
"music.youtube.com", "example.co.uk", "ab.example.com", "localhost",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for draw := 0; draw < 64; draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
for _, o := range cutOffsets(name, budget, fixedRand(draw)) {
|
||||
label := severedLabel(name, o)
|
||||
require.NotContains(t, label, "!", "draw %d: cut at %d in %q severed nothing", draw, o, name)
|
||||
start := strings.Index(name, label)
|
||||
width := len(label)
|
||||
margin := (width-1)/3 + 1
|
||||
require.GreaterOrEqual(t, o-start, margin,
|
||||
"draw %d: cut at %d leaves only %d byte(s) of %q on the left", draw, o, o-start, label)
|
||||
require.GreaterOrEqual(t, start+width-o, margin,
|
||||
"draw %d: cut at %d leaves only %d byte(s) of %q on the right", draw, o, start+width-o, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsRespectTheBudget: the budget is what bounds a hostile name's
|
||||
// cost — a measured 500 ms of connection setup per cut in the packet modes.
|
||||
func TestCutOffsetsRespectTheBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
var long strings.Builder
|
||||
for i := 0; i < 40; i++ {
|
||||
long.WriteString("lb.")
|
||||
}
|
||||
long.WriteString("example.com") // 40 cuttable labels plus the registrable one
|
||||
for _, budget := range []int{1, 2, 3, 4} {
|
||||
require.Len(t, cutOffsets(long.String(), budget, rand.Intn), budget, "budget %d", budget)
|
||||
}
|
||||
require.Len(t, cutOffsets(long.String(), packetCutBudget, rand.Intn), 1,
|
||||
"a 253-byte SNI must not be able to buy more than one 500 ms wait")
|
||||
require.Len(t, cutOffsets(long.String(), recordCutBudget, rand.Intn), 4,
|
||||
"nor more than five records")
|
||||
}
|
||||
|
||||
// TestCutOffsetsFallBackWhenNoLabelCanBeCut covers the names where NO candidate
|
||||
// label has an interior offset. Severing a label is impossible there, so what
|
||||
// is checked is that a cut still happens and still lands inside the buffer: a
|
||||
// matcher keyed on the whole FQDN fails across it.
|
||||
func TestCutOffsetsFallBackWhenNoLabelCanBeCut(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"a.b.co.uk", "x.pp.ru", "a.b.c.d", "1.2.3.4", "::1", "x.com"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for draw := 0; draw < len(name)+4; draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(name, budget, fixedRand(draw))
|
||||
require.NotEmpty(t, offsets, "%q went out in one piece", name)
|
||||
require.Greater(t, offsets[0], 0)
|
||||
require.Less(t, offsets[len(offsets)-1], len(name))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsAreOrderedAndDistinct: the write loop slices b between
|
||||
// consecutive offsets, so anything out of order or repeated is an empty or
|
||||
// negative segment on the wire. Candidates come back in PRIORITY order, which
|
||||
// is not wire order — this is the test that the sort is not forgotten.
|
||||
func TestCutOffsetsAreOrderedAndDistinct(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{
|
||||
"www.youtube.com", "foo.bar.baz.youtube.com", "cdn-static-assets.youtube.com",
|
||||
"a.bb.ccc.dddd.example.com", "youtube.com.", ".youtube.com", "co.uk",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
offsets := cutOffsets(name, recordCutBudget, rand.Intn)
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
require.Greater(t, o, prev, "%q: offsets %v are not strictly increasing", name, offsets)
|
||||
prev = o
|
||||
}
|
||||
require.Less(t, prev, len(name))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- the arithmetic must not panic on anything ------------------------------
|
||||
|
||||
// adversarialNames is the closed list of shapes that reach the arithmetic from
|
||||
// outside: empty and one-byte names, every position a dot can take, names that
|
||||
// are nothing but dots, names at the 253-byte limit, and bytes that are not
|
||||
// ASCII at all. The unit test, the fuzz seed corpus and the end-to-end test all
|
||||
// draw from it, so all three see the same inputs.
|
||||
func adversarialNames() []string {
|
||||
return []string{
|
||||
"", "a", ".", "..", "...", "....",
|
||||
".com", "com.", ".com.", ".youtube.com", "youtube.com.", ".youtube.com.",
|
||||
"a..b.example.com", "..youtube..com..", "-.-.-.-", "-", "--",
|
||||
"xn--p1ai", "test.xn--p1ai", "xn--", ".xn--p1ai.",
|
||||
"\xff\xfe.example.com", "\x00\x00.com", "пример.рф", "\xff",
|
||||
strings.Repeat("a", 253),
|
||||
strings.Repeat("ab.", 84) + "a", // 253 bytes, 85 labels
|
||||
strings.Repeat(".", 253),
|
||||
strings.Repeat("a.", 126) + "a",
|
||||
"1.2.3.4", "::1", "::ffff:1.2.3.4", "fe80::1%eth0", "0.0.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutOffsetsSurviveEveryAdversarialName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range adversarialNames() {
|
||||
t.Run(strings.ToValidUTF8(name, "?"), func(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(name, budget, rand.Intn) // must not panic
|
||||
require.LessOrEqual(t, len(offsets), budget)
|
||||
if len(name) >= 2 {
|
||||
require.NotEmpty(t, offsets, "%q is long enough to cut and was not cut", name)
|
||||
} else {
|
||||
require.Empty(t, offsets, "%q has no offset that leaves bytes on both sides", name)
|
||||
}
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
require.Greater(t, o, prev)
|
||||
require.Less(t, o, len(name))
|
||||
prev = o
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzCutOffsets is the open half of the audit above: the closed list says what
|
||||
// we thought of, this says whether anything else reaches rand.Intn with a
|
||||
// non-positive argument or produces an offset the write loop cannot slice at.
|
||||
// Under plain `go test` it runs the seed corpus, which is that closed list.
|
||||
func FuzzCutOffsets(f *testing.F) {
|
||||
for _, name := range adversarialNames() {
|
||||
f.Add(name, packetCutBudget)
|
||||
f.Add(name, recordCutBudget)
|
||||
}
|
||||
for _, name := range []string{"www.youtube.com", "a.b.example.co.uk", "localhost"} {
|
||||
f.Add(name, 1)
|
||||
f.Add(name, 4)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, name string, budget int) {
|
||||
if budget < 0 {
|
||||
budget = -budget
|
||||
}
|
||||
budget = budget%recordCutBudget + 1 // 1..4, never zero or negative
|
||||
offsets := cutOffsets(name, budget, rand.Intn)
|
||||
if len(offsets) > budget {
|
||||
t.Fatalf("%q: %d offsets for a budget of %d", name, len(offsets), budget)
|
||||
}
|
||||
if len(name) >= 2 && len(offsets) == 0 {
|
||||
t.Fatalf("%q (%d bytes) was handed over in one piece", name, len(name))
|
||||
}
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
if o <= prev || o >= len(name) {
|
||||
t.Fatalf("%q: offsets %v are not strictly increasing inside [1,%d)", name, offsets, len(name))
|
||||
}
|
||||
prev = o
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- end to end: what actually goes out on the wire -------------------------
|
||||
|
||||
// fakeConn records every Write. It is deliberately NOT a *net.TCPConn, which is
|
||||
// also the common production case (the outbound is usually a proxy stream whose
|
||||
// reader transforms the bytes, so N.UnwrapReader stops there), so Conn.Write
|
||||
// takes the sleep-instead-of-ACK path — hence the 1ns fallback delay the tests
|
||||
// below pass to NewConn.
|
||||
type fakeConn struct {
|
||||
writes [][]byte
|
||||
}
|
||||
|
||||
func (c *fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *fakeConn) Close() error { return nil }
|
||||
func (c *fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (c *fakeConn) Write(b []byte) (int, error) {
|
||||
c.writes = append(c.writes, append([]byte(nil), b...))
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// clientHelloFor produces a real ClientHello for serverName by letting
|
||||
// crypto/tls build one and capturing the first write.
|
||||
func clientHelloFor(t *testing.T, serverName string) []byte {
|
||||
t.Helper()
|
||||
rec := &fakeConn{}
|
||||
_ = tls.Client(rec, &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}).Handshake()
|
||||
require.NotEmpty(t, rec.writes, "crypto/tls wrote no ClientHello for %q", serverName)
|
||||
hello := rec.writes[0]
|
||||
// Control on the instrument: the parser this package ships must find the
|
||||
// name we asked for, otherwise the assertions below prove nothing.
|
||||
sni := IndexTLSServerName(hello)
|
||||
require.NotNil(t, sni, "IndexTLSServerName found no SNI in the generated ClientHello")
|
||||
require.Equal(t, serverName, sni.ServerName)
|
||||
return hello
|
||||
}
|
||||
|
||||
// buildClientHello assembles a ClientHello by hand around a server_name_list of
|
||||
// the given entries. crypto/tls will not emit a name with a leading or trailing
|
||||
// dot, an IP literal, an empty name or a second list entry — hostnameInSNI
|
||||
// rewrites or refuses all of them — and those are exactly the shapes a
|
||||
// forwarded ClientHello from a LAN client can carry.
|
||||
func buildClientHello(t *testing.T, entries ...string) []byte {
|
||||
t.Helper()
|
||||
var list []byte
|
||||
for _, e := range entries {
|
||||
list = append(list, sniNameDNSHostnameType)
|
||||
list = binary.BigEndian.AppendUint16(list, uint16(len(e)))
|
||||
list = append(list, e...)
|
||||
}
|
||||
extBody := binary.BigEndian.AppendUint16(nil, uint16(len(list)))
|
||||
extBody = append(extBody, list...)
|
||||
|
||||
ext := binary.BigEndian.AppendUint16(nil, sniExtensionType)
|
||||
ext = binary.BigEndian.AppendUint16(ext, uint16(len(extBody)))
|
||||
ext = append(ext, extBody...)
|
||||
|
||||
extensions := binary.BigEndian.AppendUint16(nil, uint16(len(ext)))
|
||||
extensions = append(extensions, ext...)
|
||||
|
||||
body := []byte{0x03, 0x03} // client_version TLS 1.2
|
||||
body = append(body, make([]byte, 32)...) // random
|
||||
body = append(body, 0x00) // session_id length
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites
|
||||
body = append(body, 0x01, 0x00) // compression_methods
|
||||
body = append(body, extensions...)
|
||||
handshake := []byte{handshakeType, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
|
||||
handshake = append(handshake, body...)
|
||||
|
||||
record := []byte{contentType, 0x03, 0x01}
|
||||
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
|
||||
record = append(record, handshake...)
|
||||
|
||||
// Control on the instrument: this hand-built record must parse the way a
|
||||
// real one does, or the tests below are testing a straw man.
|
||||
sni := IndexTLSServerName(record)
|
||||
require.NotNil(t, sni, "hand-built ClientHello did not parse")
|
||||
require.Equal(t, len(entries[0]), sni.Length, "Length must be the FIRST entry")
|
||||
require.Equal(t, entries[0], string(record[sni.Index:sni.Index+sni.Length]))
|
||||
return record
|
||||
}
|
||||
|
||||
// patchSNI rewrites the server name inside a ClientHello in place. from and to
|
||||
// must be the same length, so every length field in the record stays valid.
|
||||
func patchSNI(t *testing.T, hello []byte, from, to string) []byte {
|
||||
t.Helper()
|
||||
require.Equal(t, len(from), len(to), "patchSNI cannot change the length")
|
||||
at := IndexTLSServerName(hello)
|
||||
require.NotNil(t, at)
|
||||
require.Equal(t, from, at.ServerName)
|
||||
out := append([]byte(nil), hello...)
|
||||
copy(out[at.Index:], to)
|
||||
sni := IndexTLSServerName(out)
|
||||
require.NotNil(t, sni)
|
||||
require.Equal(t, to, sni.ServerName)
|
||||
return out
|
||||
}
|
||||
|
||||
// segments returns, for one recorded run, the payload of every segment written
|
||||
// and the absolute offsets in hello at which the cuts fell.
|
||||
func segments(t *testing.T, hello []byte, writes [][]byte, recordFragment bool) ([][]byte, []int) {
|
||||
t.Helper()
|
||||
var payloads [][]byte
|
||||
for _, w := range writes {
|
||||
if !recordFragment {
|
||||
payloads = append(payloads, w)
|
||||
continue
|
||||
}
|
||||
// A record-fragmented write is one or more TLS records: 3 bytes of the
|
||||
// original header, a 2-byte length, then the payload.
|
||||
for len(w) > 0 {
|
||||
require.GreaterOrEqual(t, len(w), recordLayerHeaderLen, "truncated record header")
|
||||
require.Equal(t, hello[:3], w[:3], "record header is not the ClientHello's own")
|
||||
n := int(binary.BigEndian.Uint16(w[3:5]))
|
||||
require.LessOrEqual(t, recordLayerHeaderLen+n, len(w), "record length runs past the write")
|
||||
payloads = append(payloads, w[recordLayerHeaderLen:recordLayerHeaderLen+n])
|
||||
w = w[recordLayerHeaderLen+n:]
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, payloads, "Write returned without putting anything on the wire")
|
||||
// Cut offsets are the cumulative payload lengths, shifted past the record
|
||||
// header that the first fragment drops.
|
||||
offset := 0
|
||||
if recordFragment {
|
||||
offset = recordLayerHeaderLen
|
||||
}
|
||||
var cuts []int
|
||||
for _, p := range payloads[:len(payloads)-1] {
|
||||
offset += len(p)
|
||||
cuts = append(cuts, offset)
|
||||
}
|
||||
return payloads, cuts
|
||||
}
|
||||
|
||||
type writeMode struct {
|
||||
name string
|
||||
splitPacket bool
|
||||
splitRecord bool
|
||||
recordFraming bool
|
||||
segmentPerCall bool // one Write call per segment
|
||||
budget int
|
||||
}
|
||||
|
||||
var writeModes = []writeMode{
|
||||
{name: "tls_fragment", splitPacket: true, segmentPerCall: true, budget: packetCutBudget},
|
||||
{name: "tls_record_fragment", splitRecord: true, recordFraming: true, budget: recordCutBudget},
|
||||
{name: "both", splitPacket: true, splitRecord: true, recordFraming: true, segmentPerCall: true, budget: packetCutBudget},
|
||||
}
|
||||
|
||||
// TestWriteSeversTheBlockedLabelOnTheWire is the end-to-end control: not "the
|
||||
// planner returned nice numbers" but "the bytes that left the socket have the
|
||||
// blocked label straddling a segment boundary", for every mode the presets
|
||||
// expose, over many real random draws.
|
||||
func TestWriteSeversTheBlockedLabelOnTheWire(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range writeModes {
|
||||
for _, tc := range []struct{ name, blocked string }{
|
||||
{"youtube.com", "youtube"}, // the ONE name the old code got right
|
||||
{"www.youtube.com", "youtube"}, // the regression
|
||||
{"m.youtube.com", "youtube"}, // one-byte label in front
|
||||
{"music.youtube.com", "youtube"},
|
||||
{"cdn-static-assets.youtube.com", "youtube"},
|
||||
{"a.b.example.co.uk", "example"}, // two-label public suffix
|
||||
} {
|
||||
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := clientHelloFor(t, tc.name)
|
||||
sniAt := IndexTLSServerName(hello).Index
|
||||
start := sniAt + strings.Index(tc.name, tc.blocked)
|
||||
end := start + len(tc.blocked)
|
||||
for i := 0; i < 100; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n, "Write must report the length of the buffer it was given")
|
||||
_, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
require.NotEmpty(t, cuts, "the ClientHello went out in one piece")
|
||||
require.LessOrEqual(t, len(cuts), mode.budget, "more cuts than this mode's budget")
|
||||
severed := false
|
||||
for _, c := range cuts {
|
||||
if c > start && c < end {
|
||||
severed = true
|
||||
}
|
||||
}
|
||||
require.True(t, severed,
|
||||
"run %d: %q left with cuts at %v, none inside %q [%d,%d)",
|
||||
i, tc.name, cuts, tc.blocked, start, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteReassemblesToTheOriginalClientHello: cutting may change how the
|
||||
// bytes are packaged and nothing else. Byte-for-byte, plus the length Write
|
||||
// reports, plus the segment count implied by the plan.
|
||||
func TestWriteReassemblesToTheOriginalClientHello(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range writeModes {
|
||||
for _, serverName := range []string{
|
||||
"www.youtube.com", "youtube.com", "a.b.example.co.uk", "localhost", "a",
|
||||
"foo.bar.baz.youtube.com",
|
||||
} {
|
||||
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := clientHelloFor(t, serverName)
|
||||
for i := 0; i < 50; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n)
|
||||
|
||||
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p, "empty segment: a cut of zero length went out on the wire")
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
// The record header is re-emitted per fragment, so what
|
||||
// must survive is the handshake body.
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "run %d: the reassembled ClientHello differs from the original", i)
|
||||
if mode.segmentPerCall {
|
||||
require.Len(t, out.writes, len(cuts)+1, "one Write call per segment")
|
||||
} else {
|
||||
require.Len(t, out.writes, 1, "record fragmentation without packet fragmentation is a single write")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN is the regression for a
|
||||
// PROCESS DEATH that shipped in v0.2.21.
|
||||
//
|
||||
// route/conn.go wraps the outbound connection with this Conn and fragments the
|
||||
// ClientHello the LAN client sent, so the server name is chosen by the client,
|
||||
// not by us. An empty label made cutOffsets call rand.Intn(0) — "panic: invalid
|
||||
// argument to Intn" — and a Go panic in a connection goroutine takes the whole
|
||||
// daemon with it. Two ordinary ways to produce one:
|
||||
//
|
||||
// "youtube.com." a fully qualified name with the root dot, which curl and
|
||||
// every browser will happily send, and for which the public
|
||||
// suffix list returns "" so the trailing empty label survived;
|
||||
// ".youtube.com" a leading dot, which nothing legitimate sends but nothing
|
||||
// stops a client from writing into its own ClientHello.
|
||||
//
|
||||
// With the kill switch armed the daemon's death is not a slow connection, it is
|
||||
// a dark LAN until procd restarts it — into the same request.
|
||||
func TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ from, to, why string }{
|
||||
{from: "youtube.comx", to: "youtube.com.", why: "the FQDN root dot — a legitimate name"},
|
||||
{from: "xyoutube.com", to: ".youtube.com", why: "leading dot"},
|
||||
{from: "xyoutube.comx", to: ".youtube.com.", why: "both"},
|
||||
{from: "ax.example.com", to: "a..example.com", why: "a doubled dot mid-name"},
|
||||
{from: "xxxxxxxxxxxx", to: "............", why: "nothing but dots"},
|
||||
{from: "1x2x3x4", to: "1.2.3.4", why: "an IP literal, which RFC 6066 forbids in SNI"},
|
||||
{from: "xxx", to: "::1", why: "an IPv6 literal"},
|
||||
{from: "a", to: "a", why: "one byte: no cut exists, and the empty plan must not be indexed"},
|
||||
{from: "ab", to: "ab", why: "two bytes: exactly one interior offset"},
|
||||
{from: "\xff\xfe.example.com", to: "\xff\xfe.example.com", why: "bytes that are not ASCII"},
|
||||
} {
|
||||
t.Run(strings.ToValidUTF8(tc.to, "?"), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := patchSNI(t, clientHelloFor(t, tc.from), tc.from, tc.to)
|
||||
for _, mode := range writeModes {
|
||||
for i := 0; i < 50; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err, "%s: %s", mode.name, tc.why)
|
||||
require.Equal(t, len(hello), n, "%s: %s", mode.name, tc.why)
|
||||
payloads, _ := segments(t, hello, out.writes, mode.recordFraming)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p)
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "%s run %d: %s", mode.name, i, tc.why)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteHandlesServerNameListsCryptoTLSWillNotEmit reaches the shapes that
|
||||
// need a hand-built record: a zero-length name, a name at the 253-byte limit,
|
||||
// and a list carrying a SECOND entry — which MyServerName.ServerName includes
|
||||
// and MyServerName.Length does not, so cut planning must run on the first entry
|
||||
// alone or it feeds the public suffix list bytes that belong to no name.
|
||||
func TestWriteHandlesServerNameListsCryptoTLSWillNotEmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
entries []string
|
||||
cut bool // must the ClientHello leave in more than one piece?
|
||||
}{
|
||||
{title: "empty name", entries: []string{""}, cut: false},
|
||||
{title: "one byte", entries: []string{"a"}, cut: false},
|
||||
{title: "253 bytes, 85 labels", entries: []string{strings.Repeat("ab.", 84) + "a"}, cut: true},
|
||||
{title: "253 bytes, one label", entries: []string{strings.Repeat("a", 253)}, cut: true},
|
||||
{title: "two entries", entries: []string{"www.youtube.com", "evil.example.com"}, cut: true},
|
||||
{title: "two entries, first empty", entries: []string{"", "www.youtube.com"}, cut: false},
|
||||
{title: "trailing dot", entries: []string{"youtube.com."}, cut: true},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := buildClientHello(t, tc.entries...)
|
||||
for _, mode := range writeModes {
|
||||
for i := 0; i < 20; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err, mode.name)
|
||||
require.Equal(t, len(hello), n, mode.name)
|
||||
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
require.Equal(t, tc.cut, len(cuts) > 0, "%s: expected cut=%v, got %d cut(s)", mode.name, tc.cut, len(cuts))
|
||||
require.LessOrEqual(t, len(cuts), mode.budget, mode.name)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p)
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "%s run %d", mode.name, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCutsTheFirstEntryOfTheServerNameList: with two entries the cut must
|
||||
// land inside "youtube" of the FIRST one. Planning over the whole remainder of
|
||||
// the extension would hand the public suffix list a string that is not a name
|
||||
// and put the cut somewhere else entirely.
|
||||
func TestWriteCutsTheFirstEntryOfTheServerNameList(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := buildClientHello(t, "www.youtube.com", "cdn-static-assets.example.com")
|
||||
sni := IndexTLSServerName(hello)
|
||||
start := sni.Index + strings.Index("www.youtube.com", "youtube")
|
||||
end := start + len("youtube")
|
||||
for i := 0; i < 200; i++ {
|
||||
out := &fakeConn{}
|
||||
_, err := NewConn(out, t.Context(), true, false, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
_, cuts := segments(t, hello, out.writes, false)
|
||||
require.Len(t, cuts, 1)
|
||||
require.Greater(t, cuts[0], start, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
|
||||
require.Less(t, cuts[0], end, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithoutSNIIsUntouched: the fast path must stay a straight pass, and
|
||||
// the second and later writes must never be re-planned.
|
||||
func TestWriteWithoutSNIIsUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := []byte("not a tls record at all")
|
||||
out := &fakeConn{}
|
||||
conn := NewConn(out, t.Context(), true, true, time.Nanosecond)
|
||||
n, err := conn.Write(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(payload), n)
|
||||
require.Len(t, out.writes, 1)
|
||||
require.Equal(t, payload, out.writes[0])
|
||||
|
||||
hello := clientHelloFor(t, "www.youtube.com")
|
||||
n, err = conn.Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n)
|
||||
require.Len(t, out.writes, 2, "a ClientHello after the first write must not be fragmented")
|
||||
require.Equal(t, hello, out.writes[1])
|
||||
}
|
||||
@@ -25,6 +25,21 @@ func requireRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// requireTCPDump skips when tcpdump is not installed.
|
||||
//
|
||||
// The same honesty this package's callers demand of a health reading: a missing
|
||||
// INSTRUMENT is "not checked", never "broken". Without it every test in this
|
||||
// file fails on `cmd.Start()` — sixteen red results that say nothing about the
|
||||
// code and hide any real failure among them — on a machine where the only thing
|
||||
// wrong is that a capture tool is absent. requireRoot has always drawn that line
|
||||
// for privileges; this draws it for the tool.
|
||||
func requireTCPDump(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("tcpdump"); err != nil {
|
||||
t.Skip("integration test requires tcpdump on PATH; install it to run this suite")
|
||||
}
|
||||
}
|
||||
|
||||
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
|
||||
t.Helper()
|
||||
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
|
||||
@@ -36,6 +51,9 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
|
||||
// the wire.
|
||||
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
|
||||
t.Helper()
|
||||
// Every capture in this file funnels through here, so one guard covers the
|
||||
// whole suite and no future test can forget it.
|
||||
requireTCPDump(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// lx:begin health-board
|
||||
|
||||
package urltest
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
)
|
||||
|
||||
// captureEvictions swaps the eviction notice sink for the duration of a test and
|
||||
// returns a func that reads back everything reported.
|
||||
func captureEvictions(t *testing.T) func() []string {
|
||||
t.Helper()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
msgs []string
|
||||
)
|
||||
orig := boardEvictionLog
|
||||
boardEvictionLog = func(m string) {
|
||||
mu.Lock()
|
||||
msgs = append(msgs, m)
|
||||
mu.Unlock()
|
||||
}
|
||||
t.Cleanup(func() { boardEvictionLog = orig })
|
||||
return func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), msgs...)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoardHoldsAGenerationWithoutEvicting is the "what it holds" half of the
|
||||
// bound. A live generation on this box is ~1200 tags (≈380 nodes plus their
|
||||
// per-group egress copies and chain hops); the board must carry that — and a
|
||||
// second generation's worth of overlap during a subscription rename — with no
|
||||
// eviction at all, or the ceiling would be silently degrading real health data.
|
||||
func TestBoardHoldsAGenerationWithoutEvicting(t *testing.T) {
|
||||
read := captureEvictions(t)
|
||||
s := NewHistoryStorage()
|
||||
|
||||
const generation = 1200
|
||||
for gen := 0; gen < 2; gen++ {
|
||||
for i := 0; i < generation; i++ {
|
||||
s.StoreURLTestHistory("gen"+strconv.Itoa(gen)+"-node-"+strconv.Itoa(i),
|
||||
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 20})
|
||||
}
|
||||
}
|
||||
if got := s.Evicted(); got != 0 {
|
||||
t.Fatalf("two full generations (%d tags) evicted %d entries; the board must hold them",
|
||||
2*generation, got)
|
||||
}
|
||||
if msgs := read(); len(msgs) != 0 {
|
||||
t.Fatalf("unexpected eviction notices: %v", msgs)
|
||||
}
|
||||
// Everything is still readable.
|
||||
if s.LoadURLTestHistory("gen0-node-0") == nil {
|
||||
t.Fatalf("the first tag of the first generation was lost without an eviction")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoardEvictsOldestAndSaysSo is the "what happens when it overflows" half.
|
||||
// Overflow must (a) actually bound the map, (b) drop the LEAST RECENTLY MEASURED
|
||||
// tags — on this box, exactly the ones no config names any more — and (c) be
|
||||
// audible: a silent eviction is a health board quietly forgetting nodes it is
|
||||
// still being asked about.
|
||||
func TestBoardEvictsOldestAndSaysSo(t *testing.T) {
|
||||
read := captureEvictions(t)
|
||||
s := NewHistoryStorage()
|
||||
|
||||
base := time.Now().Add(-24 * time.Hour)
|
||||
// Stale generation first: measured a day ago, nothing since.
|
||||
const stale = 1500
|
||||
for i := 0; i < stale; i++ {
|
||||
s.StoreURLTestHistory("stale-"+strconv.Itoa(i),
|
||||
&adapter.URLTestHistory{LastOK: base.Add(time.Duration(i) * time.Millisecond), Delay: 30})
|
||||
}
|
||||
if s.Evicted() != 0 {
|
||||
t.Fatalf("evicted before the ceiling was reached")
|
||||
}
|
||||
// Now push past the ceiling with fresh measurements.
|
||||
for i := 0; i <= maxBoardEntries; i++ {
|
||||
s.StoreURLTestHistory("fresh-"+strconv.Itoa(i),
|
||||
&adapter.URLTestHistory{LastOK: time.Now(), Delay: 15})
|
||||
}
|
||||
|
||||
if got := s.Evicted(); got == 0 {
|
||||
t.Fatalf("board grew past %d entries without evicting anything — it is still unbounded", maxBoardEntries)
|
||||
}
|
||||
s.access.RLock()
|
||||
size := len(s.delayHistory)
|
||||
s.access.RUnlock()
|
||||
if size > maxBoardEntries {
|
||||
t.Fatalf("board holds %d entries, above the %d ceiling", size, maxBoardEntries)
|
||||
}
|
||||
|
||||
// The day-old generation is what went, not the fresh one.
|
||||
if s.LoadURLTestHistory("stale-0") != nil {
|
||||
t.Fatalf("the oldest observation survived while newer ones were dropped")
|
||||
}
|
||||
if s.LoadURLTestHistory("fresh-"+strconv.Itoa(maxBoardEntries)) == nil {
|
||||
t.Fatalf("the newest measurement was evicted")
|
||||
}
|
||||
|
||||
msgs := read()
|
||||
if len(msgs) == 0 {
|
||||
t.Fatalf("entries were evicted with no notice — eviction must never be silent")
|
||||
}
|
||||
m := msgs[0]
|
||||
for _, want := range []string{"health board full", "evicted", "re-probed"} {
|
||||
if !strings.Contains(m, want) {
|
||||
t.Fatalf("eviction notice %q does not say %q", m, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoardEvictionThroughMarkFailed pins the OTHER write path. MarkFailed is how
|
||||
// a dead node is recorded, and a flood of dead renamed nodes is exactly the shape
|
||||
// of the leak — so it has to prune too, not just the success path.
|
||||
func TestBoardEvictionThroughMarkFailed(t *testing.T) {
|
||||
captureEvictions(t)
|
||||
s := NewHistoryStorage()
|
||||
for i := 0; i <= maxBoardEntries; i++ {
|
||||
s.MarkFailed("dead-" + strconv.Itoa(i))
|
||||
}
|
||||
s.access.RLock()
|
||||
size := len(s.delayHistory)
|
||||
s.access.RUnlock()
|
||||
if size > maxBoardEntries {
|
||||
t.Fatalf("MarkFailed grew the board to %d, above the %d ceiling", size, maxBoardEntries)
|
||||
}
|
||||
if s.Evicted() == 0 {
|
||||
t.Fatalf("MarkFailed never prunes — the failure path is still unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// lx:end health-board
|
||||
@@ -10,11 +10,128 @@
|
||||
package urltest
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
)
|
||||
|
||||
// --- board capacity ---------------------------------------------------------
|
||||
//
|
||||
// The board is the one structure in the daemon whose key space is chosen by
|
||||
// somebody else. Its keys are outbound TAGS, and on this box a tag is a node
|
||||
// NAME straight out of the subscription — plus the derived per-group egress
|
||||
// copies ("group-<g>-m<i>-<node>") and per-chain hop copies the probe planner
|
||||
// creates for the same nodes. Providers rename their nodes freely, so a daily
|
||||
// subscription refresh introduces a whole new generation of keys, while the
|
||||
// store itself is pinned to the ENGINE's context (shater/engine.New) and so
|
||||
// outlives every generation and every Apply — by design, so health survives a
|
||||
// config change.
|
||||
//
|
||||
// Nothing ever removed a key. DeleteURLTestHistory exists but no shater path
|
||||
// calls it (only daemon/ and clashapi/, which this fork does not run), so the
|
||||
// map was strictly append-only for the life of the process — and the process is
|
||||
// expected to live for months.
|
||||
//
|
||||
// The arithmetic: ~380 nodes, and a config with a couple of egress-bound groups
|
||||
// plus a handful of chains puts a LIVE generation at roughly 380 base tags +
|
||||
// 2x380 group copies + ~100 chain copies ≈ 1200 keys. One new generation per day
|
||||
// is ~440k keys a year, at ~200 B per entry (map bucket + a tag string that is
|
||||
// routinely 30-50 B with flag emoji, + a 56 B URLTestHistory) ≈ 88 MB of a
|
||||
// 512 MB box — spent entirely on nodes that no longer exist.
|
||||
const (
|
||||
// maxBoardEntries is the hard ceiling. 4096 is ~3.4 live generations, so the
|
||||
// board comfortably holds the current config plus the overlap while a
|
||||
// subscription refresh swaps names, and still costs under a megabyte. A tighter
|
||||
// bound would start evicting tags the running config actually uses; a looser one
|
||||
// would stop being a bound in any useful sense.
|
||||
maxBoardEntries = 4096
|
||||
// keepBoardEntries is the prune target: drop a quarter at a time so the
|
||||
// O(n log n) selection is amortised over ~1024 inserts instead of running on
|
||||
// every probe once the board is full.
|
||||
keepBoardEntries = 3072
|
||||
)
|
||||
|
||||
// boardEvictionLog reports an eviction. A package var so tests can capture it;
|
||||
// production leaves it writing to the process log, which under procd is the same
|
||||
// syslog/logsink stream every other daemon line lands in.
|
||||
//
|
||||
// Eviction is NEVER silent. It is not free either: an evicted tag reverts to
|
||||
// "untested" and its next probe re-measures it, so a board that evicts entries
|
||||
// belonging to the LIVE config is a board whose ceiling is too low — and the only
|
||||
// way anyone finds that out is this line.
|
||||
var boardEvictionLog = func(msg string) { boardLogger().Warn(msg) }
|
||||
|
||||
// pruneLocked drops the least-recently-OBSERVED entries when the board exceeds
|
||||
// maxBoardEntries. "Least recently observed" is max(LastOK, LastFail): the entry
|
||||
// nothing has measured for the longest is, on this box, precisely a tag that no
|
||||
// longer exists in any config — a renamed node, a removed group copy, a retired
|
||||
// chain hop. Caller holds access.
|
||||
func (s *HistoryStorage) pruneLocked() {
|
||||
if len(s.delayHistory) <= maxBoardEntries {
|
||||
return
|
||||
}
|
||||
type kv struct {
|
||||
tag string
|
||||
seen time.Time
|
||||
}
|
||||
all := make([]kv, 0, len(s.delayHistory))
|
||||
for tag, h := range s.delayHistory {
|
||||
seen := h.LastOK
|
||||
if h.LastFail.After(seen) {
|
||||
seen = h.LastFail
|
||||
}
|
||||
all = append(all, kv{tag, seen})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].seen.Before(all[j].seen) })
|
||||
drop := len(all) - keepBoardEntries
|
||||
var oldest time.Time
|
||||
for i := 0; i < drop; i++ {
|
||||
if i == 0 {
|
||||
oldest = all[i].seen
|
||||
}
|
||||
delete(s.delayHistory, all[i].tag)
|
||||
}
|
||||
s.evicted += uint64(drop)
|
||||
|
||||
msg := "urltest: health board full (" + strconv.Itoa(maxBoardEntries) + " tags) — evicted " +
|
||||
strconv.Itoa(drop) + " least-recently-measured entries (" + strconv.FormatUint(s.evicted, 10) +
|
||||
" total since start); they revert to untested and will be re-probed"
|
||||
if !oldest.IsZero() {
|
||||
msg += "; oldest observation was " + time.Since(oldest).Truncate(time.Second).String() + " ago"
|
||||
}
|
||||
boardEvictionLog(msg)
|
||||
}
|
||||
|
||||
// Evicted reports how many entries the capacity bound has dropped since the store
|
||||
// was created. Nonzero means the board reached maxBoardEntries at least once.
|
||||
func (s *HistoryStorage) Evicted() uint64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.access.RLock()
|
||||
defer s.access.RUnlock()
|
||||
return s.evicted
|
||||
}
|
||||
|
||||
// boardLogger is the process-wide fallback logger for eviction notices. The store
|
||||
// is built from a plain constructor with no logger in sight (box.New, the daemon,
|
||||
// shater/engine all call NewHistoryStorage()), so rather than change that
|
||||
// signature everywhere the notice goes to the standard logger — which on the
|
||||
// router is the daemon's own stderr, i.e. the same sink logsink owns.
|
||||
var (
|
||||
boardLogOnce sync.Once
|
||||
boardLog log.ContextLogger
|
||||
)
|
||||
|
||||
func boardLogger() log.ContextLogger {
|
||||
boardLogOnce.Do(func() { boardLog = log.StdLogger() })
|
||||
return boardLog
|
||||
}
|
||||
|
||||
// HealthVerdict classifies a stored history entry at read time.
|
||||
type HealthVerdict int
|
||||
|
||||
@@ -54,6 +171,7 @@ func (s *HistoryStorage) MarkFailed(tag string) {
|
||||
updated.Delay = previous.Delay
|
||||
}
|
||||
s.delayHistory[tag] = updated
|
||||
s.pruneLocked()
|
||||
s.notifyUpdated()
|
||||
s.access.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package urltest
|
||||
|
||||
// lx: health board §5.C — the reachability half of "should this be probed".
|
||||
//
|
||||
// # Two different reasons not to probe, and why they cannot be one flag
|
||||
//
|
||||
// A group's OWN probing schedule is stood down for two unrelated reasons, and
|
||||
// conflating them breaks one of the two:
|
||||
//
|
||||
// - NOT USED — no enabled routing rule reaches this group, so probing it
|
||||
// measures a path nothing travels. That is a property of the CONFIG, it is
|
||||
// decided once when the config is generated, and it travels in the config
|
||||
// itself (option.URLTestOutboundOptions.SelfCheck). It cannot change while
|
||||
// the box runs, because the rules cannot change while the box runs.
|
||||
//
|
||||
// - NOT REACHABLE RIGHT NOW — the group is a hop of a chain and a hop in
|
||||
// FRONT of it is currently dead. Every member of this group dials through
|
||||
// that hop, so every probe would fail inside it: the measurement would be
|
||||
// about the broken hop, and would be recorded against this one. That is a
|
||||
// property of the WORLD, it changes minute by minute, and it must be
|
||||
// re-asked every time rather than baked into the config — a hop that comes
|
||||
// back must resume probing on its own, with no reapply and nobody pressing
|
||||
// anything.
|
||||
//
|
||||
// ProbeGate is the second one. It is deliberately a QUESTION asked at the
|
||||
// moment of probing and never a stored answer: there is no flag to set, so
|
||||
// there is no flag to forget to clear.
|
||||
//
|
||||
// The gate governs the group's own SCHEDULE only — the warm-up sweep and the
|
||||
// ticker. An explicit check (a human, an API call) is a deliberate request and
|
||||
// is never refused, exactly as with SelfCheck.
|
||||
type ProbeGate interface {
|
||||
// ProbeAllowed reports whether the outbound tagged tag may run its own
|
||||
// scheduled probe right now.
|
||||
//
|
||||
// Implementations MUST answer true when they do not know: a gate that
|
||||
// refuses on missing information would silence probing precisely when the
|
||||
// system has the least idea what is going on, and nothing would ever
|
||||
// measure its way out of that. A nil ProbeGate means "no gate" and every
|
||||
// probe proceeds.
|
||||
ProbeAllowed(tag string) bool
|
||||
|
||||
// ProbeWhenIdle reports whether the outbound tagged tag must keep measuring
|
||||
// even when no traffic is passing through it.
|
||||
//
|
||||
// A urltest group normally probes only while it is in use: Touch arms the
|
||||
// ticker on a dial, and the idle timeout stops it again. That is right for a
|
||||
// group whose readings matter only while somebody is dialling it, and wrong
|
||||
// for one the routing config REACHES: a rule that matches rarely — a narrow
|
||||
// domain list, say — is in force the whole time, so the health of its target
|
||||
// is a live question the whole time. Letting it go quiet means the panel
|
||||
// reports "untested" about a rule that is armed, and the first real request
|
||||
// pays a cold probe instead of picking an already-known-good member.
|
||||
//
|
||||
// Unlike ProbeAllowed, the safe answer here is FALSE when nothing is known.
|
||||
// This one ADDS work, and a gate that claimed it on missing information would
|
||||
// keep every group in the process probing forever — not a default anybody
|
||||
// asked for. Absent gate, unknown tag, nothing configured yet: false, and the
|
||||
// idle timeout behaves exactly as it always has.
|
||||
ProbeWhenIdle(tag string) bool
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type HistoryStorage struct {
|
||||
access sync.RWMutex
|
||||
delayHistory map[string]*adapter.URLTestHistory
|
||||
updateHooks []*observable.Subscriber[struct{}]
|
||||
// evicted counts entries dropped by the capacity bound (board_lx.go). The map
|
||||
// is keyed by outbound tags chosen by a subscription provider, so it needs a
|
||||
// ceiling; see the comment on maxBoardEntries.
|
||||
evicted uint64
|
||||
}
|
||||
|
||||
func NewHistoryStorage() *HistoryStorage {
|
||||
@@ -71,6 +75,11 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes
|
||||
}
|
||||
// lx:end health-board
|
||||
s.delayHistory[tag] = history
|
||||
// lx:begin health-board — the map is keyed by provider-chosen tags and the
|
||||
// store outlives every engine generation, so it must bound itself here: no
|
||||
// shater path ever calls DeleteURLTestHistory. See maxBoardEntries.
|
||||
s.pruneLocked()
|
||||
// lx:end health-board
|
||||
s.notifyUpdated()
|
||||
s.access.Unlock()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
// lx:begin sec-oomgate
|
||||
"sync"
|
||||
// lx:end sec-oomgate
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -19,6 +22,10 @@ type ManagedService struct {
|
||||
handler ManagedHandler
|
||||
debug bool
|
||||
oomReporter oomkiller.OOMReporter
|
||||
// lx:begin sec-oomgate
|
||||
oomReportMu sync.Mutex
|
||||
oomReportLast time.Time
|
||||
// lx:end sec-oomgate
|
||||
}
|
||||
|
||||
type ManagedServiceOptions struct {
|
||||
@@ -90,6 +97,18 @@ func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty)
|
||||
if s.oomReporter == nil {
|
||||
return nil, status.Error(codes.Unavailable, "OOM reporter not available")
|
||||
}
|
||||
// lx:begin sec-oomgate
|
||||
// Rate-limit operator-triggered reports to at most one per minute: each write
|
||||
// dumps process state + the config snapshot (secrets) to disk, so an
|
||||
// authenticated client must not be able to spin it in a tight loop.
|
||||
s.oomReportMu.Lock()
|
||||
if !s.oomReportLast.IsZero() && time.Since(s.oomReportLast) < time.Minute {
|
||||
s.oomReportMu.Unlock()
|
||||
return nil, status.Error(codes.ResourceExhausted, "OOM report rate-limited (max 1/min)")
|
||||
}
|
||||
s.oomReportLast = time.Now()
|
||||
s.oomReportMu.Unlock()
|
||||
// lx:end sec-oomgate
|
||||
return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total())
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -2,6 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
// lx:begin sec-consttime
|
||||
"crypto/subtle"
|
||||
// lx:end sec-consttime
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@@ -59,8 +62,11 @@ func authenticate(ctx context.Context, secret string) error {
|
||||
return status.Error(codes.Unauthenticated, "missing authorization")
|
||||
}
|
||||
token, isBearer := strings.CutPrefix(values[0], "Bearer ")
|
||||
if !isBearer || token != secret {
|
||||
// lx:begin sec-consttime
|
||||
// Constant-time compare: a plain != leaks the secret via response timing.
|
||||
if !isBearer || subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 {
|
||||
return status.Error(codes.Unauthenticated, "invalid authorization")
|
||||
}
|
||||
// lx:end sec-consttime
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -131,7 +131,9 @@ func (s *StartedService) StartTailscaleSSHSession(
|
||||
continue
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
go s.forwardSSHAgentChannel(channel)
|
||||
// lx:begin sec-sshagent
|
||||
go s.forwardSSHAgentChannel(sessionCtx, channel)
|
||||
// lx:end sec-sshagent
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -313,7 +315,8 @@ func (s *StartedService) StartTailscaleSSHSession(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
|
||||
// lx:begin sec-sshagent
|
||||
func (s *StartedService) forwardSSHAgentChannel(ctx context.Context, channel ssh.Channel) {
|
||||
defer channel.Close()
|
||||
fd, err := s.handler.ConnectSSHAgent()
|
||||
if err != nil {
|
||||
@@ -326,15 +329,33 @@ func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// The ssh-agent conn stays blocked in Read while idle, so io.Copy(channel,
|
||||
// conn) never returns on its own — without this it leaks a goroutine + the
|
||||
// agent fd for every closed session. Cancelling on either copy finishing (or
|
||||
// on the session ctx) closes both ends, unblocking the peer copy. Both Close
|
||||
// calls are idempotent with the deferred ones above.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.Close()
|
||||
channel.Close()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(conn, channel)
|
||||
cancel()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(channel, conn)
|
||||
cancel()
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// lx:end sec-sshagent
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB |
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
untrusted comment: shater feed signing key
|
||||
RWRaxLF3aJy44JbcxSFujtrFFEQ8lIsnTkd1K5TdjIhdlC2c0wa0fv4V
|
||||
+90
-3
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
@@ -171,6 +172,73 @@ func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// requestBuffer owns the pooled buffer that backs one DoH query.
|
||||
//
|
||||
// Both transports behind HTTPSTransportWrapper write the request body on a
|
||||
// goroutine of their own and return from RoundTrip as soon as the response
|
||||
// HEADERS arrive: net/http's write loop is still copying out of the body a
|
||||
// bufferful at a time (4 KiB of write buffer, or io.Copy's 32 KiB once it hands
|
||||
// the body to the connection), and http2's writeRequestBody has read only the
|
||||
// first max-frame-size bytes of it. Returning the buffer to the pool at that
|
||||
// point handed live memory to the next caller while the query was still going
|
||||
// out — everything past that first copy left the router as whatever that caller
|
||||
// had written there. A data race, and a memory-disclosure primitive aimed at the
|
||||
// resolver. Measured, not reasoned: with the write parked mid-query the bytes on
|
||||
// the wire diverge from the bytes we packed at exactly one copy buffer in.
|
||||
//
|
||||
// Ownership is counted rather than handed over once, because a retry holds two
|
||||
// bodies at a time and the two transports order that differently:
|
||||
// http.Transport.rewindBody CLOSES the old body before asking GetBody for a
|
||||
// new one, while http2's shouldRetryRequest asks GetBody first and closes the
|
||||
// old body on a goroutine. exchange keeps a count of its own until RoundTrip
|
||||
// returns — the only window in which either can call GetBody — so neither
|
||||
// ordering can free the buffer under the other. If a transport ever fails to
|
||||
// close a body, the count never reaches zero and the buffer is simply not
|
||||
// reused: garbage, not corruption.
|
||||
type requestBuffer struct {
|
||||
buffer *buf.Buffer
|
||||
raw []byte
|
||||
refs atomic.Int32
|
||||
}
|
||||
|
||||
func newRequestBuffer(buffer *buf.Buffer, raw []byte) *requestBuffer {
|
||||
holder := &requestBuffer{buffer: buffer, raw: raw}
|
||||
holder.refs.Store(1)
|
||||
return holder
|
||||
}
|
||||
|
||||
// body hands out a reader over the packed query as one more owner. It refuses
|
||||
// once the buffer is back in the pool, so a late caller gets an error instead
|
||||
// of a reader over memory that now belongs to somebody else.
|
||||
func (b *requestBuffer) body() (*pooledRequestBody, bool) {
|
||||
for {
|
||||
refs := b.refs.Load()
|
||||
if refs < 1 {
|
||||
return nil, false
|
||||
}
|
||||
if b.refs.CompareAndSwap(refs, refs+1) {
|
||||
return &pooledRequestBody{Reader: bytes.NewReader(b.raw), owner: b}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *requestBuffer) release() {
|
||||
if b.refs.Add(-1) == 0 {
|
||||
b.buffer.Release()
|
||||
}
|
||||
}
|
||||
|
||||
type pooledRequestBody struct {
|
||||
*bytes.Reader
|
||||
owner *requestBuffer
|
||||
closeOne sync.Once
|
||||
}
|
||||
|
||||
func (b *pooledRequestBody) Close() error {
|
||||
b.closeOne.Do(b.owner.release)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
|
||||
exMessage := *message
|
||||
exMessage.Id = 0
|
||||
@@ -181,11 +249,31 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
requestBuffer.Release()
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
|
||||
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
|
||||
// Drops the count exchange holds once RoundTrip is done with the request;
|
||||
// the bodies handed to the transport keep their own until it closes them.
|
||||
defer queryBuffer.release()
|
||||
requestBody, _ := queryBuffer.body() // cannot fail: the count above is ours
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), requestBody)
|
||||
if err != nil {
|
||||
requestBuffer.Release()
|
||||
requestBody.Close()
|
||||
return nil, err
|
||||
}
|
||||
// http.NewRequestWithContext infers both only for the body types it knows,
|
||||
// and pooledRequestBody is not one of them. Upstream got them for free from
|
||||
// *bytes.Reader; GetBody is what lets a POST be replayed when a pooled
|
||||
// connection turns out to have been closed under us. Being unknown to
|
||||
// net/http also costs one packet on the HTTP/1.1 leg: isKnownInMemoryReader
|
||||
// no longer recognises the body, so the request headers are flushed before
|
||||
// the query instead of travelling with it.
|
||||
request.ContentLength = int64(len(rawMessage))
|
||||
request.GetBody = func() (io.ReadCloser, error) {
|
||||
retryBody, ok := queryBuffer.body()
|
||||
if !ok {
|
||||
return nil, E.New("DoH request buffer already released")
|
||||
}
|
||||
return retryBody, nil
|
||||
}
|
||||
request.Header = t.headers.Clone()
|
||||
request.Header.Set("Content-Type", MimeType)
|
||||
request.Header.Set("Accept", MimeType)
|
||||
@@ -193,7 +281,6 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
currentTransport := t.transport
|
||||
t.transportAccess.Unlock()
|
||||
response, err := currentTransport.RoundTrip(request)
|
||||
requestBuffer.Release()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// The request body of a DoH query is backed by a POOLED buffer. Neither
|
||||
// transport behind HTTPSTransportWrapper is done with that body when RoundTrip
|
||||
// returns: net/http hands the request to a write loop of its own and returns as
|
||||
// soon as the response HEADERS have been read, and golang.org/x/net/http2 writes
|
||||
// the body on the goroutine that runs writeRequest while roundTrip waits on
|
||||
// respHeaderRecv. Returning the buffer to the pool at that point hands live
|
||||
// memory to the next caller while the query is still being written to the wire,
|
||||
// and what goes out is whatever that next caller put there.
|
||||
//
|
||||
// Both tests below force a window that is normally microseconds wide to stay
|
||||
// open, and drain the pool while it is open:
|
||||
//
|
||||
// - HTTP/1.1: the client connection stops accepting writes past the request
|
||||
// headers, so net/http's write loop is parked having copied only the first
|
||||
// io.Copy buffer (32 KiB) of the query.
|
||||
// - HTTP/2: the server pins a 1 KiB stream receive window and does not read
|
||||
// the body, so writeRequestBody is parked in awaitFlowControl having copied
|
||||
// only the first max-frame-size bytes of the query.
|
||||
//
|
||||
// In both, the server sends the response HEADERS first and withholds the
|
||||
// response BODY until the pool has been drained, so Exchange has returned from
|
||||
// RoundTrip — and released the buffer, on the broken build — while the query is
|
||||
// still going out.
|
||||
//
|
||||
// Both queries are padded past the transport's copy buffer on purpose. Below it
|
||||
// the transport lifts the whole query out of the pooled buffer in a single Read
|
||||
// that RACES the release rather than provably following it, and a test built on
|
||||
// that race would be a coin toss. The ownership defect is the same at every
|
||||
// size; only its deterministic proof needs the padding.
|
||||
|
||||
const (
|
||||
// Past io.Copy's 32 KiB buffer, which is the granularity net/http moves a
|
||||
// request body at (persistConnWriter.ReadFrom -> io.Copy), and still inside
|
||||
// buf.MaxPooledBufferSize so the buffer really comes from the pool.
|
||||
httpsH1PaddedQuerySize = 40000
|
||||
// Past http2's max frame size, which is how much of the body
|
||||
// writeRequestBody lifts into its scratch buffer per round.
|
||||
httpsH2PaddedQuerySize = 20000
|
||||
// Pinned on the HTTP/2 server so the client cannot write the whole body
|
||||
// before the response headers come back.
|
||||
httpsPinnedStreamWindow = 1024
|
||||
// Pinned too: Go's HTTP/2 server advertises a 1 MiB max frame size by
|
||||
// default, and the client sizes its body-copy buffer from that — with the
|
||||
// default it would slurp a 20 KB query in one Read and the divergence would
|
||||
// be hidden by the copy size rather than absent. 16384 is the protocol
|
||||
// minimum and what real resolvers advertise.
|
||||
httpsPinnedMaxFrameSize = 16384
|
||||
// How many times the HTTP/2 scenario is repeated; see the test.
|
||||
httpsH2Rounds = 8
|
||||
// How long to wait after the response headers before draining the pool, so
|
||||
// that Exchange has certainly returned from RoundTrip.
|
||||
httpsReleaseSettleDelay = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// httpsPaddedQuery returns a query and the exact bytes HTTPSTransport.exchange
|
||||
// packs for it.
|
||||
func httpsPaddedQuery(t *testing.T, padding int) (*mDNS.Msg, []byte) {
|
||||
t.Helper()
|
||||
message := new(mDNS.Msg)
|
||||
message.SetQuestion("example.com.", mDNS.TypeA)
|
||||
opt := new(mDNS.OPT)
|
||||
opt.Hdr.Name = "."
|
||||
opt.Hdr.Rrtype = mDNS.TypeOPT
|
||||
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: make([]byte, padding)})
|
||||
message.Extra = append(message.Extra, opt)
|
||||
|
||||
onWire := *message
|
||||
onWire.Id = 0
|
||||
onWire.Compress = true
|
||||
expected, err := onWire.Pack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return message, expected
|
||||
}
|
||||
|
||||
func httpsTestReply(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
query := new(mDNS.Msg)
|
||||
query.SetQuestion("example.com.", mDNS.TypeA)
|
||||
response := new(mDNS.Msg)
|
||||
response.SetReply(query)
|
||||
raw, err := response.Pack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// httpsPoisonPool takes buffers of one size class out of the pool and fills them
|
||||
// with a pattern no DNS message contains. They are returned, not released: the
|
||||
// caller holds them so nothing can hand them back while the check runs.
|
||||
func httpsPoisonPool(size int, count int) []*buf.Buffer {
|
||||
poison := make([]*buf.Buffer, 0, count)
|
||||
for range count {
|
||||
buffer := buf.NewSize(size)
|
||||
poison = append(poison, buffer)
|
||||
free := buffer.FreeBytes()
|
||||
for i := range free {
|
||||
free[i] = 0xEE
|
||||
}
|
||||
}
|
||||
return poison
|
||||
}
|
||||
|
||||
func httpsReleaseAll(buffers []*buf.Buffer) {
|
||||
for _, buffer := range buffers {
|
||||
buffer.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// httpsRequirePoisonReachesReleasedBuffer is the CONTROL for the tests below. A
|
||||
// clean result there means nothing unless this instrument is shown to be able to
|
||||
// produce a dirty one: it must be true that a buffer released while its bytes
|
||||
// are still referenced comes back out of the pool and gets overwritten. If that
|
||||
// stops holding — a different allocator, a pool that zeroes, a size class that
|
||||
// is not pooled at all — the tests below would go green on broken code.
|
||||
//
|
||||
// Retried, because under -race sync.Pool.Put drops one object in four on
|
||||
// purpose. That same dice roll is why the checks below are 3-in-4 detectors
|
||||
// under -race and certainties without it; it can only make a broken build look
|
||||
// clean, never a clean build look broken.
|
||||
func httpsRequirePoisonReachesReleasedBuffer(t *testing.T, size int, pattern []byte) {
|
||||
t.Helper()
|
||||
for range 32 {
|
||||
control := buf.NewSize(size)
|
||||
free := control.FreeBytes()
|
||||
if len(free) < len(pattern) {
|
||||
t.Fatalf("control failed: a %d-byte buffer came back %d bytes long", size, len(free))
|
||||
}
|
||||
copy(free, pattern)
|
||||
alias := free[:len(pattern)]
|
||||
control.Release()
|
||||
|
||||
held := httpsPoisonPool(size, 8)
|
||||
poisoned := !bytes.Equal(alias, pattern)
|
||||
httpsReleaseAll(held)
|
||||
if poisoned {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("control failed: poisoning the pool never touched a released buffer, so a clean result below would prove nothing")
|
||||
}
|
||||
|
||||
// httpsTestDialer hands HTTPSTransportWrapper a connection to a local test
|
||||
// server, optionally wrapped.
|
||||
type httpsTestDialer struct {
|
||||
target string
|
||||
wrap func(net.Conn) net.Conn
|
||||
access sync.Mutex
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
func (d *httpsTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", d.target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrapped net.Conn = conn
|
||||
if d.wrap != nil {
|
||||
wrapped = d.wrap(conn)
|
||||
}
|
||||
d.access.Lock()
|
||||
d.conns = append(d.conns, conn)
|
||||
d.access.Unlock()
|
||||
return wrapped, nil
|
||||
}
|
||||
|
||||
func (d *httpsTestDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
func (d *httpsTestDialer) closeAll() {
|
||||
d.access.Lock()
|
||||
defer d.access.Unlock()
|
||||
for _, conn := range d.conns {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// httpsGatedConn stops accepting writes once limit bytes have gone out, until
|
||||
// the gate is opened. HTTP/1.1 has no flow-control knob to park the writer with,
|
||||
// so the connection provides one.
|
||||
type httpsGatedConn struct {
|
||||
net.Conn
|
||||
limit int64
|
||||
written atomic.Int64
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func (c *httpsGatedConn) Write(p []byte) (int, error) {
|
||||
if c.written.Load()+int64(len(p)) > c.limit {
|
||||
select {
|
||||
case <-c.gate:
|
||||
case <-time.After(30 * time.Second):
|
||||
return 0, errors.New("gated conn: nobody opened the gate")
|
||||
}
|
||||
}
|
||||
n, err := c.Conn.Write(p)
|
||||
c.written.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
// httpsSlowServer is the handler both tests share: response HEADERS first, then
|
||||
// nothing until the pool has been drained, then the request body, then the
|
||||
// response body.
|
||||
type httpsSlowServer struct {
|
||||
reply []byte
|
||||
served atomic.Int32
|
||||
warmups int32
|
||||
headersSent chan struct{}
|
||||
bodyGate chan struct{}
|
||||
received chan []byte
|
||||
readErr chan error
|
||||
}
|
||||
|
||||
func newHTTPSSlowServer(reply []byte) *httpsSlowServer {
|
||||
return &httpsSlowServer{
|
||||
reply: reply,
|
||||
headersSent: make(chan struct{}, 1),
|
||||
bodyGate: make(chan struct{}),
|
||||
received: make(chan []byte, 1),
|
||||
readErr: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *httpsSlowServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.served.Add(1) <= s.warmups {
|
||||
// Warm-up: answer normally, so the connection is established and the
|
||||
// client has applied the server's SETTINGS before the query that
|
||||
// matters goes out.
|
||||
io.Copy(io.Discard, request.Body)
|
||||
writer.Header().Set("Content-Type", MimeType)
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(s.reply)))
|
||||
writer.Write(s.reply)
|
||||
return
|
||||
}
|
||||
// Without this, net/http's HTTP/1.1 server drains up to 256 KB of the
|
||||
// request body before it will write response headers, precisely so that a
|
||||
// half-duplex client cannot deadlock. That would consume the query before
|
||||
// the client is anywhere near done sending it, and there would be nothing
|
||||
// left in flight to catch. Full duplex is how a resolver that answers from
|
||||
// cache before reading the whole query behaves; HTTP/2 is full duplex
|
||||
// already and returns an error here, which is fine.
|
||||
http.NewResponseController(writer).EnableFullDuplex()
|
||||
writer.Header().Set("Content-Type", MimeType)
|
||||
// Content-Length matters: without it Exchange falls into io.ReadAll and
|
||||
// waits for the end of the response, which this handler is about to
|
||||
// withhold on purpose.
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(s.reply)))
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
writer.(http.Flusher).Flush()
|
||||
s.headersSent <- struct{}{}
|
||||
|
||||
// A real resolver would be reading the query by now. Withholding it is what
|
||||
// keeps the client parked mid-body while the pool is drained.
|
||||
<-s.bodyGate
|
||||
body, err := io.ReadAll(request.Body)
|
||||
s.readErr <- err
|
||||
s.received <- body
|
||||
|
||||
writer.Write(s.reply)
|
||||
}
|
||||
|
||||
// drainPoolOnceHeadersAreOut waits for the response headers, gives Exchange time
|
||||
// to return from RoundTrip, drains the size class the query buffer came from —
|
||||
// on this goroutine, so a buffer released on the way out lands in our hands and
|
||||
// not somewhere harmless — and only then lets the server read the query.
|
||||
func (s *httpsSlowServer) drainPoolOnceHeadersAreOut(bufferSize int) <-chan []*buf.Buffer {
|
||||
poisoned := make(chan []*buf.Buffer, 1)
|
||||
go func() {
|
||||
<-s.headersSent
|
||||
time.Sleep(httpsReleaseSettleDelay)
|
||||
poisoned <- httpsPoisonPool(bufferSize, 32)
|
||||
close(s.bodyGate)
|
||||
}()
|
||||
return poisoned
|
||||
}
|
||||
|
||||
func (s *httpsSlowServer) requireQueryOnWire(t *testing.T, expected []byte) {
|
||||
t.Helper()
|
||||
var sent []byte
|
||||
select {
|
||||
case sent = <-s.received:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the server never received the request body")
|
||||
}
|
||||
if err := <-s.readErr; err != nil {
|
||||
t.Fatal("reading the request body: ", err)
|
||||
}
|
||||
if bytes.Equal(sent, expected) {
|
||||
return
|
||||
}
|
||||
firstDiff := -1
|
||||
for i := 0; i < len(sent) && i < len(expected); i++ {
|
||||
if sent[i] != expected[i] {
|
||||
firstDiff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("the query on the wire is not the query we packed: %d of %d bytes received, first difference at offset %d — "+
|
||||
"the pooled request buffer was reused while the transport was still reading it", len(sent), len(expected), firstDiff)
|
||||
}
|
||||
|
||||
// TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP1 proves that the query an
|
||||
// HTTP/1.1 resolver receives is the query we asked to send, even when the pool
|
||||
// is drained the instant the response headers arrive.
|
||||
func TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP1(t *testing.T) {
|
||||
message, expected := httpsPaddedQuery(t, httpsH1PaddedQuerySize)
|
||||
bufferSize := 1 + message.Len()
|
||||
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
|
||||
|
||||
handler := newHTTPSSlowServer(httpsTestReply(t))
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dialer := &httpsTestDialer{
|
||||
target: server.Listener.Addr().String(),
|
||||
wrap: func(conn net.Conn) net.Conn {
|
||||
// One 4 KiB flush of net/http's write buffer gets through, which is
|
||||
// what carries the request headers to the server, and the write loop
|
||||
// parks on the next one — still holding the query.
|
||||
return &httpsGatedConn{Conn: conn, limit: 4096, gate: handler.bodyGate}
|
||||
},
|
||||
}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
|
||||
// Scheme http puts HTTPSTransportWrapper on its HTTP/1.1 leg, the one it
|
||||
// also falls back to whenever a resolver does not negotiate h2.
|
||||
destination := &url.URL{Scheme: "http", Host: "doh.invalid", Path: "/dns-query"}
|
||||
dnsTransport := &HTTPSTransport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTPS, "test-doh-h1", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: destination,
|
||||
headers: http.Header{},
|
||||
transport: NewHTTPSTransportWrapper(dialer, M.ParseSocksaddr(server.Listener.Addr().String()), destination),
|
||||
}
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
poisoned := handler.drainPoolOnceHeadersAreOut(bufferSize)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if _, err := dnsTransport.Exchange(ctx, message); err != nil {
|
||||
t.Fatal("exchange: ", err)
|
||||
}
|
||||
defer httpsReleaseAll(<-poisoned)
|
||||
|
||||
handler.requireQueryOnWire(t, expected)
|
||||
}
|
||||
|
||||
// TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP2 does the same over h2,
|
||||
// the leg every resolver that speaks HTTP/2 lands on.
|
||||
func TestHTTPSExchangeRequestBufferOutlivesRoundTripHTTP2(t *testing.T) {
|
||||
message, expected := httpsPaddedQuery(t, httpsH2PaddedQuerySize)
|
||||
bufferSize := 1 + message.Len()
|
||||
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
|
||||
// Repeated because a buffer released on the goroutine running Exchange
|
||||
// usually lands in that P's private sync.Pool slot, which the goroutine
|
||||
// draining the pool cannot steal: one round catches a broken build about
|
||||
// half the time, eight catch it better than 99 times in 100. Every round
|
||||
// must come back clean.
|
||||
for round := range httpsH2Rounds {
|
||||
if !t.Run(strconv.Itoa(round), func(t *testing.T) {
|
||||
httpsH2Round(t, message, expected, bufferSize)
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func httpsH2Round(t *testing.T, message *mDNS.Msg, expected []byte, bufferSize int) {
|
||||
handler := newHTTPSSlowServer(httpsTestReply(t))
|
||||
// x/net/http2 may put the first request on the wire before it has applied
|
||||
// the server's SETTINGS, and would then overrun the 1 KiB window this test
|
||||
// pins and be reset with FLOW_CONTROL_ERROR. One small query first settles
|
||||
// that: reading its response proves the SETTINGS frame ahead of it was
|
||||
// processed.
|
||||
handler.warmups = 1
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
h2server := &http2.Server{
|
||||
MaxUploadBufferPerStream: httpsPinnedStreamWindow,
|
||||
MaxReadFrameSize: httpsPinnedMaxFrameSize,
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
go h2server.ServeConn(conn, &http2.ServeConnOpts{Handler: handler})
|
||||
}
|
||||
}()
|
||||
|
||||
dialer := &httpsTestDialer{target: listener.Addr().String()}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
|
||||
// Scheme https keeps HTTPSTransportWrapper on its h2 leg. The dialer hands
|
||||
// back a plain connection, which x/net/http2 speaks prior-knowledge h2 over;
|
||||
// TLS adds nothing this test is about.
|
||||
destination := &url.URL{Scheme: "https", Host: "doh.invalid", Path: "/dns-query"}
|
||||
dnsTransport := &HTTPSTransport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTPS, "test-doh-h2", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: destination,
|
||||
headers: http.Header{},
|
||||
transport: NewHTTPSTransportWrapper(dialer, M.ParseSocksaddr(listener.Addr().String()), destination),
|
||||
}
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
warmup := new(mDNS.Msg)
|
||||
warmup.SetQuestion("warmup.invalid.", mDNS.TypeA)
|
||||
if _, err = dnsTransport.Exchange(ctx, warmup); err != nil {
|
||||
t.Fatal("warm-up exchange: ", err)
|
||||
}
|
||||
|
||||
poisoned := handler.drainPoolOnceHeadersAreOut(bufferSize)
|
||||
if _, err = dnsTransport.Exchange(ctx, message); err != nil {
|
||||
t.Fatal("exchange: ", err)
|
||||
}
|
||||
defer httpsReleaseAll(<-poisoned)
|
||||
|
||||
handler.requireQueryOnWire(t, expected)
|
||||
}
|
||||
|
||||
// TestHTTPSRequestBufferSurvivesRewind covers the second owner a retry creates.
|
||||
// net/http rewinds a dead connection's request by CLOSING the body it has and
|
||||
// then asking GetBody for another one (rewindBody), while x/net/http2 asks
|
||||
// GetBody first and closes the old body on a goroutine (shouldRetryRequest,
|
||||
// closeReqBodyLocked). Either ordering frees the buffer under the retry if the
|
||||
// first Close is what returns it to the pool, and the retry then sends whatever
|
||||
// the next pool user wrote — the same disclosure, one attempt later.
|
||||
func TestHTTPSRequestBufferSurvivesRewind(t *testing.T) {
|
||||
message, expected := httpsPaddedQuery(t, httpsH2PaddedQuerySize)
|
||||
bufferSize := 1 + message.Len()
|
||||
httpsRequirePoisonReachesReleasedBuffer(t, bufferSize, expected)
|
||||
|
||||
exMessage := *message
|
||||
exMessage.Id = 0
|
||||
exMessage.Compress = true
|
||||
requestBuffer := buf.NewSize(bufferSize)
|
||||
rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
|
||||
defer queryBuffer.release()
|
||||
|
||||
first, ok := queryBuffer.body()
|
||||
if !ok {
|
||||
t.Fatal("the first body was refused while exchange still holds the buffer")
|
||||
}
|
||||
// The transport got some of the query out before the connection turned out
|
||||
// to be dead, then closed the body.
|
||||
if _, err = io.CopyN(io.Discard, first, 128); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Close()
|
||||
|
||||
// GetBody, as the retry would call it.
|
||||
second, ok := queryBuffer.body()
|
||||
if !ok {
|
||||
t.Fatal("GetBody was refused after the first body was closed: the retry has no query left to send")
|
||||
}
|
||||
poison := httpsPoisonPool(bufferSize, 32)
|
||||
defer httpsReleaseAll(poison)
|
||||
|
||||
retried, err := io.ReadAll(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(retried, expected) {
|
||||
firstDiff := -1
|
||||
for i := 0; i < len(retried) && i < len(expected); i++ {
|
||||
if retried[i] != expected[i] {
|
||||
firstDiff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("the retried query is not the query we packed: %d of %d bytes, first difference at offset %d — "+
|
||||
"closing the first body returned the buffer to the pool while the retry still needed it", len(retried), len(expected), firstDiff)
|
||||
}
|
||||
second.Close()
|
||||
}
|
||||
|
||||
// TestHTTPSRequestBufferRefusesBodyAfterRelease pins the recoverable end of the
|
||||
// contract: once the buffer really is back in the pool, GetBody must hand out an
|
||||
// error rather than a reader over memory that now belongs to somebody else.
|
||||
func TestHTTPSRequestBufferRefusesBodyAfterRelease(t *testing.T) {
|
||||
requestBuffer := buf.NewSize(64)
|
||||
rawMessage := requestBuffer.FreeBytes()[:8]
|
||||
queryBuffer := newRequestBuffer(requestBuffer, rawMessage)
|
||||
|
||||
body, ok := queryBuffer.body()
|
||||
if !ok {
|
||||
t.Fatal("the first body was refused while the caller still holds the buffer")
|
||||
}
|
||||
body.Close()
|
||||
body.Close() // http3 and net/http both manage to close a body twice
|
||||
queryBuffer.release()
|
||||
|
||||
if _, ok = queryBuffer.body(); ok {
|
||||
t.Fatal("a body was handed out over a buffer that is already back in the pool")
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,12 @@ func (t *HTTP3Transport) newTransport() *http3.Transport {
|
||||
conn.Close()
|
||||
return nil, dialErr
|
||||
}
|
||||
// quic-go does not take ownership of the packet conn passed to
|
||||
// DialEarly: when the connection ends it only stops reading.
|
||||
go func() {
|
||||
<-quicConn.Context().Done()
|
||||
conn.Close()
|
||||
}()
|
||||
return quicConn, nil
|
||||
},
|
||||
TLSClientConfig: t.tlsConfig,
|
||||
@@ -156,15 +162,34 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
exMessage := *message
|
||||
exMessage.Id = 0
|
||||
exMessage.Compress = true
|
||||
requestBuffer := buf.NewSize(1 + message.Len())
|
||||
rawMessage, err := exMessage.PackBuffer(requestBuffer.FreeBytes())
|
||||
// NOT a pooled buffer, deliberately — the request body must own memory this
|
||||
// transport can never hand back.
|
||||
//
|
||||
// quic-go writes the request body on a goroutine of its own (http3's
|
||||
// doRequest spawns it and goes on to block in ReadResponse), and NOTHING ever
|
||||
// joins that goroutine. On the success path sendRequestBody closes the body
|
||||
// when it is finished, but on every error path RoundTripOpt closes it as soon
|
||||
// as doRequest returns — and doRequest waits only on the request-cancellation
|
||||
// watchdog, not on the writer. So there is no moment at which this code can
|
||||
// know the body is no longer being read, and therefore no moment at which it
|
||||
// may return a pooled buffer. Releasing on Close looks like an ownership
|
||||
// handoff and is not one.
|
||||
//
|
||||
// Owning it costs nothing here, measured rather than assumed: for a typical
|
||||
// query (a 36-byte name, A record) Pack is 87 ns/op at 64 B and 1 alloc,
|
||||
// against 108 ns/op at 64 B and 1 alloc for packing into a pooled buffer. The
|
||||
// pool never avoided an allocation on this path — buf.NewSize allocates the
|
||||
// Buffer struct itself, the same 64 bytes the message needs — it only added
|
||||
// Get/Put on top. This path is hot in queries, not in bytes.
|
||||
//
|
||||
// The response buffer below stays pooled: it is read to completion and
|
||||
// unpacked before Exchange returns, and nothing outlives it.
|
||||
rawMessage, err := exMessage.Pack()
|
||||
if err != nil {
|
||||
requestBuffer.Release()
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage))
|
||||
if err != nil {
|
||||
requestBuffer.Release()
|
||||
return nil, err
|
||||
}
|
||||
request.Header = t.headers.Clone()
|
||||
@@ -174,7 +199,6 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS
|
||||
currentTransport := t.transport
|
||||
t.transportAccess.Unlock()
|
||||
response, err := currentTransport.RoundTrip(request)
|
||||
requestBuffer.Release()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing-box/dns/transport"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// The request body of a DoH3 query used to be backed by a POOLED buffer. quic-go
|
||||
// sends that body on a goroutine of its own which outlives RoundTrip (http3's
|
||||
// doRequest spawns it and returns as soon as the response HEADERS arrive), and
|
||||
// NOTHING joins that goroutine, so there is no moment at which the transport may
|
||||
// hand the buffer back.
|
||||
//
|
||||
// Two tests, because the two paths are observable in different ways.
|
||||
//
|
||||
// - On the SUCCESS path the body keeps flowing, so the damage is visible on the
|
||||
// wire: TestHTTP3ExchangeRequestBufferOutlivesRoundTrip pins a 2 KB server
|
||||
// stream window and answers before reading the body, so the client is still
|
||||
// writing when Exchange returns, and compares what the server received.
|
||||
//
|
||||
// - On the FAILURE and CANCELLATION paths the damage is not visible on the wire
|
||||
// at all: every ReadResponse error in quic-go calls str.CancelWrite BEFORE
|
||||
// RoundTripOpt closes the body, so whatever the writer reads afterwards is
|
||||
// thrown at a dead stream. What is left is a read of memory that belongs to
|
||||
// somebody else. TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory therefore
|
||||
// pins the CAUSE instead of the symptom: the bytes of a query must never end
|
||||
// up in a buffer this transport can return to the pool.
|
||||
|
||||
const (
|
||||
// Big enough to need more than one 8 KiB read out of the request body
|
||||
// (http3's bodyCopyBufferSize), small enough to still come from the pool
|
||||
// (buf.MaxPooledBufferSize).
|
||||
paddedQuerySize = 20000
|
||||
// Pinned on the server so the client cannot write the whole body before the
|
||||
// response comes back.
|
||||
pinnedStreamWindow = 2048
|
||||
// Padding for the marked query of the ownership test. Only has to be
|
||||
// distinctive and pooled, not large.
|
||||
markedQueryPadding = 4096
|
||||
markedQueryNeedle = 64
|
||||
// How deep to drain a size class when looking for the needle.
|
||||
poolScanDepth = 64
|
||||
// How many times a CONTROL may repeat before it gives up.
|
||||
//
|
||||
// Both controls in this file assert the same thing — a buffer released while
|
||||
// its bytes are still referenced comes back out of the pool — and under
|
||||
// `-race` that is a DICE ROLL, not a certainty: sync.Pool.Put drops one
|
||||
// object in four on purpose (runtime_randn(4) == 0, sync/pool.go). Measured
|
||||
// in golang:1.26 with `go test -race -count=60`: the single-attempt control
|
||||
// failed 18 times out of 60, i.e. the gate's -race pass had a ~30% chance of
|
||||
// going red on a tree with nothing wrong with it.
|
||||
//
|
||||
// A retry is the honest repair rather than a papering-over, because the
|
||||
// control's claim is EXISTENTIAL — "this instrument is able to find a
|
||||
// released, still-referenced buffer" — and one success proves it. It is not
|
||||
// an average over attempts, so nothing is diluted by taking more than one.
|
||||
// 32 attempts leave a (1/4)^32 chance of a false alarm.
|
||||
//
|
||||
// What this does NOT do, said plainly: it does not make the VERDICT below
|
||||
// certain under -race. The same 1-in-4 drop means a scan that comes back
|
||||
// clean has a 1-in-4 chance of being clean because the pool threw the
|
||||
// evidence away. That direction is the safe one — it can only let a broken
|
||||
// build look clean, never make a clean build look broken — and the -race
|
||||
// pass is not the only one that runs this test: [2/7] of scripts/run-tests.sh
|
||||
// runs the same file WITHOUT -race, where both the control and the verdict
|
||||
// are certainties.
|
||||
controlAttempts = 32
|
||||
)
|
||||
|
||||
func paddedQuery(t *testing.T) (*mDNS.Msg, []byte) {
|
||||
t.Helper()
|
||||
message := new(mDNS.Msg)
|
||||
message.SetQuestion("example.com.", mDNS.TypeA)
|
||||
opt := new(mDNS.OPT)
|
||||
opt.Hdr.Name = "."
|
||||
opt.Hdr.Rrtype = mDNS.TypeOPT
|
||||
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: make([]byte, paddedQuerySize)})
|
||||
message.Extra = append(message.Extra, opt)
|
||||
|
||||
// Exactly what HTTP3Transport.Exchange puts on the wire.
|
||||
onWire := *message
|
||||
onWire.Id = 0
|
||||
onWire.Compress = true
|
||||
expected, err := onWire.Pack()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return message, expected
|
||||
}
|
||||
|
||||
// poisonPool takes buffers of one size class out of the pool and fills them with
|
||||
// a pattern no DNS message contains. The buffers are returned, not released: the
|
||||
// caller holds them so nothing can hand them back while the check runs.
|
||||
func poisonPool(size int, count int) []*buf.Buffer {
|
||||
poison := make([]*buf.Buffer, 0, count)
|
||||
for range count {
|
||||
buffer := buf.NewSize(size)
|
||||
poison = append(poison, buffer)
|
||||
free := buffer.FreeBytes()
|
||||
for i := range free {
|
||||
free[i] = 0xEE
|
||||
}
|
||||
}
|
||||
return poison
|
||||
}
|
||||
|
||||
func releaseAll(buffers []*buf.Buffer) {
|
||||
for _, buffer := range buffers {
|
||||
buffer.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// requirePoisonReachesReleasedBuffer is the CONTROL for the test below. A clean
|
||||
// result there means nothing unless this instrument is shown to be able to
|
||||
// produce a dirty one: it must be true that a buffer released while its bytes
|
||||
// are still referenced comes back out of the pool and gets overwritten. If this
|
||||
// stops holding — a different allocator, a pool that zeroes, a size class that
|
||||
// is not pooled at all — the test below would go green on broken code.
|
||||
//
|
||||
// Retried, because under -race sync.Pool.Put drops one object in four on
|
||||
// purpose. That same dice roll is why the check below is a 3-in-4 detector under
|
||||
// -race and a certainty without it; it can only make a broken build look clean,
|
||||
// never a clean build look broken. See controlAttempts.
|
||||
func requirePoisonReachesReleasedBuffer(t *testing.T, size int, pattern []byte) {
|
||||
t.Helper()
|
||||
for range controlAttempts {
|
||||
control := buf.NewSize(size)
|
||||
free := control.FreeBytes()
|
||||
if len(free) < len(pattern) {
|
||||
t.Fatalf("control failed: a %d-byte buffer came back %d bytes long", size, len(free))
|
||||
}
|
||||
copy(free, pattern)
|
||||
alias := free[:len(pattern)]
|
||||
control.Release()
|
||||
|
||||
held := poisonPool(size, 8)
|
||||
poisoned := !bytes.Equal(alias, pattern)
|
||||
releaseAll(held)
|
||||
if poisoned {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("control failed: poisoning the pool never touched a released buffer, so a clean result below would prove nothing")
|
||||
}
|
||||
|
||||
// TestHTTP3ExchangeRequestBufferOutlivesRoundTrip proves that the query the
|
||||
// server receives is the query we asked to send, even when the pool is drained
|
||||
// the instant Exchange returns.
|
||||
func TestHTTP3ExchangeRequestBufferOutlivesRoundTrip(t *testing.T) {
|
||||
message, expected := paddedQuery(t)
|
||||
bufferSize := 1 + message.Len()
|
||||
requirePoisonReachesReleasedBuffer(t, bufferSize, expected)
|
||||
|
||||
drainGate := make(chan struct{})
|
||||
received := make(chan []byte, 1)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Answer BEFORE reading the request body. A real resolver would not, but
|
||||
// any peer, middlebox or loss pattern that delays the body has the same
|
||||
// effect, and this makes the window deterministic.
|
||||
response := new(mDNS.Msg)
|
||||
response.SetReply(testQuery())
|
||||
rawResponse, err := response.Pack()
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", transport.MimeType)
|
||||
// Content-Length matters here: without it Exchange falls into io.ReadAll
|
||||
// and waits for the stream FIN, which this handler is about to withhold.
|
||||
writer.Header().Set("Content-Length", strconv.Itoa(len(rawResponse)))
|
||||
writer.Write(rawResponse)
|
||||
writer.(http.Flusher).Flush()
|
||||
|
||||
<-drainGate
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
received <- body
|
||||
})
|
||||
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), &quic.Config{
|
||||
InitialStreamReceiveWindow: pinnedStreamWindow,
|
||||
MaxStreamReceiveWindow: pinnedStreamWindow,
|
||||
InitialConnectionReceiveWindow: 1 << 16,
|
||||
MaxConnectionReceiveWindow: 1 << 16,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http3.Server{Handler: mux}
|
||||
go server.ServeListener(listener)
|
||||
t.Cleanup(func() {
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
dialer := &trackingDialer{}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
dnsTransport := &HTTP3Transport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3-buffer", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
|
||||
headers: http.Header{},
|
||||
serverAddr: M.ParseSocksaddr(listener.Addr().String()),
|
||||
tlsConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "localhost",
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
}
|
||||
dnsTransport.transport = dnsTransport.newTransport()
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err = dnsTransport.Exchange(ctx, message); err != nil {
|
||||
t.Fatal("exchange: ", err)
|
||||
}
|
||||
|
||||
// Exchange has returned, the body is still in flight. Drain the size class it
|
||||
// came from, on this very goroutine, so a buffer released on the way out lands
|
||||
// in our hands and not somewhere harmless. The buffers are held until after
|
||||
// the comparison below.
|
||||
poison := poisonPool(bufferSize, 32)
|
||||
defer releaseAll(poison)
|
||||
|
||||
close(drainGate)
|
||||
var sent []byte
|
||||
select {
|
||||
case sent = <-received:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the server never received the request body")
|
||||
}
|
||||
if !bytes.Equal(sent, expected) {
|
||||
firstDiff := -1
|
||||
for i := 0; i < len(sent) && i < len(expected); i++ {
|
||||
if sent[i] != expected[i] {
|
||||
firstDiff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("the query on the wire is not the query we packed: %d of %d bytes received, first difference at offset %d — "+
|
||||
"the pooled request buffer was reused while quic-go was still reading it", len(sent), len(expected), firstDiff)
|
||||
}
|
||||
}
|
||||
|
||||
// markedQuery builds a query whose EDNS0 padding carries a random tag, so the
|
||||
// packed bytes contain a needle that can be searched for in pool memory and
|
||||
// cannot collide with anything else.
|
||||
func markedQuery(t *testing.T) (*mDNS.Msg, []byte) {
|
||||
t.Helper()
|
||||
padding := make([]byte, markedQueryPadding)
|
||||
if _, err := rand.Read(padding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
message := new(mDNS.Msg)
|
||||
message.SetQuestion("example.com.", mDNS.TypeA)
|
||||
opt := new(mDNS.OPT)
|
||||
opt.Hdr.Name = "."
|
||||
opt.Hdr.Rrtype = mDNS.TypeOPT
|
||||
opt.Option = append(opt.Option, &mDNS.EDNS0_PADDING{Padding: padding})
|
||||
message.Extra = append(message.Extra, opt)
|
||||
return message, padding[:markedQueryNeedle]
|
||||
}
|
||||
|
||||
// poolHoldsNeedle drains one size class of the buffer pool and reports whether
|
||||
// any buffer in it still carries the needle. It must run on the goroutine that
|
||||
// released the buffer: sync.Pool keeps a per-P private slot that no other P can
|
||||
// steal from, and on the paths this test covers the release happens inline in
|
||||
// RoundTripOpt, on the caller's own goroutine.
|
||||
func poolHoldsNeedle(size int, needle []byte, count int) bool {
|
||||
held := make([]*buf.Buffer, 0, count)
|
||||
defer func() { releaseAll(held) }()
|
||||
var found bool
|
||||
for range count {
|
||||
buffer := buf.NewSize(size)
|
||||
held = append(held, buffer)
|
||||
if bytes.Contains(buffer.FreeBytes(), needle) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// requireInstrumentFindsPackedQuery is the CONTROL. It does exactly what the old
|
||||
// Exchange did — pack a query into a pooled buffer and release it — and demands
|
||||
// that the scan below FINDS the needle. Without it, "the pool does not hold the
|
||||
// query" would also be the verdict for a scan that can never find anything.
|
||||
//
|
||||
// Retried for the same reason its sibling control above is, and it was NOT
|
||||
// before: under -race sync.Pool.Put drops one object in four, so a single
|
||||
// attempt made this control — and with it the whole -race pass of the gate —
|
||||
// fail on 18 of 60 measured runs with nothing wrong in the tree. A fresh
|
||||
// needle is packed on each attempt, so a later one cannot be answered by an
|
||||
// earlier one's bytes. See controlAttempts for what the retry does and does not
|
||||
// buy.
|
||||
func requireInstrumentFindsPackedQuery(t *testing.T) {
|
||||
t.Helper()
|
||||
for range controlAttempts {
|
||||
message, needle := markedQuery(t)
|
||||
size := 1 + message.Len()
|
||||
exMessage := *message
|
||||
exMessage.Id = 0
|
||||
exMessage.Compress = true
|
||||
|
||||
buffer := buf.NewSize(size)
|
||||
if _, err := exMessage.PackBuffer(buffer.FreeBytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer.Release()
|
||||
|
||||
if poolHoldsNeedle(size, needle, poolScanDepth) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("control failed: %d times in a row, a query packed into a pooled buffer and released was NOT "+
|
||||
"found by the scan, so a clean verdict below would prove nothing. Under -race sync.Pool.Put drops "+
|
||||
"one object in four, which is what the retries absorb; this many consecutive misses is something "+
|
||||
"else — a pool that zeroes on Put, a size class that stopped being pooled, or buf.Buffer no longer "+
|
||||
"handing its array back at all", controlAttempts)
|
||||
}
|
||||
|
||||
// TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory pins the ownership rule the
|
||||
// failure paths depend on.
|
||||
//
|
||||
// quic-go's http3.Transport closes the request body on every error path
|
||||
// (transport.go RoundTripOpt) the moment doRequest returns, and doRequest waits
|
||||
// only on the request-cancellation watchdog — never on the goroutine writing the
|
||||
// body. So releasing the buffer when the body is closed is not an ownership
|
||||
// handoff, and the only safe arrangement is for the query never to live in pool
|
||||
// memory at all.
|
||||
//
|
||||
// This test encodes THAT design. A future guarded-pool design (a lock around
|
||||
// Read and Close, refusing reads after release) would also be correct and would
|
||||
// fail this test on purpose — it would have to replace it, and say so.
|
||||
func TestHTTP3ExchangeNeverPacksQueriesIntoPooledMemory(t *testing.T) {
|
||||
requireInstrumentFindsPackedQuery(t)
|
||||
|
||||
// A UDP socket nobody answers on: the handshake runs to the context deadline
|
||||
// instead of being refused, which is the shape a router sees when the tunnel
|
||||
// carrying its resolver drops.
|
||||
blackhole, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { blackhole.Close() })
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
ctx func(t *testing.T) (context.Context, context.CancelFunc)
|
||||
}{
|
||||
{
|
||||
// RoundTripOpt closes the body after the handshake gives up.
|
||||
name: "server never answers",
|
||||
ctx: func(t *testing.T) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The cancellation watchdog fires, then RoundTripOpt closes the body.
|
||||
name: "context already cancelled",
|
||||
ctx: func(t *testing.T) (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx, func() {}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
message, needle := markedQuery(t)
|
||||
size := 1 + message.Len()
|
||||
|
||||
dialer := &trackingDialer{}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
dnsTransport := &HTTP3Transport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3-ownership", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
|
||||
headers: http.Header{},
|
||||
serverAddr: M.ParseSocksaddr(blackhole.LocalAddr().String()),
|
||||
tlsConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "localhost",
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
}
|
||||
dnsTransport.transport = dnsTransport.newTransport()
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := testCase.ctx(t)
|
||||
defer cancel()
|
||||
if _, err := dnsTransport.Exchange(ctx, message); err == nil {
|
||||
t.Fatal("expected the exchange to fail; this test is about the failure path")
|
||||
}
|
||||
|
||||
// Same goroutine that ran RoundTripOpt, so the per-P private slot a
|
||||
// release would have landed in is the one being drained.
|
||||
if poolHoldsNeedle(size, needle, poolScanDepth) {
|
||||
t.Fatal("the bytes of the query came back out of the buffer pool: the request body was packed into pooled " +
|
||||
"memory and released while quic-go's body writer could still be reading it")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package quic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/quic-go/http3"
|
||||
sbTLS "github.com/sagernet/sing-box/common/tls"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing-box/dns/transport"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
mDNS "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
var _ N.Dialer = (*trackingDialer)(nil)
|
||||
|
||||
// These tests pin down who owns the UDP socket handed to quic-go.
|
||||
//
|
||||
// quic-go's Dial/DialEarly take a net.PacketConn but do NOT take ownership of
|
||||
// it: quic.setupTransport() builds a Transport with createdConn=false, and
|
||||
// Transport.Close() then only calls conn.SetReadDeadline(time.Now()) instead of
|
||||
// conn.Close(). So every QUIC connection torn down here — idle timeout, a
|
||||
// retryable error, an engine reload calling Reset() — used to strand the UDP
|
||||
// socket that carried it for the rest of the process's life. On a router that
|
||||
// resolves through DoQ/DoH3 for months that is an unbounded fd leak.
|
||||
//
|
||||
// Both tests reconnect once and assert the socket from the FIRST connection is
|
||||
// actually closed. Without the `<-conn.Context().Done() -> rawConn.Close()`
|
||||
// watchdogs in quic.go / http3.go they fail on that assertion.
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
c.closeOnce.Do(func() { close(c.closed) })
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// trackingDialer hands out real UDP sockets and remembers every one of them.
|
||||
type trackingDialer struct {
|
||||
access sync.Mutex
|
||||
conns []*trackedConn
|
||||
}
|
||||
|
||||
func (d *trackingDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, network, destination.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracked := &trackedConn{Conn: conn, closed: make(chan struct{})}
|
||||
d.access.Lock()
|
||||
d.conns = append(d.conns, tracked)
|
||||
d.access.Unlock()
|
||||
return tracked, nil
|
||||
}
|
||||
|
||||
func (d *trackingDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return net.ListenUDP("udp", nil)
|
||||
}
|
||||
|
||||
func (d *trackingDialer) count() int {
|
||||
d.access.Lock()
|
||||
defer d.access.Unlock()
|
||||
return len(d.conns)
|
||||
}
|
||||
|
||||
func (d *trackingDialer) at(index int) *trackedConn {
|
||||
d.access.Lock()
|
||||
defer d.access.Unlock()
|
||||
return d.conns[index]
|
||||
}
|
||||
|
||||
func (d *trackingDialer) closeAll() {
|
||||
d.access.Lock()
|
||||
defer d.access.Unlock()
|
||||
for _, conn := range d.conns {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func requireClosed(t *testing.T, conn *trackedConn, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-conn.closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("%s: the UDP socket of the retired QUIC connection was never closed — quic-go does not own it, we must", what)
|
||||
}
|
||||
}
|
||||
|
||||
func requireDialed(t *testing.T, dialer *trackingDialer, want int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if dialer.count() >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected at least %d dial(s), got %d", want, dialer.count())
|
||||
}
|
||||
|
||||
func testServerTLSConfig(t *testing.T, nextProtos []string) *tls.Config {
|
||||
t.Helper()
|
||||
certificate, err := sbTLS.GenerateKeyPair(nil, nil, nil, "localhost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{*certificate},
|
||||
NextProtos: nextProtos,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
}
|
||||
|
||||
func testClientTLSConfig(t *testing.T, nextProtos []string) sbTLS.Config {
|
||||
t.Helper()
|
||||
config, err := sbTLS.NewClient(context.Background(), logger.NOP(), "localhost", option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Insecure: true,
|
||||
ServerName: "localhost",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.SetNextProtos(nextProtos)
|
||||
return config
|
||||
}
|
||||
|
||||
// startDoQServer serves a minimal DoQ responder and returns its address.
|
||||
func startDoQServer(t *testing.T) M.Socksaddr {
|
||||
t.Helper()
|
||||
listener, err := quic.ListenAddr("127.0.0.1:0", testServerTLSConfig(t, []string{"doq"}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
listener.Close()
|
||||
})
|
||||
go func() {
|
||||
for {
|
||||
conn, acceptErr := listener.Accept(ctx)
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
go func(conn *quic.Conn) {
|
||||
for {
|
||||
stream, streamErr := conn.AcceptStream(ctx)
|
||||
if streamErr != nil {
|
||||
return
|
||||
}
|
||||
go func(stream *quic.Stream) {
|
||||
defer stream.Close()
|
||||
request, readErr := transport.ReadMessage(stream)
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
response := new(mDNS.Msg)
|
||||
response.SetReply(request)
|
||||
transport.WriteMessage(stream, 0, response)
|
||||
}(stream)
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return M.ParseSocksaddr(listener.Addr().String())
|
||||
}
|
||||
|
||||
func testQuery() *mDNS.Msg {
|
||||
message := new(mDNS.Msg)
|
||||
message.SetQuestion("example.com.", mDNS.TypeA)
|
||||
return message
|
||||
}
|
||||
|
||||
func TestQUICTransportClosesPacketConnOnReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
serverAddr := startDoQServer(t)
|
||||
dialer := &trackingDialer{}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
|
||||
dnsTransport := &Transport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeQUIC, "test-doq", nil),
|
||||
dialer: dialer,
|
||||
serverAddr: serverAddr,
|
||||
tlsConfig: testClientTLSConfig(t, []string{"doq"}),
|
||||
connection: transport.NewConnPool(transport.ConnPoolOptions[*quic.Conn]{
|
||||
Mode: transport.ConnPoolSingle,
|
||||
IsAlive: func(conn *quic.Conn) bool {
|
||||
return conn != nil && !common.Done(conn.Context())
|
||||
},
|
||||
Close: func(conn *quic.Conn, _ error) {
|
||||
conn.CloseWithError(0, "")
|
||||
},
|
||||
}),
|
||||
}
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
|
||||
t.Fatal("first exchange: ", err)
|
||||
}
|
||||
requireDialed(t, dialer, 1)
|
||||
first := dialer.at(0)
|
||||
|
||||
// Retire the connection the way a retryable error or an engine reload does.
|
||||
dnsTransport.Reset()
|
||||
requireClosed(t, first, "Reset()")
|
||||
|
||||
// The reconnect must still work, on a fresh socket.
|
||||
if _, err := dnsTransport.Exchange(ctx, testQuery()); err != nil {
|
||||
t.Fatal("second exchange: ", err)
|
||||
}
|
||||
requireDialed(t, dialer, 2)
|
||||
second := dialer.at(1)
|
||||
if second == first {
|
||||
t.Fatal("expected a new UDP socket for the reconnect")
|
||||
}
|
||||
|
||||
if err := dnsTransport.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireClosed(t, second, "Close()")
|
||||
}
|
||||
|
||||
func TestHTTP3TransportClosesPacketConnOnReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dns-query", func(writer http.ResponseWriter, request *http.Request) {
|
||||
message, err := readRequestMessage(request)
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response := new(mDNS.Msg)
|
||||
response.SetReply(message)
|
||||
rawResponse, err := response.Pack()
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", transport.MimeType)
|
||||
writer.Write(rawResponse)
|
||||
})
|
||||
listener, err := quic.ListenAddrEarly("127.0.0.1:0", testServerTLSConfig(t, []string{http3.NextProtoH3}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http3.Server{Handler: mux}
|
||||
go server.ServeListener(listener)
|
||||
t.Cleanup(func() {
|
||||
server.Close()
|
||||
listener.Close()
|
||||
})
|
||||
serverAddr := M.ParseSocksaddr(listener.Addr().String())
|
||||
|
||||
dialer := &trackingDialer{}
|
||||
t.Cleanup(dialer.closeAll)
|
||||
|
||||
stdConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "localhost",
|
||||
NextProtos: []string{http3.NextProtoH3},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
dnsTransport := &HTTP3Transport{
|
||||
TransportAdapter: dns.NewTransportAdapter(C.DNSTypeHTTP3, "test-doh3", nil),
|
||||
logger: logger.NOP(),
|
||||
dialer: dialer,
|
||||
destination: &url.URL{Scheme: "https", Host: "localhost", Path: "/dns-query"},
|
||||
headers: http.Header{},
|
||||
serverAddr: serverAddr,
|
||||
tlsConfig: stdConfig,
|
||||
}
|
||||
dnsTransport.transport = dnsTransport.newTransport()
|
||||
t.Cleanup(func() { dnsTransport.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
|
||||
t.Fatal("first exchange: ", err)
|
||||
}
|
||||
requireDialed(t, dialer, 1)
|
||||
first := dialer.at(0)
|
||||
|
||||
dnsTransport.Reset()
|
||||
requireClosed(t, first, "Reset()")
|
||||
|
||||
if _, err = dnsTransport.Exchange(ctx, testQuery()); err != nil {
|
||||
t.Fatal("second exchange: ", err)
|
||||
}
|
||||
requireDialed(t, dialer, 2)
|
||||
second := dialer.at(1)
|
||||
if second == first {
|
||||
t.Fatal("expected a new UDP socket for the reconnect")
|
||||
}
|
||||
|
||||
if err = dnsTransport.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireClosed(t, second, "Close()")
|
||||
}
|
||||
|
||||
func readRequestMessage(request *http.Request) (*mDNS.Msg, error) {
|
||||
defer request.Body.Close()
|
||||
rawMessage := make([]byte, 4096)
|
||||
n, err := readFull(request.Body, rawMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var message mDNS.Msg
|
||||
err = message.Unpack(rawMessage[:n])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &message, nil
|
||||
}
|
||||
|
||||
func readFull(reader interface{ Read([]byte) (int, error) }, buffer []byte) (int, error) {
|
||||
var total int
|
||||
for total < len(buffer) {
|
||||
n, err := reader.Read(buffer[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
if total > 0 {
|
||||
return total, nil
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/quic-go"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
@@ -117,6 +118,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
|
||||
rawConn.Close()
|
||||
return nil, E.Cause(err, "establish QUIC connection")
|
||||
}
|
||||
// quic-go does not take ownership of the packet conn passed to
|
||||
// DialEarly: when the connection ends it only stops reading.
|
||||
go func() {
|
||||
<-earlyConnection.Context().Done()
|
||||
rawConn.Close()
|
||||
}()
|
||||
return earlyConnection, nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -144,6 +151,11 @@ func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.
|
||||
return nil, E.Cause(err, "open stream")
|
||||
}
|
||||
defer stream.CancelRead(0)
|
||||
stopWatch := context.AfterFunc(ctx, func() {
|
||||
stream.CancelRead(0)
|
||||
_ = stream.SetWriteDeadline(time.Now())
|
||||
})
|
||||
defer stopWatch()
|
||||
err = transport.WriteMessage(stream, 0, message)
|
||||
if err != nil {
|
||||
stream.Close()
|
||||
|
||||
@@ -12,6 +12,50 @@ as GitHub **pre-releases** and never become "Latest".
|
||||
|
||||
#### Unreleased (shater)
|
||||
|
||||
**`l3-honest-drop` — ICMP routed to an L4-only outbound is dropped, not
|
||||
forged** — ships with `shaterd` (part of the shater L3 ingress,
|
||||
`docs-shater/DECISIONS.md` D25), not as an lx release tag; recorded here because
|
||||
it edits two upstream files. Without it the TUN stack answers an unroutable echo
|
||||
ITSELF — sing-tun's `ICMPForwarder.HandlePacket` rewrites Echo→EchoReply
|
||||
whenever the flow judgment comes back Accept (`stack_gvisor_icmp.go`) — so a
|
||||
ping routed to vless/vmess/… would read as a working tunnel while the packet
|
||||
never left the router.
|
||||
|
||||
* **`route/route.go` (`PreMatch`)** — the pre-match walk was renamed to
|
||||
`preMatch` and the exported `PreMatch` became a thin FUNNEL that rewrites
|
||||
`PreMatchContinue` and `PreMatchBypass` to `PreMatchDrop` for
|
||||
`N.NetworkICMP`. An earlier version overrode `continueResult` inside
|
||||
`preMatchFlow` instead; that covered only the exits reaching that function and
|
||||
left three of the walk's own exits forging — the `prepareMatchMetadata` error
|
||||
return, the sniff bail-outs, and the `default:` arm of the rule-action switch
|
||||
(every action pre-match has no arm for: `hijack-dns`, `direct`, …). A guard on
|
||||
the single return value cannot be outgrown by a new exit. `PreMatchBypass` is
|
||||
folded in because sing-tun implements `ActionBypass` on the nfqueue plane only
|
||||
— on the TUN path it lands in the same `default:` arm as Accept, i.e. forges.
|
||||
* **`adapter/router.go` (`JudgeFlow`, the `!isPort` branch)** — ICMP returns
|
||||
`ActionDrop` where it fell through to `ActionAccept`. Second line of defense:
|
||||
`adapter.FlowOutbound` and `tun.Port` are distinct interfaces, and a drift
|
||||
between them must not quietly re-enable the forged reply.
|
||||
* **TCP/UDP behaviour is unchanged** — `PreMatchContinue` still means "take the
|
||||
ordinary connection route" for both, `PreMatchBypass` still means bypass, and
|
||||
the `!isPort` fallthrough still returns `ActionAccept` for them; pinned by
|
||||
`route/prematch_icmp_lx_test.go` and `adapter/judgeflow_icmp_lx_test.go`
|
||||
(both inside the marker), each ICMP case having an explicit TCP/UDP twin.
|
||||
* **NOT covered: a FRAGMENTED echo to a WireGuard/AWG outbound is still
|
||||
forged** — sing-tun's `ForwardDispatcher.Dispatch` returns before asking for a
|
||||
verdict at all when `parsed.fragment`, and the reassembled packet reaches
|
||||
`ICMPForwarder.HandlePacket`, whose `installFlow` demands an UNSPECIFIED port
|
||||
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, 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
|
||||
an upstream rebase by eye. Note that `PreMatch`'s own body now lives in
|
||||
`preMatch`, so an upstream change to the walk applies to that function.
|
||||
|
||||
**Fork-layer + control-plane rework of proxy health** — ships with `shaterd`
|
||||
(the shater router daemon), not as an lx release tag; recorded here because the
|
||||
load-bearing half lives in fork zones (`common/urltest`, `protocol/group`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
+38
-26
@@ -31,12 +31,11 @@ Do not delete it — we port proven pieces from it. What v0.1 has:
|
||||
- **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS +
|
||||
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
|
||||
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
|
||||
- **CI + signed opkg feed** on Gitea: builds per-arch, signs the feed index with
|
||||
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed
|
||||
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`,
|
||||
secret in the Gitea repo secret `KEY_BUILD`.
|
||||
- **CI + a signed package feed** on Gitea: builds per-arch, signs the feed index,
|
||||
publishes a rolling `latest` Gitea release the router consumes as a feed.
|
||||
(v0.1 shipped `.ipk` signed with a usign key — that lane is retired, D22.)
|
||||
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
|
||||
fail-closed, opkg install/upgrade from the signed feed.
|
||||
fail-closed, install/upgrade from the signed feed.
|
||||
|
||||
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
|
||||
`run.json` are xray-shaped.
|
||||
@@ -52,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
|
||||
@@ -85,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/`, `dist/shater-feed.pub`).
|
||||
|
||||
## What to port from v0.1 (don't rewrite these ideas)
|
||||
|
||||
@@ -105,8 +107,8 @@ overlay, don't redo:
|
||||
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
|
||||
and the flexible **ruleset/list** model — though sing-box has its own share-link
|
||||
parser and config schema we now target.
|
||||
- **CI feed build + usign signing + Gitea release** (adapt to the single forked
|
||||
binary; keep key `5ac4b177689cb8e0`).
|
||||
- **CI feed build + index signing + Gitea release** (adapted to the single forked
|
||||
binary; the format is apk, signed with the EC key — D22).
|
||||
- The LuCI **design system** (the "instrument panel" identity) — reused for the
|
||||
mini-dashboard and as the panel's visual language.
|
||||
|
||||
@@ -120,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.
|
||||
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret
|
||||
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep
|
||||
verifying).
|
||||
- **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.
|
||||
- **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:** **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.
|
||||
|
||||
+1261
-1
File diff suppressed because it is too large
Load Diff
+59
-9
@@ -6,15 +6,51 @@ 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
|
||||
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
|
||||
Host/QUIC sniffing.
|
||||
- **[MVP]** **L3 ingress for ICMP** (`globals.l3_tunnel`, opt-in, default off):
|
||||
LAN ping travels THROUGH the tunnel instead of being dropped or answered by a
|
||||
forged local reply. The engine opens a dedicated TUN (`shater-l3`, gVisor
|
||||
stack, `auto_route` off); nft marks LAN icmp/icmpv6 only and a scoped
|
||||
`ip rule` routes it in — the TPROXY plane and the main routing table stay
|
||||
untouched (D25). Carried only by L3-capable egresses (WireGuard/AmneziaWG,
|
||||
direct); ICMP routed to vless/vmess/… is honestly dropped, never faked.
|
||||
Ceiling is upstream sing-tun's: ICMP echo only — Windows tracert works, IPv6
|
||||
traceroute shows just the destination; ESP/AH/GRE/IGMP stay with the
|
||||
`untunnelable` policy (D17) unless `untunnelable_egress` carries them (D26).
|
||||
- **[MVP]** **Kernel egress for untunnelable protocols**
|
||||
(`globals.untunnelable_egress`, opt-in, default empty): names an existing
|
||||
interface/tunnel egress, and IPsec (ESP/AH), PPTP/GRE, SCTP — everything that
|
||||
is neither TCP nor UDP, plus ICMP when the L3 ingress is off — is routed out
|
||||
that egress's device by the KERNEL with kernel NAT, reusing the egress's own
|
||||
fwmark/table from `addEgressRouting`; the proxy never sees a byte, which is
|
||||
why every protocol works (D26). What that buys depends on the device: a
|
||||
WireGuard interface really is a tunnel, a second WAN is just another uplink
|
||||
whose real address the destination sees. It does not revive multicast IPTV,
|
||||
and UDP-based VPNs (WireGuard, OpenVPN-UDP, IPsec NAT-T) never needed it —
|
||||
they follow the routing rules as before. The `untunnelable` policy (D17)
|
||||
keeps only the failure case: a route that did not come up.
|
||||
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
|
||||
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
|
||||
proto → target (outbound/selector/chain/direct/block) + egress.
|
||||
destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
|
||||
A rule names its **destination through a rule-set only** — a reusable named list
|
||||
(inline domains/CIDRs, a local or remote file, or a geosite/geoip category) that is
|
||||
compiled once into a `.srs` and shared by every rule that references it. Domain
|
||||
entries take `full:` (exact), `suffix:` / a leading dot (host + subdomains),
|
||||
`keyword:` (substring) and `regexp:`; a bare entry means host + subdomains.
|
||||
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
|
||||
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
|
||||
interface/tunnel (e.g. an AmneziaWG tunnel).
|
||||
@@ -36,6 +72,16 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
||||
type=fakeip + pool — there is no global "FakeIP mode"); no DNS leaks. Routing
|
||||
is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population
|
||||
mechanism does not exist in v0.2 (see generate/dns.go).
|
||||
The hijack covers the queries a client sends **to the router itself** — the
|
||||
address DHCP hands out — because `globals.dns_intercept` is **ON by default**
|
||||
(D24). With it off, those queries go to dnsmasq and out to the ISP in the clear,
|
||||
so the well-behaved client leaks while the one that hard-codes 8.8.8.8 does not.
|
||||
`.lan` and the private PTR zones are preserved through dnsmasq either way. Two
|
||||
things the promise does NOT cover, both by design: while the engine is DOWN the
|
||||
holding plane hooks `forward` only, so dnsmasq still answers router-addressed
|
||||
:53 unfiltered (client traffic and DNS to external resolvers stay blocked); and
|
||||
with no `config resolver` at all there is no DNS plane to filter with — queries
|
||||
fall through to the system resolver and generate says so.
|
||||
- **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter).
|
||||
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
|
||||
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
|
||||
@@ -73,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.
|
||||
@@ -89,8 +139,8 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
||||
SIM uplink → different egress); backup/restore; i18n (EN + RU).
|
||||
|
||||
## Ops & distribution
|
||||
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key
|
||||
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`.
|
||||
- **[MVP]** Single signed binary; signed apk feed on Gitea (EC key
|
||||
`dist/shater-apk.pem`); one-line install; named-package `apk upgrade`.
|
||||
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
|
||||
- **[T2]** apk (OpenWrt 25.x) packaging; multi-router fleet management; REST/gRPC
|
||||
external API; Telegram bot.
|
||||
- **[T2]** Multi-router fleet management; REST/gRPC external API; Telegram bot.
|
||||
(apk packaging landed and is now the only lane — D22.)
|
||||
|
||||
+349
-92
@@ -1,7 +1,7 @@
|
||||
# Shater v0.2 — Build & Install
|
||||
|
||||
How to build the ship artifact (the SPA-embedded `shaterd` binary) and install
|
||||
the OpenWrt feed onto a router.
|
||||
the signed apk repo onto a router.
|
||||
|
||||
## 1. Build the `shaterd` binary
|
||||
|
||||
@@ -26,7 +26,9 @@ What it does:
|
||||
Arg / env:
|
||||
|
||||
- `VERSION` — stamped into `constant.Version`. Resolution: positional arg →
|
||||
`$SHATER_VERSION` → `git describe --tags` → `v0.2.0-dev`.
|
||||
`$SHATER_VERSION` → `ci/version.sh --binary` → `v0.2.0-dev`. `ci/version.sh` is
|
||||
the **same** computation the package version comes from (§2.1), so the string
|
||||
the panel shows always matches what `apk list -I shaterd` reports.
|
||||
- `--fast` — skip `npm ci` when `panel/node_modules` already exists.
|
||||
- `UPX=/path/to/upx` — override the UPX binary (default `upx` on `PATH`). UPX is
|
||||
cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also
|
||||
@@ -41,32 +43,53 @@ UPX="…/scratchpad/upx-4.2.4-win64/upx.exe" scripts/build-shaterd.sh v0.2.0 --f
|
||||
The `dist/*` and `openwrt/shaterd/files/shaterd-*.upx` outputs are gitignored —
|
||||
they are release artifacts, not source.
|
||||
|
||||
Tag set (D9 — keep in sync with `docs-shater/DECISIONS.md`):
|
||||
Tag set (D9/D23) — defined in **one** place, `scripts/router-tags.sh`, which
|
||||
documents every tag and is sourced by the build:
|
||||
|
||||
```
|
||||
with_quic,with_wireguard,with_utls,
|
||||
with_gvisor,with_quic,with_wireguard,with_utls,
|
||||
badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
|
||||
```
|
||||
|
||||
We drop `with_purego,with_naive_outbound`: they pull cronet-go, which forces a
|
||||
glibc `PT_INTERP` even under `CGO_ENABLED=0`, making the binary unusable on musl.
|
||||
We drop `with_gvisor`: the shater data plane is tproxy/redirect and generate
|
||||
never emits a tun inbound, so the userspace gvisor netstack is unreachable code.
|
||||
We drop `with_clash_api`: the admin panel is shater's own web server and the
|
||||
generator never emits a `clash_api` service, so the Clash server is dead code.
|
||||
We drop `with_dhcp`: shater resolver types are `udp/tcp/doh/dot/local/fakeip`;
|
||||
a `dhcp://` DNS transport is never generated or registered.
|
||||
|
||||
`with_gvisor` was dropped in 2026-07 as "unreachable — we emit no tun inbound"
|
||||
and **put back on 2026-07-25**: gVisor is also the netstack of the WireGuard
|
||||
endpoint, so without it every `wg://`/`awg://` node died at apply time with
|
||||
*"gVisor is not included in this build"* while the panel still offered the
|
||||
feature. It costs ~2.8 MB raw / ~0.65 MB UPX per arch. Full story: `DECISIONS.md`
|
||||
D23.
|
||||
|
||||
### Changing the tag set
|
||||
|
||||
Run the guard — it is what stands between a size trim and a silently dead
|
||||
feature, and CI runs it before the artifact is built:
|
||||
|
||||
```sh
|
||||
scripts/check-router-tags.sh # from Windows/macOS it re-execs itself in golang:1.26
|
||||
```
|
||||
|
||||
It (1) fails if a feature declared in `FEATURES.md` lost a build tag it needs to
|
||||
run (`shater/buildtags`, no tags/OS/network required) and (2) constructs one node
|
||||
of every declared protocol through `box.New` **compiled with the shipped tag
|
||||
set** — nothing may be skipped in that run. Adding a protocol to
|
||||
`shater/parse`+`shater/generate` means adding a row to `buildtags.Features` and a
|
||||
probe case in `shater/generate/shipped_tags_linux_test.go`.
|
||||
|
||||
## 2. Packages
|
||||
|
||||
Four OpenWrt packages live under `openwrt/`:
|
||||
Three 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. |
|
||||
|
||||
### Why `shaterd` is a prebuilt-binary package
|
||||
|
||||
@@ -84,22 +107,62 @@ it. Because the binary is UPX-packed, the package disables the SDK's default str
|
||||
feed installed and run `make package/shaterd/compile` (and the others) per target.
|
||||
See `openwrt-package-build-ci` for SDK/feed mechanics.
|
||||
|
||||
### 2.1 Package versions come from the git tag
|
||||
|
||||
`PKG_VERSION`/`PKG_RELEASE` are **not** maintained by hand. They used to be, and
|
||||
nobody bumped them: **v0.2.2 … v0.2.6 all shipped as `shaterd 0.2.0-r3`** with
|
||||
different binaries inside (v0.2.6's ELF is 5 491 616 B against r2's 5 488 336 B).
|
||||
apk offers an upgrade only when the feed's version string differs from the
|
||||
installed one, so `apk update` saw nothing new and the routers could not be
|
||||
updated through the normal path at all.
|
||||
|
||||
`ci/version.sh` now derives them from `git describe`, once per CI job:
|
||||
|
||||
| Build | `PKG_VERSION` | `PKG_RELEASE` | `constant.Version` |
|
||||
|---|---|---|---|
|
||||
| tag push `v0.2.7` | `0.2.7` | `1` | `v0.2.7-r1` |
|
||||
| dispatch, 3 commits past `v0.2.7` | `0.2.7` | `4` | `v0.2.7-r4-g<sha>` |
|
||||
| no reachable tag / no git | `0.0.0` | `1` | `v0.0.0-r1` |
|
||||
|
||||
Ordering is what makes this safe (checked with `apk version -t` on apk-tools
|
||||
3.0.3): the dotted part decides first, `-rN` only breaks ties — so
|
||||
`0.2.7-r1 > 0.2.6-r12 > 0.2.6-r1 > 0.2.0-r3`. A release therefore always
|
||||
outranks every rolling build before it, rolling builds between two releases grow
|
||||
monotonically, and an untagged build (`0.0.0`) can never masquerade as an
|
||||
upgrade.
|
||||
|
||||
The value travels as `SHATER_PKG_VERSION`/`SHATER_PKG_RELEASE` in the SDK build
|
||||
environment; the Makefiles read it with a literal fallback for manual/offline
|
||||
builds. `ci/sdk-build-apk.sh` then **asserts** the produced `.apk` really carries
|
||||
it, so a lost variable fails the build instead of shipping a stale version. The
|
||||
release job asserts the same version again on the published rolling repo (§5.1).
|
||||
|
||||
All three are versioned this way. There used to be a fourth package carrying its
|
||||
upstream's own version and therefore exempt from the assertion above; it is gone
|
||||
(D29), and with it the exception nobody could be expected to remember.
|
||||
|
||||
## 3. Install on a router
|
||||
|
||||
Install order follows the deps (`shaterd` → `shater-core` → `luci-app-shater`):
|
||||
**The normal path is the signed apk repo — §5.** This section is the manual
|
||||
fallback (a router with no route to the Gitea host, or a hand-carried build).
|
||||
|
||||
Install order follows the deps (`shaterd` → `shater-core` → `luci-app-shater`).
|
||||
apk filenames carry no architecture, so make sure you copied the `.apk` built for
|
||||
*this* router's arch (`cat /etc/apk/arch`):
|
||||
|
||||
```sh
|
||||
opkg install shaterd_0.2.0-1_<arch>.ipk # or: apk add shaterd (25.12+)
|
||||
opkg install shater-core_0.2.0-1_all.ipk
|
||||
opkg install luci-app-shater_0.2.0-1_all.ipk
|
||||
opkg install byedpi_0.17.3-1_<arch>.ipk # optional: ByeDPI egress
|
||||
# <ver> = the release version, e.g. 0.2.7-r1 (§2.1 — it comes from the git tag)
|
||||
# --allow-untrusted: our member .apk are unsigned by design — trust lives in the
|
||||
# signed packages.adb index (§5), which a loose file install does not consult.
|
||||
apk add --allow-untrusted ./shaterd-<ver>.apk
|
||||
apk add --allow-untrusted ./shater-core-<ver>.apk
|
||||
apk add --allow-untrusted ./luci-app-shater-<ver>.apk
|
||||
```
|
||||
|
||||
Installing from a signed feed instead:
|
||||
From the repo instead (§5 sets it up once), deps pull the rest in:
|
||||
|
||||
```sh
|
||||
# add the feed (customfeeds.conf / apk repositories), then:
|
||||
opkg update && opkg install shater-core luci-app-shater # shaterd pulled in as a dep
|
||||
apk update && apk add luci-app-shater # -> shater-core -> shaterd
|
||||
```
|
||||
|
||||
## 4. Enable
|
||||
@@ -109,93 +172,162 @@ 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
|
||||
"Open panel" button mints a single-use token and hands the browser off to the panel.
|
||||
|
||||
## 5. Add the signed feed (recommended — then `opkg upgrade` just works)
|
||||
### What enabling does to DNS
|
||||
|
||||
CI (`.gitea/workflows/release.yml`) publishes every build as a **rolling `latest`
|
||||
Gitea release** that is itself a signed opkg `src/gz` feed: the release holds the
|
||||
`.ipk` for all arches, a `Packages`/`Packages.gz` index, a usign `Packages.sig`,
|
||||
and the public key `shater-feed.pub`. opkg filters by `Architecture`, so the **same
|
||||
two lines work on every device** (x86 testbed picks `x86_64 + all`; the BPI routers
|
||||
pick `aarch64_cortex-a53 + all`).
|
||||
From the first apply, **every** LAN plaintext `:53` goes into the engine — including
|
||||
the queries a client sends to the router's own address, which is what DHCP hands out.
|
||||
That is `globals.dns_intercept`, and it is **on by default** (D24); without it those
|
||||
queries reach dnsmasq and the ISP unfiltered, i.e. the client with default settings
|
||||
leaks while the one that hard-coded `8.8.8.8` does not. What follows from it:
|
||||
|
||||
> **Format:** OpenWrt 24.10 (our SDK) uses **opkg** (`.ipk`, `Packages.gz`, usign),
|
||||
> so the feed is `src/gz` and the trust anchor is the usign key
|
||||
> `dist/shater-feed.pub` (fingerprint **`5ac4b177689cb8e0`**). apk only replaces
|
||||
> opkg at OpenWrt **25.12** — see §6.
|
||||
- `.lan` and private reverse (PTR) lookups still go to dnsmasq — the engine gets a
|
||||
rule for those suffixes. If you renamed dnsmasq's domain away from `lan`, add a
|
||||
`config dns_rule` for the new suffix.
|
||||
- Configure at least one `config resolver`. With none, the engine has no resolver
|
||||
plane: intercepted queries fall through to the system resolver (dnsmasq → your
|
||||
ISP, in the clear), blocklists and per-device DNS rules are inert, and the apply
|
||||
says so in its warnings.
|
||||
- While the engine is DOWN, DNS is **not** blacked out: the fail-closed holding
|
||||
plane hooks `forward` only, so dnsmasq keeps answering router-addressed `:53`
|
||||
(unfiltered, plaintext) while client traffic and DNS to external resolvers stay
|
||||
blocked. "The tunnel is down" is not "DNS is private".
|
||||
|
||||
One-time setup on the router:
|
||||
To opt out, on the router:
|
||||
|
||||
```sh
|
||||
# 1) trust the feed key — the FILENAME must equal the usign key fingerprint.
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
|
||||
# 2) add the feed (one URL serves every arch).
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
|
||||
>> /etc/opkg/customfeeds.conf
|
||||
|
||||
# 3) refresh + install (shaterd is pulled in as a dependency).
|
||||
opkg update
|
||||
opkg install luci-app-shater # -> shater-core -> shaterd
|
||||
opkg install byedpi # optional: ByeDPI desync egress
|
||||
uci set shater.globals.dns_intercept=0
|
||||
uci commit shater
|
||||
shaterd apply
|
||||
```
|
||||
|
||||
With the key installed, opkg's default `check_signature 1` verifies the feed on
|
||||
every `opkg update`; no `--nocheck-signature` needed. A **tagged** release
|
||||
(`vX.Y.Z`) publishes the identical layout at
|
||||
`.../releases/download/vX.Y.Z` if you prefer to pin a version instead of tracking
|
||||
`latest`.
|
||||
Your `0` is kept: `/etc/config/shater` is a conffile (upgrades never replace it) and
|
||||
the daemon always writes the option back explicitly, so it is never re-enabled by a
|
||||
default.
|
||||
|
||||
### Updating
|
||||
### The boot-time fail-closed armor
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
|
||||
`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.
|
||||
```
|
||||
|
||||
Updates are only offered when the feed's `Version` differs from the installed one,
|
||||
so **bump `PKG_RELEASE`** (or `PKG_VERSION`) in the package Makefile on every
|
||||
shipped change — otherwise `opkg upgrade` sees the same version and does nothing.
|
||||
Do **not** `opkg upgrade` base/system packages from this feed; upgrade only the
|
||||
four shater packages above.
|
||||
that is the mechanism working, not a fault.
|
||||
|
||||
## 6. apk feed (OpenWrt/ImmortalWrt 25.12+ — incl. BananaWRT 25.12-mtk-vendor)
|
||||
**When it refuses to arm** — each is a state check made at boot, never a record of
|
||||
something that happened on the way down:
|
||||
|
||||
OpenWrt/ImmortalWrt **25.12** replaces opkg with Alpine's **apk**: `.apk` files,
|
||||
a binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
|
||||
effectively mandatory signatures (unsigned needs `--allow-untrusted`). The
|
||||
package **Makefiles are unchanged** — the SDK release decides the format.
|
||||
| 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. |
|
||||
|
||||
CI builds this lane **in parallel** with the opkg feed (same manual triggers:
|
||||
`v*` tag push or `workflow_dispatch`): the `build-apk` jobs in
|
||||
`.gitea/workflows/release.yml` compile the same 4 packages through the official
|
||||
**ImmortalWrt 25.12 SDK** (tarballs from
|
||||
**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
|
||||
binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
|
||||
effectively mandatory signatures (unsigned needs `--allow-untrusted`). This is
|
||||
the only format shater publishes — the `.ipk`/opkg lane was removed in 2026-07
|
||||
(`DECISIONS.md` D22); every device we serve is on 25.12 with apk-tools 3.
|
||||
|
||||
CI (`v*` tag push or `workflow_dispatch`) compiles the 4 packages through the
|
||||
official **ImmortalWrt 25.12 SDK** (tarballs from
|
||||
`downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`)
|
||||
and publish **one release per arch** — rolling `apk-latest-x86_64` /
|
||||
`apk-latest-aarch64_cortex-a53`, or `apk-vX.Y.Z-<arch>` for a tagged version.
|
||||
Per-arch (unlike the combined opkg release) because apk filenames carry no
|
||||
architecture and packages are fetched relative to the `packages.adb` URL.
|
||||
and publishes **one release per arch**: the rolling `apk-latest-x86_64` /
|
||||
`apk-latest-aarch64_cortex-a53`, plus `apk-vX.Y.Z-<arch>` on a tag. Per-arch
|
||||
because apk filenames carry no architecture and packages are fetched *relative to
|
||||
the `packages.adb` URL*, so one flat multi-arch release would collide.
|
||||
|
||||
> **Key:** apk cannot use the usign key. The apk trust anchor is the separate EC
|
||||
> public key **`dist/shater-apk.pem`** (generated once by `ci/gen-apk-key.sh`;
|
||||
> private half lives ONLY in the Gitea secret **`KEY_APK`**, the apk analog of
|
||||
> `KEY_BUILD`). Never regenerate either key — that invalidates every deployed
|
||||
> router's trust. The usign identity `shater-feed.pub` keeps signing the
|
||||
> opkg/24.10 feed, untouched.
|
||||
> **Key:** the trust anchor is the EC public key **`dist/shater-apk.pem`**
|
||||
> (generated once by `ci/gen-apk-key.sh`; the private half lives ONLY in the
|
||||
> Gitea secret **`KEY_APK`**). Never regenerate it — that invalidates every
|
||||
> deployed router's trust.
|
||||
|
||||
One-time setup on a 25.12 router (BananaWRT `25.12-mtk-vendor` on the BPI-R3
|
||||
mini, BPI-R4 on 25.12, or the future 25.12 VM — `/etc/apk/arch` picks the right
|
||||
per-arch release automatically):
|
||||
### 5.1 Rolling or pinned — pick the repo URL deliberately
|
||||
|
||||
The repo line names an **index file**, and which one you name is the whole
|
||||
update policy:
|
||||
|
||||
| Repo line points at | Behaviour | Cost |
|
||||
|---|---|---|
|
||||
| `apk-latest-<arch>/packages.adb` (**rolling**) | Every release run REPLACES this release's assets, so `apk update && apk upgrade <our packages>` always sees the newest build. Install once, never touch the file again. | You get whatever CI published last; there is no per-router pin. |
|
||||
| `apk-vX.Y.Z-<arch>/packages.adb` (**pinned**) | The router stays on exactly that build. `apk update` will never offer a newer shater. | `/etc/apk/repositories.d/shater.list` must be edited **by hand on every upgrade**, on every router. |
|
||||
|
||||
`mini_router` is deliberately on a **pinned** URL — a considered choice, and the
|
||||
hand-edit per release is its price. Use rolling unless you specifically want to
|
||||
freeze a device.
|
||||
|
||||
> The rolling release used to go stale silently: publishing was an either/or, so
|
||||
> tag runs wrote only `apk-vX.Y.Z-<arch>` and `apk-latest-<arch>` was last
|
||||
> refreshed on 2026-07-24 at `0.2.0` while v0.2.9/v0.2.10 shipped. A router on
|
||||
> the rolling URL kept getting a successful `apk update` with nothing new. Fixed
|
||||
> 2026-07-25: `release-apk` writes the rolling pointer on **every** run and then
|
||||
> reads the release back over the Gitea API, asserting it holds our three
|
||||
> tag-versioned packages at exactly the version just built and **no** leftover
|
||||
> asset at another version (two versions of one package in one index would let
|
||||
> apk choose instead of us).
|
||||
|
||||
### 5.2 One-time setup on the router
|
||||
|
||||
BananaWRT `25.12-mtk-vendor` on the BPI-R3 mini, OpenWrt 25.12 on the BPI-R4, or
|
||||
the testbed VM — `/etc/apk/arch` picks the right per-arch release automatically:
|
||||
|
||||
```sh
|
||||
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
|
||||
@@ -203,26 +335,145 @@ wget -O /etc/apk/keys/shater-apk.pem \
|
||||
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
|
||||
|
||||
# 2) add the repo — the line points at the packages.adb INDEX FILE itself.
|
||||
# (rolling; for a pinned router put apk-vX.Y.Z-$(cat /etc/apk/arch) here — §5.1)
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
|
||||
> /etc/apk/repositories.d/shater.list
|
||||
|
||||
# 3) refresh + install (shaterd pulled in as a dependency).
|
||||
apk update
|
||||
apk add luci-app-shater # -> shater-core -> shaterd
|
||||
apk add byedpi # optional: ByeDPI desync egress
|
||||
```
|
||||
|
||||
### Updating
|
||||
### 5.3 Updating
|
||||
|
||||
**Never run a bare `apk upgrade`.** With no arguments apk reconciles *every*
|
||||
installed package against *every* configured repository at once; on a router
|
||||
whose distfeeds point at a moving snapshot that can pull in — or roll back —
|
||||
unrelated system packages. Always name ours:
|
||||
|
||||
```sh
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
```
|
||||
|
||||
Same rule as opkg: an upgrade is only offered when the feed version differs, so
|
||||
bump `PKG_RELEASE`/`PKG_VERSION` on every shipped change (apk shows it as
|
||||
`0.2.0-r1`). Pin a version instead of tracking rolling by pointing the repo line
|
||||
at `.../download/apk-vX.Y.Z-$(cat /etc/apk/arch)/packages.adb`.
|
||||
apk-tools 3 documents exactly this behaviour for `apk upgrade`: *"When no
|
||||
packages are specified, all packages are upgraded if possible. If list of
|
||||
packages is provided, only those packages are upgraded along with needed
|
||||
dependencies."* The equivalent form, which additionally re-pins the packages in
|
||||
`world`, is:
|
||||
|
||||
```sh
|
||||
apk add -u shaterd shater-core luci-app-shater # -u = --upgrade
|
||||
```
|
||||
|
||||
Check what you are on
|
||||
with `apk list -I shaterd shater-core luci-app-shater` — the version reads
|
||||
`0.2.7-r1` (§2.1: `PKG_VERSION-rPKG_RELEASE`, derived from the git tag by CI, so
|
||||
every build really is a new version; before that fix v0.2.2…v0.2.6 all published
|
||||
as `0.2.0-r3` and `apk update` offered nothing). Rolling vs pinned repo URL —
|
||||
§5.1.
|
||||
|
||||
#### If this router still has `byedpi` installed
|
||||
|
||||
Older releases shipped a fourth, optional package — `byedpi` (the `ciadpi` local
|
||||
desync proxy) — behind an egress of `type='byedpi'`. Both are **removed from the
|
||||
product** ([D29](DECISIONS.md#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken)):
|
||||
the desync it provided is done by the engine's own `dpi` presets, which were
|
||||
failing for a defect of ours that is fixed.
|
||||
|
||||
Dropping it from the feed does **not** take it off a router it is already on —
|
||||
nothing here uninstalls anything. Remove it by hand:
|
||||
|
||||
```sh
|
||||
apk del byedpi
|
||||
```
|
||||
|
||||
That is safe. No shater package depends on it, nothing in `shaterd` looks for the
|
||||
`ciadpi` binary any more, and it takes `/etc/init.d/byedpi`, `/usr/bin/ciadpi`
|
||||
and (unless you edited it) `/etc/config/byedpi` with it. Leaving it installed is
|
||||
also harmless — it is then simply a service nothing routes to.
|
||||
|
||||
If an egress in `/etc/config/shater` still says `option type 'byedpi'`, it is
|
||||
**blocked, not leaking**: the daemon builds no outbound for the kind, so every
|
||||
node, group and rule bound to that egress stops rather than going out over the
|
||||
plain WAN. The panel and the apply warnings name it and say what to change it to
|
||||
— `direct` (or `interface`) with `dpi 'record'`. Nothing is migrated for you on
|
||||
purpose: the only automatic rewrite that would keep the egress routing is the one
|
||||
that would silently put that traffic on the plain WAN.
|
||||
|
||||
### 5.4 Downgrading — going back to an older build
|
||||
|
||||
Per-version releases live forever (`apk-vX.Y.Z-<arch>`, §5.1), so the way back is
|
||||
always open. It is a different command from updating, because **`apk upgrade`
|
||||
never downgrades** — that is not a policy of ours, it is what the solver does.
|
||||
|
||||
**Read this first: an older build refuses to write a newer config.** The UCI
|
||||
schema version is stored in `globals.schema_version`, and a build that finds a
|
||||
schema NEWER than it understands refuses every config write — the panel, the
|
||||
6-hourly subscription refresh and the profile watcher all stop persisting, with a
|
||||
message naming both versions. That refusal is the recoverable outcome: your
|
||||
`/etc/config/shater` is untouched, and installing the newer package again brings
|
||||
everything back. The alternative would have been an older build quietly rewriting
|
||||
the file in its own, poorer form. The file as it stood before the first change of
|
||||
any build is also kept at `/etc/shater/config.pre-v<schema>.bak`.
|
||||
|
||||
So: downgrade across a schema bump only as a temporary measure, and expect the
|
||||
box to hold the config it has rather than accept edits.
|
||||
|
||||
```sh
|
||||
# 0) note where you are, and keep it.
|
||||
apk list -I shaterd shater-core luci-app-shater
|
||||
|
||||
# 1) repoint the repo file at the PINNED per-version release you want.
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-v0.2.9-$(cat /etc/apk/arch)/packages.adb" \
|
||||
> /etc/apk/repositories.d/shater.list
|
||||
|
||||
# 2) refresh, and read the exact version strings that feed offers.
|
||||
apk update
|
||||
apk list shaterd shater-core luci-app-shater
|
||||
|
||||
# 3) install them BY NAME with an explicit version. `=` is what downgrades.
|
||||
apk add shaterd=0.2.9-r1 shater-core=0.2.9-r1 luci-app-shater=0.2.9-r1
|
||||
```
|
||||
|
||||
Going back up afterwards is two commands, not one — the `=` form leaves a
|
||||
**pinned constraint** in `/etc/apk/world` (`shaterd=0.2.9-r1`), and a pin outranks
|
||||
an upgrade:
|
||||
|
||||
```sh
|
||||
# repoint /etc/apk/repositories.d/shater.list back (rolling, or the newer tag)
|
||||
apk update
|
||||
apk add shaterd shater-core luci-app-shater # drops the =version pin
|
||||
apk upgrade shaterd shater-core luci-app-shater # moves the packages
|
||||
```
|
||||
|
||||
**Measured, not inferred** — on the testbed VM (ImmortalWrt 25.12.1
|
||||
`r37978-cd0a06bfd3fd`, apk-tools 3.0.5, x86_64), against the real
|
||||
`apk-v0.2.9-x86_64` and `apk-v0.2.10-x86_64` feeds, in an isolated `--root`
|
||||
sandbox so nothing on the box moved:
|
||||
|
||||
| Command, with only v0.2.9 in the shater feed | What apk actually did |
|
||||
|---|---|
|
||||
| `apk upgrade shaterd` | nothing — stayed on `0.2.10-r1` |
|
||||
| `apk add shaterd=0.2.9-r1` | `Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1)`, and `world` became `shaterd=0.2.9-r1` |
|
||||
| `apk add shaterd` (after the pin) | pin cleared; the installed version did **not** move |
|
||||
| `apk upgrade shaterd` (pin cleared, feed back at v0.2.10) | `Upgrading shaterd (0.2.9-r1 -> 0.2.10-r1)` |
|
||||
| `apk add shaterd=0.2.9-r1` while the feed carries only 0.2.10 | `ERROR: unable to select packages: shaterd-0.2.10-r1: breaks: world[shaterd=0.2.9-r1]` — nothing installed. Repoint the repo FIRST. |
|
||||
|
||||
**`apk upgrade -a` is not the way to do this**, even though it does downgrade.
|
||||
`--available` reconciles against the repositories rather than against the
|
||||
installed versions, and naming our packages does **not** keep it to them: the same
|
||||
run on the testbed reported
|
||||
|
||||
```
|
||||
(22/27) Downgrading shaterd (0.2.10-r1 -> 0.2.9-r1)
|
||||
(23/27) Downgrading shater-core (0.2.10-r1 -> 0.2.9-r1)
|
||||
(24/27) Downgrading luci-app-shater (0.2.10-r1 -> 0.2.9-r1)
|
||||
```
|
||||
|
||||
together with `luci-app-attendedsysupgrade`, `luci-i18n-firewall-zh-cn` and two
|
||||
more unrelated packages rolled back to whatever the distfeed snapshot holds. Use
|
||||
the `=version` form, which touched exactly the three packages named.
|
||||
|
||||
### BananaWRT `25.12-mtk-vendor` compatibility
|
||||
|
||||
@@ -231,9 +482,15 @@ The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
|
||||
**`aarch64_cortex-a53`**, and its images even point their distfeeds at vanilla
|
||||
`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.
|
||||
is needed. We ship **no kmods** (shaterd is a static Go binary),
|
||||
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.
|
||||
|
||||
+127
-16
@@ -28,6 +28,14 @@ openwrt/
|
||||
luci-app-shater/ # thin LuCI launcher [Phase 3]
|
||||
```
|
||||
|
||||
> That block is the Phase-2 **plan**, kept because the wave assignment below reads
|
||||
> from it. The tree that shipped is flatter — `shater/` holds `alert apply
|
||||
> buildtags cmd devices engine generate logsink model netplane panel parse
|
||||
> registry stats subscribe` — with rulesets, schedules, profiles, backup and
|
||||
> migration living inside `model/` and `generate/` rather than as packages of
|
||||
> their own, and with **no `preset/`**: v0.2 has no preset subsystem at all (see
|
||||
> the `config preset` note in the schema section).
|
||||
|
||||
Build order / waves (parallel agents must own DISJOINT dirs, build only their own
|
||||
package, and never edit `go.mod`):
|
||||
- **Wave 1 (contract):** `model/` (+ uci reader + migrate). Everything imports it.
|
||||
@@ -76,8 +84,7 @@ flock. Commit-confirm/rollback and read verbs can follow, but Teardown must be h
|
||||
`procd_set_param file` watch); `reload_service`→start/stop; `service_triggers`
|
||||
reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-cron`
|
||||
(sub/ruleset/schedule due + reconcile). `uci-defaults/30_shater-core` (seed rt_tables
|
||||
8192, enable inits, seed disabled presets, `model.Migrate`, sysctl from
|
||||
`netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
|
||||
8192, enable inits, `model.Migrate`, sysctl from `netplane.SysctlConf`). `hotplug.d/iface/99-shater` (debounced `shaterd reconcile`).
|
||||
`sysctl.d/99-shater.conf` (from `netplane`). Default `/etc/config/shater` conffile.
|
||||
|
||||
---
|
||||
@@ -136,7 +143,7 @@ reload-trigger "shater"; `stop` sends SIGTERM (honest teardown). `init.d/shater-
|
||||
- **conns.go** — `ConnsJSON`, `parseConntrack` (live flows, proxied/direct).
|
||||
- **flock_unix.go** — real blocking cross-process flock; `lockPath=/var/lock/xrayctl.lock`.
|
||||
- **geodata.go** — `geoAssetPresent`, `geoStrip`, `GeodataStatus/Download/Remove` (strip geo matchers when dat absent).
|
||||
- **migrate.go** — `Migrate`, `CurrentSchemaVersion=1`, `uciRunner` (UCI schema migration; refuses newer).
|
||||
- **migrate.go** — `Migrate`, `uciRunner`, and on the v0.1 branch `CurrentSchemaVersion = 1` with a single step `{0 -> 1}`. **That 1 is v0.1's number and nothing else's.** v0.2's `shater/model/migrate.go` is at `CurrentSchemaVersion = 2` with `{0 -> 1, 1 -> 2}` — `migrate1to2` is the one that removed `dst_domain`/`dst_ip` from `config rule` (see the schema subsection below, which is the live document). Both versions refuse a config NEWER than the build; in v0.2 that refusal also covers every config WRITE (`model.ErrSchemaTooNew`), so a downgraded package cannot quietly rewrite a newer config into the older form.
|
||||
- **nodeops.go** — `NodeSetEnabled/NodeDelete/NodeAssignGroup/NodeQR`.
|
||||
- **observatory.go** — read xray live state via gRPC API inbound (127.0.0.1:10853). (rewrite → lx command server)
|
||||
- **preset.go** — `presetRules`, `presetDef`, `builtinPresets` (curated rule packs → synthetic Rules).
|
||||
@@ -195,7 +202,7 @@ type Chain struct { Name string; Hops []string } // "group:<n>" | "node:<n>", L1
|
||||
type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
|
||||
type Rule struct {
|
||||
Name string; Enabled bool; Order int
|
||||
Src []string; DstDomain,DstRuleset,DstIP []string; DstPort,Proto string
|
||||
Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
|
||||
Target string // chain:|group:|node:|direct|block
|
||||
Egress,Kill string
|
||||
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
|
||||
@@ -251,16 +258,116 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
|
||||
> v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
|
||||
|
||||
### uci.go — `/etc/config/shater` schema
|
||||
- `config globals`: enabled, loglevel, kill_switch, dns_mode, ipv6, fwmark_base, table_base, confirm_timeout, resolver_default, resolver_fallback, probe_url, probe_interval, schema_version, active_profile.
|
||||
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff.
|
||||
- `config subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.
|
||||
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
|
||||
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
|
||||
- `config chain`: name, list hop. `config egress`: name, type, interface, target.
|
||||
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url), url, path, format, update_interval, list entry.
|
||||
- `config rule`: name, enabled, order, list src/dst_domain/dst_ruleset/dst_ip, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end/tz.
|
||||
- `config preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
|
||||
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
|
||||
|
||||
> This subsection alone describes the **CURRENT v0.2 schema**, not v0.1 — the
|
||||
> shipped `/etc/config/shater` points its reader here by name, so it is kept in
|
||||
> step with `shater/model/uci.go` (read) and `render.go` (write), which are the
|
||||
> only two places a section type or option name exists. Everything else in PART A
|
||||
> is the v0.1 survey the port was planned from and is deliberately frozen.
|
||||
>
|
||||
> An option not listed below is not "undocumented" — it is IGNORED: the parser's
|
||||
> type switch drops an unknown section type whole, and an unknown option inside a
|
||||
> known section is never read. That is deliberate (`TestUnknownSectionAndOptionIgnored`
|
||||
> pins that such a config still parses — the daemon has to come up on whatever it
|
||||
> finds), and it is also why a dead knob here is SILENT: setting one changes the
|
||||
> file and nothing else, with no error anywhere to say so.
|
||||
|
||||
- `config globals` — the full option set, with the value used when the option is
|
||||
ABSENT (the `model.DefaultGlobals` seed). Booleans are always written back as
|
||||
`'1'`/`'0'` by `render.go`, so an explicit value never decays into the seed:
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `enabled` | `0` as shipped | master switch; `0` ⇒ `Reconcile` tears the stack down instead of applying |
|
||||
| `loglevel` (alias `log_level`) | `warning` | engine + daemon level; `none/off/silent/disabled` ⇒ log disabled, unknown ⇒ `warn` + a validation warning |
|
||||
| `log_syslog` / `log_file` / `log_persist` | `1` / `1` / `0` | operational log (`shater/logsink`): syslog, rotated file, and whether that file lives on flash instead of tmpfs |
|
||||
| `log_max_kb` | `2048` | size cap of the log file, clamped to 128…8192; `0` = "use the default", not "off" |
|
||||
| `kill_switch` | `closed` | `closed` = fail-closed (block on engine loss, incl. a holding plane when the engine never started); `open` = plain routing |
|
||||
| `ipv6` | `1` | `0` drops LAN IPv6 in the forward chain instead of leaving it unproxied |
|
||||
| `fwmark_base` / `table_base` | `0x2000` | reserved fwmark / routing-table bases (must not collide with fw4 or other apps) |
|
||||
| `confirm_timeout` | `0` | seconds before an unconfirmed apply auto-rolls back; `0` = commit-confirm off |
|
||||
| `resolver_default` / `resolver_fallback` / `endpoint_resolver` | unset | `config resolver` names: the DNS catch-all, its failover chain, and the bootstrap-direct server that resolves proxy endpoint DOMAINS |
|
||||
| `probe_url` / `probe_interval` | engine defaults | the ONE instrument all health probing uses (D20 — there are no per-group overrides) |
|
||||
| `panel_port` | `0` ⇒ `8088` | admin-panel HTTP port |
|
||||
| `dns_filter` | `0` | master enable of the blocklist/allowlist filter (D15); needs at least one `config resolver` |
|
||||
| `dns_intercept` | **`1`** | force ALL LAN plaintext `:53` into the engine, INCLUDING queries addressed to the router itself. See D24 for why this is the default, what preserves `.lan`, and what happens while the engine is down |
|
||||
| `block_doh` | `0` | NXDOMAIN the known public DoH hostnames + the Firefox canary and reject `:443` to their IPs, so clients fall back to `:53` (which the engine catches) |
|
||||
| `group_health` | `1` | OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group |
|
||||
| `untunnelable` | `block` | policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): `block` \| `icmp` (echo out, rest dropped) \| `direct` (all out, bypassing the tunnel) |
|
||||
| `l3_tunnel` | **`1`** | 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`. ON by default since the flip (`model/l3tunnel_default_test.go`): with it off a LAN ping is decided by `untunnelable` alone, whose every rung either drops the echo or lets it out of the WAN with the client's real address. An ABSENT option therefore comes back ON; only an explicit `0` closes it, and that opt-out survives the write→read round-trip. Set through UCI — no panel control writes it (the Networks page reads it to explain what `untunnelable` still decides). 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). 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 |
|
||||
| `schema_version` | `0` = pre-versioned | UCI schema revision; `shaterd migrate` writes `2` |
|
||||
| `active_profile` | unset | display bookkeeping: the last profile switched to |
|
||||
|
||||
Deleted options still parse (unknown keys are ignored) and drain out on the next
|
||||
render: `dns_mode` (D17 — fake-IP is a resolver TYPE), `sweep_interval` (D19).
|
||||
- `config inbound`: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp.
|
||||
**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`), fetch_detour, ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days — plus the persisted `Subscription-Userinfo` state the daemon writes back itself: user_upload, user_download, user_total, user_expire, userinfo_at (absent = `0` = "not reported", on both the read and the write side).
|
||||
`fetch_detour` is consulted only when `fetch_via=proxy`; it names the outbound the fetch dials through — `group:X` \| `node:X` \| `egress:X` \| `chain:X` \| `direct`, empty = direct (`apply.HTTPClient`/`resolveVia`).
|
||||
- `config node`: name, enabled, uri, mux, mux_concurrency, sockopt_mark, tcp_fast_open, tcp_keepalive_idle, egress — the last binds THIS node's own upstream to a `config egress` (multi-WAN), so its exit connection leaves over the chosen device.
|
||||
**No `xudp_concurrency`/`xudp_udp443`**: xudp was an xray packet-encoding knob with no sing-box counterpart, and the fields went with the generator rewrite.
|
||||
`from_sub` / `fingerprint` / `stale` are READ but never written. Subscription nodes live in per-subscription cache files (`model/subcache.go`); the options survive only so an old config's cached nodes are imported once on first read, after which they drain out of UCI.
|
||||
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, egress — the last binds EVERY member's dialer to that egress (a node's own `egress` is more specific and wins).
|
||||
**No `probe_url`/`probe_interval`**: the per-group overrides were deleted; probing is configured once, in `globals` (D20). Old configs carrying them still parse — the options are ignored and drain out on the next render.
|
||||
- `config chain`: name, list hop (`group:<n>` \| `node:<n>`, L1..Ln, Ln = exit).
|
||||
- `config egress`: name, type, interface, dpi.
|
||||
type is `interface` \| `direct`; `tunnel` is an accepted ALIAS of `interface` and an empty value means `direct` — both folded to the canonical spelling once, at the config boundary (`Model.NormalizeEgressTypes`, called by `ReadUCI`), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by `ValidateEgresses`, every binding to it fail-closed). A type this product REMOVED is a third case with the same fail-closed behaviour and a different sentence — `model.RetiredEgressTypes` is the closed table both `ValidateEgresses` and the generator read it from, so an operator whose config was correct for an older build is told what happened rather than that their value is a typo (D29).
|
||||
`interface` names the device and is meaningful for the `interface` type only; `dpi` is the native DPI-bypass preset — `off`|`fragment`|`record`|`spoof` (D13).
|
||||
**No `port`**: no surviving egress kind dials anything, so the option is not parsed and drains out on the next render. It belonged to the removed SOCKS-hop kind (D29).
|
||||
**No `target`**: v0.1's `proxy`/`block` egress kinds are gone — where traffic goes is a rule's `target`, what device it leaves by is an egress.
|
||||
- `config ruleset`: name, type(`domain`|`ipcidr`, default `domain`), source(`inline`|`file`|`url`|`geosite`|`geoip`, default `inline`), url, path, format, update_interval, list category, list entry.
|
||||
`format` names the ENGINE rule-set format and has exactly two real values, `binary` (a compiled `.srs`) and `source` (a sing-box rule-set `.json`); empty — and the v0.1 leftover `plain`, and `auto` — mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip.
|
||||
`list category` is the canonical spelling (one chip per geosite category or geoip country code, each materialised as its own remote `.srs`); a legacy single `option category` is still accepted on read and re-emitted as a list.
|
||||
- `config rule`: name, enabled, order, list src, list dst_ruleset, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end, sched_utc_offset.
|
||||
v0.1 carried `dst_domain`/`dst_ip` on the rule itself; **schema v2 removed both** — a
|
||||
destination is a `config ruleset` and nothing else. `shaterd migrate` folds each legacy
|
||||
list into a generated `rule-<name>` (and `rule-<name>-ip`) inline ruleset; see
|
||||
`DECISIONS.md` D21 for the entry-by-entry conversion table.
|
||||
- `config profile`: name, enabled, priority, list match_iface, list enable_rule, list disable_rule, endpoint_resolver.
|
||||
The condition is `match_iface` and nothing else: the active default-route device must be in that set (`cmd/shaterd/profilewatch.go`). The overrides are the two rule lists plus an optional per-profile `endpoint_resolver`, which overrides `globals.endpoint_resolver` while the profile is active (`generate/dns.go`; the point is keying the bootstrap resolver to the WAN in use).
|
||||
**No `probe_url`/`probe_mode`**, and they were deleted rather than documented: they promised "activate while a probe succeeds/fails", nothing ever probed, AND the selector treated a profile carrying a `probe_url` as having an unsatisfiable condition and skipped it — so adding a probe to a working profile silently switched that profile off.
|
||||
**No `sched_*`, no `default_target`/`default_egress`** either — a profile enables and disables named rules; it does not carry a routing default of its own.
|
||||
- `config resolver`: name, type, address, detour, pool.
|
||||
type is `doh` (synonym `https`) \| `dot` (synonym `tls`) \| `plain` (synonyms `udp` and the empty default) \| `tcp` \| `local` \| `fakeip`. Anything else is NOT built — the resolver simply does not exist, and `generate` says so by name.
|
||||
Each type consumes a different subset, and the rest is thrown away (`generate.warnIgnoredResolverFields` warns per field rather than dropping it silently): `doh`/`dot`/`plain`/`tcp` use address + detour, ignore pool; `local` uses detour only (it reads the router's `/etc/resolv.conf`); `fakeip` uses pool only (default `198.18.0.0/15`) — it mints answers locally, so it has nothing to dial and ignores both address and detour.
|
||||
`detour` is per-SERVER, not global: every sing-box DNS server carries its own.
|
||||
- `config dns_rule`: order, list match_domain, list match_src, resolver. It has no `name` — a DNS rule is identified by its order and selectors.
|
||||
- `config blocklist`: name, enabled, source(`inline`|`file`|`url`|`geosite`), url, path, list category, list entry, response, update_interval.
|
||||
`response` has exactly two values: `nxdomain` (default — Rcode 3, empty answer) and `zero` (NOERROR + `A 0.0.0.0`, and `AAAA ::` when `globals.ipv6`). There is no third; neither is sing-box's `action:reject`, which answers REFUSED.
|
||||
- `config allowlist`: the same minus `response` (an allowlist has no verdict to render); it overrides every blocklist, being emitted at higher priority.
|
||||
- `config device`: name, mac, ip, enabled, list block, list allow — where `block`/`allow` are DOMAINS, not targets: a device entry is per-device DNS filtering (block regardless of the global `dns_filter` switch, allow overriding every blocklist). Per-device ROUTING is not a device concern — it is an ordinary `config rule` whose `src` names the device.
|
||||
- `config alert`: name, enabled, type(`telegram`|`webhook`), token + chat_id (telegram), url (webhook), list event, via, fallback.
|
||||
- **`config preset` is NOT a section type, and there is no preset subsystem in
|
||||
v0.2.** `ParseUCIExport`'s type switch has no `preset` branch, so such a section
|
||||
is parsed by nothing, reaches no part of the model, and setting `enabled=1` on
|
||||
one changes the file and nothing about the router.
|
||||
It used to appear anyway: `30_shater-core` seeded three (`block_ads`,
|
||||
`ru_bypass`, `private`) "so the LuCI Rules page renders their toggles", and
|
||||
v0.2's LuCI app is a launcher with no Rules page. Worse than inert — the panel's
|
||||
first save wiped them, because `writeUCIWith` replaces the whole package
|
||||
(`uci delete shater` + `uci import`), so the placeholder deleted itself and read
|
||||
as a breakage. **The seeding is gone, and the script now DELETES any `preset`
|
||||
section an older release left behind** (safe by construction: the type is read
|
||||
by no consumer, so there is no setting to lose).
|
||||
This is not a feature waiting to be re-enabled. v0.1's packs were xray
|
||||
`geosite:`/`geoip:` matcher lists materialised into synthetic rules
|
||||
(`xrayctl/preset.go` on the v0.1 branch); under schema v2 a rule's destination
|
||||
IS a `config ruleset`, so the same pack is an ordinary ruleset + rule — which
|
||||
the panel's Routing page builds today, geosite/geoip sources included. Anything
|
||||
richer needs a section type the parser knows, and that has to land in
|
||||
`shater/model` first.
|
||||
|
||||
### 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).
|
||||
@@ -268,10 +375,14 @@ 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`, DELETE any leftover `config preset` section (a type nothing parses — see the schema note above; the seeding of three of them is gone), seed the `shater_l3` fw4 zone + a `<zone>→shater_l3` forwarding for every zone (named sections, `list device 'shater-l3*'`) and migrate a legacy exact-name entry to the wildcard, run `shaterd migrate` (whose result is **classified and reported**, not discarded — see below), 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.
|
||||
- **`shaterd migrate` reporting** (both call sites: `uci-defaults/30_shater-core` and `init.d/shater`'s `start_service`). The verb exits 1 for every failure, so the shell classifies the outcome itself, with a CLOSED positive list — `ok` / `downgrade` / `unreadable` / `failed` (`shater_migrate_class`, duplicated in the two scripts because the package installs no shell library they could share; `TestMigrateClassifiersAgree` fails if they ever diverge). `downgrade` is recognised by the substring `newer than this build`, which both `model.migrateWith`'s refusal and `model.ErrSchemaTooNew` contain — a contract pinned by `TestMigrateDowngradeSignatureIsAContract`. An unrecognised failure lands on `failed`, which says so and quotes the binary verbatim, rather than being reported as one of the causes we can name.
|
||||
Failures reach the operator on **two channels that are not syslog**, because `globals.log_syslog=0` is a deliberate setting about the syslog stream and not a request to be left uninformed: the script's own **stderr** (the operator's terminal on a hand-typed `restart`; the package manager's output inside `apk add`/`opkg install`), and **`/etc/shater/migrate-failed`** on flash, written on failure and REMOVED on the first success — its absence is the all-clear. syslog gets the same line too when `log_syslog` allows it. A migration that SUCCEEDED is routine and stays on the syslog channel only.
|
||||
`30_shater-core` still **exits 0** after a failed migration, deliberately: a uci-defaults script that exits non-zero is kept and re-run at every boot, and this one re-runs a detached enable+restart of `shater`/`shater-cron` plus a firewall reload — so one recoverable failure would become permanent boot-time churn, to carry a status nothing reads. The retry that matters already exists: `init.d/shater` runs the migration on every start.
|
||||
- **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`.
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Документация shater
|
||||
|
||||
Документация продукта **shater** (управляемый интернет-шлюз для роутеров на
|
||||
OpenWrt). Лицо репозитория и быстрый старт — в корневом [`../README.md`](../README.md).
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [CONTEXT.md](CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, решения в кратце, testbed/инфра |
|
||||
| [INSTALL.md](INSTALL.md) | Сборка ship-артефакта (`shaterd`) и установка apk-фида (25.12+): роллинг или фиксация версии |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | One-binary дизайн, auth-handoff LuCI→панель, data/DNS/apply-потоки (диаграммы) |
|
||||
| [FEATURES.md](FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
|
||||
| [ROADMAP.md](ROADMAP.md) | Фазовый план |
|
||||
| [DECISIONS.md](DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия и т.д. |
|
||||
| [DESIGN.md](DESIGN.md) | Визуальная система панели — направление «Faceplate», токены, компоненты |
|
||||
| [PORTING.md](PORTING.md) | Порт проверенных кусков из v0.1 |
|
||||
|
||||
Документация движка-форка (sing-box-lx) — в его слое: [`../docs-lx/`](../docs-lx/)
|
||||
и [`../SPECS/`](../SPECS/).
|
||||
+17
-21
@@ -49,25 +49,21 @@ 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)
|
||||
- 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).
|
||||
- Add egress `dpi` value `byedpi`: a supervised local `ciadpi` SOCKS5 instance +
|
||||
a `socks` outbound pointed at it. New `openwrt/` procd package + musl-static
|
||||
cross-build of ciadpi (~100 KB); `model` reserves the egress kind, `generate`
|
||||
wires the `socks` outbound.
|
||||
- QUIC gap closed by routing (drop `udp/443` for desync-domains → TCP+TLS
|
||||
fallback), not by adopting a packet plane.
|
||||
- The **`byedpi` package** (`openwrt/byedpi/`, SEPARATE & optional) provides the
|
||||
`ciadpi` process behind a `type='byedpi'` egress: it cross-compiles ciadpi via
|
||||
the SDK toolchain and ships a procd init that supervises one `ciadpi` SOCKS5
|
||||
desync instance per enabled `config instance` in `/etc/config/byedpi`
|
||||
(127.0.0.1:`<port>`). Install it only when you want a byedpi egress; a
|
||||
`type='byedpi'` egress with no matching `ciadpi` listener simply has nothing to
|
||||
dial. `shater-core` does NOT depend on it (opt-in).
|
||||
- **Gate:** a DPI-blocked domain (that plain `fragment` can't crack) loads via the
|
||||
`byedpi` egress on the VM, direct (no tunnel), kill-switch still honest.
|
||||
## Phase 2b — DPI-bypass egress ✅ DONE, then REVERSED (D29, 2026-07-27)
|
||||
- Shipped as **ByeDPI (ciadpi)**: a supervised local SOCKS5 desync process in its
|
||||
own optional `openwrt/byedpi/` package, reached through a `type='byedpi'`
|
||||
egress. Chosen over zapret because it *is* an egress and needed no second
|
||||
packet plane (D13); zapret stays rejected.
|
||||
- **Removed in full on 2026-07-27** — package, egress kind, readiness endpoint and
|
||||
panel plate. It was adopted because the engine's own `tls_fragment` /
|
||||
`tls_record_fragment` presets did not get through; the cause was a defect in
|
||||
our fragmentation (the split always landed inside the first label of the name),
|
||||
not a limit of the method. With that fixed the built-in presets carry this, and
|
||||
the external process is weight without a job. Full argument: **D29**.
|
||||
- What survives from this phase: the native `dpi` presets `fragment` / `record` /
|
||||
`spoof` on a `direct` or `interface` egress, which is what the feature is now.
|
||||
- A config still naming the removed kind is fail-closed and told so by name — see
|
||||
`model.RetiredEgressTypes`.
|
||||
|
||||
## Phase 3 — Admin panel MVP + thin LuCI launcher ✅ DONE (2026-07-15)
|
||||
- `panel/`: embedded web server on its own port + session store; token-mint ubus
|
||||
@@ -86,7 +82,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.
|
||||
@@ -104,7 +100,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
|
||||
|
||||
## Phase 8 — Ship it ✅ DONE
|
||||
- Adapt CI to build/sign the single forked binary for both arches; publish the
|
||||
signed opkg feed (reuse key `5ac4b177689cb8e0`); install/upgrade docs.
|
||||
signed feed (apk since D22, EC key `dist/shater-apk.pem`); install/upgrade docs.
|
||||
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
|
||||
|
||||
## Cross-cutting (every phase)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Живое тестирование shater v0.2.6 на mini_router
|
||||
|
||||
**Дата:** 2026-07-25
|
||||
**Устройство:** Bananapi BPi-R3 Mini · ImmortalWrt **25.12-linkup** · `aarch64_cortex-a53`
|
||||
**Установка:** из подписанного apk-фида `apk-v0.2.6-aarch64_cortex-a53`
|
||||
**Пакеты:** `shaterd 0.2.0-r3`, `shater-core 0.2.0-r3`, `luci-app-shater 0.2.0-r2`, `byedpi 0.17.3-r1`
|
||||
**Сборка:** CI run 61, коммит `024e9308c` (вершина `main`)
|
||||
|
||||
Сценарий: полное удаление предыдущей установки → чистая установка из фида →
|
||||
проверка дефолтного состояния → восстановление рабочего конфига с подписками
|
||||
(315 узлов) → функциональная проверка.
|
||||
|
||||
**Итог: 79 проверок, 74 PASS, 5 находок** (детали и разбор — в
|
||||
`shater-bugs-2026-07-25.md` на рабочем столе).
|
||||
|
||||
---
|
||||
|
||||
## 1. Релиз и фид
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T1 | Публикация `apk-v0.2.6-<arch>` для обеих архитектур | PASS |
|
||||
| T2 | Ассеты: 4 `.apk` + `packages.adb` + `shater-apk.pem` | PASS |
|
||||
| T3 | `apk update` принимает индекс (проверка EC-подписи) | PASS |
|
||||
| T4 | Пакеты видны в нужных версиях (r3/r3/r2) | PASS |
|
||||
| T5 | Диагностика сборки: `kmod packages selected (=m): 0` (было 1078) | PASS |
|
||||
| T6 | Собраны ровно наши 4 пакета | PASS |
|
||||
| T7 | opkg-лейн v0.2.6 (24.10) тоже зелёный | PASS |
|
||||
|
||||
## 2. Установка
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T8 | `apk add luci-app-shater byedpi` — 4 пакета | PASS |
|
||||
| T9 | Зависимости `kmod-nft-tproxy`/`kmod-nft-socket` из базового фида | PASS |
|
||||
| T10 | Целостность: `apk manifest` = sha256 файла на диске | PASS |
|
||||
| T11 | Установлен именно бинарь v0.2.6 (5 491 616 Б vs 5 488 336 Б в r2) | PASS |
|
||||
| T12 | init-скрипты `shater`, `shater-cron` | PASS |
|
||||
| T13 | `sysctl.d/99-shater.conf`, `hotplug.d/iface/99-shater` | PASS |
|
||||
| T14 | boot-линки `S99shater`, `K10shater`, `S96shater-cron` | PASS |
|
||||
|
||||
## 3. Дефолтное состояние (чистая установка)
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T15 | Дефолтный конфиг создан uci-defaults (27 строк) | PASS |
|
||||
| T16 | `enabled='0'` — плоскость не ставится без согласия | PASS |
|
||||
| T17 | Заготовлен tproxy-inbound на LAN, пресеты выключены | PASS |
|
||||
| T18 | Демон стартует, `plane=none`, `table=false` | PASS |
|
||||
| T19 | Права конфига `-rw-------` (0600) | PASS |
|
||||
|
||||
## 4. Восстановление рабочего конфига
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T20 | Восстановление из бэкапа (3213 UCI-строк) | PASS |
|
||||
| T21 | Кэш подписок цел: 315 узлов в 4 файлах | PASS |
|
||||
| T22 | `shaterd migrate` → `ok`, схема v1 | PASS |
|
||||
| T23 | Старт с реальным конфигом: `active`, `engine_running`, `plane=full` | PASS |
|
||||
|
||||
## 5. Data plane
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T24 | Таблица `inet shater` создана (9 цепочек/сетов) | PASS |
|
||||
| T25 | 16 tproxy-правил | PASS |
|
||||
| T26 | `ip rule from all fwmark 0x2000 lookup shater` | PASS |
|
||||
| T27 | `accept_local=1` на `br-lan` | PASS |
|
||||
| T28 | DNS-divert: `dport 53 → tproxy :12345` для LAN-интерфейсов | PASS |
|
||||
| T29 | DoT заблокирован: `dport 853 reject` | PASS |
|
||||
| T30 | `block_doh=1`, правила присутствуют | PASS |
|
||||
| T31 | **Kill-switch fail-closed**: цепочка `forward` завершается `drop` для LAN (v4+v6) | PASS |
|
||||
| T32 | fw4 и dnsmasq не тронуты (свои таблицы целы) | PASS |
|
||||
|
||||
## 6. Панель и API
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T33 | SPA отдаётся на `:8088` | PASS |
|
||||
| T34 | `shaterd mint-token` выдаёт одноразовый токен | PASS |
|
||||
| T35 | `/api/status` без сессии → **401** | PASS |
|
||||
| T36 | `/api/session` (POST, JSON) → 200 + cookie `HttpOnly; SameSite=Strict; Max-Age=28800` | PASS |
|
||||
| T37 | `/api/status` по cookie отдаёт данные, совпадающие с CLI | PASS |
|
||||
| T38 | `/api/config` — 340 записей узлов | PASS |
|
||||
| T39 | `/api/groups/health` — 103 протестировано, 13 живых, выбран `FR-vless-8` | PASS |
|
||||
| T40 | `/api/devices` — устройства с IPv4/IPv6/MAC | PASS |
|
||||
| T41 | `/api/interfaces` — `ewan/eth1 10.0.0.125/24 zone=wan` | PASS |
|
||||
| T42 | `/api/ruleset/status` — remote-ruleset обновлён сегодня | PASS |
|
||||
| T43 | `/api/stats` — memory backend, счётчики и top-domains | PASS |
|
||||
| T44 | `/api/stats/log` — query-log с доменом, qtype, rcode, сервером | PASS |
|
||||
| T45 | `/api/log?range=100` — пусто (следствие `log_file='0'`, не дефект) | OK |
|
||||
|
||||
## 7. Жизненный цикл конфигурации
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T46 | `shaterd apply` → `{"changed":false}`, `can_rollback=true` | PASS |
|
||||
| T47 | `shaterd confirm` снимает авто-откат (`can_rollback=false`) | PASS |
|
||||
| T48 | `shaterd rollback` после confirm корректно сообщает об отсутствии last-good | PASS |
|
||||
| T49 | `shaterd reconcile` (SIGHUP) не роняет движок | PASS |
|
||||
| T50 | `shaterd sub update all-qomar` — реально обновил 143 узла | PASS |
|
||||
| T51 | `shaterd blocklist update` → reconcile signalled | PASS |
|
||||
| T52 | `shaterd schedule due` → reconcile signalled | PASS |
|
||||
|
||||
## 8. Устойчивость
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T53 | `kill -9` демона → procd поднимает новый PID | PASS |
|
||||
| T54 | После respawn: `engine_running=true`, `plane=full` | PASS |
|
||||
| T55 | `stop` снимает таблицу `inet shater` полностью | PASS |
|
||||
| T56 | `stop` → пауза → `start`: плоскость восстанавливается | PASS |
|
||||
| T57 | Сеть при остановленном shater не деградирует | PASS |
|
||||
| T58 | Память: 253 МБ занято из 2 ГБ при работающем движке | PASS |
|
||||
|
||||
## 9. DNS
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T59 | Резолв через `127.0.0.1` | PASS |
|
||||
| T60 | LAN-клиенты резолвят через движок (query-log растёт) | PASS |
|
||||
| T61 | `.lan`-домены остаются за dnsmasq | PASS |
|
||||
| T62 | dnsmasq жив и слушает на всех адресах | PASS |
|
||||
| T63 | **Резолв через LAN-адрес `10.67.0.1` после `restart`** | **FAIL — B3** |
|
||||
| T64 | Тот же резолв после `stop` → пауза → `start` | PASS |
|
||||
|
||||
## 10. Конфигурация и логи
|
||||
|
||||
| # | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| T65 | 5 правил маршрутизации, 2 профиля, активен `ethernet-uplink` | PASS |
|
||||
| T66 | **Два правила `default`, оба catch-all — нижнее живое, верхнее мертво** | **FAIL — B1** |
|
||||
| T67 | **`shaterd nodes` всегда возвращает `[]`** | **FAIL — B2** |
|
||||
| T68 | Логи уходят в syslog (`log_syslog=1`, 22 записи) | PASS |
|
||||
| T69 | **ANSI-escape коды в syslog** | **FAIL — B5** |
|
||||
| T70 | `loglevel=warning` соблюдается | PASS |
|
||||
| T71–T79 | Прочие проверки состояния (статус-поля, права, uptime, счётчики, целостность таблиц) | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Находки
|
||||
|
||||
| ID | Суть | Важность |
|
||||
|---|---|---|
|
||||
| **B1** | Два catch-all правила `default`; одно из них не работает никогда. **Поправка к первоначальному диагнозу:** правило без условий задаёт `route.Final`, а не выпускается как match-all, поэтому выигрывает ПОСЛЕДНЕЕ (`order=100 → group:auto`) — трафик идёт через прокси, а мёртвая настройка это `order=20 → direct` | средняя |
|
||||
| **B2** | `shaterd nodes` — заглушка, всегда `[]`, хотя usage обещает список узлов (в кэше 315, в `/api/config` 340) | средняя |
|
||||
| **B3** | После `service shater restart` резолв к LAN-адресу роутера не работает и не восстанавливается; `stop`+пауза+`start` — работает (гонка) | средняя |
|
||||
| **B4** | `PKG_RELEASE` не менялся с v0.2.1 → v0.2.2…v0.2.6 выходят как `r3` при разном содержимом; `apk upgrade` не увидит обновления | средняя |
|
||||
| **B5** | ANSI-раскраска попадает в syslog | низкая |
|
||||
|
||||
Разбор с воспроизведением — в `shater-bugs-2026-07-25.md`.
|
||||
|
||||
## История CI по этому релизу
|
||||
|
||||
Путь до зелёной сборки apk-лейна занял четыре итерации, каждая вскрывала
|
||||
следующий слой одной причины:
|
||||
|
||||
| Тег | Что чинили | Итог |
|
||||
|---|---|---|
|
||||
| v0.2.2 | — (первый прогон с фиксами аудита) | `Disk quota exceeded`, 3593 `apk mkpkg kmod-*` |
|
||||
| v0.2.3 | `.config` строится с нуля, а не дописывается | 1078 kmod — SDK вообще не везёт `.config` |
|
||||
| v0.2.4 | Выключены `ALL`/`ALL_KMODS`/`ALL_NONSHARED` | 1078 kmod — они выбираются не через `ALL_KMODS` |
|
||||
| v0.2.5 | Второй проход: явное `is not set` для каждого kmod | 1078 kmod — kconfig игнорирует user-значение у беспромптовых символов |
|
||||
| **v0.2.6** | Удаление сгенерированных блоков `config PACKAGE_*` (`default m`) из `Config-build.in` | **0 kmod, сборка зелёная** |
|
||||
|
||||
Корень: `target/sdk/Makefile` генерирует `Config-build.in` прогоном
|
||||
`convert-config.pl` по конфигу бильдбота, где `ALL_KMODS=y` уже развернулся в
|
||||
`CONFIG_PACKAGE_kmod-*=m` на каждый модуль. Фильтр `next if /^(# )?CONFIG_PACKAGE/`
|
||||
в скрипте стоит в ветке `else`, куда строка со знаком `=` не попадает, поэтому
|
||||
каждый kmod приезжает в SDK как безусловный `default m`.
|
||||
@@ -2,6 +2,9 @@ package libbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
// lx:begin sec-consttime
|
||||
"crypto/subtle"
|
||||
// lx:end sec-consttime
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
@@ -97,9 +100,11 @@ func unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerIn
|
||||
if len(values) == 0 {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing authentication secret")
|
||||
}
|
||||
if values[0] != sCommandServerSecret {
|
||||
// lx:begin sec-consttime
|
||||
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid authentication secret")
|
||||
}
|
||||
// lx:end sec-consttime
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
@@ -115,9 +120,11 @@ func streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServe
|
||||
if len(values) == 0 {
|
||||
return status.Error(codes.Unauthenticated, "missing authentication secret")
|
||||
}
|
||||
if values[0] != sCommandServerSecret {
|
||||
// lx:begin sec-consttime
|
||||
if subtle.ConstantTimeCompare([]byte(values[0]), []byte(sCommandServerSecret)) != 1 {
|
||||
return status.Error(codes.Unauthenticated, "invalid authentication secret")
|
||||
}
|
||||
// lx:end sec-consttime
|
||||
return handler(srv, ss)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,11 @@ func (r *oomReporter) WriteReport(memoryUsage uint64) error {
|
||||
draftInfo = nil
|
||||
}
|
||||
reportsDir := filepath.Join(sWorkingPath, "oom_reports")
|
||||
err = os.MkdirAll(reportsDir, 0o777)
|
||||
// lx:begin sec-perms
|
||||
// OOM reports embed the config snapshot (server secrets, keys) and logs;
|
||||
// keep the tree owner-only (0700 dirs / 0600 files) instead of 0777/0666.
|
||||
err = os.MkdirAll(reportsDir, 0o700)
|
||||
// lx:end sec-perms
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +125,9 @@ func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error {
|
||||
|
||||
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
|
||||
now := time.Now().UTC()
|
||||
err := os.MkdirAll(destPath, 0o777)
|
||||
// lx:begin sec-perms
|
||||
err := os.MkdirAll(destPath, 0o700)
|
||||
// lx:end sec-perms
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ func baseReportMetadata() reportMetadata {
|
||||
|
||||
func writeReportFile(destPath string, name string, content []byte) {
|
||||
filePath := filepath.Join(destPath, name)
|
||||
os.WriteFile(filePath, content, 0o666)
|
||||
// lx:begin sec-perms
|
||||
// Report files may carry the config snapshot (secrets) — owner-only.
|
||||
os.WriteFile(filePath, content, 0o600)
|
||||
// lx:end sec-perms
|
||||
chownReport(filePath)
|
||||
}
|
||||
|
||||
@@ -69,7 +72,9 @@ func copyConfigSnapshot(destPath string) {
|
||||
}
|
||||
|
||||
func initReportDir(path string) {
|
||||
os.MkdirAll(path, 0o777)
|
||||
// lx:begin sec-perms
|
||||
os.MkdirAll(path, 0o700)
|
||||
// lx:end sec-perms
|
||||
chownReport(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d
|
||||
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3
|
||||
github.com/sagernet/sing-mux v0.3.5
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8
|
||||
github.com/sagernet/sing-shadowsocks2 v0.2.1
|
||||
github.com/sagernet/sing-shadowtls v0.2.1
|
||||
@@ -104,14 +104,20 @@ require (
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/huin/goupnp v1.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.0.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
|
||||
github.com/libp2p/go-netroute v0.2.1 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
|
||||
@@ -101,6 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
|
||||
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -109,10 +111,14 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY=
|
||||
github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||
@@ -122,6 +128,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
||||
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -138,6 +146,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v
|
||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8=
|
||||
github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8=
|
||||
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
|
||||
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
|
||||
@@ -264,8 +276,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6
|
||||
github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg=
|
||||
github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI=
|
||||
github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk=
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7 h1:hFLPJ21uNZSbRnzhOKz4Zv0b4F93mpDorWyN93BeRcM=
|
||||
github.com/sagernet/sing-quic v0.6.2-0.20260525051024-9467ede27fb7/go.mod h1:+oqD54aHel4ALKkp1hVXWCgLU/EjLojvm6AUzDfvj0I=
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw=
|
||||
github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI=
|
||||
github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo=
|
||||
@@ -360,6 +372,8 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
@@ -367,17 +381,24 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -389,6 +410,7 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
@@ -396,8 +418,10 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
@@ -1,88 +0,0 @@
|
||||
#
|
||||
# byedpi — ByeDPI (ciadpi), a tiny portable-C SOCKS5/HTTP desync proxy.
|
||||
#
|
||||
# This is the process behind a Shater egress of `type='byedpi'`: shaterd's
|
||||
# `generate` emits a SOCKS5 outbound `egress-<name>` -> 127.0.0.1:<port>, and a
|
||||
# `ciadpi` instance supervised by this package listens on that port, applies
|
||||
# TCP/TLS desync to the connections passing through it, and goes DIRECT to the
|
||||
# target (no tunnel). Kept as a SEPARATE, optional package: a byedpi egress is
|
||||
# opt-in — install this only when you want the external desync engine.
|
||||
#
|
||||
# Compiled C (musl, per target) => NOT PKGARCH:=all. The OpenWrt SDK toolchain
|
||||
# cross-compiles ciadpi via its own plain Makefile.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=byedpi
|
||||
PKG_VERSION:=0.17.3
|
||||
PKG_RELEASE:=1
|
||||
|
||||
# Pinned upstream release tag v0.17.3 (commit
|
||||
# 7efde1b1296eaaa187b70e951894dde17527489c). codeload emits a stable tarball
|
||||
# per tag; PKG_HASH is the sha256 of that tarball (build fails on mismatch).
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://codeload.github.com/hufrea/byedpi/tar.gz/refs/tags/v$(PKG_VERSION)?
|
||||
PKG_HASH:=0a9cb8585554c68c3e2be88c33c9bf6f99f8e8c7f54b362285adab99e262566c
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_LICENSE_FILES:=LICENSE
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/byedpi
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=ByeDPI (ciadpi) local SOCKS5/HTTP desync proxy
|
||||
URL:=https://github.com/hufrea/byedpi
|
||||
# Pure C against musl; every target has a C toolchain, so no arch-depends.
|
||||
# No runtime library deps beyond libc (static-ish tiny binary).
|
||||
DEPENDS:=
|
||||
endef
|
||||
|
||||
define Package/byedpi/description
|
||||
ByeDPI is a small local SOCKS5/HTTP proxy that applies TCP/TLS desynchronization
|
||||
(split, disorder, fake packets, TLS-record splitting) to the connections passing
|
||||
through it and then connects DIRECTLY to the destination — no upstream tunnel.
|
||||
Its binary is `ciadpi`. In the Shater stack it is the process behind an egress of
|
||||
`type='byedpi'`: shaterd routes selected traffic to a local SOCKS5 outbound
|
||||
pointed at ciadpi's 127.0.0.1:<port>. Multi-instance, driven by /etc/config/byedpi.
|
||||
endef
|
||||
|
||||
# ciadpi's upstream Makefile appends its own required flags with `CFLAGS +=`.
|
||||
# A CFLAGS set on the make command line CLOBBERS that `+=` (GNU make: a
|
||||
# command-line assignment overrides the makefile's append), so we must re-supply
|
||||
# ciadpi's own needed flags (-I. -std=c99 and its warning set) alongside
|
||||
# $(TARGET_CFLAGS). CPPFLAGS (-D_DEFAULT_SOURCE) is left untouched by not
|
||||
# overriding it. The default target `all` builds the `ciadpi` binary; its link
|
||||
# rule is `$(CC) -o ciadpi $(OBJ) $(LDFLAGS)`, so $(TARGET_LDFLAGS) reaches the
|
||||
# link. Kernel headers (linux/netfilter_ipv4.h) come from the SDK sysroot.
|
||||
define Build/Compile
|
||||
+$(MAKE) -C $(PKG_BUILD_DIR) \
|
||||
CC="$(TARGET_CC)" \
|
||||
CFLAGS="$(TARGET_CFLAGS) -I. -std=c99 -Wall -Wno-unused -Wextra -Wno-unused-parameter" \
|
||||
LDFLAGS="$(TARGET_LDFLAGS)" \
|
||||
all
|
||||
endef
|
||||
|
||||
define Package/byedpi/install
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
$(INSTALL_BIN) $(PKG_BUILD_DIR)/ciadpi $(1)/usr/bin/ciadpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/etc/init.d/byedpi $(1)/etc/init.d/byedpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/etc/config/byedpi $(1)/etc/config/byedpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_BIN) ./files/etc/uci-defaults/40_byedpi $(1)/etc/uci-defaults/40_byedpi
|
||||
endef
|
||||
|
||||
# /etc/config/byedpi is user-editable desired state -> preserve on upgrade.
|
||||
define Package/byedpi/conffiles
|
||||
/etc/config/byedpi
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,byedpi))
|
||||
@@ -1,33 +0,0 @@
|
||||
#
|
||||
# ByeDPI (ciadpi) desync SOCKS proxies (/etc/config/byedpi).
|
||||
#
|
||||
# Each `config instance` is one supervised `ciadpi` process bound to
|
||||
# 127.0.0.1:<port>. Point a Shater egress at it:
|
||||
#
|
||||
# config egress 'bd'
|
||||
# option name 'bd'
|
||||
# option type 'byedpi'
|
||||
# option port '1080' # must match an enabled instance's `port`
|
||||
#
|
||||
# then a rule with `option target 'egress:bd'` routes selected traffic through
|
||||
# ciadpi, which desyncs it and connects DIRECTLY to the target (no tunnel).
|
||||
#
|
||||
# This shipped default is INERT (enabled='0'): installing the package opens no
|
||||
# listener. Set enabled='1' and apply to bring the proxy up.
|
||||
#
|
||||
# This file is a conffile — your edits survive package upgrades.
|
||||
#
|
||||
|
||||
config instance 'default'
|
||||
option enabled '0'
|
||||
option port '1080'
|
||||
# Desync preset (documented ByeDPI example — general RKN/YouTube-busting):
|
||||
# --disorder 1 : split the first segment at offset 1 and send the two
|
||||
# parts in REVERSE order (TCP desync; defeats naive
|
||||
# stream reassembly in the DPI).
|
||||
# --auto=torst : auto mode — if the connection is reset (TCP RST), retry
|
||||
# with the desync params instead of failing.
|
||||
# --tlsrec 1+s : re-frame the TLS record boundary at the SNI offset +1,
|
||||
# so the ClientHello SNI is split across TLS records and
|
||||
# SNI-based DPI can't match the hostname.
|
||||
option args '--disorder 1 --auto=torst --tlsrec 1+s'
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# /etc/init.d/byedpi — procd supervisor for ByeDPI (ciadpi) desync SOCKS proxies.
|
||||
#
|
||||
# One supervised `ciadpi` process per ENABLED `config instance` in
|
||||
# /etc/config/byedpi. Each instance is a local SOCKS5 desync proxy bound to
|
||||
# 127.0.0.1:<port>; a Shater egress of type='byedpi' with the matching `port`
|
||||
# routes traffic to it (shaterd emits a SOCKS5 outbound to that port). ciadpi
|
||||
# desyncs the connection and goes DIRECT to the target — no tunnel.
|
||||
#
|
||||
# Design:
|
||||
# * INERT by default: the shipped instance has enabled='0', and config_foreach
|
||||
# starts nothing unless an instance is explicitly enabled. Installing this
|
||||
# package can never, by itself, open a listener or affect connectivity.
|
||||
# * FOREGROUND: ciadpi stays in the foreground unless -D/--daemon is given (we
|
||||
# never pass it), so procd supervises the real process. respawn on crash.
|
||||
# * Instances are named after their UCI section, so `reload` diffs per-section
|
||||
# and restarts only the instances whose config actually changed.
|
||||
# * busybox ash only — no bashisms.
|
||||
|
||||
USE_PROCD=1
|
||||
START=90 # before shater (START=99): the SOCKS egress should be up
|
||||
STOP=11 # after shater (STOP=10) — higher STOP runs LATER on shutdown,
|
||||
# so the desync proxy outlives the data plane it serves.
|
||||
|
||||
PROG=/usr/bin/ciadpi
|
||||
CONF=byedpi
|
||||
|
||||
# Validate one `instance` section. Datatypes per openwrt-uci:
|
||||
# enabled : bool (default 0 — inert)
|
||||
# port : port (default 1080 — matches shater's byedpi egress default)
|
||||
# args : free-form desync flag string (passed verbatim to ciadpi)
|
||||
validate_instance_section() {
|
||||
uci_load_validate "$CONF" instance "$1" "$2" \
|
||||
'enabled:bool:0' \
|
||||
'port:port:1080' \
|
||||
'args:string:'
|
||||
}
|
||||
|
||||
start_instance() {
|
||||
# $1 = section name, $2 = validation return code
|
||||
local cfg="$1"
|
||||
[ "$2" = 0 ] || { echo "byedpi: validation failed for '$cfg'"; return 1; }
|
||||
[ "$enabled" -eq 1 ] || return 0
|
||||
|
||||
# Never claim to run without the binary (half-removed/failed upgrade must
|
||||
# degrade to "off", not to a phantom respawn loop).
|
||||
[ -x "$PROG" ] || { echo "byedpi: $PROG missing, skipping '$cfg'"; return 1; }
|
||||
|
||||
procd_open_instance "$cfg"
|
||||
# Bind loopback only: this proxy is reachable solely by the local engine.
|
||||
procd_set_param command "$PROG" -i 127.0.0.1 -p "$port"
|
||||
# Desync flag list. Unquoted on purpose: word-split $args into separate argv
|
||||
# tokens (e.g. "--disorder 1 --auto=torst --tlsrec 1+s" -> 5 arguments).
|
||||
# shellcheck disable=SC2086
|
||||
[ -n "$args" ] && procd_append_param command $args
|
||||
|
||||
procd_set_param respawn
|
||||
# Restart this instance when its config changes (checksum-watched).
|
||||
procd_set_param file /etc/config/$CONF
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
start_service() {
|
||||
config_load "$CONF"
|
||||
config_foreach validate_instance_section instance start_instance
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
start_service
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
# Reload (not full restart) when /etc/config/byedpi changes via a
|
||||
# config.change event (LuCI Save&Apply / `reload_config`).
|
||||
procd_add_reload_trigger "$CONF"
|
||||
procd_add_validation validate_instance_section
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# /etc/uci-defaults/40_byedpi
|
||||
#
|
||||
# Idempotent first-boot setup for the byedpi package. Runs once on first boot
|
||||
# (and via the default postinst on a live opkg/apk install); must exit 0 so it
|
||||
# is cleared and not retried. Enabling the init is safe on a fresh box: the init
|
||||
# and the shipped config are INERT (the 'default' instance has enabled='0'), so
|
||||
# nothing listens until an instance is explicitly enabled.
|
||||
|
||||
[ -x /etc/init.d/byedpi ] && /etc/init.d/byedpi enable
|
||||
|
||||
exit 0
|
||||
@@ -24,8 +24,13 @@ LUCI_TITLE:=LuCI thin launcher for Shater (mini dashboard + panel handoff)
|
||||
LUCI_DEPENDS:=+shater-core +rpcd
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_VERSION:=0.2.0
|
||||
PKG_RELEASE:=1
|
||||
# Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
|
||||
# SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
|
||||
# full rationale — bug B4). The literals are the manual/offline fallback only.
|
||||
# These MUST stay above the luci.mk include: luci.mk only defaults PKG_VERSION/
|
||||
# PKG_RELEASE when they are still unset, and the i18n subpackages inherit them.
|
||||
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
|
||||
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
@@ -6,11 +6,24 @@
|
||||
'require ui';
|
||||
|
||||
// The admin panel (shaterd's own web server) listens on its own port. shaterd
|
||||
// reports the configured port in status.panel_port (globals.panel_port, default
|
||||
// reports the CONFIGURED port in status.panel_port (globals.panel_port, default
|
||||
// 8088); the launcher builds the redirect from that, falling back to the default
|
||||
// when status is unavailable. `panelPort` tracks the latest reported value and is
|
||||
// refreshed from each status poll. If the panel is ever fronted by TLS, the http
|
||||
// scheme below must follow.
|
||||
//
|
||||
// CONFIGURED IS NOT BOUND, and nothing on this page can turn one into the other:
|
||||
//
|
||||
// - shaterd starts the panel server in a goroutine and treats a bind error as
|
||||
// log-and-continue ("panel server unavailable (daemon continues)",
|
||||
// cmd/shaterd/main.go). After a restart that races the old listener, the
|
||||
// daemon is healthy, `panel_port` still names the port, and nothing is
|
||||
// listening on it.
|
||||
// - SHATER_PANEL_ADDR can disable the panel outright (panelAddr()), while
|
||||
// apply.Status still reports effectivePanelPort() — 8088 when unset.
|
||||
//
|
||||
// There is no "panel is listening" field to read, so this page must not imply
|
||||
// one. The hint and the tooltip say the port is configured, not checked.
|
||||
var DEFAULT_PANEL_PORT = 8088;
|
||||
var panelPort = DEFAULT_PANEL_PORT;
|
||||
|
||||
@@ -26,15 +39,32 @@ var callMintToken = rpc.declare({
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
// led renders a small status dot: state is 'good' | 'warn' | 'bad'.
|
||||
// LED palette. 'unknown' is an UNLIT socket — never amber and never green.
|
||||
// Amber is this page's "degraded", and there is nothing to be degraded about
|
||||
// when no reading has arrived; green on a missing reading is how the panel used
|
||||
// to claim health it had not measured (see panel/src/planeState.ts, which says
|
||||
// the same thing and is the wording this page is kept in step with).
|
||||
var LED_COLORS = {
|
||||
good: '#37b24d',
|
||||
warn: '#f59f00',
|
||||
bad: '#e03131',
|
||||
unknown: '#6b6b6b'
|
||||
};
|
||||
|
||||
// led renders a small status dot: state is 'good' | 'warn' | 'bad' | 'unknown'.
|
||||
// The dot is decorative — every row states its condition in words beside it — so
|
||||
// it is hidden from assistive tech rather than being the only carrier of meaning.
|
||||
function led(state) {
|
||||
var color = state === 'good' ? '#37b24d'
|
||||
: state === 'warn' ? '#f59f00'
|
||||
: '#e03131';
|
||||
// Closed positive list. An unrecognised state resolves to UNKNOWN, never to
|
||||
// green: an open default here is exactly how a state nobody thought about
|
||||
// ends up painted healthy.
|
||||
var color = Object.prototype.hasOwnProperty.call(LED_COLORS, state)
|
||||
? LED_COLORS[state] : LED_COLORS.unknown;
|
||||
var glow = (color === LED_COLORS.unknown) ? '' : ';box-shadow:0 0 5px ' + color;
|
||||
return E('span', {
|
||||
'aria-hidden': 'true',
|
||||
'style': 'display:inline-block;width:.72em;height:.72em;border-radius:50%;' +
|
||||
'margin-right:.6em;vertical-align:-.05em;background:' + color +
|
||||
';box-shadow:0 0 5px ' + color
|
||||
'margin-right:.6em;vertical-align:-.05em;background:' + color + glow
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,63 +79,439 @@ function row(state, label, value) {
|
||||
]);
|
||||
}
|
||||
|
||||
// statusRows maps the shaterd status object to LED rows. An empty object (the
|
||||
// ubus call failed / daemon down) degrades every row to a "down" reading.
|
||||
function statusRows(st) {
|
||||
st = st || {};
|
||||
var down = (st.running !== true);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure state derivation — no DOM below this line until statusRows().
|
||||
//
|
||||
// statusReadout() maps a shaterd status object to a list of
|
||||
// { state, label, value } descriptors. It is deliberately free of E()/DOM so it
|
||||
// can be run against recorded fixtures offline; tests/status-readout.test.js
|
||||
// does exactly that for the four cases this page has to tell apart — engine up,
|
||||
// engine down with the daemon answering, no daemon at all, and a daemon that
|
||||
// cannot read the configuration — plus the degenerate and old-daemon answers.
|
||||
// The gate runs it as step [7/7].
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PLANES is the closed set of values a LIVE Applier.Status() can put in `plane`
|
||||
// (shater/apply/apply.go: "full" | "hold" | "none"). It is the FALLBACK proof of
|
||||
// daemon liveness for a shaterd that predates daemon_answered — see daemonState().
|
||||
var PLANES = { full: true, hold: true, none: true };
|
||||
|
||||
// daemonState — is the shaterd PROCESS answering?
|
||||
//
|
||||
// 'up' — a live Applier produced this status.
|
||||
// 'down' — proven not: `shaterd status` printed its OFFLINE STUB.
|
||||
// 'unknown' — no usable answer, or an answer too old to say either way.
|
||||
//
|
||||
// THE FIELD, THEN THE FALLBACK.
|
||||
//
|
||||
// `daemon_answered` (cmd/shaterd/main.go, statusDaemonAnsweredKey) is the
|
||||
// CONTRACT: true means a running daemon answered over the control socket and
|
||||
// every other field is that daemon's own Applier.Status(); false means the object
|
||||
// is the offline stub — the apply.Status zero value plus a UCI and kernel read —
|
||||
// and is not a status report at all. It is a positive, closed, two-valued
|
||||
// statement about where the object came from, which is exactly what this page
|
||||
// needs and what it never had.
|
||||
//
|
||||
// The `plane` test below is what this page used BEFORE that field existed, and it
|
||||
// is kept only for the non-atomic-update window: `apk upgrade` can leave a new
|
||||
// luci-app-shater beside an old shaterd, and that shaterd emits no
|
||||
// daemon_answered. It works because the stub leaves `plane` at "" while a live
|
||||
// Status() always assigns one of the three words — a side effect, not a promise,
|
||||
// which is precisely why it is now second and not first. If the two ever
|
||||
// disagree, the explicit field wins: a stub that somehow carried a plane word
|
||||
// must still read as "no daemon answered".
|
||||
//
|
||||
// `running` MUST NOT be used for this. It changed meaning on 2026-07-26
|
||||
// (a8970b8ac): it used to be a hardcoded true, and is now the ENGINE's liveness
|
||||
// (apply.go `Running: engineUp`). A daemon that is perfectly alive with a dead
|
||||
// engine reports running=false — and this page used to answer that with a red
|
||||
// "Daemon: not running", the advice "start the Shater service first", and a
|
||||
// DISABLED button to the one place the config can be fixed. The holding plane
|
||||
// keeps management reachable on purpose (shater/netplane/nft.go); LuCI was the
|
||||
// only thing taking that guarantee away.
|
||||
//
|
||||
// Nor is "the ubus call returned" sufficient: the rpcd plugin shells out to
|
||||
// `shaterd status`, which prints a parseable object on BOTH branches (the exit
|
||||
// code is what differs, and command substitution in the plugin drops it).
|
||||
//
|
||||
// Everything else is unknown and is painted as unknown: {} from a failed ubus
|
||||
// call, {"error":...} from the plugin (which is ALSO what a live-but-wedged
|
||||
// daemon produces — cmdStatus prints nothing and exits 1 on a control-socket
|
||||
// timeout, so "wedged" must not be reported as "dead"), and a status from a
|
||||
// daemon predating both fields.
|
||||
function daemonState(st) {
|
||||
if (!st || typeof st !== 'object')
|
||||
return 'unknown';
|
||||
// Closed positive list on the contract field. Anything that is not exactly
|
||||
// `true` or exactly `false` is not a verdict — it falls through rather than
|
||||
// being coerced, because a truthy string is not an answer.
|
||||
if (st.daemon_answered === true)
|
||||
return 'up';
|
||||
if (st.daemon_answered === false)
|
||||
return 'down';
|
||||
// Compatibility fallback: an old shaterd under a new LuCI.
|
||||
if (typeof st.plane === 'string' && PLANES[st.plane] === true)
|
||||
return 'up';
|
||||
if (st.plane === '')
|
||||
return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// configState — could the daemon READ the router's configuration when it took
|
||||
// this status?
|
||||
//
|
||||
// 'ok' — config_readable=true: enabled, kill_switch and panel_port below
|
||||
// are readings.
|
||||
// 'failed' — config_readable=false: those three are ZERO VALUES AND MEAN
|
||||
// NOTHING (apply.go Status.ConfigReadable). Rendering `enabled`
|
||||
// false as "switched off" here is the documented defect: the read
|
||||
// fails when /overlay is full or a `uci commit` was interrupted,
|
||||
// which is exactly when the fail-closed plane has the LAN cut off —
|
||||
// and telling the owner they switched it off themselves sends them
|
||||
// to a settings page backed by the same unreadable file.
|
||||
// 'unknown' — nobody said. Two ways to get here, and neither may be read as
|
||||
// 'failed':
|
||||
// * a daemon predating the field (non-atomic package update);
|
||||
// * NO LIVE DAEMON AT ALL. The offline stub in cmdStatus reads
|
||||
// UCI directly and never sets ConfigReadable, so it emits
|
||||
// config_readable=false while its enabled/kill_switch/
|
||||
// panel_port ARE genuine reads. Taking that at face value would
|
||||
// put "the configuration could not be read" on screen for a
|
||||
// perfectly readable configuration, and would throw away the
|
||||
// only facts a dead-daemon status does carry. So the field is
|
||||
// only consulted when a daemon actually answered.
|
||||
function configState(st) {
|
||||
if (daemonState(st) !== 'up')
|
||||
return 'unknown';
|
||||
if (st.config_readable === true)
|
||||
return 'ok';
|
||||
if (st.config_readable === false)
|
||||
return 'failed';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// killSwitchSetting — the CONFIGURED kill-switch policy, or null when it cannot
|
||||
// be known. It is one of the three config-sourced fields, so an unreadable
|
||||
// configuration leaves it "" — and "" must never normalise to "closed", which is
|
||||
// how a router nobody could read printed a green "fail-closed" row (the panel's
|
||||
// killSwitchReadout carries the same note).
|
||||
function killSwitchSetting(st) {
|
||||
if (configState(st) === 'failed')
|
||||
return null;
|
||||
if (st.kill_switch === 'closed')
|
||||
return 'closed';
|
||||
if (st.kill_switch === 'open')
|
||||
return 'open';
|
||||
return null;
|
||||
}
|
||||
|
||||
// panelTarget — where the launcher will point, and whether the port came from the
|
||||
// router or from this file's built-in default. It never claims the panel answers
|
||||
// there; see the DEFAULT_PANEL_PORT comment at the top.
|
||||
function panelTarget(st) {
|
||||
st = (st && typeof st === 'object') ? st : {};
|
||||
// The config gate is belt-and-braces: an unreadable configuration already
|
||||
// leaves panel_port at 0, which the > 0 test rejects. It is spelled out so the
|
||||
// rule ("only meaningful with config_readable=true") is visible at the point
|
||||
// of use rather than inferred from a zero value.
|
||||
if (configState(st) !== 'failed' &&
|
||||
typeof st.panel_port === 'number' && st.panel_port > 0)
|
||||
return { port: st.panel_port, known: true };
|
||||
return { port: DEFAULT_PANEL_PORT, known: false };
|
||||
}
|
||||
|
||||
// engineState — is a sing-box instance actually started?
|
||||
//
|
||||
// The engine lives INSIDE the shaterd process, so a dead daemon is a dead engine
|
||||
// and this page may say so without guessing. With the daemon up, `engine_running`
|
||||
// is the self-documenting field and `running` carries the same fact by
|
||||
// construction; either may prove a NEGATIVE, and a negative always wins. Neither
|
||||
// asserting anything leaves 'unknown'.
|
||||
function engineState(st) {
|
||||
var d = daemonState(st);
|
||||
if (d === 'down')
|
||||
return 'down';
|
||||
if (d === 'unknown')
|
||||
return 'unknown';
|
||||
if (st.running === false || st.engine_running === false)
|
||||
return 'down';
|
||||
if (st.running === true || st.engine_running === true)
|
||||
return 'up';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function mk(state, label, value) {
|
||||
return { state: state, label: label, value: value };
|
||||
}
|
||||
|
||||
function statusReadout(st) {
|
||||
st = (st && typeof st === 'object') ? st : {};
|
||||
|
||||
var dstate = daemonState(st);
|
||||
var estate = engineState(st);
|
||||
var cstate = configState(st);
|
||||
var kswitch = killSwitchSetting(st);
|
||||
// WHICH FIELDS SURVIVE A DEAD DAEMON. The offline stub's contract
|
||||
// (cmd/shaterd/main.go statusDaemonAnsweredKey) names them: enabled, active,
|
||||
// table, kill_switch and panel_port are read on the spot from UCI and the
|
||||
// kernel and are real; running, engine_running, plane, traffic, hash, warnings
|
||||
// and uptime "are placeholders, not measurements". Today the stub happens to
|
||||
// leave all of them at their zero values, so reading them would look harmless
|
||||
// — but "it happens to be zero" is the same side-effect reasoning that
|
||||
// daemon_answered was added to replace. They are dropped on the stated
|
||||
// contract instead, so a stub that ever grew a value cannot paint this page
|
||||
// green.
|
||||
var live = (dstate !== 'down');
|
||||
var planeWord = live ? st.plane : '';
|
||||
var traffic = (live && st.traffic && typeof st.traffic === 'object') ? st.traffic : {};
|
||||
var rows = [];
|
||||
|
||||
// Daemon process itself.
|
||||
rows.push(row(
|
||||
down ? 'bad' : 'good',
|
||||
_('Daemon (shaterd)'),
|
||||
down ? _('not running') : _('running')
|
||||
));
|
||||
// --- The shaterd process itself. ------------------------------------------
|
||||
// Its own liveness is not a field; it is whether a live daemon answered.
|
||||
if (dstate === 'up')
|
||||
rows.push(mk('good', _('Daemon (shaterd)'), _('responding')));
|
||||
else if (dstate === 'down')
|
||||
rows.push(mk('bad', _('Daemon (shaterd)'),
|
||||
_('not responding — start the Shater service')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Daemon (shaterd)'),
|
||||
_('no usable answer — state unknown')));
|
||||
|
||||
// Desired state: globals.enabled in UCI.
|
||||
rows.push(row(
|
||||
st.enabled ? 'good' : 'warn',
|
||||
_('Service enabled'),
|
||||
st.enabled ? _('enabled') : _('inert (disabled)')
|
||||
));
|
||||
// --- The engine (sing-box) inside it. -------------------------------------
|
||||
if (estate === 'up')
|
||||
rows.push(mk('good', _('Engine (sing-box)'), _('running')));
|
||||
else if (estate === 'down' && dstate === 'down')
|
||||
rows.push(mk('bad', _('Engine (sing-box)'),
|
||||
_('stopped — it runs inside shaterd, which is not answering')));
|
||||
else if (estate === 'down')
|
||||
rows.push(mk('bad', _('Engine (sing-box)'),
|
||||
_('stopped — the daemon is up but no instance is running')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Engine (sing-box)'), _('not reported')));
|
||||
|
||||
// Interception raised (ACTIVE_FLAG present after a successful enabled apply).
|
||||
rows.push(row(
|
||||
st.active ? 'good' : (st.enabled ? 'warn' : 'bad'),
|
||||
_('Interception'),
|
||||
st.active ? _('active') : _('inactive')
|
||||
));
|
||||
// --- Could the configuration be read at all? ------------------------------
|
||||
// Placed ABOVE the three rows it qualifies, because it decides what they mean.
|
||||
if (cstate === 'ok')
|
||||
rows.push(mk('good', _('Configuration'), _('readable')));
|
||||
else if (cstate === 'failed')
|
||||
rows.push(mk('bad', _('Configuration'),
|
||||
_('COULD NOT BE READ — the service/kill-switch/panel-port rows below say ' +
|
||||
'"not known" because the daemon has no reading, NOT because anything is ' +
|
||||
'switched off. If traffic is blocked that is the fail-closed plane; do not ' +
|
||||
'turn anything off to fix it.') +
|
||||
(typeof st.config_error === 'string' && st.config_error !== ''
|
||||
? ' [' + st.config_error + ']' : '')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Configuration'),
|
||||
dstate === 'up'
|
||||
? _('not reported by this daemon')
|
||||
: _('not reported — no daemon answered')));
|
||||
|
||||
// Data plane: the `inet shater` nft table is loaded.
|
||||
rows.push(row(
|
||||
st.table ? 'good' : (st.enabled ? 'warn' : 'bad'),
|
||||
_('Data plane'),
|
||||
st.table ? _('nft table inet shater loaded') : _('not loaded')
|
||||
));
|
||||
// --- Desired state: globals.enabled in UCI. -------------------------------
|
||||
// Read through cstate first: `enabled` is sourced from the configuration, so
|
||||
// with config_readable=false its `false` is a zero value, not a choice.
|
||||
if (cstate === 'failed')
|
||||
rows.push(mk('unknown', _('Service enabled'),
|
||||
_('not known — the configuration could not be read')));
|
||||
else if (st.enabled === true)
|
||||
rows.push(mk('good', _('Service enabled'), _('enabled')));
|
||||
else if (st.enabled === false)
|
||||
rows.push(mk('warn', _('Service enabled'), _('inert (disabled)')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Service enabled'), _('not reported')));
|
||||
|
||||
// Kill-switch: fail-closed ("closed") is the safe posture; "open" leaks
|
||||
// LAN→WAN if the engine goes down. Unknown (older daemon) degrades to warn.
|
||||
var ks = st.kill_switch;
|
||||
rows.push(row(
|
||||
ks === 'closed' ? 'good' : 'warn',
|
||||
_('Kill-switch'),
|
||||
ks === 'closed' ? _('closed (fail-closed)')
|
||||
: ks === 'open' ? _('open (leaky)')
|
||||
: _('unknown')
|
||||
));
|
||||
// --- The ACTIVE_FLAG latch. -----------------------------------------------
|
||||
// NOT a health signal, and this row must never read as one. apply.go states
|
||||
// the contract: it is the "the service is meant to be running" latch that
|
||||
// gates hotplug and cron; it is raised by a successful enabled apply and
|
||||
// cleared only by teardown, so it STAYS UP while the engine is down and the
|
||||
// fail-closed holding plane is blocking the LAN — deliberately, because
|
||||
// clearing it would switch off the very cron reconcile that brings the engine
|
||||
// back. This page used to render it as "Interception: active", in green, over
|
||||
// a dead engine and a blocked LAN. The lamp now reports only whether the
|
||||
// latch AGREES with globals.enabled.
|
||||
//
|
||||
// The latch itself is a filesystem fact and stays readable when the
|
||||
// configuration does not; what an unreadable configuration takes away is the
|
||||
// COMPARISON, since the lamp only reports whether the latch agrees with
|
||||
// globals.enabled. So the words stay and the lamp goes out.
|
||||
if (typeof st.active !== 'boolean')
|
||||
rows.push(mk('unknown', _('Service latch'), _('not reported')));
|
||||
else if (cstate === 'failed')
|
||||
rows.push(mk('unknown', _('Service latch'), st.active
|
||||
? _('raised — the service is meant to be running; whether that matches the ' +
|
||||
'setting is not known, the configuration could not be read')
|
||||
: _('cleared — the service is torn down; whether that matches the setting is ' +
|
||||
'not known, the configuration could not be read')));
|
||||
else if (st.active)
|
||||
rows.push(mk(st.enabled === true ? 'good' : 'warn', _('Service latch'),
|
||||
_('raised — the service is meant to be running')));
|
||||
else
|
||||
rows.push(mk(st.enabled === false ? 'good' : 'warn', _('Service latch'),
|
||||
_('cleared — the service is torn down')));
|
||||
|
||||
// Running engine config hash ("" when the engine is not started).
|
||||
rows.push(row(
|
||||
st.hash ? 'good' : 'warn',
|
||||
_('Config hash'),
|
||||
st.hash ? st.hash : '—'
|
||||
));
|
||||
// --- What is loaded in the kernel right now. ------------------------------
|
||||
// The row this page was missing. `plane` distinguishes the working ruleset
|
||||
// from the FAIL-CLOSED HOLDING PLANE, which `table` cannot: `table` is a bare
|
||||
// existence check, so a held LAN and a working one look identical through it.
|
||||
switch (planeWord) {
|
||||
case 'full':
|
||||
// Deliberately mechanical wording. "full" means the table, the policy
|
||||
// routing and the engine are all in place — it does NOT mean traffic is
|
||||
// tunnelled. That claim belongs to the traffic verdict below.
|
||||
rows.push(mk('good', _('Traffic plane'),
|
||||
_('full — ruleset, routing and engine are all installed')));
|
||||
break;
|
||||
case 'hold':
|
||||
rows.push(mk('bad', _('Traffic plane'),
|
||||
_('hold — the engine is down and LAN→WAN forwarding is BLOCKED')));
|
||||
break;
|
||||
case 'none':
|
||||
// The alarming wording is earned by a KNOWN fail-closed setting. With an
|
||||
// unreadable configuration kswitch is null, and the row states the fact it
|
||||
// has (nothing is installed) without the claim it does not.
|
||||
rows.push(mk(kswitch === 'closed' ? 'bad' : 'warn', _('Traffic plane'),
|
||||
kswitch === 'closed'
|
||||
? _('none — nothing is installed; traffic reaches the WAN unprotected')
|
||||
: _('none — no data plane is installed')));
|
||||
break;
|
||||
default:
|
||||
rows.push(mk('unknown', _('Traffic plane'),
|
||||
dstate === 'down'
|
||||
? _('not reported — no daemon answered')
|
||||
: _('not reported by this daemon')));
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Where the traffic goes under the running config. ---------------------
|
||||
// Separate from the plane on purpose: a router with one `default -> direct`
|
||||
// rule has a fully installed plane and sends every packet out the plain WAN
|
||||
// with its real address.
|
||||
switch (traffic.verdict) {
|
||||
case 'tunnel':
|
||||
rows.push(mk('good', _('Traffic verdict'), _('tunnel — unmatched traffic is proxied')));
|
||||
break;
|
||||
case 'split':
|
||||
rows.push(mk('warn', _('Traffic verdict'),
|
||||
_('split — the default leaves directly; only matched rules are tunnelled')));
|
||||
break;
|
||||
case 'direct':
|
||||
rows.push(mk('warn', _('Traffic verdict'),
|
||||
_('direct — nothing is tunnelled; traffic leaves over the plain WAN')));
|
||||
break;
|
||||
case 'blocked':
|
||||
rows.push(mk('warn', _('Traffic verdict'), _('blocked — unmatched traffic is dropped')));
|
||||
break;
|
||||
default:
|
||||
rows.push(mk('unknown', _('Traffic verdict'), _('not reported')));
|
||||
break;
|
||||
}
|
||||
|
||||
// --- The nft table, as a bare presence check. -----------------------------
|
||||
// Kept because the offline stub still reads it straight from the kernel, so
|
||||
// it is the one plane fact available when no daemon answers. It says nothing
|
||||
// about WHICH ruleset is loaded — that is the Traffic plane row.
|
||||
//
|
||||
// "Not loaded" is only the calm amber when the service is KNOWN to be switched
|
||||
// off. With an unreadable configuration that is not known, and an absent
|
||||
// firewall table with no explanation is the alarming side, not the calm one.
|
||||
if (st.table === true)
|
||||
rows.push(mk('good', _('nft table'), _('inet shater is loaded')));
|
||||
else if (st.table === false)
|
||||
rows.push(mk(cstate !== 'failed' && st.enabled === false ? 'warn' : 'bad',
|
||||
_('nft table'), _('not loaded')));
|
||||
else
|
||||
rows.push(mk('unknown', _('nft table'), _('not reported')));
|
||||
|
||||
// --- Kill-switch: the configured policy, and whether it is in force. ------
|
||||
// "closed" with no plane installed is a setting that is not in effect, which
|
||||
// is worse news than "open" and must not share its amber lamp.
|
||||
if (cstate === 'failed')
|
||||
rows.push(mk('unknown', _('Kill-switch'),
|
||||
_('not known — the configuration could not be read')));
|
||||
else if (kswitch === 'closed' && planeWord === 'none')
|
||||
rows.push(mk('bad', _('Kill-switch'),
|
||||
_('closed, but NOT in effect — no data plane is installed')));
|
||||
else if (kswitch === 'closed' && dstate === 'up')
|
||||
rows.push(mk('good', _('Kill-switch'), _('closed (fail-closed)')));
|
||||
else if (kswitch === 'closed')
|
||||
rows.push(mk('warn', _('Kill-switch'),
|
||||
_('configured closed; whether it is installed is not known')));
|
||||
else if (kswitch === 'open')
|
||||
rows.push(mk('warn', _('Kill-switch'), _('open (leaky)')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Kill-switch'), _('not reported')));
|
||||
|
||||
// --- Running engine config hash ("" when the engine is not started). ------
|
||||
if (live && typeof st.hash === 'string' && st.hash !== '')
|
||||
rows.push(mk('good', _('Config hash'), st.hash));
|
||||
else if (estate === 'down')
|
||||
rows.push(mk('unknown', _('Config hash'), _('none — the engine is not started')));
|
||||
else
|
||||
rows.push(mk('unknown', _('Config hash'), _('not reported')));
|
||||
|
||||
// --- Where the "Open panel" button will point. ----------------------------
|
||||
// The lamp is UNLIT even on a perfectly healthy router, and that is the point:
|
||||
// the lamps on this page report a condition, and the condition an operator
|
||||
// cares about here — "will the panel answer on that port" — is one nothing in
|
||||
// this status measures. shaterd's panel server is started in a goroutine whose
|
||||
// bind error is only logged, so a taken port leaves a healthy daemon reporting
|
||||
// a port nothing is listening on; SHATER_PANEL_ADDR can switch the server off
|
||||
// entirely and the port is still reported. A green lamp here would be a
|
||||
// promise made out of a configuration value.
|
||||
var target = panelTarget(st);
|
||||
if (target.known)
|
||||
rows.push(mk('unknown', _('Panel port'),
|
||||
_('%PORT% — configured; nothing here reports whether the panel is listening on it')
|
||||
.replace('%PORT%', String(target.port))));
|
||||
else if (cstate === 'failed')
|
||||
rows.push(mk('unknown', _('Panel port'),
|
||||
_('not known — the configuration could not be read; the button will try the built-in default %PORT%')
|
||||
.replace('%PORT%', String(target.port))));
|
||||
else
|
||||
rows.push(mk('unknown', _('Panel port'),
|
||||
_('not reported — the button will try the built-in default %PORT%')
|
||||
.replace('%PORT%', String(target.port))));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// statusRows turns the readout into LED table rows.
|
||||
function statusRows(st) {
|
||||
return statusReadout(st).map(function(r) {
|
||||
return row(r.state, r.label, r.value);
|
||||
});
|
||||
}
|
||||
|
||||
// panelTitle describes the button's target and what is known about it. It never
|
||||
// promises the panel is up — only where the launcher will point.
|
||||
function panelTitle(st) {
|
||||
var dstate = daemonState(st);
|
||||
var target = panelTarget(st);
|
||||
// Second sentence, on every branch: the port is a configuration value that
|
||||
// nothing on the wire confirms. A page that says "opens the panel" and lands
|
||||
// on a connection refused has made a claim it had no field to support.
|
||||
var port = target.known
|
||||
? _('The port is the configured one; a status cannot say whether the panel is listening on it, so a connection error here means the port, not your token.')
|
||||
: _('No panel port was reported, so this falls back to the built-in default and may well be the wrong port.');
|
||||
if (dstate === 'up')
|
||||
return _('Mint a session token and open the admin panel.') + ' ' + port;
|
||||
if (dstate === 'down')
|
||||
return _('shaterd is not answering, so this will probably fail — but the panel is served by the daemon, not by the engine, so it is worth trying: any failure is reported here.') + ' ' + port;
|
||||
return _('The daemon state is not known. Try it — a failure is reported here rather than hidden.') + ' ' + port;
|
||||
}
|
||||
|
||||
// panelHint is the grey line beside the button. Same rule as the tooltip: it
|
||||
// states what the button DOES, and marks the port as configured rather than
|
||||
// checked.
|
||||
function panelHint(hostname, target) {
|
||||
return (target.known
|
||||
? _('opens http://%HOST%:%PORT%/ with a single-use session token — %PORT% is the configured port, not a checked one')
|
||||
: _('opens http://%HOST%:%PORT%/ with a single-use session token — no port was reported, so %PORT% is this page\'s built-in default'))
|
||||
.replace('%HOST%', hostname)
|
||||
.replace(/%PORT%/g, String(target.port));
|
||||
}
|
||||
|
||||
// handleOpenPanel mints a single-use token and hands it to the panel via the
|
||||
// ARCHITECTURE §2 browser bridge: GET http://<router>:<port>/?t=<token>. The panel
|
||||
// validates+consumes the token and drops a session cookie.
|
||||
@@ -151,13 +557,21 @@ return view.extend({
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
// Exposed so the offline fixture harness (tests/status-readout.test.js) can
|
||||
// exercise the state derivation without a browser, a router, or a DOM.
|
||||
statusReadout: statusReadout,
|
||||
daemonState: daemonState,
|
||||
engineState: engineState,
|
||||
configState: configState,
|
||||
panelTarget: panelTarget,
|
||||
panelTitle: panelTitle,
|
||||
panelHint: panelHint,
|
||||
|
||||
load: function() {
|
||||
return L.resolveDefault(callStatus(), {});
|
||||
},
|
||||
|
||||
render: function(st) {
|
||||
var self = this;
|
||||
|
||||
var table = E('table', { 'class': 'table' }, statusRows(st));
|
||||
|
||||
var openBtn = E('button', {
|
||||
@@ -165,34 +579,37 @@ return view.extend({
|
||||
'click': ui.createHandlerFn(this, handleOpenPanel)
|
||||
}, [ _('Open panel') ]);
|
||||
|
||||
function hintText() {
|
||||
return _('opens http://%s:%d/ with a single-use session token')
|
||||
.format(window.location.hostname, panelPort);
|
||||
}
|
||||
|
||||
var hint = E('span', {
|
||||
'style': 'margin-left:1em;color:#888;font-size:90%'
|
||||
}, hintText());
|
||||
}, panelHint(window.location.hostname, panelTarget(st)));
|
||||
|
||||
// Reflect daemon reachability on the button up front, then keep the whole
|
||||
// dashboard live. Also track the panel port reported in status so the
|
||||
// launcher redirect and hint follow globals.panel_port.
|
||||
// Track the panel port reported in status so the launcher redirect and the
|
||||
// hint follow globals.panel_port.
|
||||
//
|
||||
// THE BUTTON IS NEVER DISABLED. It used to be locked whenever
|
||||
// `running !== true`, which after a8970b8ac means "the engine is down" —
|
||||
// precisely the situation the panel exists to get you out of, and one in
|
||||
// which the daemon and its web server are still up and still minting
|
||||
// tokens (cmd/shaterd/main.go starts the panel server independently of the
|
||||
// engine). Locking it on a guess is the failure; a mint that fails already
|
||||
// reports itself through ui.addNotification, which is the recoverable
|
||||
// direction for an unknown state.
|
||||
function reflect(state) {
|
||||
state = state || {};
|
||||
panelPort = state.panel_port || DEFAULT_PANEL_PORT;
|
||||
hint.textContent = hintText();
|
||||
var down = (state.running !== true);
|
||||
openBtn.disabled = down;
|
||||
openBtn.title = down
|
||||
? _('shaterd is not running — start the Shater service first')
|
||||
: _('Mint a session token and open the admin panel');
|
||||
state = (state && typeof state === 'object') ? state : {};
|
||||
// One source for the port: the same panelTarget() the "Panel port" row
|
||||
// renders, so the row, the hint, the tooltip and the redirect cannot
|
||||
// disagree about where the button goes.
|
||||
var target = panelTarget(state);
|
||||
panelPort = target.port;
|
||||
hint.textContent = panelHint(window.location.hostname, target);
|
||||
openBtn.title = panelTitle(state);
|
||||
}
|
||||
reflect(st || {});
|
||||
reflect(st);
|
||||
|
||||
poll.add(function() {
|
||||
return L.resolveDefault(callStatus(), {}).then(function(s) {
|
||||
dom.content(table, statusRows(s));
|
||||
reflect(s || {});
|
||||
reflect(s);
|
||||
});
|
||||
}, 5);
|
||||
|
||||
@@ -209,7 +626,7 @@ return view.extend({
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Admin panel')),
|
||||
E('p', { 'class': 'cbi-value-description' },
|
||||
_('The rich admin panel is served by shaterd on its own port. LuCI mints a short-lived, single-use token for your browser — the panel has no separate login.')),
|
||||
_('The rich admin panel is served by shaterd on its own port — by the daemon, not by the engine, so it stays reachable while the engine is down. LuCI mints a short-lived, single-use token for your browser; the panel has no separate login.')),
|
||||
E('div', {}, [ openBtn, hint ])
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -4,9 +4,41 @@
|
||||
# Registers the ubus object "shater" (object name == this file's name) with two
|
||||
# read-side methods the thin LuCI launcher calls over ubus:
|
||||
#
|
||||
# status -> passthrough of `shaterd status` ({running,enabled,active,table,hash})
|
||||
# status -> passthrough of `shaterd status`
|
||||
# mint_token -> passthrough of `shaterd mint-token` ({"token":"..."} | {"error":"..."})
|
||||
#
|
||||
# The status object is whatever apply.Status marshals (shater/apply/apply.go is the
|
||||
# only definition; this script never parses or reshapes it), plus the one field
|
||||
# `shaterd status` splices in itself. As of 2026-07-27 that is:
|
||||
#
|
||||
# daemon_answered, running, engine_running, enabled, active, table, plane,
|
||||
# traffic, hash, kill_switch, panel_port, config_readable, config_error,
|
||||
# can_rollback, warnings, started_unix, uptime_seconds
|
||||
#
|
||||
# Three of those are load-bearing for the caller and easy to misread:
|
||||
#
|
||||
# daemon_answered — WHERE THE OBJECT CAME FROM, and the only field that says so by
|
||||
# contract (cmd/shaterd/main.go, statusDaemonAnsweredKey). true: a running
|
||||
# daemon answered over the control socket. false: this is the OFFLINE STUB —
|
||||
# the apply.Status zero value plus a UCI and kernel read — and the fields only
|
||||
# a live daemon can know (running/engine_running/plane/traffic/hash/warnings/
|
||||
# uptime) are placeholders. dashboard.js keys "daemon: down" off this.
|
||||
# running / engine_running — the ENGINE's liveness, not the daemon's. `running`
|
||||
# was a hardcoded true until a8970b8ac (2026-07-26) and is now `engineUp`, so
|
||||
# a healthy daemon with a dead engine reports running=false. The daemon's own
|
||||
# liveness is not a field of apply.Status at all.
|
||||
# config_readable — whether the daemon could READ the configuration. When false,
|
||||
# enabled/kill_switch/panel_port are zero values and mean NOTHING. Note the
|
||||
# stub above never sets it, so its false is not a failed read either — a
|
||||
# consumer must check daemon_answered first. dashboard.js does.
|
||||
#
|
||||
# EXIT CODE: `shaterd status` now exits 1 when no daemon answered, and this script
|
||||
# deliberately ignores that — command substitution below keeps only stdout. The stub
|
||||
# is still worth relaying (it carries the real UCI and nft-table readings, and it
|
||||
# says what it is), and dropping it would blank the LuCI page instead of degrading
|
||||
# it. The wedged case is the one where the exit code matters, and it reports itself
|
||||
# by printing NOTHING: the case below then emits the error object.
|
||||
#
|
||||
# Why shell out to shaterd instead of talking to /var/run/shaterd.ctl directly:
|
||||
# a reliable AF_UNIX client is NOT guaranteed on stock OpenWrt (busybox `nc` is
|
||||
# usually built without `-U`; socat/ucode-socket aren't in the base image). shaterd
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Offline harness for the dashboard's state derivation.
|
||||
*
|
||||
* Run: node openwrt/luci-app-shater/tests/status-readout.test.js
|
||||
* (the gate runs it as step [7/7]; see scripts/run-tests.sh NONGO_TEST_RUNNERS)
|
||||
*
|
||||
* Why this exists: the LuCI page is the ONE screen an operator reaches when the
|
||||
* engine is down and the fail-closed holding plane is blocking the LAN. What it
|
||||
* says there is a claim about the router's behaviour, and until now nothing
|
||||
* checked those claims. There is no browser and no router in this loop — the view
|
||||
* exposes statusReadout/daemonState/engineState/configState/panelTarget/
|
||||
* panelTitle/panelHint as plain functions, and this file feeds them recorded
|
||||
* status objects.
|
||||
*
|
||||
* The fixtures are not invented. Each is what the wire actually carries:
|
||||
*
|
||||
* ENGINE_UP — apply.Status() from a live daemon with a started engine,
|
||||
* marked daemon_answered:true by the CLI.
|
||||
* ENGINE_DOWN — the same daemon with a dead engine; the holding plane is
|
||||
* installed and the LAN is blocked.
|
||||
* DAEMON_DOWN — the OFFLINE STUB `shaterd status` prints when the daemon is
|
||||
* unreachable (cmd/shaterd/main.go cmdStatus): apply.Status zero
|
||||
* value + a UCI and kernel read, marked daemon_answered:false.
|
||||
* CONFIG_BAD — a LIVE daemon that could not read the configuration
|
||||
* (config_readable:false): enabled/kill_switch/panel_port are
|
||||
* zero values that mean NOTHING.
|
||||
* NO_ANSWER — {} , what L.resolveDefault hands render() when the ubus call
|
||||
* fails outright.
|
||||
* PLUGIN_ERROR — {"error":...} from the rpcd plugin, which is ALSO what a
|
||||
* live-but-wedged daemon produces.
|
||||
* OLD_* — a shaterd predating daemon_answered/config_readable, under a
|
||||
* new luci-app-shater. Packages do not update atomically, so
|
||||
* this combination WILL exist in the field.
|
||||
* LEGACY — a daemon predating plane/engine_running as well.
|
||||
*
|
||||
* Mutation check (each has been run; the named assertions in brackets fail):
|
||||
* - delete the daemon_answered branches from daemonState() [H/*, C/*]
|
||||
* - delete the `plane` fallback from daemonState() [OLD/*]
|
||||
* - read config_readable without the daemon gate [C/config-*]
|
||||
* - paint config_readable:false rows from their zero values [K/*]
|
||||
* Reproduce by copying dashboard.js, breaking the copy, and pointing
|
||||
* DASHBOARD_JS at it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
// --- Load the view module with LuCI's globals stubbed. ----------------------
|
||||
// The view file is a module body LuCI wraps in a function, so it ends in a
|
||||
// top-level `return` and cannot be require()d. Wrapping it in new Function is the
|
||||
// same thing LuCI's loader does. The 'require x' lines are bare string literals
|
||||
// and evaluate to nothing.
|
||||
// DASHBOARD_JS points the harness at a copy of the view. It exists so the
|
||||
// mutation check is repeatable: copy dashboard.js, reintroduce the defect in the
|
||||
// copy, run this file against it, and watch the named assertions fail. A test
|
||||
// that cannot be shown to fail on the broken code is decoration.
|
||||
var SRC = process.env.DASHBOARD_JS || path.join(__dirname, '..', 'htdocs',
|
||||
'luci-static', 'resources', 'view', 'shater', 'dashboard.js');
|
||||
|
||||
function loadView() {
|
||||
var src = fs.readFileSync(SRC, 'utf8');
|
||||
var factory = new Function('view', 'dom', 'poll', 'rpc', 'ui', 'E', '_', 'L',
|
||||
'window', src);
|
||||
return factory(
|
||||
{ extend: function(o) { return o; } }, // view
|
||||
{ content: function() {} }, // dom
|
||||
{ add: function() {} }, // poll
|
||||
{ declare: function() { return function() {}; } }, // rpc
|
||||
{ createHandlerFn: function() { return function() {}; }, addNotification: function() {} },
|
||||
function() { return {}; }, // E
|
||||
function(s) { return s; }, // _ (identity)
|
||||
{ resolveDefault: function(p, d) { return Promise.resolve(d); } },
|
||||
{ location: { hostname: 'router' }, open: function() { return null; } }
|
||||
);
|
||||
}
|
||||
|
||||
var page = loadView();
|
||||
|
||||
// --- Fixtures ---------------------------------------------------------------
|
||||
|
||||
var ENGINE_UP = {
|
||||
daemon_answered: true,
|
||||
running: true, engine_running: true, enabled: true, active: true, table: true,
|
||||
config_readable: true, config_error: '',
|
||||
plane: 'full', traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 },
|
||||
hash: 'a1b2c3d4', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
|
||||
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
|
||||
};
|
||||
|
||||
var ENGINE_DOWN = {
|
||||
daemon_answered: true,
|
||||
running: false, engine_running: false, enabled: true, active: true, table: true,
|
||||
config_readable: true, config_error: '',
|
||||
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
|
||||
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
|
||||
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
|
||||
};
|
||||
|
||||
// Exactly what Status.JSON() + markStatusOrigin(false) emit for the cmdStatus
|
||||
// offline stub. NOTE config_readable:false: the stub reads UCI directly (so
|
||||
// enabled/kill_switch/panel_port below ARE real readings) but never sets the
|
||||
// field, so it ships the zero value. Anything keying on config_readable without
|
||||
// first checking that a daemon answered will call this configuration unreadable.
|
||||
var DAEMON_DOWN = {
|
||||
daemon_answered: false,
|
||||
running: false, engine_running: false, enabled: true, active: true, table: true,
|
||||
config_readable: false, config_error: '',
|
||||
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
|
||||
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
|
||||
warnings: null, started_unix: 0, uptime_seconds: 0
|
||||
};
|
||||
|
||||
// A LIVE daemon whose readConfig() failed: /overlay full, or a `uci commit`
|
||||
// interrupted. apply.go then leaves enabled=false, kill_switch="" and
|
||||
// panel_port=0 as PLACEHOLDERS and raises config_readable=false + config_error.
|
||||
// The engine cannot be generated, so the fail-closed holding plane is what is
|
||||
// installed — which is the whole trap: the LAN is cut off and the three
|
||||
// placeholders spell out a calm "the owner switched it off".
|
||||
var CONFIG_BAD = {
|
||||
daemon_answered: true,
|
||||
running: false, engine_running: false, enabled: false, active: true, table: true,
|
||||
config_readable: false,
|
||||
config_error: 'uci: read /etc/config/shater: no space left on device',
|
||||
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
|
||||
hash: '', kill_switch: '', panel_port: 0, can_rollback: false,
|
||||
warnings: [], started_unix: 1753500000, uptime_seconds: 90
|
||||
};
|
||||
|
||||
var NO_ANSWER = {};
|
||||
var PLUGIN_ERROR = { error: 'shaterd unavailable' };
|
||||
|
||||
// Non-atomic package update: new luci-app-shater, old shaterd. No
|
||||
// daemon_answered, no config_readable — the `plane` fallback is all there is.
|
||||
var OLD_DAEMON_UP = {
|
||||
running: false, engine_running: false, enabled: true, active: true, table: true,
|
||||
plane: 'hold', traffic: { verdict: '', default: '', tunnel_rules: 0 },
|
||||
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: true,
|
||||
warnings: [], started_unix: 1753500000, uptime_seconds: 3600
|
||||
};
|
||||
var OLD_DAEMON_DOWN = {
|
||||
running: false, engine_running: false, enabled: true, active: true, table: true,
|
||||
plane: '', traffic: { verdict: '', default: '', tunnel_rules: 0 },
|
||||
hash: '', kill_switch: 'closed', panel_port: 8088, can_rollback: false,
|
||||
warnings: null, started_unix: 0, uptime_seconds: 0
|
||||
};
|
||||
|
||||
// Older still: no plane, no engine_running either.
|
||||
var LEGACY = {
|
||||
running: true, enabled: true, active: true, table: true, hash: 'deadbeef',
|
||||
kill_switch: 'closed', panel_port: 8088
|
||||
};
|
||||
|
||||
// A configured panel port that is NOT the built-in default, so "reported" and
|
||||
// "fell back" are distinguishable in the readout and in the button hint.
|
||||
var CUSTOM_PORT = Object.assign({}, ENGINE_UP, { panel_port: 9090 });
|
||||
|
||||
// --- Assertions -------------------------------------------------------------
|
||||
|
||||
var failures = [];
|
||||
|
||||
function check(name, cond, detail) {
|
||||
if (cond) return;
|
||||
failures.push(name + (detail ? ': ' + detail : ''));
|
||||
}
|
||||
|
||||
// readout indexes the rows by label. A row that is NOT emitted must fail by name
|
||||
// rather than by throwing on `undefined.state`: a harness that dies mid-run stops
|
||||
// reporting the assertions after it, which is the silent-skip failure this
|
||||
// project has been bitten by. Missing rows come back as a loud sentinel instead.
|
||||
var MISSING = { state: '<row absent>', value: '<row absent>', missing: true };
|
||||
|
||||
function readout(st) {
|
||||
var out = {};
|
||||
page.statusReadout(st).forEach(function(r) { out[r.label] = r; });
|
||||
return new Proxy(out, {
|
||||
get: function(t, k) {
|
||||
if (typeof k !== 'string' || k in t) return t[k];
|
||||
return MISSING;
|
||||
},
|
||||
has: function(t, k) { return k in t; }
|
||||
});
|
||||
}
|
||||
|
||||
function show(title, st) {
|
||||
process.stdout.write('\n=== ' + title + ' ===\n');
|
||||
process.stdout.write(' daemon=' + page.daemonState(st) +
|
||||
' engine=' + page.engineState(st) +
|
||||
' config=' + page.configState(st) + '\n');
|
||||
page.statusReadout(st).forEach(function(r) {
|
||||
process.stdout.write(' [' + r.state.padEnd(7) + '] ' +
|
||||
r.label.padEnd(18) + ' ' + r.value + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
function lamps(st) {
|
||||
return page.statusReadout(st).map(function(r) { return r.state; });
|
||||
}
|
||||
|
||||
// 1. Live daemon, engine up.
|
||||
show('A. daemon alive, engine running', ENGINE_UP);
|
||||
check('A/daemon', page.daemonState(ENGINE_UP) === 'up');
|
||||
check('A/engine', page.engineState(ENGINE_UP) === 'up');
|
||||
check('A/config', page.configState(ENGINE_UP) === 'ok');
|
||||
check('A/no-red', lamps(ENGINE_UP).indexOf('bad') === -1,
|
||||
'a fully healthy router must show no red lamp');
|
||||
|
||||
// Closed positive list of the rows that a fully-reporting healthy router MUST
|
||||
// have a verdict for. It replaces a blanket "no unknown anywhere", which stopped
|
||||
// being the right assertion when the Panel port row was added: that row is unlit
|
||||
// even here, on purpose, because nothing in a status measures whether the panel
|
||||
// is listening. Naming the rows says which readings are owed, and a row that
|
||||
// silently disappears fails here rather than passing as "no unknowns".
|
||||
var MUST_BE_KNOWN = ['Daemon (shaterd)', 'Engine (sing-box)', 'Configuration',
|
||||
'Service enabled', 'Service latch', 'Traffic plane', 'Traffic verdict',
|
||||
'nft table', 'Kill-switch', 'Config hash'];
|
||||
var a = readout(ENGINE_UP);
|
||||
MUST_BE_KNOWN.forEach(function(label) {
|
||||
check('A/known:' + label, a[label].state !== 'unknown' && !a[label].missing,
|
||||
'every field is present, so this row may not read as unknown (got ' +
|
||||
a[label].state + ')');
|
||||
});
|
||||
check('A/panel-port-unlit', a['Panel port'].state === 'unknown',
|
||||
'the panel port is a CONFIGURED value; a lit lamp would promise a listener ' +
|
||||
'that nothing in this status measures');
|
||||
|
||||
// 2. THE DEFECT. Live daemon, dead engine, LAN held.
|
||||
show('B. daemon alive, engine DOWN, holding plane', ENGINE_DOWN);
|
||||
check('B/daemon-up', page.daemonState(ENGINE_DOWN) === 'up',
|
||||
'the daemon is answering; calling it dead is the bug being fixed');
|
||||
check('B/engine-down', page.engineState(ENGINE_DOWN) === 'down');
|
||||
var b = readout(ENGINE_DOWN);
|
||||
check('B/daemon-row-green', b['Daemon (shaterd)'].state === 'good',
|
||||
'got ' + b['Daemon (shaterd)'].state + ' / ' + b['Daemon (shaterd)'].value);
|
||||
check('B/daemon-row-no-start-advice',
|
||||
b['Daemon (shaterd)'].value.indexOf('start') === -1,
|
||||
'must not tell the operator to start a service that is already running');
|
||||
check('B/plane-red', b['Traffic plane'].state === 'bad');
|
||||
check('B/plane-says-blocked', /BLOCKED/.test(b['Traffic plane'].value));
|
||||
check('B/latch-not-called-interception',
|
||||
!b['Service latch'].missing && b['Interception'].missing === true,
|
||||
'`active` is the run latch, not a "we are proxying" signal — apply.go: ' +
|
||||
'"Never render it as \'we are proxying\'"');
|
||||
check('B/latch-value-is-a-latch', /meant to be running/.test(b['Service latch'].value),
|
||||
'the latch row must state the latch, not claim traffic is being proxied');
|
||||
check('B/latch-not-active-word', !/^active$/.test(b['Service latch'].value));
|
||||
check('B/verdict-unknown', b['Traffic verdict'].state === 'unknown',
|
||||
'no verdict was published; it must not be painted as tunnel');
|
||||
check('B/some-red', lamps(ENGINE_DOWN).indexOf('bad') !== -1,
|
||||
'a blocked LAN must not be an all-green screen');
|
||||
|
||||
// The button is a property of render(), not of the pure readout, so it is
|
||||
// guarded at the source level: nothing may ever set `disabled` on the launcher.
|
||||
// Locking the way into the panel while the engine is down is the defect this
|
||||
// whole file exists for, and it must not come back by a different route.
|
||||
check('B/button-never-disabled',
|
||||
!/openBtn\s*\.\s*disabled/.test(fs.readFileSync(SRC, 'utf8')),
|
||||
'dashboard.js assigns openBtn.disabled — the launcher must never be locked');
|
||||
|
||||
// 3. Daemon not answering at all — the offline stub, marked daemon_answered:false.
|
||||
show('C. daemon NOT running (offline stub)', DAEMON_DOWN);
|
||||
check('C/daemon-down', page.daemonState(DAEMON_DOWN) === 'down',
|
||||
'daemon_answered:false is the contract; got ' + page.daemonState(DAEMON_DOWN));
|
||||
check('C/engine-down', page.engineState(DAEMON_DOWN) === 'down');
|
||||
var c = readout(DAEMON_DOWN);
|
||||
check('C/daemon-row-red', c['Daemon (shaterd)'].state === 'bad');
|
||||
check('C/plane-unknown', c['Traffic plane'].state === 'unknown',
|
||||
'the stub reports no plane; got ' + c['Traffic plane'].value);
|
||||
check('C/kill-switch-not-green', c['Kill-switch'].state !== 'good',
|
||||
'"closed" from a dead daemon proves nothing is installed to enforce it');
|
||||
check('C/distinct-from-B',
|
||||
c['Daemon (shaterd)'].value !== b['Daemon (shaterd)'].value,
|
||||
'engine-down and daemon-down must not render identically');
|
||||
|
||||
// The stub ships config_readable:false without ever having tried a config read
|
||||
// (cmd/shaterd/main.go never sets it), while its enabled/kill_switch/panel_port
|
||||
// ARE genuine UCI reads. Believing the field here would put a red "COULD NOT BE
|
||||
// READ" on screen for a perfectly readable file and throw away the only facts a
|
||||
// dead-daemon status carries.
|
||||
check('C/config-not-claimed-unreadable', page.configState(DAEMON_DOWN) === 'unknown',
|
||||
'config_readable from the offline stub is a zero value, not a reading; got ' +
|
||||
page.configState(DAEMON_DOWN));
|
||||
check('C/config-row-unknown', c['Configuration'].state === 'unknown',
|
||||
'got ' + c['Configuration'].state + ' / ' + c['Configuration'].value);
|
||||
check('C/config-row-blames-the-daemon', /no daemon answered/.test(c['Configuration'].value));
|
||||
check('C/enabled-still-read', c['Service enabled'].state === 'good',
|
||||
'the stub read globals.enabled from UCI; that reading must survive');
|
||||
check('C/panel-port-still-read', page.panelTarget(DAEMON_DOWN).known === true,
|
||||
'the stub read globals.panel_port from UCI; the button must use it');
|
||||
|
||||
// 4/5/6/7. Degenerate answers must degrade to unknown, never to healthy.
|
||||
[['D. ubus call failed ({})', NO_ANSWER],
|
||||
['E. rpcd plugin error / wedged daemon', PLUGIN_ERROR],
|
||||
['F. legacy daemon (no plane, no engine_running)', LEGACY]].forEach(function(p) {
|
||||
show(p[0], p[1]);
|
||||
var st = p[1];
|
||||
check(p[0] + '/daemon-unknown', page.daemonState(st) === 'unknown');
|
||||
check(p[0] + '/engine-unknown', page.engineState(st) === 'unknown');
|
||||
var r = readout(st);
|
||||
check(p[0] + '/plane-unknown', r['Traffic plane'].state === 'unknown');
|
||||
check(p[0] + '/daemon-row-unknown', r['Daemon (shaterd)'].state === 'unknown');
|
||||
check(p[0] + '/config-unknown', r['Configuration'].state === 'unknown');
|
||||
check(p[0] + '/no-false-green-plane', r['Traffic plane'].state !== 'good');
|
||||
});
|
||||
|
||||
// The legacy fixture additionally must not crash and must not lose the fields it
|
||||
// DOES carry — a non-atomic package update must degrade, not black out.
|
||||
var f = readout(LEGACY);
|
||||
check('F/enabled-still-read', f['Service enabled'].state === 'good');
|
||||
check('F/hash-still-read', f['Config hash'].value === 'deadbeef');
|
||||
check('F/table-still-read', f['nft table'].state === 'good');
|
||||
|
||||
// --- G. the compatibility fallback: an OLD shaterd under this LuCI ----------
|
||||
// `apk upgrade shaterd shater-core luci-app-shater` is not atomic, so a new page
|
||||
// will meet a daemon that emits no daemon_answered at all. `plane` must keep
|
||||
// carrying the verdict on its own.
|
||||
show('G1. OLD shaterd, alive (plane fallback)', OLD_DAEMON_UP);
|
||||
check('G1/daemon-up', page.daemonState(OLD_DAEMON_UP) === 'up',
|
||||
'plane:"hold" is a word only a live Applier.Status() writes; got ' +
|
||||
page.daemonState(OLD_DAEMON_UP));
|
||||
var g1 = readout(OLD_DAEMON_UP);
|
||||
check('G1/daemon-row-green', g1['Daemon (shaterd)'].state === 'good');
|
||||
check('G1/plane-red', g1['Traffic plane'].state === 'bad',
|
||||
'the holding plane must still be reported as blocking');
|
||||
check('G1/config-unknown', g1['Configuration'].state === 'unknown',
|
||||
'an old daemon says nothing about config_readable; absence is not failure');
|
||||
check('G1/enabled-still-read', g1['Service enabled'].state === 'good',
|
||||
'a missing config_readable must NOT suppress an enabled that means what it says');
|
||||
|
||||
show('G2. OLD shaterd, dead (plane:"" fallback)', OLD_DAEMON_DOWN);
|
||||
check('G2/daemon-down', page.daemonState(OLD_DAEMON_DOWN) === 'down',
|
||||
'plane:"" is the pre-daemon_answered stub signature; got ' +
|
||||
page.daemonState(OLD_DAEMON_DOWN));
|
||||
check('G2/daemon-row-red', readout(OLD_DAEMON_DOWN)['Daemon (shaterd)'].state === 'bad');
|
||||
|
||||
// --- H. the field beats the side effect -------------------------------------
|
||||
// If the two signals ever disagree, the explicit contract wins. A stub that
|
||||
// somehow carried a plane word (a future change to cmdStatus, a merged object)
|
||||
// must still read as "no daemon answered" — that is the entire reason the field
|
||||
// was added, and keying off `plane` first would quietly restore the old defect.
|
||||
var STUB_WITH_PLANE = Object.assign({}, DAEMON_DOWN, { plane: 'full' });
|
||||
show('H. daemon_answered:false but plane:"full"', STUB_WITH_PLANE);
|
||||
check('H/field-wins', page.daemonState(STUB_WITH_PLANE) === 'down',
|
||||
'daemon_answered:false must outrank a plane word; got ' +
|
||||
page.daemonState(STUB_WITH_PLANE));
|
||||
var h = readout(STUB_WITH_PLANE);
|
||||
check('H/daemon-row-red', h['Daemon (shaterd)'].state === 'bad');
|
||||
// And the fields the stub CANNOT know are dropped rather than rendered. The
|
||||
// stub's contract lists plane/traffic/hash among "placeholders, not
|
||||
// measurements"; a green "full — ruleset, routing and engine are all installed"
|
||||
// over a daemon that never answered is the original defect in a new costume.
|
||||
check('H/plane-row-unknown', h['Traffic plane'].state === 'unknown',
|
||||
'a plane word from an object marked daemon_answered:false is a placeholder; ' +
|
||||
'got ' + h['Traffic plane'].state + ' / ' + h['Traffic plane'].value);
|
||||
var STUB_WITH_EVERYTHING = Object.assign({}, DAEMON_DOWN, {
|
||||
plane: 'full', hash: 'cafebabe',
|
||||
traffic: { verdict: 'tunnel', default: 'proxy', tunnel_rules: 3 }
|
||||
});
|
||||
var he = readout(STUB_WITH_EVERYTHING);
|
||||
check('H/verdict-row-unknown', he['Traffic verdict'].state === 'unknown',
|
||||
'got ' + he['Traffic verdict'].value);
|
||||
check('H/hash-row-not-shown', he['Config hash'].value.indexOf('cafebabe') === -1,
|
||||
'a hash nobody measured must not be printed as the running config: ' +
|
||||
he['Config hash'].value);
|
||||
check('H/no-green-plane-anywhere', lamps(STUB_WITH_EVERYTHING).filter(function(s, i) {
|
||||
return s === 'good' && ['Traffic plane', 'Traffic verdict', 'Config hash']
|
||||
.indexOf(page.statusReadout(STUB_WITH_EVERYTHING)[i].label) !== -1;
|
||||
}).length === 0,
|
||||
'no engine-side row may be green while daemon_answered is false');
|
||||
// Control for the three above: the SAME values from a daemon that did answer are
|
||||
// rendered in full. Without this, "dropped" would also pass on a page that
|
||||
// dropped them unconditionally.
|
||||
var ANSWERED_WITH_EVERYTHING = Object.assign({}, STUB_WITH_EVERYTHING,
|
||||
{ daemon_answered: true });
|
||||
var ha = readout(ANSWERED_WITH_EVERYTHING);
|
||||
check('H/control-plane-shown', ha['Traffic plane'].state === 'good');
|
||||
check('H/control-verdict-shown', ha['Traffic verdict'].state === 'good');
|
||||
check('H/control-hash-shown', ha['Config hash'].value === 'cafebabe');
|
||||
|
||||
// A non-boolean daemon_answered is not a verdict and must not be coerced: it
|
||||
// falls through to the fallback, which here proves the daemon live by itself.
|
||||
var WEIRD_FLAG = Object.assign({}, ENGINE_UP, { daemon_answered: 'true' });
|
||||
check('H/non-boolean-not-coerced', page.daemonState(WEIRD_FLAG) === 'up',
|
||||
'a string is not the field; the plane fallback must decide instead');
|
||||
var WEIRD_FLAG_NO_PLANE = { daemon_answered: 'yes' };
|
||||
check('H/non-boolean-alone-is-unknown',
|
||||
page.daemonState(WEIRD_FLAG_NO_PLANE) === 'unknown',
|
||||
'with nothing else to go on, a non-boolean flag is not an answer');
|
||||
|
||||
// --- I/J. unrecognised plane words ------------------------------------------
|
||||
// Closed positive list, recoverable default — a word this build does not know
|
||||
// lands in unknown, never in the last-listed branch.
|
||||
var FUTURE_PLANE = Object.assign({}, ENGINE_UP, { plane: 'partial' });
|
||||
check('I/daemon-still-up', page.daemonState(FUTURE_PLANE) === 'up',
|
||||
'the daemon SAID it answered; an unknown plane word does not unsay it');
|
||||
check('I/plane-row-unknown', readout(FUTURE_PLANE)['Traffic plane'].state === 'unknown');
|
||||
var OLD_FUTURE_PLANE = Object.assign({}, OLD_DAEMON_UP, { plane: 'partial' });
|
||||
check('J/fallback-list-is-closed', page.daemonState(OLD_FUTURE_PLANE) === 'unknown',
|
||||
'with no contract field, an unrecognised plane value proves nothing');
|
||||
check('J/plane-row-unknown', readout(OLD_FUTURE_PLANE)['Traffic plane'].state === 'unknown');
|
||||
|
||||
// --- K. THE CONFIGURATION CANNOT BE READ ------------------------------------
|
||||
// A live daemon with config_readable:false. enabled/kill_switch/panel_port are
|
||||
// zero values; rendering them as readings is the inverted lie the panel already
|
||||
// fixed on its side (planeState.ts protectionState / killSwitchReadout).
|
||||
show('K. daemon alive, CONFIGURATION UNREADABLE', CONFIG_BAD);
|
||||
check('K/daemon-up', page.daemonState(CONFIG_BAD) === 'up');
|
||||
check('K/config-failed', page.configState(CONFIG_BAD) === 'failed');
|
||||
var k = readout(CONFIG_BAD);
|
||||
check('K/config-row-red', k['Configuration'].state === 'bad',
|
||||
'got ' + k['Configuration'].state);
|
||||
check('K/config-row-carries-the-error',
|
||||
k['Configuration'].value.indexOf('no space left on device') !== -1,
|
||||
'the daemon said WHY; dropping it makes the operator guess');
|
||||
check('K/config-row-says-dont-switch-off', /do not turn anything off/i.test(k['Configuration'].value),
|
||||
'the instinct here is to switch things off, and that is the one action that ' +
|
||||
'makes it worse');
|
||||
check('K/enabled-not-called-disabled', k['Service enabled'].state === 'unknown',
|
||||
'enabled=false with config_readable=false is a zero value, not the owner\'s ' +
|
||||
'choice; got ' + k['Service enabled'].state + ' / ' + k['Service enabled'].value);
|
||||
check('K/enabled-says-not-known', /not known/.test(k['Service enabled'].value));
|
||||
check('K/kill-switch-not-green', k['Kill-switch'].state === 'unknown',
|
||||
'kill_switch:"" must not normalise to a green "fail-closed"; got ' +
|
||||
k['Kill-switch'].state + ' / ' + k['Kill-switch'].value);
|
||||
check('K/kill-switch-says-not-known', /not known/.test(k['Kill-switch'].value));
|
||||
check('K/latch-lamp-out', k['Service latch'].state === 'unknown',
|
||||
'the latch is readable but the comparison against globals.enabled is not');
|
||||
check('K/latch-still-states-the-latch', /raised/.test(k['Service latch'].value),
|
||||
'the fact survives even though the verdict does not');
|
||||
check('K/plane-red', k['Traffic plane'].state === 'bad',
|
||||
'the holding plane is what is installed, and it is blocking');
|
||||
check('K/panel-port-unknown', page.panelTarget(CONFIG_BAD).known === false,
|
||||
'panel_port:0 is a placeholder, not a port');
|
||||
check('K/panel-port-falls-back-to-default', page.panelTarget(CONFIG_BAD).port === 8088);
|
||||
check('K/panel-port-row-says-so', /could not be read/.test(k['Panel port'].value),
|
||||
'got ' + k['Panel port'].value);
|
||||
|
||||
// The same fixture WITHOUT a table loaded: "not loaded" may only be the calm
|
||||
// amber when the service is KNOWN to be off. With no readable configuration it
|
||||
// is not known, so the alarming lamp is the correct one.
|
||||
var CONFIG_BAD_NO_TABLE = Object.assign({}, CONFIG_BAD, { table: false });
|
||||
check('K/table-absent-is-red',
|
||||
readout(CONFIG_BAD_NO_TABLE)['nft table'].state === 'bad',
|
||||
'enabled=false is a placeholder here and must not soften an absent firewall ' +
|
||||
'table to amber');
|
||||
// Control for the line above: with a READABLE configuration that says disabled,
|
||||
// the same absent table IS the calm amber. Without this, "red" proves nothing.
|
||||
var DISABLED_NO_TABLE = Object.assign({}, ENGINE_UP,
|
||||
{ enabled: false, table: false, plane: 'none', kill_switch: 'open' });
|
||||
check('K/table-absent-is-amber-when-really-disabled',
|
||||
readout(DISABLED_NO_TABLE)['nft table'].state === 'warn',
|
||||
'a service the owner switched off has no table on purpose');
|
||||
|
||||
// A plane:"none" whose kill-switch setting is unknown must not carry the
|
||||
// "traffic reaches the WAN unprotected" claim — that sentence is earned by a
|
||||
// KNOWN fail-closed setting.
|
||||
var CONFIG_BAD_NO_PLANE = Object.assign({}, CONFIG_BAD, { plane: 'none' });
|
||||
check('K/none-plane-claims-nothing-about-protection',
|
||||
!/unprotected/.test(readout(CONFIG_BAD_NO_PLANE)['Traffic plane'].value),
|
||||
'with an unreadable configuration the kill-switch setting is not known, so ' +
|
||||
'"unprotected" is a claim this page cannot make');
|
||||
// Control: with a readable configuration that says closed, the claim IS made.
|
||||
var CLOSED_NO_PLANE = Object.assign({}, ENGINE_UP,
|
||||
{ plane: 'none', kill_switch: 'closed' });
|
||||
check('K/none-plane-does-claim-it-when-known',
|
||||
/unprotected/.test(readout(CLOSED_NO_PLANE)['Traffic plane'].value) &&
|
||||
readout(CLOSED_NO_PLANE)['Traffic plane'].state === 'bad',
|
||||
'a known fail-closed setting with nothing installed IS the dangerous state');
|
||||
|
||||
// --- L. the launcher: a configured port is not a listening one ---------------
|
||||
// shaterd starts the panel server in a goroutine and only LOGS a bind failure
|
||||
// (cmd/shaterd/main.go: "panel server unavailable (daemon continues)"), and
|
||||
// SHATER_PANEL_ADDR can disable it outright while apply.Status keeps reporting a
|
||||
// port. Nothing on the wire says the panel is listening, so nothing on this page
|
||||
// may say it either.
|
||||
check('L/custom-port-used', page.panelTarget(CUSTOM_PORT).port === 9090 &&
|
||||
page.panelTarget(CUSTOM_PORT).known === true);
|
||||
check('L/no-answer-falls-back', page.panelTarget(NO_ANSWER).port === 8088 &&
|
||||
page.panelTarget(NO_ANSWER).known === false);
|
||||
check('L/garbage-falls-back', page.panelTarget(null).port === 8088);
|
||||
|
||||
var hintKnown = page.panelHint('router', page.panelTarget(CUSTOM_PORT));
|
||||
process.stdout.write('\n=== L. launcher hint / tooltip ===\n');
|
||||
process.stdout.write(' hint(known) ' + hintKnown + '\n');
|
||||
check('L/hint-names-the-url', hintKnown.indexOf('http://router:9090/') !== -1,
|
||||
'got ' + hintKnown);
|
||||
check('L/hint-marks-the-port-unchecked', /not a checked one/.test(hintKnown),
|
||||
'the hint must not read as "the panel is there": ' + hintKnown);
|
||||
check('L/hint-has-no-placeholders-left', hintKnown.indexOf('%') === -1,
|
||||
'unsubstituted placeholder in the hint: ' + hintKnown);
|
||||
|
||||
var hintUnknown = page.panelHint('router', page.panelTarget(CONFIG_BAD));
|
||||
process.stdout.write(' hint(unknown) ' + hintUnknown + '\n');
|
||||
check('L/hint-admits-the-guess', /built-in default/.test(hintUnknown),
|
||||
'a port nobody reported must be named as this page\'s guess: ' + hintUnknown);
|
||||
check('L/hint-unknown-has-no-placeholders-left', hintUnknown.indexOf('%') === -1,
|
||||
'unsubstituted placeholder in the hint: ' + hintUnknown);
|
||||
|
||||
// Every tooltip, on every state, carries the port caveat. Closed list of the
|
||||
// states, so a new branch added without the caveat fails here.
|
||||
[['up', ENGINE_UP], ['engine-down', ENGINE_DOWN], ['daemon-down', DAEMON_DOWN],
|
||||
['config-bad', CONFIG_BAD], ['no-answer', NO_ANSWER]].forEach(function(p) {
|
||||
var t = page.panelTitle(p[1]);
|
||||
process.stdout.write(' title(' + p[0] + ') ' + t + '\n');
|
||||
check('L/title-caveat:' + p[0],
|
||||
/configured one/.test(t) || /built-in default/.test(t),
|
||||
'the tooltip promises a panel without saying the port is unverified: ' + t);
|
||||
check('L/title-not-empty:' + p[0], typeof t === 'string' && t.length > 20);
|
||||
});
|
||||
|
||||
// --- Report -----------------------------------------------------------------
|
||||
|
||||
process.stdout.write('\n');
|
||||
if (failures.length) {
|
||||
process.stdout.write('FAIL (' + failures.length + ')\n');
|
||||
failures.forEach(function(f) { process.stdout.write(' - ' + f + '\n'); });
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write('OK — all cases distinguished\n');
|
||||
@@ -13,8 +13,13 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shater-core
|
||||
PKG_VERSION:=0.2.0
|
||||
PKG_RELEASE:=2
|
||||
|
||||
# Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
|
||||
# SHATER_PKG_RELEASE in the SDK build env (see openwrt/shaterd/Makefile for the
|
||||
# full rationale — bug B4: v0.2.2…v0.2.6 all shipped as 0.2.0-r3). The literals
|
||||
# are the manual/offline fallback only.
|
||||
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
|
||||
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=GPL-2.0-or-later
|
||||
@@ -35,6 +40,10 @@ define Package/shater-core
|
||||
# shaterd : the daemon our init supervises (`shaterd run`)
|
||||
# kmod-nft-tproxy : kernel TPROXY (shaterd emits the `inet shater` rules)
|
||||
# kmod-nft-socket : socket match used by the tproxy divert chain
|
||||
# kmod-tun : /dev/net/tun — the daemon opens the `shater-l3` TUN
|
||||
# for L3 ingress (globals.l3_tunnel); usually built-in
|
||||
# on stock images, but a slimmed image without it would
|
||||
# make the option fail with a cryptic open() error.
|
||||
# ip-full : `ip rule`/`ip route`/rt_tables for policy routing
|
||||
# nftables-json : shaterd shells out to `nft`, and netplane/stats.go
|
||||
# parses `nft -j list ...` — the JSON output only exists
|
||||
@@ -44,7 +53,7 @@ define Package/shater-core
|
||||
# ca-bundle : the daemon is CGO_ENABLED=0, so crypto/x509 has no
|
||||
# host cert fallback — without /etc/ssl/certs every
|
||||
# HTTPS subscription / .srs ruleset fetch fails.
|
||||
DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full +nftables-json +ca-bundle
|
||||
DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
@@ -75,6 +84,10 @@ define Package/shater-core/install
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/etc/init.d/shater $(1)/etc/init.d/shater
|
||||
$(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron
|
||||
# START=21 one-shot that loads the persisted fail-closed plane before fw4's
|
||||
# `lan -> wan ACCEPT` can be the only thing on the box (the main init is
|
||||
# START=99, i.e. seconds of plaintext forwarding on every boot).
|
||||
$(INSTALL_BIN) ./files/etc/init.d/shater-armor $(1)/etc/init.d/shater-armor
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
|
||||
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
|
||||
@@ -85,8 +98,33 @@ define Package/shater-core/install
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/etc/config/shater $(1)/etc/config/shater
|
||||
|
||||
# THE SAME FILE AGAIN, READ-ONLY, AS DOCUMENTATION. /etc/config/shater is 271
|
||||
# lines of which 248 are comment, and on the router it is the only description
|
||||
# of the schema there is (PORTING.md does not ship). Being a conffile keeps an
|
||||
# upgrade from replacing it, but it does NOT keep the daemon from rewriting it:
|
||||
# the config write path replaces the whole package (`uci delete shater` + `uci
|
||||
# import`), which drops every comment — and it runs without an operator, from
|
||||
# the panel, the 6-hourly subscription refresh and the 25-second profile
|
||||
# watcher. So the annotated original is installed a second time where nothing
|
||||
# rewrites it, and the header of the live file points at it.
|
||||
#
|
||||
# INSTALL_DATA, not INSTALL_CONF: this copy is package metadata (refreshed by
|
||||
# every upgrade so it documents the build actually installed), not user config.
|
||||
$(INSTALL_DIR) $(1)/usr/share/shater
|
||||
$(INSTALL_DATA) ./files/etc/config/shater $(1)/usr/share/shater/config.sample
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_BIN) ./files/etc/uci-defaults/30_shater-core $(1)/etc/uci-defaults/30_shater-core
|
||||
|
||||
# sysupgrade's "keep settings" walks /lib/upgrade/keep.d/*, and without this the
|
||||
# node inventory in /etc/shater/subs does NOT survive a flash: the restored box
|
||||
# has its rules and its groups and no nodes for them to point at, and the only
|
||||
# repair is `sub update`, which needs the internet the tunnel was going to
|
||||
# provide. Package metadata, not user config, so INSTALL_DATA and not
|
||||
# INSTALL_CONF. (/etc/config/shater needs no entry — it is a conffile and
|
||||
# sysupgrade already keeps it that way.)
|
||||
$(INSTALL_DIR) $(1)/lib/upgrade/keep.d
|
||||
$(INSTALL_DATA) ./files/lib/upgrade/keep.d/shater-core $(1)/lib/upgrade/keep.d/shater-core
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,shater-core))
|
||||
|
||||
@@ -12,8 +12,30 @@
|
||||
# from this file. There is no separate xray/dnsmasq and no generated run.json.
|
||||
#
|
||||
# Full schema: docs-shater/PORTING.md (PART A "uci.go — /etc/config/shater
|
||||
# schema") and shater/model. This file is installed as a conffile — your edits
|
||||
# survive package upgrades.
|
||||
# schema") and shater/model.
|
||||
#
|
||||
# WHAT SURVIVES WHAT. This file is installed as a conffile, so `apk upgrade
|
||||
# shater-core` will not replace it: your VALUES survive a package upgrade.
|
||||
#
|
||||
# These COMMENTS do not survive the first write, and that write does not need
|
||||
# you to make it. The daemon and the panel persist the whole package in one go
|
||||
# (`uci delete shater` + `uci import`), and a package rebuilt by `uci import`
|
||||
# keeps no comments and no hand-made blank lines; the sections come back in the
|
||||
# daemon's own order. Three things write here with nobody at the keyboard: a save
|
||||
# in the admin panel, a subscription refresh (cron, every 6 h) and the profile
|
||||
# watcher switching profiles (it looks every 25 s). So expect the annotations
|
||||
# below to be gone shortly after the box is first configured.
|
||||
#
|
||||
# THE ANNOTATED COPY IS KEPT: /usr/share/shater/config.sample is this same file,
|
||||
# installed by the package where nothing rewrites it. Read it there (`cat
|
||||
# /usr/share/shater/config.sample`) and copy the fragment you need. It is
|
||||
# refreshed by each package upgrade, so it always documents the build you have.
|
||||
#
|
||||
# BEFORE THE FIRST CHANGE, the previous file is copied to
|
||||
# /etc/shater/config.pre-v<schema>.bak — once per schema version, never
|
||||
# overwritten afterwards. That copy is the one taken at the transition (a schema
|
||||
# migration, or the first save on a newly installed build); it is not a rolling
|
||||
# backup, and it is deliberately not carried across a sysupgrade.
|
||||
#
|
||||
|
||||
config globals 'globals'
|
||||
@@ -23,17 +45,84 @@ config globals 'globals'
|
||||
option kill_switch 'closed'
|
||||
# There is no dns_mode option: routing is decided by in-engine rule-sets and
|
||||
# fake-IP is a resolver type (`config resolver` with type=fakeip + pool).
|
||||
#
|
||||
# Force ALL LAN plaintext DNS (:53) into the engine, INCLUDING queries the
|
||||
# client sends to the router itself (the address DHCP hands out). ON by
|
||||
# default: with it off, a client using the router as its resolver is answered
|
||||
# by dnsmasq and forwarded to the ISP in the clear — no blocklists, no
|
||||
# per-device DNS rules, no resolver detour — while a client that hard-codes
|
||||
# 8.8.8.8 IS intercepted. The obedient client leaked; the evader did not.
|
||||
#
|
||||
# Set to '0' to opt out (dnsmasq answers router-addressed :53 again). Your
|
||||
# explicit value is never overwritten: this file is a conffile, and the daemon
|
||||
# always writes the option back as '1'/'0'.
|
||||
#
|
||||
# .lan and the private reverse (PTR) zones keep working: with at least one
|
||||
# `config resolver` present the engine gets a synthetic server pointed at
|
||||
# dnsmasq on 127.0.0.1:53 plus a rule that sends those suffixes to it; with no
|
||||
# resolver at all the engine falls back to the system resolver, which is
|
||||
# dnsmasq too. If you changed dnsmasq's domain away from `lan`, add a
|
||||
# `config dns_rule` for it (only `lan` + RFC6303 reverse zones are built in).
|
||||
#
|
||||
# While the engine is DOWN the LAN is NOT left without DNS: the fail-closed
|
||||
# holding plane hooks `forward` only, so dnsmasq still answers router-addressed
|
||||
# :53 — unfiltered and in the clear, the documented trade-off (blocking it
|
||||
# would also cut the daemon's own name resolution and its chance to recover).
|
||||
# Queries aimed at an EXTERNAL resolver are dropped with the rest of the LAN's
|
||||
# forwarded traffic.
|
||||
option dns_intercept '1'
|
||||
# Carry LAN ping through the tunnel. ON by default, and the alternative is
|
||||
# why: without it a ping is decided by `untunnelable` below, whose rungs are
|
||||
# "drop it" (block, the default) or "let it out of the WAN interface with the
|
||||
# client's real IP on it" (icmp/direct). There was no setting in which ping
|
||||
# both worked and stayed inside the tunnel. With this on, the engine opens a
|
||||
# TUN, LAN ICMP is routed into it, and an outbound that speaks layer 3
|
||||
# (WireGuard/AmneziaWG, or a direct route) carries the echo for real. An
|
||||
# outbound that does not (vless/trojan/shadowsocks) makes the ping DROP —
|
||||
# honestly: no reply is forged, ping reports loss. So a ping that used to
|
||||
# "work" through such a node was a ping that was leaking.
|
||||
#
|
||||
# It costs a permanent TUN device plus the gVisor netstack behind it, about
|
||||
# 2 MB of RSS for as long as the daemon runs.
|
||||
#
|
||||
# Set to '0' to opt out — worth it on a 32/64 MB router, or to bisect whether
|
||||
# the L3 ingress is what broke something. `shaterd apply` will tell you what
|
||||
# the off state costs. Your explicit value is never overwritten: this file is
|
||||
# a conffile and the daemon always writes the option back as '1'/'0'.
|
||||
#
|
||||
# NOTE the interaction: with this ON, `untunnelable` no longer governs ping at
|
||||
# all (the L3 route decision happens before the firewall chain its verdicts
|
||||
# live in). It still governs ESP/AH/GRE/IGMP/SCTP, which no tunnel of ours can
|
||||
# carry. `untunnelable 'icmp'` in particular stops meaning "block plus working
|
||||
# ping" and is reported as such.
|
||||
option l3_tunnel '1'
|
||||
option ipv6 '1'
|
||||
# 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'
|
||||
@@ -42,7 +131,6 @@ config inbound
|
||||
option tproxy_port '12345'
|
||||
option tcp '1'
|
||||
option udp '1'
|
||||
option sniff '1'
|
||||
|
||||
# --- Commented examples (copy, uncomment, adjust, then enable globals) -------
|
||||
#
|
||||
@@ -62,7 +150,29 @@ config inbound
|
||||
# list node 'my-node'
|
||||
#
|
||||
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
|
||||
# Match on src / dst_domain / dst_ruleset / dst_ip / dst_port / proto.
|
||||
# Match on src / dst_ruleset / dst_port / proto. A rule with NO matcher at all is
|
||||
# the default route for everything that reached it.
|
||||
#
|
||||
# WHERE the traffic is going is named ONLY by dst_ruleset — one or more
|
||||
# `config ruleset` names; the rule matches when ANY of them matches. There is no
|
||||
# inline domain or address list on a rule (`dst_domain`/`dst_ip` were removed in
|
||||
# schema v2): a destination list is written once as a ruleset, compiled into a
|
||||
# .srs and shared by every rule that references it. `shaterd migrate` converts
|
||||
# older configs automatically, creating a `rule-<name>` ruleset per rule.
|
||||
#config ruleset
|
||||
# option name 'blocked-video'
|
||||
# option type 'domain'
|
||||
# option source 'inline'
|
||||
# list entry 'youtube.com'
|
||||
# list entry 'suffix:googlevideo.com'
|
||||
#
|
||||
#config rule
|
||||
# option name 'video-via-main'
|
||||
# option enabled '1'
|
||||
# option order '50'
|
||||
# list dst_ruleset 'blocked-video'
|
||||
# option target 'group:main'
|
||||
#
|
||||
#config rule
|
||||
# option name 'all-via-main'
|
||||
# option enabled '1'
|
||||
@@ -76,18 +186,24 @@ config inbound
|
||||
# record - tls_record_fragment (alternative; mutually exclusive w/ fragment)
|
||||
# spoof - tls_spoof (inject a decoy ClientHello; needs root NET_RAW/NET_ADMIN)
|
||||
# Point a rule's target at it for DPI-blocked-but-not-IP-blocked domains — direct
|
||||
# and fragmented, no exit node, no extra binary. (The stronger external `byedpi`
|
||||
# preset is Phase-2b.)
|
||||
# and fragmented, no exit node, no extra binary. These three are the WHOLE set;
|
||||
# the external desync egress that once stood beside them is removed (D29), and a
|
||||
# `type 'byedpi'` egress left over from an older build is blocked, not routed.
|
||||
#config egress
|
||||
# option name 'frag'
|
||||
# option type 'direct'
|
||||
# option dpi 'fragment'
|
||||
#
|
||||
#config ruleset
|
||||
# option name 'youtube'
|
||||
# option source 'geosite'
|
||||
# list category 'youtube'
|
||||
#
|
||||
#config rule
|
||||
# option name 'youtube-fragment'
|
||||
# option enabled '1'
|
||||
# option order '50'
|
||||
# list dst_domain 'geosite:youtube'
|
||||
# list dst_ruleset 'youtube'
|
||||
# option target 'egress:frag'
|
||||
#
|
||||
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
|
||||
@@ -100,8 +216,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).
|
||||
#
|
||||
|
||||
@@ -33,6 +33,30 @@
|
||||
# be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
|
||||
# reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
|
||||
# background actor may resurrect interception behind a stopped daemon.
|
||||
# * BEING REPLACED IS NOT BEING SWITCHED OFF. `restart` and `reload` (which is
|
||||
# stop+start, i.e. every LuCI Save & Apply) both run through `stop`, and the
|
||||
# daemon's SIGTERM teardown removes the fail-closed table unconditionally — it
|
||||
# does not consult kill_switch at all. Between that teardown and the
|
||||
# successor's first apply the init GUARANTEES a gap: it waits for the old
|
||||
# process to exit (shater_wait_stopped), then runs `shaterd migrate`, then
|
||||
# starts a daemon that still has to build an engine. So a restart is announced
|
||||
# with RESTART_FLAG, which tells the outgoing daemon to leave the fail-closed
|
||||
# holding plane STANDING — apply.TeardownExiting swaps it in with one nft
|
||||
# transaction and then skips the delete, so the table is never absent, not even
|
||||
# for the 80-90 ms the old arm-after-teardown order measured. A real `stop`
|
||||
# raises no flag and therefore still means what it says.
|
||||
# (A package UPGRADE does not come through here at all on apk v3: shater-core's
|
||||
# script table is post-install / pre-deinstall / post-upgrade, with no
|
||||
# pre-upgrade, so default_prerm — and its `stop` — runs only on REMOVAL.)
|
||||
# * The FAIL-CLOSED PLANE MUST ALSO EXIST BEFORE THIS SCRIPT DOES. START=99 is
|
||||
# after fw4 (19) and netifd (20), so at every boot the LAN forwards to the WAN
|
||||
# in the clear for as long as it takes procd to decompress the daemon off
|
||||
# flash and get an engine up. /etc/init.d/shater-armor (START=21) loads
|
||||
# BOOT_ARMOR — a copy of the holding plane the daemon persists on every apply
|
||||
# — to close that window. This script owns the DISARM half, and it owns it
|
||||
# with a CLOSED LIST: an operator's `stop`, or a removal, and nothing else.
|
||||
# Powering the box down must not — `shutdown` reaches stop_service too, and it
|
||||
# is not a person switching the product off (see shater_stop_disarms).
|
||||
# * The engine must never be permanently abandoned while interception stands:
|
||||
# respawn retries are infinite (procd never gives up); a sustained-dead
|
||||
# daemon is additionally escalated by the shater-cron watchdog.
|
||||
@@ -47,6 +71,168 @@ PROG=/usr/bin/shaterd
|
||||
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
|
||||
# nothing reconciles before this init has run at boot.
|
||||
ACTIVE_FLAG=/var/run/shater.active
|
||||
# Written by `shaterd run`; the single-owner token this init waits on so a
|
||||
# restart never overlaps a new data plane with the previous one's teardown.
|
||||
PIDFILE=/var/run/shaterd.pid
|
||||
# Raised around a restart/reload, read by the OUTGOING `shaterd run` at SIGTERM:
|
||||
# present => "you are being replaced, leave the fail-closed plane standing";
|
||||
# absent => "you are being switched off, take everything down". tmpfs, so a
|
||||
# power cut can never make the next boot look like a restart.
|
||||
RESTART_FLAG=/var/run/shater.restarting
|
||||
# The persisted fail-closed holding plane. Written by the daemon on every apply,
|
||||
# loaded by /etc/init.d/shater-armor at boot. Its PRESENCE is the arm token, so
|
||||
# removing it here is how a deliberate stop stops the next boot from blocking.
|
||||
BOOT_ARMOR=/etc/shater/boot.nft
|
||||
# Seconds `start` will wait for a predecessor to finish its teardown. Must be
|
||||
# >= term_timeout below (procd's hard cap on a predecessor's life after SIGTERM)
|
||||
# so we never give up while procd is still letting it shut down cleanly.
|
||||
STOP_WAIT_SECS=40
|
||||
|
||||
# WHICH ACTION rc.common was invoked with, frozen at source time.
|
||||
#
|
||||
# rc.common does, in this order:
|
||||
# initscript=$1; action=${2:-help}; shift 2; ...; . "$initscript"; $action "$@"
|
||||
# so `action` is ALREADY assigned when this file is sourced, and every action then
|
||||
# runs as a function in THAT SAME shell. MEASURED on the target (ImmortalWrt
|
||||
# 25.12.1 r37978) with a throwaway probe init script, not read off documentation:
|
||||
#
|
||||
# /etc/init.d/X restart -> stop_service action=[restart], start_service [restart]
|
||||
# /etc/init.d/X stop -> stop_service action=[stop]
|
||||
# /etc/init.d/X reload -> reload_service action=[reload]
|
||||
# `reboot` -> stop_service action=[SHUTDOWN] <-- see below
|
||||
# the boot after it -> start_service action=[boot]
|
||||
#
|
||||
# A previous probe reported this variable EMPTY and the emptiness was written up as
|
||||
# the defect. It was the probe: `sh -x /etc/init.d/shater restart` bypasses the
|
||||
# `#!/bin/sh /etc/rc.common` shebang, so rc.common never runs, never assigns
|
||||
# `action`, and the variable reads empty no matter what this file does.
|
||||
#
|
||||
# Frozen into our own variable because `action` is a short, generic name that other
|
||||
# framework helpers also use as a local; a snapshot taken before any function runs
|
||||
# cannot be shadowed later.
|
||||
SHATER_RC_ACTION="$action"
|
||||
|
||||
# --- what an action MEANS --------------------------------------------------
|
||||
#
|
||||
# THE BUG THESE TWO PREDICATES REPLACE (v0.2.17, measured on the live router).
|
||||
# The old stop_service was `case $action in restart|reload) keep;; *) DISARM;; esac`
|
||||
# — an open default that swept up every action nobody had enumerated. `reboot` is
|
||||
# one of them: procd runs the K-links with the action `shutdown`, so the shutdown
|
||||
# path deleted the arm token on the way down and the next boot had nothing to load.
|
||||
# The mechanism destroyed itself at exactly the moment it exists for. Instrument
|
||||
# reading from the router, one minute apart across a reboot:
|
||||
#
|
||||
# 13:28 /etc/shater/boot.nft present
|
||||
# ---- reboot (stop_service action=[shutdown] -> old `*` branch -> rm)
|
||||
# 18s at_S22: NO_TABLE armor_file=NO_FILE
|
||||
#
|
||||
# So both lists below are POSITIVE and CLOSED. An action nobody thought about —
|
||||
# `shutdown` above all, but also whatever a future procd invents — falls through
|
||||
# both and changes nothing. The default now fails in the recoverable direction: at
|
||||
# worst a boot arms when it need not have, which costs the second before the daemon
|
||||
# applies and is still gated by shater-armor's own four state refusals. The old
|
||||
# default failed in the direction of the plaintext window the feature was built to
|
||||
# close.
|
||||
#
|
||||
# They are predicates rather than an inline `case` so the test gate can execute the
|
||||
# real thing: it sources THIS FILE in /bin/sh and calls them with every action procd
|
||||
# actually uses (shater/cmd/shaterd/initscript_test.go). A comment claiming
|
||||
# `shutdown` is handled is what shipped last time.
|
||||
|
||||
# True only for the ONE action that means "the operator switched the product off".
|
||||
# Deliberately not `shutdown`: powering a router down is not turning a feature off.
|
||||
#
|
||||
# NOT sufficient on its own — see shater_stop_disarms. `stop` is also how the
|
||||
# package manager's plumbing reaches us, and a package manager is not a person.
|
||||
shater_action_disarms() {
|
||||
case "$1" in
|
||||
stop) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Is a package manager in the middle of a transaction RIGHT NOW?
|
||||
#
|
||||
# This is a state, read at the moment the decision is made, exactly like
|
||||
# shater-armor's four refusals — not a record of an event. The same question is
|
||||
# already asked (for the same reason: prerm/postinst plumbing is not a user
|
||||
# action) by the detached bring-up in /etc/uci-defaults/30_shater-core.
|
||||
shater_pkg_transaction() {
|
||||
pidof apk >/dev/null 2>&1 && return 0
|
||||
pidof opkg >/dev/null 2>&1 && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Is the main service still enabled at boot? Same glob, and for the same reason,
|
||||
# as shater-armor's own check: `/etc/init.d/shater enabled` would source procd.sh
|
||||
# and take a blocking flock, which is not something to do from inside a package
|
||||
# manager's transaction.
|
||||
shater_rc_enabled() {
|
||||
local f
|
||||
for f in /etc/rc.d/S[0-9][0-9]shater; do
|
||||
[ -e "$f" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# THE ACTUAL DISARM DECISION.
|
||||
# $1 = action
|
||||
# $2 = 1 when a package transaction is in flight
|
||||
# $3 = 1 when the service is still enabled in rc.d
|
||||
# All three are passed in rather than read inside, so the gate can drive every
|
||||
# combination without a package manager or an /etc/rc.d.
|
||||
#
|
||||
# WHY IT IS NOT JUST THE ACTION. base-files' default_prerm runs, in this order:
|
||||
#
|
||||
# if [ "$PKG_UPGRADE" != "1" ]; then "$i" disable; fi
|
||||
# "$i" stop
|
||||
#
|
||||
# so a package manager reaches stop_service wearing the operator's clothes. Two
|
||||
# different intentions arrive as the same action, and the difference between them
|
||||
# is readable at the moment of the decision:
|
||||
#
|
||||
# REMOVAL — prerm has ALREADY run `disable`, so S99shater is gone. The product
|
||||
# is going away; the armor goes with it. (It is belt-and-braces even
|
||||
# so: shater-armor refuses to arm without that symlink, and the whole
|
||||
# init script is about to be deleted anyway.)
|
||||
# REPLACED — the service is still enabled, so something intends to bring it
|
||||
# back. That is not an operator switching anything off, and deleting
|
||||
# the armor here would leave the next boot unprotected. "The next
|
||||
# apply will rewrite it" is not an answer: the armor exists precisely
|
||||
# to cover a reboot, and a reboot between an update and the first
|
||||
# apply is how this product is deployed.
|
||||
#
|
||||
# MEASURED, because the paragraph above is about a path I got wrong once already.
|
||||
# On THIS target (apk-tools 3.0.5, ImmortalWrt 25.12.1) shater-core's script table
|
||||
# is post-install / pre-deinstall / post-upgrade, with NO pre-upgrade — so an apk
|
||||
# UPGRADE never executes default_prerm and never calls `stop` at all. Verified with
|
||||
# a real `apk fix --reinstall shater-core` while sampling the armor file: 245 625
|
||||
# samples, zero disappearances, even with this guard mutated off. The upgrade half
|
||||
# of this predicate is therefore defence-in-depth for a shape that is one
|
||||
# `pre-upgrade` script (or a returning opkg lane) away, NOT a fix for an observed
|
||||
# failure. The removal half is live today.
|
||||
shater_stop_disarms() {
|
||||
shater_action_disarms "$1" || return 1
|
||||
# No package manager involved => a person typed it. The escape hatch must work.
|
||||
[ "$2" = "1" ] || return 0
|
||||
# A package transaction that has NOT disabled the service is replacing it.
|
||||
[ "$3" = "1" ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# True when a successor is coming, so the outgoing daemon should leave the
|
||||
# fail-closed holding plane standing instead of removing it.
|
||||
#
|
||||
# `shutdown` is deliberately NOT a handoff either: nothing is coming, and the
|
||||
# kernel that would hold the plane is going away with it. Leaving the flag down
|
||||
# there also keeps the marker's meaning exact — it says "you are being replaced",
|
||||
# and at shutdown nothing is.
|
||||
shater_action_handoff() {
|
||||
case "$1" in
|
||||
restart|reload) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
@@ -66,6 +252,209 @@ _slog() {
|
||||
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
|
||||
}
|
||||
|
||||
# A line the operator gets EVEN WITH globals.log_syslog=0, without going behind
|
||||
# that setting's back.
|
||||
#
|
||||
# log_syslog is a statement about ONE destination: the syslog stream (see _slog
|
||||
# above — the same toggle silences the daemon's own stderr->logread fan-out).
|
||||
# Honouring it by staying silent everywhere turns "keep syslog quiet" into "never
|
||||
# tell me the config could not be brought forward", which is not what it says and
|
||||
# not what anybody means by it. So the refusal goes somewhere else instead:
|
||||
#
|
||||
# * THIS SCRIPT'S OWN STDERR, unconditionally. That is not the syslog stream; it
|
||||
# is the reply to whoever invoked the script. Typed by hand it lands on the
|
||||
# operator's terminal at the moment they are looking at it; run from
|
||||
# 30_shater-core inside `apk add` / `opkg install` it lands in the package
|
||||
# manager's output, which is the one screen an installing operator does read.
|
||||
# At boot it goes to procd's stderr (console) — not durable, hence the file.
|
||||
# * /etc/shater/migrate-failed, on flash, written on failure and REMOVED on the
|
||||
# first success. That is the durable half: it survives the reboot nobody
|
||||
# watched, `cat` reads it, and its absence is the honest all-clear. Written
|
||||
# AFTER the stderr line on purpose — a full /overlay is one of the named
|
||||
# causes of the failure it is reporting, so it must never be the only channel.
|
||||
# * syslog too, but only when log_syslog allows it — that channel keeps
|
||||
# obeying the operator exactly as before.
|
||||
#
|
||||
# One call, one text, three destinations, so the wording cannot drift between
|
||||
# them. Failures only: _shout is not a status line.
|
||||
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
|
||||
_shout() {
|
||||
echo "shater: $*" >&2
|
||||
_slog -p daemon.err "$*"
|
||||
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
|
||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
|
||||
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
|
||||
}
|
||||
|
||||
# --- `shaterd migrate`: which of the four things happened --------------------
|
||||
#
|
||||
# The schema version on disk, as an integer. 0 for "absent" and 0 for anything
|
||||
# non-numeric, DELIBERATELY the same two answers model.readSchemaVersion gives
|
||||
# (`strconv.Atoi` of a garbage value is 0 with the error dropped) — this number
|
||||
# is only ever used to name a version in a message, and a shell that disagreed
|
||||
# with the binary about what v0 means would print a version the binary never saw.
|
||||
shater_schema_version() {
|
||||
local v
|
||||
v=$(uci -q get shater.globals.schema_version) || v=""
|
||||
case "$v" in
|
||||
"") echo 0 ;;
|
||||
*[!0-9]*) echo 0 ;;
|
||||
*) echo "$v" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# shater_migrate_class <rc> <output-of-shaterd-migrate> — prints EXACTLY one of:
|
||||
#
|
||||
# ok the binary reported success (it may or may not have had work)
|
||||
# downgrade REFUSED: the config on disk is NEWER than this build
|
||||
# unreadable /etc/config/shater could not be read at all
|
||||
# failed it failed for a reason this script does not recognise
|
||||
#
|
||||
# A CLOSED POSITIVE LIST, and the last rung is the point of it. Until v0.2.19 all
|
||||
# four of these were reported with ONE sentence, and that sentence described only
|
||||
# the third one: "routing rules that still carry the removed dst_domain/dst_ip
|
||||
# options stay DISABLED until this succeeds. Free space on /overlay and re-run".
|
||||
# On a DOWNGRADE every clause of that is false — nothing is disabled, /overlay is
|
||||
# not the problem, and re-running does not help, because the fix is to put the
|
||||
# newer package back. A confident wrong diagnosis costs more than no diagnosis.
|
||||
#
|
||||
# `downgrade` is recognised from the binary's own words. That is a CONTRACT with
|
||||
# shater/model: both refusals — model.migrateWith's "config schema v%d newer than
|
||||
# this build (v%d); upgrade the package" and model.ErrSchemaTooNew's "config
|
||||
# schema newer than this build" — contain the substring matched below, and
|
||||
# TestMigrateDowngradeSignatureIsAContract (shater/cmd/shaterd) fails if either
|
||||
# stops containing it. If the wording is ever changed anyway, this degrades to
|
||||
# `failed`, which names itself as unrecognised and quotes the binary verbatim —
|
||||
# the recoverable side. It cannot degrade into one of the confident branches.
|
||||
shater_migrate_class() {
|
||||
local rc="$1" out="$2"
|
||||
|
||||
[ "$rc" = "0" ] && { echo ok; return 0; }
|
||||
|
||||
case "$out" in
|
||||
*"newer than this build"*) echo downgrade; return 0 ;;
|
||||
esac
|
||||
|
||||
# Asked LAST, so a refusal we can name is never re-labelled as an I/O problem.
|
||||
# `uci export` fails both when the file is missing and when it does not parse,
|
||||
# which is the same thing from here: nothing can be said about a schema that
|
||||
# cannot be read.
|
||||
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
|
||||
|
||||
echo failed
|
||||
}
|
||||
|
||||
# Run the migration and report it. Called from start_service and mirrored by
|
||||
# /etc/uci-defaults/30_shater-core; see the long note at the call site for why
|
||||
# this never refuses to start.
|
||||
shater_migrate() {
|
||||
local before after out rc class
|
||||
|
||||
before=$(shater_schema_version)
|
||||
out=$("$PROG" migrate 2>&1)
|
||||
rc=$?
|
||||
class=$(shater_migrate_class "$rc" "$out")
|
||||
after=$(shater_schema_version)
|
||||
|
||||
case "$class" in
|
||||
ok)
|
||||
# The all-clear is the ABSENCE of the breadcrumb, so a fixed router stops
|
||||
# claiming to be broken the moment it is fixed.
|
||||
rm -f "$SHATER_MIGRATE_BREADCRUMB"
|
||||
# Nothing to do is not news; obeys log_syslog like every other status line.
|
||||
[ "$before" = "$after" ] && return 0
|
||||
_slog -p daemon.info \
|
||||
"UCI schema migrated: v$before -> v$after. The config as it was at v$before was copied to /etc/shater/config.pre-v$before.bak before the first change."
|
||||
;;
|
||||
downgrade)
|
||||
_shout "UCI schema migration REFUSED — this is a DOWNGRADE, not a broken config. /etc/config/shater carries schema v$after, which is NEWER than this build understands, so nothing was migrated and nothing on disk was changed. Your settings are intact; they are also unchangeable, because the daemon and the panel refuse every config write for the same reason and a save from the panel will fail too. Nothing on this router fixes it: install a shater build that understands schema v$after — the one that ran here before the downgrade (docs-shater/INSTALL.md has the pinned per-version feed). Starting anyway, so the panel stays reachable. '$PROG migrate' said: ${out:-no output}"
|
||||
;;
|
||||
unreadable)
|
||||
_shout "UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so this script cannot even say which schema is on disk. A config that is missing or does not parse is neither migrated nor repaired here. Starting anyway — the daemon will come up on whatever it can parse, which may be nothing, leaving it inert with the panel still reachable. Check /etc/config/shater by hand; an /etc/shater/config.pre-v*.bak copy from an earlier migration may be next to it. '$PROG migrate' said: ${out:-no output}"
|
||||
;;
|
||||
failed)
|
||||
_shout "UCI schema migration FAILED for a reason this script does not recognise; the config on disk is still at schema v$after. Starting anyway: refusing to start would take the admin panel down with it, and the panel is the only way to fix the box. The mundane cause is a full /overlay, where 'uci commit' cannot write — check 'df /overlay' first, then re-run '$PROG migrate' or restart the service.$(
|
||||
[ "$after" = "1" ] && printf ' %s' "While the config stays at v1, routing rules that still carry the removed dst_domain/dst_ip options are held DISABLED by the daemon and reported as such — those rules are not in force."
|
||||
) '$PROG migrate' said: ${out:-no output}"
|
||||
;;
|
||||
*)
|
||||
# shater_migrate_class returns a closed set and every member of it is
|
||||
# handled above, so this is unreachable. It exists to say "this script
|
||||
# disagrees with itself" out loud instead of picking one of the confident
|
||||
# branches and being wrong quietly — which is the exact failure the closed
|
||||
# list replaced.
|
||||
_shout "INTERNAL: '$PROG migrate' produced a result /etc/init.d/shater cannot classify (class='$class', rc=$rc). That is a bug in this script, not a state of the router. Starting anyway. Output was: ${out:-no output}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Announce/withdraw "this daemon is being replaced, not switched off". Read by
|
||||
# `shaterd run` when it receives SIGTERM.
|
||||
shater_mark_restart() {
|
||||
mkdir -p "$(dirname "$RESTART_FLAG")" 2>/dev/null
|
||||
: > "$RESTART_FLAG"
|
||||
}
|
||||
shater_clear_restart() { rm -f "$RESTART_FLAG"; }
|
||||
|
||||
# Remove the persisted boot armor, so the LAN is NOT blocked at the next boot
|
||||
# before the daemon starts. Called from exactly two places, both of which are a
|
||||
# statement about the PRODUCT rather than about this process: an operator typing
|
||||
# `stop`, and a daemon binary that is no longer on the box. In neither case is
|
||||
# anything going to come along and replace the armor with a real data plane, and a
|
||||
# kill switch with nothing behind it is just a brick.
|
||||
#
|
||||
# NOT called on the shutdown path. That is the whole fix — see
|
||||
# shater_action_disarms.
|
||||
shater_disarm_boot() { rm -f "$BOOT_ARMOR"; }
|
||||
|
||||
# Echo the pid of a LIVE `shaterd run`, or fail. The pidfile is written by the
|
||||
# daemon itself and removed only by the daemon that owns it, AFTER its teardown
|
||||
# has completed — so "pidfile names a live process" is precisely "the previous
|
||||
# data plane has not been dismantled yet".
|
||||
shater_daemon_pid() {
|
||||
local pid
|
||||
pid=$(cat "$PIDFILE" 2>/dev/null) || return 1
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
# Block until no predecessor daemon is left, bounded by STOP_WAIT_SECS.
|
||||
#
|
||||
# WHY THIS EXISTS. procd's `stop` is ASYNCHRONOUS: rc.common's `restart` is
|
||||
# literally `stop; start`, and the `service delete` ubus call returns the moment
|
||||
# procd has SENT SIGTERM — not when the instance is gone. `start` therefore
|
||||
# re-adds the instance while the outgoing `shaterd run` is still executing its
|
||||
# honest teardown (engine close, then `nft delete table`, `ip rule`/`ip route`
|
||||
# removal and the per-iface sysctl restore). The result is that `restart` is NOT
|
||||
# equivalent to `stop` + pause + `start`: the new plane is stood up on top of
|
||||
# kernel state the old one has not finished removing, which is what B3 (DNS to
|
||||
# the router's own LAN address dead after a restart, and never recovering) came
|
||||
# out of. Waiting here restores the equivalence, and costs literally nothing when
|
||||
# there is no predecessor — the check runs before the first sleep.
|
||||
#
|
||||
# Returning non-zero does NOT abort the start: the daemon carries its own
|
||||
# single-owner guard and will refuse (or wait) on its side. Better to hand the
|
||||
# decision to the process that can actually see the plane than to leave the box
|
||||
# with no service at all.
|
||||
shater_wait_stopped() {
|
||||
local i=0 pid
|
||||
pid=$(shater_daemon_pid) || return 0
|
||||
_slog -p daemon.info \
|
||||
"restart: waiting for the previous shaterd (pid $pid) to finish tearing the data plane down"
|
||||
while [ "$i" -lt "$STOP_WAIT_SECS" ]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
shater_daemon_pid >/dev/null || {
|
||||
_slog -p daemon.info "restart: previous shaterd exited after ${i}s; starting a fresh one"
|
||||
return 0
|
||||
}
|
||||
done
|
||||
_slog -p daemon.warn \
|
||||
"restart: previous shaterd (pid $pid) still alive after ${STOP_WAIT_SECS}s — starting anyway"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- procd lifecycle -------------------------------------------------------
|
||||
|
||||
start_service() {
|
||||
@@ -81,15 +470,52 @@ start_service() {
|
||||
# Guard: never claim to run without the daemon binary. A half-removed/failed
|
||||
# shaterd upgrade must degrade to "plugin off", not to a box that thinks
|
||||
# interception is live with nothing behind it.
|
||||
#
|
||||
# "Plugin off" now has to include DISARMING. With the boot armor in play, a
|
||||
# missing binary is the one case where the fail-closed plane could stand
|
||||
# forever with nothing able to replace it: the armor loads at START=21, the
|
||||
# daemon never starts, and every later boot repeats it. The product being gone
|
||||
# is not a security event — it is an uninstall — so the plane comes down and
|
||||
# the LAN returns to plain routing, loudly.
|
||||
if [ ! -x "$PROG" ]; then
|
||||
shater_clear_restart
|
||||
shater_disarm_boot
|
||||
rm -f "$ACTIVE_FLAG"
|
||||
nft delete table inet shater 2>/dev/null
|
||||
_slog -p daemon.err \
|
||||
"shaterd binary missing/not executable at $PROG — refusing to start (LAN stays on plain routing)"
|
||||
"shaterd binary missing/not executable at $PROG — refusing to start; the fail-closed plane and its boot armor have been REMOVED (LAN back to plain routing, unprotected). Reinstall shaterd."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Do not stand a new data plane up on top of one that is still being taken
|
||||
# down. On `restart` procd has only just SIGTERMed the previous instance and
|
||||
# returned; this is the handshake that makes `restart` == `stop` + pause +
|
||||
# `start`. It also keeps `migrate` below from rewriting UCI underneath a
|
||||
# daemon that is still reading it. No-op (and no delay) when nothing is
|
||||
# running, which is the boot case.
|
||||
shater_wait_stopped
|
||||
|
||||
# The predecessor is gone and has already consumed the flag (it reads it in its
|
||||
# SIGTERM handler). Withdraw it now, so a LATER `stop` is unambiguous even if
|
||||
# this start fails further down.
|
||||
shater_clear_restart
|
||||
|
||||
# Bring the UCI schema forward before the daemon reads it (idempotent;
|
||||
# refuses a newer schema) so an upgraded package never applies a stale config.
|
||||
"$PROG" migrate >/dev/null 2>&1
|
||||
#
|
||||
# THE FAILURE IS REPORTED, NOT SWALLOWED, AND IT IS NAMED. This is the only
|
||||
# place the schema migration runs at boot (`shaterd run`, the SIGHUP reconcile
|
||||
# and the panel's config write all read UCI directly), so if it fails here it
|
||||
# does not get retried until the next start — which is also why this, and not
|
||||
# the uci-defaults call, is the report that matters: it comes back at every
|
||||
# boot and every restart for as long as the problem lasts.
|
||||
#
|
||||
# The three outcomes are three different problems with three different fixes
|
||||
# (free space / put the newer package back / the file is unreadable), and
|
||||
# shater_migrate says which one it was instead of asserting the middle one at
|
||||
# all of them. Start regardless in every case: refusing to start would take the
|
||||
# admin panel down with it, and the panel is the only way to fix the box.
|
||||
shater_migrate
|
||||
|
||||
procd_open_instance shater
|
||||
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
|
||||
@@ -111,7 +537,16 @@ start_service() {
|
||||
procd_set_param stderr 1
|
||||
# Give the daemon room to run its honest teardown (engine.Close + netplane
|
||||
# restore) before procd SIGKILLs it.
|
||||
procd_set_param term_timeout 10
|
||||
#
|
||||
# 30s, not 10s: an engine holding a few hundred outbounds closes its
|
||||
# urltest/observatory goroutines and flushes experimental.cache_file to FLASH
|
||||
# before the netplane teardown even starts, and on eMMC/NAND that alone can
|
||||
# outlast 10s. A SIGKILL there aborts the teardown at an arbitrary point and
|
||||
# leaves the plane HALF removed — the nft table gone but the policy routing
|
||||
# still installed, or vice versa — which is precisely the class of leftover
|
||||
# state the successor's idempotent fast-path cannot see and never repairs.
|
||||
# Shutdown is bounded by procd either way; we are only choosing where.
|
||||
procd_set_param term_timeout 30
|
||||
procd_close_instance
|
||||
|
||||
# Mark the stack live for hotplug/cron — but ONLY when interception is
|
||||
@@ -128,6 +563,45 @@ start_service() {
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
# Say WHY we are stopping before procd sends the signal, because the daemon
|
||||
# cannot tell from the signal alone and the answer changes what it leaves in
|
||||
# the kernel. Two INDEPENDENT questions, and the old code conflated them into
|
||||
# one two-armed `case` whose else-branch answered both wrongly for `shutdown`:
|
||||
#
|
||||
# 1. IS A SUCCESSOR COMING (this process only)? restart / reload.
|
||||
# Raise RESTART_FLAG so the outgoing daemon replaces its data plane with
|
||||
# the fail-closed HOLDING plane instead of removing it. The gap until the
|
||||
# successor applies is not a moment: this script waits out the old
|
||||
# process, runs `shaterd migrate`, then starts a daemon that must build an
|
||||
# engine — all of it, before this flag existed, with `lan -> wan ACCEPT`
|
||||
# and nothing else.
|
||||
#
|
||||
# 2. IS THE PRODUCT BEING SWITCHED OFF (across boots)? `stop` — and only
|
||||
# `stop`, and only when a PERSON is behind it (shater_stop_disarms; the
|
||||
# package manager reaches us through `stop` too). Then the boot armor goes
|
||||
# with it, so the next boot does not quietly reinstate what the operator
|
||||
# just switched off — the same rule ACTIVE_FLAG has always enforced for
|
||||
# hotplug/cron.
|
||||
#
|
||||
# `shutdown` answers NO to both, which is the defect this replaced: a reboot is
|
||||
# not a successor and it is certainly not an operator switching the product off.
|
||||
# It is the boot the armor exists for. An upgrade answers NO to the second for
|
||||
# the same kind of reason.
|
||||
if shater_action_handoff "$SHATER_RC_ACTION"; then
|
||||
shater_mark_restart
|
||||
else
|
||||
shater_clear_restart
|
||||
fi
|
||||
local in_pkg=0 rc_en=0
|
||||
shater_pkg_transaction && in_pkg=1
|
||||
shater_rc_enabled && rc_en=1
|
||||
if shater_stop_disarms "$SHATER_RC_ACTION" "$in_pkg" "$rc_en"; then
|
||||
shater_disarm_boot
|
||||
elif [ "$in_pkg" = "1" ] && shater_action_disarms "$SHATER_RC_ACTION"; then
|
||||
_slog -p daemon.info \
|
||||
"stop came from a package transaction that left the service enabled — keeping the boot armor, so being replaced cannot leave the next boot unprotected"
|
||||
fi
|
||||
|
||||
# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
|
||||
# what we are about to tear down. procd then sends SIGTERM to `shaterd run`,
|
||||
# which runs its OWN honest teardown (engine.Close + netplane restore) — we
|
||||
@@ -141,10 +615,17 @@ stop_service() {
|
||||
reload_service() {
|
||||
# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
|
||||
# reload_config). Simplest correct behaviour: stop + start. `stop` clears the
|
||||
# flag and SIGTERMs the daemon (honest teardown); `start` re-guards on
|
||||
# enabled and, if still enabled, launches a fresh `shaterd run` that reads
|
||||
# the new UCI and applies it. When the stack is disabled, `start` is a no-op,
|
||||
# so a disable+apply cleanly tears everything down.
|
||||
# flag and SIGTERMs the daemon (honest teardown); `start` WAITS for that
|
||||
# teardown to actually finish (shater_wait_stopped) and then launches a fresh
|
||||
# `shaterd run` that reads the new UCI and applies it. When the stack is
|
||||
# disabled, `start` is a no-op, so a disable+apply cleanly tears everything
|
||||
# down. Because the wait lives in start_service, this path gets the same
|
||||
# stop-then-start ordering guarantee as `restart`.
|
||||
#
|
||||
# Marked EXPLICITLY as well as via SHATER_RC_ACTION: this is the path a routine
|
||||
# Save & Apply takes, so it is the one that must not depend on reading an
|
||||
# rc.common variable correctly. Belt and braces, one line.
|
||||
shater_mark_restart
|
||||
stop
|
||||
start
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# /etc/init.d/shater-armor — the fail-closed plane, before the daemon exists.
|
||||
#
|
||||
# WHAT THIS CLOSES
|
||||
#
|
||||
# /etc/init.d/shater is START=99. By then fw4 (START=19) has long since loaded
|
||||
# `lan -> wan ACCEPT` and netifd (START=20) has brought the LAN bridge up, so the
|
||||
# router forwards LAN traffic to the WAN in the clear from the moment the link
|
||||
# comes up until `shaterd run` has been decompressed off flash, has waited out any
|
||||
# predecessor, has migrated UCI, has read the config and has installed its first
|
||||
# table. On router-class hardware with a UPX-packed binary that is seconds — and
|
||||
# they are exactly the seconds in which Wi-Fi finishes associating and every
|
||||
# client on the network reconnects and starts talking. `kill_switch=closed` was
|
||||
# configured the whole time and covered none of it.
|
||||
#
|
||||
# There was nothing in the package that could cover it either: no /etc/nftables.d
|
||||
# include, no `nft -f` in uci-defaults. Protection existed only inside a Go
|
||||
# process that had not started yet.
|
||||
#
|
||||
# HOW
|
||||
#
|
||||
# The daemon persists a copy of its fail-closed HOLDING plane (the same ruleset it
|
||||
# installs when the engine is down: one forward chain, LAN-to-LAN and router
|
||||
# traffic accepted, everything else from the diverted devices dropped) to
|
||||
# $ARMOR on every apply. This script loads it early. When the daemon comes up it
|
||||
# replaces the table atomically — the ruleset begins with `delete table` and adds
|
||||
# its own in one netlink transaction — so there is never a moment with no table.
|
||||
#
|
||||
# `iifname` matches by NAME at packet time, not by ifindex at load time, so
|
||||
# loading this before netifd has created br-lan is fine: the rules simply start
|
||||
# matching when the device appears. That is why START can sit here rather than
|
||||
# racing netifd.
|
||||
#
|
||||
# START=21: after fw4 (19) and netifd (20), because fw4's own start tears its
|
||||
# table down and rebuilds it and we do not want to be in the middle of that, and
|
||||
# because there is nothing to protect before the LAN device is being created. The
|
||||
# residual exposure is the fraction of a second between netifd's `ifup` and this
|
||||
# script, against seconds-to-a-minute before.
|
||||
#
|
||||
# THE ESCAPE HATCHES (a kill switch that cannot be switched off is a brick)
|
||||
#
|
||||
# These are STATE checks, evaluated here, at the moment of arming — not a record
|
||||
# of something that happened on the way down. That distinction is the whole
|
||||
# lesson of v0.2.17: the arm token was deleted by an EVENT on the shutdown path
|
||||
# ("this looks like a stop"), and since `reboot` also runs the K-links, the
|
||||
# mechanism reliably erased itself on the one transition it was built for. An
|
||||
# event on the way down cannot be trusted to describe the world on the way up; a
|
||||
# question asked on the way up can be.
|
||||
#
|
||||
# * $ARMOR only exists while the daemon's last applied config was BOTH enabled
|
||||
# and fail-closed. `globals.enabled=0` and `kill_switch=open` each remove it
|
||||
# at the next apply, and an operator typing `/etc/init.d/shater stop` removes
|
||||
# it there and then. Powering the box off does NOT.
|
||||
# * We refuse to arm when the main service is disabled in rc.d, or when the
|
||||
# daemon binary is gone — in either case nothing would ever come along to
|
||||
# replace the armor with a real data plane. These two are what makes a
|
||||
# genuinely uninstalled/disabled product safe REGARDLESS of what the file
|
||||
# says, which is why they are checked here rather than trusted to have been
|
||||
# acted on earlier.
|
||||
# * We refuse to arm when UCI can be read AND says the stack is disabled. A
|
||||
# config that cannot be read is NOT a refusal: that case is precisely why the
|
||||
# armor is a file rather than a query.
|
||||
# * The chain hooks `forward` only, so SSH, LuCI and the admin panel (all input
|
||||
# hook, to the router's own addresses) stay reachable. The operator can always
|
||||
# get in and undo this.
|
||||
#
|
||||
# Note what a bare `/etc/init.d/shater stop` does NOT mean: it does not survive a
|
||||
# reboot, because S99shater is still linked and procd starts the daemon again. So
|
||||
# "stopped" is not a durable off-state and this script must not be designed as if
|
||||
# it were — the durable ones are `disable` (no S??shater) and `globals.enabled=0`,
|
||||
# and those are the two refusals above.
|
||||
#
|
||||
# busybox ash only — no bashisms.
|
||||
|
||||
START=21 # after firewall (19) and network (20), long before shater (99)
|
||||
STOP=89
|
||||
|
||||
ARMOR=/etc/shater/boot.nft
|
||||
PROG=/usr/bin/shaterd
|
||||
|
||||
# Syslog line that honors globals.log_syslog, like the other two inits. An
|
||||
# unreadable UCI leaves the option empty => ON, which is what we want here: the
|
||||
# one boot where the config cannot be read is the boot worth logging.
|
||||
_slog() {
|
||||
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater-armor "$@"
|
||||
}
|
||||
|
||||
# Is the MAIN service enabled at boot? Answered by looking for its rc.d symlink
|
||||
# rather than by running `/etc/init.d/shater enabled`: that is a USE_PROCD script,
|
||||
# so every action of it sources procd.sh, which takes a blocking flock — and this
|
||||
# runs at START=21, in the middle of boot, for a question a glob answers exactly
|
||||
# as well. The START number is not hardcoded; any S<NN>shater counts.
|
||||
shater_service_enabled() {
|
||||
local f
|
||||
for f in /etc/rc.d/S[0-9][0-9]shater; do
|
||||
[ -e "$f" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start() {
|
||||
# No saved plane => the stack has never applied an enabled, fail-closed config
|
||||
# (or it was explicitly switched off). Nothing to do, and nothing to say.
|
||||
[ -f "$ARMOR" ] || return 0
|
||||
[ -s "$ARMOR" ] || {
|
||||
_slog -p daemon.err "$ARMOR is empty — NOT arming; the LAN is unprotected until shaterd starts"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Never arm something nothing can disarm.
|
||||
[ -x "$PROG" ] || {
|
||||
_slog -p daemon.err \
|
||||
"$PROG is missing — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
|
||||
return 0
|
||||
}
|
||||
shater_service_enabled || {
|
||||
_slog -p daemon.warn \
|
||||
"the shater service is disabled in rc.d — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
|
||||
return 0
|
||||
}
|
||||
|
||||
# A READABLE config that says "off" wins over the saved plane (it means the
|
||||
# daemon was stopped before it could disarm). An UNREADABLE config does not:
|
||||
# that is the case this whole mechanism exists for.
|
||||
en=$(uci -q get shater.globals.enabled 2>/dev/null)
|
||||
if [ -n "$en" ] && [ "$en" != "1" ]; then
|
||||
rm -f "$ARMOR"
|
||||
_slog -p daemon.info "globals.enabled=$en — boot armor removed, not arming"
|
||||
return 0
|
||||
fi
|
||||
|
||||
command -v nft >/dev/null 2>&1 || {
|
||||
_slog -p daemon.err "nft is not installed — cannot arm; the LAN is unprotected until shaterd starts"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Validate before loading: a truncated/incompatible snapshot must not leave a
|
||||
# half-built table behind on the one boot it is needed.
|
||||
if ! nft -c -f "$ARMOR" >/dev/null 2>&1; then
|
||||
_slog -p daemon.err \
|
||||
"$ARMOR did not validate (nft -c) — NOT arming; the LAN is unprotected until shaterd starts"
|
||||
return 0
|
||||
fi
|
||||
if nft -f "$ARMOR" >/dev/null 2>&1; then
|
||||
_slog -p daemon.warn \
|
||||
"fail-closed plane armed from $ARMOR: LAN->WAN forwarding is BLOCKED until shaterd applies. SSH, LuCI and the admin panel stay reachable."
|
||||
else
|
||||
_slog -p daemon.err \
|
||||
"could not load $ARMOR — the LAN is unprotected until shaterd starts"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
stop() {
|
||||
# Deliberately a NO-OP. By the time anything stops this service the daemon owns
|
||||
# `inet shater`, and deleting the table here would dismantle a LIVE data plane
|
||||
# on the strength of a service that only ever ran for one second at boot. The
|
||||
# disarm paths that matter live where the decision is actually made:
|
||||
# /etc/init.d/shater stop (operator switched it off) and the daemon itself
|
||||
# (globals.enabled=0 / kill_switch=open).
|
||||
return 0
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
# plus the data-plane watchdog (v0.2).
|
||||
#
|
||||
# A tiny procd-supervised loop that, once per tick, checks every enabled
|
||||
# subscription and url-ruleset against its per-item `update_interval` and runs
|
||||
# subscription against its per-item `update_interval` and runs
|
||||
# shaterd sub update <name> (subscriptions)
|
||||
# shaterd ruleset update <name> (url rulesets)
|
||||
# when the item is due, then a single `shaterd reconcile` if anything changed
|
||||
# (the daemon's config-hash gate rebuilds the engine only on a real change).
|
||||
# `config ruleset` items are NOT touched here — see shater_run_due for who owns
|
||||
# their refresh and where the remaining gap is.
|
||||
#
|
||||
# RELIABILITY CONTRACT (same "железно" posture as /etc/init.d/shater):
|
||||
# * The loop body is fully INERT unless globals.enabled=1 AND the main shater
|
||||
@@ -27,6 +28,15 @@
|
||||
# the main service is STOPPED (tears interception down — fail-open, LAN
|
||||
# returns to plain routing); with kill_switch=closed the rules stay
|
||||
# (blocked-by-design) and we log loudly.
|
||||
# * CRASH-LOOP WATCHDOG: the dead-daemon counter above cannot see the failure
|
||||
# it matters most for. /etc/init.d/shater sets `respawn 3600 5 0`, so a
|
||||
# daemon that dies a few seconds into startup is back within 5s and a single
|
||||
# `pidof` per 60s tick nearly always finds a process — the counter resets and
|
||||
# never reaches WATCHDOG_TICKS, while the fail-closed plane keeps the LAN shut
|
||||
# and the panel (served BY the daemon) never comes up. So the tick's sleep is
|
||||
# spent SAMPLING the daemon's identity instead of sleeping blind, and a tick in
|
||||
# which several different daemons lived is counted as churn. See
|
||||
# shater_churn_scan / shater_churn_verdict / shater_churn_action.
|
||||
# * The loop never self-exits (procd would respawn-churn an exiting body);
|
||||
# it idles on its guards instead. busybox ash only — no bashisms.
|
||||
|
||||
@@ -42,14 +52,34 @@ INIT_SCRIPT=/etc/init.d/shater-cron
|
||||
SHATER_INIT=/etc/init.d/shater
|
||||
SHATERD=/usr/bin/shaterd
|
||||
ACTIVE_FLAG=/var/run/shater.active
|
||||
# Raised by /etc/init.d/shater around a restart/reload and cleared by the
|
||||
# successor's start_service. Read here ONLY as a "a person/package asked for this
|
||||
# bounce" veto on the crash-loop verdict — never as a liveness signal.
|
||||
RESTART_FLAG=/var/run/shater.restarting
|
||||
# Written by `shaterd run` itself (main.go writePidfile) before it builds anything,
|
||||
# and removed by that same process on a clean exit. It is the only handle that
|
||||
# names THE daemon: `pidof shaterd` also matches the short-lived CLI verbs this
|
||||
# very loop runs (`sub update`, `reconcile`, `schedule due`).
|
||||
PIDFILE=/var/run/shaterd.pid
|
||||
STAMP_DIR=/var/run/shater/cron
|
||||
TICK=60 # seconds between due-checks
|
||||
RETRY_SECS=300 # backoff before retrying a FAILED fetch
|
||||
WATCHDOG_TICKS=5 # consecutive dead-daemon ticks before escalating
|
||||
DEFAULT_SUB_INTERVAL=6h
|
||||
DEFAULT_RS_INTERVAL=24h
|
||||
DEFAULT_BL_INTERVAL=24h # url blocklist refresh interval (D16)
|
||||
|
||||
# --- crash-loop watchdog tuning --------------------------------------------
|
||||
#
|
||||
# Every number here is chosen against ONE question: what can a legitimate restart
|
||||
# produce? A legitimate bounce (`restart`, LuCI Save & Apply -> reload, a package
|
||||
# transaction) replaces the daemon EXACTLY ONCE, and it is announced twice over —
|
||||
# /etc/init.d/shater raises RESTART_FLAG in stop_service and clears ACTIVE_FLAG for
|
||||
# the duration. A crash loop is announced by nothing and repeats without bound.
|
||||
LOOP_POLL=5 # seconds between identity samples inside one tick
|
||||
LOOP_MIN_GENS=3 # distinct daemons in ONE tick that count as churn
|
||||
LOOP_WINDOWS=2 # consecutive churn ticks before we call it a loop
|
||||
LOOP_REPORT_TICKS=30 # do not repeat the report more often than this
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
shater_enabled() {
|
||||
@@ -132,8 +162,33 @@ shater_stamp_retry() {
|
||||
|
||||
# Walk anonymous `config subscription` / `config ruleset` sections by index and
|
||||
# run any that are due. Echoes non-empty on stdout if at least one item updated.
|
||||
#
|
||||
# WHERE A SUBSCRIPTION'S BYTES TRAVEL, and what this loop sees when they cannot.
|
||||
#
|
||||
# `shaterd sub update <name>` honours the subscription's own `fetch_via`:
|
||||
# * fetch_via != proxy — fetched DIRECT by the short-lived CLI process, exactly
|
||||
# as it always was. Needs no daemon; works at cold start and first boot.
|
||||
# * fetch_via == proxy — DELEGATED to the running daemon over its control
|
||||
# socket, because only the daemon owns the engine the fetch has to travel
|
||||
# through. Until 2026-07-27 the CLI printed one `daemon.warn` line and
|
||||
# fetched DIRECT instead, so every tick of THIS loop and every fetch-at-boot
|
||||
# put the feed URL and the router's real address on the plain WAN — while the
|
||||
# panel's Refresh button (the same operation, through the daemon) worked, so
|
||||
# it looked configured. It now FAILS instead, exit 1.
|
||||
#
|
||||
# CONSEQUENCE HERE, stated rather than discovered later: with a proxy-fetched
|
||||
# subscription and no live daemon, the `if` below takes the else arm every time —
|
||||
# one syslog line and shater_stamp_retry, i.e. a fresh attempt every RETRY_SECS
|
||||
# (300s) for as long as the daemon is down. That is ~288 lines a day per such
|
||||
# subscription. It is NOT the `ruleset update` situation this file used to have:
|
||||
# there the work had no owner and the retry could never succeed, whereas here the
|
||||
# retry succeeds within RETRY_SECS of the daemon coming back, and the loop only
|
||||
# runs at all while `shater_enabled && shater_active` — a dead daemon is already
|
||||
# being escalated by shater_watchdog, and with kill_switch=open it stops the stack
|
||||
# (clearing ACTIVE_FLAG), which makes this loop inert. Deliberately left noisy:
|
||||
# a subscription that is silently going stale is worse than a repeated line.
|
||||
shater_run_due() {
|
||||
local i name en ivl secs stamp src changed=""
|
||||
local i name en ivl secs stamp changed=""
|
||||
|
||||
# Subscriptions.
|
||||
i=0
|
||||
@@ -159,28 +214,36 @@ shater_run_due() {
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
|
||||
# Rulesets (only url sources auto-update; others have nothing to fetch).
|
||||
i=0
|
||||
while uci -q get "shater.@ruleset[$i]" >/dev/null 2>&1; do
|
||||
name=$(uci -q get "shater.@ruleset[$i].name")
|
||||
src=$(uci -q get "shater.@ruleset[$i].source")
|
||||
if [ -n "$name" ] && [ "$src" = "url" ]; then
|
||||
ivl=$(uci -q get "shater.@ruleset[$i].update_interval")
|
||||
secs=$(shater_ivl_secs "$ivl" "$DEFAULT_RS_INTERVAL")
|
||||
stamp="$STAMP_DIR/rs.$(shater_safe_name "$name")"
|
||||
if shater_due "$stamp" "$secs"; then
|
||||
if "$SHATERD" ruleset update "$name" >/dev/null 2>&1; then
|
||||
shater_stamp "$stamp"
|
||||
changed=1
|
||||
else
|
||||
_slog -p daemon.warn \
|
||||
"ruleset update '$name' failed; retrying in ${RETRY_SECS}s"
|
||||
shater_stamp_retry "$stamp" "$secs"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
# RULE-SETS ARE NOT UPDATED FROM HERE, AND NEVER WERE.
|
||||
#
|
||||
# There used to be a second loop that ran `shaterd ruleset update <name>` for
|
||||
# every `config ruleset` with source=url. That verb has never existed: it
|
||||
# printed a note and exited 0, so this loop stamped the item as freshly updated
|
||||
# and raised `changed`, which cost a reconcile per item per interval and told
|
||||
# the operator the list was current when not one byte had been fetched. The verb
|
||||
# now exits non-zero (shater/cmd/shaterd/main.go, notImpl), which turns the same
|
||||
# loop into one failed attempt and one syslog line every RETRY_SECS — ~288 lines
|
||||
# a day, per rule-set, about work that has no owner here. Noise in the log hides
|
||||
# real problems as effectively as a lie about success does.
|
||||
#
|
||||
# WHO REFRESHES A url RULE-SET NOW, so the next reader does not think this was
|
||||
# forgotten. `source=url` splits into two shapes in shater/generate/ruleset.go:
|
||||
#
|
||||
# * the URL serves an engine-native .srs/.json -> it stays a REMOTE rule-set
|
||||
# and sing-box owns fetch/cache/refresh through RemoteRuleSet.UpdateInterval
|
||||
# on the running box. This cron loop never had anything to contribute.
|
||||
# * the URL serves a plain-text list -> it is compiled locally into
|
||||
# /etc/shater/lists/<tag>.srs, and that artifact is refreshed by the
|
||||
# GENERATOR, "when missing or older than update_interval" — i.e. only when
|
||||
# something else already caused a generate. Nothing schedules one, so this
|
||||
# shape has NO periodic refresh at all today. That is a real gap, and it is
|
||||
# stated here rather than papered over with a call to a verb that does
|
||||
# nothing: closing it needs a daemon-side timer (or a real `ruleset update`),
|
||||
# not a shell loop, because only the daemon can force a rebuild past the
|
||||
# config-hash gate.
|
||||
#
|
||||
# `config blocklist` url items are a different mechanism and DO refresh — see
|
||||
# shater_run_due_blocklists below.
|
||||
|
||||
[ -n "$changed" ] && echo 1
|
||||
}
|
||||
@@ -256,6 +319,171 @@ shater_watchdog() {
|
||||
echo "$dead"
|
||||
}
|
||||
|
||||
# --- crash-loop watchdog ----------------------------------------------------
|
||||
#
|
||||
# THE HOLE. shater_watchdog above answers "is a daemon there?" once every TICK
|
||||
# seconds. /etc/init.d/shater sets `respawn 3600 5 0`, so a daemon that dies a few
|
||||
# seconds into startup is back 5s later and that one sample nearly always finds a
|
||||
# process: the dead-counter resets, never reaches WATCHDOG_TICKS, and the single
|
||||
# failure the watchdog exists for — a new binary or a bad config that cannot get
|
||||
# an engine up while the fail-closed plane holds the LAN shut — is the one it can
|
||||
# never see. The daemon also serves the panel, so in that state the operator has
|
||||
# neither internet nor a way to look at the box.
|
||||
#
|
||||
# THE SIGNAL. Not "is it there" but "is it the SAME one". The tick's sleep is
|
||||
# spent taking an identity sample every LOOP_POLL seconds instead of sleeping
|
||||
# blind, and a tick in which LOOP_MIN_GENS different daemons lived is a churn
|
||||
# tick. LOOP_WINDOWS consecutive churn ticks is the verdict.
|
||||
#
|
||||
# WHY A LEGITIMATE RESTART CANNOT REACH IT. Four independent reasons, in order of
|
||||
# how much they are relied on:
|
||||
#
|
||||
# 1. A bounce replaces the daemon ONCE. One restart scores 2 generations in the
|
||||
# tick it happens in and 1 in every tick after, so it cannot even produce a
|
||||
# single churn tick at LOOP_MIN_GENS=3, let alone LOOP_WINDOWS of them in a
|
||||
# row. Reaching the verdict takes >= 4 replacements inside 2 consecutive
|
||||
# minutes, >= 2 in each.
|
||||
# 2. Bounces are ANNOUNCED. /etc/init.d/shater raises RESTART_FLAG in
|
||||
# stop_service and clears ACTIVE_FLAG for the whole stop->start, and either
|
||||
# one seen in any sample of a tick discards that tick outright.
|
||||
# 3. The panel's Apply does not restart anything: it writes UCI and applies over
|
||||
# the daemon's control socket, in process. Only `restart`, a LuCI Save &
|
||||
# Apply (config.change -> reload) and a package transaction bounce the
|
||||
# daemon, and a human cannot produce those at four a minute.
|
||||
# 4. The sample names THE daemon via its pidfile, not `pidof shaterd` — the
|
||||
# short-lived CLI verbs this very loop runs share that process name.
|
||||
#
|
||||
# WHAT IT DOES NOT COVER, stated rather than implied: a daemon that dies
|
||||
# INSTANTLY (well under a second) is almost never caught alive by a 5s sample, so
|
||||
# it scores few generations and this detector stays quiet. That case is exactly
|
||||
# the one the existing dead-tick counter does see — its `pidof` misses too, tick
|
||||
# after tick — so the two cover opposite ends and are deliberately left as two
|
||||
# independent instruments rather than merged into one clever number.
|
||||
|
||||
# One identity sample: echoes the pid of the live `shaterd run`, or "-" for none.
|
||||
#
|
||||
# Through the PIDFILE, which `shaterd run` writes before it builds anything and
|
||||
# removes on a clean exit, because that is the only handle that names THE daemon:
|
||||
# `pidof shaterd` also matches `shaterd sub update` / `reconcile` / `schedule due`.
|
||||
# /proc/<pid>/comm is checked so a stale pidfile whose pid has been reused by an
|
||||
# unrelated process cannot read as a live daemon. No forks: `read` is a builtin.
|
||||
shater_sample_pid() {
|
||||
local pid="" comm=""
|
||||
[ -r "$PIDFILE" ] && read -r pid 2>/dev/null < "$PIDFILE"
|
||||
case "$pid" in
|
||||
''|*[!0-9]*) echo -; return ;;
|
||||
esac
|
||||
[ -r "/proc/$pid/comm" ] && read -r comm 2>/dev/null < "/proc/$pid/comm"
|
||||
[ "$comm" = "shaterd" ] || { echo -; return; }
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
# shater_churn_scan <sample>... -> "<generations> <absent-samples>"
|
||||
#
|
||||
# A GENERATION is one distinct daemon lifetime observed during the tick: a live
|
||||
# pid that differs from the last live pid seen. A daemon that simply keeps running
|
||||
# therefore scores exactly 1 generation and 0 absent samples for as long as it
|
||||
# runs — the signal is flat unless something is actually being replaced.
|
||||
#
|
||||
# A GAP (samples with no daemon at all, e.g. procd's 5s respawn hole) is counted
|
||||
# but does NOT by itself open a new generation: only a different pid does. An
|
||||
# earlier draft reset the comparison across a gap so that "same pid seen again
|
||||
# after a gap" would score two. That case cannot occur — a respawn always gets a
|
||||
# fresh pid — so it was unfalsifiable code, and resetting also meant a momentarily
|
||||
# unreadable pidfile could inflate the count. Not resetting is both simpler and
|
||||
# the safer direction.
|
||||
#
|
||||
# Pure: no I/O, no globals, every input on the command line. That is what lets the
|
||||
# gate drive it with synthetic sample streams instead of a live router.
|
||||
shater_churn_scan() {
|
||||
local gens=0 absent=0 last="" s
|
||||
for s in "$@"; do
|
||||
if [ "$s" = "-" ]; then
|
||||
absent=$(( absent + 1 ))
|
||||
continue
|
||||
fi
|
||||
[ "$s" = "$last" ] || gens=$(( gens + 1 ))
|
||||
last="$s"
|
||||
done
|
||||
echo "$gens $absent"
|
||||
}
|
||||
|
||||
# shater_churn_verdict <gens> <samples> <announced> <churn-so-far>
|
||||
# -> the new consecutive-churn-tick count
|
||||
#
|
||||
# Also pure. `announced`=1 means a sample during the tick saw RESTART_FLAG up or
|
||||
# ACTIVE_FLAG down, i.e. /etc/init.d/shater said out loud that it was bouncing the
|
||||
# daemon: that tick proves nothing and resets the run. A tick with no samples at
|
||||
# all (the first pass through the loop) likewise scores 0 rather than guessing.
|
||||
shater_churn_verdict() {
|
||||
local gens="$1" n="$2" announced="$3" churn="$4"
|
||||
[ "$announced" = "1" ] && { echo 0; return; }
|
||||
[ "$n" -gt 0 ] || { echo 0; return; }
|
||||
if [ "$gens" -ge "$LOOP_MIN_GENS" ]; then
|
||||
echo $(( churn + 1 ))
|
||||
return
|
||||
fi
|
||||
echo 0
|
||||
}
|
||||
|
||||
# shater_churn_action <churn-ticks> <kill_switch> -> none | log | stop
|
||||
#
|
||||
# WHAT TO DO, and why it is not our call to make twice. A crash loop leaves the
|
||||
# box in the same state a dead daemon does — no engine, fail-closed plane standing
|
||||
# — so the answer is the one the operator already gave with kill_switch, not a new
|
||||
# policy invented here:
|
||||
#
|
||||
# open The operator asked for connectivity over interception. Stop the stack,
|
||||
# exactly as shater_watchdog does for a sustained-dead daemon: the plane
|
||||
# comes down and the LAN returns to plain routing. It also disarms the
|
||||
# boot armor, so the NEXT boot is clean too instead of repeating the loop
|
||||
# behind a closed LAN. Nothing else can end the loop: procd's retries are
|
||||
# infinite by design.
|
||||
# closed The operator asked for blocked-over-leaking. Blocked is what they get,
|
||||
# and opening their LAN from a background loop would be the opposite of
|
||||
# what the knob says. Report it loudly and let the person decide; the
|
||||
# message names the one command that opens it.
|
||||
#
|
||||
# The list is POSITIVE and CLOSED, and the fall-through goes to `log`: an absent
|
||||
# or unrecognised kill_switch is the model's documented default ("closed", see
|
||||
# shater/model/model.go DefaultGlobals), and `log` is the recoverable side — it
|
||||
# changes nothing and can be acted on, where a wrong `stop` silently drops a
|
||||
# household onto the unproxied WAN.
|
||||
#
|
||||
# NOTE (not changed here, deliberately): shater_watchdog above answers the same
|
||||
# question with `if closed ... else stop`, so for an ABSENT kill_switch it fails
|
||||
# open — the opposite of the documented default. It is left alone because that
|
||||
# behaviour predates this file's crash-loop work; it is reported upward instead.
|
||||
shater_churn_action() {
|
||||
local churn="$1" ks="$2"
|
||||
[ "$churn" -ge "$LOOP_WINDOWS" ] || { echo none; return; }
|
||||
case "$ks" in
|
||||
open) echo stop ;;
|
||||
closed) echo log ;;
|
||||
*) echo log ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Sleep out one tick in LOOP_POLL slices, sampling the daemon's identity as we go.
|
||||
# Publishes CHURN_SAMPLES / CHURN_N / CHURN_ANNOUNCED for the next pass of loop().
|
||||
# Deliberately NOT a subshell (globals must survive), and it always returns 0 so a
|
||||
# false `[ -f ]` at the end cannot look like a failure.
|
||||
shater_tick_sample() {
|
||||
local slept=0
|
||||
CHURN_SAMPLES=""
|
||||
CHURN_N=0
|
||||
CHURN_ANNOUNCED=0
|
||||
while [ "$slept" -lt "$TICK" ]; do
|
||||
sleep "$LOOP_POLL"
|
||||
slept=$(( slept + LOOP_POLL ))
|
||||
CHURN_SAMPLES="$CHURN_SAMPLES $(shater_sample_pid)"
|
||||
CHURN_N=$(( CHURN_N + 1 ))
|
||||
[ -f "$RESTART_FLAG" ] && CHURN_ANNOUNCED=1
|
||||
[ -f "$ACTIVE_FLAG" ] || CHURN_ANNOUNCED=1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# loop: the foreground body supervised by procd. Never exits on its own — it
|
||||
# idles while disabled/inactive so procd is not respawn-churned by a
|
||||
# self-exiting body when the stack is off.
|
||||
@@ -274,7 +502,12 @@ loop() {
|
||||
# the flock immediately and keeps children (sleep/shaterd) from inheriting
|
||||
# it. A no-op where fd 1000 is not open (older procd.sh without procd_lock).
|
||||
exec 1000>&-
|
||||
local changed dead=0
|
||||
local changed dead=0 churn=0 quiet=0 scan gens absent ks act
|
||||
# No tick has been sampled yet on the first pass; shater_churn_verdict scores
|
||||
# an empty tick as 0 rather than guessing.
|
||||
CHURN_SAMPLES=""
|
||||
CHURN_N=0
|
||||
CHURN_ANNOUNCED=0
|
||||
mkdir -p "$STAMP_DIR"
|
||||
while :; do
|
||||
if shater_enabled && shater_active; then
|
||||
@@ -299,10 +532,44 @@ loop() {
|
||||
"$SHATERD" schedule due >/dev/null 2>&1
|
||||
fi
|
||||
dead=$(shater_watchdog "$dead")
|
||||
|
||||
# Crash-loop verdict on the tick that has just elapsed. Unquoted on
|
||||
# purpose: CHURN_SAMPLES is a whitespace-separated token list and word
|
||||
# splitting is how it becomes arguments.
|
||||
scan=$(shater_churn_scan $CHURN_SAMPLES)
|
||||
gens=${scan%% *}
|
||||
absent=${scan##* }
|
||||
churn=$(shater_churn_verdict "$gens" "$CHURN_N" "$CHURN_ANNOUNCED" "$churn")
|
||||
ks=$(uci -q get shater.globals.kill_switch)
|
||||
act=$(shater_churn_action "$churn" "$ks")
|
||||
case "$act" in
|
||||
stop)
|
||||
_slog -p daemon.crit \
|
||||
"shaterd is CRASH-LOOPING: $gens distinct daemons in the last ${TICK}s (absent in $absent of $CHURN_N samples), $churn such windows in a row — it is being respawned faster than it can bring an engine up. kill_switch=open, so shater is being STOPPED: interception comes down and the LAN returns to plain, UNPROXIED routing. Find the reason with 'logread -e shaterd', then '/etc/init.d/shater start'."
|
||||
"$SHATER_INIT" stop
|
||||
churn=0
|
||||
quiet="$LOOP_REPORT_TICKS"
|
||||
;;
|
||||
log)
|
||||
# Rate-limited: a standing condition, not an event. Never
|
||||
# silent for good, though — an operator who looks at the log an
|
||||
# hour later must still find it being said.
|
||||
if [ "$quiet" -le 0 ]; then
|
||||
_slog -p daemon.crit \
|
||||
"shaterd is CRASH-LOOPING: $gens distinct daemons in the last ${TICK}s (absent in $absent of $CHURN_N samples), $churn such windows in a row — it is being respawned faster than it can bring an engine up. kill_switch=${ks:-closed} keeps the fail-closed plane standing, so the LAN stays blocked and the admin panel is down with the daemon that serves it. Nothing is decided for you: find the reason with 'logread -e shaterd', or open the LAN with '/etc/init.d/shater stop'."
|
||||
quiet="$LOOP_REPORT_TICKS"
|
||||
fi
|
||||
churn=0
|
||||
;;
|
||||
esac
|
||||
[ "$quiet" -gt 0 ] && quiet=$(( quiet - 1 ))
|
||||
else
|
||||
dead=0
|
||||
churn=0
|
||||
quiet=0
|
||||
fi
|
||||
sleep "$TICK"
|
||||
# Sleeps out the tick, sampling the daemon's identity while it does.
|
||||
shater_tick_sample
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
@@ -36,31 +36,344 @@ mkdir -p /etc/shater
|
||||
# transaction, and any /etc/init.d/* invocation in that window risks blocking
|
||||
# the transaction on rc.common's per-service flock (procd_lock).
|
||||
|
||||
# Seed the built-in preset packs (disabled) so the LuCI Rules page renders their
|
||||
# toggles. Idempotent: only creates a section that does not yet exist.
|
||||
seed_preset() {
|
||||
local sid="$1" name="$2" s n
|
||||
uci -q get "shater.$sid" >/dev/null 2>&1 && return 0
|
||||
# A pack section may already exist under a DIFFERENT section id (created by
|
||||
# the LuCI seeding or an older release) — match by pack name, not just id,
|
||||
# or we would duplicate the toggle.
|
||||
for s in $(uci -q show shater 2>/dev/null | sed -n "s/^shater\.\([^.=]*\)=preset$/\1/p"); do
|
||||
n=$(uci -q get "shater.$s.name")
|
||||
[ "$n" = "$name" ] && return 0
|
||||
# NO preset packs are seeded here, and the ones older releases seeded are removed.
|
||||
#
|
||||
# Until now this script created three `config preset` sections (block_ads,
|
||||
# ru_bypass, private; all `enabled=0`) "so the LuCI Rules page renders their
|
||||
# toggles". Both halves of that stopped being true in v0.2, and what was left was
|
||||
# a knob wired to nothing:
|
||||
#
|
||||
# * `preset` IS NOT A SECTION TYPE. The type switch in model.ParseUCIExport
|
||||
# (shater/model/uci.go) has no `preset` branch, and an unknown section type is
|
||||
# dropped on the floor rather than rejected — TestUnknownSectionAndOptionIgnored
|
||||
# pins that a config carrying one still parses, because the daemon has to come
|
||||
# up on whatever it finds. So `uci set shater.block_ads.enabled=1; uci commit`
|
||||
# edited the file and changed NOTHING about the running router, and there was
|
||||
# no error anywhere to say so. Nothing in shater/, panel/ or luci-app-shater
|
||||
# reads the type either.
|
||||
# * v0.2's luci-app-shater is a launcher for the daemon's own admin panel. There
|
||||
# is no LuCI Rules page for the toggles to appear on.
|
||||
# * The sections did not even persist. model.writeUCIWith replaces the WHOLE
|
||||
# package (`uci delete shater` + `uci import`), so the first save from the
|
||||
# panel deleted all three. A placeholder that erases itself reads as "this
|
||||
# broke", not as "this was never here" — which is worse than its absence.
|
||||
#
|
||||
# So the packs are not "missing": nothing in v0.2 lost a feature when the sections
|
||||
# stopped being written, because nothing ever read them. And re-adding the seed is
|
||||
# not how presets would come back. v0.1's packs were xray `geosite:`/`geoip:`
|
||||
# matcher lists materialised into synthetic rules (`xrayctl/preset.go` on the v0.1
|
||||
# branch); in v0.2 a rule's destination IS a `config ruleset` (schema v2), so the
|
||||
# same pack is an ordinary ruleset + rule — which the panel's Routing page already
|
||||
# builds, geosite/geoip sources included. Anything richer needs a section type the
|
||||
# parser knows about, which has to land in shater/model FIRST; seeding UCI ahead
|
||||
# of the parser only produces silence.
|
||||
#
|
||||
# The purge is narrow and safe by construction: it matches on the section TYPE
|
||||
# being exactly `preset`, and that type is read by no consumer, so there is no
|
||||
# setting to lose. Bounded and re-querying each round because a `config preset`
|
||||
# may also be ANONYMOUS (`shater.@preset[0]`), where deleting from a list captured
|
||||
# up front would shift the remaining indices out from under it.
|
||||
purge_presets() {
|
||||
local s n=0 changed=""
|
||||
[ -f /etc/config/shater ] || return 0
|
||||
while [ "$n" -lt 32 ]; do
|
||||
s=$(uci -q show shater 2>/dev/null |
|
||||
sed -n 's/^shater\.\([^.=]*\)=preset$/\1/p' | head -n 1)
|
||||
[ -n "$s" ] || break
|
||||
uci -q delete "shater.$s" || break
|
||||
changed=1
|
||||
n=$((n + 1))
|
||||
done
|
||||
uci set "shater.$sid=preset"
|
||||
uci set "shater.$sid.name=$name"
|
||||
uci set "shater.$sid.enabled=0"
|
||||
[ -n "$changed" ] && uci -q commit shater
|
||||
return 0
|
||||
}
|
||||
if uci -q get shater.globals >/dev/null 2>&1 || [ -f /etc/config/shater ]; then
|
||||
seed_preset block_ads block-ads
|
||||
seed_preset ru_bypass ru-bypass
|
||||
seed_preset private private
|
||||
uci -q commit shater
|
||||
fi
|
||||
purge_presets
|
||||
|
||||
# Introduce the daemon-created `shater-l3*` TUN to fw4 (L3 ingress, D-L3). The
|
||||
# daemon policy-routes LAN ICMP into that device from OUR nft table
|
||||
# `inet shater`, but nftables runs EVERY table on every packet and a drop in
|
||||
# any one of them wins — an accept in `inet shater` cannot override fw4. And
|
||||
# fw4 WILL drop this forward: netifd knows nothing about a device the daemon
|
||||
# creates at runtime, so it belongs to no zone and falls into fw4's zone-less
|
||||
# defaults (REJECT). The device has to be declared to fw4 itself; it cannot be
|
||||
# fixed from our own table.
|
||||
#
|
||||
# Seeded UNCONDITIONALLY (not gated on globals.l3_tunnel): uci-defaults run
|
||||
# once, so gating on the option would require re-running this script when the
|
||||
# option is flipped later — which never happens. An idle zone is harmless: its
|
||||
# device match is a plain iifname/oifname STRING compare that simply never hits
|
||||
# while the TUN does not exist.
|
||||
#
|
||||
# Idempotency: `config zone`/`config forwarding` are normally ANONYMOUS
|
||||
# sections, and a naive `uci add firewall zone` would append a duplicate on
|
||||
# every re-run (uci-defaults re-run on package upgrade/reinstall). The zone is
|
||||
# NAMED instead, guarded by an existence check — a re-run re-finds the section
|
||||
# and touches nothing. The forwardings are named too where the name is free, but
|
||||
# their guard is a scan of the actual src/dest pairs, which is stronger; see
|
||||
# seed_l3_forwarding below.
|
||||
seed_l3_zone() {
|
||||
# No fw4 on this image (bare nftables build) => nothing drops the forward
|
||||
# on fw4's behalf and there is nothing to punch through.
|
||||
[ -f /etc/config/firewall ] || return 0
|
||||
if ! uci -q get firewall.shater_l3 >/dev/null; then
|
||||
uci set firewall.shater_l3=zone
|
||||
uci set firewall.shater_l3.name='shater_l3'
|
||||
uci set firewall.shater_l3.input='REJECT'
|
||||
uci set firewall.shater_l3.output='ACCEPT'
|
||||
uci set firewall.shater_l3.forward='REJECT'
|
||||
uci set firewall.shater_l3.masq='0'
|
||||
# INERT TODAY, kept for the day it is not. mtu_fix clamps forwarded TCP
|
||||
# MSS to the route MTU — but the L3 TUN is 65535 (deliberately: at any
|
||||
# smaller value the kernel fragments into the device, and the flow
|
||||
# dispatcher refuses to judge a fragment and lets the stack forge the
|
||||
# echo reply — see l3MTU in shater/generate/inbound.go), so the clamp has
|
||||
# nothing to clamp to. And only ICMP is ever marked into this device, so
|
||||
# no TCP rides here to be clamped in the first place. It earns its keep
|
||||
# the moment either of those changes; removing it would make that day
|
||||
# silent.
|
||||
uci set firewall.shater_l3.mtu_fix='1'
|
||||
# `list device`, deliberately NOT the usual `list network`: fw4
|
||||
# resolves a zone's networks through netifd, and netifd never learns
|
||||
# about a device the daemon creates at runtime — a stub interface
|
||||
# (proto none) would need to be brought UP to contribute an l3_device,
|
||||
# and nothing ever brings it up, so `list network` resolves to an
|
||||
# EMPTY device set and fw4 keeps dropping the forward. `list device`
|
||||
# instead compiles to an iifname/oifname STRING match, valid before
|
||||
# the TUN exists and matching from the moment shaterd creates it —
|
||||
# no netifd involvement and no firewall reload at enable time. Do not
|
||||
# "normalize" this to `list network` in a refactor; it breaks silently.
|
||||
#
|
||||
# The WILDCARD is load-bearing too. The daemon no longer opens one fixed
|
||||
# device: it alternates between `shater-l3a` and `shater-l3b` so that a
|
||||
# new engine generation never has to reopen the name the previous one is
|
||||
# still holding (that collision — TUNSETIFF: device or resource busy —
|
||||
# took the whole LAN down on the production router, because the recovery
|
||||
# path rebuilt the same config and hit the same busy name). fw4 compiles
|
||||
# `shater-l3*` to `iifname "shater-l3*"` / `oifname "shater-l3*"`,
|
||||
# verified on ImmortalWrt 25.12.1 with nftables 1.1.6, so ONE zone covers
|
||||
# every slot and no firewall reload is needed when the slot changes.
|
||||
uci add_list firewall.shater_l3.device='shater-l3*'
|
||||
fi
|
||||
seed_l3_forwardings
|
||||
uci -q commit firewall
|
||||
}
|
||||
|
||||
# EVERY zone gets a forwarding into shater_l3, not just `lan`.
|
||||
#
|
||||
# The bug this closes is silent by construction. The daemon's divert set is built
|
||||
# from every enabled `config inbound`'s network PLUS every device a rule names
|
||||
# through an `iface:`/`zone:` source (shater/netplane/nft.go, nftDivertRefs) — so
|
||||
# on a router with several LAN zones, ICMP from ALL of them is marked and routed
|
||||
# into the TUN by our table. Our table then accepts it and fw4 drops it anyway,
|
||||
# because the forward is judged in `forward_<source zone>` and only `lan` had a
|
||||
# jump to `accept_to_shater_l3`. Result: ping through the tunnel works from one
|
||||
# subnet and not from the next, with nothing in any log to say why — fw4's drop
|
||||
# is the zone's policy verdict, not a rule with a name. The owner's production
|
||||
# router has a single LAN zone, which is exactly why this went unnoticed; his
|
||||
# second router has three.
|
||||
#
|
||||
# Every zone, including an uplink zone, and that is deliberate rather than lazy:
|
||||
#
|
||||
# - The alternative is guessing which zones hold clients, and every available
|
||||
# signal is wrong somewhere. `masq='1'` marks the WAN on a stock config and
|
||||
# also marks a double-NAT LAN. The name `wan*` is a convention, not a rule.
|
||||
# A guess that is wrong reintroduces exactly the silent breakage above, while
|
||||
# a superfluous entry costs a line of ruleset.
|
||||
# - A forwarding into shater_l3 permits nothing on its own. It authorises the
|
||||
# forward of packets ROUTED INTO the TUN, and the only thing that routes a
|
||||
# packet there is our own fwmark rule, which matches solely on the divert
|
||||
# device set. A packet arriving on the WAN is not marked and never reaches
|
||||
# this decision; if an operator ever puts a WAN device in the divert set,
|
||||
# they meant to and this is the entry that makes it work.
|
||||
# - The reverse direction is NOT opened: no `src shater_l3` forwarding exists,
|
||||
# so nothing comes out of the TUN into a zone by way of these sections. The
|
||||
# engine's own replies return on the conntrack `established,related accept`
|
||||
# at the top of fw4's forward chain.
|
||||
#
|
||||
# LIMIT, stated because it is not obvious: this is a SNAPSHOT. uci-defaults run
|
||||
# at first boot and on package install/upgrade, so a zone created AFTER the last
|
||||
# shater-core install has no forwarding until the next one. Re-running this
|
||||
# script (or reinstalling the package) re-seeds. The durable fix belongs in the
|
||||
# daemon, which recomputes the divert set on every apply and already knows which
|
||||
# zones are in it; it is deliberately not attempted from here.
|
||||
seed_l3_forwardings() {
|
||||
uci -q show firewall 2>/dev/null |
|
||||
sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p" |
|
||||
while read -r sid; do
|
||||
zone=$(uci -q get "firewall.$sid.name")
|
||||
# Unnamed zone: fw4 cannot reference it from a forwarding either.
|
||||
[ -n "$zone" ] || continue
|
||||
# Our own zone: a forwarding from shater_l3 to itself is meaningless.
|
||||
[ "$zone" = "shater_l3" ] && continue
|
||||
seed_l3_forwarding "$zone"
|
||||
done
|
||||
}
|
||||
|
||||
# One `config forwarding` <zone> -> shater_l3, created only if no such forwarding
|
||||
# exists yet.
|
||||
#
|
||||
# The guard scans the ACTUAL src/dest pairs rather than trusting a section id,
|
||||
# which covers all three ways one can already be there: the legacy named section
|
||||
# `shater_l3_fwd` seeded by earlier releases (src=lan), the per-zone names this
|
||||
# function writes, and an anonymous one an operator added by hand. Without that,
|
||||
# a re-run — uci-defaults re-run on every package upgrade — would append a
|
||||
# duplicate for `lan` on every upgrade.
|
||||
seed_l3_forwarding() {
|
||||
local zone="$1" sid found
|
||||
|
||||
found=$(uci -q show firewall 2>/dev/null |
|
||||
sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p" |
|
||||
while read -r f; do
|
||||
[ "$(uci -q get "firewall.$f.dest")" = "shater_l3" ] || continue
|
||||
[ "$(uci -q get "firewall.$f.src")" = "$zone" ] || continue
|
||||
echo yes
|
||||
break
|
||||
done)
|
||||
[ -n "$found" ] && return 0
|
||||
|
||||
# Section ids are [a-zA-Z0-9_] only, while a zone name may legally carry a
|
||||
# hyphen — sanitise, and keep the legacy id for `lan` so an existing install
|
||||
# is recognised as already seeded rather than gaining a second section.
|
||||
if [ "$zone" = "lan" ]; then
|
||||
sid="shater_l3_fwd"
|
||||
else
|
||||
sid="shater_l3_fwd_$(printf '%s' "$zone" | sed 's/[^a-zA-Z0-9_]/_/g')"
|
||||
fi
|
||||
# The id may still be taken — by a section for a DIFFERENT zone whose name
|
||||
# sanitises to the same thing, or by something else entirely. Fall back to an
|
||||
# anonymous section rather than overwrite: the src/dest scan above is what
|
||||
# makes this idempotent, the name is only there to be readable.
|
||||
if uci -q get "firewall.$sid" >/dev/null; then
|
||||
sid=$(uci add firewall forwarding) || return 0
|
||||
else
|
||||
uci set "firewall.$sid=forwarding"
|
||||
fi
|
||||
uci set "firewall.$sid.src=$zone"
|
||||
uci set "firewall.$sid.dest=shater_l3"
|
||||
}
|
||||
seed_l3_zone
|
||||
|
||||
# Upgrade path for routers seeded by a pre-slot build.
|
||||
#
|
||||
# The block above only runs when the zone does NOT exist, which is exactly right
|
||||
# for idempotency and exactly wrong here: an already-installed router has the
|
||||
# zone with the OLD exact device `shater-l3`, that name matches no slot, and fw4
|
||||
# would go back to dropping the forward — i.e. LAN ping through the tunnel dies
|
||||
# silently on upgrade while everything reports healthy. Rewrite it in place.
|
||||
#
|
||||
# Narrow on purpose: only the literal legacy entry is replaced, and only when the
|
||||
# wildcard is not already listed, so an operator who added devices of their own
|
||||
# keeps them and a re-run changes nothing (uci-defaults re-run on every package
|
||||
# upgrade). No `fw4 reload` here — uci-defaults run before the firewall starts on
|
||||
# boot, and on a package upgrade the daemon's next apply is what needs the zone,
|
||||
# not this script.
|
||||
migrate_l3_zone_wildcard() {
|
||||
[ -f /etc/config/firewall ] || return 0
|
||||
uci -q get firewall.shater_l3 >/dev/null || return 0
|
||||
devs=$(uci -q get firewall.shater_l3.device) || return 0
|
||||
case " $devs " in
|
||||
*" shater-l3* "*) return 0 ;; # already migrated
|
||||
*" shater-l3 "*) ;; # legacy exact name present
|
||||
*) return 0 ;;
|
||||
esac
|
||||
uci -q del_list firewall.shater_l3.device='shater-l3'
|
||||
uci add_list firewall.shater_l3.device='shater-l3*'
|
||||
uci -q commit firewall
|
||||
}
|
||||
migrate_l3_zone_wildcard
|
||||
|
||||
# Bring the UCI schema forward on upgrade (idempotent; refuses a newer schema).
|
||||
[ -x /usr/bin/shaterd ] && /usr/bin/shaterd migrate >/dev/null 2>&1
|
||||
#
|
||||
# THE RESULT IS NO LONGER THROWN AWAY. `>/dev/null 2>&1` discarded stdout, stderr
|
||||
# AND the exit status, so a refusal here was indistinguishable from success — on
|
||||
# the one screen the operator who caused it is actually looking at.
|
||||
#
|
||||
# WHY THIS STILL `exit 0`s (the script ends with one, and this block does not
|
||||
# change that). A uci-defaults script that exits non-zero is NOT deleted and runs
|
||||
# again at every boot. That is the wrong trade here, three times over:
|
||||
#
|
||||
# * This file does far more than migrate — it seeds rt_tables, the shater_l3
|
||||
# fw4 zone and its per-zone forwardings, applies sysctl, and launches a
|
||||
# DETACHED BRING-UP that enables and RESTARTS shater/shater-cron and reloads
|
||||
# the firewall. Re-running all of that at every boot to carry one bit of "the
|
||||
# migration failed" would bounce the tunnel on every boot, after S99 had
|
||||
# already started it. One recoverable failure would become permanent churn.
|
||||
# * The exit status is not a reporting channel: nothing reads a uci-defaults
|
||||
# script's status, and neither procd nor the package manager surfaces it. It
|
||||
# buys no diagnosis, only the re-run.
|
||||
# * The retry it would buy already exists, and is better. /etc/init.d/shater
|
||||
# runs the same migration on EVERY start with the full classified report, so
|
||||
# a failure is retried at every boot and every restart regardless. And
|
||||
# re-running cannot fix either real cause anyway: a downgrade is fixed by
|
||||
# installing the right package, a full /overlay by freeing space.
|
||||
#
|
||||
# So: capture the status, name the cause, record it durably, and exit 0. The
|
||||
# script has done everything it can do, and the failure is not lost.
|
||||
#
|
||||
# The classification is the same closed positive list /etc/init.d/shater uses
|
||||
# (shater_migrate_class there, with the long argument for why the list is closed
|
||||
# and why `downgrade` is matched on the binary's own words). It is repeated here
|
||||
# rather than shared because the package installs no shell library the two could
|
||||
# both source; TestMigrateClassifiersAgree (shater/cmd/shaterd) runs both over
|
||||
# the same inputs and fails if they ever disagree.
|
||||
SHATER_MIGRATE_BREADCRUMB=/etc/shater/migrate-failed
|
||||
|
||||
shater_migrate_class() {
|
||||
local rc="$1" out="$2"
|
||||
[ "$rc" = "0" ] && { echo ok; return 0; }
|
||||
case "$out" in
|
||||
*"newer than this build"*) echo downgrade; return 0 ;;
|
||||
esac
|
||||
uci -q export shater >/dev/null 2>&1 || { echo unreadable; return 0; }
|
||||
echo failed
|
||||
}
|
||||
|
||||
# stderr, unconditionally: inside `apk add` / `opkg install` that is the package
|
||||
# manager's own output, i.e. the installing operator's screen. Plus the durable
|
||||
# breadcrumb on flash, which /etc/init.d/shater removes on the first successful
|
||||
# migration. Deliberately NOT syslog — globals.log_syslog may be off by the
|
||||
# operator's choice, and this path honours it by using other channels instead.
|
||||
shater_migrate_shout() {
|
||||
echo "shater: $*" >&2
|
||||
mkdir -p "$(dirname "$SHATER_MIGRATE_BREADCRUMB")" 2>/dev/null
|
||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" \
|
||||
> "$SHATER_MIGRATE_BREADCRUMB" 2>/dev/null || :
|
||||
}
|
||||
|
||||
run_migrate() {
|
||||
local out rc class schema
|
||||
[ -x /usr/bin/shaterd ] || return 0
|
||||
|
||||
out=$(/usr/bin/shaterd migrate 2>&1)
|
||||
rc=$?
|
||||
class=$(shater_migrate_class "$rc" "$out")
|
||||
schema=$(uci -q get shater.globals.schema_version)
|
||||
case "$schema" in ""|*[!0-9]*) schema=0 ;; esac
|
||||
|
||||
case "$class" in
|
||||
ok)
|
||||
rm -f "$SHATER_MIGRATE_BREADCRUMB"
|
||||
;;
|
||||
downgrade)
|
||||
shater_migrate_shout "install: UCI schema migration REFUSED — /etc/config/shater is schema v$schema and the shater build being installed understands an older one, so this is a DOWNGRADE. Nothing was migrated and nothing on disk was changed: your settings are intact, and also unchangeable, because the daemon and the panel refuse every config write for the same reason. Install a build that understands schema v$schema again (docs-shater/INSTALL.md has the pinned per-version feed). 'shaterd migrate' said: ${out:-no output}"
|
||||
;;
|
||||
unreadable)
|
||||
shater_migrate_shout "install: UCI schema migration FAILED and /etc/config/shater CANNOT BE READ ('uci export shater' fails), so the schema on disk cannot even be named. Check the file by hand before configuring anything. 'shaterd migrate' said: ${out:-no output}"
|
||||
;;
|
||||
failed)
|
||||
shater_migrate_shout "install: UCI schema migration FAILED for a reason this script does not recognise; the config is still at schema v$schema. The mundane cause is a full /overlay ('uci commit' cannot write) — check 'df /overlay'. /etc/init.d/shater retries this on every start and reports it there too. 'shaterd migrate' said: ${out:-no output}"
|
||||
;;
|
||||
*)
|
||||
# Unreachable: shater_migrate_class returns a closed set, all of it
|
||||
# handled above. Named rather than swept up, so a script that disagrees
|
||||
# with itself says so instead of picking a confident branch and being
|
||||
# wrong quietly.
|
||||
shater_migrate_shout "install: INTERNAL — 'shaterd migrate' produced a result this script cannot classify (class='$class', rc=$rc). That is a bug in /etc/uci-defaults/30_shater-core. Output was: ${out:-no output}"
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
run_migrate
|
||||
|
||||
# Apply our sysctl knobs NOW (boot applies them via procd's sysctl service, but
|
||||
# on a live opkg/apk install nothing else re-reads sysctl.d — without this, an
|
||||
@@ -110,8 +423,27 @@ SHATER_BRINGUP='
|
||||
done
|
||||
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable
|
||||
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron enable
|
||||
# The boot-time fail-closed armor. `enable` only — it is a one-shot that loads
|
||||
# the persisted holding plane at START=21, and running it NOW would install a
|
||||
# block on a live box moments before the daemon replaces it anyway. It has to
|
||||
# be enabled here regardless of whether the stack is on: the file it loads only
|
||||
# exists while the daemon wants it to, so an enabled-but-unarmed service is a
|
||||
# no-op, and enabling it later would mean the first boot after an upgrade is
|
||||
# the one boot still exposed.
|
||||
[ -x /etc/init.d/shater-armor ] && /etc/init.d/shater-armor enable
|
||||
[ -x /etc/init.d/shater ] && /etc/init.d/shater restart
|
||||
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart
|
||||
# Fold the seeded shater_l3 zone into the LIVE ruleset — matters on a live
|
||||
# opkg/apk install only, where firewall started long before our commit and
|
||||
# nothing else would re-read it until the next reboot. Gated on the fw4
|
||||
# table actually being loaded: at FIRST boot this job can run before the
|
||||
# S19 firewall start, and an early reload would install a ruleset built
|
||||
# from a half-initialized netifd AND make the later start a no-op (fw4
|
||||
# start skips when its table already exists). No table => the pending S19
|
||||
# start reads the committed config by itself, no reload needed.
|
||||
if nft list tables 2>/dev/null | grep -q "inet fw4"; then
|
||||
[ -x /etc/init.d/firewall ] && /etc/init.d/firewall reload
|
||||
fi
|
||||
exit 0
|
||||
'
|
||||
SHATER_TMO=""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# /lib/upgrade/keep.d/shater-core — what sysupgrade and LuCI "Backup" must carry
|
||||
# out of /etc/shater.
|
||||
#
|
||||
# HOW THIS FILE IS READ. /sbin/sysupgrade (base-files, list_static_conffiles):
|
||||
#
|
||||
# find $(sed -ne '/^[[:space:]]*$/d; /^#/d; p' \
|
||||
# /etc/sysupgrade.conf /lib/upgrade/keep.d/* 2>/dev/null) \
|
||||
# \( -type f -o -type l \) $filter 2>/dev/null
|
||||
#
|
||||
# so blank lines and lines starting with '#' are stripped, and every other line is
|
||||
# a path handed to `find`: a directory is recursed, a path that does not exist is
|
||||
# silently skipped (hence a trailing '/' for the two directories, and no need to
|
||||
# guard for a fresh install that has neither). The result is tarred and, on a real
|
||||
# sysupgrade, HELD IN RAM across the flash — which is why this is a per-file
|
||||
# decision and not simply "/etc/shater/".
|
||||
#
|
||||
# WHY IT EXISTS. Everything the product knows besides /etc/config/shater lives in
|
||||
# /etc/shater, and nothing shipped a keep.d entry for it. A "keep settings"
|
||||
# sysupgrade, or a LuCI backup restored onto a new router, therefore produced a
|
||||
# box whose config looked complete and whose node inventory was EMPTY — silently.
|
||||
#
|
||||
# /etc/config/shater is NOT listed here: it is declared in
|
||||
# Package/shater-core/conffiles, and sysupgrade backs CHANGED conffiles up on its
|
||||
# own (list_changed_conffiles). Listing it again would work, but it would claim
|
||||
# ownership of a mechanism that already covers it.
|
||||
|
||||
# THE NODE INVENTORY. Subscription-fetched nodes deliberately live OUTSIDE UCI
|
||||
# (shater/model/subcache.go) — one JSON file per subscription. Without them the
|
||||
# restored box has groups and rules that reference nodes which do not exist, so no
|
||||
# tunnel comes up, and the only repair is `sub update`, which needs the internet
|
||||
# the tunnel was supposed to be providing. Indented JSON: a few hundred KiB even
|
||||
# for a several-hundred-node subscription.
|
||||
/etc/shater/subs/
|
||||
|
||||
# THE BOOT-ARMOR ARM TOKEN. Its PRESENCE is what lets /etc/init.d/shater-armor
|
||||
# (START=21) load the fail-closed plane before fw4's `lan -> wan ACCEPT` is the
|
||||
# only rule on the box. Without it the first boot after a restore forwards LAN to
|
||||
# WAN in the clear until the daemon has built an engine. One small nft script.
|
||||
/etc/shater/boot.nft
|
||||
|
||||
# COMPILED LIST ARTIFACTS (.srs). Losing these fails SILENTLY in the worst
|
||||
# direction: a missing LOCAL rule-set is left out of the generated config and the
|
||||
# engine starts perfectly happily with the filtering simply gone
|
||||
# (shater/generate/ruleset.go, compiledListRuleSet). "It will re-download itself"
|
||||
# is NOT true for them either — a compiled url list is rebuilt only by the next
|
||||
# generate, and nothing schedules one (see the note in /etc/init.d/shater-cron
|
||||
# about `ruleset update`). Cheap to keep: compiled .srs is 3-6% of the source
|
||||
# text (~80 KiB for a 150k-domain list), under a 4 MiB soft cap.
|
||||
/etc/shater/lists/
|
||||
|
||||
# ALERT DE-DUPLICATION STATE. A few hundred bytes mapping subscription -> when its
|
||||
# expiry warning last fired. Without it every subscription already announced
|
||||
# announces itself again on the restored box — the exact re-alert storm the file
|
||||
# was created to prevent (shater/alert/expiry.go).
|
||||
/etc/shater/alert-state.json
|
||||
|
||||
# DELIBERATELY NOT KEPT. Each of these is history or cache, and the backup is
|
||||
# built in RAM:
|
||||
#
|
||||
# /etc/shater/stats.db Traffic/query HISTORY, not configuration. Bounded
|
||||
# only by globals.stats_disk_limit_mb, whose default is
|
||||
# 64 MB and whose 0 means UNLIMITED — one file able to
|
||||
# outweigh everything else here by two orders of
|
||||
# magnitude, and the only entry whose loss costs the
|
||||
# operator nothing but a chart.
|
||||
# /etc/shater/cache.db sing-box's own cache (8 MiB cap, deleted above it).
|
||||
# Rebuilt on demand by design, and a stale rule-set
|
||||
# cache carried onto a different box is worse than no
|
||||
# cache at all.
|
||||
# /etc/shater/shaterd.log A log (capped by globals.log_max_kb). A restored box
|
||||
# wants its own log, and this one carries the DNS query
|
||||
# history of the box it came from — which is not
|
||||
# something to move into an archive a person then puts
|
||||
# somewhere else.
|
||||
#
|
||||
# ON SECRECY, since this archive routinely ends up in cloud storage: subs/*.json
|
||||
# carries every node's credentials (UUID/password/keys). That is not a NEW
|
||||
# exposure — /etc/config/shater already carries the subscription URLs and every
|
||||
# manual node's credentials, and it is already in the backup as a conffile — but a
|
||||
# shater backup is a secret-bearing file and should be treated as one.
|
||||
@@ -34,8 +34,17 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shaterd
|
||||
PKG_VERSION:=0.2.0
|
||||
PKG_RELEASE:=2
|
||||
|
||||
# VERSIONING — derived from the git tag, NOT hand-maintained here (bug B4).
|
||||
# ci/version.sh turns `git describe` into SHATER_PKG_VERSION/SHATER_PKG_RELEASE
|
||||
# (tag vX.Y.Z -> X.Y.Z + r1; off-tag -> last tag + r<commits+1>), and
|
||||
# ci/build-feed-apk.sh exports them into the SDK build env. ci/sdk-build-apk.sh
|
||||
# then ASSERTS that the produced .apk really carries that version, so a lost env
|
||||
# can never silently ship a stale one again.
|
||||
# The literals below are ONLY the manual/offline fallback (no CI, no git) — they
|
||||
# are not "the release version"; releases are named by the tag.
|
||||
PKG_VERSION:=$(if $(SHATER_PKG_VERSION),$(SHATER_PKG_VERSION),0.2.0)
|
||||
PKG_RELEASE:=$(if $(SHATER_PKG_RELEASE),$(SHATER_PKG_RELEASE),1)
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
@@ -95,8 +104,8 @@ define Package/shaterd/install
|
||||
$(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd
|
||||
endef
|
||||
|
||||
# This package ships ONLY the binary — no init script — so opkg's default
|
||||
# postinst never touches the running service. On `opkg upgrade shaterd` the new
|
||||
# This package ships ONLY the binary — no init script — so the package manager's
|
||||
# postinst never touches the running service. On `apk upgrade shaterd` the new
|
||||
# ELF lands at /usr/bin/shaterd while the OLD image keeps running from its
|
||||
# unlinked inode: the upgrade silently has no effect until the next reboot, and
|
||||
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
|
||||
|
||||
@@ -18,6 +18,32 @@ type URLTestOutboundOptions struct {
|
||||
// lx: SPEC 019 v2 — load-balancing.
|
||||
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
|
||||
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
|
||||
// lx: health board §5.C — SelfCheck stands the group's OWN background
|
||||
// health-check up or down. nil/absent == true, so every existing config keeps
|
||||
// today's behaviour.
|
||||
//
|
||||
// Why this exists at all: a urltest group probes its members BY ITSELF — a
|
||||
// warm-up sweep at PostStart and a ticker for as long as traffic keeps
|
||||
// touching it — and it dials the members' outbounds DIRECTLY, from the
|
||||
// router, over whatever the default WAN route is. For a group that traffic
|
||||
// actually flows through, that is exactly right: the probe travels the same
|
||||
// path the connections do. But for a group NO routing rule reaches, that
|
||||
// same probe measures a path nothing uses — and it stores the result under
|
||||
// the members' BASE tags, which every health consumer then reads as "the
|
||||
// node's health". A node that is blocked on the direct WAN and perfectly
|
||||
// alive behind a tunnel therefore reads "dead" the moment such a group
|
||||
// probes it; the reading is not merely stale, it is FALSE, and it poisons
|
||||
// the shared board for everyone (selection, the panel, the observatory's
|
||||
// freshness gate). SelfCheck=false is how the control plane stands such a
|
||||
// group's own schedule down: the shater engine computes which groups the
|
||||
// applied rules actually reach (the observatory's used-set) and disables
|
||||
// the self-check on the rest, so the ONLY prober left is the observatory —
|
||||
// which probes along the real dial paths and nothing else.
|
||||
//
|
||||
// The flag suppresses only the group's own SCHEDULE (the PostStart warm-up
|
||||
// and the Touch ticker). An EXPLICIT CheckOutbounds/URLTest call — the
|
||||
// adapter interface a human or an API invokes on purpose — still works.
|
||||
SelfCheck *bool `json:"self_check,omitempty"`
|
||||
}
|
||||
|
||||
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test src/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -262,6 +262,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- fixture band (dev builds only; see App.tsx MockBanner) ----
|
||||
Deliberately outside the crit/amber vocabulary: nothing is wrong with the
|
||||
router, there is no router. The hazard hatch is the service-sticker language a
|
||||
piece of network hardware already uses for "this unit is not in service". */
|
||||
.mock-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1.5);
|
||||
margin-top: calc(var(--u, 8px) * 2);
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed var(--faint);
|
||||
border-radius: 9px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--sink),
|
||||
var(--sink) 9px,
|
||||
var(--panel) 9px,
|
||||
var(--panel) 18px
|
||||
);
|
||||
}
|
||||
.mock-band-tag {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--faint);
|
||||
border-radius: 4px;
|
||||
background: var(--raised);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--dim);
|
||||
}
|
||||
.mock-band-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mock-band-headline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mock-band-detail {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--dim);
|
||||
max-width: 76ch;
|
||||
}
|
||||
.mock-band-detail code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- commit-confirm band (every page except Apply, which has the full panel) ----
|
||||
Same plate as the protection banner so the two read as one family; the seconds
|
||||
are the loud element because they are the only thing that is running out. */
|
||||
.cfm-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1.5);
|
||||
margin-top: calc(var(--u, 8px) * 2);
|
||||
padding: 10px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--groove));
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 10%, var(--raised)), var(--raised));
|
||||
box-shadow: 0 1px 0 var(--edge) inset;
|
||||
}
|
||||
.cfm-band-count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--amber);
|
||||
}
|
||||
.cfm-band-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.cfm-band-unit {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.cfm-band-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cfm-band-headline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cfm-band-detail {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--dim);
|
||||
max-width: 76ch;
|
||||
}
|
||||
.cfm-band-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cfm-band-link {
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--raised);
|
||||
}
|
||||
.cfm-band-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cfm-band {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfm-band-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- last-apply findings (Overview) ----
|
||||
Severity carries the colour; the accent is reserved for interactive controls. */
|
||||
.findings {
|
||||
@@ -288,6 +432,18 @@
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--faint);
|
||||
}
|
||||
/* Findings come from the last SUCCESSFUL apply. When the config on disk was
|
||||
refused, that is a different configuration from the one the reader just saved —
|
||||
and under a heading reading "Last apply" a clean list means "your edit is
|
||||
fine". A sentence about the whole list, so it gets its own line above it rather
|
||||
than a third cell in the header, where 390 px left it four words a column. */
|
||||
.findings-stale {
|
||||
margin: calc(var(--u, 8px) * 1.25) 0 0;
|
||||
max-width: 76ch;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--crit);
|
||||
}
|
||||
.findings-list {
|
||||
margin: calc(var(--u, 8px) * 1.5) 0 0;
|
||||
padding: 0;
|
||||
@@ -312,6 +468,13 @@
|
||||
.finding--warning {
|
||||
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
|
||||
}
|
||||
/* The daemon's "the list is capped" disclosure. Dashed, because the row is about
|
||||
what ISN'T here — it must not read as one more finding to work through. */
|
||||
.finding--truncated {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
|
||||
background: var(--panel);
|
||||
}
|
||||
.finding-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -405,3 +568,221 @@
|
||||
color: var(--dim);
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
/* ---- inline rename (shared) ----
|
||||
The pencil-in-the-row interaction: click the ✎ beside a name, type over it,
|
||||
Enter commits / Esc cancels / blur commits. Lifted out of Devices.css when
|
||||
Nodes grew the same affordance — one interaction, one set of rules, so the two
|
||||
pages can never drift apart. `--locked` is the same control with the action
|
||||
withheld: it stays visible and focusable-looking so a missing rename reads as
|
||||
a stated rule, not a dead button. */
|
||||
.inline-rename {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.inline-rename:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.inline-rename:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inline-rename:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
/* Withheld, not broken: keep the glyph readable and let the cursor say "there is
|
||||
a reason" rather than dimming it into invisibility. */
|
||||
.inline-rename--locked {
|
||||
opacity: 0.75;
|
||||
cursor: help;
|
||||
}
|
||||
.inline-rename--locked:hover {
|
||||
color: var(--dim);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.inline-rename-input {
|
||||
min-width: 0;
|
||||
max-width: 24ch;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--sink);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 1px 2px var(--shadow) inset;
|
||||
}
|
||||
.inline-rename-input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inline-rename-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ---- pre-apply hazard band (Apply.tsx ApplyRiskBand ← planeState.applyRisk) ----
|
||||
Shown on Apply and Overview when pressing the button below would leave the
|
||||
network with no way out.
|
||||
|
||||
It borrows the CRIT vocabulary because the outcome really is crit-severity, but
|
||||
a forecast must not be mistaken for an observed fault — the panel's other crit
|
||||
bands all report something that has already happened. Two things keep them
|
||||
apart: the band leads with an eyebrow naming the tense, and it ends in the
|
||||
ACCENT rather than in more red. Crit says how bad this is; the accent marks the
|
||||
controls that prevent it. Deliberately taller and quieter-edged than
|
||||
.plane-band, because unlike a banner this one is meant to be read, not
|
||||
glanced at. */
|
||||
.risk-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--u, 8px) * 0.75);
|
||||
margin-top: calc(var(--u, 8px) * 2);
|
||||
padding: 14px 16px 15px;
|
||||
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
|
||||
border-left: 3px solid var(--crit);
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
|
||||
box-shadow: 0 1px 0 var(--edge) inset;
|
||||
}
|
||||
.risk-band-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1.25);
|
||||
}
|
||||
.risk-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--track-label-wide, 0.24em);
|
||||
text-transform: uppercase;
|
||||
color: var(--crit);
|
||||
}
|
||||
.risk-headline {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
}
|
||||
.risk-detail,
|
||||
.risk-undo {
|
||||
margin: 0;
|
||||
max-width: 76ch;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--dim);
|
||||
}
|
||||
/* The missing auto-rollback is the part that decides whether a mistake here costs
|
||||
two minutes or an SSH session, so it is the one line drawn at full ink. */
|
||||
.risk-undo.hot {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.risk-steps {
|
||||
margin: calc(var(--u, 8px) * 0.5) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--u, 8px) * 0.75);
|
||||
max-width: 76ch;
|
||||
}
|
||||
.risk-steps li {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* A square tick in the accent — the panel's "this is a control you touch" colour,
|
||||
and the only accent in a band that is otherwise entirely red. */
|
||||
.risk-steps li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.55em;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.risk-band {
|
||||
padding: 12px 13px 13px;
|
||||
}
|
||||
.risk-detail,
|
||||
.risk-undo,
|
||||
.risk-steps li {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the config on disk is not the one running (Overview.tsx NotAppliedBand) ----
|
||||
Same crit vocabulary and the same internals as .risk-band, and deliberately so:
|
||||
the severity is identical. What separates them is the TENSE, which the eyebrow
|
||||
states — the hazard band forecasts what a button would do, this one reports a
|
||||
state the box is already in. So this band ends in crit rather than in the
|
||||
accent: there is nothing to prevent any more, and the control that clears it is
|
||||
an edit to the configuration, not a button on this page.
|
||||
|
||||
It sits ABOVE the findings because it says what the findings are about. */
|
||||
.stale-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--u, 8px) * 0.75);
|
||||
margin-top: calc(var(--u, 8px) * 2);
|
||||
padding: 14px 16px 15px;
|
||||
border: 1px solid color-mix(in srgb, var(--crit) 55%, var(--groove));
|
||||
border-left: 3px solid var(--crit);
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--crit) 12%, var(--raised)), var(--raised));
|
||||
box-shadow: 0 1px 0 var(--edge) inset;
|
||||
}
|
||||
/* The step that refused — the one word an operator acts on, so it is the one
|
||||
thing here drawn at full ink inside a line of body copy. */
|
||||
.stale-stage {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
/* The daemon's reason, verbatim. Monospaced because it is machine text quoted
|
||||
into prose, and boxed so a long generator error cannot be mistaken for our own
|
||||
sentence about it. */
|
||||
.stale-cause {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--sink) 75%, transparent);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.stale-band {
|
||||
padding: 12px 13px 13px;
|
||||
}
|
||||
.stale-cause {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
+145
-9
@@ -1,12 +1,14 @@
|
||||
import './App.css'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Faceplate, FaceplateHeader, Led, Module } from './components'
|
||||
import { Button, Faceplate, FaceplateHeader, Led, Module } from './components'
|
||||
import type { LedVariant } from './components'
|
||||
import { ApiError, MOCK, getStatus } from './api'
|
||||
import { ApiError, MOCK, confirm as apiConfirm, getStatus } from './api'
|
||||
import type { Status } from './api'
|
||||
import { usePendingConfirm } from './pendingConfirm'
|
||||
import { bootstrapSession } from './session'
|
||||
import { ROUTES, navigate, useRoute } from './router'
|
||||
import { protectionState } from './planeState'
|
||||
import { engineState, protectionState, serviceIntent } from './planeState'
|
||||
import { truncationNote } from './findings'
|
||||
import type { Route } from './router'
|
||||
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
|
||||
|
||||
@@ -99,12 +101,102 @@ export function App() {
|
||||
footer={<StatusBar status={status} />}
|
||||
>
|
||||
<Nav route={route} />
|
||||
<MockBanner />
|
||||
<PlaneBanner status={status} route={route} />
|
||||
<ConfirmBand route={route} onChanged={() => void refreshStatus()} />
|
||||
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
|
||||
</Faceplate>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The commit-confirm countdown, on every page.
|
||||
*
|
||||
* The daemon arms an auto-rollback on EVERY apply, but only the Apply page ever
|
||||
* said so: press Apply on Routing, read "Applied", walk away, and the router
|
||||
* reverts a minute later with nothing on screen having mentioned it. This band
|
||||
* carries that deadline — and the button that stops it — to wherever the operator
|
||||
* actually is.
|
||||
*
|
||||
* Suppressed on Apply, which renders the full control room for the same window
|
||||
* (and reads the same record, so a reload no longer loses the countdown there
|
||||
* either).
|
||||
*/
|
||||
function ConfirmBand({ route, onChanged }: { route: Route; onChanged: () => void }) {
|
||||
const armed = usePendingConfirm()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Keeping the config is the only action offered here; rolling back early is a
|
||||
// deliberate act with its own before/after readout, and that lives on Apply.
|
||||
const keep = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await apiConfirm()
|
||||
if (r.error) setError(r.error)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'request failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
onChanged()
|
||||
}
|
||||
}, [onChanged])
|
||||
|
||||
if (!armed || route === 'apply') return null
|
||||
|
||||
return (
|
||||
<div className="cfm-band" role="alert">
|
||||
<Led variant="amber" pulse />
|
||||
<div className="cfm-band-count" role="timer" aria-label={`${armed.remaining} seconds until auto-rollback`}>
|
||||
<span className="cfm-band-num">{armed.remaining}</span>
|
||||
<span className="cfm-band-unit">s</span>
|
||||
</div>
|
||||
<div className="cfm-band-copy">
|
||||
<span className="cfm-band-headline">This config is live but not kept</span>
|
||||
<span className="cfm-band-detail">
|
||||
{error
|
||||
? `Couldn’t keep it — ${error}. Try again, or open Apply.`
|
||||
: 'Every apply arms an auto-rollback. Keep this config before the timer runs out, or the router reverts to the last-good one.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cfm-band-actions">
|
||||
<Button variant="primary" onClick={() => void keep()} disabled={busy}>
|
||||
{busy ? 'Keeping…' : 'Keep this config'}
|
||||
</Button>
|
||||
<a className="cfm-band-link" href="#/apply" onClick={() => navigate('apply')}>
|
||||
Apply page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Says, on every page, that nothing on screen came from a router.
|
||||
*
|
||||
* Only a DEV build can ever render this — the fixtures are not in a production
|
||||
* bundle (api.ts initMockBackend), so an operator cannot reach this state at all.
|
||||
* It is here for the person who CAN: a footer line reading "DEMO DATA" is easy to
|
||||
* work past for an afternoon and then screenshot into a bug report, and every
|
||||
* number above it is invented.
|
||||
*/
|
||||
function MockBanner() {
|
||||
if (!MOCK) return null
|
||||
return (
|
||||
<div className="mock-band" role="status">
|
||||
<span className="mock-band-tag">FIXTURES</span>
|
||||
<div className="mock-band-copy">
|
||||
<span className="mock-band-headline">No router is being read</span>
|
||||
<span className="mock-band-detail">
|
||||
Every reading on this page is invented by <code>src/mock.ts</code> for offline
|
||||
development. Drop <code>?mock</code> from the address to talk to a daemon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The protection state, pinned under the nav on every page EXCEPT Overview
|
||||
* (which shows the same state as its own headline readout — see planeState.ts).
|
||||
@@ -130,12 +222,15 @@ function PlaneBanner({ status, route }: { status: Status | null; route: Route })
|
||||
|
||||
const criticals = (status.warnings ?? []).filter((w) => w.severity === 'critical').length
|
||||
const state = protectionState(status)
|
||||
// The published list is capped at 50, so with a note attached the count is a
|
||||
// floor. Say "at least" rather than quoting a total the daemon didn't send.
|
||||
const atLeast = truncationNote(status.warnings) ? 'At least ' : ''
|
||||
|
||||
// Wording comes from the shared source of truth so the banner and Overview can
|
||||
// never describe the same router differently.
|
||||
const headline = state.alarm
|
||||
? state.headline
|
||||
: `${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply`
|
||||
: `${atLeast}${criticals} protection ${criticals === 1 ? 'gap' : 'gaps'} from the last apply`
|
||||
const detail = state.alarm
|
||||
? state.detail
|
||||
: 'Something you configured isn’t in effect. Review the findings before relying on it.'
|
||||
@@ -175,7 +270,9 @@ function Page({
|
||||
if (route === 'dns') return <DNS />
|
||||
if (route === 'targets') return <Targets />
|
||||
if (route === 'devices') return <Devices />
|
||||
if (route === 'insights') return <Insights />
|
||||
// Insights takes `status` for ONE reason: so it can say that its ten empty
|
||||
// sections are empty because the service is off. It polls its own stats.
|
||||
if (route === 'insights') return <Insights status={status} />
|
||||
if (route === 'settings') return <Settings />
|
||||
if (route === 'profiles') return <Profiles />
|
||||
if (route === 'apply') return <Apply />
|
||||
@@ -225,14 +322,46 @@ function StatusBar({ status }: { status: Status | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one lamp that is on screen no matter which page you are on.
|
||||
*
|
||||
* It used to read `status.running`, which the daemon hardcoded to `true` — so the
|
||||
* "Offline" branch could never be reached and the plate said "Online" through an
|
||||
* engine that had failed to start. It now asks {@link engineState}, whose whole
|
||||
* job is to be able to answer "down", and refuses to guess when nothing has been
|
||||
* reported: an unlit socket, not a green light.
|
||||
*
|
||||
* AND IT ASKS WHETHER THE ENGINE WAS SUPPOSED TO BE RUNNING. `engineState` alone
|
||||
* cannot tell a crashed engine from one nobody started: `Status.running` is the
|
||||
* DAEMON's liveness and `engine_running` is the ENGINE's, and neither is the
|
||||
* operator's switch. So the lamp above every page on a correctly installed,
|
||||
* not-yet-configured router was crit "Engine down" — the product's most visible
|
||||
* lamp reporting a failure that had not happened. {@link serviceIntent} is the
|
||||
* missing question, and only a positive `off` takes this branch: with the
|
||||
* configuration unreadable the intent is `unknown` and the crit stands, because
|
||||
* that is the case where the LAN really has been cut off.
|
||||
*/
|
||||
function masterIndicator(
|
||||
phase: Phase,
|
||||
status: Status | null,
|
||||
): { label: string; variant: LedVariant; pulse?: boolean } {
|
||||
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
|
||||
if (status.running && status.active) return { label: 'Online', variant: 'on', pulse: true }
|
||||
if (status.running) return { label: 'Standby', variant: 'amber' }
|
||||
return { label: 'Offline', variant: 'crit' }
|
||||
const engine = engineState(status)
|
||||
if (engine === 'down' && serviceIntent(status) === 'off') {
|
||||
// Named for the act, not the symptom: "Switched off" says a person did this
|
||||
// and can undo it, where "Engine down" says the appliance broke.
|
||||
return { label: 'Switched off', variant: 'off' }
|
||||
}
|
||||
switch (engine) {
|
||||
case 'down':
|
||||
return { label: 'Engine down', variant: 'crit' }
|
||||
case 'up':
|
||||
return status.active
|
||||
? { label: 'Online', variant: 'on', pulse: true }
|
||||
: { label: 'Standby', variant: 'amber' }
|
||||
default:
|
||||
return { label: 'Unknown', variant: 'off' }
|
||||
}
|
||||
}
|
||||
|
||||
function UnauthPlate() {
|
||||
@@ -240,8 +369,15 @@ function UnauthPlate() {
|
||||
<Faceplate ariaLabel="shater — not authenticated" header={<FaceplateHeader wordmark="SHATER" subline="v0.2 · openwrt appliance" />}>
|
||||
<div className="plate-msg">
|
||||
<Module name="Session" value="LOCKED" led={{ variant: 'amber' }}>
|
||||
{/* THE ONLY RECOVERY INSTRUCTION THE PRODUCT GIVES, so it has to point at
|
||||
the real menu entry. It said "System → shater"; the page is registered
|
||||
at `admin/services/shater` (luci-app-shater/root/usr/share/luci/menu.d/
|
||||
luci-app-shater.json, title "Shater"), which LuCI renders under
|
||||
SERVICES. Anyone reading this line has just lost access to the panel
|
||||
and is looking for the one door back — sending them to the wrong menu
|
||||
costs far more than its size. */}
|
||||
<p className="placeholder-note">
|
||||
No active session. Open the panel from the LuCI menu (System → shater →{' '}
|
||||
No active session. Open the panel from the LuCI menu (Services → Shater →{' '}
|
||||
<strong>Open panel</strong>) to hand off a fresh access token.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Editing an alert channel — and, above all, editing a secret the panel refuses
|
||||
// to show.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// What these protect:
|
||||
//
|
||||
// 1. AN EMPTY TOKEN BOX KEEPS THE STORED TOKEN. The row masks the bot token
|
||||
// deliberately, so it cannot be prefilled; an empty box that meant "clear it"
|
||||
// would destroy a secret on every save of an unrelated field, and the only
|
||||
// way to get it back is BotFather. This is the project's rule — a save may
|
||||
// clear only a field the editor was able to SHOW — applied to the one field
|
||||
// that can never be shown.
|
||||
// 2. A TYPED TOKEN STILL REPLACES. Otherwise the fix for a typo'd token is no
|
||||
// fix at all.
|
||||
// 3. THE OTHER TYPE'S SETTINGS SURVIVE A TYPE SWITCH, for the same reason: the
|
||||
// form in Telegram mode renders no URL box, so it may not clear a URL.
|
||||
// alert/notifier.go's buildPayload reads only the selected type's fields, so
|
||||
// carrying them costs nothing and switching back costs nothing either.
|
||||
// 4. VALIDATION KNOWS THE DIFFERENCE between "no token was given" and "no token
|
||||
// exists". The add form's rule ("Telegram needs a bot token") is one an edit
|
||||
// can never satisfy, and that is exactly why they now share a validator.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { Alert } from './api'
|
||||
import {
|
||||
buildAlert,
|
||||
draftFromAlert,
|
||||
hasStoredToken,
|
||||
validateAlertDraft,
|
||||
TOKEN_KEEP_HINT,
|
||||
} from './alertEdit.ts'
|
||||
import type { AlertDraft } from './alertEdit.ts'
|
||||
|
||||
const tg: Alert = {
|
||||
Name: 'tg',
|
||||
Enabled: true,
|
||||
Type: 'telegram',
|
||||
Token: '123456:REAL-SECRET',
|
||||
ChatID: '-1001',
|
||||
Events: ['killswitch'],
|
||||
}
|
||||
|
||||
const hook: Alert = {
|
||||
Name: 'hook',
|
||||
Enabled: false,
|
||||
Type: 'webhook',
|
||||
URL: 'https://hooks.example.com/abc?key=xyz',
|
||||
Events: ['apply_fail'],
|
||||
}
|
||||
|
||||
const draft = (over: Partial<AlertDraft> = {}): AlertDraft => ({
|
||||
name: 'tg',
|
||||
type: 'telegram',
|
||||
token: '',
|
||||
chatId: '-1001',
|
||||
url: '',
|
||||
events: ['killswitch'],
|
||||
via: 'direct',
|
||||
fallback: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// --- 1 + 2. the secret -------------------------------------------------------
|
||||
|
||||
test('an edit form starts with the token box EMPTY, never prefilled', () => {
|
||||
const d = draftFromAlert(tg, 'direct')
|
||||
assert.equal(d.token, '', 'the token must not be handed back to the browser')
|
||||
// Everything the form CAN show is prefilled, so an edit is an edit and not a
|
||||
// re-entry exercise.
|
||||
assert.equal(d.chatId, '-1001')
|
||||
assert.deepEqual(d.events, ['killswitch'])
|
||||
})
|
||||
|
||||
test('saving with an empty token box KEEPS the stored token', () => {
|
||||
// The whole point: fix the chat ID without going back to BotFather.
|
||||
const next = buildAlert(tg, draft({ chatId: '-1002' }))
|
||||
assert.equal(next.Token, '123456:REAL-SECRET', 'an unshown secret must never be cleared by a save')
|
||||
assert.equal(next.ChatID, '-1002')
|
||||
})
|
||||
|
||||
test('a typed token replaces the stored one, trimmed', () => {
|
||||
const next = buildAlert(tg, draft({ token: ' 999:NEW-SECRET ' }))
|
||||
assert.equal(next.Token, '999:NEW-SECRET')
|
||||
})
|
||||
|
||||
test('the interface SAYS that empty means keep — it is not left to be inferred', () => {
|
||||
assert.equal(hasStoredToken(tg), true)
|
||||
assert.equal(hasStoredToken(hook), false)
|
||||
assert.equal(hasStoredToken(null), false)
|
||||
assert.match(TOKEN_KEEP_HINT, /leave this empty to keep/i)
|
||||
})
|
||||
|
||||
// --- 3. a type switch is not a delete ----------------------------------------
|
||||
|
||||
test('switching telegram → webhook keeps the token and chat ID stored', () => {
|
||||
const next = buildAlert(
|
||||
tg,
|
||||
draft({ type: 'webhook', url: 'https://hooks.example.com/x', token: '' }),
|
||||
)
|
||||
assert.equal(next.Type, 'webhook')
|
||||
assert.equal(next.URL, 'https://hooks.example.com/x')
|
||||
assert.equal(next.Token, '123456:REAL-SECRET', 'the form showed no token box — it may not clear one')
|
||||
assert.equal(next.ChatID, '-1001')
|
||||
})
|
||||
|
||||
test('saving a telegram channel does not clear a URL the form never showed', () => {
|
||||
const both: Alert = { ...tg, URL: 'https://hooks.example.com/keep' }
|
||||
const next = buildAlert(both, draft({ chatId: '-1003' }))
|
||||
assert.equal(next.URL, 'https://hooks.example.com/keep')
|
||||
})
|
||||
|
||||
test('a field the form DID show can be emptied — that is the difference', () => {
|
||||
// In webhook mode the URL box is on screen, so clearing it is a decision the
|
||||
// operator made and can see. (Validation refuses to save it; the builder's job
|
||||
// is only to not invent a value.)
|
||||
const next = buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: '' }))
|
||||
assert.equal(next.URL, undefined)
|
||||
})
|
||||
|
||||
test('the row switch owns Enabled — an edit never flips it', () => {
|
||||
assert.equal(buildAlert(hook, draft({ name: 'hook', type: 'webhook', url: hook.URL! })).Enabled, false)
|
||||
assert.equal(buildAlert(tg, draft()).Enabled, true)
|
||||
// A NEW channel arrives on.
|
||||
assert.equal(buildAlert(null, draft({ token: 't' })).Enabled, true)
|
||||
})
|
||||
|
||||
test('delivery: a direct channel carries no Via and no Fallback key at all', () => {
|
||||
const direct = buildAlert(tg, draft({ via: 'direct', fallback: true }))
|
||||
assert.equal('Via' in direct, false)
|
||||
assert.equal('Fallback' in direct, false)
|
||||
const routed = buildAlert(tg, draft({ via: 'group:eu', fallback: true }))
|
||||
assert.equal(routed.Via, 'group:eu')
|
||||
assert.equal(routed.Fallback, true)
|
||||
// Turning the detour back off drops both — a stale Fallback on a direct channel
|
||||
// would describe a retry path that does not exist.
|
||||
const back = buildAlert(routed, draft({ via: 'direct', fallback: false }))
|
||||
assert.equal('Via' in back, false)
|
||||
assert.equal('Fallback' in back, false)
|
||||
})
|
||||
|
||||
// --- 4. validation ------------------------------------------------------------
|
||||
|
||||
test('an edit with an empty token box passes, because one is already stored', () => {
|
||||
assert.equal(validateAlertDraft(draft(), new Set(['tg']), tg), null)
|
||||
})
|
||||
|
||||
test('a NEW telegram channel with no token is refused', () => {
|
||||
const problem = validateAlertDraft(draft({ name: 'fresh', token: '' }), new Set(), null)
|
||||
assert.notEqual(problem, null)
|
||||
assert.match(problem!, /bot token/i)
|
||||
})
|
||||
|
||||
test('converting a webhook to telegram demands a token — there is none to keep', () => {
|
||||
const problem = validateAlertDraft(
|
||||
draft({ name: 'hook', type: 'telegram', token: '', chatId: '-1' }),
|
||||
new Set(['hook']),
|
||||
hook,
|
||||
)
|
||||
assert.notEqual(problem, null)
|
||||
assert.match(problem!, /none is stored/i)
|
||||
})
|
||||
|
||||
test('renaming to its own name is allowed; colliding with another is not', () => {
|
||||
const taken = new Set(['tg', 'other'])
|
||||
assert.equal(validateAlertDraft(draft({ name: 'tg' }), taken, tg), null)
|
||||
const problem = validateAlertDraft(draft({ name: 'other' }), taken, tg)
|
||||
assert.match(problem!, /already exists/i)
|
||||
})
|
||||
|
||||
test('the remaining shape rules still bite', () => {
|
||||
assert.match(validateAlertDraft(draft({ name: ' ' }), new Set(), tg)!, /name/i)
|
||||
assert.match(validateAlertDraft(draft({ chatId: '' }), new Set(), tg)!, /chat ID/i)
|
||||
assert.match(validateAlertDraft(draft({ events: [] }), new Set(), tg)!, /event/i)
|
||||
assert.match(
|
||||
validateAlertDraft(draft({ type: 'webhook', url: 'hooks.example.com' }), new Set(), tg)!,
|
||||
/http/i,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Alert } from './api'
|
||||
|
||||
/**
|
||||
* Editing an alert channel that already exists — and, in particular, editing a
|
||||
* secret the panel refuses to show.
|
||||
*
|
||||
* WHY THIS MODULE EXISTS. Alerts could be created and toggled, and their delivery
|
||||
* path (Via/Fallback) changed, but Type / Token / ChatID / URL / Events were
|
||||
* write-once: the only way to fix a typo in a chat ID was to delete the channel
|
||||
* and build it again. For the bot token that is worse than tedious — the row
|
||||
* masks it deliberately (`secret hidden`), so "re-enter it" means going back to
|
||||
* BotFather for a token you already own.
|
||||
*
|
||||
* THE RULE THIS FILE ENCODES. A save may clear only a field the editor was able
|
||||
* to SHOW. The token is never shown, so an empty token box cannot mean "erase the
|
||||
* stored token" — it means "keep it". The same reasoning covers the fields of the
|
||||
* OTHER channel type: while the form is in Telegram mode it shows no URL box, so
|
||||
* a save in Telegram mode leaves a stored URL alone (and vice versa). That is
|
||||
* also why switching type is non-destructive — the settings of the other kind sit
|
||||
* there unused until you switch back. Nothing reads them meanwhile:
|
||||
* alert/notifier.go `buildPayload` switches on Type and touches only that type's
|
||||
* fields.
|
||||
*
|
||||
* It lives outside `pages/Alerts.tsx` because it is the part that must be TESTED,
|
||||
* and the panel's runner is `node --test src/*.test.ts`: plain modules only, no
|
||||
* JSX, no DOM (same reason as ruleset.ts and egressEdit.ts).
|
||||
*/
|
||||
|
||||
export type AlertType = 'telegram' | 'webhook'
|
||||
|
||||
/** The live fields of either alert form (add or edit). */
|
||||
export interface AlertDraft {
|
||||
name: string
|
||||
type: AlertType
|
||||
/** Telegram bot token. EMPTY MEANS "keep whatever is stored" — never "clear it". */
|
||||
token: string
|
||||
chatId: string
|
||||
url: string
|
||||
events: string[]
|
||||
/** Canonical delivery value from the picker; 'direct' (or '') ⇒ no detour. */
|
||||
via: string
|
||||
fallback: boolean
|
||||
}
|
||||
|
||||
const HTTP_RE = /^https?:\/\//i
|
||||
|
||||
/** Does this channel already hold a bot token? Drives which hint the form shows. */
|
||||
export function hasStoredToken(a: Alert | null): boolean {
|
||||
return !!(a?.Token ?? '').trim()
|
||||
}
|
||||
|
||||
/** Prefill an edit form from a stored channel. The token box always starts EMPTY. */
|
||||
export function draftFromAlert(a: Alert, via: string): AlertDraft {
|
||||
return {
|
||||
name: a.Name,
|
||||
type: a.Type === 'webhook' ? 'webhook' : 'telegram',
|
||||
token: '',
|
||||
chatId: a.ChatID ?? '',
|
||||
url: a.URL ?? '',
|
||||
events: [...(a.Events ?? [])],
|
||||
via,
|
||||
fallback: a.Fallback ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the form is allowed to submit, or the message to show instead.
|
||||
*
|
||||
* `stored` is the channel being edited (null when adding). It is what makes the
|
||||
* token rule work in both directions: an edit passes with an empty token box
|
||||
* because one is already stored, and a NEW Telegram channel — or one being
|
||||
* converted from a webhook, which has no token — still has to be given one.
|
||||
*/
|
||||
export function validateAlertDraft(
|
||||
d: AlertDraft,
|
||||
taken: ReadonlySet<string>,
|
||||
stored: Alert | null,
|
||||
): string | null {
|
||||
const nm = d.name.trim()
|
||||
if (!nm) return 'Give the alert a name.'
|
||||
if (nm !== (stored?.Name ?? '') && taken.has(nm)) return `An alert named “${nm}” already exists.`
|
||||
if (d.type === 'telegram') {
|
||||
if (!d.token.trim() && !hasStoredToken(stored)) {
|
||||
return 'Telegram needs a bot token — none is stored for this alert yet.'
|
||||
}
|
||||
if (!d.chatId.trim()) return 'Telegram needs a chat ID.'
|
||||
} else if (!HTTP_RE.test(d.url.trim())) {
|
||||
return 'Enter an http(s):// webhook URL.'
|
||||
}
|
||||
if (d.events.length === 0) return 'Pick at least one event to notify on.'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the channel a save writes.
|
||||
*
|
||||
* The stored channel is spread in first, so anything this form does not model
|
||||
* survives untouched — the same idiom the rule editor uses for Order/Enabled/Kill.
|
||||
* Enabled is deliberately taken from storage too: the row's own switch owns it,
|
||||
* and an edit form that has no switch must not decide it.
|
||||
*/
|
||||
export function buildAlert(stored: Alert | null, d: AlertDraft): Alert {
|
||||
const typed = d.token.trim()
|
||||
// Telegram fields: the token box, when filled, replaces; when empty it keeps.
|
||||
// In webhook mode the box is not rendered at all, so it can never speak here.
|
||||
const token = d.type === 'telegram' && typed ? typed : (stored?.Token ?? '')
|
||||
const chatId = d.type === 'telegram' ? d.chatId.trim() : (stored?.ChatID ?? '')
|
||||
const url = d.type === 'webhook' ? d.url.trim() : (stored?.URL ?? '')
|
||||
const routed = d.via !== '' && d.via !== 'direct'
|
||||
|
||||
const out: Alert = {
|
||||
...(stored ?? ({} as Alert)),
|
||||
Name: d.name.trim(),
|
||||
Enabled: stored ? stored.Enabled : true,
|
||||
Type: d.type,
|
||||
Events: [...d.events],
|
||||
}
|
||||
// Written when non-empty, dropped when the operator emptied a field the form
|
||||
// actually showed (a webhook URL in webhook mode, a chat ID in Telegram mode).
|
||||
// `delete` rather than `''` so the PUT body stays the shape the add form sends.
|
||||
if (token) out.Token = token
|
||||
else delete out.Token
|
||||
if (chatId) out.ChatID = chatId
|
||||
else delete out.ChatID
|
||||
if (url) out.URL = url
|
||||
else delete out.URL
|
||||
if (routed) out.Via = d.via
|
||||
else delete out.Via
|
||||
if (routed && d.fallback) out.Fallback = true
|
||||
else delete out.Fallback
|
||||
return out
|
||||
}
|
||||
|
||||
/** Said under the token box when one is already stored. */
|
||||
export const TOKEN_KEEP_HINT =
|
||||
'A bot token is stored. Leave this empty to keep it — type a new one only to replace it. It is never shown back.'
|
||||
|
||||
/** Said under the token box when there is nothing to keep. */
|
||||
export const TOKEN_NEW_HINT = 'Stored secretly and never shown again — you can replace it later.'
|
||||
|
||||
/** Said when an edit switches an existing channel to the other type. */
|
||||
export const TYPE_SWITCH_NOTE =
|
||||
'The other type’s settings stay stored and unused, so you can switch back without entering them again.'
|
||||
+1201
-85
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
// The configuration on disk was refused, and everything else on the page is
|
||||
// about a different one.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// THE MEASUREMENT THIS EXISTS FOR (stand, shater/apply/apply.go): with a config
|
||||
// the engine could not accept, reconciliation retried it every 60 seconds — each
|
||||
// retry a full engine swap that failed and rolled back — while the status said
|
||||
// `engine_running: true` and carried the OLD config's hash and the OLD config's
|
||||
// warnings, and named the cause nowhere.
|
||||
//
|
||||
// WHAT THESE PROTECT:
|
||||
//
|
||||
// 1. THE REFUSED STATE AND THE APPLIED STATE MUST NOT RENDER ALIKE, and the
|
||||
// refused one must NAME THE STAGE.
|
||||
// 2. THE CONTROL, BOTH WAYS. On a healthy box nothing new appears at all —
|
||||
// otherwise "it warns when refused" is satisfiable by a helper that warns
|
||||
// always. And a daemon that does not publish the field must not be alarmed
|
||||
// either, because there is no evidence about it.
|
||||
// 3. IT MUST NOT CONTRADICT `engine_running`. True is TRUE in this state. The
|
||||
// reading has to say WHICH configuration is running, not deny that one is.
|
||||
// 4. THE REST OF THE PAGE IS DISOWNED BY NAME. "Refused" alone leaves the hash,
|
||||
// the traffic verdict and the findings reading as if they were about the
|
||||
// edit that was just saved.
|
||||
// 5. NO BROWSER-COMPUTED ELAPSED TIME. The router has no RTC.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { Status } from './api.ts'
|
||||
import { appliedState, rejectedReading, shortHash } from './appliedConfig.ts'
|
||||
|
||||
const status = (over: Partial<Status>): Status => ({
|
||||
running: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
table: true,
|
||||
hash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||
version: '0.2.19',
|
||||
engine_running: true,
|
||||
config_readable: true,
|
||||
plane: 'full',
|
||||
...over,
|
||||
})
|
||||
|
||||
/** A box whose saved edit was refused at validation while the tunnel kept working. */
|
||||
const refused = status({
|
||||
config_applied: false,
|
||||
apply_error: 'rule "kids": target "de-hysteria" resolves to nothing',
|
||||
apply_error_stage: 'the schema gate',
|
||||
apply_attempts: 7,
|
||||
apply_failed_since_unix: 1_700_000_000,
|
||||
})
|
||||
|
||||
const healthy = status({ config_applied: true, apply_error: '', apply_error_stage: '' })
|
||||
|
||||
// ---- the three states -------------------------------------------------------
|
||||
|
||||
test('the three states are three, and false is not folded into absent', () => {
|
||||
assert.equal(appliedState(healthy), 'applied')
|
||||
assert.equal(appliedState(refused), 'rejected')
|
||||
assert.equal(appliedState(status({})), 'unknown') // a daemon without the field
|
||||
assert.equal(appliedState(null), 'unknown')
|
||||
})
|
||||
|
||||
test('a refused configuration produces a statement, and it names the stage', () => {
|
||||
const r = rejectedReading(refused, '09:14:00')
|
||||
assert.ok(r, 'the refusal produced nothing to show')
|
||||
assert.equal(r.stage, 'schema')
|
||||
assert.match(r.stageText, /the schema gate/)
|
||||
assert.match(r.stageHint, /did not pass validation/)
|
||||
// The daemon's reason, verbatim — not summarised into a mood.
|
||||
assert.match(r.cause, /target "de-hysteria" resolves to nothing/)
|
||||
})
|
||||
|
||||
// ---- the controls, both ways ------------------------------------------------
|
||||
|
||||
test('CONTROL: a healthy box gets nothing new at all', () => {
|
||||
// Without this, every assertion above is satisfied by a helper that reports a
|
||||
// refusal unconditionally.
|
||||
assert.equal(rejectedReading(healthy, '09:14:00'), null)
|
||||
})
|
||||
|
||||
test('CONTROL: a daemon that does not publish the field is not alarmed either', () => {
|
||||
// Absent is not false. There is no evidence this box's hash is stale, and
|
||||
// stamping "your edit is not in effect" across the page on no evidence is the
|
||||
// same class of lie as hiding it when it is true.
|
||||
assert.equal(rejectedReading(status({}), ''), null)
|
||||
assert.equal(rejectedReading(null, ''), null)
|
||||
})
|
||||
|
||||
test('CONTROL: refused and applied cannot be drawn the same way', () => {
|
||||
// The one assertion the whole defect reduces to. It fails if the two states
|
||||
// ever produce the same screen.
|
||||
const a = rejectedReading(healthy, '09:14:00')
|
||||
const b = rejectedReading(refused, '09:14:00')
|
||||
assert.notEqual(a === null, b === null, 'refused and applied produced the same rendering')
|
||||
assert.equal(a, null)
|
||||
assert.ok(b)
|
||||
})
|
||||
|
||||
// ---- what it says, and what it must not say ---------------------------------
|
||||
|
||||
test('it does NOT contradict engine_running — it says which config is running', () => {
|
||||
const r = rejectedReading(refused, '09:14:00')
|
||||
assert.ok(r)
|
||||
assert.match(r.scope, /The engine IS running/)
|
||||
assert.match(r.scope, /PREVIOUS configuration/)
|
||||
// The running config named by its own short hash, so the two can be told apart.
|
||||
assert.match(r.scope, /a1b2c3d4e5f6/)
|
||||
})
|
||||
|
||||
test('the hash, the traffic verdict and the findings are disowned BY NAME', () => {
|
||||
// "It was refused" is not enough. Every other reading on the page stays green
|
||||
// and keeps describing the configuration that is running.
|
||||
const r = rejectedReading(refused, '09:14:00')
|
||||
assert.ok(r)
|
||||
for (const named of [/config hash/, /where traffic goes/, /every finding/]) {
|
||||
assert.match(r.scope, named)
|
||||
}
|
||||
assert.match(r.scope, /Your edit is not in effect/)
|
||||
})
|
||||
|
||||
test('a stopped engine is not told the tunnel still works', () => {
|
||||
const r = rejectedReading(status({ ...refused, engine_running: false }), '09:14:00')
|
||||
assert.ok(r)
|
||||
assert.doesNotMatch(r.scope, /tunnel still works/)
|
||||
assert.match(r.scope, /Nothing of this configuration is running/)
|
||||
// …and the rest of the page is still disowned, because the readings are still
|
||||
// there and still about something else.
|
||||
assert.match(r.scope, /every finding/)
|
||||
})
|
||||
|
||||
test('persistence says whether anything is still trying, with the router’s clock', () => {
|
||||
const r = rejectedReading(refused, '09:14:00')
|
||||
assert.ok(r)
|
||||
assert.match(r.persistence, /Tried 7 times/)
|
||||
assert.match(r.persistence, /first refused at 09:14:00 on the router’s clock/)
|
||||
assert.match(r.persistence, /retried on a widening interval/)
|
||||
assert.match(r.persistence, /saving any change to the configuration cancels the wait/)
|
||||
})
|
||||
|
||||
test('no elapsed time is computed in the browser — the router has no RTC', () => {
|
||||
// Passing no formatted clock must not make the panel invent one from
|
||||
// `apply_failed_since_unix` against the browser's clock.
|
||||
const r = rejectedReading(refused, '')
|
||||
assert.ok(r)
|
||||
assert.doesNotMatch(r.persistence, /minute\(s\)|ago|for \d+ /)
|
||||
assert.match(r.persistence, /Tried 7 times\./)
|
||||
})
|
||||
|
||||
// ---- the closed stage vocabulary --------------------------------------------
|
||||
|
||||
test('the three stages are recognised and each says something different', () => {
|
||||
const seen = new Set<string>()
|
||||
for (const [phrase, kind] of [
|
||||
['the schema gate', 'schema'],
|
||||
['building the engine configuration', 'generate'],
|
||||
['starting the engine', 'engine'],
|
||||
] as const) {
|
||||
const r = rejectedReading(status({ ...refused, apply_error_stage: phrase }), '')
|
||||
assert.ok(r)
|
||||
assert.equal(r.stage, kind)
|
||||
assert.match(r.stageText, new RegExp(phrase))
|
||||
assert.ok(r.stageHint.length > 0, `${kind} has no hint`)
|
||||
seen.add(r.stageHint)
|
||||
}
|
||||
assert.equal(seen.size, 3, 'two stages tell the operator the same thing')
|
||||
})
|
||||
|
||||
test('an unrecognised stage is shown verbatim and labelled, never guessed', () => {
|
||||
const r = rejectedReading(status({ ...refused, apply_error_stage: 'installing the data plane' }), '')
|
||||
assert.ok(r)
|
||||
assert.equal(r.stage, 'unrecognised')
|
||||
assert.match(r.stageText, /installing the data plane/)
|
||||
assert.match(r.stageText, /does not recognise/)
|
||||
assert.equal(r.stageHint, '', 'a stage we cannot name must not carry advice about another one')
|
||||
})
|
||||
|
||||
test('a stage the daemon never named is its own state, not one of the three', () => {
|
||||
const r = rejectedReading(status({ ...refused, apply_error_stage: '' }), '')
|
||||
assert.ok(r)
|
||||
assert.equal(r.stage, 'unnamed')
|
||||
assert.match(r.stageText, /did not name/)
|
||||
})
|
||||
|
||||
test('a refusal with no recorded reason still reads as a refusal', () => {
|
||||
const r = rejectedReading(status({ ...refused, apply_error: '' }), '')
|
||||
assert.ok(r)
|
||||
assert.match(r.cause, /recorded no reason/)
|
||||
assert.notEqual(r.cause, '', 'an empty cause line reads as “nothing wrong”')
|
||||
})
|
||||
|
||||
test('shortHash matches the daemon’s own short form, prefix or not', () => {
|
||||
assert.equal(shortHash('a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
|
||||
assert.equal(shortHash('abc'), 'abc')
|
||||
assert.equal(shortHash(''), '')
|
||||
// A prefixed hash must shorten to the SAME twelve characters, or the band and
|
||||
// the Engine module's hash row print two different strings for one config on
|
||||
// one screen — in the one state where telling configs apart is the whole job.
|
||||
assert.equal(shortHash('sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90'), 'a1b2c3d4e5f6')
|
||||
})
|
||||
|
||||
test('the band names the running config exactly as the hash row shortens it', () => {
|
||||
const h = 'sha256:9f7c0abc12345678'
|
||||
const r = rejectedReading(status({ ...refused, hash: h }), '')
|
||||
assert.ok(r)
|
||||
assert.match(r.scope, new RegExp(`\\(${shortHash(h)}\\)`))
|
||||
assert.doesNotMatch(r.scope, /sha256:/)
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
// Is the configuration on disk the one that is RUNNING — and if not, what is
|
||||
// everything else on the screen actually describing?
|
||||
//
|
||||
// Backend contract: shater/apply/apply.go — Status.ConfigApplied / ApplyError /
|
||||
// ApplyErrorStage / ApplyAttempts / ApplyFailedSinceUnix, and Applier.rejected,
|
||||
// the record that makes them possible.
|
||||
//
|
||||
// THE MEASUREMENT THIS EXISTS FOR. With a configuration on disk the engine
|
||||
// cannot accept, the daemon's reconciliation retried it every 60 seconds — each
|
||||
// retry a full engine swap that failed and rolled back — while the status body
|
||||
// said `engine_running: true` and carried the OLD configuration's hash and the
|
||||
// OLD configuration's warnings. The reason for the refusal appeared nowhere in
|
||||
// it. The owner watched a healthy engine while the tunnel restarted once a
|
||||
// minute and their edit did nothing.
|
||||
//
|
||||
// `engine_running: true` is TRUE in that state and this module never contradicts
|
||||
// it. The whole job here is to say WHICH configuration is running, and to mark
|
||||
// every value on the page that describes that older one instead of the one the
|
||||
// operator just saved.
|
||||
|
||||
import type { Status } from './api'
|
||||
|
||||
/**
|
||||
* A CLOSED set of three, and `unknown` is a real member.
|
||||
*
|
||||
* applied — the configuration on disk IS the one running.
|
||||
* rejected — it was read and REFUSED. Something else is running.
|
||||
* unknown — the key is ABSENT: nobody measured it, so there is nothing to
|
||||
* assert in either direction.
|
||||
*
|
||||
* WHY `unknown` IS NOT FOLDED INTO `rejected`. `false` is a MEASURED refusal and
|
||||
* earns the alarm; absence is not a measurement at all, and the daemon spends a
|
||||
* pointer to keep the two apart (apply.Status.ConfigApplied is a *bool with
|
||||
* `omitempty`). It has to: `false` publishes "THE CONFIGURATION ON DISK HAS NOT
|
||||
* BEEN APPLIED … your edit is not in effect", so with a plain bool that alarm
|
||||
* would be the ZERO VALUE OF THE TYPE — raised by any object that simply never
|
||||
* mentioned the field, such as the offline stub `shaterd status` prints with no
|
||||
* daemon answering, over a data plane that may have been running for weeks.
|
||||
*
|
||||
* A live status always carries one of the two verdicts, so absence here means
|
||||
* either that stub or a daemon older than the field — and neither is evidence
|
||||
* that this router's hash is stale. Stamping "your edit is not in effect" across
|
||||
* a page on no evidence is the same class of lie as hiding it when it is true.
|
||||
* `unknown` therefore claims nothing: it neither raises the alarm nor certifies
|
||||
* the page. This is the same treatment `plane` and `traffic` already get.
|
||||
*/
|
||||
export type AppliedState = 'applied' | 'rejected' | 'unknown'
|
||||
|
||||
export function appliedState(status: Status | null | undefined): AppliedState {
|
||||
const v = status?.config_applied
|
||||
if (v === true) return 'applied'
|
||||
if (v === false) return 'rejected'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** The stage vocabulary, normalised. POSITIVE and CLOSED — `unrecognised` is how
|
||||
* a future fourth stage arrives, and it is shown verbatim rather than guessed
|
||||
* at. `unnamed` is the daemon saying nothing, which is not the same thing. */
|
||||
export type ApplyStage = 'schema' | 'generate' | 'engine' | 'unnamed' | 'unrecognised'
|
||||
|
||||
/** The three phrases apply.go publishes (stageSchemaGate / stageGenerate /
|
||||
* stageEngine), verbatim. Matched exactly: the daemon writes these constants, so
|
||||
* a value that is merely close is a value this build does not know. */
|
||||
const STAGES: Record<string, ApplyStage> = {
|
||||
'the schema gate': 'schema',
|
||||
'building the engine configuration': 'generate',
|
||||
'starting the engine': 'engine',
|
||||
}
|
||||
|
||||
export interface RejectedReading {
|
||||
stage: ApplyStage
|
||||
/** The step, named. Always non-empty — silence here is itself reported. */
|
||||
stageText: string
|
||||
/** What the operator does about this stage. '' when the stage is not known. */
|
||||
stageHint: string
|
||||
/** The daemon's reason, verbatim, or a statement that it gave none. */
|
||||
cause: string
|
||||
/** Whether anything is still trying, and how long it has been like this. */
|
||||
persistence: string
|
||||
/**
|
||||
* WHAT THE REST OF THE PAGE IS ABOUT. This is the sentence the defect was
|
||||
* missing: it is not enough to say the config was refused, because every other
|
||||
* reading on screen stays green and keeps describing the configuration that is
|
||||
* running.
|
||||
*/
|
||||
scope: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The full statement of a standing refusal — or `null` when there is nothing to
|
||||
* state, which is every status that is not `rejected`.
|
||||
*
|
||||
* `hash` is the short form of the RUNNING configuration's hash, and it is in the
|
||||
* text on purpose: the single most misleading thing about this state is that
|
||||
* `hash` in the same status body looks entirely normal. It names the config the
|
||||
* operator is actually running so the two can be told apart.
|
||||
*
|
||||
* NO ELAPSED TIME IS COMPUTED HERE. `apply_failed_since_unix` is the router's
|
||||
* clock, the router has no RTC, and a browser-side "failing for 12 minutes" would
|
||||
* be fiction whenever the two clocks differ. The attempt COUNT carries
|
||||
* persistence; the instant is handed back to the caller to format against the
|
||||
* router's clock, like every other router timestamp in this panel.
|
||||
*/
|
||||
export function rejectedReading(
|
||||
status: Status | null | undefined,
|
||||
/** Already-formatted router-clock time of `apply_failed_since_unix`; '' when
|
||||
* there is none. The caller owns formatting — this module has no locale. */
|
||||
sinceClock = '',
|
||||
): RejectedReading | null {
|
||||
if (appliedState(status) !== 'rejected') return null
|
||||
|
||||
const raw = (status?.apply_error_stage ?? '').trim()
|
||||
const stage: ApplyStage = raw === '' ? 'unnamed' : (STAGES[raw] ?? 'unrecognised')
|
||||
const err = (status?.apply_error ?? '').trim()
|
||||
const attempts = status?.apply_attempts ?? 0
|
||||
|
||||
return {
|
||||
stage,
|
||||
stageText: stageText(stage, raw),
|
||||
stageHint: stageHint(stage),
|
||||
// A refusal whose reason was not recorded is still a refusal, and saying so is
|
||||
// the point — an empty line here would read as "no reason, so probably fine".
|
||||
cause: err || 'The daemon recorded no reason for the refusal.',
|
||||
persistence: persistenceText(attempts, sinceClock),
|
||||
scope: scopeText(status),
|
||||
}
|
||||
}
|
||||
|
||||
function stageText(stage: ApplyStage, raw: string): string {
|
||||
switch (stage) {
|
||||
case 'schema':
|
||||
return 'the schema gate'
|
||||
case 'generate':
|
||||
return 'building the engine configuration'
|
||||
case 'engine':
|
||||
return 'starting the engine'
|
||||
case 'unrecognised':
|
||||
// Verbatim, and labelled. A step this build does not know is still the step
|
||||
// that refused, and guessing which of the three it resembles would send the
|
||||
// operator to the wrong page.
|
||||
return `a step this panel does not recognise — the daemon calls it “${raw}”`
|
||||
case 'unnamed':
|
||||
return 'a step the daemon did not name'
|
||||
}
|
||||
}
|
||||
|
||||
function stageHint(stage: ApplyStage): string {
|
||||
switch (stage) {
|
||||
case 'schema':
|
||||
return 'The configuration did not pass validation, so nothing was built from it. The cause names the setting.'
|
||||
case 'generate':
|
||||
return 'The configuration is valid and could not be turned into an engine configuration — a reference that resolves to nothing, or a combination the generator refuses.'
|
||||
case 'engine':
|
||||
return 'The configuration built, and the engine would not come up on it — a port already taken, or a transport that fails at start.'
|
||||
case 'unrecognised':
|
||||
case 'unnamed':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is anything still trying, and since when.
|
||||
*
|
||||
* The retry is on a widening interval and ANY edit cancels the wait, so "it will
|
||||
* be retried" is true and worth saying: an operator who reads "refused" alone
|
||||
* cannot tell whether the box has given up.
|
||||
*/
|
||||
function persistenceText(attempts: number, sinceClock: string): string {
|
||||
const tries =
|
||||
attempts > 0
|
||||
? `Tried ${attempts} time${attempts === 1 ? '' : 's'}`
|
||||
: 'The daemon did not say how many times it has been tried'
|
||||
const since = sinceClock ? `, first refused at ${sinceClock} on the router’s clock` : ''
|
||||
return `${tries}${since}. It is retried on a widening interval, and saving any change to the configuration cancels the wait and tries it again at once.`
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence that names what everything else on screen describes.
|
||||
*
|
||||
* It is built from what the status actually carries, so it never promises a
|
||||
* reading that is not on the page: with no `hash` there is no hash to disown.
|
||||
*/
|
||||
function scopeText(status: Status | null | undefined): string {
|
||||
const running = shortHash(status?.hash ?? '')
|
||||
const engineUp = status?.engine_running === true
|
||||
const head = engineUp
|
||||
? `The engine IS running — on the PREVIOUS configuration${running ? ` (${running})` : ''}, not this one. That is why the tunnel still works.`
|
||||
: `Nothing of this configuration is running${running ? `; the hash shown is ${running}` : ''}.`
|
||||
return `${head} Everything else on this page describes that older configuration: the config hash, where traffic goes, and every finding below. Your edit is not in effect.`
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading 12 characters of a config hash — the same short form the daemon's
|
||||
* own warning uses (apply.shortHash), so the two texts name the config
|
||||
* identically.
|
||||
*
|
||||
* The `sha256:` prefix is stripped first. The daemon's hash is bare hex
|
||||
* (engine.hashOptions), but a prefixed form exists in fixtures and older data, and
|
||||
* without the strip the twelve characters would be spent on the word "sha256:" —
|
||||
* so this band and the Engine module's hash row would print two different strings
|
||||
* for one configuration, on one screen, in the one state where telling two
|
||||
* configurations apart is the whole job. Overview's hash row calls THIS function
|
||||
* for that reason; there is deliberately only one normalisation.
|
||||
*/
|
||||
export function shortHash(h: string): string {
|
||||
const bare = h.replace(/^sha256:/, '')
|
||||
return bare.length <= 12 ? bare : bare.slice(0, 12)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// applyRisk — the warning shown BEFORE the apply that takes the network down.
|
||||
//
|
||||
// Traced through the daemon: nothing rejects an empty config (model.Validate is
|
||||
// advisory, generate errors only on a nil model, the engine starts because the
|
||||
// outbound list always holds direct+block); with the kill-switch closed and no
|
||||
// catch-all rule generate/route.go sets `route.final = "block"`; and the tproxy
|
||||
// divert for the shipped `lan` inbound is installed, so every TCP connection and
|
||||
// UDP flow from the LAN is handed to the engine and dropped.
|
||||
//
|
||||
// A predictive warning is only worth having if it is quiet on configs that are
|
||||
// fine, so every "it fires" case below is paired with the single-field change
|
||||
// that must silence it. Four conditions, four ways for the danger to be absent.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyRisk, isCatchAllRule, isInterceptingInbound } from './planeState.ts'
|
||||
import type { ApplyRisk, ApplyRiskInput, CatchAllRule } from './planeState.ts'
|
||||
|
||||
/** The shipped config with the service switched on and nothing else changed:
|
||||
* fail-closed, one enabled tproxy inbound, no rules, no resolvers, no window. */
|
||||
function shipped(over: Partial<ApplyRiskInput> = {}): ApplyRiskInput {
|
||||
return {
|
||||
Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 0 },
|
||||
Rules: [],
|
||||
Inbounds: [{ Enabled: true, Type: 'tproxy' }],
|
||||
Resolvers: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** A rule with no matcher of any kind — the effective catch-all (route.Final). */
|
||||
function catchAll(over: Partial<CatchAllRule> = {}): CatchAllRule {
|
||||
return { Enabled: true, ...over }
|
||||
}
|
||||
|
||||
// --- it fires on the dangerous config ---------------------------------------
|
||||
|
||||
test('warns before an apply that blocks every device', () => {
|
||||
const r = applyRisk(shipped())
|
||||
assert.ok(r, 'the shipped config, switched on, blocks everything')
|
||||
// Says what the ACTION does, in traffic terms, not what is wrong with a field.
|
||||
assert.match(r.headline, /cuts every device off the internet/)
|
||||
assert.match(r.detail, /drops it/)
|
||||
// Only certain claims: TCP and UDP are what tproxy diverts. ICMP depends on
|
||||
// the Untunnelable policy, so it is not promised here either way.
|
||||
assert.match(r.detail, /TCP connection and UDP flow/)
|
||||
// What still works, so nobody power-cycles a router they can still reach.
|
||||
assert.match(r.detail, /reach each other and this panel/)
|
||||
// And the fix, naming the page and the shape of the rule.
|
||||
assert.match(r.steps[0], /Routing page/)
|
||||
assert.match(r.steps[0], /no conditions/)
|
||||
})
|
||||
|
||||
test('names the missing auto-rollback, and the value to set', () => {
|
||||
const r = applyRisk(shipped())
|
||||
assert.ok(r)
|
||||
assert.equal(r.noAutoRollback, true)
|
||||
assert.match(r.undo, /no auto-rollback/)
|
||||
assert.match(r.undo, /SSH/)
|
||||
// The README's documented first-apply value, which the panel never mentioned.
|
||||
assert.ok(
|
||||
r.steps.some((s) => /120 seconds/.test(s) && /Settings/.test(s)),
|
||||
'the confirm-window remedy must be offered, with the documented value',
|
||||
)
|
||||
})
|
||||
|
||||
test('an armed confirm window changes the undo line, not the warning', () => {
|
||||
const r = applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }))
|
||||
assert.ok(r, 'a confirm window does not make blocking the network unremarkable')
|
||||
assert.equal(r.noAutoRollback, false)
|
||||
assert.match(r.undo, /120s/)
|
||||
assert.match(r.undo, /reverts to the last-good one/)
|
||||
assert.doesNotMatch(r.undo, /SSH/)
|
||||
// ...and it stops offering a remedy the operator has already applied.
|
||||
assert.ok(!r.steps.some((s) => /120 seconds/.test(s)))
|
||||
})
|
||||
|
||||
test('zero resolvers is named, because DNS is the one thing that still leaves', () => {
|
||||
// CONTROL FIRST: one configured resolver and the step is gone. A DNS warning
|
||||
// that appears whatever the DNS config says is not a reading of the DNS config.
|
||||
const withResolver = applyRisk(shipped({ Resolvers: [{}] }))
|
||||
assert.ok(withResolver)
|
||||
assert.ok(!withResolver.steps.some((s) => /DNS page/.test(s)))
|
||||
|
||||
const without = applyRisk(shipped())
|
||||
assert.ok(without)
|
||||
const dns = without.steps.find((s) => /DNS page/.test(s))
|
||||
assert.ok(dns, 'with no resolver, lookups go to the provider unprotected — say so')
|
||||
assert.match(dns, /in the clear/)
|
||||
|
||||
// IT IS NOT ONLY THE QUERIES ADDRESSED TO THE ROUTER. The shipped
|
||||
// `dns_intercept '1'` pulls a query aimed at a resolver the device picked for
|
||||
// ITSELF into the engine too, and it leaves as the same clear UDP/53 — measured
|
||||
// both ways on the stand, each producing its own plaintext packet on the WAN.
|
||||
// The earlier wording covered only the router-addressed half, which reads as a
|
||||
// promise that the other half is contained. It is not.
|
||||
assert.match(dns, /to the router or to a resolver they picked themselves/)
|
||||
// ...and encrypted DNS is not the way out of it either: :853 out of the LAN
|
||||
// measured connects=0, because the plan rejects it.
|
||||
assert.match(dns, /:853/)
|
||||
// The claim the detail below is not allowed to contradict.
|
||||
assert.match(dns, /only thing that still leaves this network/)
|
||||
})
|
||||
|
||||
// --- the band may not contradict itself -------------------------------------
|
||||
//
|
||||
// MEASURED on the stand: in this exact state 0 packets left the WAN across the
|
||||
// whole run (27 in the control that differs only by an added catch-all) — except
|
||||
// for exactly 2, both plaintext UDP/53. So the DNS step is right and the detail's
|
||||
// "nothing reaches the internet" was wrong by those 2 packets.
|
||||
//
|
||||
// The wording fix alone does not survive the next editor, because the two halves
|
||||
// live 15 lines apart and each reads fine on its own. What follows is therefore
|
||||
// not a check of the words but of the INVARIANT between them: no clause of the
|
||||
// band may claim that nothing leaves while another clause names something that
|
||||
// does. Edit either half back without the other and this fails.
|
||||
|
||||
/** The band is one utterance to one operator, so a claim in the steps and a claim
|
||||
* in the detail are claims in the same breath. Flatten it to clauses. */
|
||||
function bandClauses(r: ApplyRisk): string[] {
|
||||
return [r.headline, r.detail, r.undo, ...r.steps]
|
||||
.join(' ')
|
||||
.split(/[.;:](?=\s|$)/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** "…<verb> … <the outside>" — a clause that talks about traffic leaving. */
|
||||
const EXIT = /\b(?:reach(?:es)?|leaves?|leaving|gets? out|goes? out|going out)\b.*?\b(?:internet|this network|your provider)\b/i
|
||||
/** Universal negation, and ONLY universal negation: "No resolver is configured"
|
||||
* must NOT count, or the instrument goes blind on the very clause it exists to
|
||||
* see. */
|
||||
const NONE = /\bnothing\b|\bno traffic\b|\bnone of it\b/i
|
||||
/** What turns "nothing leaves" into a survivable claim by admitting an exception. */
|
||||
const QUALIFIED = /\belse\b|\bexcept\b|\bapart from\b|\bother than\b|\baside from\b/i
|
||||
|
||||
const saysNothingLeaves = (c: string) => EXIT.test(c) && NONE.test(c) && !QUALIFIED.test(c)
|
||||
const saysSomethingLeaves = (c: string) => EXIT.test(c) && !NONE.test(c)
|
||||
|
||||
test('the band never says both "nothing leaves" and "DNS leaves"', () => {
|
||||
// The instrument, proved on a fabricated band before it is trusted on a real
|
||||
// one: the absolute claim IS detectable, and it IS detected next to the DNS
|
||||
// clause. Without this, a regex that matches nothing would pass silently.
|
||||
const control = bandClauses({
|
||||
headline: 'x',
|
||||
detail: 'Devices can still reach each other and this panel; nothing reaches the internet.',
|
||||
undo: 'x',
|
||||
steps: ['It is the only thing that still leaves this network.'],
|
||||
noAutoRollback: true,
|
||||
})
|
||||
assert.ok(control.some(saysNothingLeaves), 'the absolute-claim detector must be able to fire')
|
||||
assert.ok(control.some(saysSomethingLeaves), 'the something-leaves detector must be able to fire')
|
||||
|
||||
// Now the real bands, in every shape that renders one.
|
||||
const bands = [
|
||||
shipped(),
|
||||
shipped({ Resolvers: [{}] }),
|
||||
shipped({ Globals: { Enabled: true, KillSwitch: 'closed', ConfirmTimeout: 120 } }),
|
||||
shipped({ Rules: [catchAll({ Enabled: false })] }),
|
||||
shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })], Resolvers: [{}, {}] }),
|
||||
].map((cfg) => {
|
||||
const r = applyRisk(cfg)
|
||||
assert.ok(r, 'this config must still produce a band, or the check below is vacuous')
|
||||
return r
|
||||
})
|
||||
|
||||
for (const r of bands) {
|
||||
const clauses = bandClauses(r)
|
||||
const denies = clauses.filter(saysNothingLeaves)
|
||||
const admits = clauses.filter(saysSomethingLeaves)
|
||||
assert.ok(
|
||||
!(denies.length > 0 && admits.length > 0),
|
||||
`the band contradicts itself:\n denies: ${JSON.stringify(denies)}\n admits: ${JSON.stringify(admits)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// AND THE INSTRUMENT IS LOOKING AT THE LIVE TEXT. Without a resolver the band
|
||||
// must contain a clause admitting that something leaves — if that ever stops
|
||||
// being true, the loop above passes for the wrong reason.
|
||||
const dangerous = applyRisk(shipped())
|
||||
assert.ok(dangerous)
|
||||
assert.ok(
|
||||
bandClauses(dangerous).some(saysSomethingLeaves),
|
||||
'the no-resolver band must still name the traffic that gets out',
|
||||
)
|
||||
// The detail's half of the invariant, pinned by hand so the one-word edit that
|
||||
// breaks it is named in the failure and not just inferred.
|
||||
assert.match(dangerous.detail, /nothing else reaches the internet/)
|
||||
assert.doesNotMatch(dangerous.detail, /nothing reaches the internet/)
|
||||
|
||||
// CONTROL: with a resolver configured nothing is claimed to leave at all, so
|
||||
// the invariant is satisfied by the other side of the same test.
|
||||
const safe = applyRisk(shipped({ Resolvers: [{}] }))
|
||||
assert.ok(safe)
|
||||
assert.equal(bandClauses(safe).filter(saysSomethingLeaves).length, 0)
|
||||
})
|
||||
|
||||
// --- the four ways it must stay silent (the controls) -----------------------
|
||||
|
||||
test('silent when the service will be off — no plane is built at all', () => {
|
||||
assert.equal(
|
||||
applyRisk(shipped({ Globals: { Enabled: false, KillSwitch: 'closed', ConfirmTimeout: 0 } })),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('silent when the kill-switch is open — unmatched traffic leaves, it is not dropped', () => {
|
||||
assert.equal(
|
||||
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: 'open', ConfirmTimeout: 0 } })),
|
||||
null,
|
||||
)
|
||||
// ...and the daemon's own normalisation is what decides "closed", so every
|
||||
// spelling that blocks on the router must still warn here.
|
||||
for (const spelling of ['closed', 'Closed', ' closed ', '', 'CLOSED', 'whatever']) {
|
||||
assert.ok(
|
||||
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: spelling, ConfirmTimeout: 0 } })),
|
||||
`kill_switch '${spelling}' blocks on the router, so it must warn here`,
|
||||
)
|
||||
}
|
||||
// Only a case-insensitive, trimmed "open" is fail-open.
|
||||
assert.equal(
|
||||
applyRisk(shipped({ Globals: { Enabled: true, KillSwitch: ' Open ', ConfirmTimeout: 0 } })),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('silent when nothing intercepts — no enabled tproxy inbound diverts anything', () => {
|
||||
assert.equal(applyRisk(shipped({ Inbounds: [] })), null)
|
||||
assert.equal(applyRisk(shipped({ Inbounds: null })), null)
|
||||
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: false, Type: 'tproxy' }] })), null)
|
||||
// socks/http/dokodemo are local listeners; they intercept nothing on their own.
|
||||
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'socks' }] })), null)
|
||||
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'http' }] })), null)
|
||||
assert.equal(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: 'dokodemo' }] })), null)
|
||||
// An absent/empty Type IS tproxy (model.Inbound.EffectiveType), so it warns.
|
||||
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true, Type: '' }] })))
|
||||
assert.ok(applyRisk(shipped({ Inbounds: [{ Enabled: true }] })))
|
||||
})
|
||||
|
||||
test('silent when a default rule exists — that is what stops final being block', () => {
|
||||
assert.equal(applyRisk(shipped({ Rules: [catchAll()] })), null)
|
||||
// A DISABLED catch-all is not one: it is not emitted, so final stays block.
|
||||
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Enabled: false })] })))
|
||||
// Neither is a rule that only matches SOME traffic.
|
||||
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstRuleset: ['ru'] })] })))
|
||||
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Src: ['192.168.1.0/24'] })] })))
|
||||
assert.ok(applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' })] })))
|
||||
assert.ok(applyRisk(shipped({ Rules: [catchAll({ Proto: 'tcp' })] })))
|
||||
// One catch-all among specific rules is still a catch-all.
|
||||
assert.equal(
|
||||
applyRisk(shipped({ Rules: [catchAll({ DstPort: '443' }), catchAll()] })),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('an UNMIGRATED rule is never the default — the case that would hide the warning', () => {
|
||||
// Its destination is still in schema-v1 options the parser no longer reads, so
|
||||
// "no matchers" means "unreadable destination", not "matches everything" — and
|
||||
// the daemon holds it disabled. Counting it would silence this warning on
|
||||
// exactly the config that needs it.
|
||||
assert.equal(isCatchAllRule(catchAll({ LegacyDst: ['example.com'] })), false)
|
||||
assert.ok(
|
||||
applyRisk(shipped({ Rules: [catchAll({ LegacyDst: ['example.com'] })] })),
|
||||
'an unmigrated rule must not be mistaken for a default route',
|
||||
)
|
||||
// CONTROL: the same rule once migrated does silence it.
|
||||
assert.equal(applyRisk(shipped({ Rules: [catchAll({ LegacyDst: [] })] })), null)
|
||||
})
|
||||
|
||||
// --- the small predicates, directly -----------------------------------------
|
||||
|
||||
test('isInterceptingInbound is a closed positive list', () => {
|
||||
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'TPROXY' }), true)
|
||||
assert.equal(isInterceptingInbound({ Enabled: true, Type: ' tproxy ' }), true)
|
||||
assert.equal(isInterceptingInbound({ Enabled: true }), true)
|
||||
// Anything the panel does not know about must NOT be assumed to intercept —
|
||||
// an open `!== 'socks'` test would warn about a router that diverts nothing.
|
||||
assert.equal(isInterceptingInbound({ Enabled: true, Type: 'something-new' }), false)
|
||||
assert.equal(isInterceptingInbound({ Enabled: false }), false)
|
||||
})
|
||||
|
||||
test('null and missing input produce no warning rather than a guess', () => {
|
||||
assert.equal(applyRisk(null), null)
|
||||
assert.equal(applyRisk(undefined), null)
|
||||
assert.equal(applyRisk(shipped({ Rules: null, Resolvers: null })) !== null, true)
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
// A BLOCKED CHAIN IS A FIELD NOW, NOT A SENTENCE.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// WHAT THIS PROTECTS. The prober walks a chain in order and stops at the first
|
||||
// hop that does not answer, so the chain's exit is never dialled. There is no
|
||||
// end-to-end measurement, and the daemon correctly files the row `source:''` —
|
||||
// which a client reading `source` strictly puts in the "nobody looked" bucket.
|
||||
// That is the wrong colour: a probe DID run, at the hop, and it failed.
|
||||
//
|
||||
// The panel used to keep such a row red by matching a fragment of the daemon's
|
||||
// error sentence. It was the last place prose decided anything here, and a
|
||||
// reworded message would have silently turned a red row grey. `blocked_by` is
|
||||
// the same fact as a NUMBER.
|
||||
//
|
||||
// 1. A NON-ZERO blocked_by KEEPS THE ROW RED, though nothing measured it.
|
||||
// 2. THE CONTROL: zero does NOT. Written so that a helper which reds
|
||||
// everything, or one which reds nothing, fails.
|
||||
// 3. NO PROSE. The daemon's sentence can be reworded to anything at all and
|
||||
// the colour does not move.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { GroupTestResult } from './api.ts'
|
||||
import { blockedHop, readingTone, testReading } from './testResult.ts'
|
||||
|
||||
const chainRow = (over: Partial<GroupTestResult>): GroupTestResult => ({
|
||||
group: 'ewan-wg-subs',
|
||||
selected: '',
|
||||
kind: 'chain',
|
||||
delay_ms: 0,
|
||||
exit_ip: '',
|
||||
exit_country: '',
|
||||
ok: false,
|
||||
error: '',
|
||||
tested_unix: 1_700_000_000,
|
||||
source: '',
|
||||
blocked_by: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
// --- 1 + 2. red on a hop, not red on zero -------------------------------------
|
||||
|
||||
test('a chain blocked at a hop stays RED, though nothing measured its exit', () => {
|
||||
const blocked = chainRow({
|
||||
blocked_by: 3,
|
||||
error:
|
||||
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
|
||||
})
|
||||
assert.equal(blockedHop(blocked), 3, 'the hop is 1-based and comes off the field')
|
||||
assert.equal(testReading(blocked), 'not-measured', 'there is still no end-to-end measurement')
|
||||
assert.equal(readingTone(blocked), 'crit', 'but a probe did run, at the hop, and failed')
|
||||
})
|
||||
|
||||
test('CONTROL: blocked_by = 0 is NOT red — the escalation is exactly one case', () => {
|
||||
// Every non-chain result carries 0. If this went red too, the field would have
|
||||
// replaced a narrow prose match with a blanket one, which is worse than what
|
||||
// it replaced.
|
||||
const quiet = chainRow({
|
||||
blocked_by: 0,
|
||||
error:
|
||||
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
|
||||
})
|
||||
assert.equal(blockedHop(quiet), 0)
|
||||
assert.equal(
|
||||
readingTone(quiet),
|
||||
'unknown',
|
||||
'an unlit lamp: painting this red reports a fault nobody has found',
|
||||
)
|
||||
assert.notEqual(readingTone(quiet), 'crit')
|
||||
})
|
||||
|
||||
test('CONTROL: the helper can still say a row is good, and a measured failure bad', () => {
|
||||
// Without these, "0 is not crit" would also be satisfied by a classifier that
|
||||
// never says anything at all.
|
||||
const alive = chainRow({ ok: true, delay_ms: 42, source: 'observatory' })
|
||||
const dead = chainRow({
|
||||
source: 'observatory',
|
||||
error: 'the observatory’s probe through this path failed',
|
||||
})
|
||||
assert.equal(readingTone(alive), 'good')
|
||||
assert.equal(readingTone(dead), 'crit')
|
||||
assert.equal(blockedHop(alive), 0, 'a working chain names no blocking hop')
|
||||
})
|
||||
|
||||
// --- 3. the prose no longer decides anything -----------------------------------
|
||||
|
||||
test('the daemon may reword its sentence freely — the colour comes off the field', () => {
|
||||
// This is the regression the change exists for. Under the old string match,
|
||||
// every one of these rows would have gone quiet grey.
|
||||
for (const error of [
|
||||
'hop 3 did not respond',
|
||||
'chain stopped at hop 3',
|
||||
'',
|
||||
'a sentence this build has never seen',
|
||||
]) {
|
||||
const r = chainRow({ blocked_by: 3, error })
|
||||
assert.equal(readingTone(r), 'crit', JSON.stringify(error))
|
||||
}
|
||||
})
|
||||
|
||||
test('…and the old sentence WITHOUT the field no longer reds a row on its own', () => {
|
||||
// The mirror: prose alone is not evidence any more. A daemon that sends the
|
||||
// sentence and no field is one this panel does not ship with, and guessing
|
||||
// from its wording is exactly the coupling that was removed.
|
||||
const proseOnly = chainRow({
|
||||
blocked_by: undefined,
|
||||
error:
|
||||
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
|
||||
})
|
||||
assert.equal(blockedHop(proseOnly), 0)
|
||||
assert.equal(readingTone(proseOnly), 'unknown')
|
||||
})
|
||||
|
||||
test('a nonsense hop number claims nothing — the escalation is not entered by accident', () => {
|
||||
for (const raw of [0, -1, 0.5, NaN, Infinity, '3' as unknown as number]) {
|
||||
assert.equal(blockedHop(chainRow({ blocked_by: raw })), 0, String(raw))
|
||||
}
|
||||
assert.equal(blockedHop(chainRow({ blocked_by: 1 })), 1, 'one IS a valid hop — 1-based')
|
||||
assert.equal(blockedHop(chainRow({ blocked_by: 4.9 })), 4, 'a fractional hop floors to a real one')
|
||||
})
|
||||
|
||||
test('a measured failure is never downgraded by a missing hop, nor upgraded by one', () => {
|
||||
const measuredFail = chainRow({
|
||||
source: 'observatory',
|
||||
blocked_by: 0,
|
||||
error: 'the observatory’s probe through this path failed',
|
||||
})
|
||||
assert.equal(readingTone(measuredFail), 'crit')
|
||||
const measuredOK = chainRow({ ok: true, source: 'observatory', blocked_by: 3 })
|
||||
assert.equal(
|
||||
readingTone(measuredOK),
|
||||
'good',
|
||||
'blocked_by only ever escalates a not-measured row; it cannot overturn a measurement',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// TWO KINDS OF ROW ON ONE BOARD, and they may not look alike.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// WHAT THIS PROTECTS. Starting a test run used to replace the results outright,
|
||||
// so pressing Test on one NODE blanked every group and chain card on the Targets
|
||||
// screen — the daemon threw true measurements away and nothing on screen
|
||||
// explained it. It no longer does: the rows a run will not itself re-measure are
|
||||
// carried forward (engine.startTestRun), capped at 64, oldest evicted first.
|
||||
//
|
||||
// That fixes one lie and opens the door to another. A card can now show a
|
||||
// reading taken twenty minutes ago beside a card showing one taken a second ago,
|
||||
// and if both are drawn as a bare timestamp the whole board reads "as of now".
|
||||
//
|
||||
// Attribution is NOT by comparing timestamps: the router has no RTC, so its
|
||||
// clock can sit far from the browser's and any computed "n minutes ago" would be
|
||||
// fiction. The daemon publishes `scope`, the set of names this run covers.
|
||||
//
|
||||
// 1. THIS RUN'S ROW AND A CARRIED ROW ARE DIFFERENT ON SCREEN. Written so
|
||||
// that drawing them identically fails.
|
||||
// 2. THE CONTROL. The same helper must produce the plain, unqualified stamp
|
||||
// too, or "they differ" is satisfied by a function that marks everything.
|
||||
// 3. AN EMPTY SCOPE IS "CANNOT ATTRIBUTE", NOT "EVERYTHING IS CARRIED". A run
|
||||
// always covers at least one target, so an empty set only ever means the
|
||||
// daemon published none — and the panel's normalizer turns an absent field
|
||||
// into exactly that.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { GroupTestResult } from './api.ts'
|
||||
import { originStamp, rowOrigin } from './testResult.ts'
|
||||
|
||||
const row = (over: Partial<GroupTestResult>): GroupTestResult => ({
|
||||
group: 'auto',
|
||||
selected: 'nl-reality-2',
|
||||
kind: 'group',
|
||||
delay_ms: 42,
|
||||
exit_ip: '185.12.34.56',
|
||||
exit_country: 'NL',
|
||||
ok: true,
|
||||
error: '',
|
||||
tested_unix: 1_700_000_000,
|
||||
source: 'observatory',
|
||||
...over,
|
||||
})
|
||||
|
||||
const CLOCK = '14:02:11'
|
||||
|
||||
// --- 1 + 2. the distinction, and the control ---------------------------------
|
||||
|
||||
test('a row this run measured and a row it carried are attributed differently', () => {
|
||||
const scope = ['stealth']
|
||||
assert.equal(rowOrigin(row({ group: 'stealth' }), scope), 'this-run')
|
||||
assert.equal(
|
||||
rowOrigin(row({ group: 'auto' }), scope),
|
||||
'carried',
|
||||
'this run never touched `auto`; its reading is from an earlier press',
|
||||
)
|
||||
})
|
||||
|
||||
test('and they are DRAWN differently — the same fact, at the pixel level', () => {
|
||||
const mine = originStamp('this-run', CLOCK)
|
||||
const carried = originStamp('carried', CLOCK)
|
||||
assert.notEqual(
|
||||
mine.text,
|
||||
carried.text,
|
||||
'a screen that stamps both with the same bare time says the board is current when half of it is not',
|
||||
)
|
||||
assert.match(carried.text, /earlier run/, 'the words have to name the fact, not hint at it')
|
||||
assert.equal(mine.text, CLOCK, 'this run needs no qualifier — it IS the reading just taken')
|
||||
assert.notEqual(mine.hint, carried.hint)
|
||||
})
|
||||
|
||||
test('CONTROL: the same helper does produce an unqualified stamp', () => {
|
||||
// Without this, "they differ" would also be satisfied by a function that
|
||||
// stamped EVERY row "earlier run" — which would be a different lie, and the
|
||||
// one that makes an operator distrust a number they just asked for.
|
||||
for (const origin of ['this-run', 'unattributed'] as const) {
|
||||
assert.equal(
|
||||
originStamp(origin, CLOCK).text,
|
||||
CLOCK,
|
||||
`${origin} must not be dressed as carried`,
|
||||
)
|
||||
assert.doesNotMatch(originStamp(origin, CLOCK).text, /earlier/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a carried row says so even when it carries no timestamp at all', () => {
|
||||
// The worst case for silence: nothing to print, and the row is still not this
|
||||
// run's. `tested_unix: 0` reaches here as an empty clock string.
|
||||
const carried = originStamp('carried', '')
|
||||
assert.equal(carried.text, 'earlier run')
|
||||
assert.notEqual(carried.text, originStamp('this-run', '').text)
|
||||
assert.equal(originStamp('this-run', '').text, '', 'and an unstamped fresh row prints nothing')
|
||||
})
|
||||
|
||||
// --- 3. an empty scope claims nothing ------------------------------------------
|
||||
|
||||
test('an EMPTY scope is "cannot attribute", not "everything is carried"', () => {
|
||||
// The panel's normalizer turns an absent `scope` into `[]` before this sees
|
||||
// it, so this branch is the pre-scope daemon. Reading it as a real scope would
|
||||
// stamp "earlier run" on every row of a run that had just measured them all.
|
||||
for (const scope of [[], undefined, null]) {
|
||||
assert.equal(rowOrigin(row({}), scope), 'unattributed', JSON.stringify(scope))
|
||||
}
|
||||
assert.equal(originStamp('unattributed', CLOCK).text, CLOCK)
|
||||
assert.doesNotMatch(
|
||||
originStamp('unattributed', CLOCK).hint,
|
||||
/earlier run/,
|
||||
'saying nothing is the honest answer here; guessing "carried" is not',
|
||||
)
|
||||
})
|
||||
|
||||
test('a run over every target leaves nothing carried', () => {
|
||||
const all = ['auto', 'stealth', 'via-tunnel']
|
||||
for (const group of all) {
|
||||
assert.equal(rowOrigin(row({ group }), all), 'this-run', group)
|
||||
}
|
||||
})
|
||||
|
||||
test('no row is not a row from an earlier run', () => {
|
||||
assert.equal(rowOrigin(undefined, ['auto']), 'unattributed')
|
||||
assert.equal(rowOrigin(null, ['auto']), 'unattributed')
|
||||
})
|
||||
|
||||
test('the three origins produce three distinct hints', () => {
|
||||
const hints = new Set(
|
||||
(['this-run', 'carried', 'unattributed'] as const).map((o) => originStamp(o, CLOCK).hint),
|
||||
)
|
||||
assert.equal(hints.size, 3, 'three different situations, three different explanations')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange. */
|
||||
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange;
|
||||
* .btn.crit is the solid-red destructive commit. */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 7px 12px;
|
||||
@@ -30,3 +31,16 @@
|
||||
color: #fff;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Destructive commit. The fill is crit stepped a little toward black so white
|
||||
* label text clears 4.5:1 in BOTH themes — the raw --crit is bright enough in
|
||||
* dark mode to fall under it. Red here always means "this removes something". */
|
||||
.btn.crit {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--crit) 88%, #000);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.crit:hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import './Button.css'
|
||||
import { forwardRef } from 'react'
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** `primary` is the solid-orange call to action; `ghost` is the default. */
|
||||
variant?: 'ghost' | 'primary'
|
||||
/**
|
||||
* `primary` is the solid-orange call to action; `crit` is the solid-red
|
||||
* destructive commit (delete, remove) — semantic crit, never the accent;
|
||||
* `ghost` is the default.
|
||||
*/
|
||||
variant?: 'ghost' | 'primary' | 'crit'
|
||||
}
|
||||
|
||||
export function Button({ variant = 'ghost', className, type, ...rest }: ButtonProps) {
|
||||
/** Ref-forwarding so a dialog can park focus on a specific button. */
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'ghost', className, type, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? 'button'}
|
||||
className={['btn', variant === 'primary' ? 'primary' : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
className={['btn', variant === 'ghost' ? '' : variant, className].filter(Boolean).join(' ')}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The panel's wall clock, in the SAME timezone as every timestamp under it.
|
||||
*
|
||||
* It used to read `getUTCHours()` and print "UTC", while `format.ts` renders every
|
||||
* log line, connection event and date through `toLocaleTimeString` — i.e. the
|
||||
* browser's zone. In Moscow that put two clocks three hours apart on one plate,
|
||||
* and the header was the one nobody could reconcile: the router's "started" time
|
||||
* read later than the current time while the uptime said it had been up for hours.
|
||||
*
|
||||
* So the clock follows the rest of the panel — local, and it SAYS which offset
|
||||
* that is, because a bare "12:41:07" beside a router in another zone is the
|
||||
* ambiguity that started this. The zone label is the browser's UTC offset, not an
|
||||
* abbreviation: "MSK"/"CEST" are not derivable everywhere, an offset always is.
|
||||
*
|
||||
* This is the BROWSER's clock, not the router's — the appliance has no RTC. Every
|
||||
* router-sourced instant in the panel is converted to this clock before it is
|
||||
* shown, which is what makes one label at the top honest for the whole page.
|
||||
*/
|
||||
function zoneLabel(d: Date): string {
|
||||
// getTimezoneOffset() is minutes WEST of UTC, so the sign is inverted.
|
||||
const min = -d.getTimezoneOffset()
|
||||
if (min === 0) return 'UTC'
|
||||
const sign = min < 0 ? '−' : '+'
|
||||
const a = Math.abs(min)
|
||||
const h = Math.floor(a / 60)
|
||||
const m = a % 60
|
||||
return `UTC${sign}${h}${m ? `:${String(m).padStart(2, '0')}` : ''}`
|
||||
}
|
||||
|
||||
function format(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} UTC`
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${zoneLabel(d)}`
|
||||
}
|
||||
|
||||
/** Live UTC readout, tabular digits, ticking once a second. */
|
||||
/** The full zone name, for the title — "Europe/Moscow" says more than "+3" does. */
|
||||
function zoneName(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Live local readout, tabular digits, ticking once a second. */
|
||||
export function Clock({ className }: { className?: string }) {
|
||||
const [now, setNow] = useState(() => format(new Date()))
|
||||
|
||||
@@ -14,5 +52,13 @@ export function Clock({ className }: { className?: string }) {
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return <span className={['clock', className].filter(Boolean).join(' ')}>{now}</span>
|
||||
const zone = zoneName()
|
||||
return (
|
||||
<span
|
||||
className={['clock', className].filter(Boolean).join(' ')}
|
||||
title={zone ? `Your device's clock — ${zone}. Every time in the panel is shown in this zone.` : undefined}
|
||||
>
|
||||
{now}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* <ConfirmDialog> — the safety interlock plate.
|
||||
*
|
||||
* This replaces the browser's native confirm dialog, which a browser can mute for
|
||||
* good ("prevent this page from creating additional dialogs"): after that it
|
||||
* returns false with no dialog at all, so every delete button in the panel goes
|
||||
* dead and silent with no way to recover short of a page reload. We draw the
|
||||
* plate ourselves, so nothing can suppress it.
|
||||
*
|
||||
* Faceplate language: a small rack module lifted off the panel — corner screws
|
||||
* (reused from Faceplate.css), an engraved label, a groove above the actions.
|
||||
* Destructive intent is carried by the crit semantic, never by the orange accent:
|
||||
* accent means "this control is active", crit means "this destroys something".
|
||||
*/
|
||||
|
||||
/* The veil is a fixed dark wash in both themes — a light scrim over a light
|
||||
* panel would not read as "the panel is out of reach". Follows the tokens.css
|
||||
* pattern: light base, dark via media query, data-theme overrides win both ways. */
|
||||
.cfm-scrim {
|
||||
--cfm-veil: rgba(33, 29, 21, 0.52);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cfm-scrim {
|
||||
--cfm-veil: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
}
|
||||
:root[data-theme='light'] .cfm-scrim {
|
||||
--cfm-veil: rgba(33, 29, 21, 0.52);
|
||||
}
|
||||
:root[data-theme='dark'] .cfm-scrim {
|
||||
--cfm-veil: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
.cfm-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Short viewports: the plate scrolls with the veil instead of being clipped. */
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--u, 8px) * 2);
|
||||
background: var(--cfm-veil);
|
||||
animation: cfm-veil-in 0.14s ease-out;
|
||||
}
|
||||
|
||||
.cfm-card {
|
||||
position: relative;
|
||||
width: min(32rem, 100%);
|
||||
max-height: calc(100dvh - var(--u, 8px) * 4);
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--u, 8px) * 3.25);
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 12px;
|
||||
/* same brushed plate as <Faceplate>, one step brighter so it reads as lifted */
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0 2px,
|
||||
color-mix(in srgb, var(--edge) 30%, transparent) 2px 3px
|
||||
),
|
||||
linear-gradient(180deg, var(--raised), color-mix(in srgb, var(--raised) 82%, var(--panel)));
|
||||
box-shadow:
|
||||
0 1px 0 var(--edge) inset,
|
||||
0 30px 60px -22px var(--shadow),
|
||||
0 4px 12px var(--shadow);
|
||||
animation: cfm-card-in 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
.cfm-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* `still` is set from usePrefersReducedMotion — the plate appears, it never
|
||||
* travels. (The global reduced-motion rule in tokens.css also neutralises the
|
||||
* duration; this keeps the intent explicit at the component.) */
|
||||
.cfm-scrim.still,
|
||||
.cfm-scrim.still .cfm-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes cfm-veil-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes cfm-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- header: engraved label + state LED ---- */
|
||||
.cfm-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: calc(var(--u, 8px) * 1.5);
|
||||
}
|
||||
.cfm-label {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: var(--track-label-wide, 0.24em);
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---- copy ---- */
|
||||
.cfm-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
/* names can be long and unbroken — wrap rather than push the plate wide */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cfm-body {
|
||||
margin: calc(var(--u, 8px) * 1.5) 0 0;
|
||||
max-width: 52ch;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- action bar ---- */
|
||||
.cfm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(var(--u, 8px));
|
||||
margin-top: calc(var(--u, 8px) * 3);
|
||||
padding-top: calc(var(--u, 8px) * 2);
|
||||
border-top: 1px solid var(--groove);
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.cfm-card {
|
||||
padding: calc(var(--u, 8px) * 2.5);
|
||||
}
|
||||
.cfm-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfm-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
/* screws crowd a small plate — drop them rather than collide with the copy */
|
||||
.cfm-card > .screw {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import './ConfirmDialog.css'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from './Button'
|
||||
import { Led } from './Led'
|
||||
import { usePrefersReducedMotion } from './usePrefersReducedMotion'
|
||||
|
||||
/**
|
||||
* How the confirming button is painted.
|
||||
*
|
||||
* crit — the action destroys something. Semantic crit, never the accent.
|
||||
* neutral — the action is a normal commit the operator should read first
|
||||
* (a warning before saving); the accent's call-to-action is correct.
|
||||
*/
|
||||
export type ConfirmTone = 'crit' | 'neutral'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** Engraved eyebrow, e.g. "DELETE RULE". Names the operation, not the object. */
|
||||
label?: string
|
||||
/** The question. One line, ends in "?". */
|
||||
title: string
|
||||
/** The consequence — what changes on the router if this goes through. */
|
||||
body?: ReactNode
|
||||
/** Verb on the confirming button. Defaults to "Delete". */
|
||||
confirmLabel?: string
|
||||
/** Verb on the dismissing button. Defaults to "Cancel". */
|
||||
cancelLabel?: string
|
||||
/** Defaults to `crit` — the overwhelmingly common case is a delete. */
|
||||
tone?: ConfirmTone
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps extends ConfirmOptions {
|
||||
open: boolean
|
||||
/** Called exactly once per dialog, with the operator's answer. */
|
||||
onResolve: (confirmed: boolean) => void
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* The modal plate itself. Normally reached through `useConfirm()`; exported so a
|
||||
* page that wants to own the open state can render it directly.
|
||||
*
|
||||
* Keyboard contract:
|
||||
* - focus moves to Cancel on open, so a reflex Enter dismisses, never deletes;
|
||||
* - Tab / Shift+Tab cycle inside the plate and cannot reach the page behind it;
|
||||
* - Esc answers "no";
|
||||
* - on close, focus returns to whatever opened the dialog.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onResolve,
|
||||
label,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
tone = 'crit',
|
||||
}: ConfirmDialogProps) {
|
||||
const titleId = useId()
|
||||
const bodyId = useId()
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const openerRef = useRef<HTMLElement | null>(null)
|
||||
const reduced = usePrefersReducedMotion()
|
||||
|
||||
// Take the page out of the tab order, park focus on Cancel, and hand focus
|
||||
// back to the opener when the plate goes away.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const opener = document.activeElement
|
||||
openerRef.current = opener instanceof HTMLElement ? opener : null
|
||||
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
// Cancel is the resting place: an Enter or a Space meant for the page lands
|
||||
// on "no". The destructive button is one Tab away, deliberately.
|
||||
;(cancelRef.current ?? cardRef.current)?.focus()
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow
|
||||
const back = openerRef.current
|
||||
openerRef.current = null
|
||||
if (back && document.contains(back)) back.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Esc answers no; Tab is caged. Capture phase so a page-level key handler
|
||||
// never sees keys aimed at the dialog.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onResolve(false)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const card = cardRef.current
|
||||
if (!card) return
|
||||
const list = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
if (list.length === 0) {
|
||||
e.preventDefault()
|
||||
card.focus()
|
||||
return
|
||||
}
|
||||
const first = list[0]
|
||||
const last = list[list.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : first).focus()
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [open, onResolve])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={['cfm-scrim', reduced ? 'still' : ''].filter(Boolean).join(' ')}
|
||||
// A click on the field around the plate means "not now". Mousedown (not
|
||||
// click) so a text selection dragged out of the plate can't dismiss it.
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onResolve(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`cfm-card tone-${tone}`}
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={body != null ? bodyId : undefined}
|
||||
>
|
||||
<i className="screw tl" aria-hidden="true" />
|
||||
<i className="screw tr" aria-hidden="true" />
|
||||
<i className="screw bl" aria-hidden="true" />
|
||||
<i className="screw br" aria-hidden="true" />
|
||||
|
||||
{/* Lamp first, then the engraved label — the way a real panel reads, and
|
||||
it keeps the LED off the corner screw. */}
|
||||
<div className="cfm-hd">
|
||||
<Led variant={tone === 'crit' ? 'crit' : 'amber'} />
|
||||
<span className="cfm-label">{label ?? (tone === 'crit' ? 'Confirm delete' : 'Confirm')}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="cfm-title" id={titleId}>
|
||||
{title}
|
||||
</h2>
|
||||
{body != null && (
|
||||
<p className="cfm-body" id={bodyId}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="cfm-actions">
|
||||
<Button ref={cancelRef} onClick={() => onResolve(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={tone === 'crit' ? 'crit' : 'primary'} onClick={() => onResolve(true)}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- provider + hook --------------------------------------------------------
|
||||
|
||||
interface Request extends ConfirmOptions {
|
||||
id: number
|
||||
resolve: (v: boolean) => void
|
||||
}
|
||||
|
||||
const ConfirmCtx = createContext<((o: ConfirmOptions) => Promise<boolean>) | null>(null)
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Everything below can then ask a question and await
|
||||
* the answer.
|
||||
*/
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [req, setReq] = useState<Request | null>(null)
|
||||
const pending = useRef<Request | null>(null)
|
||||
const seq = useRef(0)
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOptions) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
// A second question while one is open answers the first with "no" rather
|
||||
// than leaving its promise — and its caller — hanging forever.
|
||||
pending.current?.resolve(false)
|
||||
seq.current += 1
|
||||
const next: Request = { ...opts, id: seq.current, resolve }
|
||||
pending.current = next
|
||||
setReq(next)
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const settle = useCallback((confirmed: boolean) => {
|
||||
const open = pending.current
|
||||
pending.current = null
|
||||
setReq(null)
|
||||
open?.resolve(confirmed)
|
||||
}, [])
|
||||
|
||||
// Teardown must not strand a caller mid-await.
|
||||
useEffect(
|
||||
() => () => {
|
||||
pending.current?.resolve(false)
|
||||
pending.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// A question belongs to the page that asked it. The provider outlives the
|
||||
// hash router, so a navigation would otherwise leave a stale plate floating
|
||||
// over a page it has nothing to do with — answer it "no" and clear it.
|
||||
useEffect(() => {
|
||||
const onNav = () => {
|
||||
if (pending.current) settle(false)
|
||||
}
|
||||
window.addEventListener('hashchange', onNav)
|
||||
return () => window.removeEventListener('hashchange', onNav)
|
||||
}, [settle])
|
||||
|
||||
return (
|
||||
<ConfirmCtx.Provider value={confirm}>
|
||||
{children}
|
||||
{req !== null && <ConfirmDialog key={req.id} open onResolve={settle} {...req} />}
|
||||
</ConfirmCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the operator, get a definite answer:
|
||||
*
|
||||
* const confirm = useConfirm()
|
||||
* if (!(await confirm({ title: 'Delete rule "x"?', body: '…' }))) return
|
||||
*
|
||||
* The returned function is stable, so it is safe in a useCallback dep list. It
|
||||
* always settles — cancel, Esc, click-outside and teardown all resolve `false`;
|
||||
* only the confirming button resolves `true`.
|
||||
*
|
||||
* Name it `confirm` at the call site on purpose: the local binding shadows the
|
||||
* global one inside that component, so an accidental bare `confirm(...)` cannot
|
||||
* reach the suppressible native dialog.
|
||||
*/
|
||||
export function useConfirm(): (o: ConfirmOptions) => Promise<boolean> {
|
||||
const ctx = useContext(ConfirmCtx)
|
||||
if (!ctx) {
|
||||
// Loud on purpose. A fallback that quietly resolved false would rebuild the
|
||||
// exact bug this component exists to kill.
|
||||
throw new Error('useConfirm() needs <ConfirmProvider> above it (mounted in main.tsx)')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DetourCatalog } from '../detour'
|
||||
|
||||
/**
|
||||
* The detour picker: `Direct` plus every group / chain / interface-egress / node
|
||||
* the Model currently defines.
|
||||
*
|
||||
* One <select>, shared by every page that pins traffic to a route — a resolver's
|
||||
* DNS path, an alert's delivery, a subscription's fetch, the list-fetch route.
|
||||
* It was three byte-identical copies (DNS.tsx, Nodes.tsx, Alerts.tsx) before the
|
||||
* fourth consumer arrived; the option labels are the words the operator learns
|
||||
* the vocabulary from, so they have to be the SAME words on every page.
|
||||
*
|
||||
* `className` and `directLabel` are the only things a page varies. `directLabel`
|
||||
* matters: under a proxy-fetch `Direct` is not "no preference", it is the plain
|
||||
* WAN with the router's real address, and the page that means that says so.
|
||||
*
|
||||
* A stored value the catalog no longer knows is kept as a trailing
|
||||
* `<option>… (missing)</option>` rather than silently reselecting the first
|
||||
* entry — a picker that quietly rewrites a stale setting to `direct` would turn
|
||||
* a visible misconfiguration into an invisible leak.
|
||||
*/
|
||||
export interface DetourSelectProps {
|
||||
/** Canonical value — run the stored string through `canonDetour` first. */
|
||||
value: string
|
||||
catalog: DetourCatalog
|
||||
/** `detourValues(catalog)` — what counts as still-resolvable. */
|
||||
valid: Set<string>
|
||||
disabled?: boolean
|
||||
/** A save/apply is in flight; folded into `disabled`. */
|
||||
busy?: boolean
|
||||
ariaLabel: string
|
||||
onChange: (v: string) => void
|
||||
className?: string
|
||||
directLabel?: string
|
||||
/**
|
||||
* Turns the picker into an OVERRIDE picker: adds a leading `<option value="">`
|
||||
* with this label, meaning "not set here — inherit". Only for fields where
|
||||
* empty and `direct` are different inputs (a profile override: `''` inherits
|
||||
* whatever globals says, `direct` forces the plain WAN over a globals setting
|
||||
* that tunnels). Leave it off and `''` is not a selectable state.
|
||||
*/
|
||||
inheritLabel?: string
|
||||
}
|
||||
|
||||
export function DetourSelect({
|
||||
value,
|
||||
catalog,
|
||||
valid,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
className = 'fp-input',
|
||||
directLabel = 'Direct (no proxy)',
|
||||
inheritLabel,
|
||||
}: DetourSelectProps) {
|
||||
const missing = value !== '' && value !== 'direct' && !valid.has(value)
|
||||
return (
|
||||
<select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={busy || disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{inheritLabel !== undefined && <option value="">{inheritLabel}</option>}
|
||||
<option value="direct">{directLabel}</option>
|
||||
{catalog.groups.length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{catalog.groups.map((g) => (
|
||||
<option key={g} value={`group:${g}`}>
|
||||
Group {g} (balancer)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.chains.length > 0 && (
|
||||
<optgroup label="Chains">
|
||||
{catalog.chains.map((c) => (
|
||||
<option key={c} value={`chain:${c}`}>
|
||||
Chain {c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.egresses.length > 0 && (
|
||||
<optgroup label="Interfaces / egresses">
|
||||
{catalog.egresses.map((e) => (
|
||||
<option key={e.name} value={`egress:${e.name}`}>
|
||||
Interface/egress {e.name}
|
||||
{e.type ? ` (${e.type})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.nodes.length > 0 && (
|
||||
<optgroup label="Nodes">
|
||||
{catalog.nodes.map((n) => (
|
||||
<option key={n} value={`node:${n}`}>
|
||||
Node {n}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{missing && <option value={value}>{value} (missing)</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* ListPicker — the additions on top of SrcPicker.css.
|
||||
*
|
||||
* Everything structural (the machined slot, the flush-docked popover, the shelf
|
||||
* strips, the sunken rows, the chip shell and its ×) is SrcPicker's and is reused
|
||||
* verbatim; only what this picker says differently lives here:
|
||||
*
|
||||
* .lstp-lane / .lstp-step the two ranked lanes inside one slot, carrying the
|
||||
* engine step number that ties the control back to the
|
||||
* order rail on the device card;
|
||||
* .lstp-dot / .lstp-load the load reading — the one colour on a list chip. It
|
||||
* is driven by data-load, never by "is it attached":
|
||||
* on = matching, warn = loaded but empty, crit = not
|
||||
* loaded / no such list, unknown = the engine has not
|
||||
* said, which is DIM and never green.
|
||||
*/
|
||||
|
||||
/* --- ranked lanes --------------------------------------------------------- */
|
||||
.lstp-lane {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 1px 4px 1px 1px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--panel) 55%, transparent);
|
||||
}
|
||||
|
||||
/* The step number. A machined stamp, not a bullet: it is the same number printed
|
||||
* on the card's order rail, so the two read as one instrument. */
|
||||
.lstp-step {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 3px;
|
||||
background: var(--sink);
|
||||
box-shadow: 0 1px 1px var(--shadow) inset;
|
||||
color: var(--dim);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* --- chips ---------------------------------------------------------------- */
|
||||
/* A typed entry is hand-made: amber dot, same as SrcPicker's custom value. It
|
||||
* carries NO kind tag — the marker it was typed with is already the first word of
|
||||
* the label, and "full:discord.com FULL" says the same thing twice. */
|
||||
|
||||
/* An attached list carries the engine's verdict, and nothing else. */
|
||||
.lstp-dot {
|
||||
background: var(--faint);
|
||||
}
|
||||
[data-load='on'] > .lstp-dot,
|
||||
.lstp-dot[data-load='on'] {
|
||||
background: var(--led-on);
|
||||
box-shadow: 0 0 4px var(--led-on);
|
||||
}
|
||||
[data-load='warn'] > .lstp-dot,
|
||||
.lstp-dot[data-load='warn'] {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 4px var(--amber);
|
||||
}
|
||||
[data-load='crit'] > .lstp-dot,
|
||||
.lstp-dot[data-load='crit'] {
|
||||
background: var(--crit);
|
||||
box-shadow: 0 0 4px var(--crit);
|
||||
}
|
||||
[data-load='unknown'] > .lstp-dot,
|
||||
.lstp-dot[data-load='unknown'] {
|
||||
background: var(--faint);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The verdict, in full. It is never truncated: "not loaded" clipped to
|
||||
* "NOT LOAD…" is the one word on this card a parent has to be able to read. */
|
||||
.lstp-load {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
color: var(--faint);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
[data-load='on'] > .lstp-load,
|
||||
.lstp-load[data-load='on'] {
|
||||
color: var(--led-on);
|
||||
}
|
||||
[data-load='warn'] > .lstp-load,
|
||||
.lstp-load[data-load='warn'] {
|
||||
color: var(--amber);
|
||||
}
|
||||
[data-load='crit'] > .lstp-load,
|
||||
.lstp-load[data-load='crit'] {
|
||||
color: var(--crit);
|
||||
}
|
||||
|
||||
.lstp-chip-list[data-load='crit'] {
|
||||
border-color: color-mix(in srgb, var(--crit) 45%, var(--groove));
|
||||
}
|
||||
.lstp-chip-list[data-load='warn'] {
|
||||
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
|
||||
}
|
||||
|
||||
/* --- popover rows --------------------------------------------------------- */
|
||||
.lstp-list {
|
||||
max-height: 168px;
|
||||
}
|
||||
.lstp-row {
|
||||
gap: 6px;
|
||||
}
|
||||
.lstp-row-detail {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
.lstp-row-tags {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.lstp-tag {
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 3px;
|
||||
color: var(--dim);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--track-label, 0.08em);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* --- notes ---------------------------------------------------------------- */
|
||||
.lstp-hint,
|
||||
.lstp-foot {
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
border-top: 1px solid var(--groove);
|
||||
background: var(--panel);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--dim);
|
||||
}
|
||||
.lstp-hint .mono {
|
||||
color: var(--ink);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.lstp-foot {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.lstp-row-detail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { describeDomainEntry, parseDomainEntry } from '../deviceLists'
|
||||
import type { ListLoad } from '../deviceLists'
|
||||
import './SrcPicker.css'
|
||||
import './ListPicker.css'
|
||||
|
||||
/**
|
||||
* ListPicker — the Faceplate chooser for ONE direction of a device's domain
|
||||
* policy: the named lists attached to it plus the domains typed on it, in a
|
||||
* single slot.
|
||||
*
|
||||
* It borrows SrcPicker's LANGUAGE wholesale (a machined slot of chips, a popover
|
||||
* docked flush under it with hairline shelves, a Custom row at the bottom, Esc and
|
||||
* click-outside, a dot encoding the chip's kind) and its stylesheet — but not its
|
||||
* code. SrcPicker is welded to `useSrcOptions()`, to IP/CIDR validation, and to an
|
||||
* empty state that reads "everyone · all LAN clients"; on a BLOCK list that
|
||||
* sentence would mean the exact opposite of the truth. Generalising it would have
|
||||
* produced a component with two of everything and a props list nobody reads.
|
||||
*
|
||||
* ## What the slot has to make legible
|
||||
*
|
||||
* The two chip kinds are not equal, and they are not adjacent in the engine's
|
||||
* order either. A device is evaluated:
|
||||
*
|
||||
* 1 allow typed · 2 block typed · 3 allow attached · 4 block attached · 5 network
|
||||
*
|
||||
* so the typed chips and the attached chips inside ONE control sit two steps
|
||||
* apart. The slot therefore renders them as two labelled lanes carrying their own
|
||||
* step number, which is what ties this control back to the order rail above it on
|
||||
* the card. Sorting them the other way round would draw a precedence that does not
|
||||
* exist.
|
||||
*
|
||||
* ## What a chip is not allowed to say
|
||||
*
|
||||
* A list chip reports what the ENGINE says about that list (via
|
||||
* /api/ruleset/status, through `listLoad`), never the fact that someone attached
|
||||
* it. A list whose URL is unreachable, whose file is missing, or whose geosite
|
||||
* category resolved to nothing is a parental control that does not work; it is
|
||||
* drawn `not loaded`, in crit, and a list the engine has not mentioned at all is
|
||||
* drawn `load unknown` rather than green.
|
||||
*/
|
||||
|
||||
export type ListPickerKind = 'block' | 'allow'
|
||||
|
||||
/** One attachable named list, as the page knows it. */
|
||||
export interface ListOption {
|
||||
/** The `config blocklist` / `config allowlist` section name — the stored value. */
|
||||
name: string
|
||||
/** Where its content comes from: "url · big.oisd.nl", "3 domains", "geosite · telegram". */
|
||||
detail: string
|
||||
/** Blocklist/Allowlist.Enabled — whether it is in the NETWORK-wide filter.
|
||||
* It does NOT gate this device: attaching the list is the switch here. */
|
||||
networkEnabled: boolean
|
||||
/** Blocklist.Response — the reply a blocked name gets. Block lists only. */
|
||||
response?: string
|
||||
/** What the running engine reports about it. */
|
||||
load: ListLoad
|
||||
}
|
||||
|
||||
export interface ListPickerProps {
|
||||
kind: ListPickerKind
|
||||
/** Attached list names — Device.Blocklists / Device.Allowlists. */
|
||||
lists: string[]
|
||||
/** Hand-typed entries — Device.Block / Device.Allow. */
|
||||
domains: string[]
|
||||
/** Every list that could be attached, in config order. */
|
||||
options: ListOption[]
|
||||
/** The engine step the TYPED lane is (1 for allow, 2 for block). */
|
||||
typedStep: number
|
||||
/** The engine step the ATTACHED lane is (3 for allow, 4 for block). */
|
||||
listStep: number
|
||||
disabled?: boolean
|
||||
ariaLabel: string
|
||||
onListsChange: (v: string[]) => void
|
||||
onDomainsChange: (v: string[]) => void
|
||||
}
|
||||
|
||||
const WORD: Record<ListPickerKind, { noun: string; verb: string; shelf: string }> = {
|
||||
block: { noun: 'block', verb: 'Blocked', shelf: 'Blocklists' },
|
||||
allow: { noun: 'allow', verb: 'Allowed', shelf: 'Allowlists' },
|
||||
}
|
||||
|
||||
const EMPTY_TEXT: Record<ListPickerKind, string> = {
|
||||
block: 'nothing blocked for this device',
|
||||
allow: 'nothing forced through for this device',
|
||||
}
|
||||
|
||||
const CUSTOM_PLACEHOLDER: Record<ListPickerKind, string> = {
|
||||
block: 'example.com or keyword:tiktok',
|
||||
allow: 'school.example.edu',
|
||||
}
|
||||
|
||||
/** Said at the moment of choosing, because both facts change what the operator is
|
||||
* about to do — and neither is visible from the chip afterwards. */
|
||||
const FOOTNOTE: Record<ListPickerKind, string> = {
|
||||
block:
|
||||
'Attaching a list runs it for this device even when the list is off for the network. The reply a blocked name gets comes from the list, not from the device.',
|
||||
allow:
|
||||
'An attached allow list is terminal: everything it covers is also lifted out of the network blocklists for this device. A big list here removes a lot of filtering.',
|
||||
}
|
||||
|
||||
/** The reading for a name the config no longer has. Built once — it never varies. */
|
||||
const MISSING_LOAD: ListLoad = {
|
||||
tone: 'crit',
|
||||
tag: 'no such list',
|
||||
detail: 'This device points at a list that is not in the config — it filters nothing.',
|
||||
ruleCount: 0,
|
||||
}
|
||||
|
||||
export function ListPicker({
|
||||
kind,
|
||||
lists,
|
||||
domains,
|
||||
options,
|
||||
typedStep,
|
||||
listStep,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onListsChange,
|
||||
onDomainsChange,
|
||||
}: ListPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [custom, setCustom] = useState('')
|
||||
const [customErr, setCustomErr] = useState<string | null>(null)
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const customRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const byName = useMemo(() => new Map(options.map((o) => [o.name, o])), [options])
|
||||
const attached = useMemo(() => new Set(lists), [lists])
|
||||
const free = useMemo(() => options.filter((o) => !attached.has(o.name)), [options, attached])
|
||||
|
||||
// A stored name with no matching option is a device pointing at a deleted list.
|
||||
// It keeps its chip and says so, rather than vanishing from the card.
|
||||
const listChips = useMemo(
|
||||
() =>
|
||||
lists.map((name) => {
|
||||
const opt = byName.get(name)
|
||||
return { name, opt, load: opt ? opt.load : MISSING_LOAD }
|
||||
}),
|
||||
[lists, byName],
|
||||
)
|
||||
const domainChips = useMemo(
|
||||
() => domains.map((d) => ({ value: d, ...describeDomainEntry(d) })),
|
||||
[domains],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
fieldRef.current?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const w = WORD[kind]
|
||||
|
||||
const attachList = (name: string) => {
|
||||
if (attached.has(name)) return
|
||||
onListsChange([...lists, name])
|
||||
}
|
||||
const detachList = (name: string) => onListsChange(lists.filter((x) => x !== name))
|
||||
const removeDomain = (v: string) => onDomainsChange(domains.filter((x) => x !== v))
|
||||
|
||||
const addCustom = () => {
|
||||
const parsed = parseDomainEntry(custom)
|
||||
if (!parsed.ok) {
|
||||
setCustomErr(parsed.reason)
|
||||
return
|
||||
}
|
||||
if (domains.some((d) => d.toLowerCase() === parsed.value)) {
|
||||
setCustomErr(`${parsed.value} is already on this list.`)
|
||||
return
|
||||
}
|
||||
onDomainsChange([...domains, parsed.value])
|
||||
setCustom('')
|
||||
setCustomErr(null)
|
||||
customRef.current?.focus()
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (disabled) return
|
||||
setOpen((o) => !o)
|
||||
}
|
||||
|
||||
const onFieldKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const empty = listChips.length === 0 && domainChips.length === 0
|
||||
|
||||
return (
|
||||
<div className="srcp lstp" ref={rootRef}>
|
||||
<div
|
||||
ref={fieldRef}
|
||||
className={disabled ? 'srcp-field disabled' : 'srcp-field'}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={toggleOpen}
|
||||
onKeyDown={onFieldKeyDown}
|
||||
>
|
||||
{empty ? (
|
||||
<span className="srcp-empty">{EMPTY_TEXT[kind]}</span>
|
||||
) : (
|
||||
<>
|
||||
{domainChips.length > 0 && (
|
||||
<span
|
||||
className="lstp-lane"
|
||||
role="group"
|
||||
aria-label={`Step ${typedStep} — domains typed here`}
|
||||
>
|
||||
<span className="lstp-step" aria-hidden="true">
|
||||
{typedStep}
|
||||
</span>
|
||||
{domainChips.map((c) => (
|
||||
<span key={c.value} className="srcp-chip lstp-chip-typed" title={c.detail}>
|
||||
<span className="srcp-dot srcp-dot-custom" aria-hidden="true" />
|
||||
<span className="srcp-chip-name mono">{c.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="srcp-chip-x"
|
||||
aria-label={`Remove ${c.value} from the ${w.noun} list`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeDomain(c.value)
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{listChips.length > 0 && (
|
||||
<span
|
||||
className="lstp-lane"
|
||||
role="group"
|
||||
aria-label={`Step ${listStep} — attached ${w.shelf.toLowerCase()}`}
|
||||
>
|
||||
<span className="lstp-step" aria-hidden="true">
|
||||
{listStep}
|
||||
</span>
|
||||
{listChips.map((c) => (
|
||||
<span
|
||||
key={c.name}
|
||||
className="srcp-chip lstp-chip-list"
|
||||
data-load={c.load.tone}
|
||||
title={`${c.name} — ${c.load.detail}`}
|
||||
>
|
||||
<span className="srcp-dot lstp-dot" aria-hidden="true" />
|
||||
<span className="srcp-chip-name mono">{c.name}</span>
|
||||
<span className="lstp-load">{c.load.tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="srcp-chip-x"
|
||||
aria-label={`Detach list ${c.name} — ${c.load.detail}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
detachList(c.name)
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="srcp-caret" aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="srcp-pop" role="dialog" aria-label={`${ariaLabel} — choose lists and domains`}>
|
||||
{/* Named lists ---------------------------------------------------- */}
|
||||
<div className="srcp-shelf" aria-hidden="true">
|
||||
<span>
|
||||
{w.shelf} · step {listStep}
|
||||
</span>
|
||||
<span className="srcp-shelf-n">{free.length || '—'}</span>
|
||||
</div>
|
||||
{free.length > 0 ? (
|
||||
<ul className="srcp-list lstp-list">
|
||||
{free.map((o) => (
|
||||
<li key={o.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="srcp-row lstp-row"
|
||||
onClick={() => attachList(o.name)}
|
||||
title={o.load.detail}
|
||||
>
|
||||
<span className="srcp-dot lstp-dot" data-load={o.load.tone} aria-hidden="true" />
|
||||
<span className="srcp-row-name">{o.name}</span>
|
||||
<span className="lstp-row-detail mono">{o.detail}</span>
|
||||
<span className="lstp-row-tags">
|
||||
{o.response === 'zero' && <span className="lstp-tag">0.0.0.0</span>}
|
||||
{!o.networkEnabled && (
|
||||
<span className="lstp-tag" title="Off for the network — attaching it still runs it here">
|
||||
network off
|
||||
</span>
|
||||
)}
|
||||
<span className="lstp-load" data-load={o.load.tone}>
|
||||
{o.load.tag}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="srcp-none">
|
||||
{options.length
|
||||
? `every ${w.shelf.toLowerCase().replace(/s$/, '')} is already attached`
|
||||
: `no ${w.shelf.toLowerCase()} configured — add one on the DNS page`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Typed domains -------------------------------------------------- */}
|
||||
<div className="srcp-shelf" aria-hidden="true">
|
||||
<span>
|
||||
Type a domain · step {typedStep}
|
||||
</span>
|
||||
</div>
|
||||
<div className="srcp-custom">
|
||||
<input
|
||||
ref={customRef}
|
||||
className="srcp-custom-input mono"
|
||||
type="text"
|
||||
value={custom}
|
||||
onChange={(e) => {
|
||||
setCustom(e.target.value)
|
||||
if (customErr) setCustomErr(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addCustom()
|
||||
}
|
||||
}}
|
||||
placeholder={CUSTOM_PLACEHOLDER[kind]}
|
||||
aria-label={`Domain to ${w.noun} for this device`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="srcp-custom-add"
|
||||
onClick={addCustom}
|
||||
disabled={!custom.trim()}
|
||||
>
|
||||
{kind === 'block' ? 'Block' : 'Allow'}
|
||||
</button>
|
||||
</div>
|
||||
{customErr && (
|
||||
<p className="srcp-note" role="alert">
|
||||
{customErr}
|
||||
</p>
|
||||
)}
|
||||
<p className="lstp-hint">
|
||||
A bare entry covers the domain and its subdomains.{' '}
|
||||
<span className="mono">full:</span> one exact name,{' '}
|
||||
<span className="mono">keyword:</span> any host containing it.
|
||||
</p>
|
||||
|
||||
<p className="lstp-foot">{FOOTNOTE[kind]}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user