Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+261
-271
@@ -1,51 +1,62 @@
|
|||||||
# Shater v0.2 — build the 4-package signed opkg feed and publish it as a rolling
|
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
|
||||||
# Gitea release consumable as an `src/gz` feed.
|
# per-arch Gitea releases consumable as an apk repository.
|
||||||
#
|
#
|
||||||
# WHAT CHANGED FROM v0.1
|
# WHAT WE SHIP
|
||||||
# v0.1 shipped 3 packages: xrayctl (SDK-compiled Go) + shater-core +
|
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
|
||||||
# luci-app-shater (hand-packed data .ipk). v0.2 collapses the runtime into ONE
|
# SDK way:
|
||||||
# forked binary and ships 4 packages, all built the canonical SDK way:
|
|
||||||
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
|
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
|
||||||
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
|
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
|
||||||
# and staged into openwrt/shaterd/files/ BEFORE the SDK
|
# and staged into openwrt/shaterd/files/ BEFORE the SDK
|
||||||
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
|
# 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
|
# - shater-core data glue, PKGARCH=all
|
||||||
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
|
# - 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)
|
# - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
|
||||||
#
|
#
|
||||||
# TARGET HARDWARE / ARCH MATRIX
|
# TARGET HARDWARE / ARCH MATRIX
|
||||||
# x86_64 -> the QEMU testbed VM (generic x86-64).
|
# x86_64 -> the QEMU testbed VM (generic x86-64).
|
||||||
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 + BPI-R4, mediatek/filogic).
|
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 mini + BPI-R4,
|
||||||
|
# mediatek/filogic), both on 25.12 with apk-tools 3.
|
||||||
# Only shaterd + byedpi are arch-specific; shater-core + luci-app-shater are
|
# 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
|
# PKGARCH=all, so one build of each covers every device — but the RELEASES
|
||||||
# Architecture at install time, so a single combined feed URL serves all.
|
# 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)
|
# FORMAT: apk ONLY (25.12+)
|
||||||
# The feed index (Packages) is usign-signed with the SECRET key in the Gitea
|
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
|
||||||
# repo secret KEY_BUILD; routers verify it with the committed public key
|
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
|
||||||
# dist/shater-feed.pub (fingerprint 5ac4b177689cb8e0). Do NOT regenerate the
|
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
|
||||||
# key — that invalidates every deployed router's trust.
|
# 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
|
# AUTO-RELEASE
|
||||||
# push a tag `vX.Y.Z` -> versioned release. workflow_dispatch / (optional) main
|
# push a tag `vX.Y.Z` -> versioned per-arch releases `apk-vX.Y.Z-<arch>`.
|
||||||
# -> rolling `latest` pre-release (always-fresh feed). Publish uses the Gitea
|
# workflow_dispatch -> rolling per-arch `apk-latest-<arch>` (always-fresh
|
||||||
# API via curl (ci/gitea-release.sh) — no external action needed.
|
# feed). Publish uses the Gitea API via curl (ci/gitea-release.sh) — no
|
||||||
|
# external action needed. NOTE: the apk release tags deliberately do NOT start
|
||||||
|
# with `v` so publishing them cannot re-trigger this workflow's `v*` filter.
|
||||||
#
|
#
|
||||||
# APK LANE (25.12+, ADDITIVE — T2)
|
# PACKAGE VERSIONING (bug B4)
|
||||||
# The fleet is migrating to BananaWRT 25.12-mtk-vendor (= ImmortalWrt 25.12
|
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
|
||||||
# base), where opkg is replaced by Alpine apk (.apk, binary packages.adb
|
# used to be, and nobody bumped them: v0.2.2…v0.2.6 all shipped as
|
||||||
# index, EC keys in /etc/apk/keys/). The `build-apk` + `release-apk` jobs
|
# `shaterd 0.2.0-r3` with different binaries inside, so `apk update` never saw
|
||||||
# below build the SAME 4 packages through the ImmortalWrt 25.12 apk-SDK and
|
# a new version and routers could not be updated at all. Now `ci/version.sh`
|
||||||
# publish PER-ARCH apk repos as releases `apk-latest-<arch>` (rolling) /
|
# derives them from the git tag ONCE per job (the "Compute version" step,
|
||||||
# `apk-<tag>-<arch>` (versioned). Per-arch because apk filenames carry no
|
# exported via $GITHUB_ENV):
|
||||||
# architecture (shaterd-0.2.0-r1.apk would collide across arches in one flat
|
# tag `vX.Y.Z` -> X.Y.Z-r1
|
||||||
# release) and apk fetches packages relative to the packages.adb URL.
|
# anything else -> <nearest tag>-r<commits since it + 1>
|
||||||
# Signed with the EC key in the Gitea secret KEY_APK; trust anchor
|
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
|
||||||
# dist/shater-apk.pem (ci/gen-apk-key.sh). The usign/opkg lane above is
|
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
|
||||||
# UNCHANGED and keeps serving the 24.10 fleet. NOTE: the apk release tags
|
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
|
||||||
# deliberately do NOT start with `v` so publishing them cannot re-trigger
|
# built .apk really carry that version, so the failure can never be silent
|
||||||
# this workflow's `v*` tag filter.
|
# again. This is also why the build job checks out with fetch-depth: 0
|
||||||
|
# — `git describe` needs tags and ancestry. `byedpi` is excluded: it keeps
|
||||||
|
# upstream ByeDPI's own PKG_VERSION (see openwrt/byedpi/Makefile).
|
||||||
|
|
||||||
# CACHING (T3 — fast CI)
|
# CACHING (T3 — fast CI)
|
||||||
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
|
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
|
||||||
@@ -63,34 +74,33 @@
|
|||||||
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
|
# (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
|
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
|
||||||
# prefix fallback is allowed.
|
# 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).
|
# jobs (each builds both GOARCHes).
|
||||||
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
|
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
|
||||||
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
|
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
|
||||||
# - apt .deb archives for the apk lane's debian:bookworm host-deps
|
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
|
||||||
# (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list is in it).
|
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
|
||||||
# - usign binary (.cache/tools) — static helper, fixed key.
|
# is in it).
|
||||||
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
|
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
|
||||||
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
|
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
|
||||||
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
|
# 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
|
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
|
||||||
# container from the workspace cache; `feeds update` on an existing clone
|
# container from the workspace cache; `feeds update` on an existing clone
|
||||||
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
|
# 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.
|
# 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 —
|
# Key = lane + SDK release (shared across the two arch jobs — the same
|
||||||
# same release pins identical feed revs; the sequential runner means the
|
# release pins identical feed revs; the sequential runner means the second
|
||||||
# second arch restores what the first saved). restore-keys lets an SDK
|
# arch restores what the first saved). restore-keys lets an SDK version
|
||||||
# version bump start from the old clones (git fetch delta, not re-clone).
|
# bump start from the old clones (git fetch delta, not re-clone).
|
||||||
# Act_runner facts this design leans on (verified in run 51 logs):
|
# Act_runner facts this design leans on (verified in run 51 logs):
|
||||||
# - the cache backend works: restores/saves confirmed, hashFiles() works;
|
# - the cache backend works: restores/saves confirmed, hashFiles() works;
|
||||||
# - docker images (openwrt/sdk, debian:bookworm, runner-images) live on the
|
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
|
||||||
# PERSISTENT host daemon — "Image is up to date" each run, no re-download;
|
# 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
|
# - 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
|
# stall (node process lingers; hit→no-save→no stall). Steady state saves
|
||||||
# nothing, so adding cache entries is fine, but keys that change every
|
# 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.
|
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
|
||||||
|
|
||||||
name: release
|
name: release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -108,41 +118,49 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# scripts/build-shaterd.sh builds the engine via a go.mod
|
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
|
||||||
# `replace => ./submodules/wireguard-go` (AmneziaWG fork), so that submodule
|
# this even `go list` fails. Same step/reason as in build-apk below.
|
||||||
# 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).
|
|
||||||
- name: Init wireguard-go submodule (awg)
|
- name: Init wireguard-go submodule (awg)
|
||||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod # pins Go 1.24.7 (go.mod `go` line)
|
go-version-file: go.mod
|
||||||
cache: false # explicit actions/cache@v3.3.2 below (setup-go's
|
cache: false # explicit actions/cache@v3.3.2 below
|
||||||
# built-in cache uses the new API act_runner lacks)
|
|
||||||
|
|
||||||
- name: Set up Node
|
# Same cache key as build-apk: this job runs first, so it warms the module
|
||||||
uses: actions/setup-node@v4
|
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
|
||||||
with:
|
|
||||||
node-version: '20' # Vite 5 needs Node 18+; 20 LTS
|
|
||||||
|
|
||||||
# ---- caches (see the header comment for keys + version pin rationale) ----
|
|
||||||
- name: Cache Go modules + build cache
|
- name: Cache Go modules + build cache
|
||||||
uses: actions/cache@v3.3.2
|
uses: actions/cache@v3.3.2
|
||||||
with:
|
with:
|
||||||
@@ -153,94 +171,36 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
go-
|
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
|
- name: Cache panel node_modules
|
||||||
id: npm-cache
|
|
||||||
uses: actions/cache@v3.3.2
|
uses: actions/cache@v3.3.2
|
||||||
with:
|
with:
|
||||||
path: panel/node_modules
|
path: panel/node_modules
|
||||||
key: npm-${{ hashFiles('panel/package-lock.json') }}
|
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)
|
- name: Panel tests
|
||||||
uses: actions/cache@v3.3.2
|
run: bash scripts/run-panel-tests.sh
|
||||||
with:
|
|
||||||
path: .cache/dl
|
|
||||||
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
|
|
||||||
restore-keys: |
|
|
||||||
dl-
|
|
||||||
|
|
||||||
# feeds git checkouts (see header): both 24.10.4 arch jobs share one entry
|
- name: Go tests (shipped tags, linux, + race)
|
||||||
# (same release = same feeds.conf.default pins), so derive the release
|
run: bash scripts/run-tests.sh
|
||||||
# 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
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# APK lane (additive): the same 4 packages through the ImmortalWrt 25.12
|
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
|
||||||
# apk-SDK for the 25.12/apk fleet (BananaWRT 25.12-mtk-vendor routers + the
|
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
|
||||||
# future 25.12 VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
||||||
# packages.adb + shater-apk.pem. Artifact prefix `apkfeed-` (NOT `shater-`)
|
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
|
||||||
# so the opkg release job's `artifacts/shater-*` glob never picks these up.
|
|
||||||
build-apk:
|
build-apk:
|
||||||
name: apk ${{ matrix.arch }}
|
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
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -253,8 +213,14 @@ jobs:
|
|||||||
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
|
- 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
|
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:
|
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
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
|
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
|
||||||
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
|
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
|
||||||
@@ -263,6 +229,13 @@ jobs:
|
|||||||
- name: Init wireguard-go submodule (awg)
|
- name: Init wireguard-go submodule (awg)
|
||||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
@@ -336,25 +309,36 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
feeds-apk-
|
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
|
- name: Install UPX
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
|
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
|
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
|
||||||
# binary is built OUT of the SDK and staged before the package build.
|
# 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
|
- name: Build & stage shaterd artifact
|
||||||
env:
|
env:
|
||||||
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
|
||||||
V="${GITHUB_REF#refs/tags/}"
|
|
||||||
else
|
|
||||||
V="v0.2.0-dev"
|
|
||||||
fi
|
|
||||||
FAST=""
|
FAST=""
|
||||||
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
|
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
|
||||||
echo "shaterd version: $V (npm cache hit: ${NPM_CACHE_HIT:-false})"
|
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 "$V" $FAST
|
bash scripts/build-shaterd.sh $FAST
|
||||||
|
|
||||||
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
|
# 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).
|
# sign the per-arch packages.adb with the EC key (secret KEY_APK).
|
||||||
@@ -377,120 +361,34 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Publish once both arches are built. Rolling `latest` on dispatch, a versioned
|
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
|
||||||
# release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API).
|
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
|
||||||
release:
|
# multi-arch release would collide). Every run refreshes the ROLLING pointer
|
||||||
name: release
|
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
|
||||||
needs: build
|
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
|
||||||
runs-on: ubuntu-latest
|
# publishing them cannot re-trigger the build.
|
||||||
steps:
|
#
|
||||||
- name: Checkout
|
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
|
||||||
uses: actions/checkout@v4
|
# 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
|
||||||
- name: Download all arch feeds
|
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
|
||||||
uses: actions/download-artifact@v3
|
# while v0.2.9/v0.2.10 published fine, and every router whose
|
||||||
with:
|
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
|
||||||
path: artifacts
|
# successful, silent `apk update` with nothing new. Rolling is the whole point
|
||||||
|
# of that URL, so it is now written unconditionally and asserted afterwards.
|
||||||
- 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.
|
|
||||||
release-apk:
|
release-apk:
|
||||||
name: release apk
|
name: release apk
|
||||||
needs: build-apk
|
needs: [test, build-apk]
|
||||||
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
|
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
|
||||||
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
|
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
|
||||||
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
|
# 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -501,6 +399,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: artifacts
|
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
|
- name: Determine release identity
|
||||||
id: rel
|
id: rel
|
||||||
run: |
|
run: |
|
||||||
@@ -522,25 +423,114 @@ jobs:
|
|||||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||||
run: |
|
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
|
for d in artifacts/apkfeed-*; do
|
||||||
[ -d "$d" ] || continue
|
[ -d "$d" ] || continue
|
||||||
arch="${d#artifacts/apkfeed-}"
|
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\`.
|
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 + byedpi (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/\`).
|
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\`) ──
|
── 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/$TAG/shater-apk.pem
|
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
|
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 update
|
||||||
apk add luci-app-shater # pulls shater-core + shaterd too
|
apk add luci-app-shater # pulls shater-core + shaterd too
|
||||||
apk add byedpi # optional: ByeDPI desync egress
|
apk add byedpi # optional: ByeDPI desync egress
|
||||||
Update: apk update && apk upgrade shaterd shater-core luci-app-shater byedpi
|
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
|
||||||
Full guide: docs-shater/INSTALL.md §6. The opkg/24.10 feed lives in the \`latest\` release."
|
assets, so the same repo line keeps serving the newest build. To pin a
|
||||||
echo "[release-apk] publishing $TAG from $d"
|
version instead, point the repo line at
|
||||||
TAG="$TAG" NAME="shater apk $VER ($arch)" BODY="$BODY" \
|
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
|
||||||
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
|
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 byedpi
|
||||||
|
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"/*
|
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
|
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
|
#!/usr/bin/env bash
|
||||||
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
|
# 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")/../..
|
PROJECT=$(dirname "$0")/../..
|
||||||
TMP_PATH=`mktemp -d`
|
TMP_PATH=$(mktemp -d)
|
||||||
cp $2 $TMP_PATH
|
trap 'rm -rf "$TMP_PATH"' EXIT
|
||||||
pushd $TMP_PATH
|
|
||||||
|
|
||||||
DEB_NAME=`ls *.deb`
|
cp "$DEB_SRC" "$TMP_PATH"/
|
||||||
ar x $DEB_NAME
|
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
|
mkdir control
|
||||||
pushd control
|
pushd control >/dev/null
|
||||||
tar xf ../control.tar.gz
|
tar xf ../control.tar.gz
|
||||||
rm md5sums
|
rm -f md5sums
|
||||||
sed "s/Architecture:\\ \w*/Architecture:\\ $1/g" ./control -i
|
sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
|
||||||
cat control
|
cat control
|
||||||
tar czf ../control.tar.gz ./*
|
tar czf ../control.tar.gz ./*
|
||||||
popd
|
popd >/dev/null
|
||||||
|
|
||||||
DEB_NAME=${DEB_NAME%.deb}
|
DEB_NAME=${DEB_NAME%.deb}
|
||||||
tar czf $DEB_NAME.ipk control.tar.gz data.tar.gz debian-binary
|
tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
|
||||||
popd
|
popd >/dev/null
|
||||||
|
|
||||||
cp $TMP_PATH/$DEB_NAME.ipk $3
|
cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
|
||||||
rm -r $TMP_PATH
|
|
||||||
|
|||||||
+7
-1
@@ -36,6 +36,12 @@ nul
|
|||||||
# playwright MCP screenshots/snapshots
|
# playwright MCP screenshots/snapshots
|
||||||
.playwright-mcp/
|
.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 -----------------------------------------
|
# -- upstream sing-box-lx -----------------------------------------
|
||||||
/.idea/
|
/.idea/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -57,7 +63,7 @@ nul
|
|||||||
/venv/
|
/venv/
|
||||||
/test/cache.db
|
/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/
|
/dist/
|
||||||
|
|
||||||
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
|
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
|
||||||
|
|||||||
+7
-1
@@ -7,4 +7,10 @@
|
|||||||
[submodule "submodules/wireguard-go"]
|
[submodule "submodules/wireguard-go"]
|
||||||
path = submodules/wireguard-go
|
path = submodules/wireguard-go
|
||||||
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
|
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
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
<!-- 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, MASQUE/CONNECT-IP.
|
||||||
|
|
||||||
|
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 and commit-confirm
|
||||||
|
auto-rollback.
|
||||||
|
- **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 byedpi`
|
||||||
|
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
|
||||||
|
pin a build; that file then has to be edited by hand for every upgrade.
|
||||||
|
|
||||||
|
shater ships **inert** (globals off) so install never breaks connectivity. After
|
||||||
|
configuring nodes/rules: `uci set shater.globals.enabled=1 && uci commit shater`,
|
||||||
|
then `shaterd apply` and `shaterd confirm`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## 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`, `byedpi` |
|
||||||
|
| `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,317 @@
|
|||||||
|
<!-- Язык: **Русский** · [English](README.en.md) -->
|
||||||
|
|
||||||
# shater
|
# shater
|
||||||
|
|
||||||
**A self-hosted internet-control appliance for OpenWrt.** One box turns your
|
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
|
||||||
network into a transparent VPN gateway, a network-wide ad/tracker/malware blocker,
|
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
|
||||||
per-device parental control, and a live traffic dashboard — configured from a rich
|
трекеров и вредоносных доменов, средство родительского контроля по устройствам
|
||||||
web admin panel, all local.
|
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
|
||||||
|
настраивается из богатой веб-панели.
|
||||||
|
|
||||||
[](LICENSE)
|
[](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)
|
**shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
|
||||||
(via [sing-box-lx](https://github.com/Leadaxe/sing-box-lx))** with our whole
|
BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
|
||||||
product embedded in the one binary: the proxy engine, a control plane, a DNS
|
клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
|
||||||
filter, and a full admin panel. Riding sing-box gives a broad, up-to-date protocol
|
гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
|
||||||
set — VLESS/VMess/Trojan/Shadowsocks, Reality, **AmneziaWG 2.0**, Hysteria2, TUIC —
|
веб-панели.
|
||||||
without reinventing the anti-DPI arms race.
|
|
||||||
|
|
||||||
The UI is split for both integration and a great experience: a **thin LuCI app**
|
Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
|
||||||
(a small dashboard + an "Open panel" button) hands a short-lived token to a
|
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
|
||||||
**standalone admin panel** the daemon serves on its own port — so panel auth is
|
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
|
||||||
bootstrapped from LuCI's existing login, and the real UX is a modern SPA we fully
|
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
|
||||||
own.
|
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
|
||||||
|
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP, MASQUE/CONNECT-IP.
|
||||||
|
|
||||||
## 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 |
|
**Надёжность («железно»)**
|
||||||
|-----|------|
|
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
|
||||||
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Start here** — project context, v0.1→v0.2 history, decisions in brief, testbed/infra |
|
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
|
||||||
| [`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 |
|
- Атомарный apply с валидацией движком и `nft -c`, **commit-confirm** с
|
||||||
| [`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. |
|
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
|
||||||
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Admin-panel visual system — the "Faceplate" direction, tokens, components, north-star prototype |
|
(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
|
- Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
|
||||||
prototype (prove AmneziaWG 2.0, measure binary size). Follow `docs-shater/ROADMAP.md`.
|
интервал обновления + вручную + на загрузке; стабильная идентичность узла между
|
||||||
|
обновлениями; квоты/срок из `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
|
- Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
|
||||||
QEMU test VM.
|
устройствам, таймлайны — из 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 (flow-offload) · block. Подробные диаграммы (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`
|
||||||
|
(+ опциональный `byedpi`). `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 add byedpi # опционально: ByeDPI desync-egress
|
||||||
|
```
|
||||||
|
|
||||||
|
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
|
||||||
|
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
|
||||||
|
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
|
||||||
|
пакеты.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apk update
|
||||||
|
apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||||
|
# эквивалент, дополнительно закрепляющий пакеты в world:
|
||||||
|
# apk add -u shaterd shater-core luci-app-shater byedpi
|
||||||
|
```
|
||||||
|
|
||||||
|
Документация 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 byedpi`.
|
||||||
|
|
||||||
|
> **Роллинг или фиксация — это выбор 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
|
||||||
|
uci commit shater
|
||||||
|
shaterd apply # apply + вооружить commit-confirm на живом демоне
|
||||||
|
shaterd confirm # подтвердить (отменяет авто-откат)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
|
||||||
|
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
|
||||||
|
панель (`:8088` по умолчанию).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сборка из исходников
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
|
||||||
|
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
|
||||||
|
|
||||||
|
| Путь | Что это |
|
||||||
|
|------|---------|
|
||||||
|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
|
||||||
|
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
|
||||||
|
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
|
||||||
|
| `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**. Форк
|
||||||
|
разрабатывается по 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).**
|
Раньше здесь лежал README форка движка **sing-box-lx**, который shater
|
||||||
> Небольшой набор клиентских фич поверх upstream — транспорт **XHTTP**, **AmneziaWG 2.0**, **MASQUE** (CONNECT-IP / Cloudflare WARP), расширения **наблюдаемости** (CommandClient) и балансировка нагрузки **round_robin** — каждая за своим build-tag.
|
вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
|
||||||
> Набор может расти, философия — нет: жить ребейзом на каждый upstream-тег, а не отдельной жизнью.
|
|
||||||
|
|
||||||
> 📄 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)).
|
> Файл оставлен как указатель, чтобы у репозитория был один основной русский
|
||||||
|
> README (`README.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.
|
|
||||||
|
|||||||
@@ -3,7 +3,33 @@
|
|||||||
| Поле | Значение |
|
| Поле | Значение |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| Тип | B (bug) |
|
| Тип | 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») конфигурацию, где
|
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
|
||||||
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
|
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
|
||||||
|
|||||||
@@ -45,30 +45,8 @@ type OutboundManager interface {
|
|||||||
Default() Outbound
|
Default() Outbound
|
||||||
Remove(tag string) error
|
Remove(tag string) error
|
||||||
Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) 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
|
// lx:begin idle-suspend
|
||||||
// IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick
|
// 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
|
// (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)
|
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 {
|
func (m *Manager) Default() adapter.Outbound {
|
||||||
m.access.RLock()
|
m.access.RLock()
|
||||||
defer m.access.RUnlock()
|
defer m.access.RUnlock()
|
||||||
|
|||||||
+27
-16
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (the 25.12
|
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (25.12+;
|
||||||
# lane — additive next to ci/build-feed.sh, which stays the opkg/24.10 lane).
|
# the only packaging lane shater has — see docs-shater/DECISIONS.md D22).
|
||||||
#
|
#
|
||||||
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
|
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
|
||||||
# e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \
|
# 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.
|
# This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls.
|
||||||
# It runs on the CI RUNNER and:
|
# It runs on the CI RUNNER and:
|
||||||
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
|
# 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);
|
# scripts/build-shaterd.sh (the artifact-order contract);
|
||||||
# 2. drives a plain `debian:bookworm` container (workspace shared via
|
# 2. drives a plain `debian:bookworm` container (the job's workspace volume is
|
||||||
# `--volumes-from`, same trick as ci/build-feed.sh) that downloads the
|
# shared into it with `--volumes-from $(hostname)`; a bare `-v $PWD:...`
|
||||||
# ImmortalWrt 25.12 apk-SDK tarball and runs ci/sdk-build-apk.sh in it:
|
# points at a host path that does not exist under act_runner's DinD) that
|
||||||
# compile the 4 packages as .apk, then `apk mkndx --sign` the per-arch
|
# downloads the ImmortalWrt 25.12 apk-SDK tarball and runs
|
||||||
# `packages.adb` index. Unlike the usign lane (index signed on the runner),
|
# ci/sdk-build-apk.sh in it: compile the 4 packages as .apk, then
|
||||||
# apk indexing NEEDS the SDK's host `apk` tool, so index+sign happen inside
|
# `apk mkndx --sign` the per-arch `packages.adb` index. Indexing NEEDS the
|
||||||
# the container.
|
# 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
|
# 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,
|
# 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.
|
# mediatek-filogic 25.12 tag — hence the official SDK tarball.
|
||||||
#
|
#
|
||||||
# Env:
|
# Env:
|
||||||
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret — the apk analog
|
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret). If set,
|
||||||
# of KEY_BUILD). If set, packages.adb carries an embedded signature
|
# packages.adb carries an embedded signature verifiable by
|
||||||
# verifiable by dist/shater-apk.pem (routers: /etc/apk/keys/).
|
# dist/shater-apk.pem (routers: /etc/apk/keys/).
|
||||||
# If unset, an UNSIGNED index is produced (warning; not shippable —
|
# If unset, an UNSIGNED index is produced (warning; not shippable —
|
||||||
# apk signatures are effectively mandatory).
|
# apk signatures are effectively mandatory).
|
||||||
set -eu
|
set -eu
|
||||||
@@ -55,6 +55,15 @@ fi
|
|||||||
|
|
||||||
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
|
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 --------------------------------------------------
|
# --- 0.5) runner-side caches --------------------------------------------------
|
||||||
# All under $REPO/.cache so (a) actions/cache in the workflow can persist them
|
# 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.
|
# 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.
|
# SDK; PKG_HASH still verifies every file, so stale = re-downloaded.
|
||||||
# apt/ debian:bookworm .deb archives for the host-deps install.
|
# apt/ debian:bookworm .deb archives for the host-deps install.
|
||||||
# The nested container runs the build as an unprivileged user -> must be writable
|
# 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"
|
CACHE="$REPO/.cache"
|
||||||
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
|
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
|
||||||
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
|
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
|
||||||
@@ -86,14 +95,16 @@ sh "$REPO/ci/fetch-sdk.sh" "$SDK_URL" "$SDK_TAR"
|
|||||||
|
|
||||||
# --- 1) SDK build + index + sign inside a debian container -------------------
|
# --- 1) SDK build + index + sign inside a debian container -------------------
|
||||||
# `--volumes-from $(hostname)` shares THIS job container's workspace volume into
|
# `--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 nested container: a bare `-v $PWD:...` points at a host path that does not
|
||||||
# the act_runner DinD setup).
|
# exist under the act_runner DinD setup.
|
||||||
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
|
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
|
||||||
docker pull -q debian:bookworm
|
docker pull -q debian:bookworm
|
||||||
docker run --rm --volumes-from "$(hostname)" \
|
docker run --rm --volumes-from "$(hostname)" \
|
||||||
-e ARCH="$ARCH" -e REPO="$REPO" -e OUT="$OUT" -e SDK_URL="$SDK_URL" \
|
-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 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 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"
|
debian:bookworm bash "$REPO/ci/sdk-build-apk.sh"
|
||||||
|
|
||||||
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
|
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
|
||||||
|
|||||||
@@ -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).
|
# 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
|
# apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys
|
||||||
# (prime256v1 PEM), NOT usign — the existing usign identity
|
# (prime256v1 PEM). This is the ONLY feed identity shater has since the opkg
|
||||||
# (dist/shater-feed.pub, fp 5ac4b177689cb8e0) keeps signing the opkg/24.10 feed
|
# lane was removed (D22) — the old usign key is history, not a second lane.
|
||||||
# and is NOT touched by this script. This generates a SEPARATE, second identity:
|
|
||||||
#
|
#
|
||||||
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
|
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
|
||||||
# Paste its full PEM contents into the Gitea repo secret
|
# Paste its full PEM contents into the Gitea repo secret
|
||||||
# KEY_APK (the apk analog of the usign secret KEY_BUILD).
|
# KEY_APK. Then delete the local file (or keep it in a
|
||||||
# Then delete the local file (or keep it in a password
|
# password manager as the offline backup — losing it
|
||||||
# manager as the offline backup — losing it means every
|
# means every deployed router must re-trust a new key).
|
||||||
# deployed router must re-trust a new key).
|
# dist/shater-apk.pem PUBLIC key. Commit it:
|
||||||
# dist/shater-apk.pem PUBLIC key. Commit it next to shater-feed.pub:
|
|
||||||
# git add -f dist/shater-apk.pem
|
# git add -f dist/shater-apk.pem
|
||||||
# (-f because /dist/ is gitignored). Routers install it
|
# (-f because /dist/ is gitignored). Routers install it
|
||||||
# as /etc/apk/keys/shater-apk.pem.
|
# as /etc/apk/keys/shater-apk.pem.
|
||||||
#
|
#
|
||||||
# Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of
|
# 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
|
# every router that already installed shater-apk.pem (see D22).
|
||||||
# usign key).
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
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
|
|
||||||
+247
-5
@@ -10,7 +10,6 @@
|
|||||||
# the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its
|
# 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
|
# distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is
|
||||||
# ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves.
|
# 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
|
# The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs
|
||||||
# as an unprivileged `build` user created here.
|
# 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] arch=$ARCH repo=$REPO out=$OUT"
|
||||||
echo "[apk-sdk] sdk=$SDK_URL"
|
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. byedpi keeps upstream ByeDPI's own version (see its Makefile).
|
||||||
|
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
|
||||||
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
||||||
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
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
|
# The prebuilt shaterd artifact must already be staged for this arch
|
||||||
# contract as the opkg lane — scripts/build-shaterd.sh runs first).
|
# (artifact-order contract — scripts/build-shaterd.sh runs first).
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64) sfx=amd64 ;;
|
x86_64) sfx=amd64 ;;
|
||||||
aarch64_cortex-a53) sfx=arm64 ;;
|
aarch64_cortex-a53) sfx=arm64 ;;
|
||||||
@@ -108,7 +112,7 @@ export HOME=/home/build
|
|||||||
cd "$SDKDIR"
|
cd "$SDKDIR"
|
||||||
|
|
||||||
# Register this repo's openwrt/ as a src-link feed named `shater` (absolute
|
# 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
|
cp -f feeds.conf.default feeds.conf
|
||||||
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
|
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
|
||||||
|
|
||||||
@@ -133,6 +137,101 @@ fi
|
|||||||
echo "[apk-sdk] feeds install (prefer shater feed)"
|
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 byedpi luci-app-shater
|
||||||
|
|
||||||
|
# --- 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 byedpi luci-app-shater; do
|
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||||
echo "CONFIG_PACKAGE_$p=m" >> .config
|
echo "CONFIG_PACKAGE_$p=m" >> .config
|
||||||
done
|
done
|
||||||
@@ -151,11 +250,135 @@ fi
|
|||||||
echo "[apk-sdk] defconfig"
|
echo "[apk-sdk] defconfig"
|
||||||
make defconfig >/dev/null
|
make defconfig >/dev/null
|
||||||
|
|
||||||
|
# --- 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|byedpi|luci-app-shater)=' .config \
|
||||||
|
| sed 's/^/[apk-sdk] /' || true
|
||||||
|
|
||||||
|
# Each of our 4 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 byedpi 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 byedpi luci-app-shater; do
|
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||||
echo "[apk-sdk] === build $p ==="
|
echo "[apk-sdk] === build $p ==="
|
||||||
make "package/$p/compile" V=s -j"$(nproc)"
|
make "package/$p/compile" V=s -j"$(nproc)"
|
||||||
done
|
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.
|
# 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=$(find bin -type f -name '*.apk' | wc -l)
|
||||||
[ "$anyapk" -gt 0 ] || {
|
[ "$anyapk" -gt 0 ] || {
|
||||||
@@ -173,6 +396,25 @@ 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 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; }
|
||||||
echo "[apk-sdk] collected $found of our .apk"
|
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`. byedpi is excluded on purpose
|
||||||
|
# (it carries upstream ByeDPI's own version). 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 ---------
|
# --- index + sign: exactly how the OpenWrt 25.12 buildsystem does it ---------
|
||||||
# apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \
|
# apk mkndx --root T --keys-dir T [--sign key] --allow-untrusted \
|
||||||
# --output packages.adb *.apk
|
# --output packages.adb *.apk
|
||||||
@@ -204,7 +446,7 @@ INNER
|
|||||||
chmod 0644 /home/build/inner.sh
|
chmod 0644 /home/build/inner.sh
|
||||||
|
|
||||||
su build -s /bin/bash -c \
|
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
|
chmod -R a+rwX "$OUT" 2>/dev/null || true
|
||||||
echo "[apk-sdk] OK arch=$ARCH — apk feed dir:"
|
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
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# `byedpi` is deliberately NOT versioned from our tag — see openwrt/byedpi/Makefile.
|
||||||
|
#
|
||||||
|
# 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
|
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.
|
// the wire.
|
||||||
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
|
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
|
||||||
t.Helper()
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
|
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
|
package urltest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"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.
|
// HealthVerdict classifies a stored history entry at read time.
|
||||||
type HealthVerdict int
|
type HealthVerdict int
|
||||||
|
|
||||||
@@ -54,6 +171,7 @@ func (s *HistoryStorage) MarkFailed(tag string) {
|
|||||||
updated.Delay = previous.Delay
|
updated.Delay = previous.Delay
|
||||||
}
|
}
|
||||||
s.delayHistory[tag] = updated
|
s.delayHistory[tag] = updated
|
||||||
|
s.pruneLocked()
|
||||||
s.notifyUpdated()
|
s.notifyUpdated()
|
||||||
s.access.Unlock()
|
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
|
access sync.RWMutex
|
||||||
delayHistory map[string]*adapter.URLTestHistory
|
delayHistory map[string]*adapter.URLTestHistory
|
||||||
updateHooks []*observable.Subscriber[struct{}]
|
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 {
|
func NewHistoryStorage() *HistoryStorage {
|
||||||
@@ -71,6 +75,11 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes
|
|||||||
}
|
}
|
||||||
// lx:end health-board
|
// lx:end health-board
|
||||||
s.delayHistory[tag] = history
|
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.notifyUpdated()
|
||||||
s.access.Unlock()
|
s.access.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package daemon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
// lx:begin sec-oomgate
|
||||||
|
"sync"
|
||||||
|
// lx:end sec-oomgate
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -19,6 +22,10 @@ type ManagedService struct {
|
|||||||
handler ManagedHandler
|
handler ManagedHandler
|
||||||
debug bool
|
debug bool
|
||||||
oomReporter oomkiller.OOMReporter
|
oomReporter oomkiller.OOMReporter
|
||||||
|
// lx:begin sec-oomgate
|
||||||
|
oomReportMu sync.Mutex
|
||||||
|
oomReportLast time.Time
|
||||||
|
// lx:end sec-oomgate
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedServiceOptions struct {
|
type ManagedServiceOptions struct {
|
||||||
@@ -90,6 +97,18 @@ func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty)
|
|||||||
if s.oomReporter == nil {
|
if s.oomReporter == nil {
|
||||||
return nil, status.Error(codes.Unavailable, "OOM reporter not available")
|
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())
|
return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -2,6 +2,9 @@ package daemon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
// lx:begin sec-consttime
|
||||||
|
"crypto/subtle"
|
||||||
|
// lx:end sec-consttime
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -59,8 +62,11 @@ func authenticate(ctx context.Context, secret string) error {
|
|||||||
return status.Error(codes.Unauthenticated, "missing authorization")
|
return status.Error(codes.Unauthenticated, "missing authorization")
|
||||||
}
|
}
|
||||||
token, isBearer := strings.CutPrefix(values[0], "Bearer ")
|
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")
|
return status.Error(codes.Unauthenticated, "invalid authorization")
|
||||||
}
|
}
|
||||||
|
// lx:end sec-consttime
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ func (s *StartedService) StartTailscaleSSHSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
go ssh.DiscardRequests(reqs)
|
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
|
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()
|
defer channel.Close()
|
||||||
fd, err := s.handler.ConnectSSHAgent()
|
fd, err := s.handler.ConnectSSHAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -326,15 +329,33 @@ func (s *StartedService) forwardSSHAgentChannel(channel ssh.Channel) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
io.Copy(conn, channel)
|
io.Copy(conn, channel)
|
||||||
|
cancel()
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
io.Copy(channel, conn)
|
io.Copy(channel, conn)
|
||||||
|
cancel()
|
||||||
}()
|
}()
|
||||||
wg.Wait()
|
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
|
|
||||||
@@ -126,6 +126,12 @@ func (t *HTTP3Transport) newTransport() *http3.Transport {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, dialErr
|
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
|
return quicConn, nil
|
||||||
},
|
},
|
||||||
TLSClientConfig: t.tlsConfig,
|
TLSClientConfig: t.tlsConfig,
|
||||||
|
|||||||
@@ -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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sagernet/quic-go"
|
"github.com/sagernet/quic-go"
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
@@ -117,6 +118,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
|
|||||||
rawConn.Close()
|
rawConn.Close()
|
||||||
return nil, E.Cause(err, "establish QUIC connection")
|
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
|
return earlyConnection, nil
|
||||||
})
|
})
|
||||||
if err != 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")
|
return nil, E.Cause(err, "open stream")
|
||||||
}
|
}
|
||||||
defer stream.CancelRead(0)
|
defer stream.CancelRead(0)
|
||||||
|
stopWatch := context.AfterFunc(ctx, func() {
|
||||||
|
stream.CancelRead(0)
|
||||||
|
_ = stream.SetWriteDeadline(time.Now())
|
||||||
|
})
|
||||||
|
defer stopWatch()
|
||||||
err = transport.WriteMessage(stream, 0, message)
|
err = transport.WriteMessage(stream, 0, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stream.Close()
|
stream.Close()
|
||||||
|
|||||||
+10
-11
@@ -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 +
|
- **`luci-app-shater`** — a custom "instrument panel" LuCI app (client-side JS +
|
||||||
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
|
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
|
||||||
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
|
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
|
- **CI + a signed package feed** on Gitea: builds per-arch, signs the feed index,
|
||||||
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed
|
publishes a rolling `latest` Gitea release the router consumes as a feed.
|
||||||
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`,
|
(v0.1 shipped `.ipk` signed with a usign key — that lane is retired, D22.)
|
||||||
secret in the Gitea repo secret `KEY_BUILD`.
|
|
||||||
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
|
- 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
|
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
|
||||||
`run.json` are xray-shaped.
|
`run.json` are xray-shaped.
|
||||||
@@ -91,7 +90,7 @@ We are rebasing onto a new engine and a new UI architecture. Full rationale in
|
|||||||
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
|
- **`shater` branch `v0.1`** = the standalone xray-based version (frozen, ported
|
||||||
from).
|
from).
|
||||||
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
|
- 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`).
|
are reading now (LICENSE, README, `docs-shater/`, the feed signing key).
|
||||||
|
|
||||||
## What to port from v0.1 (don't rewrite these ideas)
|
## What to port from v0.1 (don't rewrite these ideas)
|
||||||
|
|
||||||
@@ -105,8 +104,8 @@ overlay, don't redo:
|
|||||||
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
|
- **Subscription fetch** (HAPP emulation, fingerprint reconcile, per-sub cache)
|
||||||
and the flexible **ruleset/list** model — though sing-box has its own share-link
|
and the flexible **ruleset/list** model — though sing-box has its own share-link
|
||||||
parser and config schema we now target.
|
parser and config schema we now target.
|
||||||
- **CI feed build + usign signing + Gitea release** (adapt to the single forked
|
- **CI feed build + index signing + Gitea release** (adapted to the single forked
|
||||||
binary; keep key `5ac4b177689cb8e0`).
|
binary; the format is apk, signed with the EC key — D22).
|
||||||
- The LuCI **design system** (the "instrument panel" identity) — reused for the
|
- The LuCI **design system** (the "instrument panel" identity) — reused for the
|
||||||
mini-dashboard and as the panel's visual language.
|
mini-dashboard and as the panel's visual language.
|
||||||
|
|
||||||
@@ -122,9 +121,9 @@ filter/stats engine wired into sing-box's DNS.
|
|||||||
`https://github.com/SagerNet/sing-box`).
|
`https://github.com/SagerNet/sing-box`).
|
||||||
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
|
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
|
||||||
`main`; new CI is added when the v0.2 build exists.
|
`main`; new CI is added when the v0.2 build exists.
|
||||||
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret
|
- **Feed signing:** EC (prime256v1) key for the apk index; secret in the repo
|
||||||
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep
|
secret `KEY_APK`; public key `dist/shater-apk.pem`, installed on routers as
|
||||||
verifying).
|
`/etc/apk/keys/shater-apk.pem`. Never regenerate it (D22).
|
||||||
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
|
- **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`
|
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),
|
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
|
||||||
|
|||||||
+349
-1
@@ -64,11 +64,17 @@ sing-box is GPL-3.0; linking it makes the combined work GPL-3.0. Our own files m
|
|||||||
stay GPL-2.0-or-later (which permits the upgrade), but the project LICENSE is
|
stay GPL-2.0-or-later (which permits the upgrade), but the project LICENSE is
|
||||||
GPL-3.0 for clarity.
|
GPL-3.0 for clarity.
|
||||||
|
|
||||||
## D7 — Keep the v0.1 feed signing identity
|
## D7 — Keep the v0.1 feed signing identity *(SUPERSEDED by D22)*
|
||||||
The usign feed key `5ac4b177689cb8e0` (public key in `dist/shater-feed.pub`,
|
The usign feed key `5ac4b177689cb8e0` (public key in `dist/shater-feed.pub`,
|
||||||
secret in Gitea secret `KEY_BUILD`) carries over, so routers that already trust it
|
secret in Gitea secret `KEY_BUILD`) carries over, so routers that already trust it
|
||||||
keep verifying v0.2 packages. Do not regenerate it without a documented rotation.
|
keep verifying v0.2 packages. Do not regenerate it without a documented rotation.
|
||||||
|
|
||||||
|
> **Superseded 2026-07-25 (D22).** The opkg feed this identity signed no longer
|
||||||
|
> exists, so there is nothing left for the key to verify. It was never rotated or
|
||||||
|
> compromised — it is simply unused. `dist/shater-feed.pub` was deleted from the
|
||||||
|
> tree; the reasoning, and how to resurrect the identity if it is ever needed
|
||||||
|
> again, is in D22.
|
||||||
|
|
||||||
## D8 — Preserve, don't destroy: v0.1 lives on its branch
|
## D8 — Preserve, don't destroy: v0.1 lives on its branch
|
||||||
The reset moved the full working xray-based project to the `v0.1` branch and
|
The reset moved the full working xray-based project to the `v0.1` branch and
|
||||||
cleaned `main`. Nothing is lost; reusable logic (reliability layer, nft/routing,
|
cleaned `main`. Nothing is lost; reusable logic (reliability layer, nft/routing,
|
||||||
@@ -95,6 +101,10 @@ runtime, forcing an ELF with `PT_INTERP=/lib64/ld-linux-x86-64.so.2` + `PT_DYNAM
|
|||||||
plane is tproxy/redirect (netplane); generate never emits a tun inbound, so
|
plane is tproxy/redirect (netplane); generate never emits a tun inbound, so
|
||||||
the userspace gvisor netstack (~3.6 MB) is unreachable. If a tun inbound ever
|
the userspace gvisor netstack (~3.6 MB) is unreachable. If a tun inbound ever
|
||||||
appears it falls back to the system stack — re-add the tag then.
|
appears it falls back to the system stack — re-add the tag then.
|
||||||
|
**REVERTED 2026-07-25 — that reasoning was wrong and shipped a dead feature.**
|
||||||
|
gVisor is not only the tun stack: it is the netstack of the **WireGuard
|
||||||
|
endpoint**, which we do emit and do declare [MVP]. See D23; the tag is back and
|
||||||
|
is now held there by a test.
|
||||||
- 2026-07-23: `with_clash_api` also dropped. The admin panel is shater's own
|
- 2026-07-23: `with_clash_api` also dropped. The admin panel is shater's own
|
||||||
web server and generate never emits a `clash_api` service; the desktop/CLI
|
web server and generate never emits a `clash_api` service; the desktop/CLI
|
||||||
`LX_TAGS` keeps the tag for external dashboards.
|
`LX_TAGS` keeps the tag for external dashboards.
|
||||||
@@ -424,3 +434,341 @@ on the next render.
|
|||||||
Consequence: all delay numbers are comparable (least ping ranks apples against
|
Consequence: all delay numbers are comparable (least ping ranks apples against
|
||||||
apples), and group settings lose two footgun fields while Settings keeps the
|
apples), and group settings lose two footgun fields while Settings keeps the
|
||||||
two that actually govern every check.
|
two that actually govern every check.
|
||||||
|
|
||||||
|
## D21 — A rule's destination is a rule-set, and nothing else
|
||||||
|
Decided 2026-07-25 (product owner). `config rule` carried THREE ways to say
|
||||||
|
where traffic is going: `dst_domain` (an inline domain list), `dst_ip` (an inline
|
||||||
|
CIDR list) and `dst_ruleset` (a reference to a `config ruleset`). Three
|
||||||
|
mechanisms meant three sets of semantics to learn and keep straight, and the
|
||||||
|
inline ones were the worse half of the trade: they are re-parsed per rule instead
|
||||||
|
of being compiled once into a `.srs`, they cannot be shared between rules, and
|
||||||
|
their matcher vocabulary had drifted from the rule-set one in a way nobody could
|
||||||
|
see (below).
|
||||||
|
|
||||||
|
**Decision: `dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is
|
||||||
|
the only destination matcher.** `Src`, `dst_port` and `proto` are untouched —
|
||||||
|
they are not lists of destinations and have no rule-set form.
|
||||||
|
|
||||||
|
- **Rejected: keep the inline lists as a shorthand.** "One obvious way" is the
|
||||||
|
whole point; a shorthand that quietly means something different from the long
|
||||||
|
form (see the bare-entry trap) is worse than no shorthand.
|
||||||
|
- **Rejected: promote inline lists to rule-sets lazily at generate time.** The
|
||||||
|
config on disk would then not say what the router does, and the panel would
|
||||||
|
have to render a list the user cannot find or edit.
|
||||||
|
|
||||||
|
### The bare-entry trap, and how the migration handles it
|
||||||
|
The two contexts already disagreed about exactly one spelling, silently:
|
||||||
|
|
||||||
|
| entry | in a rule (`dst_domain`) | in a rule-set (`entry`) | migrated to |
|
||||||
|
|--------------------|--------------------------|-------------------------|--------------|
|
||||||
|
| `example.com` | **exact host** | **host + subdomains** | `full:example.com` |
|
||||||
|
| `full:example.com` | exact host | exact host | unchanged |
|
||||||
|
| `suffix:example.com` / `.example.com` | host + subdomains | host + subdomains | unchanged |
|
||||||
|
| `keyword:ads` | substring | substring | unchanged |
|
||||||
|
| `regexp:^ads\.` | pattern | pattern *(added here)* | unchanged |
|
||||||
|
| `geosite:x` / `geoip:x` | inert (engine field removed) | inert (unknown prefix) | unchanged |
|
||||||
|
|
||||||
|
`shaterd migrate` (schema v1→v2, `shater/model/migrate.go`) creates one inline
|
||||||
|
`config ruleset` per rule that still carries a legacy list — `rule-<rule name>`
|
||||||
|
for domains, `rule-<rule name>-ip` for addresses — moves the entries across with
|
||||||
|
the conversion above, appends the new name to `dst_ruleset`, and deletes the old
|
||||||
|
option. It is idempotent, it resumes an interrupted run, and it never overwrites
|
||||||
|
a hand-written rule-set that already owns the generated name (it picks
|
||||||
|
`rule-<name>-2`). `regexp:` support was added to inline rule-sets in the same
|
||||||
|
change precisely so the move can be lossless.
|
||||||
|
|
||||||
|
`geosite:`/`geoip:` entries are copied VERBATIM rather than promoted to a
|
||||||
|
`source=geosite` rule-set: those matchers have been inert since the engine
|
||||||
|
dropped the route-rule geosite/geoip fields, and turning a dead matcher live
|
||||||
|
during an upgrade would be a behaviour change, not a migration. The text is kept
|
||||||
|
so the operator can see it and convert it deliberately.
|
||||||
|
|
||||||
|
**One deliberate semantic change, called out:** a rule that used BOTH lists
|
||||||
|
matched them with AND (an engine route rule ANDs its matcher fields), which is
|
||||||
|
almost never what "these sites and these networks" meant. The two generated
|
||||||
|
rule-sets are ORed, because `rule_set: [a, b]` matches when either matches. Such
|
||||||
|
a rule matches more after the migration than before; it affects only configs that
|
||||||
|
used both fields at once.
|
||||||
|
|
||||||
|
That AND→OR change is about the ENGINE's TCP/UDP path, and it deliberately does
|
||||||
|
**not** extend to the untunnelable-protocol plane (`shater/apply/untunnelable.go`,
|
||||||
|
the ping / IPTV / VPN-passthrough policy in nftables). There, a v1
|
||||||
|
`dst_domain + dst_ip` rule could never claim a packet that carries no domain, so
|
||||||
|
the plan skipped it; reading the migrated form as OR would have made the address
|
||||||
|
half suddenly decisive, and with `target=direct` that means an upgrade quietly
|
||||||
|
sending previously-tunnelled ICMP out with the client's real source address. A
|
||||||
|
rule whose rule-sets are known to match by NAME is therefore still skipped by that
|
||||||
|
plan, and the skip is reported ("a routing rule matches by name … as well as by
|
||||||
|
address"). Split the rule in two if you want the addresses decided there.
|
||||||
|
|
||||||
|
### The vocabulary is about ENTRIES YOU TYPE, not about every list body
|
||||||
|
The table above is the vocabulary of an **inline** rule-set's `entry` values (and
|
||||||
|
of the DNS-filter/device lists, which share the classifier). The other two rule-set
|
||||||
|
sources are not other spellings of it:
|
||||||
|
|
||||||
|
| source | what it is | vocabulary |
|
||||||
|
|------------------------------|----------------------------------|------------|
|
||||||
|
| `inline` | entries you type | the table above |
|
||||||
|
| `url` → `.srs` / `.json` | a compiled rule-set, engine-owned | the engine's, not ours |
|
||||||
|
| `url` → anything else | a hosts / one-domain-per-line / AdBlock TEXT FILE | **none** — every line is a domain plus its subdomains |
|
||||||
|
| `file` | a local `.srs` / `.json` | the engine's, not ours |
|
||||||
|
|
||||||
|
**Rejected: run text lists through the entry classifier too.** A published
|
||||||
|
AdGuard/OISD list is full of colon-bearing tokens that are ordinary filter syntax
|
||||||
|
(`##…:has(…)`, `$domain=`, absolute URLs); classifying them would either mis-import
|
||||||
|
them or bury the operator under hundreds of "unrecognised prefix" warnings per
|
||||||
|
list. The formats also disagree structurally — a hosts line carries several names,
|
||||||
|
so the text parser works per token, while an entry is a whole line. And `regexp:`
|
||||||
|
arriving from a third-party URL is a pattern compiled into the router's matcher and
|
||||||
|
evaluated per query, which is a very different proposition from one the operator
|
||||||
|
typed.
|
||||||
|
|
||||||
|
So the difference stands and is paid for in diagnostics instead: a text list
|
||||||
|
containing `full:` / `suffix:` / `keyword:` / `regexp:` is reported per list, on
|
||||||
|
every generate, naming the entries and pointing at `source=inline` where they work
|
||||||
|
(`warnListEntryVocabulary`, `shater/generate/ruleset.go`). The check tests only
|
||||||
|
those four markers, never the general `word:` shape, so it fires on a human's
|
||||||
|
mistake and stays quiet on published filter syntax.
|
||||||
|
|
||||||
|
Consequence: one destination mechanism, one vocabulary, one place a list is
|
||||||
|
edited; every list is compiled once and reused. The panel's rule editor drops its
|
||||||
|
Domain(s) and IP/CIDR(s) fields; its destination control is a checkbox list of
|
||||||
|
the rulesets that already exist, and nothing more. Creating and filling a list
|
||||||
|
stays in the Rulesets panel — **rejected: a "create a list from here" shortcut in
|
||||||
|
the rule editor**, because a second place to author a list is a second place for
|
||||||
|
its semantics and its duplicate-name rules to drift, and the whole point of this
|
||||||
|
decision was to stop having two.
|
||||||
|
|
||||||
|
## D22 — One packaging lane: apk. The opkg/`.ipk` lane is deleted, not disabled
|
||||||
|
Decided 2026-07-25 (product owner). CI built and published TWO signed feeds from
|
||||||
|
every run: opkg/usign (`.ipk` + `Packages.gz`, OpenWrt 24.10) and apk/EC (`.apk` +
|
||||||
|
`packages.adb`, OpenWrt/ImmortalWrt 25.12). The opkg half served nobody. Checked
|
||||||
|
on the actual hardware, not inferred:
|
||||||
|
|
||||||
|
| Device | Firmware | pkg arch | package manager |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mini_router` (BPi-R3 Mini) | ImmortalWrt 25.12.1 | `aarch64_cortex-a53` | apk-tools 3.0.5 |
|
||||||
|
| `main_router` (BPi-R4) | OpenWrt 25.12.0 | `aarch64_cortex-a53` | apk-tools 3.0.5 — **no `opkg` binary on the system at all** |
|
||||||
|
|
||||||
|
**Decision: delete the opkg lane outright.** Removed: the `build` + `release`
|
||||||
|
jobs from `.gitea/workflows/release.yml`; `ci/build-feed.sh`, `ci/sdk-build.sh`,
|
||||||
|
`ci/make-index.sh`, `ci/install-usign.sh`; and the trust anchor
|
||||||
|
`dist/shater-feed.pub`. The Gitea secret `KEY_BUILD` is now referenced by
|
||||||
|
nothing and can be deleted from the repo settings. `ci/version.sh`,
|
||||||
|
`ci/gitea-release.sh` and `ci/fetch-sdk.sh` are shared or apk-only and stay.
|
||||||
|
|
||||||
|
- **Rejected: keep the lane but stop triggering it** (comment it out / gate it on
|
||||||
|
a dispatch input). Dead code in CI is worse than no code: it keeps a second SDK
|
||||||
|
matrix, a second signing key and a second feed layout alive in everyone's head
|
||||||
|
and in every future edit, and it silently rots because nothing runs it. The
|
||||||
|
24.10 SDK images it pins are themselves a frozen dependency.
|
||||||
|
- **Rejected: keep `dist/shater-feed.pub` as a historical artifact.** A committed
|
||||||
|
trust anchor is an instruction — it invites someone to follow the old install
|
||||||
|
path for a feed that is no longer produced. Nothing is lost by removing it:
|
||||||
|
git history still holds the file, the SECRET half is untouched in `KEY_BUILD`,
|
||||||
|
and a usign secret key blob contains its own public half, so the identity can
|
||||||
|
be reconstructed if a 24.10 device ever has to be served again. Deleting the
|
||||||
|
file is reversible; a stale trust anchor pointing at an unmaintained feed is
|
||||||
|
the thing that quietly misleads.
|
||||||
|
- **Not done: revoking or rotating the usign key.** There is no incident. It is
|
||||||
|
retired, not burned (D7).
|
||||||
|
|
||||||
|
Consequence: one SDK, one key, one feed layout, one set of install instructions.
|
||||||
|
It also makes the rolling release `apk-latest-<arch>` the *only* install path
|
||||||
|
that does not require hand-editing a file per release — which is why the same
|
||||||
|
change fixed it: publishing was an either/or (`apk-latest-<arch>` on dispatch,
|
||||||
|
ELSE `apk-vX.Y.Z-<arch>` on a tag), so once releases moved to tag pushes the
|
||||||
|
rolling pointer stopped being written and froze at `0.2.0` while v0.2.9/v0.2.10
|
||||||
|
shipped — routers on the rolling URL got a successful, silent `apk update` with
|
||||||
|
nothing new. `release-apk` now writes the rolling pointer on every run and
|
||||||
|
asserts, by reading the published release back over the Gitea API, that it holds
|
||||||
|
our three tag-versioned packages at exactly the version just built and no asset
|
||||||
|
at any other version.
|
||||||
|
|
||||||
|
|
||||||
|
## D23 — The router tag set is a checked contract, not a string literal
|
||||||
|
`with_gvisor` was trimmed from the router set on 2026-07-23 (D9) as "unreachable
|
||||||
|
code: we never emit a tun inbound". True about tun — and irrelevant, because
|
||||||
|
gVisor is also the netstack of the **WireGuard endpoint**, which shater emits and
|
||||||
|
FEATURES.md declares [MVP] (AmneziaWG is called *"a driving requirement"*). Every
|
||||||
|
binary shipped between then and 2026-07-25 answered a configured WireGuard node
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
create instance: initialize endpoint[0]: create WireGuard device:
|
||||||
|
gVisor is not included in this build, rebuild with -tags with_gvisor
|
||||||
|
```
|
||||||
|
|
||||||
|
`transport/wireguard/device_stack_stub.go` (`//go:build !with_gvisor`) returns
|
||||||
|
`tun.ErrGVisorNotIncluded` from **both** device constructors, so
|
||||||
|
`system_interface: true` is not an escape hatch either: WireGuard was 100% dead
|
||||||
|
in the shipped artifact while the panel offered it, the parser accepted `wg://`,
|
||||||
|
`awg://` and wg-quick `.conf` imports, and the owner had 7 WireGuard sections in
|
||||||
|
UCI on a production router.
|
||||||
|
|
||||||
|
- **Decision:** `with_gvisor` is part of the router tag set and stays there for
|
||||||
|
as long as we ship WireGuard. It costs **~2.8 MB raw / ~0.65 MB UPX per arch**
|
||||||
|
(measured 2026-07-25, both arches; `/overlay` on the production router is
|
||||||
|
6.9 GB with 205 MB used). A tag whose absence turns a declared feature into a
|
||||||
|
runtime error is not "dead weight" — it is the feature.
|
||||||
|
|
||||||
|
### Why the bug was invisible, and what now makes it visible
|
||||||
|
The defect was not a typo in a tag list. It was that **nothing connected the tag
|
||||||
|
list to the feature list**, and the shipped tag combination was the one build
|
||||||
|
configuration nothing exercised: the whole test suite compiles with the FULL
|
||||||
|
upstream set (`with_gvisor` included), so `TestAmneziaWGEndpoint` passed happily
|
||||||
|
while the artifact it was supposed to vouch for could not create a WireGuard
|
||||||
|
device. Tests proved the code was right; they never proved the *build* was.
|
||||||
|
|
||||||
|
Three pieces now hold it together:
|
||||||
|
|
||||||
|
1. **One definition of the set** — `scripts/router-tags.sh` (`SHATER_ROUTER_TAGS`
|
||||||
|
+ `SHATER_ROUTER_LDFLAGS`), sourced by `scripts/build-shaterd.sh` and by the
|
||||||
|
checker. The tag list used to live as a literal inside the build script, i.e.
|
||||||
|
in a file no test reads. A second copy is a second truth.
|
||||||
|
2. **A declared-feature table** — `shater/buildtags`: every tag-gated capability
|
||||||
|
we promise, with the exact tags it needs *to run* and why (the code anchor).
|
||||||
|
`TestRouterTagSetCoversDeclaredFeatures` parses the shell file and fails if a
|
||||||
|
declared feature lost a tag. It needs no build tags, no Linux, no network and
|
||||||
|
no privileges, so it runs in every plain `go test ./...` — including on the
|
||||||
|
Windows dev host, where nothing else can see the shipped configuration.
|
||||||
|
3. **A construction test under the shipped tags** —
|
||||||
|
`shater/generate.TestShippedTagSetConstructsDeclaredProtocols` drives one node
|
||||||
|
of every declared protocol (ss/vmess/trojan/vless ws-grpc-httpupgrade-quic-
|
||||||
|
xhttp/REALITY/uTLS-fp/hysteria2/tuic/**wg**/**awg**) through `box.New`+`Start`.
|
||||||
|
`scripts/check-router-tags.sh` runs it **with `SHATER_ROUTER_TAGS`**, and CI
|
||||||
|
runs that script (`.gitea/workflows/release.yml`) *before* the artifact is
|
||||||
|
built. In a router-tag-set run nothing may be skipped: a protocol that is not
|
||||||
|
compiled in fails the run instead of quietly disappearing from it.
|
||||||
|
|
||||||
|
(2) catches a trim the moment it is made and names the feature it kills; (3)
|
||||||
|
catches what a list comparison cannot — a tag that is present but insufficient.
|
||||||
|
Neither is a substitute for the other. A new protocol in `shater/parse` +
|
||||||
|
`shater/generate` means a new row in `buildtags.Features` and a new probe case;
|
||||||
|
`TestEveryTagGatedFeatureIsProbed` fails until both exist.
|
||||||
|
|
||||||
|
- **Rejected: "just add the tag".** The one-line fix restores WireGuard and
|
||||||
|
leaves the mechanism that hid it fully intact — the next size-driven trim is
|
||||||
|
equally invisible. The tag is the smallest part of this decision.
|
||||||
|
- **Rejected: run the WHOLE test suite with the router tag set in CI.** It is the
|
||||||
|
obvious move and it does not work: parts of the suite legitimately depend on
|
||||||
|
upstream-only tags, and the run costs a second full compile of a 25 MB binary's
|
||||||
|
worth of packages on every release. A focused, unprivileged construction test
|
||||||
|
buys the same evidence for ~10 s and, unlike a full run, can be *required* to
|
||||||
|
skip nothing.
|
||||||
|
- **Rejected: assert the tag set against upstream's `DEFAULT_BUILD_TAGS`.** That
|
||||||
|
makes any trim a failure, which turns the check into noise and re-litigates D9
|
||||||
|
on every upstream rebase. The contract is with our own feature list, not with
|
||||||
|
upstream's.
|
||||||
|
- **Not done: dropping `with_lx_command`.** It is inert for `shaterd` — nothing
|
||||||
|
under `shater/` imports `sing-box/daemon` or `experimental/libbox`, and
|
||||||
|
`go list -deps ./shater/cmd/shaterd` links neither, so it costs zero bytes. It
|
||||||
|
stays only so the router set remains a subset of the lx desktop set. Noted
|
||||||
|
because "a tag that buys nothing" is the mirror image of this bug and should be
|
||||||
|
removed deliberately, not silently.
|
||||||
|
|
||||||
|
## D24 — DNS interception is the DEFAULT (`dns_intercept=1`), not an opt-in
|
||||||
|
Decided 2026-07-26. `Globals.DNSIntercept` shipped as opt-in (`default false`, and
|
||||||
|
absent from both `DefaultGlobals` and the shipped `/etc/config/shater`). The result
|
||||||
|
was an **inverted** posture, which is the reason this is a decision and not a
|
||||||
|
preference:
|
||||||
|
|
||||||
|
- a client with **standard** settings — DNS = the router's address, exactly what
|
||||||
|
DHCP hands out — sent its queries to the router. The nft `:53` divert was behind
|
||||||
|
the flag (`netplane/nft.go`), and the rule right after it is an unconditional
|
||||||
|
`fib daddr type local accept`, so the query was delivered locally to dnsmasq and
|
||||||
|
forwarded to the ISP **in the clear**: no blocklists, no per-device DNS rules,
|
||||||
|
no Block-DoH, no resolver detour, nothing;
|
||||||
|
- a client that hard-coded `8.8.8.8` "to bypass the router" was addressing a
|
||||||
|
non-local IP and **was** caught by the ordinary tproxy catch-all.
|
||||||
|
|
||||||
|
The obedient client leaked; the evader did not. Meanwhile `FEATURES.md`, `README.md`
|
||||||
|
and D14 all promised "no DNS leaks" and "dnsmasq never sees LAN queries" — true only
|
||||||
|
for the traffic pattern the default did not cover. `dns_intercept` appeared nowhere
|
||||||
|
in `docs-shater/` at all.
|
||||||
|
|
||||||
|
**Decision: `DNSIntercept` is seeded ON in `model.DefaultGlobals`, and the shipped
|
||||||
|
`/etc/config/shater` carries an explicit `option dns_intercept '1'`.** Nothing about
|
||||||
|
the interception MECHANISM changed — only which side of the switch is the default.
|
||||||
|
|
||||||
|
**`.lan` and the private PTR zones keep working, and that is a pre-existing part of
|
||||||
|
the mechanism, not something bolted on for this flip.** `generate/dns.go` adds a
|
||||||
|
synthetic DNS server (`shater-local-dns`, plain UDP to `127.0.0.1:53`, detour
|
||||||
|
`direct`, so the daemon's own loop-mark keeps it out of the divert) and PREPENDS a
|
||||||
|
`domain_suffix` rule for `lan` + the RFC6303 private reverse zones, ahead of every
|
||||||
|
device/filter rule. Two honest limitations: it hardcodes `lan` (a router whose
|
||||||
|
dnsmasq domain was changed needs a `config dns_rule` for the new suffix), and it
|
||||||
|
only exists when the model has at least one `config resolver` — with none, buildDNS
|
||||||
|
emits no DNS plane at all and the engine falls back to its built-in `local`
|
||||||
|
transport, which reads `/etc/resolv.conf` (127.0.0.1 → dnsmasq), so local names
|
||||||
|
still resolve but nothing is filtered.
|
||||||
|
|
||||||
|
**A dead engine does NOT black out the LAN's DNS.** This was the first thing checked,
|
||||||
|
because "intercept everything" invites the reading "engine down = no DNS anywhere",
|
||||||
|
and that is not what happens:
|
||||||
|
- the fail-closed **holding plane** (D17, `RenderHoldNft`) hooks `forward` ONLY.
|
||||||
|
A query addressed to the router is INPUT-hook traffic, so dnsmasq answers it as
|
||||||
|
it always did — unfiltered and plaintext to the ISP. Deliberate: blocking it
|
||||||
|
would also cut the daemon's own name resolution and with it any chance of
|
||||||
|
self-recovery;
|
||||||
|
- with the FULL plane loaded and the engine's tproxy socket gone, the `tproxy`
|
||||||
|
statement returns `NFT_BREAK`, which aborts its own rule; the packet continues
|
||||||
|
down the chain into the same `fib daddr type local accept` and reaches dnsmasq.
|
||||||
|
|
||||||
|
So the failure mode is a DNS **fail-open** (working, unfiltered) while client
|
||||||
|
TRAFFIC stays fail-closed — and a query aimed at an EXTERNAL resolver is dropped
|
||||||
|
with the rest of the forwarded traffic. Operators must know this: "the tunnel is
|
||||||
|
down" does not mean "DNS is private".
|
||||||
|
|
||||||
|
**Existing installs.** `/etc/config/shater` is a conffile
|
||||||
|
(`openwrt/shater-core/Makefile`), so an upgrade never replaces it:
|
||||||
|
- a config that never mentioned the option (all of them, before this change) now
|
||||||
|
parses over the ON seed and **starts intercepting on the next apply**. That is the
|
||||||
|
intended behaviour change, and the only one this decision makes;
|
||||||
|
- an explicit `option dns_intercept '0'` keeps winning. It survives the
|
||||||
|
`WriteUCI→ReadUCI` round-trip because `render.go` emits booleans ALWAYS —
|
||||||
|
the trap a default-true bool has and a default-false one does not: a value
|
||||||
|
omitted at false would come back as the seed and silently re-enable itself.
|
||||||
|
`shater/model/dnsintercept_test.go` pins both directions, plus the shipped file.
|
||||||
|
|
||||||
|
**Not done: silencing the "no resolvers configured" warning by shipping a resolver.**
|
||||||
|
With interception on and no `config resolver`, generate warns — and it is right to:
|
||||||
|
every client query now lands in an engine that has no resolver plane, so it is
|
||||||
|
answered by the system resolver (dnsmasq → the ISP, in the clear) with filtering and
|
||||||
|
anti-leak inert. Shipping a `type local` resolver would make the warning disappear
|
||||||
|
while changing nothing about where the queries go: the panel would show a configured
|
||||||
|
resolver and the operator would believe DNS was handled. That is the inverted lie
|
||||||
|
this project keeps deleting. The warning stays; what it needs is the accurate
|
||||||
|
wording (it currently claims `.lan` breaks, which the fallback above disproves), not
|
||||||
|
a workaround. Note also that a fresh install ships INERT (`enabled '0'`) and
|
||||||
|
`Reconcile` tears down instead of generating, so the warning cannot appear before the
|
||||||
|
operator has enabled the stack — at which point it describes their live config.
|
||||||
|
|
||||||
|
**OPEN, and it gates shipping this default: the synthetic local server changes how
|
||||||
|
proxy-endpoint DOMAINS are resolved.** Found while landing D24, reproduced on Linux
|
||||||
|
with one resolver and a node addressed by a hostname:
|
||||||
|
|
||||||
|
- `common/dialer/dialer.go` resolves a domain server address through
|
||||||
|
`route.default_domain_resolver`; when that is unset it uses
|
||||||
|
`dnsTransport.Default()` — the engine's built-in `local` transport, i.e. a
|
||||||
|
bootstrap-DIRECT lookup — but **only while fewer than two DNS transports exist**.
|
||||||
|
With two or more and no default, it reports the `missing-domain-resolver`
|
||||||
|
deprecation and leaves the query transport nil, so `dns.Router.Lookup` falls back
|
||||||
|
to `lookupWithRules`: the CLIENT DNS plane.
|
||||||
|
- `dns_intercept` adds `shater-local-dns`, which takes a single-resolver config from
|
||||||
|
one transport to two. So a config whose only resolver is DoH-through-the-tunnel —
|
||||||
|
the recommended anti-leak setup — would start resolving its own node's hostname
|
||||||
|
through that same tunnel: a bootstrap loop where there was none.
|
||||||
|
- Evidence: the same model emits no deprecation notice with `dns_intercept=0` and
|
||||||
|
two `missing-domain-resolver` notices with `dns_intercept=1`;
|
||||||
|
`generate.TestDNSFilterRemoteBlocklistHTTPClient` (Linux-only) fails on exactly
|
||||||
|
that notice and is deliberately left failing rather than relaxed.
|
||||||
|
|
||||||
|
The fix belongs in `generate` (`route.go:160` already sets
|
||||||
|
`route.default_domain_resolver` from `endpointResolver()`, which is opt-in and unset
|
||||||
|
by default): when buildDNS emits the synthetic local server and no endpoint resolver
|
||||||
|
is configured, `default_domain_resolver` must be pointed at a bootstrap-direct
|
||||||
|
server, which restores exactly the pre-D24 behaviour and clears the notice. Until
|
||||||
|
that lands, an operator can get the same result by setting `endpoint_resolver` to a
|
||||||
|
direct resolver. Note the hazard is **not** created by D24 — any config with two
|
||||||
|
resolvers has it today; the default merely makes it universal.
|
||||||
|
|||||||
+20
-6
@@ -13,8 +13,12 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
|||||||
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
|
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
|
||||||
Host/QUIC sniffing.
|
Host/QUIC sniffing.
|
||||||
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
|
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
|
||||||
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
|
destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
|
||||||
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).
|
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
|
||||||
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
|
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
|
||||||
interface/tunnel (e.g. an AmneziaWG tunnel).
|
interface/tunnel (e.g. an AmneziaWG tunnel).
|
||||||
@@ -36,6 +40,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
|
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
|
is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population
|
||||||
mechanism does not exist in v0.2 (see generate/dns.go).
|
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]** Client DoT/DoH blocking (stop devices bypassing the filter).
|
||||||
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
|
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
|
||||||
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
|
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
|
||||||
@@ -89,8 +103,8 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
|||||||
SIM uplink → different egress); backup/restore; i18n (EN + RU).
|
SIM uplink → different egress); backup/restore; i18n (EN + RU).
|
||||||
|
|
||||||
## Ops & distribution
|
## Ops & distribution
|
||||||
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key
|
- **[MVP]** Single signed binary; signed apk feed on Gitea (EC key
|
||||||
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`.
|
`dist/shater-apk.pem`); one-line install; named-package `apk upgrade`.
|
||||||
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
|
- **[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
|
- **[T2]** Multi-router fleet management; REST/gRPC external API; Telegram bot.
|
||||||
external API; Telegram bot.
|
(apk packaging landed and is now the only lane — D22.)
|
||||||
|
|||||||
+173
-84
@@ -1,7 +1,7 @@
|
|||||||
# Shater v0.2 — Build & Install
|
# Shater v0.2 — Build & Install
|
||||||
|
|
||||||
How to build the ship artifact (the SPA-embedded `shaterd` binary) and 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
|
## 1. Build the `shaterd` binary
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ What it does:
|
|||||||
Arg / env:
|
Arg / env:
|
||||||
|
|
||||||
- `VERSION` — stamped into `constant.Version`. Resolution: positional arg →
|
- `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.
|
- `--fast` — skip `npm ci` when `panel/node_modules` already exists.
|
||||||
- `UPX=/path/to/upx` — override the UPX binary (default `upx` on `PATH`). UPX is
|
- `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
|
cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also
|
||||||
@@ -41,22 +43,44 @@ 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 —
|
The `dist/*` and `openwrt/shaterd/files/shaterd-*.upx` outputs are gitignored —
|
||||||
they are release artifacts, not source.
|
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
|
badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
|
||||||
```
|
```
|
||||||
|
|
||||||
We drop `with_purego,with_naive_outbound`: they pull cronet-go, which forces a
|
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.
|
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
|
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.
|
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`;
|
We drop `with_dhcp`: shater resolver types are `udp/tcp/doh/dot/local/fakeip`;
|
||||||
a `dhcp://` DNS transport is never generated or registered.
|
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
|
## 2. Packages
|
||||||
|
|
||||||
Four OpenWrt packages live under `openwrt/`:
|
Four OpenWrt packages live under `openwrt/`:
|
||||||
@@ -84,22 +108,65 @@ 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.
|
feed installed and run `make package/shaterd/compile` (and the others) per target.
|
||||||
See `openwrt-package-build-ci` for SDK/feed mechanics.
|
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).
|
||||||
|
|
||||||
|
`byedpi` is deliberately excluded — `PKG_VERSION:=0.17.3` is *upstream ByeDPI's*
|
||||||
|
version, which is what `PKG_HASH` pins and what tells you which ByeDPI is
|
||||||
|
installed. Stamping our tag on it would also be a downgrade: every comparator
|
||||||
|
reads `0.2.7 < 0.17.3` (component-wise, `2 < 17`). Bump its `PKG_RELEASE` by hand
|
||||||
|
when our packaging of it changes.
|
||||||
|
|
||||||
## 3. Install on a router
|
## 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
|
```sh
|
||||||
opkg install shaterd_0.2.0-1_<arch>.ipk # or: apk add shaterd (25.12+)
|
# <ver> = the release version, e.g. 0.2.7-r1 (§2.1 — it comes from the git tag)
|
||||||
opkg install shater-core_0.2.0-1_all.ipk
|
# --allow-untrusted: our member .apk are unsigned by design — trust lives in the
|
||||||
opkg install luci-app-shater_0.2.0-1_all.ipk
|
# signed packages.adb index (§5), which a loose file install does not consult.
|
||||||
opkg install byedpi_0.17.3-1_<arch>.ipk # optional: ByeDPI egress
|
apk add --allow-untrusted ./shaterd-<ver>.apk
|
||||||
|
apk add --allow-untrusted ./shater-core-<ver>.apk
|
||||||
|
apk add --allow-untrusted ./luci-app-shater-<ver>.apk
|
||||||
|
apk add --allow-untrusted ./byedpi-0.17.3-r1.apk # optional: ByeDPI egress
|
||||||
```
|
```
|
||||||
|
|
||||||
Installing from a signed feed instead:
|
From the repo instead (§5 sets it up once), deps pull the rest in:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# add the feed (customfeeds.conf / apk repositories), then:
|
apk update && apk add luci-app-shater # -> shater-core -> shaterd
|
||||||
opkg update && opkg install shater-core luci-app-shater # shaterd pulled in as a dep
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Enable
|
## 4. Enable
|
||||||
@@ -119,83 +186,87 @@ daemon (`shaterd run`), which owns the engine, the `inet shater` data plane, pol
|
|||||||
routing, in-process DNS, and the admin panel (default `:8088`). The LuCI app's
|
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.
|
"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`
|
From the first apply, **every** LAN plaintext `:53` goes into the engine — including
|
||||||
Gitea release** that is itself a signed opkg `src/gz` feed: the release holds the
|
the queries a client sends to the router's own address, which is what DHCP hands out.
|
||||||
`.ipk` for all arches, a `Packages`/`Packages.gz` index, a usign `Packages.sig`,
|
That is `globals.dns_intercept`, and it is **on by default** (D24); without it those
|
||||||
and the public key `shater-feed.pub`. opkg filters by `Architecture`, so the **same
|
queries reach dnsmasq and the ISP unfiltered, i.e. the client with default settings
|
||||||
two lines work on every device** (x86 testbed picks `x86_64 + all`; the BPI routers
|
leaks while the one that hard-coded `8.8.8.8` does not. What follows from it:
|
||||||
pick `aarch64_cortex-a53 + all`).
|
|
||||||
|
|
||||||
> **Format:** OpenWrt 24.10 (our SDK) uses **opkg** (`.ipk`, `Packages.gz`, usign),
|
- `.lan` and private reverse (PTR) lookups still go to dnsmasq — the engine gets a
|
||||||
> so the feed is `src/gz` and the trust anchor is the usign key
|
rule for those suffixes. If you renamed dnsmasq's domain away from `lan`, add a
|
||||||
> `dist/shater-feed.pub` (fingerprint **`5ac4b177689cb8e0`**). apk only replaces
|
`config dns_rule` for the new suffix.
|
||||||
> opkg at OpenWrt **25.12** — see §6.
|
- 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
|
```sh
|
||||||
# 1) trust the feed key — the FILENAME must equal the usign key fingerprint.
|
uci set shater.globals.dns_intercept=0
|
||||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
uci commit shater
|
||||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
shaterd apply
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
With the key installed, opkg's default `check_signature 1` verifies the feed on
|
Your `0` is kept: `/etc/config/shater` is a conffile (upgrades never replace it) and
|
||||||
every `opkg update`; no `--nocheck-signature` needed. A **tagged** release
|
the daemon always writes the option back explicitly, so it is never re-enabled by a
|
||||||
(`vX.Y.Z`) publishes the identical layout at
|
default.
|
||||||
`.../releases/download/vX.Y.Z` if you prefer to pin a version instead of tracking
|
|
||||||
`latest`.
|
|
||||||
|
|
||||||
### Updating
|
## 5. The signed apk repo (the normal install path)
|
||||||
|
|
||||||
```sh
|
OpenWrt/ImmortalWrt **25.12** packages with Alpine's **apk**: `.apk` files, a
|
||||||
opkg update
|
binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
|
||||||
opkg upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
|
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.
|
||||||
|
|
||||||
Updates are only offered when the feed's `Version` differs from the installed one,
|
CI (`v*` tag push or `workflow_dispatch`) compiles the 4 packages through the
|
||||||
so **bump `PKG_RELEASE`** (or `PKG_VERSION`) in the package Makefile on every
|
official **ImmortalWrt 25.12 SDK** (tarballs from
|
||||||
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.
|
|
||||||
|
|
||||||
## 6. apk feed (OpenWrt/ImmortalWrt 25.12+ — incl. BananaWRT 25.12-mtk-vendor)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
`downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`)
|
`downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`)
|
||||||
and publish **one release per arch** — rolling `apk-latest-x86_64` /
|
and publishes **one release per arch**: the rolling `apk-latest-x86_64` /
|
||||||
`apk-latest-aarch64_cortex-a53`, or `apk-vX.Y.Z-<arch>` for a tagged version.
|
`apk-latest-aarch64_cortex-a53`, plus `apk-vX.Y.Z-<arch>` on a tag. Per-arch
|
||||||
Per-arch (unlike the combined opkg release) because apk filenames carry no
|
because apk filenames carry no architecture and packages are fetched *relative to
|
||||||
architecture and packages are fetched relative to the `packages.adb` URL.
|
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
|
> **Key:** the trust anchor is the EC public key **`dist/shater-apk.pem`**
|
||||||
> public key **`dist/shater-apk.pem`** (generated once by `ci/gen-apk-key.sh`;
|
> (generated once by `ci/gen-apk-key.sh`; the private half lives ONLY in the
|
||||||
> private half lives ONLY in the Gitea secret **`KEY_APK`**, the apk analog of
|
> Gitea secret **`KEY_APK`**). Never regenerate it — that invalidates every
|
||||||
> `KEY_BUILD`). Never regenerate either key — that invalidates every deployed
|
> deployed router's trust.
|
||||||
> router's trust. The usign identity `shater-feed.pub` keeps signing the
|
|
||||||
> opkg/24.10 feed, untouched.
|
|
||||||
|
|
||||||
One-time setup on a 25.12 router (BananaWRT `25.12-mtk-vendor` on the BPI-R3
|
### 5.1 Rolling or pinned — pick the repo URL deliberately
|
||||||
mini, BPI-R4 on 25.12, or the future 25.12 VM — `/etc/apk/arch` picks the right
|
|
||||||
per-arch release automatically):
|
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
|
```sh
|
||||||
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
|
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
|
||||||
@@ -203,6 +274,7 @@ 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"
|
"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.
|
# 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" \
|
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
|
||||||
> /etc/apk/repositories.d/shater.list
|
> /etc/apk/repositories.d/shater.list
|
||||||
|
|
||||||
@@ -212,17 +284,34 @@ apk add luci-app-shater # -> shater-core -> shaterd
|
|||||||
apk add byedpi # optional: ByeDPI desync egress
|
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
|
```sh
|
||||||
apk update
|
apk update
|
||||||
apk upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
|
apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||||
```
|
```
|
||||||
|
|
||||||
Same rule as opkg: an upgrade is only offered when the feed version differs, so
|
apk-tools 3 documents exactly this behaviour for `apk upgrade`: *"When no
|
||||||
bump `PKG_RELEASE`/`PKG_VERSION` on every shipped change (apk shows it as
|
packages are specified, all packages are upgraded if possible. If list of
|
||||||
`0.2.0-r1`). Pin a version instead of tracking rolling by pointing the repo line
|
packages is provided, only those packages are upgraded along with needed
|
||||||
at `.../download/apk-vX.Y.Z-$(cat /etc/apk/arch)/packages.adb`.
|
dependencies."* The equivalent form, which additionally re-pins the packages in
|
||||||
|
`world`, is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apk add -u shaterd shater-core luci-app-shater byedpi # -u = --upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop `byedpi` from either list if you never installed it. Check what you are on
|
||||||
|
with `apk list -I shaterd shater-core luci-app-shater byedpi` — 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.
|
||||||
|
|
||||||
### BananaWRT `25.12-mtk-vendor` compatibility
|
### BananaWRT `25.12-mtk-vendor` compatibility
|
||||||
|
|
||||||
|
|||||||
+41
-4
@@ -195,7 +195,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 Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Name string; Enabled bool; Order int
|
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
|
Target string // chain:|group:|node:|direct|block
|
||||||
Egress,Kill string
|
Egress,Kill string
|
||||||
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
|
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
|
||||||
@@ -251,14 +251,51 @@ 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).
|
> v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
|
||||||
|
|
||||||
### uci.go — `/etc/config/shater` schema
|
### 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 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) |
|
||||||
|
| `geo_provider` | unset = auto | `sagernet` \| `loyalsoldier` \| `metacubex` \| `custom`; auto = country codes from SagerNet, everything else from Loyalsoldier |
|
||||||
|
| `geosite_url` / `geoip_url` | unset | `{category}` templates, honoured only when `geo_provider=custom` |
|
||||||
|
| `geosite_index_url` / `geoip_index_url` | unset | git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions |
|
||||||
|
| `stats_backend` | `memory` | `off` (no aggregation at all) \| `memory` (RAM, lost on restart) \| `sqlite` (aggregates in RAM + query/connection log on disk) |
|
||||||
|
| `stats_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, sniff.
|
- `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 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 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 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 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 ruleset`: name, type(domain|ipcidr), source(inline|file|url|geosite|geoip), url, path, format, update_interval, list category, 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 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 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 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.
|
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
|
||||||
|
|
||||||
|
|||||||
@@ -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/).
|
||||||
@@ -104,7 +104,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
|
|||||||
|
|
||||||
## Phase 8 — Ship it ✅ DONE
|
## Phase 8 — Ship it ✅ DONE
|
||||||
- Adapt CI to build/sign the single forked binary for both arches; publish the
|
- 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).
|
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
|
||||||
|
|
||||||
## Cross-cutting (every phase)
|
## 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
// lx:begin sec-consttime
|
||||||
|
"crypto/subtle"
|
||||||
|
// lx:end sec-consttime
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -97,9 +100,11 @@ func unaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerIn
|
|||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return nil, status.Error(codes.Unauthenticated, "missing authentication secret")
|
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")
|
return nil, status.Error(codes.Unauthenticated, "invalid authentication secret")
|
||||||
}
|
}
|
||||||
|
// lx:end sec-consttime
|
||||||
return handler(ctx, req)
|
return handler(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +120,11 @@ func streamAuthInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServe
|
|||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return status.Error(codes.Unauthenticated, "missing authentication secret")
|
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")
|
return status.Error(codes.Unauthenticated, "invalid authentication secret")
|
||||||
}
|
}
|
||||||
|
// lx:end sec-consttime
|
||||||
return handler(srv, ss)
|
return handler(srv, ss)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ func (r *oomReporter) WriteReport(memoryUsage uint64) error {
|
|||||||
draftInfo = nil
|
draftInfo = nil
|
||||||
}
|
}
|
||||||
reportsDir := filepath.Join(sWorkingPath, "oom_reports")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,9 @@ func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error {
|
|||||||
|
|
||||||
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
|
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
|
||||||
now := time.Now().UTC()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ func baseReportMetadata() reportMetadata {
|
|||||||
|
|
||||||
func writeReportFile(destPath string, name string, content []byte) {
|
func writeReportFile(destPath string, name string, content []byte) {
|
||||||
filePath := filepath.Join(destPath, name)
|
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)
|
chownReport(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +72,9 @@ func copyConfigSnapshot(destPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initReportDir(path string) {
|
func initReportDir(path string) {
|
||||||
os.MkdirAll(path, 0o777)
|
// lx:begin sec-perms
|
||||||
|
os.MkdirAll(path, 0o700)
|
||||||
|
// lx:end sec-perms
|
||||||
chownReport(path)
|
chownReport(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
@@ -15,6 +15,17 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=byedpi
|
PKG_NAME:=byedpi
|
||||||
|
|
||||||
|
# DELIBERATELY NOT auto-versioned from our git tag (unlike shaterd/shater-core/
|
||||||
|
# luci-app-shater, which take SHATER_PKG_VERSION/SHATER_PKG_RELEASE from
|
||||||
|
# ci/version.sh). PKG_VERSION here is THIRD-PARTY UPSTREAM's version — it is what
|
||||||
|
# PKG_SOURCE_URL/PKG_HASH pin, and what tells an operator which ByeDPI is
|
||||||
|
# actually installed. Stamping our tag on it would be both a lie and a
|
||||||
|
# regression: our tags are 0.2.x, and the version comparator (apk-tools 3,
|
||||||
|
# verified) reads 0.2.7 < 0.17.3 — component-wise numerically, 2 < 17
|
||||||
|
# — so the "new" package would be a DOWNGRADE and routers would refuse it.
|
||||||
|
# Bump PKG_RELEASE BY HAND when *our packaging* of it changes (init script, uci
|
||||||
|
# defaults, build flags); bump PKG_VERSION+PKG_HASH when upstream releases.
|
||||||
PKG_VERSION:=0.17.3
|
PKG_VERSION:=0.17.3
|
||||||
PKG_RELEASE:=1
|
PKG_RELEASE:=1
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ LUCI_TITLE:=LuCI thin launcher for Shater (mini dashboard + panel handoff)
|
|||||||
LUCI_DEPENDS:=+shater-core +rpcd
|
LUCI_DEPENDS:=+shater-core +rpcd
|
||||||
LUCI_PKGARCH:=all
|
LUCI_PKGARCH:=all
|
||||||
|
|
||||||
PKG_VERSION:=0.2.0
|
# Version comes from the git tag via ci/version.sh -> SHATER_PKG_VERSION /
|
||||||
PKG_RELEASE:=2
|
# 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_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
PKG_LICENSE:=GPL-3.0-or-later
|
||||||
|
|||||||
@@ -13,8 +13,13 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=shater-core
|
PKG_NAME:=shater-core
|
||||||
PKG_VERSION:=0.2.0
|
|
||||||
PKG_RELEASE:=3
|
# 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_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||||
PKG_LICENSE:=GPL-2.0-or-later
|
PKG_LICENSE:=GPL-2.0-or-later
|
||||||
@@ -75,6 +80,10 @@ define Package/shater-core/install
|
|||||||
$(INSTALL_DIR) $(1)/etc/init.d
|
$(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 $(1)/etc/init.d/shater
|
||||||
$(INSTALL_BIN) ./files/etc/init.d/shater-cron $(1)/etc/init.d/shater-cron
|
$(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_DIR) $(1)/etc/hotplug.d/iface
|
||||||
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
|
$(INSTALL_BIN) ./files/etc/hotplug.d/iface/99-shater $(1)/etc/hotplug.d/iface/99-shater
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ config globals 'globals'
|
|||||||
option kill_switch 'closed'
|
option kill_switch 'closed'
|
||||||
# There is no dns_mode option: routing is decided by in-engine rule-sets and
|
# 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).
|
# 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'
|
||||||
option ipv6 '1'
|
option ipv6 '1'
|
||||||
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
|
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
|
||||||
option fwmark_base '0x2000'
|
option fwmark_base '0x2000'
|
||||||
@@ -62,7 +88,29 @@ config inbound
|
|||||||
# list node 'my-node'
|
# list node 'my-node'
|
||||||
#
|
#
|
||||||
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
|
# 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
|
#config rule
|
||||||
# option name 'all-via-main'
|
# option name 'all-via-main'
|
||||||
# option enabled '1'
|
# option enabled '1'
|
||||||
@@ -83,11 +131,16 @@ config inbound
|
|||||||
# option type 'direct'
|
# option type 'direct'
|
||||||
# option dpi 'fragment'
|
# option dpi 'fragment'
|
||||||
#
|
#
|
||||||
|
#config ruleset
|
||||||
|
# option name 'youtube'
|
||||||
|
# option source 'geosite'
|
||||||
|
# list category 'youtube'
|
||||||
|
#
|
||||||
#config rule
|
#config rule
|
||||||
# option name 'youtube-fragment'
|
# option name 'youtube-fragment'
|
||||||
# option enabled '1'
|
# option enabled '1'
|
||||||
# option order '50'
|
# option order '50'
|
||||||
# list dst_domain 'geosite:youtube'
|
# list dst_ruleset 'youtube'
|
||||||
# option target 'egress:frag'
|
# option target 'egress:frag'
|
||||||
#
|
#
|
||||||
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
|
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
|
||||||
|
|||||||
@@ -33,6 +33,24 @@
|
|||||||
# be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
|
# be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
|
||||||
# reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
|
# reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
|
||||||
# background actor may resurrect interception behind a stopped daemon.
|
# background actor may resurrect interception behind a stopped daemon.
|
||||||
|
# * BEING REPLACED IS NOT BEING SWITCHED OFF. `restart`, `reload` (which is
|
||||||
|
# stop+start, i.e. every LuCI Save & Apply) and every package upgrade all 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 behind instead of bare routing. A real
|
||||||
|
# `stop` raises no flag and therefore still means what it says.
|
||||||
|
# * 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: a deliberate
|
||||||
|
# `stop`, or a missing daemon binary, removes the armor so it cannot outlive
|
||||||
|
# the product it protects.
|
||||||
# * The engine must never be permanently abandoned while interception stands:
|
# * The engine must never be permanently abandoned while interception stands:
|
||||||
# respawn retries are infinite (procd never gives up); a sustained-dead
|
# respawn retries are infinite (procd never gives up); a sustained-dead
|
||||||
# daemon is additionally escalated by the shater-cron watchdog.
|
# daemon is additionally escalated by the shater-cron watchdog.
|
||||||
@@ -47,6 +65,38 @@ PROG=/usr/bin/shaterd
|
|||||||
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
|
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
|
||||||
# nothing reconciles before this init has run at boot.
|
# nothing reconciles before this init has run at boot.
|
||||||
ACTIVE_FLAG=/var/run/shater.active
|
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 sets `action=${2:-help}` before it sources this file, and every action
|
||||||
|
# then runs as a function in THAT SAME shell — so `stop_service` can see whether it
|
||||||
|
# was reached by `stop` or as the first half of `restart`/`reload`. That is the one
|
||||||
|
# distinction procd itself does not expose (`restart` is literally `stop; start`,
|
||||||
|
# and stop_service is called identically by both).
|
||||||
|
#
|
||||||
|
# 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. An EMPTY or unexpected value degrades to "real stop",
|
||||||
|
# which is the pre-existing behaviour and the safe direction to be wrong in: it
|
||||||
|
# costs a plaintext window on restart, where the other default would leave a
|
||||||
|
# deliberately stopped router blocked.
|
||||||
|
SHATER_RC_ACTION="$action"
|
||||||
|
|
||||||
# --- helpers ---------------------------------------------------------------
|
# --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -66,6 +116,69 @@ _slog() {
|
|||||||
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
|
[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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 when the operator stops the service and when
|
||||||
|
# the daemon binary is gone — in both cases nothing is 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.
|
||||||
|
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 -------------------------------------------------------
|
# --- procd lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
start_service() {
|
start_service() {
|
||||||
@@ -81,15 +194,53 @@ start_service() {
|
|||||||
# Guard: never claim to run without the daemon binary. A half-removed/failed
|
# 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
|
# shaterd upgrade must degrade to "plugin off", not to a box that thinks
|
||||||
# interception is live with nothing behind it.
|
# 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
|
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 \
|
_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
|
return 0
|
||||||
fi
|
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;
|
# Bring the UCI schema forward before the daemon reads it (idempotent;
|
||||||
# refuses a newer schema) so an upgraded package never applies a stale config.
|
# refuses a newer schema) so an upgraded package never applies a stale config.
|
||||||
"$PROG" migrate >/dev/null 2>&1
|
#
|
||||||
|
# THE FAILURE IS LOGGED, NOT SWALLOWED. 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. And it CAN fail for a mundane reason — a full
|
||||||
|
# /overlay makes `uci commit` fail — after which the config still carries the
|
||||||
|
# schema-v1 `dst_domain`/`dst_ip` options. The daemon holds every rule that
|
||||||
|
# still has them DISABLED and reports it, so nothing is silently misrouted, but
|
||||||
|
# rules the operator wrote are then not in force and the reason has to be
|
||||||
|
# visible somewhere. Hence: log the binary's own stderr, and start anyway —
|
||||||
|
# refusing to start would take the admin panel down with it, and the panel is
|
||||||
|
# the only way to fix the box.
|
||||||
|
local migrate_out
|
||||||
|
migrate_out=$("$PROG" migrate 2>&1) || _slog -p daemon.err \
|
||||||
|
"UCI schema migration FAILED: ${migrate_out:-no output from $PROG migrate}. Starting anyway; routing rules that still carry the removed dst_domain/dst_ip options stay DISABLED until this succeeds. Free space on /overlay and re-run '$PROG migrate', or restart the service."
|
||||||
|
|
||||||
procd_open_instance shater
|
procd_open_instance shater
|
||||||
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
|
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
|
||||||
@@ -111,7 +262,16 @@ start_service() {
|
|||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
# Give the daemon room to run its honest teardown (engine.Close + netplane
|
# Give the daemon room to run its honest teardown (engine.Close + netplane
|
||||||
# restore) before procd SIGKILLs it.
|
# 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
|
procd_close_instance
|
||||||
|
|
||||||
# Mark the stack live for hotplug/cron — but ONLY when interception is
|
# Mark the stack live for hotplug/cron — but ONLY when interception is
|
||||||
@@ -128,6 +288,33 @@ start_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_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:
|
||||||
|
#
|
||||||
|
# restart / reload -> a successor is coming. 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, until now, with `lan -> wan
|
||||||
|
# ACCEPT` and nothing else.
|
||||||
|
# anything else -> a deliberate `stop`. Everything comes down, and the
|
||||||
|
# boot armor goes with it so the next boot does not
|
||||||
|
# quietly reinstate what the operator just switched off.
|
||||||
|
# An admin `stop` has to STICK; that is the same rule
|
||||||
|
# ACTIVE_FLAG has always enforced for hotplug/cron.
|
||||||
|
case "$SHATER_RC_ACTION" in
|
||||||
|
restart|reload)
|
||||||
|
shater_mark_restart
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shater_clear_restart
|
||||||
|
shater_disarm_boot
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
|
# 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`,
|
# 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
|
# which runs its OWN honest teardown (engine.Close + netplane restore) — we
|
||||||
@@ -141,10 +328,17 @@ stop_service() {
|
|||||||
reload_service() {
|
reload_service() {
|
||||||
# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
|
# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
|
||||||
# reload_config). Simplest correct behaviour: stop + start. `stop` clears the
|
# reload_config). Simplest correct behaviour: stop + start. `stop` clears the
|
||||||
# flag and SIGTERMs the daemon (honest teardown); `start` re-guards on
|
# flag and SIGTERMs the daemon (honest teardown); `start` WAITS for that
|
||||||
# enabled and, if still enabled, launches a fresh `shaterd run` that reads
|
# teardown to actually finish (shater_wait_stopped) and then launches a fresh
|
||||||
# the new UCI and applies it. When the stack is disabled, `start` is a no-op,
|
# `shaterd run` that reads the new UCI and applies it. When the stack is
|
||||||
# so a disable+apply cleanly tears everything down.
|
# 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
|
stop
|
||||||
start
|
start
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/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)
|
||||||
|
#
|
||||||
|
# * $ARMOR only exists while the daemon's last applied config was BOTH enabled
|
||||||
|
# and fail-closed. `globals.enabled=0`, `kill_switch=open` and a deliberate
|
||||||
|
# `/etc/init.d/shater stop` each remove it.
|
||||||
|
# * 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.
|
||||||
|
# * 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.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
}
|
||||||
@@ -110,6 +110,14 @@ SHATER_BRINGUP='
|
|||||||
done
|
done
|
||||||
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable
|
[ -x /etc/init.d/shater ] && /etc/init.d/shater enable
|
||||||
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron 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 ] && /etc/init.d/shater restart
|
||||||
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart
|
[ -x /etc/init.d/shater-cron ] && /etc/init.d/shater-cron restart
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -34,8 +34,17 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=shaterd
|
PKG_NAME:=shaterd
|
||||||
PKG_VERSION:=0.2.0
|
|
||||||
PKG_RELEASE:=3
|
# 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_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
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
|
$(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd
|
||||||
endef
|
endef
|
||||||
|
|
||||||
# This package ships ONLY the binary — no init script — so opkg's default
|
# This package ships ONLY the binary — no init script — so the package manager's
|
||||||
# postinst never touches the running service. On `opkg upgrade shaterd` the new
|
# 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
|
# 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
|
# unlinked inode: the upgrade silently has no effect until the next reboot, and
|
||||||
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
|
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
|
||||||
|
|||||||
@@ -18,6 +18,32 @@ type URLTestOutboundOptions struct {
|
|||||||
// lx: SPEC 019 v2 — load-balancing.
|
// lx: SPEC 019 v2 — load-balancing.
|
||||||
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
|
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
|
||||||
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
|
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
|
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --test src/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"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) ----
|
/* ---- last-apply findings (Overview) ----
|
||||||
Severity carries the colour; the accent is reserved for interactive controls. */
|
Severity carries the colour; the accent is reserved for interactive controls. */
|
||||||
.findings {
|
.findings {
|
||||||
@@ -312,6 +456,13 @@
|
|||||||
.finding--warning {
|
.finding--warning {
|
||||||
border-color: color-mix(in srgb, var(--amber) 40%, var(--groove));
|
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 {
|
.finding-copy {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -405,3 +556,73 @@
|
|||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
max-width: 74ch;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+118
-7
@@ -1,12 +1,14 @@
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
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 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 type { Status } from './api'
|
||||||
|
import { usePendingConfirm } from './pendingConfirm'
|
||||||
import { bootstrapSession } from './session'
|
import { bootstrapSession } from './session'
|
||||||
import { ROUTES, navigate, useRoute } from './router'
|
import { ROUTES, navigate, useRoute } from './router'
|
||||||
import { protectionState } from './planeState'
|
import { engineState, protectionState } from './planeState'
|
||||||
|
import { truncationNote } from './findings'
|
||||||
import type { Route } from './router'
|
import type { Route } from './router'
|
||||||
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
|
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} />}
|
footer={<StatusBar status={status} />}
|
||||||
>
|
>
|
||||||
<Nav route={route} />
|
<Nav route={route} />
|
||||||
|
<MockBanner />
|
||||||
<PlaneBanner status={status} route={route} />
|
<PlaneBanner status={status} route={route} />
|
||||||
|
<ConfirmBand route={route} onChanged={() => void refreshStatus()} />
|
||||||
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
|
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
|
||||||
</Faceplate>
|
</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
|
* 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).
|
* (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 criticals = (status.warnings ?? []).filter((w) => w.severity === 'critical').length
|
||||||
const state = protectionState(status)
|
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
|
// Wording comes from the shared source of truth so the banner and Overview can
|
||||||
// never describe the same router differently.
|
// never describe the same router differently.
|
||||||
const headline = state.alarm
|
const headline = state.alarm
|
||||||
? state.headline
|
? 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
|
const detail = state.alarm
|
||||||
? state.detail
|
? state.detail
|
||||||
: 'Something you configured isn’t in effect. Review the findings before relying on it.'
|
: 'Something you configured isn’t in effect. Review the findings before relying on it.'
|
||||||
@@ -225,14 +320,30 @@ 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.
|
||||||
|
*/
|
||||||
function masterIndicator(
|
function masterIndicator(
|
||||||
phase: Phase,
|
phase: Phase,
|
||||||
status: Status | null,
|
status: Status | null,
|
||||||
): { label: string; variant: LedVariant; pulse?: boolean } {
|
): { label: string; variant: LedVariant; pulse?: boolean } {
|
||||||
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
|
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
|
||||||
if (status.running && status.active) return { label: 'Online', variant: 'on', pulse: true }
|
switch (engineState(status)) {
|
||||||
if (status.running) return { label: 'Standby', variant: 'amber' }
|
case 'down':
|
||||||
return { label: 'Offline', variant: 'crit' }
|
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() {
|
function UnauthPlate() {
|
||||||
|
|||||||
+379
-48
@@ -13,8 +13,13 @@
|
|||||||
// serves in-memory fixtures instead of hitting the network, so `npm run dev`
|
// serves in-memory fixtures instead of hitting the network, so `npm run dev`
|
||||||
// and screenshot runs render without a live backend. A real backend in dev is
|
// and screenshot runs render without a live backend. A real backend in dev is
|
||||||
// reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch).
|
// reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch).
|
||||||
|
//
|
||||||
|
// THE FIXTURES ARE A DEV-BUILD-ONLY ARTEFACT — see initMockBackend below. They
|
||||||
|
// used to be a plain static import, decided at RUNTIME off `location.search`, so
|
||||||
|
// the invented router shipped inside the binary that goes on real hardware and a
|
||||||
|
// link ending in `?dev` painted a healthy appliance without making one request.
|
||||||
|
|
||||||
import * as mock from './mock'
|
import { armPendingConfirm, clearPendingConfirm, noteConfirmTimeout } from './pendingConfirm'
|
||||||
|
|
||||||
// --- error type -------------------------------------------------------------
|
// --- error type -------------------------------------------------------------
|
||||||
|
|
||||||
@@ -51,6 +56,38 @@ export class ApiError extends Error {
|
|||||||
*/
|
*/
|
||||||
export type Plane = 'full' | 'hold' | 'none'
|
export type Plane = 'full' | 'hold' | 'none'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the router's traffic actually ENDS UP, decided by the daemon from the
|
||||||
|
* engine config it is running (apply.Status.traffic ← generate.TrafficOf).
|
||||||
|
*
|
||||||
|
* tunnel — the default route goes into a tunnel: everything not matched by a
|
||||||
|
* more specific rule is proxied.
|
||||||
|
* split — the default leaves directly, but some rules do tunnel their traffic.
|
||||||
|
* direct — the default leaves directly and nothing is tunnelled at all.
|
||||||
|
* blocked — the default is the fail-closed backstop: unmatched traffic is
|
||||||
|
* dropped, not let out. Nothing leaks.
|
||||||
|
*
|
||||||
|
* `plane` DOES NOT ANSWER THIS and must never be read as if it did. `plane` says
|
||||||
|
* how much of the data plane is installed (nft table, policy routing, engine up);
|
||||||
|
* a router whose only rule is `default → direct` has all of it and sends the whole
|
||||||
|
* LAN out the plain WAN with its real address. That combination — plane "full",
|
||||||
|
* traffic "direct" — was live on a user's router under a green "Protected" LED.
|
||||||
|
*/
|
||||||
|
export type TrafficVerdict = 'tunnel' | 'split' | 'direct' | 'blocked'
|
||||||
|
|
||||||
|
export interface Traffic {
|
||||||
|
// '' or absent ⇒ not known (daemon that predates this field, nothing applied
|
||||||
|
// yet, or the plane is on hold). NEVER treat unknown as 'tunnel'.
|
||||||
|
verdict?: TrafficVerdict | ''
|
||||||
|
// The outbound tag the engine's default route names, in the engine's own
|
||||||
|
// vocabulary ("direct", "block", a node/group tag). Diagnostic — wording is
|
||||||
|
// driven by `verdict`, never by parsing this.
|
||||||
|
default?: string
|
||||||
|
// How many of the engine's route rules send their matched traffic into a tunnel.
|
||||||
|
// Separates "some of your traffic is protected" from "none of it is".
|
||||||
|
tunnel_rules?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One thing the last apply could not do. Deliberately fail-OPEN with a warning
|
* One thing the last apply could not do. Deliberately fail-OPEN with a warning
|
||||||
* rather than refusing the whole config (the alternative was taking the network
|
* rather than refusing the whole config (the alternative was taking the network
|
||||||
@@ -88,6 +125,10 @@ export interface Status {
|
|||||||
// How much of the data plane is installed. Absent on older daemons ⇒ unknown,
|
// How much of the data plane is installed. Absent on older daemons ⇒ unknown,
|
||||||
// in which case the UI shows nothing rather than guessing "full".
|
// in which case the UI shows nothing rather than guessing "full".
|
||||||
plane?: Plane
|
plane?: Plane
|
||||||
|
// Where the traffic actually goes under the running config. Absent on older
|
||||||
|
// daemons ⇒ unknown; see TrafficVerdict for why this is a separate question
|
||||||
|
// from `plane`.
|
||||||
|
traffic?: Traffic
|
||||||
// Findings from the last apply. ALWAYS an array from the daemon (never null);
|
// Findings from the last apply. ALWAYS an array from the daemon (never null);
|
||||||
// empty means the last apply was clean. Pre-sorted critical-first and capped at
|
// empty means the last apply was clean. Pre-sorted critical-first and capped at
|
||||||
// 50, where a truncated list ends with an `info` entry saying "suppressed".
|
// 50, where a truncated list ends with an `info` entry saying "suppressed".
|
||||||
@@ -354,19 +395,142 @@ export interface GroupHealth {
|
|||||||
* any more and nothing to report here beyond the groups themselves.
|
* any more and nothing to report here beyond the groups themselves.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hop of one chain, measured where that hop actually sits in the path.
|
||||||
|
*
|
||||||
|
* This is the reading the daemon always took and never showed. A chain is not a
|
||||||
|
* target with a single health — it is an ordered series of them, and the only
|
||||||
|
* question an operator ever asks about a broken chain is WHICH hop broke. The
|
||||||
|
* end-to-end exit reading cannot answer that: it says "the path is dead" for a
|
||||||
|
* four-hop chain and leaves the person to guess between four suspects.
|
||||||
|
*
|
||||||
|
* WIRE ORDER. `index` is 1-based and counts hops in the order the router dials
|
||||||
|
* them: hop 1 is the first physical hop, and each later hop is dialled THROUGH
|
||||||
|
* the ones before it. The hop carrying `exit: true` — always the largest index —
|
||||||
|
* is where traffic leaves for the internet. A leading `egress:` in the chain's
|
||||||
|
* configured Hops is NOT a numbered hop: the daemon lifts it into the entry
|
||||||
|
* detour of hop 1, so a chain written `egress:ewan → node:awgout → group:sub0`
|
||||||
|
* reports two hops, not three. Anything zipping this against the model's Hops
|
||||||
|
* must drop that leading egress first and give up on labelling entirely if the
|
||||||
|
* counts still disagree — a chain that splices sub-chains gets flattened here,
|
||||||
|
* and a confidently WRONG hop name is worse than no name.
|
||||||
|
*
|
||||||
|
* `tag` is the engine-side outbound (`chain-<name>-h2`). Debugging and tooltips
|
||||||
|
* only; it is never a label to put in front of a person.
|
||||||
|
*
|
||||||
|
* ORDERED WALK — THE READING STOPS AT THE FIRST DEAD HOP. Hops are NOT measured
|
||||||
|
* independently, and never were measurable that way: hop 3 is dialled THROUGH
|
||||||
|
* hop 2, so probing hop 3 while hop 2 is down measures hop 2 a second time and
|
||||||
|
* learns nothing about hop 3. The daemon therefore walks the path in wire order
|
||||||
|
* and stops at the first hop that does not answer. Every hop below that one is
|
||||||
|
* left undialled and reported `state: "untested"` — no measurement exists —
|
||||||
|
* carrying {@link ChainHopBlock} in `blocked_by` to name the hop that stopped the
|
||||||
|
* walk. So a chain never reports a dead hop with a live hop below it; that shape
|
||||||
|
* is not a rare case, it is unreachable.
|
||||||
|
*
|
||||||
|
* NODE HOP vs GROUP HOP. For `kind: "node"` the hop IS the measurement: `total`
|
||||||
|
* is 1, the counters follow its own state, and `selected` is ''. For
|
||||||
|
* `kind: "group"` the counters roll up that hop's per-hop member COPIES — the
|
||||||
|
* copies dialled through the hops in front of it, which is exactly why they can
|
||||||
|
* read alive here while the same group's standalone card reads dead. Both
|
||||||
|
* readings are true; they measure different dial paths. `selected` is the node
|
||||||
|
* NAME the hop routes through right now, and `delay_ms` / `age_seconds` belong
|
||||||
|
* to that selected member (or the freshest alive one).
|
||||||
|
*
|
||||||
|
* Invariants the daemon guarantees — never re-derive them, just read them:
|
||||||
|
* `tested === alive + dead` and `alive + dead + untested === total`.
|
||||||
|
*
|
||||||
|
* `state` is a closed set of THREE. `untested` is NEVER "dead" and never
|
||||||
|
* "healthy": it means nothing fresh enough is known. Without `blocked_by` that is
|
||||||
|
* a matter of timing — for a used chain it resolves on its own within seconds.
|
||||||
|
* With `blocked_by` it will not resolve until the named hop is fixed. There is no
|
||||||
|
* fourth state for that; the state stays `untested` because that is what it is.
|
||||||
|
* `age_seconds: -1` means the age is unknown.
|
||||||
|
*/
|
||||||
|
export interface ChainHopHealth {
|
||||||
|
/** 1-based WIRE order. Hop 1 is dialled first; see the note above. */
|
||||||
|
index: number
|
||||||
|
/** Engine outbound tag (`chain-<name>-h2`) — tooltips/debugging, never a label. */
|
||||||
|
tag: string
|
||||||
|
/** `node` ⇒ the hop is the measurement. `group` ⇒ the counters roll up members. */
|
||||||
|
kind: 'node' | 'group'
|
||||||
|
/** This hop is where traffic leaves for the internet. Always the largest index. */
|
||||||
|
exit: boolean
|
||||||
|
/** Closed set — switch on it exhaustively. `untested` is never "dead". */
|
||||||
|
state: 'alive' | 'dead' | 'untested'
|
||||||
|
/** RTT of the selected/freshest alive member; 0 (meaningless) when not alive. */
|
||||||
|
delay_ms: number
|
||||||
|
/** Age of that measurement in seconds; -1 when unknown. */
|
||||||
|
age_seconds: number
|
||||||
|
/** Node name this GROUP hop routes through right now; '' for a node hop. */
|
||||||
|
selected: string
|
||||||
|
total: number
|
||||||
|
tested: number
|
||||||
|
alive: number
|
||||||
|
dead: number
|
||||||
|
untested: number
|
||||||
|
/**
|
||||||
|
* PRESENT ONLY on a hop the ordered walk never reached — i.e. a hop sitting
|
||||||
|
* below one the prober found `dead`. The key is omitted otherwise; absent is
|
||||||
|
* the normal case and means "this hop was actually dialled".
|
||||||
|
*
|
||||||
|
* Its presence is the daemon's own statement that this hop has NO measurement,
|
||||||
|
* and it comes with the rest of that statement already filled in: `state` is
|
||||||
|
* `untested`, `delay_ms` is 0, `age_seconds` is -1, and the counters are
|
||||||
|
* `alive: 0, dead: 0, tested: 0, untested: total`. Read those; do not re-derive
|
||||||
|
* a verdict from them, and do not infer a block from zeroed counters either —
|
||||||
|
* an unprobed-yet hop has the same numbers and a very different meaning.
|
||||||
|
* `selected` MAY still be non-empty: the wrapper does have a pick, it simply
|
||||||
|
* was not measured, so it says which node the hop would use, not which node is
|
||||||
|
* carrying traffic.
|
||||||
|
*/
|
||||||
|
blocked_by?: ChainHopBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hop that stopped the ordered walk, as reported on every hop below it.
|
||||||
|
*
|
||||||
|
* This exists because "no reading" and "no reading, and here is whose fault that
|
||||||
|
* is" are different answers to the operator's actual question. Without it a
|
||||||
|
* blocked hop is indistinguishable from one the observatory has not come round to
|
||||||
|
* yet, and the interface can only shrug.
|
||||||
|
*
|
||||||
|
* `index` is the 1-based WIRE index of the blocking hop and is ALWAYS smaller
|
||||||
|
* than the index of the hop carrying it, so it points at a hop already on screen.
|
||||||
|
* `tag` is that hop's engine outbound (`chain-<name>-h3`) — debugging and
|
||||||
|
* tooltips only, never a label to put in front of a person, exactly as on
|
||||||
|
* {@link ChainHopHealth}.tag.
|
||||||
|
*/
|
||||||
|
export interface ChainHopBlock {
|
||||||
|
/** 1-based wire index of the hop that did not answer. Always < this hop's index. */
|
||||||
|
index: number
|
||||||
|
/** That hop's engine outbound tag — tooltips/debugging, never a label. */
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-chain reachability, the chain analogue of {@link GroupHealth}.used (plan
|
/** Per-chain reachability, the chain analogue of {@link GroupHealth}.used (plan
|
||||||
* §5.E): a chain no enabled routing rule routes through is outside the
|
* §5.E): a chain no enabled routing rule routes through is outside the
|
||||||
* observatory's plan, so its exit is never probed and the Targets card renders it
|
* observatory's plan, so nothing probes it and the Targets card says so instead
|
||||||
* "unused" instead of an exit-test readout. A chain has no membership counters —
|
* of rendering a health reading. A chain has no membership counters of its own —
|
||||||
* it is a fixed path, and its end-to-end health is the exit test's job. */
|
* it is a fixed path, and its health lives on its {@link ChainHopHealth} hops. */
|
||||||
export interface ChainHealth {
|
export interface ChainHealth {
|
||||||
name: string
|
name: string
|
||||||
/** An enabled routing rule (the Final target, a DNS-resolver detour, a device
|
/** An enabled routing rule (the Final target, a DNS-resolver detour, a device
|
||||||
* target, …) reaches this chain, so the observatory probes its exit in the
|
* target, …) reaches this chain, so the observatory probes its hops in the
|
||||||
* background. false ⇒ nothing routes through the chain: it is skipped by the
|
* background. false ⇒ nothing routes through the chain: it is skipped by the
|
||||||
* background probing and its end-to-end health stays untested. That is an
|
* background probing and its health stays untested. That is an "unused" note
|
||||||
* "unused" note about the ROUTING CONFIG, never a health problem. */
|
* about the ROUTING CONFIG, never a health problem. */
|
||||||
used: boolean
|
used: boolean
|
||||||
|
/**
|
||||||
|
* Per-hop health in wire order (see {@link ChainHopHealth}).
|
||||||
|
*
|
||||||
|
* MAY BE ABSENT, and absent does not mean "this chain has no hops". It means
|
||||||
|
* the engine never materialised per-hop outbounds for it: the chain is unused,
|
||||||
|
* or it collapses to a single hop and the daemon points traffic straight at
|
||||||
|
* that target instead of building a copy of it. Read a missing key as "nothing
|
||||||
|
* measured per hop", never as an empty path or as a fault.
|
||||||
|
*/
|
||||||
|
hops?: ChainHopHealth[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupsHealth {
|
export interface GroupsHealth {
|
||||||
@@ -809,9 +973,17 @@ export interface Rule {
|
|||||||
Enabled: boolean
|
Enabled: boolean
|
||||||
Order: number
|
Order: number
|
||||||
Src?: string[] | null
|
Src?: string[] | null
|
||||||
DstDomain?: string[] | null
|
/**
|
||||||
|
* WHERE the traffic is going — the rule's only destination matcher. Each entry
|
||||||
|
* names a {@link Ruleset}; 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, and `shaterd migrate` folds every existing one
|
||||||
|
* into a generated `rule-<name>` ruleset, so a destination list is written and
|
||||||
|
* edited in exactly one place and compiled once into a .srs that every rule
|
||||||
|
* referencing it shares.
|
||||||
|
*/
|
||||||
DstRuleset?: string[] | null
|
DstRuleset?: string[] | null
|
||||||
DstIP?: string[] | null
|
|
||||||
DstPort?: string
|
DstPort?: string
|
||||||
/**
|
/**
|
||||||
* Narrow the rule to one transport or one sniffed application protocol. A
|
* Narrow the rule to one transport or one sniffed application protocol. A
|
||||||
@@ -952,12 +1124,59 @@ export interface Model {
|
|||||||
|
|
||||||
// --- transport --------------------------------------------------------------
|
// --- transport --------------------------------------------------------------
|
||||||
|
|
||||||
/** True when the URL asks for the offline fixture backend (?mock or ?dev). */
|
// --- the offline fixture backend (dev builds only) ---------------------------
|
||||||
export const MOCK: boolean = (() => {
|
|
||||||
|
/**
|
||||||
|
* True when the in-memory fixtures are serving this session instead of the
|
||||||
|
* daemon. ALWAYS false in a production build — see {@link initMockBackend}.
|
||||||
|
*
|
||||||
|
* A live binding, not a constant: it is decided once during boot, before the
|
||||||
|
* first render, and every importer sees the same value for the whole session.
|
||||||
|
*/
|
||||||
|
export let MOCK = false
|
||||||
|
|
||||||
|
/** The loaded fixture module. `null` unless a dev build was asked for `?mock`. */
|
||||||
|
let fixtures: typeof import('./mock') | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the fixture backend, if this build has one and the URL asks for it.
|
||||||
|
* Call ONCE from the entry point and await it before the first render — the
|
||||||
|
* pages read {@link MOCK} while they render, so flipping it afterwards would
|
||||||
|
* leave a half-mocked screen.
|
||||||
|
*
|
||||||
|
* Two gates, and the order matters. `import.meta.env.DEV` is folded to a literal
|
||||||
|
* `false` by Vite at build time, so in a production build the whole body is
|
||||||
|
* unreachable, `import('./mock')` is tree-shaken out of the module graph, and the
|
||||||
|
* fixtures are not in the emitted bundle AT ALL — not lazily, not behind a flag.
|
||||||
|
* `vite.config.ts` fails the build if that ever stops being true.
|
||||||
|
*
|
||||||
|
* This is deliberately stronger than "hide the mock behind a query flag". The
|
||||||
|
* flag was the bug: `?dev` on a production URL rendered an invented healthy
|
||||||
|
* router — 119 of 122 nodes alive, "Protected" — with no request made and one
|
||||||
|
* line of small print in the footer to say so. A person cannot audit a bundle;
|
||||||
|
* the only honest guarantee is that the invented data is not in it.
|
||||||
|
*/
|
||||||
|
export async function initMockBackend(): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV && mockRequested()) {
|
||||||
|
fixtures = await import('./mock')
|
||||||
|
MOCK = true
|
||||||
|
}
|
||||||
|
return MOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does the URL ask for the offline fixture backend (`?mock` or `?dev`)? */
|
||||||
|
function mockRequested(): boolean {
|
||||||
if (typeof location === 'undefined') return false
|
if (typeof location === 'undefined') return false
|
||||||
const q = new URLSearchParams(location.search)
|
const q = new URLSearchParams(location.search)
|
||||||
return q.has('mock') || q.has('dev')
|
return q.has('mock') || q.has('dev')
|
||||||
})()
|
}
|
||||||
|
|
||||||
|
/** The fixture backend, for the `MOCK ? … : …` branches below. Throws rather
|
||||||
|
* than inventing data if it is ever reached without having been loaded. */
|
||||||
|
function mock(): NonNullable<typeof fixtures> {
|
||||||
|
if (!fixtures) throw new Error('mock backend not loaded — call initMockBackend() first')
|
||||||
|
return fixtures
|
||||||
|
}
|
||||||
|
|
||||||
/** A decoded response plus the raw Headers, for endpoints whose contract puts
|
/** A decoded response plus the raw Headers, for endpoints whose contract puts
|
||||||
* pagination metadata outside the JSON body (see the stats log endpoints). */
|
* pagination metadata outside the JSON body (see the stats log endpoints). */
|
||||||
@@ -1006,33 +1225,54 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
// --- endpoints --------------------------------------------------------------
|
// --- endpoints --------------------------------------------------------------
|
||||||
|
|
||||||
export function getStatus(): Promise<Status> {
|
export function getStatus(): Promise<Status> {
|
||||||
return MOCK ? mock.getStatus() : req<Status>('api/status')
|
return MOCK ? mock().getStatus() : req<Status>('api/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfig(): Promise<Model> {
|
export async function getConfig(): Promise<Model> {
|
||||||
return MOCK ? mock.getConfig() : req<Model>('api/config')
|
const m = await (MOCK ? mock().getConfig() : req<Model>('api/config'))
|
||||||
|
// Every page reads the config, and the commit-confirm window's length is the
|
||||||
|
// only thing needed to arm a countdown — so it is captured here once instead of
|
||||||
|
// being threaded through eight pages. See pendingConfirm.ts.
|
||||||
|
noteConfirmTimeout(m.Globals?.ConfirmTimeout)
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }> {
|
export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.putConfig(m)
|
? mock().putConfig(m)
|
||||||
: req('api/config', { method: 'PUT', body: JSON.stringify(m) })
|
: req('api/config', { method: 'PUT', body: JSON.stringify(m) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apply(): Promise<ApplyResult> {
|
/**
|
||||||
return MOCK ? mock.apply() : req<ApplyResult>('api/apply', { method: 'POST' })
|
* POST /api/apply.
|
||||||
|
*
|
||||||
|
* The daemon arms an auto-rollback on EVERY successful apply that changed
|
||||||
|
* something (panel/api.go handleApply → ArmRollback), whichever page's button was
|
||||||
|
* pressed. Recording it here — the one place every one of those buttons goes
|
||||||
|
* through — is what lets the countdown and the "Keep this config" control follow
|
||||||
|
* the operator around the panel instead of living in the Apply page's local
|
||||||
|
* state. See pendingConfirm.ts.
|
||||||
|
*/
|
||||||
|
export async function apply(): Promise<ApplyResult> {
|
||||||
|
const r = await (MOCK ? mock().apply() : req<ApplyResult>('api/apply', { method: 'POST' }))
|
||||||
|
if (!r.error && r.changed) armPendingConfirm()
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirm(): Promise<ApplyResult> {
|
export async function confirm(): Promise<ApplyResult> {
|
||||||
return MOCK ? mock.confirm() : req<ApplyResult>('api/confirm', { method: 'POST' })
|
const r = await (MOCK ? mock().confirm() : req<ApplyResult>('api/confirm', { method: 'POST' }))
|
||||||
|
if (!r.error) clearPendingConfirm()
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rollback(): Promise<ApplyResult> {
|
export async function rollback(): Promise<ApplyResult> {
|
||||||
return MOCK ? mock.rollback() : req<ApplyResult>('api/rollback', { method: 'POST' })
|
const r = await (MOCK ? mock().rollback() : req<ApplyResult>('api/rollback', { method: 'POST' }))
|
||||||
|
if (!r.error) clearPendingConfirm()
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStats(): Promise<Stats> {
|
export function getStats(): Promise<Stats> {
|
||||||
return MOCK ? mock.getStats() : req<Stats>('api/stats')
|
return MOCK ? mock().getStats() : req<Stats>('api/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- daemon log download ------------------------------------------------------
|
// --- daemon log download ------------------------------------------------------
|
||||||
@@ -1084,7 +1324,7 @@ function saveBlob(blob: Blob, filename: string): void {
|
|||||||
*/
|
*/
|
||||||
export async function downloadLog(range: LogRange): Promise<void> {
|
export async function downloadLog(range: LogRange): Promise<void> {
|
||||||
if (MOCK) {
|
if (MOCK) {
|
||||||
saveBlob(new Blob([mock.getLogText(range)], { type: 'text/plain' }), `shater-log-${range}.txt`)
|
saveBlob(new Blob([mock().getLogText(range)], { type: 'text/plain' }), `shater-log-${range}.txt`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let res: Response
|
let res: Response
|
||||||
@@ -1163,14 +1403,14 @@ function logPage<T>(env: { body: T[] | null; headers: Headers }): StatsLogPage<T
|
|||||||
/** GET /api/stats/log — one page of the DNS query log with its cursor metadata. */
|
/** GET /api/stats/log — one page of the DNS query log with its cursor metadata. */
|
||||||
export function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> {
|
export function getStatsLogPage(q: StatsLogQuery = {}): Promise<StatsLogPage<QueryLogEntry>> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.getStatsLogPage(q)
|
? mock().getStatsLogPage(q)
|
||||||
: reqFull<QueryLogEntry[] | null>(`api/stats/log${statsLogQS(q)}`).then(logPage)
|
: reqFull<QueryLogEntry[] | null>(`api/stats/log${statsLogQS(q)}`).then(logPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/stats/conns — one page of the connection log with its cursor metadata. */
|
/** GET /api/stats/conns — one page of the connection log with its cursor metadata. */
|
||||||
export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> {
|
export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<ConnLogEntry>> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.getStatsConnsPage(q)
|
? mock().getStatsConnsPage(q)
|
||||||
: reqFull<ConnLogEntry[] | null>(`api/stats/conns${statsLogQS(q)}`).then(logPage)
|
: reqFull<ConnLogEntry[] | null>(`api/stats/conns${statsLogQS(q)}`).then(logPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,18 +1418,73 @@ export function getStatsConnsPage(q: StatsLogQuery = {}): Promise<StatsLogPage<C
|
|||||||
* Rows only; callers that tail the stream want {@link getStatsLogPage} instead. */
|
* Rows only; callers that tail the stream want {@link getStatsLogPage} instead. */
|
||||||
export function getStatsLog(q: number | StatsLogQuery = {}): Promise<QueryLogEntry[]> {
|
export function getStatsLog(q: number | StatsLogQuery = {}): Promise<QueryLogEntry[]> {
|
||||||
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
|
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
|
||||||
return MOCK ? mock.getStatsLog(o) : req<QueryLogEntry[]>(`api/stats/log${statsLogQS(o)}`)
|
return MOCK ? mock().getStatsLog(o) : req<QueryLogEntry[]>(`api/stats/log${statsLogQS(o)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/stats/conns — the live connection-event log (device→dest), newest first. */
|
/** GET /api/stats/conns — the live connection-event log (device→dest), newest first. */
|
||||||
export function getStatsConns(q: number | StatsLogQuery = {}): Promise<ConnLogEntry[]> {
|
export function getStatsConns(q: number | StatsLogQuery = {}): Promise<ConnLogEntry[]> {
|
||||||
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
|
const o: StatsLogQuery = typeof q === 'number' ? { limit: q } : q
|
||||||
return MOCK ? mock.getStatsConns(o) : req<ConnLogEntry[]>(`api/stats/conns${statsLogQS(o)}`)
|
return MOCK ? mock().getStatsConns(o) : req<ConnLogEntry[]>(`api/stats/conns${statsLogQS(o)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One routing rule's reachability verdict — the rule analogue of
|
||||||
|
* {@link ChainHealth}.used: a quiet note about the ROUTING CONFIG, never a health
|
||||||
|
* signal.
|
||||||
|
*
|
||||||
|
* `unreachable` means the rule can NEVER take effect, whatever the traffic. Today
|
||||||
|
* the daemon reports exactly one certain case, and it is a subtle one: a rule with
|
||||||
|
* no conditions at all is not matched in sequence — it becomes the router's
|
||||||
|
* default. Two such rules therefore retire each other, and the LAST one by Order
|
||||||
|
* wins, so an earlier "default → direct" is dead even though it sorts first. A
|
||||||
|
* condition-less rule never retires a rule that HAS conditions: those are matched
|
||||||
|
* ahead of the default whatever their Order.
|
||||||
|
*
|
||||||
|
* `index` is the rule's position in GET /api/config's `Rules`, which is how a
|
||||||
|
* verdict is matched to a row — rule names are not unique, and the config that
|
||||||
|
* prompted this had two rules both called `default`. `name`/`order` are echoed so
|
||||||
|
* a page holding a verdict fetched before an edit can check it still describes the
|
||||||
|
* row it is about to badge, and drop it silently otherwise.
|
||||||
|
*/
|
||||||
|
export interface RuleReach {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
order: number
|
||||||
|
unreachable: boolean
|
||||||
|
/** The rule that supersedes this one; absent when `unreachable` is false. */
|
||||||
|
shadowed_by?: string
|
||||||
|
/** Its index in `Rules`, or -1 when there is none. */
|
||||||
|
shadowed_by_index: number
|
||||||
|
shadowed_by_order?: number
|
||||||
|
/** Operator-facing sentence; absent when `unreachable` is false. */
|
||||||
|
reason?: string
|
||||||
|
/**
|
||||||
|
* Whether the rule is IN FORCE right now — `Rule.Enabled` after the active WAN
|
||||||
|
* profile's overrides. This is NOT `GET /api/config`'s `Enabled`: that one is
|
||||||
|
* the desired state the page PUTs back, and on a router with profiles the two
|
||||||
|
* legitimately disagree. Draw rows from this; keep the switch on the other.
|
||||||
|
*/
|
||||||
|
effective_enabled: boolean
|
||||||
|
/** The active profile that CHANGED this rule's state; absent when none did. */
|
||||||
|
overridden_by?: string
|
||||||
|
/** Which way it went. Absent together with `overridden_by`. */
|
||||||
|
override?: 'enabled' | 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/rules/reachability. `rules` is ALWAYS an array, one entry per rule in
|
||||||
|
* the same order as GET /api/config's `Rules`. */
|
||||||
|
export interface RulesReachability {
|
||||||
|
rules: RuleReach[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/rules/reachability — which routing rules can never fire, and why. */
|
||||||
|
export function getRulesReachability(): Promise<RulesReachability> {
|
||||||
|
return MOCK ? mock().getRulesReachability() : req<RulesReachability>('api/rules/reachability')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */
|
/** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */
|
||||||
export function getRulesetStatus(): Promise<RulesetStatus[]> {
|
export function getRulesetStatus(): Promise<RulesetStatus[]> {
|
||||||
return MOCK ? mock.getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status')
|
return MOCK ? mock().getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1200,7 +1495,7 @@ export function getRulesetStatus(): Promise<RulesetStatus[]> {
|
|||||||
*/
|
*/
|
||||||
export function updateRuleset(tag: string): Promise<RulesetStatus | { ok: boolean }> {
|
export function updateRuleset(tag: string): Promise<RulesetStatus | { ok: boolean }> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.updateRuleset(tag)
|
? mock().updateRuleset(tag)
|
||||||
: req('api/ruleset/update', { method: 'POST', body: JSON.stringify({ tag }) })
|
: req('api/ruleset/update', { method: 'POST', body: JSON.stringify({ tag }) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,7 +1523,7 @@ export interface RulesetCheck {
|
|||||||
*/
|
*/
|
||||||
export function checkRulesetCategory(source: string, category: string): Promise<RulesetCheck> {
|
export function checkRulesetCategory(source: string, category: string): Promise<RulesetCheck> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.checkRulesetCategory(source, category)
|
? mock().checkRulesetCategory(source, category)
|
||||||
: req<RulesetCheck>('api/ruleset/check', {
|
: req<RulesetCheck>('api/ruleset/check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ source, category }),
|
body: JSON.stringify({ source, category }),
|
||||||
@@ -1257,18 +1552,18 @@ export interface RulesetCategories {
|
|||||||
*/
|
*/
|
||||||
export function getRulesetCategories(source: string): Promise<RulesetCategories> {
|
export function getRulesetCategories(source: string): Promise<RulesetCategories> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.getRulesetCategories(source)
|
? mock().getRulesetCategories(source)
|
||||||
: req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`)
|
: req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/devices — discovered LAN clients merged with per-device config. */
|
/** GET /api/devices — discovered LAN clients merged with per-device config. */
|
||||||
export function getDevices(): Promise<DiscoveredDevice[]> {
|
export function getDevices(): Promise<DiscoveredDevice[]> {
|
||||||
return MOCK ? mock.getDevices() : req<DiscoveredDevice[]>('api/devices')
|
return MOCK ? mock().getDevices() : req<DiscoveredDevice[]>('api/devices')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/interfaces — the router's UCI network interfaces for the egress picker. */
|
/** GET /api/interfaces — the router's UCI network interfaces for the egress picker. */
|
||||||
export function getInterfaces(): Promise<Interface[]> {
|
export function getInterfaces(): Promise<Interface[]> {
|
||||||
return MOCK ? mock.getInterfaces() : req<Interface[]>('api/interfaces')
|
return MOCK ? mock().getInterfaces() : req<Interface[]>('api/interfaces')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/session — exchange a single-use handoff token for a session cookie. */
|
/** POST /api/session — exchange a single-use handoff token for a session cookie. */
|
||||||
@@ -1304,7 +1599,7 @@ export function importWg(conf: string): Promise<{ uri: string; name: string }> {
|
|||||||
*/
|
*/
|
||||||
export function updateSubscription(name: string): Promise<{ added: number }> {
|
export function updateSubscription(name: string): Promise<{ added: number }> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.updateSubscription(name)
|
? mock().updateSubscription(name)
|
||||||
: req('api/subscription/update', { method: 'POST', body: JSON.stringify({ name }) })
|
: req('api/subscription/update', { method: 'POST', body: JSON.stringify({ name }) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,7 +1619,7 @@ export function updateSubscription(name: string): Promise<{ added: number }> {
|
|||||||
export function getGroupsHealth(
|
export function getGroupsHealth(
|
||||||
opts: { group?: string; members?: boolean } = {},
|
opts: { group?: string; members?: boolean } = {},
|
||||||
): Promise<GroupsHealth> {
|
): Promise<GroupsHealth> {
|
||||||
if (MOCK) return mock.getGroupsHealth(opts)
|
if (MOCK) return mock().getGroupsHealth(opts)
|
||||||
const p = new URLSearchParams()
|
const p = new URLSearchParams()
|
||||||
if (opts.group) p.set('group', opts.group)
|
if (opts.group) p.set('group', opts.group)
|
||||||
if (opts.members) p.set('members', '1')
|
if (opts.members) p.set('members', '1')
|
||||||
@@ -1333,8 +1628,21 @@ export function getGroupsHealth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One group's (or chain's) last test: which member the balancer picked, how fast
|
* What the OBSERVATORY measured for one group or chain — not a dial the panel
|
||||||
* it answered, and what the internet saw as the source address.
|
* made.
|
||||||
|
*
|
||||||
|
* This shape used to come from a fresh connection opened on demand, straight at
|
||||||
|
* the target. That was a lie on any router whose proxies are blocked when dialled
|
||||||
|
* directly and work only as a hop behind a tunnel: the card reported dead for a
|
||||||
|
* path that carries traffic all day. The daemon now has exactly one thing that
|
||||||
|
* measures — the background observatory, which probes along the REAL dial path,
|
||||||
|
* per-hop copies and all — and this endpoint reports what it found. There is no
|
||||||
|
* second measurement anywhere, and the panel never opens a connection of its own.
|
||||||
|
*
|
||||||
|
* So read the fields as a READ, not as a test run: `ok` and `delay_ms` are the
|
||||||
|
* observatory's verdict for the path traffic actually takes, and `tested_unix`
|
||||||
|
* (router clock, seconds) is when the OBSERVATORY took that measurement — which
|
||||||
|
* can be a few seconds before the refresh was asked for.
|
||||||
*
|
*
|
||||||
* `ok:true` with an EMPTY `exit_ip`/`exit_country` is a valid, successful result,
|
* `ok:true` with an EMPTY `exit_ip`/`exit_country` is a valid, successful result,
|
||||||
* not a partial failure: the delay was measured but the exit address could not be
|
* not a partial failure: the delay was measured but the exit address could not be
|
||||||
@@ -1343,10 +1651,23 @@ export function getGroupsHealth(
|
|||||||
*
|
*
|
||||||
* Chains ride the same endpoint. For a chain row, `group` carries the CHAIN's
|
* Chains ride the same endpoint. For a chain row, `group` carries the CHAIN's
|
||||||
* name and `selected` the node its last group hop picked ('' when the exit hop
|
* name and `selected` the node its last group hop picked ('' when the exit hop
|
||||||
* isn't a group). Everything else reads the same way.
|
* isn't a group). Per-hop detail is a different read: {@link ChainHopHealth}.
|
||||||
*
|
*
|
||||||
* `ok:false` ⇒ the test failed and `error` carries the human reason; every other
|
* `ok:false` ⇒ there is no usable measurement and `error` carries the human
|
||||||
* field is meaningless. `tested_unix` is the router's clock, in seconds.
|
* reason; every other field is meaningless. Four of those reasons are about the
|
||||||
|
* observatory rather than the path, and must not be rendered as "your target is
|
||||||
|
* broken":
|
||||||
|
*
|
||||||
|
* "not routed by any enabled rule, so nothing measures it — the observatory
|
||||||
|
* only probes paths the rules use"
|
||||||
|
* "the observatory has not reached this target yet — it refreshes on the
|
||||||
|
* global probe interval"
|
||||||
|
* "background probing is disabled, so there is nothing to measure this target
|
||||||
|
* with"
|
||||||
|
* "the observatory's probe through this path failed"
|
||||||
|
*
|
||||||
|
* Only the last one is a health finding. The first three say the measurement
|
||||||
|
* does not exist, which is a different thing and a different fix.
|
||||||
*/
|
*/
|
||||||
export interface GroupTestResult {
|
export interface GroupTestResult {
|
||||||
group: string // group name — or a chain name for a chain row
|
group: string // group name — or a chain name for a chain row
|
||||||
@@ -1363,7 +1684,11 @@ export interface GroupTestResult {
|
|||||||
* GET /api/groups/test — progress plus every result so far. `results` is ALWAYS
|
* GET /api/groups/test — progress plus every result so far. `results` is ALWAYS
|
||||||
* an array (never null); `done`/`total` count finished vs targeted groups and
|
* an array (never null); `done`/`total` count finished vs targeted groups and
|
||||||
* chains while `running` is true. Idle reads `{running:false}` with the last
|
* chains while `running` is true. Idle reads `{running:false}` with the last
|
||||||
* run's results still attached, so a reload after a test still shows what it found.
|
* run's results still attached, so a reload still shows what was last read.
|
||||||
|
*
|
||||||
|
* "Running" means the observatory is working through an out-of-turn refresh pass
|
||||||
|
* over the named targets and this endpoint is collecting what it measures. It is
|
||||||
|
* not the panel dialling anything.
|
||||||
*/
|
*/
|
||||||
export interface GroupTestStatus {
|
export interface GroupTestStatus {
|
||||||
running: boolean
|
running: boolean
|
||||||
@@ -1396,18 +1721,24 @@ export interface GroupTestStart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/groups/test — measure a target's delay and exit address. Pass a
|
* POST /api/groups/test — ask the observatory for an out-of-turn refresh pass,
|
||||||
* group or chain name to test one; pass nothing (or '') to test every group
|
* then report what it measured. Pass a group or chain name to refresh one; pass
|
||||||
* and every chain. Singleton: a second call while a run is in flight resolves
|
* nothing (or '') for every group and every chain.
|
||||||
* to `{started:false, reason:'already running'}` rather than failing.
|
*
|
||||||
|
* It does NOT dial. The observatory is the only thing in the daemon that
|
||||||
|
* measures anything, and it measures along the real dial path — so this is the
|
||||||
|
* "don't wait for the next probe interval" button, not a second opinion. The
|
||||||
|
* numbers it returns are the same numbers the cards are already showing, just
|
||||||
|
* fresher. Singleton: a second call while a pass is in flight resolves to
|
||||||
|
* `{started:false, reason:'already running'}` rather than failing.
|
||||||
*/
|
*/
|
||||||
export function postGroupsTest(name = ''): Promise<GroupTestStart> {
|
export function postGroupsTest(name = ''): Promise<GroupTestStart> {
|
||||||
return MOCK
|
return MOCK
|
||||||
? mock.postGroupsTest(name)
|
? mock().postGroupsTest(name)
|
||||||
: req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) })
|
: req<GroupTestStart>('api/groups/test', { method: 'POST', body: JSON.stringify({ name }) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/groups/test — progress + results of the current/last group test. */
|
/** GET /api/groups/test — progress + results of the current/last group test. */
|
||||||
export function getGroupsTest(): Promise<GroupTestStatus> {
|
export function getGroupsTest(): Promise<GroupTestStatus> {
|
||||||
return MOCK ? mock.getGroupsTest() : req<GroupTestStatus>('api/groups/test')
|
return MOCK ? mock().getGroupsTest() : req<GroupTestStatus>('api/groups/test')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
@@ -30,3 +31,16 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
filter: brightness(1.05);
|
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 './Button.css'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
import type { ButtonHTMLAttributes } from 'react'
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type={type ?? 'button'}
|
type={type ?? 'button'}
|
||||||
className={['btn', variant === 'primary' ? 'primary' : '', className]
|
className={['btn', variant === 'ghost' ? '' : variant, className].filter(Boolean).join(' ')}
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 {
|
function format(d: Date): string {
|
||||||
const p = (n: number) => String(n).padStart(2, '0')
|
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 }) {
|
export function Clock({ className }: { className?: string }) {
|
||||||
const [now, setNow] = useState(() => format(new Date()))
|
const [now, setNow] = useState(() => format(new Date()))
|
||||||
|
|
||||||
@@ -14,5 +52,13 @@ export function Clock({ className }: { className?: string }) {
|
|||||||
return () => window.clearInterval(id)
|
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
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export { Button } from './Button'
|
|||||||
export type { ButtonProps } from './Button'
|
export type { ButtonProps } from './Button'
|
||||||
export { Select } from './Select'
|
export { Select } from './Select'
|
||||||
export type { SelectProps, SelectOption } from './Select'
|
export type { SelectProps, SelectOption } from './Select'
|
||||||
|
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog'
|
||||||
|
export type { ConfirmDialogProps, ConfirmOptions, ConfirmTone } from './ConfirmDialog'
|
||||||
export { Clock } from './Clock'
|
export { Clock } from './Clock'
|
||||||
export { CatSuggest } from './CatSuggest'
|
export { CatSuggest } from './CatSuggest'
|
||||||
export { SrcPicker } from './SrcPicker'
|
export { SrcPicker } from './SrcPicker'
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// findings.ts — which apply-time finding is shown where.
|
||||||
|
//
|
||||||
|
// Run with `npm test` (node's built-in test runner + native TypeScript
|
||||||
|
// stripping; no test dependency is added to the SPA, which ships inside the
|
||||||
|
// daemon binary).
|
||||||
|
//
|
||||||
|
// Two defects are pinned here.
|
||||||
|
//
|
||||||
|
// 1. THE TRUNCATION NOTE WAS UNREACHABLE. The daemon caps Status.warnings at 50
|
||||||
|
// and overwrites the last slot with an `info` note counting what it dropped.
|
||||||
|
// Overview filtered `info` away wholesale, and the settings-page route keys on
|
||||||
|
// a section (`generate`) that no page owns — so the single line telling the
|
||||||
|
// operator "you are not seeing all of it" reached no screen at all.
|
||||||
|
//
|
||||||
|
// 2. FINDINGS ABOUT AN ENTITY NEVER REACHED THAT ENTITY'S PAGE. The generator
|
||||||
|
// drops a node it cannot build and names it; the Nodes page rendered that node
|
||||||
|
// as an ordinary row with a green toggle, because it never read the findings.
|
||||||
|
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
attentionFindings,
|
||||||
|
entityFindings,
|
||||||
|
findingsByName,
|
||||||
|
sectionNotes,
|
||||||
|
truncationNote,
|
||||||
|
worstSeverity,
|
||||||
|
} from './findings.ts'
|
||||||
|
import type { StatusWarning } from './api.ts'
|
||||||
|
|
||||||
|
const crit = (section: string, name: string, message = 'broken'): StatusWarning => ({
|
||||||
|
severity: 'critical',
|
||||||
|
section,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
const warn = (section: string, name: string, message = 'degraded'): StatusWarning => ({
|
||||||
|
severity: 'warning',
|
||||||
|
section,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
const info = (section: string, name: string, message: string): StatusWarning => ({
|
||||||
|
severity: 'info',
|
||||||
|
section,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Verbatim from apply/warnings.go finalizeWarnings. */
|
||||||
|
const SUPPRESSED = info(
|
||||||
|
'generate',
|
||||||
|
'',
|
||||||
|
'7 further warning(s) suppressed; run `logread -e shater` for the full list',
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- the truncation note ----------------------------------------------------
|
||||||
|
|
||||||
|
test('the truncation note is found, whatever else is in the list', () => {
|
||||||
|
const note = truncationNote([crit('rule', 'a'), warn('node', 'b'), SUPPRESSED])
|
||||||
|
assert.notEqual(note, null)
|
||||||
|
assert.match(note!.message, /7 further warning/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a whole list has no truncation note', () => {
|
||||||
|
assert.equal(truncationNote([crit('rule', 'a'), warn('node', 'b')]), null)
|
||||||
|
assert.equal(truncationNote([]), null)
|
||||||
|
assert.equal(truncationNote(undefined), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an ordinary info note is not mistaken for the truncation note', () => {
|
||||||
|
const notes = [info('untunnelable', 'block', 'Ping and traceroute do not work…')]
|
||||||
|
assert.equal(truncationNote(notes), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the truncation note is kept out of the settings-page notes it would pollute', () => {
|
||||||
|
const all = [info('generate', '', 'cache: moved to /overlay'), SUPPRESSED]
|
||||||
|
const notes = sectionNotes(all, 'generate')
|
||||||
|
assert.equal(notes.length, 1)
|
||||||
|
assert.match(notes[0].message, /cache:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the attention list still carries only critical and warning', () => {
|
||||||
|
const all = [crit('rule', 'a'), warn('node', 'b'), info('untunnelable', 'block', 'x'), SUPPRESSED]
|
||||||
|
const attention = attentionFindings(all)
|
||||||
|
assert.equal(attention.length, 2)
|
||||||
|
assert.ok(attention.every((w) => w.severity !== 'info'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- per-entity findings ----------------------------------------------------
|
||||||
|
|
||||||
|
test('a page takes only the sections it owns', () => {
|
||||||
|
const all = [
|
||||||
|
crit('node', 'tokyo-01', 'parse share-link: bad scheme (skipped)'),
|
||||||
|
warn('subscription', 'qomar', 'fetch failed'),
|
||||||
|
crit('rule', 'default', 'never applies'),
|
||||||
|
info('generate', '', 'cache: x'),
|
||||||
|
]
|
||||||
|
const mine = entityFindings(all, ['node', 'subscription'])
|
||||||
|
assert.deepEqual(
|
||||||
|
mine.map((w) => w.name),
|
||||||
|
['tokyo-01', 'qomar'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entity findings never include info notes', () => {
|
||||||
|
const all = [info('node', 'tokyo-01', 'just a note'), SUPPRESSED]
|
||||||
|
assert.equal(entityFindings(all, ['node', 'generate']).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findings index by name, and global (unnamed) ones are left out', () => {
|
||||||
|
const all = [
|
||||||
|
crit('node', 'tokyo-01', 'first'),
|
||||||
|
warn('node', 'tokyo-01', 'second'),
|
||||||
|
crit('node', '', 'global to the section'),
|
||||||
|
]
|
||||||
|
const byName = findingsByName(entityFindings(all, ['node']))
|
||||||
|
assert.equal(byName.size, 1)
|
||||||
|
assert.equal(byName.get('tokyo-01')!.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one lamp per row takes the loudest severity', () => {
|
||||||
|
assert.equal(worstSeverity([warn('node', 'a'), crit('node', 'a')]), 'critical')
|
||||||
|
assert.equal(worstSeverity([warn('node', 'a')]), 'warning')
|
||||||
|
assert.equal(worstSeverity([]), null)
|
||||||
|
})
|
||||||
+91
-2
@@ -6,7 +6,8 @@
|
|||||||
//
|
//
|
||||||
// critical / warning — something needs attention: a protection promise is
|
// critical / warning — something needs attention: a protection promise is
|
||||||
// broken, or something you configured isn't in effect. These belong on
|
// broken, or something you configured isn't in effect. These belong on
|
||||||
// Overview, where the operator looks first.
|
// Overview, where the operator looks first — and, when they name an entity,
|
||||||
|
// ALSO on the page that owns that entity (see `entityFindings`).
|
||||||
//
|
//
|
||||||
// info — a statement ABOUT the configuration, not a problem. It never clears,
|
// info — a statement ABOUT the configuration, not a problem. It never clears,
|
||||||
// because nothing is wrong: it is simply describing a choice that was made.
|
// because nothing is wrong: it is simply describing a choice that was made.
|
||||||
@@ -16,9 +17,49 @@
|
|||||||
// page that never goes away and never asks for anything trains people to skim
|
// page that never goes away and never asks for anything trains people to skim
|
||||||
// the list — which is exactly how a real critical finding gets missed. Anything
|
// the list — which is exactly how a real critical finding gets missed. Anything
|
||||||
// standing in the findings list should be something you could act on.
|
// standing in the findings list should be something you could act on.
|
||||||
|
//
|
||||||
|
// The one exception is carved out below: the daemon's own note that it dropped
|
||||||
|
// findings to fit the cap. It is `info` by severity and unactionable by nature,
|
||||||
|
// and it is the single most important line in the list, because it is the list
|
||||||
|
// telling you it is not the whole list.
|
||||||
|
|
||||||
import type { StatusWarning } from './api'
|
import type { StatusWarning } from './api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon's truncation disclosure, verbatim from apply/warnings.go
|
||||||
|
* finalizeWarnings:
|
||||||
|
*
|
||||||
|
* "%d further warning(s) suppressed; run `logread -e shater` for the full list"
|
||||||
|
*
|
||||||
|
* Matched on the stable clause rather than the whole sentence so a reworded tail
|
||||||
|
* still registers. If this ever stops matching, the failure mode is a list that
|
||||||
|
* silently claims to be complete — which is why `truncationNote` is tested.
|
||||||
|
*/
|
||||||
|
const SUPPRESSED_RE = /further warning\(s\) suppressed/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon's "this list is incomplete" note, or null when the list is whole.
|
||||||
|
*
|
||||||
|
* Status.warnings is capped at 50, sorted critical-first, and the last slot is
|
||||||
|
* REPLACED by an `info` note counting what was dropped. That note therefore
|
||||||
|
* arrives on the one channel the panel filtered away wholesale: `info` never
|
||||||
|
* reached Overview, and the settings-page route (`sectionNotes`) keys on
|
||||||
|
* section `generate`, which no page owns. So the single line saying "there are
|
||||||
|
* findings you are not being shown" was the only one guaranteed to be invisible.
|
||||||
|
*
|
||||||
|
* Callers must render this WITH the attention list, not instead of it.
|
||||||
|
*/
|
||||||
|
export function truncationNote(warnings: StatusWarning[] | undefined): StatusWarning | null {
|
||||||
|
return (
|
||||||
|
(warnings ?? []).find((w) => w.severity === 'info' && SUPPRESSED_RE.test(w.message)) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this the truncation disclosure rather than an ordinary note? */
|
||||||
|
function isTruncationNote(w: StatusWarning): boolean {
|
||||||
|
return w.severity === 'info' && SUPPRESSED_RE.test(w.message)
|
||||||
|
}
|
||||||
|
|
||||||
/** Findings that need attention — the Overview list. Info notes are excluded. */
|
/** Findings that need attention — the Overview list. Info notes are excluded. */
|
||||||
export function attentionFindings(warnings: StatusWarning[] | undefined): StatusWarning[] {
|
export function attentionFindings(warnings: StatusWarning[] | undefined): StatusWarning[] {
|
||||||
return (warnings ?? []).filter((w) => w.severity === 'critical' || w.severity === 'warning')
|
return (warnings ?? []).filter((w) => w.severity === 'critical' || w.severity === 'warning')
|
||||||
@@ -29,10 +70,58 @@ export function attentionFindings(warnings: StatusWarning[] | undefined): Status
|
|||||||
* (e.g. `untunnelable` → the Networks page's "Other traffic" section). Only info:
|
* (e.g. `untunnelable` → the Networks page's "Other traffic" section). Only info:
|
||||||
* a critical/warning is an attention item and stays on Overview, so it can't be
|
* a critical/warning is an attention item and stays on Overview, so it can't be
|
||||||
* quietly buried on a settings page instead.
|
* quietly buried on a settings page instead.
|
||||||
|
*
|
||||||
|
* The truncation note is excluded: it is about the LIST, not about any section,
|
||||||
|
* and it has its own home beside the list ({@link truncationNote}).
|
||||||
*/
|
*/
|
||||||
export function sectionNotes(
|
export function sectionNotes(
|
||||||
warnings: StatusWarning[] | undefined,
|
warnings: StatusWarning[] | undefined,
|
||||||
section: string,
|
section: string,
|
||||||
): StatusWarning[] {
|
): StatusWarning[] {
|
||||||
return (warnings ?? []).filter((w) => w.severity === 'info' && w.section === section)
|
return (warnings ?? []).filter(
|
||||||
|
(w) => w.severity === 'info' && w.section === section && !isTruncationNote(w),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attention findings about entities ONE page owns — for that page to show
|
||||||
|
* beside the entities themselves.
|
||||||
|
*
|
||||||
|
* Overview is where you look when you already suspect something; a page like
|
||||||
|
* Nodes is where you look when you don't. The generator drops a node it cannot
|
||||||
|
* build — an unparseable share link, a WireGuard key materialised twice — and
|
||||||
|
* says so by name ("node \"x\": parse share-link: … (skipped)"), yet that node
|
||||||
|
* kept rendering as an ordinary row with a green toggle, because the page never
|
||||||
|
* read the findings at all. The switch says on; the engine has no such outbound.
|
||||||
|
*
|
||||||
|
* This does NOT move anything off Overview: the same finding appears in both
|
||||||
|
* places, which is correct — one list is "what is wrong with this router", the
|
||||||
|
* other is "what is wrong with this node".
|
||||||
|
*/
|
||||||
|
export function entityFindings(
|
||||||
|
warnings: StatusWarning[] | undefined,
|
||||||
|
sections: readonly string[],
|
||||||
|
): StatusWarning[] {
|
||||||
|
const want = new Set(sections)
|
||||||
|
return attentionFindings(warnings).filter((w) => want.has(w.section))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index attention findings by entity name, for badging a row directly. Entries
|
||||||
|
* with an empty `name` are global to their section and are left out. */
|
||||||
|
export function findingsByName(findings: StatusWarning[]): Map<string, StatusWarning[]> {
|
||||||
|
const out = new Map<string, StatusWarning[]>()
|
||||||
|
for (const f of findings) {
|
||||||
|
if (!f.name) continue
|
||||||
|
const list = out.get(f.name)
|
||||||
|
if (list) list.push(f)
|
||||||
|
else out.set(f.name, [f])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loudest severity in a set — for a row badge that has room for one lamp. */
|
||||||
|
export function worstSeverity(findings: StatusWarning[]): 'critical' | 'warning' | null {
|
||||||
|
if (findings.some((f) => f.severity === 'critical')) return 'critical'
|
||||||
|
if (findings.length > 0) return 'warning'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,39 @@ export function fmtDateTime(unix: number): string {
|
|||||||
return d && t ? `${d}, ${t}` : d || t
|
return d && t ? `${d}, ${t}` : d || t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- remote-list freshness ---------------------------------------------------
|
||||||
|
// A url/geo-sourced list re-fetches on a cadence and the engine reports when it
|
||||||
|
// last pulled (GET /api/ruleset/status). Routing shows this for rule-sets and DNS
|
||||||
|
// shows it for blocklists, so the two readings live here and cannot drift apart.
|
||||||
|
|
||||||
|
/** "updated 3h ago" / "never updated" for a remote list's last fetch (RFC3339). */
|
||||||
|
export function relFetch(iso: string): string {
|
||||||
|
if (!iso) return 'never updated'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return 'never updated'
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - t) / 1000))
|
||||||
|
if (s < 45) return 'updated just now'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m < 60) return `updated ${m}m ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `updated ${h}h ago`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
return `updated ${d}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "every 24h" for an auto-update cadence in seconds ("" when there is none). */
|
||||||
|
export function everyLabel(sec: number): string {
|
||||||
|
if (!sec || sec <= 0) return ''
|
||||||
|
if (sec % 3600 === 0) {
|
||||||
|
const h = sec / 3600
|
||||||
|
if (h < 48) return `every ${h}h`
|
||||||
|
if (sec % 86400 === 0) return `every ${sec / 86400}d`
|
||||||
|
return `every ${h}h`
|
||||||
|
}
|
||||||
|
if (sec % 60 === 0) return `every ${sec / 60}m`
|
||||||
|
return `every ${sec}s`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coarse "how long until / since" reading for a unix deadline, relative to now.
|
* A coarse "how long until / since" reading for a unix deadline, relative to now.
|
||||||
*
|
*
|
||||||
|
|||||||
+26
-5
@@ -2,12 +2,33 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './tokens.css'
|
import './tokens.css'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { initMockBackend } from './api'
|
||||||
|
import { ConfirmProvider } from './components'
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) throw new Error('#root not found')
|
if (!rootEl) throw new Error('#root not found')
|
||||||
|
|
||||||
createRoot(rootEl).render(
|
// Settle the fixture question BEFORE the first render: pages read `MOCK` while
|
||||||
<StrictMode>
|
// they render, so a backend that arrives afterwards would paint half a screen
|
||||||
<App />
|
// from the daemon and half from fixtures. In a production build this resolves
|
||||||
</StrictMode>,
|
// immediately and to `false` — the fixtures are not in the bundle to load (see
|
||||||
)
|
// api.ts initMockBackend and the assertNoMockFixtures plugin in vite.config.ts).
|
||||||
|
function mount() {
|
||||||
|
// ConfirmProvider sits ABOVE <App> so it survives App's early returns (the
|
||||||
|
// unauth / no-link plates) — useConfirm() can never find itself without a host.
|
||||||
|
createRoot(rootEl!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<App />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fixture module that fails to load is a broken dev checkout, not a reason to
|
||||||
|
// hand the operator a blank plate — mount anyway and let the shell report that it
|
||||||
|
// cannot reach a daemon, which by then is the truth.
|
||||||
|
void initMockBackend().then(mount, (e) => {
|
||||||
|
console.error('mock backend failed to load; continuing against the real API', e)
|
||||||
|
mount()
|
||||||
|
})
|
||||||
|
|||||||
+327
-36
@@ -6,7 +6,7 @@
|
|||||||
// state mutates in-memory so the Apply / Confirm / Rollback flow is exercisable.
|
// state mutates in-memory so the Apply / Confirm / Rollback flow is exercisable.
|
||||||
//
|
//
|
||||||
// Type-only imports from api.ts (erased at build) keep this free of a runtime cycle.
|
// Type-only imports from api.ts (erased at build) keep this free of a runtime cycle.
|
||||||
import type { ApplyResult, ChainHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, QueryLogEntry, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning } from './api'
|
import type { ApplyResult, ChainHealth, ChainHopHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, Profile, QueryLogEntry, RuleReach, RulesReachability, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning, Traffic } from './api'
|
||||||
|
|
||||||
let armed = false // a pending commit-confirm auto-rollback
|
let armed = false // a pending commit-confirm auto-rollback
|
||||||
let hasLastGood = false // a predecessor config exists to roll back to (post-apply)
|
let hasLastGood = false // a predecessor config exists to roll back to (post-apply)
|
||||||
@@ -129,9 +129,25 @@ const CONFIG: Model = {
|
|||||||
{ Name: 'via-tunnel', Source: 'subscription', Subscription: 'primary', Strategy: 'leastping', Egress: 'awg' },
|
{ Name: 'via-tunnel', Source: 'subscription', Subscription: 'primary', Strategy: 'leastping', Egress: 'awg' },
|
||||||
{ Name: 'fallback', Source: 'subscription', Subscription: 'backup', Strategy: 'roundrobin', Egress: '' },
|
{ Name: 'fallback', Source: 'subscription', Subscription: 'backup', Strategy: 'roundrobin', Egress: '' },
|
||||||
],
|
],
|
||||||
// One multi-hop chain so `?mock` exercises the chain card's Test button and
|
// Three chains, one per state the hop readout has to render.
|
||||||
// its result readout: enters through the awg tunnel, exits via the auto group.
|
Chains: [
|
||||||
Chains: [{ Name: 'relay', Hops: ['egress:awg', 'group:auto'] }],
|
// The owner's real production shape: leave through a WAN interface, cross an
|
||||||
|
// AmneziaWG node, then three subscription groups in series. The leading
|
||||||
|
// `egress:` is NOT a numbered hop — the daemon lifts it into hop 1's entry
|
||||||
|
// detour — so this reports FOUR hops, and hop 3 is dead while its neighbours
|
||||||
|
// answer. That single red notch in the middle of a live path is the entire
|
||||||
|
// reason per-hop health exists, so `?mock` must show it at a glance.
|
||||||
|
{
|
||||||
|
Name: 'ewan-wg-subs',
|
||||||
|
Hops: ['egress:wan', 'node:home-wg', 'group:auto', 'group:stealth', 'group:via-tunnel'],
|
||||||
|
},
|
||||||
|
// Used, but the observatory hasn't come round yet — every hop untested. Not
|
||||||
|
// dead and not healthy: the state the panel most easily renders as a fault.
|
||||||
|
{ Name: 'sub-fresh', Hops: ['node:home-wg', 'group:fallback'] },
|
||||||
|
// No enabled rule targets it, so the observatory skips it entirely and the
|
||||||
|
// daemon never materialises its hops: `used:false` and NO `hops` key.
|
||||||
|
{ Name: 'relay', Hops: ['egress:awg', 'group:auto'] },
|
||||||
|
],
|
||||||
Egresses: [
|
Egresses: [
|
||||||
{ Name: 'wan', Type: 'interface', Interface: 'wan' },
|
{ Name: 'wan', Type: 'interface', Interface: 'wan' },
|
||||||
// An AmneziaWG tunnel — the whole point of a group-level egress binding.
|
// An AmneziaWG tunnel — the whole point of a group-level egress binding.
|
||||||
@@ -145,7 +161,20 @@ const CONFIG: Model = {
|
|||||||
Rules: [
|
Rules: [
|
||||||
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
|
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
|
||||||
{ Name: 'ru-bypass', Enabled: true, Order: 20, DstRuleset: ['ru-inside'], Target: 'direct' },
|
{ Name: 'ru-bypass', Enabled: true, Order: 20, DstRuleset: ['ru-inside'], Target: 'direct' },
|
||||||
|
// These two are what make the chains USED — the observatory probes only the
|
||||||
|
// paths an enabled rule can reach, so without them every chain card would
|
||||||
|
// read "not routed" and the hop rail would never appear in `?mock`. Kept
|
||||||
|
// ABOVE the condition-less rule at Order 40, which would otherwise swallow
|
||||||
|
// everything below it and mark them "never applies".
|
||||||
|
{ Name: 'media-via-chain', Enabled: true, Order: 22, DstRuleset: ['yt-geosite'], Target: 'chain:ewan-wg-subs' },
|
||||||
|
{ Name: 'spare-via-chain', Enabled: true, Order: 24, DstRuleset: ['ad-hosts'], Target: 'chain:sub-fresh' },
|
||||||
{ Name: 'private-direct', Enabled: true, Order: 30, DstRuleset: ['private-nets'], Target: 'direct' },
|
{ Name: 'private-direct', Enabled: true, Order: 30, DstRuleset: ['private-nets'], Target: 'direct' },
|
||||||
|
// A SECOND condition-less rule, above the real default. It reads like a working
|
||||||
|
// rule and does nothing: a rule with no conditions becomes the router's default,
|
||||||
|
// and the last such rule by Order wins — so this one never applies. It is in the
|
||||||
|
// fixture on purpose, to exercise the "never applies" badge; the field config
|
||||||
|
// that prompted it had two rules BOTH named `default` (orders 20 and 100).
|
||||||
|
{ Name: 'default-bypass', Enabled: true, Order: 40, Target: 'direct' },
|
||||||
{ Name: 'default-tunnel', Enabled: true, Order: 900, Target: 'group:auto' },
|
{ Name: 'default-tunnel', Enabled: true, Order: 900, Target: 'group:auto' },
|
||||||
],
|
],
|
||||||
// Named match-lists a rule points DstRuleset at. url + geosite + geoip are remote
|
// Named match-lists a rule points DstRuleset at. url + geosite + geoip are remote
|
||||||
@@ -165,6 +194,7 @@ const CONFIG: Model = {
|
|||||||
// to an official remote list; the others are the usual url / inline lists.
|
// to an official remote list; the others are the usual url / inline lists.
|
||||||
Blocklists: [
|
Blocklists: [
|
||||||
{ Name: 'StevenBlack', Enabled: true, Source: 'url', URL: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', Response: 'nxdomain', UpdateInterval: '24h' },
|
{ Name: 'StevenBlack', Enabled: true, Source: 'url', URL: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', Response: 'nxdomain', UpdateInterval: '24h' },
|
||||||
|
{ Name: 'oisd-basic', Enabled: true, Source: 'url', URL: 'https://big.oisd.nl/domainswild', Response: 'nxdomain', UpdateInterval: '24h' },
|
||||||
{ Name: 'telegram-block', Enabled: false, Source: 'geosite', Categories: ['telegram'], Response: 'nxdomain', UpdateInterval: '24h' },
|
{ Name: 'telegram-block', Enabled: false, Source: 'geosite', Categories: ['telegram'], Response: 'nxdomain', UpdateInterval: '24h' },
|
||||||
],
|
],
|
||||||
Resolvers: [
|
Resolvers: [
|
||||||
@@ -294,8 +324,116 @@ const RULESET_STATUS: RulesetStatus[] = [
|
|||||||
rule_count: 903,
|
rule_count: 903,
|
||||||
},
|
},
|
||||||
{ tag: 'rs-ru-geoip-ru', name: 'ru-geoip', category: 'ru', kind: 'ruleset', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
|
{ tag: 'rs-ru-geoip-ru', name: 'ru-geoip', category: 'ru', kind: 'ruleset', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
|
||||||
|
// Blocklists report through the same endpoint under `bl-<name>`, which the DNS
|
||||||
|
// page never asked for — so a list that has NEVER been fetched still read
|
||||||
|
// "filtering". StevenBlack is that case here; oisd-basic is the healthy one, so
|
||||||
|
// both readings are exercisable offline.
|
||||||
|
{ tag: 'bl-StevenBlack', name: 'StevenBlack', category: '', kind: 'blocklist', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
|
||||||
|
{
|
||||||
|
tag: 'bl-oisd-basic',
|
||||||
|
name: 'oisd-basic',
|
||||||
|
category: '',
|
||||||
|
kind: 'blocklist',
|
||||||
|
remote: true,
|
||||||
|
last_updated: new Date(Date.now() - 6 * 3600_000).toISOString(),
|
||||||
|
interval_seconds: 86_400,
|
||||||
|
rule_count: 218_431,
|
||||||
|
},
|
||||||
|
// Disabled in CONFIG, so the row reads "off" whatever this says — it exists to
|
||||||
|
// prove the row does not start claiming things the moment a status appears.
|
||||||
|
{ tag: 'bl-telegram-block-telegram', name: 'telegram-block', category: 'telegram', kind: 'blocklist', remote: true, last_updated: '', interval_seconds: 86_400, rule_count: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** GET /api/rules/reachability. Mirrors the daemon's analysis over CONFIG.Rules:
|
||||||
|
* a rule with no conditions is the router's default, and the LAST such rule by
|
||||||
|
* Order wins — every earlier one can never apply. It reads the live CONFIG so
|
||||||
|
* edits made in `?mock` keep the badge honest.
|
||||||
|
*
|
||||||
|
* It also mirrors model.ResolveActiveProfile + ApplyProfileRuleOverrides, because
|
||||||
|
* `effective_enabled` is the whole point of the endpoint: CONFIG's `mobile-uplink`
|
||||||
|
* is active and both enables and disables rules, so `?mock` shows the same
|
||||||
|
* desired-vs-effective split the field config does. */
|
||||||
|
export async function getRulesReachability(): Promise<RulesReachability> {
|
||||||
|
await wait(60)
|
||||||
|
const rules = CONFIG.Rules ?? []
|
||||||
|
|
||||||
|
// A pin naming an existing, ENABLED profile wins outright. Otherwise auto-select:
|
||||||
|
// highest Priority among enabled profiles, ties by Name, skipping any with an
|
||||||
|
// iface condition (the WAN watcher owns those and expresses its verdict as the pin).
|
||||||
|
const profiles = CONFIG.Profiles ?? []
|
||||||
|
const pinned = String(CONFIG.Globals?.ActiveProfile ?? '').trim()
|
||||||
|
let prof: Profile | null = profiles.find((p) => p.Enabled && p.Name === pinned) ?? null
|
||||||
|
if (!prof) {
|
||||||
|
for (const p of profiles) {
|
||||||
|
if (!p.Enabled || (p.MatchIface ?? []).length > 0) continue
|
||||||
|
const pp = p.Priority ?? 0
|
||||||
|
const bp = prof?.Priority ?? 0
|
||||||
|
if (!prof || pp > bp || (pp === bp && p.Name < prof.Name)) prof = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enable first, then Disable, so a name in both ends up disabled (Disable wins).
|
||||||
|
const effective = rules.map((r) => Boolean(r.Enabled))
|
||||||
|
if (prof) {
|
||||||
|
const force = (names: string[] | null | undefined, on: boolean) => {
|
||||||
|
for (const raw of names ?? []) {
|
||||||
|
const n = raw.trim()
|
||||||
|
rules.forEach((r, i) => {
|
||||||
|
if (r.Name === n) effective[i] = on
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
force(prof.EnableRules, true)
|
||||||
|
force(prof.DisableRules, false)
|
||||||
|
}
|
||||||
|
const activeProfile = prof
|
||||||
|
|
||||||
|
const out: RuleReach[] = rules.map((r, index) => ({
|
||||||
|
index,
|
||||||
|
name: String(r.Name ?? ''),
|
||||||
|
order: Number(r.Order ?? 0),
|
||||||
|
unreachable: false,
|
||||||
|
shadowed_by_index: -1,
|
||||||
|
effective_enabled: effective[index],
|
||||||
|
// Annotate only where the profile actually FLIPPED the outcome — a profile that
|
||||||
|
// disables an already-off rule has overridden nothing the operator can see.
|
||||||
|
...(activeProfile && effective[index] !== Boolean(r.Enabled)
|
||||||
|
? {
|
||||||
|
overridden_by: activeProfile.Name,
|
||||||
|
override: effective[index] ? ('enabled' as const) : ('disabled' as const),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}))
|
||||||
|
const conditionless = (r: (typeof rules)[number]): boolean =>
|
||||||
|
!(r.Src ?? []).length &&
|
||||||
|
!(r.DstRuleset ?? []).length &&
|
||||||
|
!String(r.DstPort ?? '').trim() &&
|
||||||
|
!String(r.Proto ?? '').trim()
|
||||||
|
const target = (r: (typeof rules)[number]): string =>
|
||||||
|
String(r.Target ?? '').trim() || (r.Egress ? `egress:${String(r.Egress).trim()}` : '')
|
||||||
|
const defaults = rules
|
||||||
|
.map((r, index) => ({ r, index }))
|
||||||
|
// The EFFECTIVE flag, not the configured one: a rule the active profile
|
||||||
|
// switched off is not in force and cannot retire anything (model's
|
||||||
|
// RuleReachability runs over the effective set for the same reason).
|
||||||
|
.filter(({ r, index }) => effective[index] && conditionless(r) && target(r))
|
||||||
|
.sort((a, b) => Number(a.r.Order ?? 0) - Number(b.r.Order ?? 0) || a.index - b.index)
|
||||||
|
const winner = defaults[defaults.length - 1]
|
||||||
|
if (winner) {
|
||||||
|
for (const { index } of defaults.slice(0, -1)) {
|
||||||
|
out[index].unreachable = true
|
||||||
|
out[index].shadowed_by = String(winner.r.Name ?? '')
|
||||||
|
out[index].shadowed_by_index = winner.index
|
||||||
|
out[index].shadowed_by_order = Number(winner.r.Order ?? 0)
|
||||||
|
out[index].reason =
|
||||||
|
`this rule has no conditions, so it sets the default for all traffic — but rule ` +
|
||||||
|
`"${winner.r.Name}" (order ${winner.r.Order}) has none either and comes after it, so ` +
|
||||||
|
`"${target(winner.r)}" is the default the router uses and this rule's target ` +
|
||||||
|
`"${target(rules[index])}" is never applied`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rules: out }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRulesetStatus(): Promise<RulesetStatus[]> {
|
export async function getRulesetStatus(): Promise<RulesetStatus[]> {
|
||||||
await wait(90)
|
await wait(90)
|
||||||
return RULESET_STATUS.map((r) => ({ ...r }))
|
return RULESET_STATUS.map((r) => ({ ...r }))
|
||||||
@@ -357,7 +495,20 @@ export async function getRulesetCategories(source: string): Promise<RulesetCateg
|
|||||||
// ?mock&warn=1 → a full warning set (critical + warning + info) on top
|
// ?mock&warn=1 → a full warning set (critical + warning + info) on top
|
||||||
// ?mock&ks=open → healthy plane but a FAIL-OPEN kill-switch, which is what
|
// ?mock&ks=open → healthy plane but a FAIL-OPEN kill-switch, which is what
|
||||||
// makes the untunnelable policy inert (F8 case 4)
|
// makes the untunnelable policy inert (F8 case 4)
|
||||||
function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSwitch: string } {
|
// ?mock&traffic=… → with the plane FULL, where the traffic actually ends up:
|
||||||
|
// split | direct | blocked | blackout | unknown. `direct` is
|
||||||
|
// the field case the readout used to call "Protected" (one
|
||||||
|
// rule, `default → direct`); `unknown` is a daemon too old to
|
||||||
|
// report. Default: tunnel.
|
||||||
|
// ?mock&plane=unreported → a daemon that sends NO `plane` field. The panel then
|
||||||
|
// knows nothing about what is installed, which is the state
|
||||||
|
// the Kill-switch module used to render as a green "ARMED"
|
||||||
|
// (`undefined !== 'none'` is true).
|
||||||
|
function mockPlane(): {
|
||||||
|
plane: 'full' | 'hold' | 'none' | undefined
|
||||||
|
engine: boolean
|
||||||
|
killSwitch: string
|
||||||
|
} {
|
||||||
const q = typeof location === 'undefined' ? '' : location.search
|
const q = typeof location === 'undefined' ? '' : location.search
|
||||||
const params = new URLSearchParams(q)
|
const params = new URLSearchParams(q)
|
||||||
const killSwitch = params.get('ks') === 'open' ? 'open' : 'closed'
|
const killSwitch = params.get('ks') === 'open' ? 'open' : 'closed'
|
||||||
@@ -365,9 +516,34 @@ function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSw
|
|||||||
if (p === 'hold') return { plane: 'hold', engine: false, killSwitch: 'closed' }
|
if (p === 'hold') return { plane: 'hold', engine: false, killSwitch: 'closed' }
|
||||||
if (p === 'none') return { plane: 'none', engine: false, killSwitch }
|
if (p === 'none') return { plane: 'none', engine: false, killSwitch }
|
||||||
if (p === 'open') return { plane: 'none', engine: false, killSwitch: 'open' }
|
if (p === 'open') return { plane: 'none', engine: false, killSwitch: 'open' }
|
||||||
|
if (p === 'unreported') return { plane: undefined, engine: true, killSwitch }
|
||||||
return { plane: 'full', engine: true, killSwitch }
|
return { plane: 'full', engine: true, killSwitch }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The daemon's verdict on where traffic goes (apply.Status.traffic). Only
|
||||||
|
// meaningful with the plane installed: with the engine down there is no running
|
||||||
|
// config to judge, and the daemon reports the unknown/zero value — so do the same
|
||||||
|
// here rather than leaving a stale "tunnel" behind a dead engine.
|
||||||
|
function mockTraffic(plane: 'full' | 'hold' | 'none' | undefined): Traffic | undefined {
|
||||||
|
if (plane !== 'full') return { verdict: '', default: '', tunnel_rules: 0 }
|
||||||
|
const params = new URLSearchParams(typeof location === 'undefined' ? '' : location.search)
|
||||||
|
switch (params.get('traffic')) {
|
||||||
|
case 'split':
|
||||||
|
return { verdict: 'split', default: 'direct', tunnel_rules: 3 }
|
||||||
|
case 'direct':
|
||||||
|
return { verdict: 'direct', default: 'direct', tunnel_rules: 0 }
|
||||||
|
case 'blocked':
|
||||||
|
return { verdict: 'blocked', default: 'block', tunnel_rules: 2 }
|
||||||
|
case 'blackout':
|
||||||
|
return { verdict: 'blocked', default: 'block', tunnel_rules: 0 }
|
||||||
|
case 'unknown':
|
||||||
|
// A daemon that predates the field sends no `traffic` at all.
|
||||||
|
return undefined
|
||||||
|
default:
|
||||||
|
return { verdict: 'tunnel', default: 'auto', tunnel_rules: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MOCK_WARNINGS: StatusWarning[] = [
|
const MOCK_WARNINGS: StatusWarning[] = [
|
||||||
{
|
{
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
@@ -393,6 +569,29 @@ const MOCK_WARNINGS: StatusWarning[] = [
|
|||||||
name: 'fakeip-pool',
|
name: 'fakeip-pool',
|
||||||
message: 'fake-IP resolver cannot be used as a fallback; the failover chain was not built',
|
message: 'fake-IP resolver cannot be used as a fallback; the failover chain was not built',
|
||||||
},
|
},
|
||||||
|
// Two findings the generator attributes to a NODE by name — the class that the
|
||||||
|
// Nodes page never showed, leaving a node the engine threw away rendered as an
|
||||||
|
// ordinary row with a green toggle. Both name real fixture nodes so the row
|
||||||
|
// badge, the collapsed-bucket "N flagged" count and the per-row strip all fire.
|
||||||
|
{
|
||||||
|
severity: 'warning',
|
||||||
|
section: 'node',
|
||||||
|
name: 'fi-trojan',
|
||||||
|
message: 'parse share-link: unsupported scheme "trojan+ws" (skipped)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
severity: 'warning',
|
||||||
|
section: 'node',
|
||||||
|
name: 'home-wg',
|
||||||
|
message:
|
||||||
|
'this WireGuard node is materialised twice in the engine config — as "home-wg" and as "group-stealth-m1-home-wg" — and traffic can reach both. A WireGuard peer keeps ONE session per public key, so two devices built from one private key evict each other continuously and NEITHER tunnel passes traffic. Only "home-wg" is kept; everything that routed through "group-stealth-m1-home-wg" is fail-closed (blocked) instead of leaving over the plain WAN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
severity: 'warning',
|
||||||
|
section: 'subscription',
|
||||||
|
name: 'backup',
|
||||||
|
message: 'fetch failed: dial tcp 203.0.113.9:443: i/o timeout — serving the nodes cached earlier',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
section: 'generate',
|
section: 'generate',
|
||||||
@@ -401,6 +600,19 @@ const MOCK_WARNINGS: StatusWarning[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon's truncation disclosure, exactly as apply/warnings.go writes it when
|
||||||
|
* the published set overflows the 50-entry cap. Served under `?mock&trunc` so the
|
||||||
|
* "this list is incomplete" rendering is exercisable — it used to be dropped
|
||||||
|
* wholesale by the panel's `info` filter and reached no screen at all.
|
||||||
|
*/
|
||||||
|
const MOCK_TRUNCATION: StatusWarning = {
|
||||||
|
severity: 'info',
|
||||||
|
section: 'generate',
|
||||||
|
name: '',
|
||||||
|
message: '7 further warning(s) suppressed; run `logread -e shater` for the full list',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The standing `untunnelable` note the daemon reports. It is INFO, never a
|
* The standing `untunnelable` note the daemon reports. It is INFO, never a
|
||||||
* problem: it states a correct, chosen configuration. Two shapes, mirroring the
|
* problem: it states a correct, chosen configuration. Two shapes, mirroring the
|
||||||
@@ -447,9 +659,12 @@ function mockWarnings(killSwitch: string): StatusWarning[] {
|
|||||||
const params = new URLSearchParams(q)
|
const params = new URLSearchParams(q)
|
||||||
const mode = (CONFIG.Globals as { Untunnelable?: string }).Untunnelable ?? 'block'
|
const mode = (CONFIG.Globals as { Untunnelable?: string }).Untunnelable ?? 'block'
|
||||||
const notes = untunnelableNote(mode, killSwitch)
|
const notes = untunnelableNote(mode, killSwitch)
|
||||||
|
// `?trunc` adds the daemon's "the published list is capped" disclosure, which
|
||||||
|
// it appends IN PLACE OF the last entry it had room for.
|
||||||
|
const trunc = params.has('trunc') ? [{ ...MOCK_TRUNCATION }] : []
|
||||||
// A degraded plane always comes with the findings that explain it.
|
// A degraded plane always comes with the findings that explain it.
|
||||||
if (params.has('warn') || params.get('plane')) {
|
if (params.has('warn') || params.get('plane') || trunc.length > 0) {
|
||||||
return [...MOCK_WARNINGS.map((w) => ({ ...w })), ...notes]
|
return [...MOCK_WARNINGS.map((w) => ({ ...w })), ...notes, ...trunc]
|
||||||
}
|
}
|
||||||
return notes
|
return notes
|
||||||
}
|
}
|
||||||
@@ -470,6 +685,7 @@ export async function getStatus(): Promise<Status> {
|
|||||||
can_rollback: armed || hasLastGood,
|
can_rollback: armed || hasLastGood,
|
||||||
engine_running: engine,
|
engine_running: engine,
|
||||||
plane,
|
plane,
|
||||||
|
traffic: mockTraffic(plane),
|
||||||
warnings: mockWarnings(killSwitch),
|
warnings: mockWarnings(killSwitch),
|
||||||
// Process uptime. Anchored to when this tab loaded plus a fixed head start, so
|
// Process uptime. Anchored to when this tab loaded plus a fixed head start, so
|
||||||
// the reading ticks forward across polls exactly like the real daemon's does.
|
// the reading ticks forward across polls exactly like the real daemon's does.
|
||||||
@@ -1046,20 +1262,59 @@ function healthList(): GroupHealth[] {
|
|||||||
return (CONFIG.Groups ?? []).map((g) => summarise(g.Name, GROUP_MEMBERS.get(g.Name) ?? []))
|
return (CONFIG.Groups ?? []).map((g) => summarise(g.Name, GROUP_MEMBERS.get(g.Name) ?? []))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-chain reachability for the Targets page's "unused" badge (plan §5.E) — the
|
/**
|
||||||
* chain analogue of healthList's `used` field. The mock's single chain `relay` is
|
* Per-hop health, keyed by chain name — what the observatory measured at each
|
||||||
* NOT referenced by any rule in CONFIG.Rules (they target group:auto / block /
|
* position of the path, in WIRE order.
|
||||||
* direct), so it reads used=false and its card renders "unused" — exactly the case
|
*
|
||||||
* the badge exists to surface. A stopped engine reports no chains. */
|
* `ewan-wg-subs` is the fixture that matters, and it encodes the ORDERED WALK.
|
||||||
|
* Hop 1 is the WireGuard node and answers; hop 2 is a subscription group whose
|
||||||
|
* copies answer THROUGH it — 119 of 122 tested alive, which is the reading only a
|
||||||
|
* per-hop probe can produce, since the same members are dialled differently on
|
||||||
|
* their own card. Hop 3 is a group whose members all time out at that position,
|
||||||
|
* and the walk STOPS there: hop 4 is dialled through hop 3, so it was never
|
||||||
|
* dialled at all. It comes back `untested` with `blocked_by` naming hop 3, its
|
||||||
|
* counters zeroed, and `selected` still set — the wrapper has a pick, nothing
|
||||||
|
* crossed it to measure. A dead hop with a live hop under it is not in this
|
||||||
|
* fixture because the daemon can no longer produce one.
|
||||||
|
*
|
||||||
|
* `sub-fresh` is used but never yet reached: every hop untested, nothing dead,
|
||||||
|
* no block — the other reason a lamp is unlit, and the one that fixes itself.
|
||||||
|
* `relay` is absent from this map on purpose — an unused chain is never
|
||||||
|
* materialised, so the daemon sends no `hops` key at all, which is "nothing
|
||||||
|
* measured", not "no hops".
|
||||||
|
*/
|
||||||
|
const CHAIN_HOPS: Record<string, ChainHopHealth[]> = {
|
||||||
|
'ewan-wg-subs': [
|
||||||
|
{ index: 1, tag: 'chain-ewan-wg-subs-h1', kind: 'node', exit: false, state: 'alive', delay_ms: 41, age_seconds: 22, selected: '', total: 1, tested: 1, alive: 1, dead: 0, untested: 0 },
|
||||||
|
{ index: 2, tag: 'chain-ewan-wg-subs-h2', kind: 'group', exit: false, state: 'alive', delay_ms: 96, age_seconds: 18, selected: '🇳🇱 Amsterdam-01', total: 298, tested: 122, alive: 119, dead: 3, untested: 176 },
|
||||||
|
{ index: 3, tag: 'chain-ewan-wg-subs-h3', kind: 'group', exit: false, state: 'dead', delay_ms: 0, age_seconds: 15, selected: '', total: 2, tested: 2, alive: 0, dead: 2, untested: 0 },
|
||||||
|
{ index: 4, tag: 'chain-ewan-wg-subs-h4', kind: 'group', exit: true, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '🇸🇬 Singapore-09', total: 6, tested: 0, alive: 0, dead: 0, untested: 6, blocked_by: { index: 3, tag: 'chain-ewan-wg-subs-h3' } },
|
||||||
|
],
|
||||||
|
'sub-fresh': [
|
||||||
|
{ index: 1, tag: 'chain-sub-fresh-h1', kind: 'node', exit: false, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '', total: 1, tested: 0, alive: 0, dead: 0, untested: 1 },
|
||||||
|
{ index: 2, tag: 'chain-sub-fresh-h2', kind: 'group', exit: true, state: 'untested', delay_ms: 0, age_seconds: -1, selected: '', total: 24, tested: 0, alive: 0, dead: 0, untested: 24 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-chain reachability plus per-hop health for the Targets page. `used` is the
|
||||||
|
* chain analogue of healthList's field; `hops` is OMITTED (never null, never []),
|
||||||
|
* exactly like the daemon, for a chain the engine never materialised. A stopped
|
||||||
|
* engine reports no chains at all. */
|
||||||
function chainHealthList(): ChainHealth[] {
|
function chainHealthList(): ChainHealth[] {
|
||||||
if (!mockPlane().engine) return []
|
if (!mockPlane().engine) return []
|
||||||
return (CONFIG.Chains ?? []).map((c) => ({ name: c.Name, used: chainUsed(c.Name) }))
|
return (CONFIG.Chains ?? []).map((c) => {
|
||||||
|
const hops = CHAIN_HOPS[c.Name]
|
||||||
|
const h: ChainHealth = { name: c.Name, used: chainUsed(c.Name) }
|
||||||
|
if (hops) h.hops = hops.map((x) => ({ ...x }))
|
||||||
|
return h
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A chain is "used" when some enabled routing rule (or Final, or a DNS detour)
|
/** A chain is "used" when some enabled routing rule (or Final, or a DNS detour)
|
||||||
* targets `chain:<name>` — the same reachability the daemon's observatory derives.
|
* targets `chain:<name>` — the same reachability the daemon's observatory derives.
|
||||||
* The mock's rules never target a chain, so every chain reads used=false; a real
|
* Two of the mock's rules do (`media-via-chain` → ewan-wg-subs, `spare-via-chain`
|
||||||
* config would mark the ones rules point at used=true. */
|
* → sub-fresh), so those two chains read used=true and get a hop rail; `relay`
|
||||||
|
* is targeted by nothing and reads used=false, which is the unused note. */
|
||||||
function chainUsed(name: string): boolean {
|
function chainUsed(name: string): boolean {
|
||||||
const target = `chain:${name}`
|
const target = `chain:${name}`
|
||||||
return (CONFIG.Rules ?? []).some(
|
return (CONFIG.Rules ?? []).some(
|
||||||
@@ -1111,15 +1366,23 @@ class ApiErrorLike extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock group/chain test. Deliberately covers every state the UI has to render,
|
// Mock refresh results. The endpoint no longer dials anything: it asks the
|
||||||
// one per target, so a single offline run exercises all of them:
|
// observatory to measure out of turn and reports what the observatory found, so
|
||||||
// auto → ok WITH an exit address
|
// every row here is a READ of a background measurement. Deliberately covers every
|
||||||
// stealth → ok WITHOUT one (delay measured, address undeterminable) — a
|
// state the UI has to render, one per target, so a single offline run exercises
|
||||||
// SUCCESS, and the case the UI most easily gets wrong
|
// all of them:
|
||||||
// relay → the chain: same wire shape, `group` carries the CHAIN's name and
|
// auto → ok WITH an exit address
|
||||||
// `selected` the node its exit group picked
|
// stealth → ok WITHOUT one (delay measured, address undeterminable) — a
|
||||||
// fallback → a failure carrying a human reason
|
// SUCCESS, and the case the UI most easily gets wrong
|
||||||
// Results land one per GET poll, so the running/progress state is visible too.
|
// ewan-wg-subs → the chain: same wire shape, `group` carries the CHAIN's name
|
||||||
|
// and `selected` the node its exit hop picked
|
||||||
|
// via-tunnel → the one honest health FAILURE: a probe that ran and failed
|
||||||
|
// fallback,
|
||||||
|
// relay → not routed at all, so no measurement exists to report
|
||||||
|
// sub-fresh → routed, but the observatory hasn't come round yet
|
||||||
|
// The last three are absence of measurement, not a broken target, and the copy
|
||||||
|
// has to keep them apart. Results land one per GET poll, so the running/progress
|
||||||
|
// state is visible too.
|
||||||
const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = {
|
const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_unix'>> = {
|
||||||
auto: {
|
auto: {
|
||||||
selected: 'nl-reality-2',
|
selected: 'nl-reality-2',
|
||||||
@@ -1137,32 +1400,60 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
|
|||||||
ok: true,
|
ok: true,
|
||||||
error: '',
|
error: '',
|
||||||
},
|
},
|
||||||
// The chain — Selected is the node the chain's exit group (auto) picked.
|
// The chain, and the pairing that makes the whole feature worth building. A
|
||||||
relay: {
|
// chain is one series path, so with hop 3 dead the end-to-end probe is never
|
||||||
selected: 'nl-reality-2',
|
// even attempted — the daemon stops walking there. This row and the hop rail
|
||||||
delay_ms: 61,
|
// therefore have to tell one story, not two: both name hop 3, and neither
|
||||||
exit_ip: '185.12.34.56',
|
// offers hop 4 as a second suspect. Note the row does NOT say "the probe
|
||||||
exit_country: 'NL',
|
// failed" — no probe of this chain's exit ran at all — which is why the daemon
|
||||||
ok: true,
|
// has a separate message for it.
|
||||||
error: '',
|
'ewan-wg-subs': {
|
||||||
|
selected: '',
|
||||||
|
delay_ms: 0,
|
||||||
|
exit_ip: '',
|
||||||
|
exit_country: '',
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'hop 3 of this chain was probed and did not answer, so nothing reaches the exit through it — fix that hop first',
|
||||||
},
|
},
|
||||||
// Dead through its tunnel, exactly as its membership health says — the exit
|
// The one real health failure in the fixture: the observatory's probe ran along
|
||||||
// test and the member health tell the same story about the same group.
|
// this path and did not come back.
|
||||||
'via-tunnel': {
|
'via-tunnel': {
|
||||||
selected: '',
|
selected: '',
|
||||||
delay_ms: 0,
|
delay_ms: 0,
|
||||||
exit_ip: '',
|
exit_ip: '',
|
||||||
exit_country: '',
|
exit_country: '',
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'no member answered through egress awg (6 of 6 timed out)',
|
error: 'the observatory’s probe through this path failed',
|
||||||
},
|
},
|
||||||
|
// Not a health verdict — nothing routes here, so no measurement of it exists.
|
||||||
fallback: {
|
fallback: {
|
||||||
selected: '',
|
selected: '',
|
||||||
delay_ms: 0,
|
delay_ms: 0,
|
||||||
exit_ip: '',
|
exit_ip: '',
|
||||||
exit_country: '',
|
exit_country: '',
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'no reachable node in the group (all 3 members timed out)',
|
error:
|
||||||
|
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
|
||||||
|
},
|
||||||
|
relay: {
|
||||||
|
selected: '',
|
||||||
|
delay_ms: 0,
|
||||||
|
exit_ip: '',
|
||||||
|
exit_country: '',
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'not routed by any enabled rule, so nothing measures it — the observatory only probes paths the rules use',
|
||||||
|
},
|
||||||
|
// Routed, materialised, simply not reached yet. Untested is not dead.
|
||||||
|
'sub-fresh': {
|
||||||
|
selected: '',
|
||||||
|
delay_ms: 0,
|
||||||
|
exit_ip: '',
|
||||||
|
exit_country: '',
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'the observatory has not reached this target yet — it refreshes on the global probe interval',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
/* Alerts section (rendered on Settings) — inherits the Faceplate tokens and the
|
||||||
|
* shared page chrome from App.css (.toast, .mono). Every rule below is a
|
||||||
|
* one-to-one copy of the DNS.css rule the markup used before the section moved
|
||||||
|
* here, renamed `dns-*` → `alr-*` so nothing collides. Orange stays an accent. */
|
||||||
|
|
||||||
|
/* ---- section shell (matches the Settings group plates one-to-one) ---- */
|
||||||
|
.alr-section {
|
||||||
|
margin-top: calc(var(--u, 8px) * 3.5);
|
||||||
|
}
|
||||||
|
.alr-sec-hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--groove);
|
||||||
|
}
|
||||||
|
.alr-sec-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: var(--track-label, 0.18em);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.alr-sec-count {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.alr-sec-note {
|
||||||
|
margin: 10px 2px 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--dim);
|
||||||
|
max-width: 56ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- add form ---- */
|
||||||
|
.alr-add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: calc(var(--u, 8px) * 2);
|
||||||
|
}
|
||||||
|
.alr-add-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.alr-input {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--sink);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 1px 2px var(--shadow) inset;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.alr-input::placeholder {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.alr-input:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.alr-input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.alr-input--name {
|
||||||
|
flex: 0 1 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* segmented type picker */
|
||||||
|
.alr-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--sink);
|
||||||
|
}
|
||||||
|
.alr-seg-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.alr-seg-btn + .alr-seg-btn {
|
||||||
|
border-left: 1px solid var(--groove);
|
||||||
|
}
|
||||||
|
.alr-seg-btn.on {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.alr-seg-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alr-resp {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.alr-resp-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--track-label, 0.18em);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.alr-select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--sink);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.alr-select:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alr-add-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.alr-field-err {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--crit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- rows ---- */
|
||||||
|
.alr-rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: calc(var(--u, 8px) * 2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.alr-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--u, 8px) * 1.5);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--raised),
|
||||||
|
color-mix(in srgb, var(--raised) 82%, var(--panel))
|
||||||
|
);
|
||||||
|
box-shadow: 0 1px 0 var(--edge) inset;
|
||||||
|
}
|
||||||
|
.alr-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.alr-row-l1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.alr-row-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 24ch;
|
||||||
|
}
|
||||||
|
.alr-row-l2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.alr-row-detail {
|
||||||
|
color: var(--dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* badge — groove-bordered, not orange (accent stays reserved) */
|
||||||
|
.alr-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--sink) 60%, transparent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.alr-badge--accent {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alr-masked {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alr-del {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- empty plate ---- */
|
||||||
|
.alr-empty {
|
||||||
|
margin-top: calc(var(--u, 8px) * 2);
|
||||||
|
padding: calc(var(--u, 8px) * 3);
|
||||||
|
border: 1px dashed var(--groove);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in srgb, var(--raised) 55%, transparent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.alr-empty-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.alr-empty-body {
|
||||||
|
margin: 8px auto 0;
|
||||||
|
max-width: 48ch;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- loading skeleton ---- */
|
||||||
|
.alr-skel {
|
||||||
|
height: 62px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, var(--raised), var(--sink), var(--raised));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: alr-skel-shift 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes alr-skel-shift {
|
||||||
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the per-row delivery picker sits inline in the row */
|
||||||
|
.alr-detour {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.alr-detour-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--track-label, 0.18em);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.alr-detour-select {
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* current delivery-path readout on the row */
|
||||||
|
.alr-path {
|
||||||
|
color: var(--faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.alr-path[data-active='on'] {
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.alr-path-name {
|
||||||
|
color: var(--led-on);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.alr-path[data-missing='y'] .alr-path-name {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.alr-path-flag {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* alert delivery: deliver-via picker + fallback toggle + caution note */
|
||||||
|
.alr-delivery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 20px;
|
||||||
|
}
|
||||||
|
.alr-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.alr-fallback-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--track-label, 0.18em);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
/* the per-row delivery controls sit inline in the row (like .alr-detour) */
|
||||||
|
.alr-ctl {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.alr-note {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--amber);
|
||||||
|
max-width: 56ch;
|
||||||
|
}
|
||||||
|
.alr-note--row {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* alert event checkboxes */
|
||||||
|
.alr-events {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.alr-event {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fp-text, inherit);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.alr-event input {
|
||||||
|
accent-color: var(--fp-accent, currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- responsive ---- */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.alr-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.alr-row-main {
|
||||||
|
flex-basis: calc(100% - 90px);
|
||||||
|
}
|
||||||
|
.alr-del {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.alr-input--name {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.alr-detour {
|
||||||
|
flex-basis: 100%;
|
||||||
|
order: 3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* A <select> won't shrink below its widest option unless it's allowed to:
|
||||||
|
without min-width:0 the long detour labels push the page into a horizontal
|
||||||
|
scroll at 390px. Let them fill the row and clip instead. */
|
||||||
|
.alr-detour-select,
|
||||||
|
.alr-resp .alr-select {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.alr-resp {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.alr-ctl {
|
||||||
|
flex-basis: 100%;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.alr-skel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.alr-input,
|
||||||
|
.alr-seg-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
import './Alerts.css'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Button, Toggle, useConfirm } from '../components'
|
||||||
|
import type { Alert, Model } from '../api'
|
||||||
|
|
||||||
|
// The Alerts section — out-of-band notifications (Telegram bot / webhook) for
|
||||||
|
// kill-switch trips, apply failures, new devices and subscription expiry. It
|
||||||
|
// lived at the bottom of the DNS page, which is the last place an operator
|
||||||
|
// looking for "tell me when the tunnel dies" would think to look; it now renders
|
||||||
|
// as a group on Settings. The component owns no I/O: every mutation goes through
|
||||||
|
// the `onSave` prop so Settings keeps a single dirty banner and a single toast.
|
||||||
|
//
|
||||||
|
// NOTE on duplication: the detour helpers below (DetourCatalog, canonDetour,
|
||||||
|
// detourValues, describeDetour, DetourSelect) plus asArray / uniqueName /
|
||||||
|
// maskUrl / EmptyPlate are deliberate copies of the ones in DNS.tsx. DNS keeps
|
||||||
|
// its own for resolvers and DNS rules; extracting a shared module would couple
|
||||||
|
// two pages that otherwise share nothing, and that refactor is out of scope
|
||||||
|
// here. If a third consumer ever appears, promote them then.
|
||||||
|
|
||||||
|
// ---- local Model extension --------------------------------------------------
|
||||||
|
|
||||||
|
/** The Model with the Alerts slice surfaced (index-signature passthrough). */
|
||||||
|
type AlertsModel = Model & { Alerts?: Alert[] | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event the daemon actually sends. A retired health-probe event was left
|
||||||
|
* out on purpose: nothing ever fired it, so a channel that subscribed to it would
|
||||||
|
* just stay quiet forever — the one failure mode an alert must not have. Only
|
||||||
|
* events with a live firing path are offered here.
|
||||||
|
*/
|
||||||
|
const ALERT_EVENTS: ReadonlyArray<{ id: string; label: string }> = [
|
||||||
|
{ id: 'killswitch', label: 'Kill-switch' },
|
||||||
|
{ id: 'apply_fail', label: 'Apply failure' },
|
||||||
|
{ id: 'new_device', label: 'New device' },
|
||||||
|
{ id: 'sub_expiry', label: 'Subscription expiring' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Shown when an alert routes through a detour with no direct fallback — the exact
|
||||||
|
// case where a tunnel-down alert could fail to send. The user asked for this.
|
||||||
|
const VIA_NO_FALLBACK_NOTE =
|
||||||
|
'A kill-switch/tunnel-down alert may not send if it routes through the affected tunnel — enable fallback.'
|
||||||
|
|
||||||
|
// ---- helpers (copies of DNS.tsx — see the header note) ----------------------
|
||||||
|
|
||||||
|
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
|
||||||
|
|
||||||
|
const HTTP_RE = /^https?:\/\//i
|
||||||
|
|
||||||
|
/** A remote URL often carries a token in its query/path — show host only. */
|
||||||
|
function maskUrl(url: string): { host: string; masked: boolean } {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
return { host: u.host, masked: u.search !== '' || u.pathname.replace(/\/+$/, '') !== '' }
|
||||||
|
} catch {
|
||||||
|
return { host: url || '—', masked: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(base: string, taken: Set<string>): string {
|
||||||
|
const seed = base.trim() || 'alert'
|
||||||
|
if (!taken.has(seed)) return seed
|
||||||
|
let i = 2
|
||||||
|
while (taken.has(`${seed}-${i}`)) i++
|
||||||
|
return `${seed}-${i}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live targets an alert's delivery can be pinned to (the picker). */
|
||||||
|
interface DetourCatalog {
|
||||||
|
groups: string[]
|
||||||
|
chains: string[]
|
||||||
|
egresses: { name: string; type: string }[]
|
||||||
|
nodes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a stored `Via` to a picker option value. Empty/`direct` ⇒
|
||||||
|
* `direct`; already-prefixed values (`group:`/`chain:`/`egress:`/`node:`) pass
|
||||||
|
* through; a bare legacy name is resolved against the catalog so a still-valid
|
||||||
|
* setup isn't mislabelled; anything unresolved is kept verbatim (shown stale).
|
||||||
|
*/
|
||||||
|
function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
|
||||||
|
const d = (raw ?? '').trim()
|
||||||
|
if (!d || d.toLowerCase() === 'direct') return 'direct'
|
||||||
|
if (/^(node|group|chain|egress):/i.test(d)) return d
|
||||||
|
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
|
||||||
|
if (cat.groups.includes(d)) return `group:${d}`
|
||||||
|
if (cat.chains.includes(d)) return `chain:${d}`
|
||||||
|
if (cat.nodes.includes(d)) return `node:${d}`
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every valid option value for a catalog, including `direct`. */
|
||||||
|
function detourValues(cat: DetourCatalog): Set<string> {
|
||||||
|
const s = new Set<string>(['direct'])
|
||||||
|
for (const g of cat.groups) s.add(`group:${g}`)
|
||||||
|
for (const c of cat.chains) s.add(`chain:${c}`)
|
||||||
|
for (const e of cat.egresses) s.add(`egress:${e.name}`)
|
||||||
|
for (const n of cat.nodes) s.add(`node:${n}`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describe a canonical detour value for the row readout. */
|
||||||
|
function describeDetour(
|
||||||
|
canon: string,
|
||||||
|
cat: DetourCatalog,
|
||||||
|
valid: Set<string>,
|
||||||
|
): { direct: boolean; prefix: string; name: string; missing: boolean } {
|
||||||
|
if (canon === 'direct') return { direct: true, prefix: '', name: '', missing: false }
|
||||||
|
const i = canon.indexOf(':')
|
||||||
|
const kind = i === -1 ? '' : canon.slice(0, i)
|
||||||
|
const name = i === -1 ? canon : canon.slice(i + 1)
|
||||||
|
const missing = !valid.has(canon)
|
||||||
|
let prefix = 'via'
|
||||||
|
if (kind === 'group') prefix = 'via group'
|
||||||
|
else if (kind === 'chain') prefix = 'via chain'
|
||||||
|
else if (kind === 'node') prefix = 'via node'
|
||||||
|
else if (kind === 'egress') {
|
||||||
|
const eg = cat.egresses.find((e) => e.name === name)
|
||||||
|
prefix = eg?.type === 'interface' ? 'via interface' : 'via egress'
|
||||||
|
}
|
||||||
|
return { direct: false, prefix, name, missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- section ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function AlertsSection({
|
||||||
|
config,
|
||||||
|
busy,
|
||||||
|
loading,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
/** Full desired-state model; null until it has loaded. */
|
||||||
|
config: Model | null
|
||||||
|
/** A save/apply is in flight — controls lock. */
|
||||||
|
busy: boolean
|
||||||
|
/** The config is still loading — show a skeleton row. */
|
||||||
|
loading: boolean
|
||||||
|
/** Persist the whole next model; resolves true on success (Settings' `save`). */
|
||||||
|
onSave: (next: Model, okMsg: string) => Promise<boolean>
|
||||||
|
}): JSX.Element {
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const model = config as AlertsModel | null
|
||||||
|
const alerts = useMemo<Alert[]>(() => asArray(model?.Alerts), [model])
|
||||||
|
|
||||||
|
// Alerts route through Direct/group/node/egress only (no chains) — the contract
|
||||||
|
// vocabulary for Alert.Via. Built straight from the Model with chains dropped.
|
||||||
|
const alertCatalog = useMemo<DetourCatalog>(
|
||||||
|
() => ({
|
||||||
|
groups: asArray(config?.Groups).map((g) => g.Name),
|
||||||
|
chains: [],
|
||||||
|
egresses: asArray(config?.Egresses).map((e) => ({ name: e.Name, type: e.Type })),
|
||||||
|
nodes: asArray(config?.Nodes).map((n) => n.Name),
|
||||||
|
}),
|
||||||
|
[config],
|
||||||
|
)
|
||||||
|
const alertValid = useMemo(() => detourValues(alertCatalog), [alertCatalog])
|
||||||
|
|
||||||
|
const alertNames = useMemo(() => new Set(alerts.map((a) => a.Name)), [alerts])
|
||||||
|
const alertsOn = alerts.filter((a) => a.Enabled).length
|
||||||
|
|
||||||
|
// ---- mutations — all writes go through onSave -----------------------------
|
||||||
|
const addAlert = useCallback(
|
||||||
|
(draft: Alert): Promise<boolean> => {
|
||||||
|
if (!model) return Promise.resolve(false)
|
||||||
|
const taken = new Set(alerts.map((a) => a.Name))
|
||||||
|
const a: Alert = { ...draft, Name: uniqueName(draft.Name, taken) }
|
||||||
|
return onSave({ ...model, Alerts: [...alerts, a] }, `Added ${a.Name}`)
|
||||||
|
},
|
||||||
|
[model, alerts, onSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleAlert = useCallback(
|
||||||
|
(idx: number, on: boolean) => {
|
||||||
|
if (!model) return
|
||||||
|
const next = alerts.map((a, i) => (i === idx ? { ...a, Enabled: on } : a))
|
||||||
|
void onSave({ ...model, Alerts: next }, `${next[idx].Name} ${on ? 'enabled' : 'disabled'}`)
|
||||||
|
},
|
||||||
|
[model, alerts, onSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeAlert = useCallback(
|
||||||
|
async (idx: number) => {
|
||||||
|
if (!model) return
|
||||||
|
const target = alerts[idx]
|
||||||
|
const ok = await confirm({
|
||||||
|
label: 'Delete alert',
|
||||||
|
title: `Delete alert “${target.Name}”?`,
|
||||||
|
body: 'This removes it from the config.',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
const next = alerts.filter((_, i) => i !== idx)
|
||||||
|
void onSave({ ...model, Alerts: next }, `Deleted ${target.Name}`)
|
||||||
|
},
|
||||||
|
[model, alerts, onSave, confirm],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setAlertVia = useCallback(
|
||||||
|
(idx: number, v: string) => {
|
||||||
|
if (!model) return
|
||||||
|
const via = v === 'direct' ? '' : v
|
||||||
|
const next = alerts.map((a, i) => (i === idx ? { ...a, Via: via || undefined } : a))
|
||||||
|
void onSave(
|
||||||
|
{ ...model, Alerts: next },
|
||||||
|
via ? `${next[idx].Name} delivers via ${via}` : `${next[idx].Name} delivers direct`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[model, alerts, onSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setAlertFallback = useCallback(
|
||||||
|
(idx: number, on: boolean) => {
|
||||||
|
if (!model) return
|
||||||
|
const next = alerts.map((a, i) => (i === idx ? { ...a, Fallback: on || undefined } : a))
|
||||||
|
void onSave(
|
||||||
|
{ ...model, Alerts: next },
|
||||||
|
`${next[idx].Name} direct fallback ${on ? 'on' : 'off'}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[model, alerts, onSave],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alr-section" aria-label="Alerts">
|
||||||
|
<header className="alr-sec-hd">
|
||||||
|
<h2 className="alr-sec-title">Alerts</h2>
|
||||||
|
<span className="alr-sec-count mono">
|
||||||
|
{alertsOn} / {alerts.length} on
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p className="alr-sec-note">
|
||||||
|
Out-of-band notifications. Delivered <strong>direct to the internet</strong> by default — so a
|
||||||
|
kill-switch or engine-down alert still reaches you when the proxy is down. You can route one
|
||||||
|
through a group, node or egress instead, with a direct fallback if that detour fails.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AddAlertForm
|
||||||
|
busy={busy}
|
||||||
|
disabled={!config}
|
||||||
|
taken={alertNames}
|
||||||
|
catalog={alertCatalog}
|
||||||
|
valid={alertValid}
|
||||||
|
onAdd={addAlert}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ul className="alr-rows" aria-hidden="true">
|
||||||
|
<li className="alr-skel" />
|
||||||
|
</ul>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<EmptyPlate
|
||||||
|
title="No alerts"
|
||||||
|
body="Add a Telegram bot or a webhook above to get notified when the kill-switch trips, a new device joins, or an apply fails."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="alr-rows">
|
||||||
|
{alerts.map((a, i) => (
|
||||||
|
<AlertRow
|
||||||
|
key={`${a.Name}-${i}`}
|
||||||
|
alert={a}
|
||||||
|
busy={busy}
|
||||||
|
catalog={alertCatalog}
|
||||||
|
valid={alertValid}
|
||||||
|
onToggle={(on) => toggleAlert(i, on)}
|
||||||
|
onVia={(v) => setAlertVia(i, v)}
|
||||||
|
onFallback={(on) => setAlertFallback(i, on)}
|
||||||
|
onDelete={() => removeAlert(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- alert add form + row ----------------------------------------------------
|
||||||
|
|
||||||
|
function AddAlertForm({
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
taken,
|
||||||
|
catalog,
|
||||||
|
valid,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
busy: boolean
|
||||||
|
disabled: boolean
|
||||||
|
taken: Set<string>
|
||||||
|
catalog: DetourCatalog
|
||||||
|
valid: Set<string>
|
||||||
|
onAdd: (a: Alert) => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [type, setType] = useState<'telegram' | 'webhook'>('telegram')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [chatId, setChatId] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [events, setEvents] = useState<string[]>(['killswitch'])
|
||||||
|
const [via, setVia] = useState('direct')
|
||||||
|
const [fallback, setFallback] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName('')
|
||||||
|
setType('telegram')
|
||||||
|
setToken('')
|
||||||
|
setChatId('')
|
||||||
|
setUrl('')
|
||||||
|
setEvents(['killswitch'])
|
||||||
|
setVia('direct')
|
||||||
|
setFallback(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEvent = (id: string) =>
|
||||||
|
setEvents((prev) => (prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id]))
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const nm = name.trim()
|
||||||
|
if (!nm) {
|
||||||
|
setErr('Give the alert a name.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (taken.has(nm)) {
|
||||||
|
setErr(`An alert named “${nm}” already exists.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'telegram') {
|
||||||
|
if (!token.trim() || !chatId.trim()) {
|
||||||
|
setErr('Telegram needs a bot token and a chat ID.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (!HTTP_RE.test(url.trim())) {
|
||||||
|
setErr('Enter an http(s):// webhook URL.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
setErr('Pick at least one event to notify on.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setErr(null)
|
||||||
|
const routed = via !== 'direct'
|
||||||
|
const routing = { Via: routed ? via : undefined, Fallback: routed && fallback ? true : undefined }
|
||||||
|
const draft: Alert =
|
||||||
|
type === 'telegram'
|
||||||
|
? { Name: nm, Enabled: true, Type: 'telegram', Token: token.trim(), ChatID: chatId.trim(), Events: events, ...routing }
|
||||||
|
: { Name: nm, Enabled: true, Type: 'webhook', URL: url.trim(), Events: events, ...routing }
|
||||||
|
const ok = await onAdd(draft)
|
||||||
|
if (ok) reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const routed = via !== 'direct'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="alr-add"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void submit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="alr-add-top">
|
||||||
|
<input
|
||||||
|
className="alr-input alr-input--name"
|
||||||
|
type="text"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Alert name"
|
||||||
|
aria-label="Alert name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value)
|
||||||
|
if (err) setErr(null)
|
||||||
|
}}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
/>
|
||||||
|
<div className="alr-seg" role="group" aria-label="Alert type">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={type === 'telegram' ? 'alr-seg-btn on' : 'alr-seg-btn'}
|
||||||
|
aria-pressed={type === 'telegram'}
|
||||||
|
onClick={() => setType('telegram')}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
>
|
||||||
|
Telegram
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={type === 'webhook' ? 'alr-seg-btn on' : 'alr-seg-btn'}
|
||||||
|
aria-pressed={type === 'webhook'}
|
||||||
|
onClick={() => setType('webhook')}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
>
|
||||||
|
Webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'telegram' ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="alr-input"
|
||||||
|
type="password"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Bot token (kept secret)"
|
||||||
|
aria-label="Telegram bot token"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => {
|
||||||
|
setToken(e.target.value)
|
||||||
|
if (err) setErr(null)
|
||||||
|
}}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="alr-input"
|
||||||
|
type="text"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Chat ID (e.g. -1001234567890)"
|
||||||
|
aria-label="Telegram chat ID"
|
||||||
|
value={chatId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChatId(e.target.value)
|
||||||
|
if (err) setErr(null)
|
||||||
|
}}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="alr-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="https://hooks.example.com/…"
|
||||||
|
aria-label="Webhook URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value)
|
||||||
|
if (err) setErr(null)
|
||||||
|
}}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset className="alr-events" aria-label="Events to notify on">
|
||||||
|
{ALERT_EVENTS.map((ev) => (
|
||||||
|
<label key={ev.id} className="alr-event">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={events.includes(ev.id)}
|
||||||
|
onChange={() => toggleEvent(ev.id)}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
/>
|
||||||
|
<span>{ev.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="alr-delivery">
|
||||||
|
<label className="alr-resp">
|
||||||
|
<span className="alr-resp-label mono">Deliver via</span>
|
||||||
|
<DetourSelect
|
||||||
|
value={via}
|
||||||
|
catalog={catalog}
|
||||||
|
valid={valid}
|
||||||
|
busy={busy}
|
||||||
|
disabled={disabled}
|
||||||
|
ariaLabel="Deliver alert via"
|
||||||
|
onChange={setVia}
|
||||||
|
directLabel="Direct (default)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="alr-fallback">
|
||||||
|
<Toggle
|
||||||
|
pressed={fallback}
|
||||||
|
onChange={setFallback}
|
||||||
|
label={fallback ? 'Disable direct fallback' : 'Enable direct fallback'}
|
||||||
|
disabled={busy || disabled || !routed}
|
||||||
|
/>
|
||||||
|
<span className="alr-fallback-label mono">Fallback to direct</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{routed && !fallback && (
|
||||||
|
<p className="alr-note" role="note">
|
||||||
|
{VIA_NO_FALLBACK_NOTE}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="alr-add-actions">
|
||||||
|
{err && (
|
||||||
|
<p className="alr-field-err" role="alert">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variant="primary" disabled={busy || disabled}>
|
||||||
|
{busy ? 'Saving…' : 'Add alert'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertRow({
|
||||||
|
alert,
|
||||||
|
busy,
|
||||||
|
catalog,
|
||||||
|
valid,
|
||||||
|
onToggle,
|
||||||
|
onVia,
|
||||||
|
onFallback,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
alert: Alert
|
||||||
|
busy: boolean
|
||||||
|
catalog: DetourCatalog
|
||||||
|
valid: Set<string>
|
||||||
|
onToggle: (on: boolean) => void
|
||||||
|
onVia: (v: string) => void
|
||||||
|
onFallback: (on: boolean) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
// Never render the token/URL in clear — show a masked descriptor only.
|
||||||
|
const detail = useMemo(() => {
|
||||||
|
if (alert.Type === 'telegram') {
|
||||||
|
return { text: `chat ${alert.ChatID || '—'}`, masked: !!alert.Token }
|
||||||
|
}
|
||||||
|
const { host, masked } = maskUrl(alert.URL ?? '')
|
||||||
|
return { text: host, masked: masked || !!alert.URL }
|
||||||
|
}, [alert.Type, alert.ChatID, alert.Token, alert.URL])
|
||||||
|
|
||||||
|
const events = asArray(alert.Events)
|
||||||
|
const canon = useMemo(() => canonDetour(alert.Via, catalog), [alert.Via, catalog])
|
||||||
|
const route = useMemo(() => describeDetour(canon, catalog, valid), [canon, catalog, valid])
|
||||||
|
const routed = canon !== 'direct'
|
||||||
|
const fallback = alert.Fallback ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="alr-row">
|
||||||
|
<Toggle
|
||||||
|
pressed={alert.Enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
label={`${alert.Enabled ? 'Disable' : 'Enable'} alert ${alert.Name}`}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<div className="alr-row-main">
|
||||||
|
<div className="alr-row-l1">
|
||||||
|
<span className="alr-row-name">{alert.Name}</span>
|
||||||
|
<span className="alr-badge">{alert.Type}</span>
|
||||||
|
{events.map((e) => (
|
||||||
|
<span key={e} className="alr-badge alr-badge--accent">
|
||||||
|
{e}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="alr-row-l2 mono">
|
||||||
|
<span className="alr-row-detail">{detail.text}</span>
|
||||||
|
{detail.masked && (
|
||||||
|
<span className="alr-masked" title="Secret is stored but hidden here">
|
||||||
|
secret hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{route.direct ? (
|
||||||
|
<span className="alr-path">direct</span>
|
||||||
|
) : (
|
||||||
|
<span className="alr-path" data-active="on" data-missing={route.missing ? 'y' : undefined}>
|
||||||
|
{route.prefix} <strong className="alr-path-name">{route.name}</strong>
|
||||||
|
{route.missing && <span className="alr-path-flag"> (missing)</span>}
|
||||||
|
{fallback ? ' · +direct fallback' : ' · no fallback'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{routed && !fallback && <p className="alr-note alr-note--row">{VIA_NO_FALLBACK_NOTE}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="alr-ctl">
|
||||||
|
<label className="alr-detour">
|
||||||
|
<span className="alr-detour-label mono">Deliver via</span>
|
||||||
|
<DetourSelect
|
||||||
|
value={canon}
|
||||||
|
catalog={catalog}
|
||||||
|
valid={valid}
|
||||||
|
busy={busy}
|
||||||
|
disabled={false}
|
||||||
|
ariaLabel={`Deliver alert ${alert.Name} via`}
|
||||||
|
onChange={onVia}
|
||||||
|
directLabel="Direct (default)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="alr-fallback">
|
||||||
|
<Toggle
|
||||||
|
pressed={fallback}
|
||||||
|
onChange={onFallback}
|
||||||
|
label={`${fallback ? 'Disable' : 'Enable'} direct fallback for ${alert.Name}`}
|
||||||
|
disabled={busy || !routed}
|
||||||
|
/>
|
||||||
|
<span className="alr-fallback-label mono">Fallback to direct</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="alr-del"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`Delete alert ${alert.Name}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live delivery picker: option list built from the Model's targets. */
|
||||||
|
function DetourSelect({
|
||||||
|
value,
|
||||||
|
catalog,
|
||||||
|
valid,
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
ariaLabel,
|
||||||
|
onChange,
|
||||||
|
directLabel = 'Direct (no proxy)',
|
||||||
|
}: {
|
||||||
|
value: string // canonical value
|
||||||
|
catalog: DetourCatalog
|
||||||
|
valid: Set<string>
|
||||||
|
busy: boolean
|
||||||
|
disabled: boolean
|
||||||
|
ariaLabel: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
directLabel?: string
|
||||||
|
}) {
|
||||||
|
const missing = value !== 'direct' && !valid.has(value)
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="alr-select alr-detour-select"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyPlate({ title, body }: { title: string; body: string }) {
|
||||||
|
return (
|
||||||
|
<div className="alr-empty">
|
||||||
|
<span className="alr-empty-title mono">{title}</span>
|
||||||
|
<p className="alr-empty-body">{body}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+108
-53
@@ -11,6 +11,8 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { Globals, Status } from '../api'
|
import type { Globals, Status } from '../api'
|
||||||
|
import { engineReadout, killSwitchReadout } from '../planeState'
|
||||||
|
import { onPendingConfirmExpire, usePendingConfirm } from '../pendingConfirm'
|
||||||
|
|
||||||
// Short, readable config hash — drops the "sha256:" prefix like the footer does.
|
// Short, readable config hash — drops the "sha256:" prefix like the footer does.
|
||||||
function short(hash: string): string {
|
function short(hash: string): string {
|
||||||
@@ -25,13 +27,6 @@ function msg(e: unknown): string {
|
|||||||
|
|
||||||
type Busy = 'apply' | 'confirm' | 'rollback' | null
|
type Busy = 'apply' | 'confirm' | 'rollback' | null
|
||||||
|
|
||||||
/** A pending commit-confirm window: the daemon has armed an auto-rollback. */
|
|
||||||
interface Armed {
|
|
||||||
total: number // the ConfirmTimeout the window started with
|
|
||||||
remaining: number // seconds left before the daemon reverts
|
|
||||||
appliedHash: string // the hash that went live on apply (the "after" of apply)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionKind = 'apply' | 'confirm' | 'rollback' | 'expire'
|
type ActionKind = 'apply' | 'confirm' | 'rollback' | 'expire'
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
kind: ActionKind
|
kind: ActionKind
|
||||||
@@ -57,7 +52,11 @@ export default function Apply() {
|
|||||||
const [configError, setConfigError] = useState<string | null>(null)
|
const [configError, setConfigError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [busy, setBusy] = useState<Busy>(null)
|
const [busy, setBusy] = useState<Busy>(null)
|
||||||
const [armed, setArmed] = useState<Armed | null>(null)
|
// The armed window is app-wide state, not this page's: it is recorded by the
|
||||||
|
// api layer on every apply and survives a reload. Keeping it local is what made
|
||||||
|
// refreshing this tab lose both the countdown and the only button that could
|
||||||
|
// stop it. See pendingConfirm.ts.
|
||||||
|
const armed = usePendingConfirm()
|
||||||
const [result, setResult] = useState<ActionResult | null>(null)
|
const [result, setResult] = useState<ActionResult | null>(null)
|
||||||
const [confirmingRollback, setConfirmingRollback] = useState(false)
|
const [confirmingRollback, setConfirmingRollback] = useState(false)
|
||||||
|
|
||||||
@@ -104,32 +103,64 @@ export default function Apply() {
|
|||||||
void loadConfig()
|
void loadConfig()
|
||||||
}, [loadConfig])
|
}, [loadConfig])
|
||||||
|
|
||||||
// ---- commit-confirm countdown: a calm 1s numeric tick, effect-scoped so the
|
// The window running out does NOT mean the daemon rolled back.
|
||||||
// timer is always cleared on unmount / confirm / rollback (no leaked intervals) ----
|
//
|
||||||
|
// apply.ArmRollback captures the data-plane generation when it arms, and on
|
||||||
|
// expiry it compares. If anything re-applied the plane in between — another
|
||||||
|
// panel apply, SIGHUP, a hotplug or the once-a-minute cron reconcile, the WAN
|
||||||
|
// profile auto-switch — it disarms and KEEPS the running config, logging "NOT
|
||||||
|
// rolling back" and nothing else. That is the common case on a production
|
||||||
|
// router, and this page used to print "daemon auto-rolled back to last-good
|
||||||
|
// config" for it: a confident report of an event that did not happen, with a
|
||||||
|
// hash pair underneath that quietly said "unchanged".
|
||||||
|
//
|
||||||
|
// The panel cannot see which branch ran — the daemon says so only in its log.
|
||||||
|
// So it reports the one thing it CAN observe, the live config hash, and waits
|
||||||
|
// for the revert to land before reading it (a rollback is a full re-apply and
|
||||||
|
// does not complete the instant the timer fires).
|
||||||
|
const liveHashRef = useRef('')
|
||||||
|
liveHashRef.current = status?.hash ?? ''
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!armed) return
|
let cancelled = false
|
||||||
if (armed.remaining <= 0) {
|
const off = onPendingConfirmExpire(() => {
|
||||||
// Window elapsed — the daemon reverts to last-good on its own. Observe it.
|
const before = liveHashRef.current
|
||||||
const before = armed.appliedHash
|
flash('Confirm window elapsed')
|
||||||
setArmed(null)
|
setResult({
|
||||||
flash('Auto-rolled back')
|
kind: 'expire',
|
||||||
|
tone: 'warn',
|
||||||
|
text: 'Confirm window elapsed. Reading what the daemon did…',
|
||||||
|
before,
|
||||||
|
after: before,
|
||||||
|
})
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const after = (await refreshStatus())?.hash ?? ''
|
let after = before
|
||||||
|
for (let i = 0; i < 4 && !cancelled; i++) {
|
||||||
|
await new Promise((r) => window.setTimeout(r, 1500))
|
||||||
|
if (cancelled) return
|
||||||
|
after = (await refreshStatus())?.hash ?? after
|
||||||
|
if (after !== before) break
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
setResult({
|
setResult({
|
||||||
kind: 'expire',
|
kind: 'expire',
|
||||||
tone: 'warn',
|
tone: 'warn',
|
||||||
text: 'Confirm window elapsed — daemon auto-rolled back to last-good config.',
|
text:
|
||||||
|
after !== before
|
||||||
|
? 'Confirm window elapsed and the live config changed — the daemon reverted to its last-good config.'
|
||||||
|
: 'Confirm window elapsed and the live config has not changed, so this config is still running. ' +
|
||||||
|
'The daemon only reverts if nothing else re-applied the data plane while the window was open; ' +
|
||||||
|
'otherwise it stands down and keeps what is live. Which one happened is in the daemon log — ' +
|
||||||
|
'download it from Settings, or run `logread -e shater`.',
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
return
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
off()
|
||||||
}
|
}
|
||||||
const id = window.setTimeout(() => {
|
}, [flash, refreshStatus])
|
||||||
setArmed((a) => (a ? { ...a, remaining: a.remaining - 1 } : a))
|
|
||||||
}, 1000)
|
|
||||||
return () => window.clearTimeout(id)
|
|
||||||
}, [armed, flash, refreshStatus])
|
|
||||||
|
|
||||||
const confirmWindow = globals?.ConfirmTimeout ?? 0
|
const confirmWindow = globals?.ConfirmTimeout ?? 0
|
||||||
|
|
||||||
@@ -146,8 +177,9 @@ export default function Apply() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const after = (await refreshStatus())?.hash ?? before
|
const after = (await refreshStatus())?.hash ?? before
|
||||||
|
// The window itself was recorded by api.apply(); this branch only writes the
|
||||||
|
// readout for it.
|
||||||
if (r.changed && confirmWindow > 0) {
|
if (r.changed && confirmWindow > 0) {
|
||||||
setArmed({ total: confirmWindow, remaining: confirmWindow, appliedHash: after })
|
|
||||||
setResult({
|
setResult({
|
||||||
kind: 'apply',
|
kind: 'apply',
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
@@ -179,7 +211,8 @@ export default function Apply() {
|
|||||||
const doConfirm = useCallback(async () => {
|
const doConfirm = useCallback(async () => {
|
||||||
const before = status?.hash ?? ''
|
const before = status?.hash ?? ''
|
||||||
setBusy('confirm')
|
setBusy('confirm')
|
||||||
setArmed(null) // stop the countdown immediately; confirm cancels the auto-rollback
|
// api.confirm() clears the shared window on success — the countdown stops the
|
||||||
|
// moment the daemon agrees, not the moment we asked.
|
||||||
try {
|
try {
|
||||||
const r = await apiConfirm()
|
const r = await apiConfirm()
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
@@ -208,7 +241,7 @@ export default function Apply() {
|
|||||||
const before = status?.hash ?? ''
|
const before = status?.hash ?? ''
|
||||||
setConfirmingRollback(false)
|
setConfirmingRollback(false)
|
||||||
setBusy('rollback')
|
setBusy('rollback')
|
||||||
setArmed(null) // rolling back also cancels any pending confirm window
|
// api.rollback() clears the shared window on success (rolling back ends it).
|
||||||
try {
|
try {
|
||||||
const r = await apiRollback()
|
const r = await apiRollback()
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
@@ -237,19 +270,37 @@ export default function Apply() {
|
|||||||
}, [status, flash, refreshStatus])
|
}, [status, flash, refreshStatus])
|
||||||
|
|
||||||
// ---- derived display state (mirrors Overview's LED semantics) ----
|
// ---- derived display state (mirrors Overview's LED semantics) ----
|
||||||
const killArmed = globals ? globals.KillSwitch === 'closed' : false
|
//
|
||||||
const engineVariant: LedVariant = !status
|
// The LIVE kill-switch wins over the saved one, exactly as on Overview: this row
|
||||||
? 'off'
|
// is a status readout, and the config on disk can already differ from what is
|
||||||
: status.running && status.active
|
// installed. Falls back to the config only while /api/status is unread.
|
||||||
? 'on'
|
const killArmed = (status?.kill_switch ?? globals?.KillSwitch ?? 'closed') === 'closed'
|
||||||
: status.running
|
// Whether that setting is actually installed — same three-plus-unknown reading
|
||||||
? 'amber'
|
// as Overview, so the two pages cannot disagree about the same router.
|
||||||
: 'crit'
|
const kill = killSwitchReadout(status, globals?.KillSwitch)
|
||||||
const dataVariant: LedVariant = status?.table ? 'on' : status?.running ? 'amber' : 'off'
|
const killWord =
|
||||||
|
kill.state === 'open'
|
||||||
|
? 'open'
|
||||||
|
: kill.state === 'armed'
|
||||||
|
? 'fail-closed'
|
||||||
|
: kill.state === 'inert'
|
||||||
|
? 'closed · not in effect'
|
||||||
|
: 'closed · not reported'
|
||||||
|
// Every engine mark on this page comes from ONE reading, and that reading is
|
||||||
|
// able to say "stopped" — see planeState.engineState for why `status.running`
|
||||||
|
// could not. This page is where someone lands when the network is down; three
|
||||||
|
// green lamps here were the difference between "I broke it" and "nothing broke".
|
||||||
|
const engine = engineReadout(status)
|
||||||
|
const engineVariant: LedVariant = engine.variant
|
||||||
|
// No nft table means there is no data plane at all. Under a fail-closed switch
|
||||||
|
// that is a leak (crit); under an open one it is the documented choice (amber).
|
||||||
|
// It used to go amber whenever `running` was true — i.e. always — and unlit
|
||||||
|
// otherwise, so the one state worth shouting about had no colour of its own.
|
||||||
|
const dataVariant: LedVariant = status?.table ? 'on' : !status ? 'off' : killArmed ? 'crit' : 'amber'
|
||||||
const configVariant: LedVariant = status?.enabled ? 'on' : 'amber'
|
const configVariant: LedVariant = status?.enabled ? 'on' : 'amber'
|
||||||
|
|
||||||
const liveHash = short(status?.hash ?? '')
|
const liveHash = short(status?.hash ?? '')
|
||||||
const pct = armed ? Math.max(0, Math.round((armed.remaining / armed.total) * 100)) : 0
|
const pct = armed ? Math.max(0, Math.round((armed.remaining / armed.pending.total) * 100)) : 0
|
||||||
|
|
||||||
// Only offer rollback when the daemon says one would revert something: an armed
|
// Only offer rollback when the daemon says one would revert something: an armed
|
||||||
// commit-confirm snapshot, or an engine last-good predecessor. When false there
|
// commit-confirm snapshot, or an engine last-good predecessor. When false there
|
||||||
@@ -264,9 +315,7 @@ export default function Apply() {
|
|||||||
label="Engine"
|
label="Engine"
|
||||||
variant={engineVariant}
|
variant={engineVariant}
|
||||||
pulse={engineVariant === 'on'}
|
pulse={engineVariant === 'on'}
|
||||||
value={
|
value={engine.word}
|
||||||
!status ? 'checking…' : status.running ? (status.active ? 'active' : 'idle') : 'stopped'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<StatusPip
|
<StatusPip
|
||||||
label="Config"
|
label="Config"
|
||||||
@@ -278,11 +327,7 @@ export default function Apply() {
|
|||||||
variant={dataVariant}
|
variant={dataVariant}
|
||||||
value={status?.table ? 'nft installed' : 'no table'}
|
value={status?.table ? 'nft installed' : 'no table'}
|
||||||
/>
|
/>
|
||||||
<StatusPip
|
<StatusPip label="Kill-switch" variant={kill.variant} value={killWord} />
|
||||||
label="Kill-switch"
|
|
||||||
variant={killArmed ? 'on' : 'amber'}
|
|
||||||
value={killArmed ? 'fail-closed' : 'open'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusError && (
|
{statusError && (
|
||||||
@@ -310,9 +355,13 @@ export default function Apply() {
|
|||||||
unit="· sha256"
|
unit="· sha256"
|
||||||
led={{ variant: configVariant }}
|
led={{ variant: configVariant }}
|
||||||
rows={[
|
rows={[
|
||||||
{ k: 'engine', v: status?.running ? 'running' : 'stopped', hot: !status?.running },
|
{ k: 'engine', v: engine.word, hot: engineVariant === 'crit' },
|
||||||
{ k: 'data plane', v: status?.table ? 'nft installed' : 'no table' },
|
{
|
||||||
{ k: 'kill-switch', v: killArmed ? 'fail-closed' : 'open', hot: !killArmed },
|
k: 'data plane',
|
||||||
|
v: status?.table ? 'nft installed' : 'no table',
|
||||||
|
hot: dataVariant === 'crit',
|
||||||
|
},
|
||||||
|
{ k: 'kill-switch', v: killWord, hot: kill.variant === 'crit' || !killArmed },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Module
|
<Module
|
||||||
@@ -325,7 +374,7 @@ export default function Apply() {
|
|||||||
}
|
}
|
||||||
led={{ variant: engineVariant }}
|
led={{ variant: engineVariant }}
|
||||||
rows={[
|
rows={[
|
||||||
{ k: 'state', v: !status ? 'checking…' : status.active ? 'active' : 'idle' },
|
{ k: 'state', v: engine.word, hot: engineVariant === 'crit' },
|
||||||
{ k: 'config', v: status?.enabled ? 'enabled' : 'disabled' },
|
{ k: 'config', v: status?.enabled ? 'enabled' : 'disabled' },
|
||||||
{ k: 'schema', v: globals ? `v${globals.SchemaVersion}` : '—' },
|
{ k: 'schema', v: globals ? `v${globals.SchemaVersion}` : '—' },
|
||||||
]}
|
]}
|
||||||
@@ -362,9 +411,13 @@ export default function Apply() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="cc-info">
|
<div className="cc-info">
|
||||||
<p className="cc-copy">
|
<p className="cc-copy">
|
||||||
Applied config <span className="mono">{short(armed.appliedHash)}</span> is live but
|
{/* The live hash IS the applied one while a window is open — that
|
||||||
not yet kept. Confirm to keep it — otherwise the daemon rolls back to the last-good
|
is what "live but not kept" means — so the readout survives a
|
||||||
config when the timer hits zero.
|
reload instead of depending on what this tab remembers. */}
|
||||||
|
Applied config <span className="mono">{liveHash}</span> is live but not yet kept.
|
||||||
|
Confirm to keep it. At zero the daemon rolls back to the last-good config — unless
|
||||||
|
something else re-applies the data plane first, in which case it stands down and
|
||||||
|
keeps whatever is live.
|
||||||
</p>
|
</p>
|
||||||
<div className="cc-bar" aria-hidden="true">
|
<div className="cc-bar" aria-hidden="true">
|
||||||
<span className="cc-bar-fill" style={{ width: `${pct}%` }} />
|
<span className="cc-bar-fill" style={{ width: `${pct}%` }} />
|
||||||
@@ -481,7 +534,9 @@ function labelFor(kind: ActionKind): string {
|
|||||||
case 'rollback':
|
case 'rollback':
|
||||||
return 'Rollback'
|
return 'Rollback'
|
||||||
case 'expire':
|
case 'expire':
|
||||||
return 'Auto-rollback'
|
// NOT "Auto-rollback": on expiry the daemon either reverts or stands down,
|
||||||
|
// and this page cannot tell which. Name the event it did observe.
|
||||||
|
return 'Window elapsed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-68
@@ -387,6 +387,79 @@
|
|||||||
.dns-row-state[data-active='on'] {
|
.dns-row-state[data-active='on'] {
|
||||||
color: var(--led-on);
|
color: var(--led-on);
|
||||||
}
|
}
|
||||||
|
/* A list that is switched on but has nothing loaded is not "off" and is certainly
|
||||||
|
not "filtering" — warn semantics, the same amber the badges use. */
|
||||||
|
.dns-row-state[data-active='warn'] {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- remote-list freshness (mirrors the rule-set rows on Routing) ---- */
|
||||||
|
.dns-row-sync {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.dns-sync-fresh {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.dns-sync-fresh[data-never='y'] {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.dns-sync-every,
|
||||||
|
.dns-sync-rules {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.dns-sync-every::before {
|
||||||
|
content: '↻ ';
|
||||||
|
}
|
||||||
|
.dns-sync-update {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--accent-soft);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--raised);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--track-label);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.dns-sync-update:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
.dns-sync-update:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.dns-sync-update:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.dns-sync-spin {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dns-sync-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes dns-sync-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* badge — groove-bordered, not orange (accent stays reserved) */
|
/* badge — groove-bordered, not orange (accent stays reserved) */
|
||||||
.dns-badge {
|
.dns-badge {
|
||||||
@@ -575,80 +648,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* alert delivery: deliver-via picker + fallback toggle + caution note */
|
|
||||||
.dns-alert-delivery {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px 20px;
|
|
||||||
}
|
|
||||||
.dns-fallback {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dns-fallback-label {
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: var(--track-label, 0.18em);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--faint);
|
|
||||||
}
|
|
||||||
/* the per-alert-row delivery controls sit inline in the row (like .dns-detour) */
|
|
||||||
.dns-alert-ctl {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.dns-alert-note {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--amber);
|
|
||||||
max-width: 56ch;
|
|
||||||
}
|
|
||||||
.dns-alert-note--row {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.dns-alert-ctl {
|
|
||||||
flex-basis: 100%;
|
|
||||||
order: 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* alert event checkboxes */
|
|
||||||
.dns-events {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 16px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
.dns-event {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--fp-text, inherit);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dns-event input {
|
|
||||||
accent-color: var(--fp-accent, currentColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.dns-skel {
|
.dns-skel {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
/* No spin under reduced motion — the static ring + "Updating…" label carry it. */
|
||||||
|
.dns-sync-spin {
|
||||||
|
animation: none;
|
||||||
|
border-top-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
.dns-chip,
|
.dns-chip,
|
||||||
.dns-input,
|
.dns-input,
|
||||||
.dns-seg-btn {
|
.dns-seg-btn,
|
||||||
|
.dns-sync-update {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-499
@@ -1,8 +1,16 @@
|
|||||||
import './DNS.css'
|
import './DNS.css'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, CatSuggest, Led, SrcPicker, Toggle } from '../components'
|
import { Button, CatSuggest, Led, SrcPicker, Toggle, useConfirm } from '../components'
|
||||||
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
|
import {
|
||||||
import type { Alert, DNSRule, Model, Resolver } from '../api'
|
apply as apiApply,
|
||||||
|
getConfig,
|
||||||
|
getRulesetStatus,
|
||||||
|
putConfig,
|
||||||
|
updateRuleset as apiUpdateRuleset,
|
||||||
|
ApiError,
|
||||||
|
} from '../api'
|
||||||
|
import type { DNSRule, Model, Resolver, RulesetStatus } from '../api'
|
||||||
|
import { everyLabel, relFetch } from '../format'
|
||||||
|
|
||||||
// The DNS / Blocklists page is a thin editor over the desired-state Model —
|
// The DNS / Blocklists page is a thin editor over the desired-state Model —
|
||||||
// exactly like Nodes.tsx. Every edit rewrites the relevant slice in-place, PUTs
|
// exactly like Nodes.tsx. Every edit rewrites the relevant slice in-place, PUTs
|
||||||
@@ -52,28 +60,9 @@ type GlobalsX = Model['Globals'] & { DNSFilter?: boolean }
|
|||||||
type DNSModel = Model & {
|
type DNSModel = Model & {
|
||||||
Blocklists?: Blocklist[] | null
|
Blocklists?: Blocklist[] | null
|
||||||
Allowlists?: Allowlist[] | null
|
Allowlists?: Allowlist[] | null
|
||||||
Alerts?: Alert[] | null
|
|
||||||
DNSRules?: DNSRule[] | null
|
DNSRules?: DNSRule[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every event the daemon actually sends. A retired health-probe event was left
|
|
||||||
* out on purpose: nothing ever fired it, so a channel that subscribed to it would
|
|
||||||
* just stay quiet forever — the one failure mode an alert must not have. Only
|
|
||||||
* events with a live firing path are offered here.
|
|
||||||
*/
|
|
||||||
const ALERT_EVENTS: ReadonlyArray<{ id: string; label: string }> = [
|
|
||||||
{ id: 'killswitch', label: 'Kill-switch' },
|
|
||||||
{ id: 'apply_fail', label: 'Apply failure' },
|
|
||||||
{ id: 'new_device', label: 'New device' },
|
|
||||||
{ id: 'sub_expiry', label: 'Subscription expiring' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Shown when an alert routes through a detour with no direct fallback — the exact
|
|
||||||
// case where a tunnel-down alert could fail to send. The user asked for this.
|
|
||||||
const VIA_NO_FALLBACK_NOTE =
|
|
||||||
'A kill-switch/tunnel-down alert may not send if it routes through the affected tunnel — enable fallback.'
|
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
|
const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
|
||||||
@@ -220,6 +209,7 @@ function describeDetour(
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function DNS() {
|
export default function DNS() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<DNSModel | null>(null)
|
const [config, setConfig] = useState<DNSModel | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -301,7 +291,6 @@ export default function DNS() {
|
|||||||
const blocklists = useMemo(() => asArray(config?.Blocklists), [config])
|
const blocklists = useMemo(() => asArray(config?.Blocklists), [config])
|
||||||
const allowlists = useMemo(() => asArray(config?.Allowlists), [config])
|
const allowlists = useMemo(() => asArray(config?.Allowlists), [config])
|
||||||
const resolvers = useMemo<Resolver[]>(() => asArray(config?.Resolvers), [config])
|
const resolvers = useMemo<Resolver[]>(() => asArray(config?.Resolvers), [config])
|
||||||
const alerts = useMemo<Alert[]>(() => asArray(config?.Alerts), [config])
|
|
||||||
// Ascending Order — the engine evaluates DNS rules first-match, so the list is
|
// Ascending Order — the engine evaluates DNS rules first-match, so the list is
|
||||||
// shown and edited in the order it actually runs.
|
// shown and edited in the order it actually runs.
|
||||||
const dnsRules = useMemo<DNSRule[]>(
|
const dnsRules = useMemo<DNSRule[]>(
|
||||||
@@ -309,6 +298,68 @@ export default function DNS() {
|
|||||||
[config],
|
[config],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- did the lists actually LOAD? -----------------------------------------
|
||||||
|
//
|
||||||
|
// A blocklist row said "filtering" whenever the list and the master switch were
|
||||||
|
// both on. Neither of those is evidence that anything is being blocked: a
|
||||||
|
// url/geosite list is fetched by the engine, the daemon treats a failed fetch as
|
||||||
|
// a CRITICAL apply finding, and the row went on saying "filtering" through it.
|
||||||
|
// The Routing page had already been given this reading for rule-sets — the same
|
||||||
|
// endpoint, the same tags (`bl-<name>` / `al-<name>`) — and the DNS page never
|
||||||
|
// asked. Slow poll: lists refresh on a ~24h cadence, so 15s only has to catch a
|
||||||
|
// manual Update-now. Grouped by NAME because a geosite list with N categories
|
||||||
|
// reports N records.
|
||||||
|
const [listStatus, setListStatus] = useState<Map<string, RulesetStatus[]>>(new Map())
|
||||||
|
const [updatingLists, setUpdatingLists] = useState<Set<string>>(new Set())
|
||||||
|
const loadListStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const all = await getRulesetStatus()
|
||||||
|
const m = new Map<string, RulesetStatus[]>()
|
||||||
|
for (const s of all) {
|
||||||
|
if (s.kind !== 'blocklist' && s.kind !== 'allowlist') continue
|
||||||
|
const key = `${s.kind}:${s.name}`
|
||||||
|
const arr = m.get(key)
|
||||||
|
if (arr) arr.push(s)
|
||||||
|
else m.set(key, [s])
|
||||||
|
}
|
||||||
|
setListStatus(m)
|
||||||
|
} catch {
|
||||||
|
// Engine stopped or an older daemon — keep the last reading. The row falls
|
||||||
|
// back to "load not reported", which claims nothing either way.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
void loadListStatus()
|
||||||
|
const id = window.setInterval(() => void loadListStatus(), 15000)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [loadListStatus])
|
||||||
|
|
||||||
|
const updateList = useCallback(
|
||||||
|
async (kind: 'blocklist' | 'allowlist', name: string) => {
|
||||||
|
const key = `${kind}:${name}`
|
||||||
|
setUpdatingLists((prev) => new Set(prev).add(key))
|
||||||
|
try {
|
||||||
|
// One geo list can hold several categories, each its own engine tag.
|
||||||
|
const recs = listStatus.get(key) ?? []
|
||||||
|
const tags = recs.length
|
||||||
|
? recs.map((r) => r.tag)
|
||||||
|
: [`${kind === 'blocklist' ? 'bl' : 'al'}-${name}`]
|
||||||
|
for (const tag of tags) await apiUpdateRuleset(tag)
|
||||||
|
await loadListStatus()
|
||||||
|
flash(`${name} refreshed`)
|
||||||
|
} catch (e) {
|
||||||
|
flash(`Refresh failed — ${errText(e)}`)
|
||||||
|
} finally {
|
||||||
|
setUpdatingLists((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[listStatus, loadListStatus, flash],
|
||||||
|
)
|
||||||
|
|
||||||
const blOn = blocklists.filter((b) => b.Enabled).length
|
const blOn = blocklists.filter((b) => b.Enabled).length
|
||||||
const alOn = allowlists.filter((a) => a.Enabled).length
|
const alOn = allowlists.filter((a) => a.Enabled).length
|
||||||
const blNames = useMemo(() => new Set(blocklists.map((b) => b.Name)), [blocklists])
|
const blNames = useMemo(() => new Set(blocklists.map((b) => b.Name)), [blocklists])
|
||||||
@@ -422,15 +473,19 @@ export default function DNS() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeBlocklist = useCallback(
|
const removeBlocklist = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = blocklists[idx]
|
const target = blocklists[idx]
|
||||||
if (!window.confirm(`Delete blocklist “${target.Name}”? This removes it from the config.`))
|
const ok = await confirm({
|
||||||
return
|
label: 'Delete blocklist',
|
||||||
|
title: `Delete blocklist “${target.Name}”?`,
|
||||||
|
body: 'This removes it from the config.',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = blocklists.filter((_, i) => i !== idx)
|
const next = blocklists.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, Blocklists: next }, `Deleted ${target.Name}`)
|
void save({ ...config, Blocklists: next }, `Deleted ${target.Name}`)
|
||||||
},
|
},
|
||||||
[config, blocklists, save],
|
[config, blocklists, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- allowlist mutations --------------------------------------------------
|
// ---- allowlist mutations --------------------------------------------------
|
||||||
@@ -457,15 +512,19 @@ export default function DNS() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeAllowlist = useCallback(
|
const removeAllowlist = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = allowlists[idx]
|
const target = allowlists[idx]
|
||||||
if (!window.confirm(`Delete allowlist “${target.Name}”? This removes it from the config.`))
|
const ok = await confirm({
|
||||||
return
|
label: 'Delete allowlist',
|
||||||
|
title: `Delete allowlist “${target.Name}”?`,
|
||||||
|
body: 'This removes it from the config.',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = allowlists.filter((_, i) => i !== idx)
|
const next = allowlists.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, Allowlists: next }, `Deleted ${target.Name}`)
|
void save({ ...config, Allowlists: next }, `Deleted ${target.Name}`)
|
||||||
},
|
},
|
||||||
[config, allowlists, save],
|
[config, allowlists, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- resolver mutations ---------------------------------------------------
|
// ---- resolver mutations ---------------------------------------------------
|
||||||
@@ -491,15 +550,58 @@ export default function DNS() {
|
|||||||
[config, resolvers, save],
|
[config, resolvers, save],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a resolver, saying what it was still wired into.
|
||||||
|
*
|
||||||
|
* The three GLOBAL slots (default, fallback, endpoint) are cleared here, because
|
||||||
|
* a global pointing at nothing is never what anyone meant. The DNS RULES are a
|
||||||
|
* different matter: each one is a decision about which queries go where, and
|
||||||
|
* silently deleting or repointing them would change where a device's DNS goes
|
||||||
|
* without saying so. So they are named instead and left alone — the dialog is
|
||||||
|
* where the operator finds out they exist, which is precisely what this page
|
||||||
|
* used to skip: it cleared the two globals without a word and never mentioned
|
||||||
|
* the rules at all.
|
||||||
|
*/
|
||||||
const removeResolver = useCallback(
|
const removeResolver = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = resolvers[idx]
|
const target = resolvers[idx]
|
||||||
if (!window.confirm(`Delete resolver “${target.Name}”? This removes it from the config.`))
|
|
||||||
return
|
|
||||||
const next = resolvers.filter((_, i) => i !== idx)
|
|
||||||
// Don't leave default/fallback pointing at a resolver that no longer exists.
|
|
||||||
const g = { ...config.Globals }
|
const g = { ...config.Globals }
|
||||||
|
const slots: string[] = []
|
||||||
|
if (g.ResolverDefault === target.Name) slots.push('the default resolver')
|
||||||
|
if (g.ResolverFallback === target.Name) slots.push('the fallback resolver')
|
||||||
|
if (g.EndpointResolver === target.Name) slots.push('the endpoint resolver')
|
||||||
|
const usedBy = dnsRules.filter((r) => r.Resolver === target.Name)
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (slots.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`It is ${slots.join(' and ')} — ${
|
||||||
|
slots.length === 1 ? 'that slot is' : 'those slots are'
|
||||||
|
} cleared, so DNS falls back to the engine's built-in resolution.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (usedBy.length === 1) {
|
||||||
|
parts.push(
|
||||||
|
`One DNS rule still sends queries to it (order ${usedBy[0].Order}). It is left as it is and will have nowhere to resolve — repoint it before you apply.`,
|
||||||
|
)
|
||||||
|
} else if (usedBy.length > 1) {
|
||||||
|
parts.push(
|
||||||
|
`${usedBy.length} DNS rules still send queries to it (orders ${usedBy
|
||||||
|
.map((r) => r.Order)
|
||||||
|
.join(', ')}). They are left as they are and will have nowhere to resolve — repoint them before you apply.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (parts.length === 0) parts.push('Nothing else in the config points at it.')
|
||||||
|
|
||||||
|
const ok = await confirm({
|
||||||
|
label: 'Delete resolver',
|
||||||
|
title: `Delete resolver “${target.Name}”?`,
|
||||||
|
body: parts.join(' '),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
const next = resolvers.filter((_, i) => i !== idx)
|
||||||
|
// Don't leave default/fallback/endpoint pointing at a resolver that's gone.
|
||||||
const cleared: string[] = []
|
const cleared: string[] = []
|
||||||
if (g.ResolverDefault === target.Name) {
|
if (g.ResolverDefault === target.Name) {
|
||||||
g.ResolverDefault = ''
|
g.ResolverDefault = ''
|
||||||
@@ -509,12 +611,16 @@ export default function DNS() {
|
|||||||
g.ResolverFallback = ''
|
g.ResolverFallback = ''
|
||||||
cleared.push('fallback')
|
cleared.push('fallback')
|
||||||
}
|
}
|
||||||
|
if (g.EndpointResolver === target.Name) {
|
||||||
|
g.EndpointResolver = ''
|
||||||
|
cleared.push('endpoint')
|
||||||
|
}
|
||||||
const msg = cleared.length
|
const msg = cleared.length
|
||||||
? `Deleted ${target.Name} — cleared ${cleared.join(' & ')}`
|
? `Deleted ${target.Name} — cleared ${cleared.join(' & ')}`
|
||||||
: `Deleted ${target.Name}`
|
: `Deleted ${target.Name}`
|
||||||
void save({ ...config, Globals: g, Resolvers: next }, msg)
|
void save({ ...config, Globals: g, Resolvers: next }, msg)
|
||||||
},
|
},
|
||||||
[config, resolvers, save],
|
[config, resolvers, dnsRules, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
const setResolverDefault = useCallback(
|
const setResolverDefault = useCallback(
|
||||||
@@ -578,84 +684,21 @@ export default function DNS() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeDNSRule = useCallback(
|
const removeDNSRule = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = dnsRules[idx]
|
const target = dnsRules[idx]
|
||||||
if (!window.confirm(`Delete this DNS rule? Matching queries fall back to the default resolver.`))
|
const ok = await confirm({
|
||||||
return
|
label: 'Delete DNS rule',
|
||||||
|
title: 'Delete this DNS rule?',
|
||||||
|
body: 'Matching queries fall back to the default resolver.',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = dnsRules.filter((_, i) => i !== idx)
|
const next = dnsRules.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, DNSRules: next }, `Deleted DNS rule → ${target.Resolver}`)
|
void save({ ...config, DNSRules: next }, `Deleted DNS rule → ${target.Resolver}`)
|
||||||
},
|
},
|
||||||
[config, dnsRules, save],
|
[config, dnsRules, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- alert mutations ------------------------------------------------------
|
|
||||||
const addAlert = useCallback(
|
|
||||||
(draft: Alert): Promise<boolean> => {
|
|
||||||
if (!config) return Promise.resolve(false)
|
|
||||||
const taken = new Set(alerts.map((a) => a.Name))
|
|
||||||
const a: Alert = { ...draft, Name: uniqueName(draft.Name, taken) }
|
|
||||||
return save({ ...config, Alerts: [...alerts, a] }, `Added ${a.Name}`)
|
|
||||||
},
|
|
||||||
[config, alerts, save],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleAlert = useCallback(
|
|
||||||
(idx: number, on: boolean) => {
|
|
||||||
if (!config) return
|
|
||||||
const next = alerts.map((a, i) => (i === idx ? { ...a, Enabled: on } : a))
|
|
||||||
void save({ ...config, Alerts: next }, `${next[idx].Name} ${on ? 'enabled' : 'disabled'}`)
|
|
||||||
},
|
|
||||||
[config, alerts, save],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeAlert = useCallback(
|
|
||||||
(idx: number) => {
|
|
||||||
if (!config) return
|
|
||||||
const target = alerts[idx]
|
|
||||||
if (!window.confirm(`Delete alert “${target.Name}”? This removes it from the config.`)) return
|
|
||||||
const next = alerts.filter((_, i) => i !== idx)
|
|
||||||
void save({ ...config, Alerts: next }, `Deleted ${target.Name}`)
|
|
||||||
},
|
|
||||||
[config, alerts, save],
|
|
||||||
)
|
|
||||||
|
|
||||||
const setAlertVia = useCallback(
|
|
||||||
(idx: number, v: string) => {
|
|
||||||
if (!config) return
|
|
||||||
const via = v === 'direct' ? '' : v
|
|
||||||
const next = alerts.map((a, i) => (i === idx ? { ...a, Via: via || undefined } : a))
|
|
||||||
void save(
|
|
||||||
{ ...config, Alerts: next },
|
|
||||||
via ? `${next[idx].Name} delivers via ${via}` : `${next[idx].Name} delivers direct`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[config, alerts, save],
|
|
||||||
)
|
|
||||||
|
|
||||||
const setAlertFallback = useCallback(
|
|
||||||
(idx: number, on: boolean) => {
|
|
||||||
if (!config) return
|
|
||||||
const next = alerts.map((a, i) => (i === idx ? { ...a, Fallback: on || undefined } : a))
|
|
||||||
void save(
|
|
||||||
{ ...config, Alerts: next },
|
|
||||||
`${next[idx].Name} direct fallback ${on ? 'on' : 'off'}`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[config, alerts, save],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Alerts route through Direct/group/node/egress only (no chains) — the contract
|
|
||||||
// vocabulary for Alert.Via. Reuse the resolver detour catalog with chains dropped.
|
|
||||||
const alertCatalog = useMemo<DetourCatalog>(
|
|
||||||
() => ({ ...detourCatalog, chains: [] }),
|
|
||||||
[detourCatalog],
|
|
||||||
)
|
|
||||||
const alertValid = useMemo(() => detourValues(alertCatalog), [alertCatalog])
|
|
||||||
|
|
||||||
const alertNames = useMemo(() => new Set(alerts.map((a) => a.Name)), [alerts])
|
|
||||||
const alertsOn = alerts.filter((a) => a.Enabled).length
|
|
||||||
|
|
||||||
const loading = config === null && loadError === null
|
const loading = config === null && loadError === null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -883,8 +926,11 @@ export default function DNS() {
|
|||||||
categories={b.Categories}
|
categories={b.Categories}
|
||||||
response={b.Response}
|
response={b.Response}
|
||||||
filterOn={dnsFilterOn}
|
filterOn={dnsFilterOn}
|
||||||
|
statuses={listStatus.get(`blocklist:${b.Name}`) ?? null}
|
||||||
|
updating={updatingLists.has(`blocklist:${b.Name}`)}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onToggle={(on) => toggleBlocklist(i, on)}
|
onToggle={(on) => toggleBlocklist(i, on)}
|
||||||
|
onUpdateNow={() => void updateList('blocklist', b.Name)}
|
||||||
onDelete={() => removeBlocklist(i)}
|
onDelete={() => removeBlocklist(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -942,9 +988,13 @@ export default function DNS() {
|
|||||||
url={a.URL}
|
url={a.URL}
|
||||||
path={a.Path}
|
path={a.Path}
|
||||||
entries={a.Entries}
|
entries={a.Entries}
|
||||||
|
categories={a.Categories}
|
||||||
filterOn={dnsFilterOn}
|
filterOn={dnsFilterOn}
|
||||||
|
statuses={listStatus.get(`allowlist:${a.Name}`) ?? null}
|
||||||
|
updating={updatingLists.has(`allowlist:${a.Name}`)}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onToggle={(on) => toggleAllowlist(i, on)}
|
onToggle={(on) => toggleAllowlist(i, on)}
|
||||||
|
onUpdateNow={() => void updateList('allowlist', a.Name)}
|
||||||
onDelete={() => removeAllowlist(i)}
|
onDelete={() => removeAllowlist(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1069,57 +1119,6 @@ export default function DNS() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- 5. ALERTS ---- */}
|
|
||||||
<div className="dns-section" aria-label="Alerts">
|
|
||||||
<header className="dns-sec-hd">
|
|
||||||
<h2 className="dns-sec-title">Alerts</h2>
|
|
||||||
<span className="dns-sec-count mono">
|
|
||||||
{alertsOn} / {alerts.length} on
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<p className="dns-sec-note">
|
|
||||||
Out-of-band notifications. Delivered <strong>direct to the internet</strong> by default — so a
|
|
||||||
kill-switch or engine-down alert still reaches you when the proxy is down. You can route one
|
|
||||||
through a group, node or egress instead, with a direct fallback if that detour fails.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AddAlertForm
|
|
||||||
busy={busy}
|
|
||||||
disabled={!config}
|
|
||||||
taken={alertNames}
|
|
||||||
catalog={alertCatalog}
|
|
||||||
valid={alertValid}
|
|
||||||
onAdd={addAlert}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<ul className="dns-rows" aria-hidden="true">
|
|
||||||
<li className="dns-skel" />
|
|
||||||
</ul>
|
|
||||||
) : alerts.length === 0 ? (
|
|
||||||
<EmptyPlate
|
|
||||||
title="No alerts"
|
|
||||||
body="Add a Telegram bot or a webhook above to get notified when the kill-switch trips, a new device joins, or an apply fails."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="dns-rows">
|
|
||||||
{alerts.map((a, i) => (
|
|
||||||
<AlertRow
|
|
||||||
key={`${a.Name}-${i}`}
|
|
||||||
alert={a}
|
|
||||||
busy={busy}
|
|
||||||
catalog={alertCatalog}
|
|
||||||
valid={alertValid}
|
|
||||||
onToggle={(on) => toggleAlert(i, on)}
|
|
||||||
onVia={(v) => setAlertVia(i, v)}
|
|
||||||
onFallback={(on) => setAlertFallback(i, on)}
|
|
||||||
onDelete={() => removeAlert(i)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className="toast" role="status">
|
<div className="toast" role="status">
|
||||||
{toast}
|
{toast}
|
||||||
@@ -1129,342 +1128,6 @@ export default function DNS() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- alert add form + row --------------------------------------------------
|
|
||||||
|
|
||||||
function AddAlertForm({
|
|
||||||
busy,
|
|
||||||
disabled,
|
|
||||||
taken,
|
|
||||||
catalog,
|
|
||||||
valid,
|
|
||||||
onAdd,
|
|
||||||
}: {
|
|
||||||
busy: boolean
|
|
||||||
disabled: boolean
|
|
||||||
taken: Set<string>
|
|
||||||
catalog: DetourCatalog
|
|
||||||
valid: Set<string>
|
|
||||||
onAdd: (a: Alert) => Promise<boolean>
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [type, setType] = useState<'telegram' | 'webhook'>('telegram')
|
|
||||||
const [token, setToken] = useState('')
|
|
||||||
const [chatId, setChatId] = useState('')
|
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [events, setEvents] = useState<string[]>(['killswitch'])
|
|
||||||
const [via, setVia] = useState('direct')
|
|
||||||
const [fallback, setFallback] = useState(false)
|
|
||||||
const [err, setErr] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setName('')
|
|
||||||
setType('telegram')
|
|
||||||
setToken('')
|
|
||||||
setChatId('')
|
|
||||||
setUrl('')
|
|
||||||
setEvents(['killswitch'])
|
|
||||||
setVia('direct')
|
|
||||||
setFallback(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleEvent = (id: string) =>
|
|
||||||
setEvents((prev) => (prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id]))
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
const nm = name.trim()
|
|
||||||
if (!nm) {
|
|
||||||
setErr('Give the alert a name.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (taken.has(nm)) {
|
|
||||||
setErr(`An alert named “${nm}” already exists.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (type === 'telegram') {
|
|
||||||
if (!token.trim() || !chatId.trim()) {
|
|
||||||
setErr('Telegram needs a bot token and a chat ID.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if (!HTTP_RE.test(url.trim())) {
|
|
||||||
setErr('Enter an http(s):// webhook URL.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (events.length === 0) {
|
|
||||||
setErr('Pick at least one event to notify on.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setErr(null)
|
|
||||||
const routed = via !== 'direct'
|
|
||||||
const routing = { Via: routed ? via : undefined, Fallback: routed && fallback ? true : undefined }
|
|
||||||
const draft: Alert =
|
|
||||||
type === 'telegram'
|
|
||||||
? { Name: nm, Enabled: true, Type: 'telegram', Token: token.trim(), ChatID: chatId.trim(), Events: events, ...routing }
|
|
||||||
: { Name: nm, Enabled: true, Type: 'webhook', URL: url.trim(), Events: events, ...routing }
|
|
||||||
const ok = await onAdd(draft)
|
|
||||||
if (ok) reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
const routed = via !== 'direct'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="dns-add"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
void submit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="dns-add-top">
|
|
||||||
<input
|
|
||||||
className="dns-input dns-input--name"
|
|
||||||
type="text"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Alert name"
|
|
||||||
aria-label="Alert name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setName(e.target.value)
|
|
||||||
if (err) setErr(null)
|
|
||||||
}}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
/>
|
|
||||||
<div className="dns-seg" role="group" aria-label="Alert type">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={type === 'telegram' ? 'dns-seg-btn on' : 'dns-seg-btn'}
|
|
||||||
aria-pressed={type === 'telegram'}
|
|
||||||
onClick={() => setType('telegram')}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
>
|
|
||||||
Telegram
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={type === 'webhook' ? 'dns-seg-btn on' : 'dns-seg-btn'}
|
|
||||||
aria-pressed={type === 'webhook'}
|
|
||||||
onClick={() => setType('webhook')}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
>
|
|
||||||
Webhook
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{type === 'telegram' ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
className="dns-input"
|
|
||||||
type="password"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Bot token (kept secret)"
|
|
||||||
aria-label="Telegram bot token"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => {
|
|
||||||
setToken(e.target.value)
|
|
||||||
if (err) setErr(null)
|
|
||||||
}}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="dns-input"
|
|
||||||
type="text"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Chat ID (e.g. -1001234567890)"
|
|
||||||
aria-label="Telegram chat ID"
|
|
||||||
value={chatId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setChatId(e.target.value)
|
|
||||||
if (err) setErr(null)
|
|
||||||
}}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className="dns-input"
|
|
||||||
type="text"
|
|
||||||
inputMode="url"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="https://hooks.example.com/…"
|
|
||||||
aria-label="Webhook URL"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUrl(e.target.value)
|
|
||||||
if (err) setErr(null)
|
|
||||||
}}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<fieldset className="dns-events" aria-label="Events to notify on">
|
|
||||||
{ALERT_EVENTS.map((ev) => (
|
|
||||||
<label key={ev.id} className="dns-event">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={events.includes(ev.id)}
|
|
||||||
onChange={() => toggleEvent(ev.id)}
|
|
||||||
disabled={busy || disabled}
|
|
||||||
/>
|
|
||||||
<span>{ev.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="dns-alert-delivery">
|
|
||||||
<label className="dns-resp">
|
|
||||||
<span className="dns-resp-label mono">Deliver via</span>
|
|
||||||
<DetourSelect
|
|
||||||
value={via}
|
|
||||||
catalog={catalog}
|
|
||||||
valid={valid}
|
|
||||||
busy={busy}
|
|
||||||
disabled={disabled}
|
|
||||||
ariaLabel="Deliver alert via"
|
|
||||||
onChange={setVia}
|
|
||||||
directLabel="Direct (default)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="dns-fallback">
|
|
||||||
<Toggle
|
|
||||||
pressed={fallback}
|
|
||||||
onChange={setFallback}
|
|
||||||
label={fallback ? 'Disable direct fallback' : 'Enable direct fallback'}
|
|
||||||
disabled={busy || disabled || !routed}
|
|
||||||
/>
|
|
||||||
<span className="dns-fallback-label mono">Fallback to direct</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{routed && !fallback && (
|
|
||||||
<p className="dns-alert-note" role="note">
|
|
||||||
{VIA_NO_FALLBACK_NOTE}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="dns-add-actions">
|
|
||||||
{err && (
|
|
||||||
<p className="dns-field-err" role="alert">
|
|
||||||
{err}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button type="submit" variant="primary" disabled={busy || disabled}>
|
|
||||||
{busy ? 'Saving…' : 'Add alert'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertRow({
|
|
||||||
alert,
|
|
||||||
busy,
|
|
||||||
catalog,
|
|
||||||
valid,
|
|
||||||
onToggle,
|
|
||||||
onVia,
|
|
||||||
onFallback,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
alert: Alert
|
|
||||||
busy: boolean
|
|
||||||
catalog: DetourCatalog
|
|
||||||
valid: Set<string>
|
|
||||||
onToggle: (on: boolean) => void
|
|
||||||
onVia: (v: string) => void
|
|
||||||
onFallback: (on: boolean) => void
|
|
||||||
onDelete: () => void
|
|
||||||
}) {
|
|
||||||
// Never render the token/URL in clear — show a masked descriptor only.
|
|
||||||
const detail = useMemo(() => {
|
|
||||||
if (alert.Type === 'telegram') {
|
|
||||||
return { text: `chat ${alert.ChatID || '—'}`, masked: !!alert.Token }
|
|
||||||
}
|
|
||||||
const { host, masked } = maskUrl(alert.URL ?? '')
|
|
||||||
return { text: host, masked: masked || !!alert.URL }
|
|
||||||
}, [alert.Type, alert.ChatID, alert.Token, alert.URL])
|
|
||||||
|
|
||||||
const events = asArray(alert.Events)
|
|
||||||
const canon = useMemo(() => canonDetour(alert.Via, catalog), [alert.Via, catalog])
|
|
||||||
const route = useMemo(() => describeDetour(canon, catalog, valid), [canon, catalog, valid])
|
|
||||||
const routed = canon !== 'direct'
|
|
||||||
const fallback = alert.Fallback ?? false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="dns-row">
|
|
||||||
<Toggle
|
|
||||||
pressed={alert.Enabled}
|
|
||||||
onChange={onToggle}
|
|
||||||
label={`${alert.Enabled ? 'Disable' : 'Enable'} alert ${alert.Name}`}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
<div className="dns-row-main">
|
|
||||||
<div className="dns-row-l1">
|
|
||||||
<span className="dns-row-name">{alert.Name}</span>
|
|
||||||
<span className="dns-badge">{alert.Type}</span>
|
|
||||||
{events.map((e) => (
|
|
||||||
<span key={e} className="dns-badge dns-badge--accent">
|
|
||||||
{e}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="dns-row-l2 mono">
|
|
||||||
<span className="dns-row-detail">{detail.text}</span>
|
|
||||||
{detail.masked && (
|
|
||||||
<span className="dns-masked" title="Secret is stored but hidden here">
|
|
||||||
secret hidden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{route.direct ? (
|
|
||||||
<span className="dns-path">direct</span>
|
|
||||||
) : (
|
|
||||||
<span className="dns-path" data-active="on" data-missing={route.missing ? 'y' : undefined}>
|
|
||||||
{route.prefix} <strong className="dns-path-name">{route.name}</strong>
|
|
||||||
{route.missing && <span className="dns-path-flag"> (missing)</span>}
|
|
||||||
{fallback ? ' · +direct fallback' : ' · no fallback'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{routed && !fallback && <p className="dns-alert-note dns-alert-note--row">{VIA_NO_FALLBACK_NOTE}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="dns-alert-ctl">
|
|
||||||
<label className="dns-detour">
|
|
||||||
<span className="dns-detour-label mono">Deliver via</span>
|
|
||||||
<DetourSelect
|
|
||||||
value={canon}
|
|
||||||
catalog={catalog}
|
|
||||||
valid={valid}
|
|
||||||
busy={busy}
|
|
||||||
disabled={false}
|
|
||||||
ariaLabel={`Deliver alert ${alert.Name} via`}
|
|
||||||
onChange={onVia}
|
|
||||||
directLabel="Direct (default)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="dns-fallback">
|
|
||||||
<Toggle
|
|
||||||
pressed={fallback}
|
|
||||||
onChange={onFallback}
|
|
||||||
label={`${fallback ? 'Disable' : 'Enable'} direct fallback for ${alert.Name}`}
|
|
||||||
disabled={busy || !routed}
|
|
||||||
/>
|
|
||||||
<span className="dns-fallback-label mono">Fallback to direct</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="dns-del"
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`Delete alert ${alert.Name}`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- add form --------------------------------------------------------------
|
// ---- add form --------------------------------------------------------------
|
||||||
|
|
||||||
interface AddDraft {
|
interface AddDraft {
|
||||||
@@ -1695,8 +1358,11 @@ function ListRow({
|
|||||||
categories,
|
categories,
|
||||||
response,
|
response,
|
||||||
filterOn,
|
filterOn,
|
||||||
|
statuses,
|
||||||
|
updating,
|
||||||
busy,
|
busy,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onUpdateNow,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string
|
||||||
@@ -1708,8 +1374,14 @@ function ListRow({
|
|||||||
categories?: string[] | null
|
categories?: string[] | null
|
||||||
response?: BlockResponse
|
response?: BlockResponse
|
||||||
filterOn: boolean
|
filterOn: boolean
|
||||||
|
/** What the running engine reports about this list, one record per geo category.
|
||||||
|
* null/[] ⇒ nothing reported: an older daemon, a stopped engine, or a list that
|
||||||
|
* has not been applied yet. The row then says so instead of guessing. */
|
||||||
|
statuses: RulesetStatus[] | null
|
||||||
|
updating: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onToggle: (on: boolean) => void
|
onToggle: (on: boolean) => void
|
||||||
|
onUpdateNow: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const detail = useMemo<{ text: string; masked: boolean; title?: string }>(() => {
|
const detail = useMemo<{ text: string; masked: boolean; title?: string }>(() => {
|
||||||
@@ -1733,8 +1405,45 @@ function ListRow({
|
|||||||
}
|
}
|
||||||
}, [source, url, path, entries, categories])
|
}, [source, url, path, entries, categories])
|
||||||
|
|
||||||
// A list only actually filters when both it and the master switch are on.
|
// url and geosite lists are FETCHED by the engine; inline and file ones are read
|
||||||
const active = enabled && filterOn
|
// straight from the config and are loaded the moment they are applied.
|
||||||
|
const remote = source === 'url' || source === 'geosite'
|
||||||
|
const recs = statuses ?? []
|
||||||
|
const hasStatus = recs.length > 0
|
||||||
|
// A geo list with several categories: the OLDEST fetch (so a category that never
|
||||||
|
// arrived is never hidden behind a fresh sibling) and the SUM of the counts.
|
||||||
|
let ruleCount = 0
|
||||||
|
let neverAny = false
|
||||||
|
let oldestIso = ''
|
||||||
|
for (const s of recs) {
|
||||||
|
ruleCount += s.rule_count
|
||||||
|
if (!s.last_updated) neverAny = true
|
||||||
|
else if (!oldestIso || Date.parse(s.last_updated) < Date.parse(oldestIso)) oldestIso = s.last_updated
|
||||||
|
}
|
||||||
|
const interval = everyLabel(recs[0]?.interval_seconds ?? 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this list is BLOCKING ANYTHING, which is a different question from
|
||||||
|
* whether it is switched on — and the one the row used to answer wrongly.
|
||||||
|
*
|
||||||
|
* "filtering" is now only said when the engine reports rules loaded for it. A
|
||||||
|
* remote list that has never been fetched (the daemon raises this as a critical
|
||||||
|
* apply finding) reads "not loaded", and one that fetched an empty list reads
|
||||||
|
* "empty". Nothing reported at all is "load not reported": unknown, not green.
|
||||||
|
*/
|
||||||
|
const state: { text: string; tone: 'on' | 'off' | 'warn' } = !enabled
|
||||||
|
? { text: 'off', tone: 'off' }
|
||||||
|
: !filterOn
|
||||||
|
? { text: 'inactive', tone: 'off' }
|
||||||
|
: !remote
|
||||||
|
? { text: 'filtering', tone: 'on' }
|
||||||
|
: !hasStatus
|
||||||
|
? { text: 'load not reported', tone: 'off' }
|
||||||
|
: neverAny
|
||||||
|
? { text: 'not loaded — nothing blocked', tone: 'warn' }
|
||||||
|
: ruleCount === 0
|
||||||
|
? { text: 'loaded empty — nothing blocked', tone: 'warn' }
|
||||||
|
: { text: 'filtering', tone: 'on' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="dns-row">
|
<li className="dns-row">
|
||||||
@@ -1764,10 +1473,39 @@ function ListRow({
|
|||||||
token hidden
|
token hidden
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="dns-row-state" data-active={active ? 'on' : 'off'}>
|
<span className="dns-row-state" data-active={state.tone}>
|
||||||
{active ? 'filtering' : 'inactive'}
|
{state.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{remote && (
|
||||||
|
<div className="dns-row-sync">
|
||||||
|
<span className="dns-sync-fresh" data-never={hasStatus && neverAny ? 'y' : undefined}>
|
||||||
|
{hasStatus ? relFetch(oldestIso) : 'status pending'}
|
||||||
|
</span>
|
||||||
|
{interval && <span className="dns-sync-every">{interval}</span>}
|
||||||
|
{ruleCount > 0 && (
|
||||||
|
<span className="dns-sync-rules">
|
||||||
|
{ruleCount.toLocaleString('en-US')} rule{ruleCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dns-sync-update"
|
||||||
|
onClick={onUpdateNow}
|
||||||
|
disabled={busy || updating}
|
||||||
|
aria-label={`Update ${name} now`}
|
||||||
|
>
|
||||||
|
{updating ? (
|
||||||
|
<>
|
||||||
|
<span className="dns-sync-spin" aria-hidden="true" />
|
||||||
|
<span>Updating…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Update now'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="dns-del"
|
className="dns-del"
|
||||||
|
|||||||
@@ -138,57 +138,9 @@
|
|||||||
|
|
||||||
/* inline rename: a quiet pencil affordance beside the name, and the mono input
|
/* inline rename: a quiet pencil affordance beside the name, and the mono input
|
||||||
it swaps to — in the same sink/groove tone as the domain editors. */
|
it swaps to — in the same sink/groove tone as the domain editors. */
|
||||||
.dev-rename {
|
/* The pencil button and the name input now live in App.css as .inline-rename /
|
||||||
flex: none;
|
.inline-rename-input — Nodes grew the same affordance and the two pages must
|
||||||
display: inline-flex;
|
not drift. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
.dev-rename:hover:not(:disabled) {
|
|
||||||
color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
||||||
}
|
|
||||||
.dev-rename:focus-visible {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
.dev-rename:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.dev-name-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;
|
|
||||||
}
|
|
||||||
.dev-name-input:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
.dev-name-input:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
.dev-id-l2 {
|
.dev-id-l2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import './Devices.css'
|
import './Devices.css'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Led, Module, Toggle } from '../components'
|
import { Button, Led, Module, Toggle, useConfirm } from '../components'
|
||||||
import type { LedVariant } from '../components'
|
import type { LedVariant } from '../components'
|
||||||
import { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api'
|
import { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api'
|
||||||
import type { Device, DiscoveredDevice, Model } from '../api'
|
import type { Device, DiscoveredDevice, Model } from '../api'
|
||||||
@@ -81,6 +81,7 @@ function networkLabel(row: DeviceRow): string {
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Devices() {
|
export default function Devices() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -251,17 +252,22 @@ export default function Devices() {
|
|||||||
const nameOf = (row: DeviceRow) => row.cfg?.Name || row.hostname || row.ip || 'device'
|
const nameOf = (row: DeviceRow) => row.cfg?.Name || row.hostname || row.ip || 'device'
|
||||||
|
|
||||||
const removeControl = useCallback(
|
const removeControl = useCallback(
|
||||||
(row: DeviceRow) => {
|
async (row: DeviceRow) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const devs = asArray(config.Devices)
|
const devs = asArray(config.Devices)
|
||||||
const idx = matchDevice(devs, row.mac, row.ip)
|
const idx = matchDevice(devs, row.mac, row.ip)
|
||||||
if (idx < 0) return
|
if (idx < 0) return
|
||||||
const nm = devs[idx].Name || nameOf(row)
|
const nm = devs[idx].Name || nameOf(row)
|
||||||
if (!window.confirm(`Stop managing “${nm}”? Its per-device rules are removed; it falls back to network defaults.`))
|
const ok = await confirm({
|
||||||
return
|
label: 'Stop managing device',
|
||||||
|
title: `Stop managing “${nm}”?`,
|
||||||
|
body: 'Its per-device rules are removed; it falls back to network defaults.',
|
||||||
|
confirmLabel: 'Stop managing',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
void save({ ...config, Devices: devs.filter((_, i) => i !== idx) }, `Removed control for ${nm}`)
|
void save({ ...config, Devices: devs.filter((_, i) => i !== idx) }, `Removed control for ${nm}`)
|
||||||
},
|
},
|
||||||
[config, save],
|
[config, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
const loading = config === null && loadError === null && devices === null && devError === null
|
const loading = config === null && loadError === null && devices === null && devError === null
|
||||||
@@ -471,7 +477,7 @@ function DeviceCard({
|
|||||||
{renaming ? (
|
{renaming ? (
|
||||||
<input
|
<input
|
||||||
ref={nameInput}
|
ref={nameInput}
|
||||||
className="dev-name-input mono"
|
className="inline-rename-input mono"
|
||||||
type="text"
|
type="text"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -497,7 +503,7 @@ function DeviceCard({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="dev-rename"
|
className="inline-rename"
|
||||||
onClick={beginRename}
|
onClick={beginRename}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={`Rename ${name}`}
|
aria-label={`Rename ${name}`}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import './Networks.css'
|
import './Networks.css'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Led, Select, Toggle } from '../components'
|
import { Button, Led, Select, Toggle, useConfirm } from '../components'
|
||||||
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
|
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
|
||||||
import type { Inbound, Interface, Model, Status } from '../api'
|
import type { Inbound, Interface, Model, Status } from '../api'
|
||||||
import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions'
|
import { isLanNetwork, isWanNetwork, useInterfaces } from '../srcOptions'
|
||||||
@@ -85,6 +85,19 @@ const DEFAULT_TPROXY_PORT = 12345
|
|||||||
* Collapsing those into one switch would make "I want ping to work" silently mean
|
* Collapsing those into one switch would make "I want ping to work" silently mean
|
||||||
* "I permit a parallel VPN bypass", so the middle option exists to remove that
|
* "I permit a parallel VPN bypass", so the middle option exists to remove that
|
||||||
* false choice — and the labels push anyone who wants diagnostics to `icmp`.
|
* false choice — and the labels push anyone who wants diagnostics to `icmp`.
|
||||||
|
*
|
||||||
|
* WHY THIS COPY WAS REWRITTEN. `block` used to say "Nothing leaves except through
|
||||||
|
* the tunnel", and it was not true. The daemon let untunnelable traffic out toward
|
||||||
|
* every destination the ROUTING RULES send direct, on the argument that such a host
|
||||||
|
* already has your address from ordinary TCP. Under the commonest setup here —
|
||||||
|
* "tunnel what's blocked, send the rest direct" — the routing default IS direct, so
|
||||||
|
* that covered everything: `block` behaved exactly like `direct`, including ESP/GRE,
|
||||||
|
* i.e. the parallel-VPN case the middle rung exists to exclude. The daemon now drops
|
||||||
|
* unconditionally under `block`, and this copy states the price instead of hiding it
|
||||||
|
* (the owner's call: this router does not do ping and does not do IPTV).
|
||||||
|
*
|
||||||
|
* `icmp` still carries that destination-dependence for its NON-ping half, so its
|
||||||
|
* cost line says so rather than claiming "nothing else gets out".
|
||||||
*/
|
*/
|
||||||
type Untunnelable = 'block' | 'icmp' | 'direct'
|
type Untunnelable = 'block' | 'icmp' | 'direct'
|
||||||
|
|
||||||
@@ -98,7 +111,10 @@ function normUntunnelable(raw: string | undefined): Untunnelable {
|
|||||||
|
|
||||||
const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||||
{ value: 'block', label: 'Block everything — most private' },
|
{ value: 'block', label: 'Block everything — most private' },
|
||||||
{ value: 'icmp', label: 'Allow ping only — for diagnostics' },
|
// Not "Allow ping only": the rung also lets the other untunnelable protocols
|
||||||
|
// out toward directly-routed addresses, and the cost line below says so. A
|
||||||
|
// label that promised "only" would be contradicted two lines under itself.
|
||||||
|
{ value: 'icmp', label: 'Allow ping — for diagnostics' },
|
||||||
{ value: 'direct', label: 'Allow everything — most compatible' },
|
{ value: 'direct', label: 'Allow everything — most compatible' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -110,13 +126,18 @@ interface PolicyCopy {
|
|||||||
|
|
||||||
const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = {
|
const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = {
|
||||||
block: {
|
block: {
|
||||||
works: 'Nothing leaves except through the tunnel.',
|
// Scoped to "this traffic" on purpose. The old line — "Nothing leaves except
|
||||||
cost: 'Ping and traceroute won’t work from your devices, and neither will multicast IPTV or connecting to a VPN from a device on your network.',
|
// through the tunnel" — was doubly loose: it was false (see the note above),
|
||||||
|
// and even read charitably it collides with directly-routed TCP, which does
|
||||||
|
// leave outside the tunnel by design.
|
||||||
|
works:
|
||||||
|
'None of this traffic leaves the router — it’s dropped, whatever your routing rules say. It’s the only setting whose promise doesn’t depend on how the rules are written.',
|
||||||
|
cost: 'Ping and traceroute stop working from your devices. So do IPsec and PPTP VPN connections made from a device on your network, multicast IPTV, and SCTP. VPNs that run over UDP — WireGuard, OpenVPN-UDP, and IPsec through NAT (IKEv2/NAT-T) — are unaffected: they go through the tunnel like everything else.',
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
},
|
},
|
||||||
icmp: {
|
icmp: {
|
||||||
works: 'Ping and traceroute work, so you can check whether something is reachable.',
|
works: 'Ping and traceroute work everywhere, so you can check whether something is reachable.',
|
||||||
cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. Only for hosts you deliberately ping, and nothing else gets out — IPTV and VPN connections stay blocked.',
|
cost: 'Whatever you ping sees your real IP address instead of the tunnel’s. IPsec, PPTP and IPTV also get out — but only toward addresses your routing rules already send direct, so a VPN app on a device can still open its own connection beside this one if its server is one of those.',
|
||||||
tone: 'warn',
|
tone: 'warn',
|
||||||
},
|
},
|
||||||
direct: {
|
direct: {
|
||||||
@@ -242,6 +263,7 @@ function computeWarnings(inbounds: Inbound[], ifaces: Interface[]): Warning[] {
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Networks({ status }: { status?: Status | null }) {
|
export default function Networks({ status }: { status?: Status | null }) {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const ifaces = useInterfaces()
|
const ifaces = useInterfaces()
|
||||||
@@ -392,23 +414,21 @@ export default function Networks({ status }: { status?: Status | null }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeInbound = useCallback(
|
const removeInbound = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = inbounds[idx]
|
const target = inbounds[idx]
|
||||||
if (
|
const ok = await confirm({
|
||||||
!window.confirm(
|
label: 'Delete inbound',
|
||||||
`Delete inbound “${target.Name}”?${
|
title: `Delete inbound “${target.Name}”?`,
|
||||||
intercepts(target)
|
body: intercepts(target)
|
||||||
? ` ${target.Network || 'Its network'} stops going through the tunnel.`
|
? `${target.Network || 'Its network'} stops going through the tunnel.`
|
||||||
: ''
|
: undefined,
|
||||||
}`,
|
})
|
||||||
)
|
if (!ok) return
|
||||||
)
|
|
||||||
return
|
|
||||||
const next = inbounds.filter((_, i) => i !== idx)
|
const next = inbounds.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`)
|
void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`)
|
||||||
},
|
},
|
||||||
[config, inbounds, save],
|
[config, inbounds, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -552,7 +572,7 @@ export default function Networks({ status }: { status?: Status | null }) {
|
|||||||
|
|
||||||
{untunnelable === 'block' && (
|
{untunnelable === 'block' && (
|
||||||
<p className="nw-sec-note nw-policy-hint">
|
<p className="nw-sec-note nw-policy-hint">
|
||||||
If you just want to check whether a site is reachable, choose <strong>Allow ping only</strong>{' '}
|
If you just want to check whether a site is reachable, choose <strong>Allow ping</strong>{' '}
|
||||||
rather than allowing everything — it’s the narrower of the two.
|
rather than allowing everything — it’s the narrower of the two.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -196,6 +196,52 @@
|
|||||||
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
|
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
/* "not built" — the saved switch says on and the engine has no such outbound. */
|
||||||
|
.badge--crit {
|
||||||
|
border-color: color-mix(in srgb, var(--crit) 55%, var(--groove));
|
||||||
|
color: var(--crit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- last-apply findings, attached to the row they are about ----
|
||||||
|
Sits under the row's own two lines, inside the row plate, so a node the
|
||||||
|
generator threw away cannot read as an ordinary enabled node. Severity carries
|
||||||
|
the colour; the accent stays reserved for controls. */
|
||||||
|
.row-findings {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.row-finding {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--amber) 40%, var(--groove));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--sink) 35%, transparent);
|
||||||
|
}
|
||||||
|
.row-finding--critical {
|
||||||
|
border-color: color-mix(in srgb, var(--crit) 45%, var(--groove));
|
||||||
|
}
|
||||||
|
.row-finding-msg {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink);
|
||||||
|
max-width: 82ch;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* Findings that belong to no single row (see Nodes.tsx globalFindings). */
|
||||||
|
.node-findings {
|
||||||
|
margin-bottom: calc(var(--u, 8px) * 2);
|
||||||
|
}
|
||||||
|
.node-findings .row-findings {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* masked-credential marker */
|
/* masked-credential marker */
|
||||||
.masked {
|
.masked {
|
||||||
@@ -641,6 +687,20 @@ select.fp-input {
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
/* A collapsed bucket has to carry its own bad news: a 300-node subscription is
|
||||||
|
closed by default, and the per-row findings inside it are otherwise unreachable
|
||||||
|
without knowing to look. */
|
||||||
|
.group-flagged {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
.group-rows {
|
.group-rows {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -727,3 +787,53 @@ select.fp-input {
|
|||||||
width: 9rem;
|
width: 9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- inline node rename ----
|
||||||
|
The pencil / input pair itself is shared (.inline-rename[-input] in App.css);
|
||||||
|
only the row-local sizing and the refusal message live here. A node name is
|
||||||
|
longer than a device name (it carries a protocol and a host), so the field is
|
||||||
|
given more room than the shared 24ch default. */
|
||||||
|
.node-name-input {
|
||||||
|
max-width: 32ch;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
/* Why a rename was refused, pinned under the row it was typed in. Semantic crit:
|
||||||
|
the name did not change, and that must not be mistaken for a saved edit. */
|
||||||
|
.row-err {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--crit);
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
/* Stated once per subscription bucket: the same rule the locked control in every
|
||||||
|
row carries, so the absent rename is explained before it is looked for. */
|
||||||
|
.group-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--groove);
|
||||||
|
border-top: 0;
|
||||||
|
background: color-mix(in srgb, var(--sink) 25%, transparent);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
/* The optional name sits beside the link input on a wide row and drops onto its
|
||||||
|
own line when the row can no longer hold both. */
|
||||||
|
.add-name {
|
||||||
|
flex: 0 1 22ch;
|
||||||
|
min-width: 12ch;
|
||||||
|
}
|
||||||
|
.add-row--conf .add-name {
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.add-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.add-name {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+638
-17
@@ -2,16 +2,18 @@ import './Nodes.css'
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { LedVariant } from '../components'
|
import type { LedVariant } from '../components'
|
||||||
import { Button, Led, Toggle } from '../components'
|
import { Button, Led, Toggle, useConfirm } from '../components'
|
||||||
import {
|
import {
|
||||||
apply as apiApply,
|
apply as apiApply,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getStatus,
|
||||||
putConfig,
|
putConfig,
|
||||||
importWg,
|
importWg,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { Model, Node as NodeCfg, Subscription } from '../api'
|
import type { Model, Node as NodeCfg, StatusWarning, Subscription } from '../api'
|
||||||
|
import { entityFindings, findingsByName } from '../findings'
|
||||||
import { fmtBytes, fmtDate, fmtUntil } from '../format'
|
import { fmtBytes, fmtDate, fmtUntil } from '../format'
|
||||||
|
|
||||||
// The whole page is a thin editor over the desired-state Model: every mutation
|
// The whole page is a thin editor over the desired-state Model: every mutation
|
||||||
@@ -158,6 +160,219 @@ function uniqueName(base: string, taken: Set<string>): string {
|
|||||||
return `${seed}-${i}`
|
return `${seed}-${i}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- node names are identity, not a caption --------------------------------
|
||||||
|
//
|
||||||
|
// A node's Name IS its sing-box outbound tag and the only thing every reference
|
||||||
|
// to it spells: a rule target `node:<name>`, a chain hop, a manual group's member
|
||||||
|
// list, a resolver detour, an alert delivery, a subscription fetch detour. Rename
|
||||||
|
// the node alone and every one of those points at nothing — and an unresolved
|
||||||
|
// target does NOT fall back to the default route, the daemon BLOCKS that traffic.
|
||||||
|
// So the rename either carries every reference with it, or it is refused.
|
||||||
|
|
||||||
|
/** Reserved outbound tags. A node called this is skipped by the generator entirely. */
|
||||||
|
const RESERVED_TAGS = ['direct', 'block']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefixes that `model.SplitTarget` reads as a KIND, not as part of a name. A
|
||||||
|
* name starting with one of them makes every bare reference to it ambiguous with
|
||||||
|
* a real `kind:name` reference, so it is refused rather than half-supported.
|
||||||
|
*/
|
||||||
|
const KIND_PREFIXES = ['node', 'group', 'egress', 'chain', 'direct', 'block']
|
||||||
|
|
||||||
|
/** Names are rendered into a line-oriented `uci export`; control chars are stripped there. */
|
||||||
|
function hasControlChar(s: string): boolean {
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const c = s.charCodeAt(i)
|
||||||
|
if (c < 0x20 || c === 0x7f) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why `name` cannot be a node name here, or null if it can.
|
||||||
|
*
|
||||||
|
* Every rule mirrors something the daemon actually does with the name, not a
|
||||||
|
* house style: reserved tags make generate skip the node; a duplicate makes two
|
||||||
|
* outbounds share a tag and the manager silently keeps the last one; a group of
|
||||||
|
* the same name is dropped by buildGroups ("rename the group"); an
|
||||||
|
* `egress-<name>` collision takes over a real egress outbound; and a control
|
||||||
|
* character is rewritten to a space by sanitizeUCIValue on write, so the saved
|
||||||
|
* name would not be the one you typed.
|
||||||
|
*/
|
||||||
|
function nodeNameError(
|
||||||
|
raw: string,
|
||||||
|
m: Model | null,
|
||||||
|
self: string | null,
|
||||||
|
): string | null {
|
||||||
|
const name = raw.trim()
|
||||||
|
if (!name) return 'A node needs a name.'
|
||||||
|
if (hasControlChar(name))
|
||||||
|
return 'Names can’t contain line breaks or control characters — they’re stripped when the config is written.'
|
||||||
|
if (RESERVED_TAGS.some((t) => t.toLowerCase() === name.toLowerCase()))
|
||||||
|
return `“${name}” is a reserved target name — a node called that is skipped by the engine. Pick another.`
|
||||||
|
const head = name.includes(':') ? name.slice(0, name.indexOf(':')).toLowerCase() : ''
|
||||||
|
if (head && KIND_PREFIXES.includes(head))
|
||||||
|
return `A name starting with “${head}:” reads as a ${head} reference everywhere it’s used. Pick another.`
|
||||||
|
if (!m) return null
|
||||||
|
|
||||||
|
const clash = asArray(m.Nodes).find((n) => n.Name === name && n.Name !== self)
|
||||||
|
if (clash)
|
||||||
|
return clash.FromSub
|
||||||
|
? `“${name}” is already a node from subscription “${clash.FromSub}”. Two nodes with one name share a single outbound — pick another.`
|
||||||
|
: `“${name}” is already another node. Pick another.`
|
||||||
|
if (asArray(m.Groups).some((g) => g.Name === name))
|
||||||
|
return `A group is already named “${name}”. The engine drops the group when a node takes its name — pick another.`
|
||||||
|
const egressClash = asArray(m.Egresses).find((e) => `egress-${e.Name}` === name)
|
||||||
|
if (egressClash)
|
||||||
|
return `“${name}” is the outbound tag of egress “${egressClash.Name}”. Pick another.`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One place a node name is written, as a short label for the rename summary. */
|
||||||
|
interface NodeRefSite {
|
||||||
|
/** Which section — drives the "N rules, M groups" count. */
|
||||||
|
kind: 'rule' | 'group' | 'chain' | 'resolver' | 'alert' | 'subscription' | 'egress'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A target/detour string naming this node in its prefixed form (`node:<name>`). */
|
||||||
|
const isNodeRef = (v: string | undefined | null, name: string): boolean =>
|
||||||
|
(v ?? '') === `node:${name}`
|
||||||
|
|
||||||
|
/** …or in the bare form the engine also resolves (a group member, a bare hop/target). */
|
||||||
|
const isBareRef = (v: string | undefined | null, name: string): boolean => (v ?? '') === name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every place `name` is written outside the node itself. Both spellings count:
|
||||||
|
* `resolveTarget` falls through to a bare node lookup, and a manual group's
|
||||||
|
* member list is bare by contract.
|
||||||
|
*/
|
||||||
|
function findNodeReferences(m: Model, name: string): NodeRefSite[] {
|
||||||
|
const out: NodeRefSite[] = []
|
||||||
|
for (const r of asArray(m.Rules)) {
|
||||||
|
if (isNodeRef(r.Target, name) || isBareRef(r.Target, name))
|
||||||
|
out.push({ kind: 'rule', label: `rule “${r.Name}” target` })
|
||||||
|
}
|
||||||
|
for (const g of asArray(m.Groups)) {
|
||||||
|
if (asArray(g.Nodes).some((n) => n === name))
|
||||||
|
out.push({ kind: 'group', label: `group “${g.Name}” member` })
|
||||||
|
}
|
||||||
|
for (const c of asArray(m.Chains)) {
|
||||||
|
if (asArray(c.Hops).some((h) => isNodeRef(h, name) || isBareRef(h, name)))
|
||||||
|
out.push({ kind: 'chain', label: `chain “${c.Name}” hop` })
|
||||||
|
}
|
||||||
|
for (const r of asArray(m.Resolvers)) {
|
||||||
|
if (isNodeRef(r.Detour, name)) out.push({ kind: 'resolver', label: `resolver “${r.Name}” DNS path` })
|
||||||
|
}
|
||||||
|
for (const a of asArray(m.Alerts)) {
|
||||||
|
if (isNodeRef(a.Via, name)) out.push({ kind: 'alert', label: `alert “${a.Name}” delivery` })
|
||||||
|
}
|
||||||
|
for (const s of asArray(m.Subscriptions)) {
|
||||||
|
if (isNodeRef(s.FetchDetour, name))
|
||||||
|
out.push({ kind: 'subscription', label: `subscription “${s.Name}” fetch` })
|
||||||
|
}
|
||||||
|
for (const e of asArray(m.Egresses)) {
|
||||||
|
if (isNodeRef(e.Target, name)) out.push({ kind: 'egress', label: `egress “${e.Name}” target` })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What makes a rename impossible to carry rather than merely wide.
|
||||||
|
*
|
||||||
|
* A BARE reference is just a name; the engine resolves it node-first, then group.
|
||||||
|
* If something else already answers to the old name, we cannot tell which object
|
||||||
|
* a bare reference meant, and rewriting it would move a reference the operator
|
||||||
|
* never pointed at this node. That is a half-done cascade, so the rename is
|
||||||
|
* refused instead — with the collision named, so it can be fixed.
|
||||||
|
*/
|
||||||
|
function bareAmbiguity(m: Model, name: string): string | null {
|
||||||
|
const group = asArray(m.Groups).find((g) => g.Name === name)
|
||||||
|
if (!group) return null
|
||||||
|
const bare = [
|
||||||
|
...asArray(m.Rules)
|
||||||
|
.filter((r) => isBareRef(r.Target, name))
|
||||||
|
.map((r) => `rule “${r.Name}”`),
|
||||||
|
...asArray(m.Chains)
|
||||||
|
.filter((c) => asArray(c.Hops).some((h) => isBareRef(h, name)))
|
||||||
|
.map((c) => `chain “${c.Name}”`),
|
||||||
|
]
|
||||||
|
if (bare.length === 0) return null
|
||||||
|
return `A group is also named “${name}”, and ${bare.join(', ')} point${bare.length === 1 ? 's' : ''} at that bare name — there is no way to tell which of the two is meant. Rename the group first, then this node.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every reference from `from` to `to`. Returns a NEW Model with only the
|
||||||
|
* touched sections replaced; the Nodes section is the caller's business.
|
||||||
|
*
|
||||||
|
* Bare references are rewritten too — that is the whole point for a manual
|
||||||
|
* group's member list — which is safe only because `bareAmbiguity` has already
|
||||||
|
* refused the one case where a bare name could mean something else.
|
||||||
|
*/
|
||||||
|
function renameNodeReferences(m: Model, from: string, to: string): Model {
|
||||||
|
if (from === to) return m
|
||||||
|
/** Prefixed-only sites (a detour is never spelled bare). */
|
||||||
|
const pfx = (v: string | undefined) => (isNodeRef(v, from) ? `node:${to}` : v)
|
||||||
|
/** Sites that accept either spelling — each is rewritten in the spelling it already uses. */
|
||||||
|
const either = (v: string | undefined) => {
|
||||||
|
if (isNodeRef(v, from)) return `node:${to}`
|
||||||
|
if (isBareRef(v, from)) return to
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Model = { ...m }
|
||||||
|
if (m.Rules) next.Rules = m.Rules.map((r) => ({ ...r, Target: either(r.Target) }))
|
||||||
|
if (m.Groups)
|
||||||
|
next.Groups = m.Groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
Nodes: g.Nodes ? g.Nodes.map((n) => (n === from ? to : n)) : g.Nodes,
|
||||||
|
}))
|
||||||
|
if (m.Chains)
|
||||||
|
next.Chains = m.Chains.map((c) => ({
|
||||||
|
...c,
|
||||||
|
Hops: c.Hops ? c.Hops.map((h) => either(h) ?? h) : c.Hops,
|
||||||
|
}))
|
||||||
|
if (m.Resolvers) next.Resolvers = m.Resolvers.map((r) => ({ ...r, Detour: pfx(r.Detour) }))
|
||||||
|
if (m.Alerts) next.Alerts = m.Alerts.map((a) => ({ ...a, Via: pfx(a.Via) }))
|
||||||
|
if (m.Subscriptions)
|
||||||
|
next.Subscriptions = m.Subscriptions.map((s) => ({ ...s, FetchDetour: pfx(s.FetchDetour) }))
|
||||||
|
if (m.Egresses) next.Egresses = m.Egresses.map((e) => ({ ...e, Target: pfx(e.Target) }))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3 rules, 1 group and 2 chains" — what the rename is about to rewrite. */
|
||||||
|
function refSummary(refs: NodeRefSite[]): string {
|
||||||
|
const plural: Record<NodeRefSite['kind'], [string, string]> = {
|
||||||
|
rule: ['rule', 'rules'],
|
||||||
|
group: ['group', 'groups'],
|
||||||
|
chain: ['chain', 'chains'],
|
||||||
|
resolver: ['resolver', 'resolvers'],
|
||||||
|
alert: ['alert', 'alerts'],
|
||||||
|
subscription: ['subscription', 'subscriptions'],
|
||||||
|
egress: ['egress', 'egresses'],
|
||||||
|
}
|
||||||
|
const order: NodeRefSite['kind'][] = [
|
||||||
|
'rule', 'group', 'chain', 'resolver', 'alert', 'subscription', 'egress',
|
||||||
|
]
|
||||||
|
const parts = order
|
||||||
|
.map((k) => [k, refs.filter((r) => r.kind === k).length] as const)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.map(([k, n]) => `${n} ${plural[k][n === 1 ? 0 : 1]}`)
|
||||||
|
if (parts.length === 1) return parts[0]
|
||||||
|
return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a rename just committed to, waiting for its row to come back.
|
||||||
|
*
|
||||||
|
* A row is keyed by the node's NAME, so committing a rename unmounts the row and
|
||||||
|
* mounts a different one — carrying the focused element away with it. This baton
|
||||||
|
* survives that remount: the row that reappears under the new name claims it and
|
||||||
|
* puts the keyboard back on its own rename button, instead of dropping the user
|
||||||
|
* on <body> halfway down a list of 300 nodes.
|
||||||
|
*/
|
||||||
|
let pendingRenameFocus: string | null = null
|
||||||
|
|
||||||
// A subscription with more than this many nodes starts collapsed so the list
|
// A subscription with more than this many nodes starts collapsed so the list
|
||||||
// doesn't become one endless scroll; an active search overrides it.
|
// doesn't become one endless scroll; an active search overrides it.
|
||||||
const LARGE_GROUP = 20
|
const LARGE_GROUP = 20
|
||||||
@@ -289,6 +504,7 @@ function DetourSelect({
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Nodes() {
|
export default function Nodes() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -305,6 +521,43 @@ export default function Nodes() {
|
|||||||
void loadConfig()
|
void loadConfig()
|
||||||
}, [loadConfig])
|
}, [loadConfig])
|
||||||
|
|
||||||
|
// ---- what the last apply said about these nodes ---------------------------
|
||||||
|
//
|
||||||
|
// The generator drops a node it cannot build and names it: an unparseable
|
||||||
|
// share link (generate/outbound.go), a name colliding with a reserved tag, a
|
||||||
|
// WireGuard private key materialised twice (generate/wgdedup.go). Until now
|
||||||
|
// this page never read /api/status, so a node the engine had thrown away
|
||||||
|
// rendered as an ordinary row with a green toggle — the switch said on and
|
||||||
|
// there was no such outbound anywhere in the running config.
|
||||||
|
//
|
||||||
|
// Findings are attached to the ROWS, not summarised at the top: a 300-node
|
||||||
|
// subscription makes a list of names useless, and the row is where the false
|
||||||
|
// reassurance was.
|
||||||
|
const [findings, setFindings] = useState<StatusWarning[]>([])
|
||||||
|
const loadFindings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await getStatus()
|
||||||
|
setFindings(entityFindings(s.warnings, ['node', 'subscription']))
|
||||||
|
} catch {
|
||||||
|
// Status is a supplement here, not the page. Keep the last set rather than
|
||||||
|
// clearing it — a dropped poll is not the same as "the problem is fixed".
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFindings()
|
||||||
|
}, [loadFindings])
|
||||||
|
|
||||||
|
const nodeFindings = useMemo(
|
||||||
|
() => findingsByName(findings.filter((w) => w.section === 'node')),
|
||||||
|
[findings],
|
||||||
|
)
|
||||||
|
const subFindings = useMemo(
|
||||||
|
() => findingsByName(findings.filter((w) => w.section === 'subscription')),
|
||||||
|
[findings],
|
||||||
|
)
|
||||||
|
// Findings about nodes/subscriptions in general, which belong to no single row.
|
||||||
|
const globalFindings = useMemo(() => findings.filter((w) => !w.name), [findings])
|
||||||
|
|
||||||
// ---- toast + persistent apply banner --------------------------------------
|
// ---- toast + persistent apply banner --------------------------------------
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
const [toast, setToast] = useState<string | null>(null)
|
||||||
const toastTimer = useRef<number | undefined>(undefined)
|
const toastTimer = useRef<number | undefined>(undefined)
|
||||||
@@ -359,8 +612,11 @@ export default function Nodes() {
|
|||||||
flash(`Apply failed — ${errText(e)}`)
|
flash(`Apply failed — ${errText(e)}`)
|
||||||
} finally {
|
} finally {
|
||||||
setApplying(false)
|
setApplying(false)
|
||||||
|
// An apply is exactly what rewrites the findings — including clearing the
|
||||||
|
// ones the operator just fixed.
|
||||||
|
void loadFindings()
|
||||||
}
|
}
|
||||||
}, [flash, loadConfig])
|
}, [flash, loadConfig, loadFindings])
|
||||||
|
|
||||||
// ---- node mutations -------------------------------------------------------
|
// ---- node mutations -------------------------------------------------------
|
||||||
const nodes = useMemo(() => asArray(config?.Nodes), [config])
|
const nodes = useMemo(() => asArray(config?.Nodes), [config])
|
||||||
@@ -385,6 +641,9 @@ export default function Nodes() {
|
|||||||
const [nodeInput, setNodeInput] = useState('')
|
const [nodeInput, setNodeInput] = useState('')
|
||||||
const [nodeErr, setNodeErr] = useState<string | null>(null)
|
const [nodeErr, setNodeErr] = useState<string | null>(null)
|
||||||
const [addMode, setAddMode] = useState<'link' | 'conf'>('link')
|
const [addMode, setAddMode] = useState<'link' | 'conf'>('link')
|
||||||
|
// Optional. Empty keeps the old behaviour (a name derived from the server
|
||||||
|
// address), so "paste a link, press Add" stays a two-step path.
|
||||||
|
const [nodeName, setNodeName] = useState('')
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
// ---- node search + collapsible grouping -----------------------------------
|
// ---- node search + collapsible grouping -----------------------------------
|
||||||
@@ -445,8 +704,12 @@ export default function Nodes() {
|
|||||||
try {
|
try {
|
||||||
const { uri, name } = await importWg(conf)
|
const { uri, name } = await importWg(conf)
|
||||||
const taken = new Set(nodes.map((n) => n.Name))
|
const taken = new Set(nodes.map((n) => n.Name))
|
||||||
|
// A typed name is used AS TYPED — uniqueName would silently turn a
|
||||||
|
// collision into "name-2", which is the confusion this field exists to
|
||||||
|
// end. It is validated instead, and a clash is refused out loud above.
|
||||||
|
const wanted = nodeName.trim()
|
||||||
const node: NodeCfg = {
|
const node: NodeCfg = {
|
||||||
Name: uniqueName(name || 'wireguard', taken),
|
Name: wanted || uniqueName(name || 'wireguard', taken),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
URI: uri,
|
URI: uri,
|
||||||
FromSub: '',
|
FromSub: '',
|
||||||
@@ -455,6 +718,7 @@ export default function Nodes() {
|
|||||||
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
|
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setNodeInput('')
|
setNodeInput('')
|
||||||
|
setNodeName('')
|
||||||
setAddMode('link')
|
setAddMode('link')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -463,11 +727,20 @@ export default function Nodes() {
|
|||||||
setImporting(false)
|
setImporting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config, nodes, save, flash],
|
[config, nodes, nodeName, save, flash],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addNode = useCallback(async () => {
|
const addNode = useCallback(async () => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
|
// The name is checked BEFORE the import round-trip, so a bad name costs
|
||||||
|
// nothing and the message lands in the form next to the field.
|
||||||
|
if (nodeName.trim()) {
|
||||||
|
const bad = nodeNameError(nodeName, config, null)
|
||||||
|
if (bad) {
|
||||||
|
setNodeErr(bad)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Auto-detect a pasted config, whichever input it landed in.
|
// Auto-detect a pasted config, whichever input it landed in.
|
||||||
if (nodeInput.includes(WG_MARKER)) {
|
if (nodeInput.includes(WG_MARKER)) {
|
||||||
await addWgConf(nodeInput)
|
await addWgConf(nodeInput)
|
||||||
@@ -486,10 +759,20 @@ export default function Nodes() {
|
|||||||
const parsed = parseShareLink(uri)
|
const parsed = parseShareLink(uri)
|
||||||
const taken = new Set(nodes.map((n) => n.Name))
|
const taken = new Set(nodes.map((n) => n.Name))
|
||||||
const base = parsed.suggested || `${parsed.proto.toLowerCase()}-${parsed.host}`.replace(/[^\w.:-]+/g, '-')
|
const base = parsed.suggested || `${parsed.proto.toLowerCase()}-${parsed.host}`.replace(/[^\w.:-]+/g, '-')
|
||||||
const node: NodeCfg = { Name: uniqueName(base, taken), Enabled: true, URI: uri, FromSub: '', Egress: '' }
|
const wanted = nodeName.trim()
|
||||||
|
const node: NodeCfg = {
|
||||||
|
Name: wanted || uniqueName(base, taken),
|
||||||
|
Enabled: true,
|
||||||
|
URI: uri,
|
||||||
|
FromSub: '',
|
||||||
|
Egress: '',
|
||||||
|
}
|
||||||
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
|
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
|
||||||
if (ok) setNodeInput('')
|
if (ok) {
|
||||||
}, [config, nodeInput, nodes, save, addMode, addWgConf])
|
setNodeInput('')
|
||||||
|
setNodeName('')
|
||||||
|
}
|
||||||
|
}, [config, nodeInput, nodeName, nodes, save, addMode, addWgConf])
|
||||||
|
|
||||||
const toggleNode = useCallback(
|
const toggleNode = useCallback(
|
||||||
(idx: number, on: boolean) => {
|
(idx: number, on: boolean) => {
|
||||||
@@ -500,15 +783,110 @@ export default function Nodes() {
|
|||||||
[config, nodes, save],
|
[config, nodes, save],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a node, naming everything that still points at it.
|
||||||
|
*
|
||||||
|
* `findNodeReferences` was already here and already right — it just wasn't asked
|
||||||
|
* on the one path where the answer matters. A RENAME carried its references and
|
||||||
|
* said so; a DELETE said "This removes it from the config", which is true of the
|
||||||
|
* node and silent about the rule, group member, chain hop or resolver detour
|
||||||
|
* left spelling a name nothing answers to. That is not a cosmetic dangle: an
|
||||||
|
* unresolved target does not fall through to the default route, so the traffic
|
||||||
|
* aimed at it is blocked.
|
||||||
|
*/
|
||||||
const removeNode = useCallback(
|
const removeNode = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = nodes[idx]
|
const target = nodes[idx]
|
||||||
if (!window.confirm(`Delete node “${target.Name}”? This removes it from the config.`)) return
|
const refs = findNodeReferences(config, target.Name)
|
||||||
|
const shown = refs.slice(0, 4).map((r) => r.label)
|
||||||
|
const more = refs.length - shown.length
|
||||||
|
const ok = await confirm({
|
||||||
|
label: 'Delete node',
|
||||||
|
title: `Delete node “${target.Name}”?`,
|
||||||
|
body:
|
||||||
|
refs.length === 0
|
||||||
|
? 'Nothing else in the config points at it.'
|
||||||
|
: `${refSummary(refs)} still ${refs.length === 1 ? 'points' : 'point'} at it — ${shown.join(
|
||||||
|
', ',
|
||||||
|
)}${
|
||||||
|
more > 0 ? `, and ${more} more` : ''
|
||||||
|
}. Nothing rewrites them, and a target that no longer resolves does not fall through to the default route: the traffic aimed at it is blocked.`,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = nodes.filter((_, i) => i !== idx)
|
const next = nodes.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, Nodes: next }, `Deleted ${target.Name}`)
|
void save({ ...config, Nodes: next }, `Deleted ${target.Name}`)
|
||||||
},
|
},
|
||||||
[config, nodes, save],
|
[config, nodes, save, confirm],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a manual node, carrying every reference with it.
|
||||||
|
*
|
||||||
|
* The name is this node's identity: its outbound tag, and the exact string a
|
||||||
|
* rule target, a chain hop, a manual group's member list, a resolver detour, an
|
||||||
|
* alert delivery and a subscription fetch detour all spell. So the rename is one
|
||||||
|
* atomic save of the Nodes section AND every referencing section, or it does not
|
||||||
|
* happen at all:
|
||||||
|
*
|
||||||
|
* - an invalid or colliding name is refused with the reason (`nodeNameError`);
|
||||||
|
* - a name a GROUP also answers to, with bare references pointing at it, is
|
||||||
|
* refused too — there is no way to know which object those meant, and
|
||||||
|
* guessing would move a reference the operator never pointed here;
|
||||||
|
* - anything else is shown exactly what it will rewrite, and only then saved.
|
||||||
|
*
|
||||||
|
* Errors surface through `onError` so they land in the row that was edited.
|
||||||
|
*/
|
||||||
|
const renameNode = useCallback(
|
||||||
|
async (idx: number, raw: string, onError: (msg: string) => void): Promise<boolean> => {
|
||||||
|
if (!config) return false
|
||||||
|
const target = nodes[idx]
|
||||||
|
const from = target.Name
|
||||||
|
const to = raw.trim()
|
||||||
|
if (to === from) return true
|
||||||
|
// Subscription names come back from the feed on the next update; renaming
|
||||||
|
// one would be undone without warning, so this path is manual-only.
|
||||||
|
if (target.FromSub) {
|
||||||
|
onError(`“${from}” is named by subscription “${target.FromSub}” — the feed rewrites it on the next update.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const bad = nodeNameError(to, config, from)
|
||||||
|
if (bad) {
|
||||||
|
onError(bad)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const blocked = bareAmbiguity(config, from)
|
||||||
|
if (blocked) {
|
||||||
|
onError(blocked)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = findNodeReferences(config, from)
|
||||||
|
if (refs.length > 0) {
|
||||||
|
const shown = refs.slice(0, 4).map((r) => r.label)
|
||||||
|
const more = refs.length - shown.length
|
||||||
|
const ok = await confirm({
|
||||||
|
tone: 'neutral',
|
||||||
|
label: 'Rename node',
|
||||||
|
title: `Rename “${from}” to “${to}”?`,
|
||||||
|
body: `This also updates ${refSummary(refs)} that point at it — ${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}. They are saved together, so nothing is left pointing at the old name.`,
|
||||||
|
confirmLabel: 'Rename',
|
||||||
|
})
|
||||||
|
if (!ok) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// One PUT: the node and every reference move in the same write, so no
|
||||||
|
// intermediate state exists where a reference dangles.
|
||||||
|
const carried = renameNodeReferences(config, from, to)
|
||||||
|
const next = asArray(carried.Nodes).map((n, i) => (i === idx ? { ...n, Name: to } : n))
|
||||||
|
return save(
|
||||||
|
{ ...carried, Nodes: next },
|
||||||
|
refs.length > 0
|
||||||
|
? `Renamed to ${to} — updated ${refs.length} reference${refs.length === 1 ? '' : 's'}`
|
||||||
|
: `Renamed to ${to}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[config, nodes, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pin (or clear) one node's dial egress. Same optimistic save→apply path as
|
// Pin (or clear) one node's dial egress. Same optimistic save→apply path as
|
||||||
@@ -563,16 +941,20 @@ export default function Nodes() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeSub = useCallback(
|
const removeSub = useCallback(
|
||||||
(idx: number) => {
|
async (idx: number) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const target = subs[idx]
|
const target = subs[idx]
|
||||||
const hasCache = nodes.some((n) => n.FromSub === target.Name)
|
const hasCache = nodes.some((n) => n.FromSub === target.Name)
|
||||||
const extra = hasCache ? ' Its cached nodes stay until you next apply.' : ''
|
const ok = await confirm({
|
||||||
if (!window.confirm(`Delete subscription “${target.Name}”?${extra}`)) return
|
label: 'Delete subscription',
|
||||||
|
title: `Delete subscription “${target.Name}”?`,
|
||||||
|
body: hasCache ? 'Its cached nodes stay until you next apply.' : undefined,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = subs.filter((_, i) => i !== idx)
|
const next = subs.filter((_, i) => i !== idx)
|
||||||
void save({ ...config, Subscriptions: next }, `Deleted ${target.Name}`)
|
void save({ ...config, Subscriptions: next }, `Deleted ${target.Name}`)
|
||||||
},
|
},
|
||||||
[config, subs, nodes, save],
|
[config, subs, nodes, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Commit an options edit for one subscription. The editor hands back a fully
|
// Commit an options edit for one subscription. The editor hands back a fully
|
||||||
@@ -654,6 +1036,14 @@ export default function Nodes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Findings about nodes in general — no single row owns them, so they sit
|
||||||
|
above the lists rather than being dropped for having no name. */}
|
||||||
|
{globalFindings.length > 0 && (
|
||||||
|
<div className="node-findings">
|
||||||
|
<RowFindings findings={globalFindings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ---- NODES ---- */}
|
{/* ---- NODES ---- */}
|
||||||
<div className="node-section" aria-label="Nodes">
|
<div className="node-section" aria-label="Nodes">
|
||||||
<header className="sec-hd">
|
<header className="sec-hd">
|
||||||
@@ -736,6 +1126,20 @@ export default function Nodes() {
|
|||||||
disabled={busy || importing || !config}
|
disabled={busy || importing || !config}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<input
|
||||||
|
className="fp-input add-name"
|
||||||
|
type="text"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
aria-label="Node name — optional"
|
||||||
|
value={nodeName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNodeName(e.target.value)
|
||||||
|
if (nodeErr) setNodeErr(null)
|
||||||
|
}}
|
||||||
|
disabled={busy || importing || !config}
|
||||||
|
/>
|
||||||
<Button type="submit" variant="primary" disabled={busy || importing || !config}>
|
<Button type="submit" variant="primary" disabled={busy || importing || !config}>
|
||||||
{importing ? 'Importing…' : saving ? 'Saving…' : addMode === 'conf' ? 'Import' : 'Add node'}
|
{importing ? 'Importing…' : saving ? 'Saving…' : addMode === 'conf' ? 'Import' : 'Add node'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -743,7 +1147,8 @@ export default function Nodes() {
|
|||||||
<p className="add-hint">
|
<p className="add-hint">
|
||||||
{addMode === 'conf'
|
{addMode === 'conf'
|
||||||
? 'Paste a wg-quick / AmneziaWG .conf — it starts with [Interface].'
|
? 'Paste a wg-quick / AmneziaWG .conf — it starts with [Interface].'
|
||||||
: 'vless://, ss://, trojan://, hysteria2://… A pasted [Interface] config is imported automatically.'}
|
: 'vless://, ss://, trojan://, hysteria2://… A pasted [Interface] config is imported automatically.'}{' '}
|
||||||
|
Leave the name empty and it’s taken from the server address; you can rename it later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{nodeErr && (
|
{nodeErr && (
|
||||||
@@ -794,12 +1199,14 @@ export default function Nodes() {
|
|||||||
<NodeGroup
|
<NodeGroup
|
||||||
key={g.key || '__manual__'}
|
key={g.key || '__manual__'}
|
||||||
group={g}
|
group={g}
|
||||||
|
findings={nodeFindings}
|
||||||
open={isGroupOpen(g)}
|
open={isGroupOpen(g)}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
egressNames={egressNames}
|
egressNames={egressNames}
|
||||||
onToggle={() => toggleGroup(g)}
|
onToggle={() => toggleGroup(g)}
|
||||||
onToggleNode={toggleNode}
|
onToggleNode={toggleNode}
|
||||||
onRemoveNode={removeNode}
|
onRemoveNode={removeNode}
|
||||||
|
onRenameNode={renameNode}
|
||||||
onSetEgress={setNodeEgress}
|
onSetEgress={setNodeEgress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -882,6 +1289,7 @@ export default function Nodes() {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
catalog={detourCatalog}
|
catalog={detourCatalog}
|
||||||
valid={detourValid}
|
valid={detourValid}
|
||||||
|
findings={subFindings.get(s.Name) ?? EMPTY_FINDINGS}
|
||||||
onToggle={(on) => toggleSub(i, on)}
|
onToggle={(on) => toggleSub(i, on)}
|
||||||
onDelete={() => removeSub(i)}
|
onDelete={() => removeSub(i)}
|
||||||
onEdit={(patch) => editSub(i, patch)}
|
onEdit={(patch) => editSub(i, patch)}
|
||||||
@@ -908,23 +1316,35 @@ function NodeGroup({
|
|||||||
open,
|
open,
|
||||||
busy,
|
busy,
|
||||||
egressNames,
|
egressNames,
|
||||||
|
findings,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleNode,
|
onToggleNode,
|
||||||
onRemoveNode,
|
onRemoveNode,
|
||||||
|
onRenameNode,
|
||||||
onSetEgress,
|
onSetEgress,
|
||||||
}: {
|
}: {
|
||||||
group: NodeGroupData
|
group: NodeGroupData
|
||||||
open: boolean
|
open: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
egressNames: string[]
|
egressNames: string[]
|
||||||
|
/** Last-apply findings per node name (findings.ts findingsByName). */
|
||||||
|
findings: Map<string, StatusWarning[]>
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onToggleNode: (idx: number, on: boolean) => void
|
onToggleNode: (idx: number, on: boolean) => void
|
||||||
onRemoveNode: (idx: number) => void
|
onRemoveNode: (idx: number) => void
|
||||||
|
onRenameNode: (idx: number, name: string, onError: (msg: string) => void) => Promise<boolean>
|
||||||
onSetEgress: (idx: number, egress: string) => Promise<boolean>
|
onSetEgress: (idx: number, egress: string) => Promise<boolean>
|
||||||
}) {
|
}) {
|
||||||
const panelId = `node-group-${group.key || 'manual'}`
|
const panelId = `node-group-${group.key || 'manual'}`
|
||||||
// The same inventory count as the section header, scoped to this bucket.
|
// The same inventory count as the section header, scoped to this bucket.
|
||||||
const count = useMemo(() => fmtEnabled(group.items.map((i) => i.node)), [group.items])
|
const count = useMemo(() => fmtEnabled(group.items.map((i) => i.node)), [group.items])
|
||||||
|
// How many nodes in this bucket the last apply had something to say about —
|
||||||
|
// shown on the COLLAPSED header, because a subscription of 300 nodes is
|
||||||
|
// collapsed by default and the row badge below would never be seen otherwise.
|
||||||
|
const flagged = useMemo(
|
||||||
|
() => group.items.filter(({ node }) => findings.has(node.Name)).length,
|
||||||
|
[group.items, findings],
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<section className={`node-group${open ? ' node-group--open' : ''}`}>
|
<section className={`node-group${open ? ' node-group--open' : ''}`}>
|
||||||
<h3 className="group-hd-wrap">
|
<h3 className="group-hd-wrap">
|
||||||
@@ -938,8 +1358,20 @@ function NodeGroup({
|
|||||||
<span className="group-caret" aria-hidden="true" />
|
<span className="group-caret" aria-hidden="true" />
|
||||||
<span className="group-name">{group.label}</span>
|
<span className="group-name">{group.label}</span>
|
||||||
<span className="group-count mono">{count}</span>
|
<span className="group-count mono">{count}</span>
|
||||||
|
{flagged > 0 && (
|
||||||
|
<span className="group-flagged" title="Findings from the last apply">
|
||||||
|
<Led variant="amber" />
|
||||||
|
{flagged} flagged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
|
{open && group.key !== '' && (
|
||||||
|
<p className="group-note">
|
||||||
|
Names come from the subscription feed and are rewritten on every update, so nodes in this
|
||||||
|
list can’t be renamed here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<ul id={panelId} className="rows-list group-rows">
|
<ul id={panelId} className="rows-list group-rows">
|
||||||
{group.items.map(({ node, idx }) => (
|
{group.items.map(({ node, idx }) => (
|
||||||
@@ -948,8 +1380,10 @@ function NodeGroup({
|
|||||||
node={node}
|
node={node}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
egressNames={egressNames}
|
egressNames={egressNames}
|
||||||
|
findings={findings.get(node.Name) ?? EMPTY_FINDINGS}
|
||||||
onToggle={(on) => onToggleNode(idx, on)}
|
onToggle={(on) => onToggleNode(idx, on)}
|
||||||
onDelete={() => onRemoveNode(idx)}
|
onDelete={() => onRemoveNode(idx)}
|
||||||
|
onRename={(name, onError) => onRenameNode(idx, name, onError)}
|
||||||
onSetEgress={(egress) => onSetEgress(idx, egress)}
|
onSetEgress={(egress) => onSetEgress(idx, egress)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -959,19 +1393,42 @@ function NodeGroup({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One shared empty array, so a clean row doesn't get a fresh identity per render. */
|
||||||
|
const EMPTY_FINDINGS: StatusWarning[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Did the generator say it left this entity OUT of the engine config?
|
||||||
|
*
|
||||||
|
* The producers all end the sentence with the same word — "(skipped)" for an
|
||||||
|
* unparseable share link or a bad WireGuard endpoint (generate/outbound.go),
|
||||||
|
* "skipped" for a name colliding with a reserved tag — and wgdedup says only one
|
||||||
|
* of the duplicates "is kept". Read the daemon's word rather than inventing a
|
||||||
|
* verdict: a finding that does NOT say this may well be about a node that is
|
||||||
|
* running perfectly, and badging it "not built" would be a new lie in place of
|
||||||
|
* the old one.
|
||||||
|
*/
|
||||||
|
function skipped(findings: StatusWarning[]): boolean {
|
||||||
|
return findings.some((f) => /\bskipped\b|\bis kept\b/i.test(f.message))
|
||||||
|
}
|
||||||
|
|
||||||
function NodeRow({
|
function NodeRow({
|
||||||
node,
|
node,
|
||||||
busy,
|
busy,
|
||||||
egressNames,
|
egressNames,
|
||||||
|
findings,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRename,
|
||||||
onSetEgress,
|
onSetEgress,
|
||||||
}: {
|
}: {
|
||||||
node: NodeCfg
|
node: NodeCfg
|
||||||
busy: boolean
|
busy: boolean
|
||||||
egressNames: string[]
|
egressNames: string[]
|
||||||
|
/** What the last apply said about THIS node; empty when it said nothing. */
|
||||||
|
findings: StatusWarning[]
|
||||||
onToggle: (on: boolean) => void
|
onToggle: (on: boolean) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onRename: (name: string, onError: (msg: string) => void) => Promise<boolean>
|
||||||
onSetEgress: (egress: string) => Promise<boolean>
|
onSetEgress: (egress: string) => Promise<boolean>
|
||||||
}) {
|
}) {
|
||||||
const { proto, host, hasCreds } = useMemo(() => parseShareLink(node.URI), [node.URI])
|
const { proto, host, hasCreds } = useMemo(() => parseShareLink(node.URI), [node.URI])
|
||||||
@@ -982,6 +1439,68 @@ function NodeRow({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const panelId = `node-egress-${node.FromSub || 'manual'}-${node.Name}`
|
const panelId = `node-egress-${node.FromSub || 'manual'}-${node.Name}`
|
||||||
|
|
||||||
|
// ---- inline rename (same interaction as a device row) ---------------------
|
||||||
|
// Enter commits, Esc cancels, blur commits; a ref-guard keeps Esc-then-blur
|
||||||
|
// from committing twice. Unlike a device, the commit can be REFUSED (a name
|
||||||
|
// collision, or references that can't be carried), so the input stays open
|
||||||
|
// with the reason under it instead of closing on a change that never happened.
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(node.Name)
|
||||||
|
const [renameErr, setRenameErr] = useState<string | null>(null)
|
||||||
|
const nameInput = useRef<HTMLInputElement>(null)
|
||||||
|
const renameBtn = useRef<HTMLButtonElement>(null)
|
||||||
|
const finished = useRef(false)
|
||||||
|
|
||||||
|
const beginRename = () => {
|
||||||
|
setDraft(node.Name)
|
||||||
|
setRenameErr(null)
|
||||||
|
finished.current = false
|
||||||
|
setRenaming(true)
|
||||||
|
}
|
||||||
|
const finishRename = async (commit: boolean) => {
|
||||||
|
if (finished.current) return
|
||||||
|
finished.current = true
|
||||||
|
const nm = draft.trim()
|
||||||
|
if (!commit || !nm || nm === node.Name) {
|
||||||
|
setRenaming(false)
|
||||||
|
setRenameErr(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Armed BEFORE the save: the renamed row remounts the moment the config
|
||||||
|
// state lands, which is before this await resolves. Arming afterwards would
|
||||||
|
// always miss it.
|
||||||
|
pendingRenameFocus = nm
|
||||||
|
const ok = await onRename(nm, (msg) => setRenameErr(msg))
|
||||||
|
if (ok) {
|
||||||
|
setRenaming(false)
|
||||||
|
setRenameErr(null)
|
||||||
|
} else {
|
||||||
|
if (pendingRenameFocus === nm) pendingRenameFocus = null
|
||||||
|
// Refused — hold the field open on the rejected text so it can be fixed.
|
||||||
|
finished.current = false
|
||||||
|
nameInput.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) {
|
||||||
|
nameInput.current?.focus()
|
||||||
|
nameInput.current?.select()
|
||||||
|
}
|
||||||
|
}, [renaming])
|
||||||
|
|
||||||
|
// Claim the baton if this row is the one the rename produced. The row remounts
|
||||||
|
// while the PUT is still in flight, so on that first pass the button is still
|
||||||
|
// disabled and focus() would be a silent no-op — the baton is held until the
|
||||||
|
// save settles and this effect re-runs with a focusable button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingRenameFocus !== node.Name) return
|
||||||
|
const btn = renameBtn.current
|
||||||
|
if (!btn || btn.disabled) return
|
||||||
|
pendingRenameFocus = null
|
||||||
|
btn.focus()
|
||||||
|
}, [node.Name, busy])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`row-item node-row${open ? ' node-row--open' : ''}`}>
|
<li className={`row-item node-row${open ? ' node-row--open' : ''}`}>
|
||||||
<div className="row-head">
|
<div className="row-head">
|
||||||
@@ -993,10 +1512,83 @@ function NodeRow({
|
|||||||
/>
|
/>
|
||||||
<div className="row-main">
|
<div className="row-main">
|
||||||
<div className="row-line1">
|
<div className="row-line1">
|
||||||
<span className="row-name">{node.Name}</span>
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
ref={nameInput}
|
||||||
|
className="inline-rename-input node-name-input mono"
|
||||||
|
type="text"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
value={draft}
|
||||||
|
aria-label={`Rename node ${node.Name}`}
|
||||||
|
aria-invalid={renameErr ? true : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value)
|
||||||
|
if (renameErr) setRenameErr(null)
|
||||||
|
}}
|
||||||
|
onBlur={() => void finishRename(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void finishRename(true)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
void finishRename(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="row-name" title={node.Name}>
|
||||||
|
{node.Name}
|
||||||
|
</span>
|
||||||
|
{managed ? (
|
||||||
|
// Not hidden — withheld, with the reason attached. A control
|
||||||
|
// that quietly isn't there reads as a bug; this one states the
|
||||||
|
// rule, and the same sentence is on the group header above.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-rename inline-rename--locked"
|
||||||
|
disabled
|
||||||
|
aria-label={`Can’t rename ${node.Name} — its name comes from subscription “${node.FromSub}” and is rewritten on the next update`}
|
||||||
|
title={`Named by subscription “${node.FromSub}” — the feed rewrites this name on the next update. Rename it in the subscription, or add the node manually.`}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={renameBtn}
|
||||||
|
type="button"
|
||||||
|
className="inline-rename"
|
||||||
|
onClick={beginRename}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`Rename node ${node.Name}`}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="badge">{proto}</span>
|
<span className="badge">{proto}</span>
|
||||||
{node.Stale && <span className="badge badge--warn">stale</span>}
|
{node.Stale && <span className="badge badge--warn">stale</span>}
|
||||||
|
{/* The toggle above is the SAVED state. When the last apply couldn't
|
||||||
|
build this node the engine has no such outbound, and the two
|
||||||
|
disagree — so the row says which, rather than leaving a green
|
||||||
|
switch to imply the node is carrying traffic. The word is the
|
||||||
|
daemon's own where it used one. */}
|
||||||
|
{findings.length > 0 && (
|
||||||
|
<span className={`badge badge--${skipped(findings) ? 'crit' : 'warn'}`}>
|
||||||
|
{skipped(findings) ? 'not built' : 'flagged'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{renameErr && (
|
||||||
|
<p className="row-err" role="alert">
|
||||||
|
{renameErr}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="row-line2 mono">
|
<div className="row-line2 mono">
|
||||||
<span className="row-host">{host}</span>
|
<span className="row-host">{host}</span>
|
||||||
{hasCreds && (
|
{hasCreds && (
|
||||||
@@ -1014,6 +1606,7 @@ function NodeRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<RowFindings findings={findings} />
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
{canPin && (
|
{canPin && (
|
||||||
@@ -1133,6 +1726,7 @@ function SubRow({
|
|||||||
busy,
|
busy,
|
||||||
catalog,
|
catalog,
|
||||||
valid,
|
valid,
|
||||||
|
findings,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -1143,6 +1737,8 @@ function SubRow({
|
|||||||
busy: boolean
|
busy: boolean
|
||||||
catalog: DetourCatalog
|
catalog: DetourCatalog
|
||||||
valid: Set<string>
|
valid: Set<string>
|
||||||
|
/** What the last apply said about THIS subscription; empty when it said nothing. */
|
||||||
|
findings: StatusWarning[]
|
||||||
onToggle: (on: boolean) => void
|
onToggle: (on: boolean) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onEdit: (patch: Subscription) => Promise<boolean>
|
onEdit: (patch: Subscription) => Promise<boolean>
|
||||||
@@ -1167,6 +1763,7 @@ function SubRow({
|
|||||||
<span className="row-name">{sub.Name}</span>
|
<span className="row-name">{sub.Name}</span>
|
||||||
{sub.Format && sub.Format !== 'auto' && <span className="badge">{sub.Format}</span>}
|
{sub.Format && sub.Format !== 'auto' && <span className="badge">{sub.Format}</span>}
|
||||||
{sub.FetchVia === 'proxy' && <span className="badge">via proxy</span>}
|
{sub.FetchVia === 'proxy' && <span className="badge">via proxy</span>}
|
||||||
|
{findings.length > 0 && <span className="badge badge--warn">flagged</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="row-line2 mono">
|
<div className="row-line2 mono">
|
||||||
<span className="row-host">{host}</span>
|
<span className="row-host">{host}</span>
|
||||||
@@ -1179,6 +1776,7 @@ function SubRow({
|
|||||||
every {interval} · {count} node{count === 1 ? '' : 's'}
|
every {interval} · {count} node{count === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<RowFindings findings={findings} />
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -1650,6 +2248,29 @@ function HeaderRows({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the last apply said about THIS row, under the row it is about.
|
||||||
|
*
|
||||||
|
* Deliberately inside the row rather than in a list at the top of the page: the
|
||||||
|
* failure being fixed is a node that looks fine, and a name in a summary three
|
||||||
|
* screens up does not fix that. The wording is the daemon's own — these messages
|
||||||
|
* already name the entity and say what was done about it ("(skipped)", "only X
|
||||||
|
* is kept"), so paraphrasing them here would only invent a second vocabulary.
|
||||||
|
*/
|
||||||
|
function RowFindings({ findings }: { findings: StatusWarning[] }) {
|
||||||
|
if (findings.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ul className="row-findings" aria-label="Findings from the last apply">
|
||||||
|
{findings.map((f, i) => (
|
||||||
|
<li key={i} className={`row-finding row-finding--${f.severity}`}>
|
||||||
|
<Led variant={f.severity === 'critical' ? 'crit' : 'amber'} />
|
||||||
|
<span className="row-finding-msg">{f.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyPlate({ title, body }: { title: string; body: string }) {
|
function EmptyPlate({ title, body }: { title: string; body: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-plate">
|
<div className="empty-plate">
|
||||||
|
|||||||
+171
-54
@@ -4,17 +4,18 @@ import type { LedVariant } from '../components'
|
|||||||
import { fmtDateTime, fmtDuration } from '../format'
|
import { fmtDateTime, fmtDuration } from '../format'
|
||||||
import {
|
import {
|
||||||
apply as apiApply,
|
apply as apiApply,
|
||||||
confirm as apiConfirm,
|
|
||||||
rollback as apiRollback,
|
rollback as apiRollback,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getRulesReachability,
|
||||||
getStats,
|
getStats,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { Model, Stats, Status, StatusWarning } from '../api'
|
import type { Model, Stats, Status, StatusWarning } from '../api'
|
||||||
|
import { confirmTimeout } from '../pendingConfirm'
|
||||||
import { navigate } from '../router'
|
import { navigate } from '../router'
|
||||||
import type { Route } from '../router'
|
import type { Route } from '../router'
|
||||||
import { attentionFindings } from '../findings'
|
import { attentionFindings, truncationNote } from '../findings'
|
||||||
import { protectionState } from '../planeState'
|
import { engineReadout, killSwitchReadout, protectionState } from '../planeState'
|
||||||
|
|
||||||
// null-safe length for a Go slice that may arrive as null.
|
// null-safe length for a Go slice that may arrive as null.
|
||||||
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
|
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
|
||||||
@@ -27,7 +28,8 @@ function short(hash: string): string {
|
|||||||
return h.length > 12 ? h.slice(0, 12) : h
|
return h.length > 12 ? h.slice(0, 12) : h
|
||||||
}
|
}
|
||||||
|
|
||||||
type ControlKind = 'apply' | 'confirm' | 'rollback'
|
// Confirm is no longer one of them — see the note beside the controls row.
|
||||||
|
type ControlKind = 'apply' | 'rollback'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live service uptime in seconds, ticking between status polls.
|
* Live service uptime in seconds, ticking between status polls.
|
||||||
@@ -42,17 +44,32 @@ type ControlKind = 'apply' | 'confirm' | 'rollback'
|
|||||||
* Returns null when the daemon doesn't report uptime (older builds) — the caller
|
* Returns null when the daemon doesn't report uptime (older builds) — the caller
|
||||||
* then renders nothing rather than inventing a number.
|
* then renders nothing rather than inventing a number.
|
||||||
*/
|
*/
|
||||||
function useUptime(status: Status | null): number | null {
|
function useUptime(status: Status | null): { seconds: number; startedUnix: number } | null {
|
||||||
const base = useRef<{ uptime: number; at: number } | null>(null)
|
const base = useRef<{ uptime: number; at: number } | null>(null)
|
||||||
|
// The instant the daemon came up, ON THE BROWSER'S CLOCK.
|
||||||
|
//
|
||||||
|
// `status.started_unix` is the router's own clock, and the router has no RTC —
|
||||||
|
// it runs on UTC with no tzdata. Rendering it through the browser's timezone
|
||||||
|
// printed a start time three hours in the FUTURE for a Moscow operator, beside
|
||||||
|
// an uptime of "2 h 41 min". Deriving it instead as now-minus-uptime is a
|
||||||
|
// difference of two client timestamps, so it is skew-proof and can never land
|
||||||
|
// ahead of the clock in the header.
|
||||||
|
const started = useRef<number | null>(null)
|
||||||
const [, forceTick] = useState(0)
|
const [, forceTick] = useState(0)
|
||||||
|
|
||||||
const reported = status?.uptime_seconds
|
const reported = status?.uptime_seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof reported !== 'number' || !Number.isFinite(reported)) {
|
if (typeof reported !== 'number' || !Number.isFinite(reported)) {
|
||||||
base.current = null
|
base.current = null
|
||||||
|
started.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
base.current = { uptime: reported, at: Date.now() }
|
const now = Date.now()
|
||||||
|
base.current = { uptime: reported, at: now }
|
||||||
|
// Re-baselining every poll would jitter the displayed second back and forth;
|
||||||
|
// only move it when the estimate has genuinely drifted (a daemon restart).
|
||||||
|
const est = Math.round(now / 1000 - reported)
|
||||||
|
if (started.current === null || Math.abs(started.current - est) > 5) started.current = est
|
||||||
forceTick((n) => n + 1)
|
forceTick((n) => n + 1)
|
||||||
}, [reported])
|
}, [reported])
|
||||||
|
|
||||||
@@ -64,8 +81,11 @@ function useUptime(status: Status | null): number | null {
|
|||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!base.current) return null
|
if (!base.current || started.current === null) return null
|
||||||
return base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000)
|
return {
|
||||||
|
seconds: base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000),
|
||||||
|
startedUnix: started.current,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Overview({
|
export function Overview({
|
||||||
@@ -93,6 +113,29 @@ export function Overview({
|
|||||||
void loadConfig()
|
void loadConfig()
|
||||||
}, [loadConfig])
|
}, [loadConfig])
|
||||||
|
|
||||||
|
// ---- how many rules are actually IN FORCE ----------------------------------
|
||||||
|
//
|
||||||
|
// `Rule.Enabled` from /api/config is the DESIRED state; the active WAN profile
|
||||||
|
// overrides it in either direction, and the daemon reports the result as
|
||||||
|
// `effective_enabled`. Counting the saved switches told a router running one
|
||||||
|
// chain that it had "2 / 2" — the Routing page had already been fixed to read
|
||||||
|
// the verdicts, and the home page kept summing the config beside it.
|
||||||
|
//
|
||||||
|
// null ⇒ no verdicts (older daemon, engine stopped, endpoint unreachable). The
|
||||||
|
// module then says so rather than passing the saved count off as the live one.
|
||||||
|
const [inForce, setInForce] = useState<number | null>(null)
|
||||||
|
const loadReach = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { rules } = await getRulesReachability()
|
||||||
|
setInForce(rules.filter((r) => r.effective_enabled).length)
|
||||||
|
} catch {
|
||||||
|
setInForce(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReach()
|
||||||
|
}, [loadReach])
|
||||||
|
|
||||||
// ---- live filter stats: poll the aggregate snapshot, degrade to honest empty states ----
|
// ---- live filter stats: poll the aggregate snapshot, degrade to honest empty states ----
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,7 +164,7 @@ export function Overview({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ---- apply / confirm / rollback ----
|
// ---- apply / rollback ----
|
||||||
const [busy, setBusy] = useState<ControlKind | null>(null)
|
const [busy, setBusy] = useState<ControlKind | null>(null)
|
||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
const [toast, setToast] = useState<string | null>(null)
|
||||||
@@ -139,22 +182,25 @@ export function Overview({
|
|||||||
setBusy(kind)
|
setBusy(kind)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
try {
|
try {
|
||||||
const fn = kind === 'apply' ? apiApply : kind === 'confirm' ? apiConfirm : apiRollback
|
const r = kind === 'apply' ? await apiApply() : await apiRollback()
|
||||||
const r = await fn()
|
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
setResult({ ok: false, msg: r.error })
|
setResult({ ok: false, msg: r.error })
|
||||||
flash(`${kind} failed`)
|
flash(`${kind} failed`)
|
||||||
} else {
|
} else {
|
||||||
|
// An apply that changed something armed an auto-rollback, and saying
|
||||||
|
// "data plane reconciled" while a timer runs is how someone walks away
|
||||||
|
// from a config that then reverts. Name the window when there is one.
|
||||||
|
const window = confirmTimeout()
|
||||||
const msg =
|
const msg =
|
||||||
kind === 'apply'
|
kind === 'apply'
|
||||||
? r.changed
|
? r.changed
|
||||||
? 'Applied — data plane reconciled'
|
? window > 0
|
||||||
|
? `Applied — keep this config within ${window}s or it rolls back`
|
||||||
|
: 'Applied — data plane reconciled'
|
||||||
: 'Applied — already up to date'
|
: 'Applied — already up to date'
|
||||||
: kind === 'confirm'
|
: 'Rolled back to last-good config'
|
||||||
? 'Confirmed — auto-rollback cancelled'
|
|
||||||
: 'Rolled back to last-good config'
|
|
||||||
setResult({ ok: true, msg })
|
setResult({ ok: true, msg })
|
||||||
flash(kind === 'apply' ? 'Applied' : kind === 'confirm' ? 'Confirmed' : 'Rolled back')
|
flash(kind === 'apply' ? 'Applied' : 'Rolled back')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'request failed'
|
const msg = e instanceof Error ? e.message : 'request failed'
|
||||||
@@ -163,16 +209,20 @@ export function Overview({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
onStatusChange()
|
onStatusChange()
|
||||||
if (kind !== 'confirm') void loadConfig()
|
void loadConfig()
|
||||||
|
// An apply or a rollback is exactly what changes which rules are in force.
|
||||||
|
void loadReach()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flash, loadConfig, onStatusChange],
|
[flash, loadConfig, loadReach, onStatusChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- service uptime (PROCESS uptime, not "time since the last apply") ----
|
// ---- service uptime (PROCESS uptime, not "time since the last apply") ----
|
||||||
const uptime = useUptime(status)
|
const uptime = useUptime(status)
|
||||||
const uptimeText = uptime === null ? '' : fmtDuration(uptime)
|
const uptimeText = uptime === null ? '' : fmtDuration(uptime.seconds)
|
||||||
const startedAt = status?.started_unix ? fmtDateTime(status.started_unix) : ''
|
// On YOUR clock, derived from the uptime — never `status.started_unix`, which is
|
||||||
|
// the router's clock and has no timezone to convert from. See useUptime.
|
||||||
|
const startedAt = uptime === null ? '' : fmtDateTime(uptime.startedUnix)
|
||||||
|
|
||||||
// ---- derived display state ----
|
// ---- derived display state ----
|
||||||
const g = config?.Globals
|
const g = config?.Globals
|
||||||
@@ -251,24 +301,27 @@ export function Overview({
|
|||||||
? `${worstGroup.group} — no answer`
|
? `${worstGroup.group} — no answer`
|
||||||
: `${worstGroup.group} — ${worstGroup.dead} down`
|
: `${worstGroup.group} — ${worstGroup.dead} down`
|
||||||
|
|
||||||
const engineVariant: LedVariant = !status
|
// One reading for the engine, and it is able to say "stopped": `status.running`
|
||||||
? 'off'
|
// was a constant `true` on the daemon, so this LED could never go crit and the
|
||||||
: status.running && status.active
|
// Engine module was green through a process that had failed to start. See
|
||||||
? 'on'
|
// planeState.engineState.
|
||||||
: status.running
|
const engine = engineReadout(status)
|
||||||
? 'amber'
|
const engineVariant: LedVariant = engine.variant
|
||||||
: 'crit'
|
|
||||||
|
|
||||||
const protection = protectionState(status)
|
const protection = protectionState(status)
|
||||||
// Configured fail-closed AND actually enforcing it. `none` means nothing is
|
// Configured fail-closed, actually enforcing it, or not known — three answers,
|
||||||
// installed, so the setting is inert no matter what it says.
|
// and the third is not folded into the first. See planeState.killSwitchReadout.
|
||||||
const killInEffect = killArmed && status?.plane !== 'none'
|
const kill = killSwitchReadout(status, g?.KillSwitch)
|
||||||
|
|
||||||
// Findings that need attention. `info` notes are statements about the config,
|
// Findings that need attention. `info` notes are statements about the config,
|
||||||
// not problems, so they live beside the setting they describe (see findings.ts)
|
// not problems, so they live beside the setting they describe (see findings.ts)
|
||||||
// — keeping this list to things someone could actually act on.
|
// — keeping this list to things someone could actually act on.
|
||||||
const warnings = attentionFindings(status?.warnings)
|
const warnings = attentionFindings(status?.warnings)
|
||||||
const criticalCount = warnings.filter((w) => w.severity === 'critical').length
|
const criticalCount = warnings.filter((w) => w.severity === 'critical').length
|
||||||
|
// The daemon caps the published list at 50 and says so in an `info` note — the
|
||||||
|
// one channel this page filters away. Carried separately so the list can admit
|
||||||
|
// it is not the whole list. See findings.ts truncationNote.
|
||||||
|
const truncated = truncationNote(status?.warnings)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page" aria-label="Overview">
|
<section className="page" aria-label="Overview">
|
||||||
@@ -291,7 +344,7 @@ export function Overview({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Findings warnings={warnings} criticalCount={criticalCount} />
|
<Findings warnings={warnings} criticalCount={criticalCount} truncated={truncated} />
|
||||||
|
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{/* Groups, not nodes: a group is where a dial path is defined, so it is the
|
{/* Groups, not nodes: a group is where a dial path is defined, so it is the
|
||||||
@@ -334,14 +387,31 @@ export function Overview({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* "N / M in force", the same reading the Routing page shows — never the
|
||||||
|
count of saved switches. The lamp follows the same rule: a table of
|
||||||
|
rules none of which are in force routes exactly nothing, and it used
|
||||||
|
to sit under a green light saying "0 / 7". */}
|
||||||
<Module
|
<Module
|
||||||
name="Routing"
|
name="Routing"
|
||||||
value={String(enabledCount(config?.Rules))}
|
value={inForce === null ? String(enabledCount(config?.Rules)) : String(inForce)}
|
||||||
unit={`/ ${len(config?.Rules)} rules`}
|
unit={
|
||||||
led={{ variant: len(config?.Rules) ? 'on' : 'amber' }}
|
inForce === null
|
||||||
|
? `/ ${len(config?.Rules)} rules saved`
|
||||||
|
: `/ ${len(config?.Rules)} in force`
|
||||||
|
}
|
||||||
|
led={{
|
||||||
|
variant:
|
||||||
|
len(config?.Rules) === 0
|
||||||
|
? 'amber'
|
||||||
|
: inForce === null
|
||||||
|
? 'off'
|
||||||
|
: inForce === 0
|
||||||
|
? 'amber'
|
||||||
|
: 'on',
|
||||||
|
}}
|
||||||
rows={[
|
rows={[
|
||||||
{ k: 'egresses', v: String(len(config?.Egresses)) },
|
{ k: 'egresses', v: String(len(config?.Egresses)) },
|
||||||
{ k: 'default', v: defaultTarget(config), hot: true },
|
{ k: 'default', v: defaultTarget(status, config), hot: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -381,17 +451,17 @@ export function Overview({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* A kill-switch set to fail-closed is only ARMED if something is actually
|
{/* A kill-switch set to fail-closed is only ARMED if something is actually
|
||||||
installed to enforce it. With no plane it is configured but inert, and
|
installed to enforce it, and "we haven't been told" is neither. With no
|
||||||
saying "ARMED" there would be a false reassurance next to a readout
|
plane it is configured but inert; with no reading the lamp stays unlit
|
||||||
that says nothing is protected. */}
|
rather than joining the healthy branch by default. */}
|
||||||
<Module
|
<Module
|
||||||
name="Kill-switch"
|
name="Kill-switch"
|
||||||
value={killInEffect ? 'ARMED' : killArmed ? 'NOT IN EFFECT' : 'OPEN'}
|
value={kill.value}
|
||||||
led={{ variant: killInEffect ? 'on' : killArmed ? 'crit' : 'amber' }}
|
led={{ variant: kill.variant }}
|
||||||
rows={[
|
rows={[
|
||||||
{ k: 'setting', v: killArmed ? 'fail-closed' : 'fail-open', hot: !killArmed },
|
{ k: 'setting', v: killArmed ? 'fail-closed' : 'fail-open', hot: !killArmed },
|
||||||
...(killArmed && !killInEffect
|
...(kill.blockingNow
|
||||||
? [{ k: 'blocking now', v: 'no — nothing installed', hot: true }]
|
? [{ k: 'blocking now', v: kill.blockingNow, hot: kill.hot }]
|
||||||
: [{ k: 'ipv6', v: g?.IPv6 ? 'covered' : 'off' }]),
|
: [{ k: 'ipv6', v: g?.IPv6 ? 'covered' : 'off' }]),
|
||||||
{ k: 'confirm', v: g?.ConfirmTimeout ? `${g.ConfirmTimeout}s window` : 'no auto-rollback' },
|
{ k: 'confirm', v: g?.ConfirmTimeout ? `${g.ConfirmTimeout}s window` : 'no auto-rollback' },
|
||||||
]}
|
]}
|
||||||
@@ -414,6 +484,7 @@ export function Overview({
|
|||||||
unit={status?.version?.includes('-') ? '· ' + status.version.split('-').slice(1).join('-') : ''}
|
unit={status?.version?.includes('-') ? '· ' + status.version.split('-').slice(1).join('-') : ''}
|
||||||
led={{ variant: engineVariant }}
|
led={{ variant: engineVariant }}
|
||||||
rows={[
|
rows={[
|
||||||
|
{ k: 'process', v: engine.word, hot: engineVariant === 'crit' },
|
||||||
{ k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> },
|
{ k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> },
|
||||||
// Uptime of the daemon PROCESS. "started" is the moment it came up,
|
// Uptime of the daemon PROCESS. "started" is the moment it came up,
|
||||||
// by the router's clock — not the moment a config was applied.
|
// by the router's clock — not the moment a config was applied.
|
||||||
@@ -431,9 +502,14 @@ export function Overview({
|
|||||||
<Button variant="primary" onClick={() => void run('apply')} disabled={busy !== null}>
|
<Button variant="primary" onClick={() => void run('apply')} disabled={busy !== null}>
|
||||||
{busy === 'apply' ? 'Applying…' : 'Apply config'}
|
{busy === 'apply' ? 'Applying…' : 'Apply config'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void run('confirm')} disabled={busy !== null}>
|
{/* A "Confirm" button used to sit here permanently, and pressing it
|
||||||
{busy === 'confirm' ? 'Confirming…' : 'Confirm'}
|
always printed "Confirmed — auto-rollback cancelled": `apply.Confirm()`
|
||||||
</Button>
|
returns nil whether or not a window was ever armed, so the message was
|
||||||
|
a success report for an event that usually had not happened.
|
||||||
|
Keeping a config is now offered only while a window is actually open,
|
||||||
|
and that is announced by the app-wide band directly above this page —
|
||||||
|
which is where the button lives, beside the countdown it belongs to,
|
||||||
|
rather than duplicated here. */}
|
||||||
{canRollback && (
|
{canRollback && (
|
||||||
<Button onClick={() => void run('rollback')} disabled={busy !== null}>
|
<Button onClick={() => void run('rollback')} disabled={busy !== null}>
|
||||||
{busy === 'rollback' ? 'Rolling back…' : 'Rollback'}
|
{busy === 'rollback' ? 'Rolling back…' : 'Rollback'}
|
||||||
@@ -464,11 +540,23 @@ const SECTION_ROUTE: Record<string, Route> = {
|
|||||||
rule: 'routing',
|
rule: 'routing',
|
||||||
ruleset: 'routing',
|
ruleset: 'routing',
|
||||||
blocklist: 'dns',
|
blocklist: 'dns',
|
||||||
|
allowlist: 'dns',
|
||||||
resolver: 'dns',
|
resolver: 'dns',
|
||||||
|
dns_rule: 'dns',
|
||||||
device: 'devices',
|
device: 'devices',
|
||||||
chain: 'targets',
|
chain: 'targets',
|
||||||
group: 'targets',
|
group: 'targets',
|
||||||
|
// A node the generator dropped (unparseable share link, duplicate WireGuard
|
||||||
|
// key, name colliding with a reserved tag) is reported under `node` — and had
|
||||||
|
// nowhere to jump to, so the one page that could show it a green toggle was
|
||||||
|
// also the one page the finding could not reach.
|
||||||
|
node: 'nodes',
|
||||||
|
subscription: 'nodes',
|
||||||
|
egress: 'targets',
|
||||||
|
inbound: 'networks',
|
||||||
interface: 'networks',
|
interface: 'networks',
|
||||||
|
profile: 'profiles',
|
||||||
|
alert: 'settings',
|
||||||
// The standing note about non-TCP/UDP traffic — its control lives on Networks.
|
// The standing note about non-TCP/UDP traffic — its control lives on Networks.
|
||||||
untunnelable: 'networks',
|
untunnelable: 'networks',
|
||||||
}
|
}
|
||||||
@@ -486,11 +574,14 @@ const SECTION_ROUTE: Record<string, Route> = {
|
|||||||
function Findings({
|
function Findings({
|
||||||
warnings,
|
warnings,
|
||||||
criticalCount,
|
criticalCount,
|
||||||
|
truncated,
|
||||||
}: {
|
}: {
|
||||||
warnings: StatusWarning[]
|
warnings: StatusWarning[]
|
||||||
criticalCount: number
|
criticalCount: number
|
||||||
|
/** The daemon's "N further suppressed" note, when the list was capped. */
|
||||||
|
truncated: StatusWarning | null
|
||||||
}) {
|
}) {
|
||||||
if (warnings.length === 0) return null
|
if (warnings.length === 0 && !truncated) return null
|
||||||
|
|
||||||
const rank = { critical: 0, warning: 1, info: 2 } as const
|
const rank = { critical: 0, warning: 1, info: 2 } as const
|
||||||
const sorted = [...warnings].sort((a, b) => rank[a.severity] - rank[b.severity])
|
const sorted = [...warnings].sort((a, b) => rank[a.severity] - rank[b.severity])
|
||||||
@@ -500,6 +591,10 @@ function Findings({
|
|||||||
<header className="findings-hd">
|
<header className="findings-hd">
|
||||||
<h2 className="findings-title">Last apply</h2>
|
<h2 className="findings-title">Last apply</h2>
|
||||||
<span className="findings-count mono">
|
<span className="findings-count mono">
|
||||||
|
{/* "at least" whenever the list was capped: the counts below it are a
|
||||||
|
floor, not a total, and the cap drops the least severe FIRST — so
|
||||||
|
on a config with fifty criticals the thing it drops is a critical. */}
|
||||||
|
{truncated ? 'at least ' : ''}
|
||||||
{criticalCount > 0
|
{criticalCount > 0
|
||||||
? `${criticalCount} critical · ${warnings.length} total`
|
? `${criticalCount} critical · ${warnings.length} total`
|
||||||
: `${warnings.length} note${warnings.length === 1 ? '' : 's'}`}
|
: `${warnings.length} note${warnings.length === 1 ? '' : 's'}`}
|
||||||
@@ -538,6 +633,20 @@ function Findings({
|
|||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{/* The list saying it is not the whole list. Last, because it is about
|
||||||
|
everything above it — and never filtered out with the other `info`
|
||||||
|
notes, which is where it used to disappear. */}
|
||||||
|
{truncated && (
|
||||||
|
<li className="finding finding--truncated">
|
||||||
|
<Led variant="amber" />
|
||||||
|
<div className="finding-copy">
|
||||||
|
<span className="finding-where mono">list truncated</span>
|
||||||
|
<span className="finding-msg">
|
||||||
|
Some findings are missing from this list. {truncated.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -563,12 +672,20 @@ const NAV_LABEL: Record<Route, string> = {
|
|||||||
// for the apply/rollback flow, where the individual flags are the actual
|
// for the apply/rollback flow, where the individual flags are the actual
|
||||||
// subject of the page.)
|
// subject of the page.)
|
||||||
|
|
||||||
function defaultTarget(config: Model | null): string {
|
/** Where everything not matched by a rule goes — the engine's route `final`.
|
||||||
const rules = config?.Rules ?? []
|
*
|
||||||
if (rules.length === 0) return '—'
|
* Taken from the daemon (status.traffic.default), which reads it off the config
|
||||||
// The highest Order enabled rule is the effective catch-all.
|
* it is running. The guess this replaced was "the highest-Order enabled rule",
|
||||||
const enabled = rules.filter((r) => r.Enabled)
|
* and that is not what the default is: a rule only becomes the default by having
|
||||||
if (enabled.length === 0) return 'none'
|
* NO conditions at all, whatever its Order (model.IsCatchAll), so a specific
|
||||||
const last = enabled.reduce((a, b) => (b.Order >= a.Order ? b : a))
|
* high-Order rule was routinely printed here as the router's default. It also
|
||||||
return last.Target || last.Egress || last.Name
|
* described the config on disk rather than the one running, and could not see a
|
||||||
|
* target that failed to resolve and fell back.
|
||||||
|
*
|
||||||
|
* Falls back to the rule count only when the daemon has not reported — never to
|
||||||
|
* a guess about where traffic goes. */
|
||||||
|
function defaultTarget(status: Status | null, config: Model | null): string {
|
||||||
|
const d = status?.traffic?.default
|
||||||
|
if (d) return d
|
||||||
|
return len(config?.Rules) === 0 ? '—' : 'not reported'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import './Profiles.css'
|
import './Profiles.css'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Led, Toggle } from '../components'
|
import { Button, Led, Toggle, useConfirm } from '../components'
|
||||||
import { apply as apiApply, getConfig, getInterfaces, putConfig, ApiError } from '../api'
|
import { apply as apiApply, getConfig, getInterfaces, putConfig, ApiError } from '../api'
|
||||||
import type { Interface, Model, Profile } from '../api'
|
import type { Interface, Model, Profile } from '../api'
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ function namesOf(v: unknown): string[] {
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Profiles() {
|
export default function Profiles() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -192,9 +193,14 @@ export default function Profiles() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const deleteProfile = useCallback(
|
const deleteProfile = useCallback(
|
||||||
(name: string) => {
|
async (name: string) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
if (!window.confirm(`Delete profile “${name}”? Its overrides stop applying.`)) return
|
const ok = await confirm({
|
||||||
|
label: 'Delete profile',
|
||||||
|
title: `Delete profile “${name}”?`,
|
||||||
|
body: 'Its overrides stop applying.',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const next = profiles.filter((p) => p.Name !== name)
|
const next = profiles.filter((p) => p.Name !== name)
|
||||||
const g =
|
const g =
|
||||||
config.Globals.ActiveProfile === name
|
config.Globals.ActiveProfile === name
|
||||||
@@ -202,7 +208,7 @@ export default function Profiles() {
|
|||||||
: config.Globals
|
: config.Globals
|
||||||
void save({ ...config, Profiles: next, Globals: g }, `Deleted ${name}`)
|
void save({ ...config, Profiles: next, Globals: g }, `Deleted ${name}`)
|
||||||
},
|
},
|
||||||
[config, profiles, save],
|
[config, profiles, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- expansion (only one profile editor open at a time) -------------------
|
// ---- expansion (only one profile editor open at a time) -------------------
|
||||||
|
|||||||
+123
-6
@@ -58,6 +58,45 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- active-profile banner ----
|
||||||
|
*
|
||||||
|
* Deliberately NOT the accent plate the save→apply bar wears above. Orange is
|
||||||
|
* "there is something for you to do" on this faceplate, and an active WAN profile
|
||||||
|
* is a standing condition, not a pending action. A quiet plate with an amber tag
|
||||||
|
* reads as "note the state" — and it is the SAME amber the overridden rows below
|
||||||
|
* carry, so the banner and its rows are visibly one story rather than two
|
||||||
|
* unrelated oddities. */
|
||||||
|
.rt-prof-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 calc(var(--u, 8px) * 2.5);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--amber) 35%, var(--groove));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--amber) 7%, transparent);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.rt-prof-banner strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.rt-prof-tag {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 1px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--groove));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--amber) 12%, transparent);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--track-label);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- empty state ---- */
|
/* ---- empty state ---- */
|
||||||
.rt-empty {
|
.rt-empty {
|
||||||
padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3);
|
padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3);
|
||||||
@@ -252,6 +291,88 @@
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- a rule that can never fire (superseded by a later condition-less rule) ----
|
||||||
|
*
|
||||||
|
* Warn semantics only: --amber, never --accent. Orange is the ACTIVE state on this
|
||||||
|
* faceplate, and a rule the router ignores is the opposite of active — painting it
|
||||||
|
* orange is what made two `default` rows look equally live. It is a dashed amber
|
||||||
|
* frame, an amber order chip, and a dimmed target, so the row reads as "wired but
|
||||||
|
* not connected" without shouting: nothing is broken, one setting is just inert. */
|
||||||
|
.rt-rule.dead {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.rt-ord.dead {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: color-mix(in srgb, var(--amber) 45%, var(--groove));
|
||||||
|
}
|
||||||
|
.rt-badge.dead {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--groove));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--amber) 12%, transparent);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.rt-dead-note {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
/* The target is still what the operator asked for, so it stays readable — just
|
||||||
|
* quiet, because the router is not using it. */
|
||||||
|
.rt-rule.dead .rt-target {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- a rule the active WAN profile overrides ----
|
||||||
|
*
|
||||||
|
* The row itself needs no new paint: an overridden-off rule already wears `.off`
|
||||||
|
* (it is off, whatever its switch says) and an overridden-on rule wears nothing
|
||||||
|
* (it is on). What was missing was never colour — it was the sentence naming who
|
||||||
|
* decided. So this is the per-row twin of the banner and borrows .rt-dead-note's
|
||||||
|
* type wholesale: same voice, same size, one <p> margin to reset. */
|
||||||
|
/* The same pill as .rt-badge.dead, so the two override states read as one pair,
|
||||||
|
* but in accent — a rule the profile forces ON is active, and active is orange on
|
||||||
|
* this faceplate. The pill is also what keeps it from running into the plain
|
||||||
|
* "default route · final" badge beside it, where "final on · by profile" read as
|
||||||
|
* one phrase. */
|
||||||
|
.rt-badge.prof-on {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid var(--accent-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rt-prof-note {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.rt-prof-note strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch + its legend. The caption shows ONLY while a profile overrides the rule,
|
||||||
|
* and it is what keeps the control honest: the plate says what the router is
|
||||||
|
* doing, this says the switch is about the saved setting. A legend under the
|
||||||
|
* control it names is the faceplate's own idiom. */
|
||||||
|
.rt-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.rt-switch-note {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8.5px;
|
||||||
|
letter-spacing: var(--track-label);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- target chip (styled like the artifact's group:auto mono chips) ---- */
|
/* ---- target chip (styled like the artifact's group:auto mono chips) ---- */
|
||||||
.rt-target {
|
.rt-target {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -359,9 +480,6 @@
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.rt-field-wide {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
.rt-flabel {
|
.rt-flabel {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -478,6 +596,8 @@ select.rt-input {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 1px 0 var(--edge) inset, 0 0 0 1px var(--accent-soft);
|
box-shadow: 0 1px 0 var(--edge) inset, 0 0 0 1px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
/* "no matchers" flag in the plate foot — shared by BOTH rule forms (add and
|
||||||
|
* edit), so the same non-blocking warning reads identically in either. */
|
||||||
.rt-edit-warn {
|
.rt-edit-warn {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
@@ -800,9 +920,6 @@ select.rt-input {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
.rt-field-wide {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
.rt-rs-row {
|
.rt-rs-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
row-gap: 10px;
|
row-gap: 10px;
|
||||||
|
|||||||
+512
-175
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
import './Settings.css'
|
import './Settings.css'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Button, Led, Select, Toggle } from '../components'
|
import { Button, Led, Select, Toggle, useConfirm } from '../components'
|
||||||
|
import { AlertsSection } from './Alerts'
|
||||||
import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api'
|
import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api'
|
||||||
import type { Globals, LogRange, Model } from '../api'
|
import type { Globals, LogRange, Model } from '../api'
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ const STATS_BACKENDS: ReadonlyArray<{ value: string; label: string }> = [
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -257,6 +259,48 @@ export default function Settings() {
|
|||||||
const groupHealthOn = globals?.GroupHealth !== false
|
const groupHealthOn = globals?.GroupHealth !== false
|
||||||
|
|
||||||
const killSwitch = globals?.KillSwitch === 'open' ? 'open' : 'closed'
|
const killSwitch = globals?.KillSwitch === 'open' ? 'open' : 'closed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master switch, which is the most destructive control in the panel and was
|
||||||
|
* the only one that asked nothing.
|
||||||
|
*
|
||||||
|
* Turning it off is not "pausing the proxy": apply.go runs Teardown() — the nft
|
||||||
|
* table goes, the policy routing goes, `plane` becomes `none`. The kill-switch
|
||||||
|
* does not save you, because a kill-switch is a rule in a table that no longer
|
||||||
|
* exists. Everything on the LAN then leaves through the plain WAN, unproxied and
|
||||||
|
* unfiltered. Deleting a rule-set asked for confirmation; this did not.
|
||||||
|
*
|
||||||
|
* Turning it back ON is not destructive and is not gated.
|
||||||
|
*/
|
||||||
|
const toggleService = useCallback(
|
||||||
|
async (on: boolean) => {
|
||||||
|
if (!on) {
|
||||||
|
const ok = await confirm({
|
||||||
|
label: 'Turn off the service',
|
||||||
|
title: 'Turn the proxy engine off?',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
This tears the whole data plane down — the firewall table, the policy routing and the
|
||||||
|
DNS interception are removed, not paused. Nothing is proxied, filtered or blocked, and
|
||||||
|
every device leaves through your normal internet connection with its real address.{' '}
|
||||||
|
{killSwitch === 'closed' ? (
|
||||||
|
<>
|
||||||
|
The kill-switch does not hold here: with nothing installed there is nothing left
|
||||||
|
to block with.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>The kill-switch is already open, so nothing changes about that.</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirmLabel: 'Turn it off',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
setGlobal('Enabled', on, on ? 'Engine enabled' : 'Engine disabled')
|
||||||
|
},
|
||||||
|
[confirm, killSwitch, setGlobal],
|
||||||
|
)
|
||||||
const killNote =
|
const killNote =
|
||||||
killSwitch === 'open'
|
killSwitch === 'open'
|
||||||
? 'Fail-open — if the engine stops, traffic falls back to the direct WAN. Stays online, but unprotected.'
|
? 'Fail-open — if the engine stops, traffic falls back to the direct WAN. Stays online, but unprotected.'
|
||||||
@@ -296,10 +340,13 @@ export default function Settings() {
|
|||||||
<div className="set-groups">
|
<div className="set-groups">
|
||||||
{/* ---- SERVICE ---- */}
|
{/* ---- SERVICE ---- */}
|
||||||
<Group title="Service" count={globals?.Enabled ? 'enabled' : 'disabled'}>
|
<Group title="Service" count={globals?.Enabled ? 'enabled' : 'disabled'}>
|
||||||
<Field label="Proxy engine" note="Master on/off for the whole appliance.">
|
<Field
|
||||||
|
label="Proxy engine"
|
||||||
|
note="Master on/off for the whole appliance. Off removes the firewall table and the policy routing — every device goes out directly, with no kill-switch to catch it."
|
||||||
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
pressed={globals?.Enabled ?? false}
|
pressed={globals?.Enabled ?? false}
|
||||||
onChange={(on) => setGlobal('Enabled', on, on ? 'Engine enabled' : 'Engine disabled')}
|
onChange={(on) => void toggleService(on)}
|
||||||
label={globals?.Enabled ? 'Disable proxy engine' : 'Enable proxy engine'}
|
label={globals?.Enabled ? 'Disable proxy engine' : 'Enable proxy engine'}
|
||||||
size="md"
|
size="md"
|
||||||
disabled={busy || !ready}
|
disabled={busy || !ready}
|
||||||
@@ -360,7 +407,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Log level"
|
label="Log level"
|
||||||
note="Verbosity of the daemon log. “none” silences the engine and drops the control-plane to panic-only — a turn-down, not a true off: even warnings and errors are hidden. The toggles below decide where whatever is emitted gets written; turning both off is the only full silence. Failures still raise alerts regardless of this level."
|
note="Verbosity of the daemon log. “none” silences the engine and drops the control-plane to panic-only — a turn-down, not a true off: even warnings and errors are hidden. The toggles below decide where whatever is emitted gets written; turning both off is the only full silence. Failures still raise alerts regardless of this level — set up where they go in the Alerts section below."
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
value={globals?.LogLevel || 'warning'}
|
value={globals?.LogLevel || 'warning'}
|
||||||
@@ -574,6 +621,13 @@ export default function Settings() {
|
|||||||
</Field>
|
</Field>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* ---- ALERTS ---- */}
|
||||||
|
{/* Extracted from the DNS page — out-of-band notifications belong with
|
||||||
|
the appliance-wide knobs, next to the log level whose note points
|
||||||
|
here. Renders its own section header (same plate as a Group); all
|
||||||
|
writes go through `save`, so the dirty banner and toast stay one. */}
|
||||||
|
<AlertsSection config={config} busy={busy} loading={loading} onSave={save} />
|
||||||
|
|
||||||
{/* ---- STATISTICS & LOGGING ---- */}
|
{/* ---- STATISTICS & LOGGING ---- */}
|
||||||
<Group
|
<Group
|
||||||
title="Statistics & logging"
|
title="Statistics & logging"
|
||||||
|
|||||||
@@ -704,6 +704,13 @@
|
|||||||
.tg-test--bad .tg-test-msg {
|
.tg-test--bad .tg-test-msg {
|
||||||
color: var(--crit);
|
color: var(--crit);
|
||||||
}
|
}
|
||||||
|
/* "Nothing measured this" is not a failure and must never be dressed as one: an
|
||||||
|
unlit lamp and the faintest text on the card, the same register the group
|
||||||
|
readout uses for its unmeasured state. */
|
||||||
|
.tg-test--none .tg-test-msg {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
.tg-test--wait .tg-test-msg {
|
.tg-test--wait .tg-test-msg {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1045,235 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- responsive ---- */
|
/* ---- responsive ---- */
|
||||||
|
/* ---- chain hop rail ----
|
||||||
|
* The chain section's signature, and the one place this card spends any
|
||||||
|
* boldness: the path is drawn as a CONDUCTOR with a numbered lamp at each hop,
|
||||||
|
* and the conductor is SEVERED below the first hop that was probed and did not
|
||||||
|
* answer. A chain is a single series path, so the question is never "how many
|
||||||
|
* hops are green", it is "where does my traffic stop" — and a broken line answers
|
||||||
|
* that before a word has been read.
|
||||||
|
*
|
||||||
|
* The two marks carry two different facts and must not be conflated:
|
||||||
|
* - the LAMP is that hop's own measurement (good / warn / crit / unlit). Below
|
||||||
|
* the break there is no measurement to draw: the daemon stops walking at the
|
||||||
|
* first dead hop, so those lamps are UNLIT and the row says which hop stopped
|
||||||
|
* the walk. Unlit is never a shade of red — it claims nothing, which is the
|
||||||
|
* truth about a hop nobody dialled;
|
||||||
|
* - the CONDUCTOR is reachability through the path, which really does stop.
|
||||||
|
*
|
||||||
|
* Orange is untouched here. Semantics carry every colour, and everything that is
|
||||||
|
* not a lamp is groove-grey. No transitions and no animation anywhere in the
|
||||||
|
* rail, so there is nothing for reduced-motion to switch off. */
|
||||||
|
.ch-rail {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ch-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--track-label);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.ch-hops {
|
||||||
|
--ch-num: 1.8ch; /* the engraved hop number's gutter */
|
||||||
|
--ch-gap: 8px;
|
||||||
|
--ch-led: 10px; /* must match .led's width */
|
||||||
|
--ch-lampy: 14px; /* row top → lamp centre; the conductor's anchor */
|
||||||
|
/* x of the conductor: the number gutter, one gap, then the lamp's centre */
|
||||||
|
--ch-spine: calc(var(--ch-num) + var(--ch-gap) + var(--ch-led) / 2);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ch-hop {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--ch-num) var(--ch-led) minmax(0, 1fr);
|
||||||
|
column-gap: var(--ch-gap);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the conductor — two halves per row, so a break lands on one link only */
|
||||||
|
.ch-hop::before,
|
||||||
|
.ch-hop::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--ch-spine);
|
||||||
|
width: 2px;
|
||||||
|
margin-left: -1px;
|
||||||
|
/* Brighter than a plain groove: this line IS the readout, and at groove
|
||||||
|
strength it disappeared into the panel and took the whole idea with it. */
|
||||||
|
background: color-mix(in srgb, var(--dim) 55%, var(--groove));
|
||||||
|
}
|
||||||
|
.ch-hop::before {
|
||||||
|
top: 0;
|
||||||
|
height: calc(var(--ch-lampy) - var(--ch-led) / 2 - 3px);
|
||||||
|
}
|
||||||
|
.ch-hop::after {
|
||||||
|
top: calc(var(--ch-lampy) + var(--ch-led) / 2 + 3px);
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
/* Nothing feeds hop 1 from above, and nothing leaves the exit downward — the
|
||||||
|
path starts and ends inside this rail. */
|
||||||
|
.ch-hop--first::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ch-hop--exit::after {
|
||||||
|
bottom: auto;
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
/* …the exit ends on a crossbar instead of trailing off: end of line. */
|
||||||
|
.ch-hop--exit .ch-socket {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ch-hop--exit .ch-socket::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
top: calc(var(--ch-lampy) + var(--ch-led) / 2 + 12px);
|
||||||
|
width: 11px;
|
||||||
|
height: 2px;
|
||||||
|
background: color-mix(in srgb, var(--dim) 55%, var(--groove));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THE SEVER. Everything from the dead hop's outgoing link downward is drawn as a
|
||||||
|
broken conductor: unmistakably not-a-line at a glance, and unmistakably not a
|
||||||
|
colour, because a colour here would compete with the lamps that carry health. */
|
||||||
|
.ch-hop--dead::after,
|
||||||
|
.ch-hop--severed::before,
|
||||||
|
.ch-hop--severed::after {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
color-mix(in srgb, var(--dim) 45%, var(--groove)) 0 3px,
|
||||||
|
transparent 3px 7px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-num {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: calc(var(--ch-lampy) * 2);
|
||||||
|
text-align: right;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.ch-socket {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: calc(var(--ch-lampy) * 2);
|
||||||
|
}
|
||||||
|
.ch-body {
|
||||||
|
min-width: 0;
|
||||||
|
/* Separates one hop from the next. The conductor runs through this space, so
|
||||||
|
too little of it and two hops read as one wrapped row. */
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ch-l1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: calc(var(--ch-lampy) * 2);
|
||||||
|
}
|
||||||
|
.ch-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ch-hop--untested .ch-name {
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
/* The exit marker is NEUTRAL on purpose. The config path above this rail tags its
|
||||||
|
exit green, which is free there — but in here green means "answering", and a
|
||||||
|
green badge on the last hop would read as a health claim about it. */
|
||||||
|
.ch-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 8.5px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.ch-delay {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.ch-quiet {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
/* A blocked hop's phrase carries a tooltip with the blocking hop's engine
|
||||||
|
outbound, so it takes the same help cursor as .ch-dead. No colour of its own:
|
||||||
|
the finding is red once, on the hop that actually failed. */
|
||||||
|
.ch-blocked {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.ch-age {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The counters read exactly as they do on a group card — alive out of TESTED,
|
||||||
|
with the untested remainder as a quiet aside only when there is one. Same
|
||||||
|
register, same weights, deliberately not a second dialect. */
|
||||||
|
.ch-l2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.ch-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ch-count b {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.ch-hop--dead .ch-count b {
|
||||||
|
color: var(--crit);
|
||||||
|
}
|
||||||
|
.ch-word {
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.ch-dead {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--crit) 12%, transparent);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--crit);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.ch-rest {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
/* On a blocked hop this chip says "set to", not "now": a pick nothing crossed.
|
||||||
|
It steps back to faint so it can't be mistaken for a live reading. */
|
||||||
|
.ch-hop--blocked .ch-now {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.ch-now {
|
||||||
|
max-width: 28ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.tg-sec-hd {
|
.tg-sec-hd {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1060,6 +1296,20 @@
|
|||||||
.gh-now {
|
.gh-now {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
/* On a phone the age stamp stops being pushed to a lonely right edge and just
|
||||||
|
joins the end of the hop's line; the selected node gets the full width
|
||||||
|
instead of an ellipsis it doesn't need. */
|
||||||
|
.ch-age {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.ch-now {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
/* Every field of a hop wraps onto its own line at this width, so the gap
|
||||||
|
between hops has to grow with them or the rail reads as one block of text. */
|
||||||
|
.ch-body {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
.gh-mems {
|
.gh-mems {
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
}
|
}
|
||||||
|
|||||||
+458
-100
@@ -1,6 +1,6 @@
|
|||||||
import './Targets.css'
|
import './Targets.css'
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Button, Led, Toggle } from '../components'
|
import { Button, Led, Toggle, useConfirm } from '../components'
|
||||||
import type { LedVariant } from '../components'
|
import type { LedVariant } from '../components'
|
||||||
import {
|
import {
|
||||||
apply as apiApply,
|
apply as apiApply,
|
||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
GroupTestResult,
|
GroupTestResult,
|
||||||
GroupTestStatus,
|
GroupTestStatus,
|
||||||
Chain,
|
Chain,
|
||||||
|
ChainHealth,
|
||||||
|
ChainHopHealth,
|
||||||
Egress,
|
Egress,
|
||||||
Interface,
|
Interface,
|
||||||
Node,
|
Node,
|
||||||
@@ -161,18 +163,18 @@ function renameReferences(m: Model, kind: RefKind, from: string, to: string): Mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sentence a delete confirmation appends: what still points at this target,
|
* The body of a delete confirmation: what still points at this target, and what
|
||||||
* and what happens to it. Empty list ⇒ an explicit "nothing references it", so
|
* happens to it. Empty list ⇒ an explicit "nothing references it", so the
|
||||||
* the operator can delete a stray with confidence instead of guessing.
|
* operator can delete a stray with confidence instead of guessing.
|
||||||
*/
|
*/
|
||||||
function refWarning(refs: RefSite[]): string {
|
function refWarning(refs: RefSite[]): string {
|
||||||
if (refs.length === 0) return ' Nothing references it.'
|
if (refs.length === 0) return 'Nothing references it.'
|
||||||
const shown = refs.slice(0, 4).map((r) => r.label)
|
const shown = refs.slice(0, 4).map((r) => r.label)
|
||||||
const more = refs.length - shown.length
|
const more = refs.length - shown.length
|
||||||
const list = `${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}`
|
const list = `${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}`
|
||||||
return refs.length === 1
|
return refs.length === 1
|
||||||
? ` It is referenced by ${list}, whose traffic will be blocked (an unresolved target never falls through to the default route).`
|
? `It is referenced by ${list}, whose traffic will be blocked (an unresolved target never falls through to the default route).`
|
||||||
: ` It is referenced by ${refs.length} places — ${list} — whose traffic will be blocked (an unresolved target never falls through to the default route).`
|
: `It is referenced by ${refs.length} places — ${list} — whose traffic will be blocked (an unresolved target never falls through to the default route).`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,14 +438,14 @@ const normalizeTest = (st: GroupTestStatus): GroupTestStatus => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the header names the reach of a running exit test. The name matters more
|
* How the header names the reach of a running refresh pass. The name matters more
|
||||||
* than the number when there is only one: "auto" tells the operator which button
|
* than the number when there is only one: "auto" tells the operator which button
|
||||||
* they pressed; "1 target" tells them nothing they didn't already know.
|
* they pressed; "1 target" tells them nothing they didn't already know.
|
||||||
*/
|
*/
|
||||||
function scopeLabel(scope: string[], targetCount: number): string {
|
function scopeLabel(scope: string[], targetCount: number): string {
|
||||||
if (scope.length === 1) return scope[0]
|
if (scope.length === 1) return scope[0]
|
||||||
if (scope.length === 0) return 'exits' // pre-scope daemon — say nothing false
|
if (scope.length === 0) return 'targets' // pre-scope daemon — say nothing false
|
||||||
return scope.length >= targetCount ? 'every exit' : `${scope.length} exits`
|
return scope.length >= targetCount ? 'every target' : `${scope.length} targets`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which editor (add or edit-by-name) is open within a section. */
|
/** Which editor (add or edit-by-name) is open within a section. */
|
||||||
@@ -457,6 +459,7 @@ interface Opt {
|
|||||||
// ---- page ------------------------------------------------------------------
|
// ---- page ------------------------------------------------------------------
|
||||||
|
|
||||||
export default function Targets() {
|
export default function Targets() {
|
||||||
|
const confirm = useConfirm()
|
||||||
const [config, setConfig] = useState<Model | null>(null)
|
const [config, setConfig] = useState<Model | null>(null)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -589,10 +592,12 @@ export default function Targets() {
|
|||||||
[health],
|
[health],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- group/chain exit test: how fast, through which node, out which address --
|
// ---- out-of-turn refresh: how fast, through which node, out which address ----
|
||||||
// The POST only kicks a run off, and a 2 s poll of the GET carries progress
|
// The POST does NOT dial. It asks the observatory — the only thing in the daemon
|
||||||
// plus every result so far. One endpoint covers groups and chains alike:
|
// that measures anything, and it measures along the real dial path — to come
|
||||||
// POST with a group or chain name tests that one; an empty name tests them all.
|
// round out of turn; a 2 s poll of the GET carries progress plus every reading
|
||||||
|
// so far. One endpoint covers groups and chains alike: POST with a name refreshes
|
||||||
|
// that one, an empty name refreshes them all.
|
||||||
const [gtest, setGtest] = useState<GroupTestStatus>(IDLE_TEST)
|
const [gtest, setGtest] = useState<GroupTestStatus>(IDLE_TEST)
|
||||||
const [gtestErr, setGtestErr] = useState<string | null>(null)
|
const [gtestErr, setGtestErr] = useState<string | null>(null)
|
||||||
const [polling, setPolling] = useState(false)
|
const [polling, setPolling] = useState(false)
|
||||||
@@ -635,7 +640,7 @@ export default function Targets() {
|
|||||||
void readTest().then((st) => {
|
void readTest().then((st) => {
|
||||||
if (!alive || !st || st.running) return
|
if (!alive || !st || st.running) return
|
||||||
setPolling(false)
|
setPolling(false)
|
||||||
flash('Group test complete')
|
flash('Readings refreshed')
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -651,16 +656,16 @@ export default function Targets() {
|
|||||||
if (r.started) {
|
if (r.started) {
|
||||||
setGtestErr(null)
|
setGtestErr(null)
|
||||||
setPolling(true)
|
setPolling(true)
|
||||||
flash(name ? `Testing ${name}…` : 'Testing every exit…')
|
flash(name ? `Refreshing ${name}…` : 'Refreshing every reading…')
|
||||||
void readTest()
|
void readTest()
|
||||||
} else if (r.reason === 'already running') {
|
} else if (r.reason === 'already running') {
|
||||||
setPolling(true) // pick up the run someone else started
|
setPolling(true) // pick up the pass someone else started
|
||||||
flash('A group test is already running')
|
flash('The prober is already refreshing')
|
||||||
} else {
|
} else {
|
||||||
flash(`Couldn’t start the test — ${r.reason || 'the daemon refused it'}`)
|
flash(`Couldn’t ask for a refresh — ${r.reason || 'the daemon refused it'}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash(`Couldn’t start the test — ${errText(e)}`)
|
flash(`Couldn’t ask for a refresh — ${errText(e)}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flash, readTest],
|
[flash, readTest],
|
||||||
@@ -787,13 +792,18 @@ export default function Targets() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeGroup = useCallback(
|
const removeGroup = useCallback(
|
||||||
(name: string) => {
|
async (name: string) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const refs = findReferences(config, 'group', name)
|
const refs = findReferences(config, 'group', name)
|
||||||
if (!window.confirm(`Delete group “${name}”?${refWarning(refs)}`)) return
|
const ok = await confirm({
|
||||||
|
label: 'Delete group',
|
||||||
|
title: `Delete group “${name}”?`,
|
||||||
|
body: refWarning(refs),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
void save({ ...config, Groups: groups.filter((g) => g.Name !== name) }, `Deleted ${name}`)
|
void save({ ...config, Groups: groups.filter((g) => g.Name !== name) }, `Deleted ${name}`)
|
||||||
},
|
},
|
||||||
[config, groups, save],
|
[config, groups, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- chain mutations ------------------------------------------------------
|
// ---- chain mutations ------------------------------------------------------
|
||||||
@@ -820,13 +830,18 @@ export default function Targets() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeChain = useCallback(
|
const removeChain = useCallback(
|
||||||
(name: string) => {
|
async (name: string) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const refs = findReferences(config, 'chain', name)
|
const refs = findReferences(config, 'chain', name)
|
||||||
if (!window.confirm(`Delete chain “${name}”?${refWarning(refs)}`)) return
|
const ok = await confirm({
|
||||||
|
label: 'Delete chain',
|
||||||
|
title: `Delete chain “${name}”?`,
|
||||||
|
body: refWarning(refs),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
void save({ ...config, Chains: chains.filter((c) => c.Name !== name) }, `Deleted ${name}`)
|
void save({ ...config, Chains: chains.filter((c) => c.Name !== name) }, `Deleted ${name}`)
|
||||||
},
|
},
|
||||||
[config, chains, save],
|
[config, chains, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- egress mutations -----------------------------------------------------
|
// ---- egress mutations -----------------------------------------------------
|
||||||
@@ -853,13 +868,18 @@ export default function Targets() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeEgress = useCallback(
|
const removeEgress = useCallback(
|
||||||
(name: string) => {
|
async (name: string) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const refs = findReferences(config, 'egress', name)
|
const refs = findReferences(config, 'egress', name)
|
||||||
if (!window.confirm(`Delete egress “${name}”?${refWarning(refs)}`)) return
|
const ok = await confirm({
|
||||||
|
label: 'Delete egress',
|
||||||
|
title: `Delete egress “${name}”?`,
|
||||||
|
body: refWarning(refs),
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
void save({ ...config, Egresses: egresses.filter((e) => e.Name !== name) }, `Deleted ${name}`)
|
void save({ ...config, Egresses: egresses.filter((e) => e.Name !== name) }, `Deleted ${name}`)
|
||||||
},
|
},
|
||||||
[config, egresses, save],
|
[config, egresses, save, confirm],
|
||||||
)
|
)
|
||||||
|
|
||||||
const busy = saving || applying
|
const busy = saving || applying
|
||||||
@@ -894,10 +914,11 @@ export default function Targets() {
|
|||||||
<h2 className="tg-sec-title">Groups</h2>
|
<h2 className="tg-sec-title">Groups</h2>
|
||||||
<span className="tg-sec-count mono">{groups.length} configured</span>
|
<span className="tg-sec-count mono">{groups.length} configured</span>
|
||||||
{/* The observatory's background probing is invisible by design — it
|
{/* The observatory's background probing is invisible by design — it
|
||||||
keeps every used group's and chain's numbers fresh on its own. The
|
keeps every used group's and chain's numbers fresh on its own, along
|
||||||
one manual run left is the exit test: it is scoped to the groups
|
the path traffic actually takes. The one manual control left does
|
||||||
and chains it names, so its progress says WHICH, and its badge
|
not measure anything itself: it asks that prober to come round out
|
||||||
lands only on those cards. */}
|
of turn. It is scoped to the groups and chains it names, so its
|
||||||
|
progress says WHICH, and its badge lands only on those cards. */}
|
||||||
<div className="tg-sec-ctl">
|
<div className="tg-sec-ctl">
|
||||||
{groupHealthOn && (
|
{groupHealthOn && (
|
||||||
<>
|
<>
|
||||||
@@ -905,11 +926,11 @@ export default function Targets() {
|
|||||||
<span
|
<span
|
||||||
className="tg-run tg-run--exit"
|
className="tg-run tg-run--exit"
|
||||||
role="status"
|
role="status"
|
||||||
title="An exit test sends one connection through each group or chain it covers and reports the delay and the address the internet sees."
|
title="The background prober is measuring the targets this refresh covers, along the path each one's traffic really takes."
|
||||||
>
|
>
|
||||||
<Led variant="amber" pulse />
|
<Led variant="amber" pulse />
|
||||||
<span className="tg-run-what">
|
<span className="tg-run-what">
|
||||||
exit test · {scopeLabel(asArray(gtest.scope), groups.length + chains.length)}
|
refreshing · {scopeLabel(asArray(gtest.scope), groups.length + chains.length)}
|
||||||
</span>
|
</span>
|
||||||
<span className="tg-run-n mono">
|
<span className="tg-run-n mono">
|
||||||
{gtest.done}/{gtest.total}
|
{gtest.done}/{gtest.total}
|
||||||
@@ -919,9 +940,9 @@ export default function Targets() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => void runTest()}
|
onClick={() => void runTest()}
|
||||||
disabled={busy || !config || (groups.length === 0 && chains.length === 0) || gtest.running}
|
disabled={busy || !config || (groups.length === 0 && chains.length === 0) || gtest.running}
|
||||||
title="Send one connection through each group and chain and report the delay and the exit address the internet sees"
|
title="Ask the background prober to measure every group and chain out of turn, then show what it measured. The panel opens no connection of its own."
|
||||||
>
|
>
|
||||||
{gtest.running ? 'Testing…' : 'Test every exit'}
|
{gtest.running ? 'Refreshing…' : 'Refresh every reading'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -941,6 +962,13 @@ export default function Targets() {
|
|||||||
dials out through a tunnel measures them through that tunnel, so the same node can be alive
|
dials out through a tunnel measures them through that tunnel, so the same node can be alive
|
||||||
in one group and dead in another.
|
in one group and dead in another.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="tg-sec-note">
|
||||||
|
One thing measures, and the panel is not it. A background prober walks every path your
|
||||||
|
rules use — hop by hop, exactly as traffic goes — and every number on this page is a read
|
||||||
|
of what it found. <strong>Refresh every reading</strong> asks it to come round out of turn
|
||||||
|
instead of waiting for the next pass; it opens no connection of its own, so a target no
|
||||||
|
rule routes through has nothing to report and says so.
|
||||||
|
</p>
|
||||||
|
|
||||||
{groupHealthOn && healthErr && (
|
{groupHealthOn && healthErr && (
|
||||||
<p className="tg-test-err" role="alert">
|
<p className="tg-test-err" role="alert">
|
||||||
@@ -951,7 +979,7 @@ export default function Targets() {
|
|||||||
|
|
||||||
{groupHealthOn && gtestErr && (
|
{groupHealthOn && gtestErr && (
|
||||||
<p className="tg-test-err" role="alert">
|
<p className="tg-test-err" role="alert">
|
||||||
Couldn’t read the test results — {gtestErr}.{' '}
|
Couldn’t read the refreshed numbers — {gtestErr}.{' '}
|
||||||
<button className="linkish" onClick={() => void readTest()}>
|
<button className="linkish" onClick={() => void readTest()}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
@@ -1094,7 +1122,9 @@ export default function Targets() {
|
|||||||
chain={c}
|
chain={c}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
showHealth={groupHealthOn}
|
showHealth={groupHealthOn}
|
||||||
used={healthByChain.get(c.Name)?.used}
|
// The whole chain health record, not just `.used` — the card
|
||||||
|
// renders the observatory's per-hop measurements from it.
|
||||||
|
health={healthByChain.get(c.Name)}
|
||||||
test={testByGroup.get(c.Name)}
|
test={testByGroup.get(c.Name)}
|
||||||
// The badge is this card's business only when the run names it.
|
// The badge is this card's business only when the run names it.
|
||||||
testing={gtest.running && testScope.has(c.Name)}
|
testing={gtest.running && testScope.has(c.Name)}
|
||||||
@@ -1216,7 +1246,7 @@ function GroupRow({
|
|||||||
group: Group
|
group: Group
|
||||||
busy: boolean
|
busy: boolean
|
||||||
/** Group health checks are on (Settings). When false, the card drops its health
|
/** Group health checks are on (Settings). When false, the card drops its health
|
||||||
* readout, its exit-test readout and its Test button — it is config only. */
|
* readout, its end-to-end reading and its Refresh button — it is config only. */
|
||||||
showHealth: boolean
|
showHealth: boolean
|
||||||
/** This group's membership health, or undefined when the engine hasn't built
|
/** This group's membership health, or undefined when the engine hasn't built
|
||||||
* it (not applied yet, or dropped for having no usable members). */
|
* it (not applied yet, or dropped for having no usable members). */
|
||||||
@@ -1226,14 +1256,14 @@ function GroupRow({
|
|||||||
healthKnown: boolean
|
healthKnown: boolean
|
||||||
test?: GroupTestResult
|
test?: GroupTestResult
|
||||||
/**
|
/**
|
||||||
* A group exit test covering THIS group is in flight.
|
* A refresh pass covering THIS group is in flight.
|
||||||
*
|
*
|
||||||
* Deliberately not "a test is running": the caller resolves it against the run's
|
* Deliberately not "a test is running": the caller resolves it against the run's
|
||||||
* scope. There is no per-card equivalent for the health run — that one measures
|
* scope. There is no per-card equivalent for the health run — that one measures
|
||||||
* every group at once and is reported once, in the section header.
|
* every group at once and is reported once, in the section header.
|
||||||
*/
|
*/
|
||||||
testing: boolean
|
testing: boolean
|
||||||
/** Any exit test is in flight; the daemon runs one at a time. */
|
/** Any refresh pass is in flight; the daemon runs one at a time. */
|
||||||
testBusy: boolean
|
testBusy: boolean
|
||||||
onTest: () => void
|
onTest: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
@@ -1293,7 +1323,11 @@ function GroupRow({
|
|||||||
health={health}
|
health={health}
|
||||||
healthKnown={healthKnown}
|
healthKnown={healthKnown}
|
||||||
/>
|
/>
|
||||||
<GroupTestReadout test={test} pending={testing && !test} />
|
<GroupTestReadout
|
||||||
|
test={test}
|
||||||
|
pending={testing && !test}
|
||||||
|
hideAbsence={health?.used === false}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1304,7 +1338,7 @@ function GroupRow({
|
|||||||
editLabel={`Edit group ${group.Name}`}
|
editLabel={`Edit group ${group.Name}`}
|
||||||
deleteLabel={`Delete group ${group.Name}`}
|
deleteLabel={`Delete group ${group.Name}`}
|
||||||
onTest={showHealth ? onTest : undefined}
|
onTest={showHealth ? onTest : undefined}
|
||||||
testLabel={showHealth ? `Test the exit of group ${group.Name}` : undefined}
|
testLabel={showHealth ? `Refresh the reading for group ${group.Name}` : undefined}
|
||||||
testDisabled={testBusy}
|
testDisabled={testBusy}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -1363,21 +1397,7 @@ function GroupHealthReadout({
|
|||||||
// its members would stay "untested" forever. That is a fact about the ROUTING
|
// its members would stay "untested" forever. That is a fact about the ROUTING
|
||||||
// CONFIG, not about the members — so instead of counters that could only ever
|
// CONFIG, not about the members — so instead of counters that could only ever
|
||||||
// read as a permanent unknown, the card says so, quietly: unused, not unwell.
|
// read as a permanent unknown, the card says so, quietly: unused, not unwell.
|
||||||
if (!health.used) {
|
if (!health.used) return <NotRoutedNote kind="group" />
|
||||||
return (
|
|
||||||
<div className="gh gh--unused">
|
|
||||||
<div className="gh-line">
|
|
||||||
<span
|
|
||||||
className="gh-unused"
|
|
||||||
title="No enabled rule routes through this group, so its members are not probed. Add it to a rule to see health."
|
|
||||||
>
|
|
||||||
unused
|
|
||||||
</span>
|
|
||||||
<span className="gh-quiet">not probed — no enabled rule routes through this group</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const v = verdictOf(health)
|
const v = verdictOf(health)
|
||||||
const { total, tested, alive, dead, untested } = health
|
const { total, tested, alive, dead, untested } = health
|
||||||
@@ -1540,6 +1560,56 @@ function GroupHealthReadout({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's answer when NOTHING ROUTES THROUGH THIS TARGET. Shared by the group
|
||||||
|
* card and the chain card, because it is the same misunderstanding on both.
|
||||||
|
*
|
||||||
|
* It has to carry two statements, and the old one-liner ("not probed — no enabled
|
||||||
|
* rule routes through this group") only carried the first. Read fast it still
|
||||||
|
* landed as a verdict: a card that normally shows health and today shows a grey
|
||||||
|
* pill reads as "the health is bad". So the two meanings are now separated, on
|
||||||
|
* purpose and in this order:
|
||||||
|
*
|
||||||
|
* 1. the ROUTING FACT — nothing routes here, so nothing measures it;
|
||||||
|
* 2. the NON-FACT — this is not a health reading at all. Absent numbers are
|
||||||
|
* absence of measurement, never failure.
|
||||||
|
*
|
||||||
|
* For a GROUP there is a third line, and it is the confusion this whole change
|
||||||
|
* exists to end: a group used only as a hop inside a chain is never routed to
|
||||||
|
* DIRECTLY, so it correctly reads unused here while carrying real traffic as a
|
||||||
|
* hop. Its health is measured at that hop, on the chain's card.
|
||||||
|
*
|
||||||
|
* Unused is neutral — groove-grey, never amber, never crit. It is a state of the
|
||||||
|
* config, and the config is not sick.
|
||||||
|
*/
|
||||||
|
function NotRoutedNote({ kind }: { kind: 'group' | 'chain' }) {
|
||||||
|
return (
|
||||||
|
<div className="gh gh--unused">
|
||||||
|
<div className="gh-line">
|
||||||
|
<span className="gh-unused">unused</span>
|
||||||
|
<span className="gh-quiet">
|
||||||
|
No enabled rule routes through this {kind}, so the observatory never probes it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="gh-say">
|
||||||
|
That is a routing fact, not a health reading. There are no numbers here because nothing
|
||||||
|
measured this {kind} — not because it failed.
|
||||||
|
</p>
|
||||||
|
{kind === 'group' ? (
|
||||||
|
<p className="gh-say">
|
||||||
|
A group used only as a hop inside a chain reads unused here on purpose: the rules point at
|
||||||
|
the chain, not at the group. Its members are measured at that hop, so its real health is on
|
||||||
|
that chain’s card, hop by hop.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="gh-say">
|
||||||
|
Point a rule at this chain and the observatory starts measuring every hop within seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One group's member rows, fetched on demand.
|
* One group's member rows, fetched on demand.
|
||||||
*
|
*
|
||||||
@@ -1645,7 +1715,9 @@ function MemberRow({ member }: { member: GroupMemberHealth }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a group test found, in the four states it actually has.
|
* What the OBSERVATORY measured for this target end to end, in the four states it
|
||||||
|
* actually has. Nothing here was dialled by the panel — it is a read of the
|
||||||
|
* background prober's own measurement along the real path.
|
||||||
*
|
*
|
||||||
* The one worth spelling out: `ok` with an EMPTY `exit_ip` is a SUCCESS. The
|
* The one worth spelling out: `ok` with an EMPTY `exit_ip` is a SUCCESS. The
|
||||||
* delay was measured; only the address lookup came back empty. Rendering that as
|
* delay was measured; only the address lookup came back empty. Rendering that as
|
||||||
@@ -1653,15 +1725,45 @@ function MemberRow({ member }: { member: GroupMemberHealth }) {
|
|||||||
* traffic, so it reads as a result with the address slot marked unknown — dim,
|
* traffic, so it reads as a result with the address slot marked unknown — dim,
|
||||||
* not red, and the LED stays green.
|
* not red, and the LED stays green.
|
||||||
*/
|
*/
|
||||||
function GroupTestReadout({ test, pending }: { test?: GroupTestResult; pending: boolean }) {
|
/**
|
||||||
|
* Errors that mean NO MEASUREMENT EXISTS, as opposed to "this target is broken".
|
||||||
|
*
|
||||||
|
* Three of the observatory's four failure reasons are about the observatory, not
|
||||||
|
* about the path: nothing routes here, nothing has reached it yet, or background
|
||||||
|
* probing is switched off. Painting those crit-red — which is what `ok:false`
|
||||||
|
* used to buy you — reports a fault that nobody has found, on a target that may
|
||||||
|
* be carrying traffic perfectly. Only "the observatory's probe through this path
|
||||||
|
* failed" is a health finding, and it is deliberately NOT in this list.
|
||||||
|
*
|
||||||
|
* Matched on a stable fragment rather than the whole sentence, so a daemon that
|
||||||
|
* rewords the tail still classifies. An error we don't recognise stays red: an
|
||||||
|
* unknown failure is likelier to be real than not, and that is the safe default.
|
||||||
|
*/
|
||||||
|
const NO_MEASUREMENT = [
|
||||||
|
'not routed by any enabled rule',
|
||||||
|
'has not reached this target yet',
|
||||||
|
'background probing is disabled',
|
||||||
|
]
|
||||||
|
const isAbsence = (err: string): boolean => NO_MEASUREMENT.some((frag) => err.includes(frag))
|
||||||
|
|
||||||
|
function GroupTestReadout({
|
||||||
|
test,
|
||||||
|
pending,
|
||||||
|
hideAbsence,
|
||||||
|
}: {
|
||||||
|
test?: GroupTestResult
|
||||||
|
pending: boolean
|
||||||
|
/** The card already explains why nothing measures this target (the unused
|
||||||
|
* note), so an absence error here would just say it a second time. */
|
||||||
|
hideAbsence?: boolean
|
||||||
|
}) {
|
||||||
if (pending) {
|
if (pending) {
|
||||||
// "testing", never "measuring": the health run owns that word and covers every
|
// Names who is working and on what: the prober, on this target. The badge is
|
||||||
// group at once. Two runs that read the same on a card is how one group's test
|
// scoped to the cards the run covers, so it can say "this one" honestly.
|
||||||
// came to look like all four were busy.
|
|
||||||
return (
|
return (
|
||||||
<div className="tg-test tg-test--wait" role="status">
|
<div className="tg-test tg-test--wait" role="status">
|
||||||
<Led variant="amber" pulse />
|
<Led variant="amber" pulse />
|
||||||
<span className="tg-test-msg">testing this exit…</span>
|
<span className="tg-test-msg">waiting for the prober to measure this…</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1670,10 +1772,22 @@ function GroupTestReadout({ test, pending }: { test?: GroupTestResult; pending:
|
|||||||
const at = test.tested_unix ? fmtClock(test.tested_unix) : ''
|
const at = test.tested_unix ? fmtClock(test.tested_unix) : ''
|
||||||
|
|
||||||
if (!test.ok) {
|
if (!test.ok) {
|
||||||
|
// No measurement exists. Unlit lamp, quiet text: this panel's way of saying
|
||||||
|
// "no verdict", which is precisely the state — never a red one.
|
||||||
|
if (isAbsence(test.error)) {
|
||||||
|
if (hideAbsence) return null
|
||||||
|
return (
|
||||||
|
<div className="tg-test tg-test--none" role="status">
|
||||||
|
<Led variant="off" />
|
||||||
|
<span className="tg-test-msg">{test.error}</span>
|
||||||
|
{at && <span className="tg-test-at mono">{at}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="tg-test tg-test--bad" role="status">
|
<div className="tg-test tg-test--bad" role="status">
|
||||||
<Led variant="crit" />
|
<Led variant="crit" />
|
||||||
<span className="tg-test-msg">{test.error || 'the test failed'}</span>
|
<span className="tg-test-msg">{test.error || 'the probe failed'}</span>
|
||||||
{at && <span className="tg-test-at mono">{at}</span>}
|
{at && <span className="tg-test-at mono">{at}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2098,7 +2212,7 @@ function ChainRow({
|
|||||||
chain,
|
chain,
|
||||||
busy,
|
busy,
|
||||||
showHealth,
|
showHealth,
|
||||||
used,
|
health,
|
||||||
test,
|
test,
|
||||||
testing,
|
testing,
|
||||||
testBusy,
|
testBusy,
|
||||||
@@ -2109,31 +2223,41 @@ function ChainRow({
|
|||||||
chain: Chain
|
chain: Chain
|
||||||
busy: boolean
|
busy: boolean
|
||||||
/** Group health checks are on (Settings). When false, the card drops its
|
/** Group health checks are on (Settings). When false, the card drops its
|
||||||
* exit-test readout and Test button — it is config only. */
|
* health readout and Refresh button — it is config only. */
|
||||||
showHealth: boolean
|
showHealth: boolean
|
||||||
/** This chain's reachability (GroupHealth.Used's chain analogue, plan §5.E).
|
/** Everything the observatory knows about this chain: whether any enabled rule
|
||||||
* undefined ⇒ the health endpoint hasn't reported this chain (not applied yet, or
|
* routes through it, and the per-hop measurements along it.
|
||||||
* a daemon version without chains): no badge. false ⇒ no enabled rule routes
|
* undefined ⇒ the health endpoint hasn't reported this chain at all (not
|
||||||
* through the chain, so the observatory never probes it and the card renders
|
* applied yet, or a daemon version without chains): the card says nothing
|
||||||
* "unused" instead of an exit-test readout. */
|
* rather than guessing. */
|
||||||
used?: boolean
|
health?: ChainHealth
|
||||||
test?: GroupTestResult
|
test?: GroupTestResult
|
||||||
/** An exit test covering THIS chain is in flight (the caller resolves it
|
/** A refresh pass covering THIS chain is in flight (the caller resolves it
|
||||||
* against the run's scope, exactly as for a group card). */
|
* against the run's scope, exactly as for a group card). */
|
||||||
testing: boolean
|
testing: boolean
|
||||||
/** Any exit test is in flight; the daemon runs one at a time. */
|
/** Any refresh pass is in flight; the daemon runs one at a time. */
|
||||||
testBusy: boolean
|
testBusy: boolean
|
||||||
onTest: () => void
|
onTest: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const hops = asArray(chain.Hops)
|
const hops = asArray(chain.Hops)
|
||||||
|
// A LEADING `egress:` is not a hop and the rail below already knows it: the
|
||||||
|
// daemon lifts it into hop 1's entry detour (see hopLabels), so it is tagged
|
||||||
|
// "entry" and never numbered. The badge counted it anyway, which is how a chain
|
||||||
|
// drawn with four hops came to be labelled "5 hops" directly above them.
|
||||||
|
const entryEgress = hops.length > 0 && hops[0].startsWith('egress:')
|
||||||
|
const numbered = entryEgress ? hops.length - 1 : hops.length
|
||||||
return (
|
return (
|
||||||
<li className="tg-row">
|
<li className="tg-row">
|
||||||
<div className="tg-row-main">
|
<div className="tg-row-main">
|
||||||
<div className="tg-row-l1">
|
<div className="tg-row-l1">
|
||||||
<span className="tg-row-name">{chain.Name}</span>
|
<span className="tg-row-name">{chain.Name}</span>
|
||||||
<span className="tg-badge">{hops.length} hop{hops.length === 1 ? '' : 's'}</span>
|
<span className="tg-badge">
|
||||||
|
{numbered === 0 && entryEgress
|
||||||
|
? 'entry only · no exit'
|
||||||
|
: `${numbered} hop${numbered === 1 ? '' : 's'}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tg-row-l2">
|
<div className="tg-row-l2">
|
||||||
{hops.length === 0 ? (
|
{hops.length === 0 ? (
|
||||||
@@ -2165,25 +2289,21 @@ function ChainRow({
|
|||||||
{showHealth && (
|
{showHealth && (
|
||||||
<>
|
<>
|
||||||
{/* A chain no enabled rule routes through is never probed (the
|
{/* A chain no enabled rule routes through is never probed (the
|
||||||
observatory walks only reachable paths), so instead of an exit-test
|
observatory walks only reachable paths), so instead of a health
|
||||||
readout the card says so, quietly — the same "unused" pattern the
|
readout the card says so — the same "unused" note the group card
|
||||||
group card uses (GroupHealthReadout), not a new design. `used` is
|
uses, not a new design. `health` is undefined until the endpoint
|
||||||
undefined until the health endpoint reports this chain (or from a
|
reports this chain (or on a daemon without chains): say nothing
|
||||||
daemon version without chains): no badge then. */}
|
then rather than guess. */}
|
||||||
{used === false && (
|
{health?.used === false ? (
|
||||||
<div className="gh gh--unused">
|
<NotRoutedNote kind="chain" />
|
||||||
<div className="gh-line">
|
) : health?.used ? (
|
||||||
<span
|
<ChainHopRail chain={chain.Name} defs={hops} hops={health.hops} />
|
||||||
className="gh-unused"
|
) : null}
|
||||||
title="No enabled rule routes through this chain, so its exit is not probed. Add it to a rule to see health."
|
<GroupTestReadout
|
||||||
>
|
test={test}
|
||||||
unused
|
pending={testing && !test}
|
||||||
</span>
|
hideAbsence={health?.used === false}
|
||||||
<span className="gh-quiet">not probed — no enabled rule routes through this chain</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<GroupTestReadout test={test} pending={testing && !test} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2194,13 +2314,250 @@ function ChainRow({
|
|||||||
editLabel={`Edit chain ${chain.Name}`}
|
editLabel={`Edit chain ${chain.Name}`}
|
||||||
deleteLabel={`Delete chain ${chain.Name}`}
|
deleteLabel={`Delete chain ${chain.Name}`}
|
||||||
onTest={showHealth ? onTest : undefined}
|
onTest={showHealth ? onTest : undefined}
|
||||||
testLabel={showHealth ? `Test the exit of chain ${chain.Name}` : undefined}
|
testLabel={showHealth ? `Refresh the reading for chain ${chain.Name}` : undefined}
|
||||||
testDisabled={testBusy}
|
testDisabled={testBusy}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- chain hop rail --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API gives hops an index and no name. The page already knows the names — the
|
||||||
|
* model's own `Hops` strings ("egress:ewan", "node:awgout", "group:sub0") — so
|
||||||
|
* zip the two by POSITION.
|
||||||
|
*
|
||||||
|
* Two things make that safe rather than clever. A LEADING `egress:` is not a
|
||||||
|
* numbered hop: the daemon lifts it into hop 1's entry detour, so it is dropped
|
||||||
|
* before counting. And if the counts still disagree — a chain that splices
|
||||||
|
* sub-chains gets FLATTENED by the daemon, producing more wire hops than the
|
||||||
|
* config lists — every label is dropped. A hop labelled with its neighbour's name
|
||||||
|
* is worse than a hop with no name at all: it would send someone to fix the wrong
|
||||||
|
* target.
|
||||||
|
*/
|
||||||
|
function hopLabels(defs: string[], hops: ChainHopHealth[]): (string | undefined)[] {
|
||||||
|
const numbered = defs.length > 0 && defs[0].startsWith('egress:') ? defs.slice(1) : defs
|
||||||
|
if (numbered.length !== hops.length) return hops.map(() => undefined)
|
||||||
|
return hops.map((h) => (h.index >= 1 && h.index <= numbered.length ? numbered[h.index - 1] : undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hop's lamp.
|
||||||
|
*
|
||||||
|
* `dead` is crit and `untested` is an UNLIT socket — never red, because nothing
|
||||||
|
* has been measured and an unlit lamp is this panel's way of saying "no verdict".
|
||||||
|
* That covers a hop the walk never reached (`blocked_by`) too: it is neither
|
||||||
|
* healthy nor broken, and unlit is the only mark that claims neither.
|
||||||
|
* The fourth case is the page's existing house reading, applied here for
|
||||||
|
* consistency rather than invented: a group hop that is carrying traffic but has
|
||||||
|
* confirmed failures on its board is amber. `state` stays the daemon's word for
|
||||||
|
* "can this hop carry traffic"; the amber only qualifies HOW WELL.
|
||||||
|
*/
|
||||||
|
function hopLed(h: ChainHopHealth): LedVariant {
|
||||||
|
if (h.state === 'dead') return 'crit'
|
||||||
|
if (h.state === 'untested') return 'off'
|
||||||
|
return h.dead > 0 ? 'amber' : 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the observatory measured at each position of a chain — the reading the
|
||||||
|
* daemon always took and the panel never showed.
|
||||||
|
*
|
||||||
|
* THE DESIGN RISK, and the one place this card spends any boldness: the rail
|
||||||
|
* draws the CONDUCTOR as well as the lamps, and severs it below the first dead
|
||||||
|
* hop. A chain is a single series path, so the operator's real question is never
|
||||||
|
* "how many hops are green" — it is "where does my traffic stop". Four lamps in a
|
||||||
|
* column answer the first question and leave the second to arithmetic. A broken
|
||||||
|
* conductor answers the second one before you have read a single word, which is
|
||||||
|
* the whole reason this feature exists.
|
||||||
|
*
|
||||||
|
* It stays honest by keeping two different facts on two different marks. The
|
||||||
|
* CONDUCTOR is reachability through the path, and that genuinely does stop at the
|
||||||
|
* break. The LAMPS are measurements — and there are none below the break to show:
|
||||||
|
* the daemon walks the path in order and stops at the first hop that does not
|
||||||
|
* answer, because every later hop is dialled THROUGH that one. Those hops arrive
|
||||||
|
* `untested` with `blocked_by` naming the hop that stopped the walk, so their
|
||||||
|
* lamps stay UNLIT: not a soft red, not a pale green, just this panel's way of
|
||||||
|
* saying no verdict exists about a hop nobody reached. The row says so in words
|
||||||
|
* too, naming that hop, because "why is this row empty" is the question the shape
|
||||||
|
* alone cannot answer.
|
||||||
|
*
|
||||||
|
* Nothing here is re-derived from the daemon's counters — the block, the zeroed
|
||||||
|
* numbers and the state all come off the wire. The only thing the panel adds is
|
||||||
|
* what a chain structurally is.
|
||||||
|
*
|
||||||
|
* Everything around the rail is deliberately quiet: no colour but the semantic
|
||||||
|
* lamps, no motion at all, the orange accent untouched.
|
||||||
|
*/
|
||||||
|
function ChainHopRail({
|
||||||
|
chain,
|
||||||
|
defs,
|
||||||
|
hops,
|
||||||
|
}: {
|
||||||
|
chain: string
|
||||||
|
/** The chain's configured hops, straight off the model — the only source of names. */
|
||||||
|
defs: string[]
|
||||||
|
/** Absent ⇒ the engine never materialised per-hop outbounds. NOT "no hops". */
|
||||||
|
hops?: ChainHopHealth[]
|
||||||
|
}) {
|
||||||
|
const ordered = useMemo(() => [...asArray(hops)].sort((a, b) => a.index - b.index), [hops])
|
||||||
|
const labels = useMemo(() => hopLabels(defs, ordered), [defs, ordered])
|
||||||
|
|
||||||
|
// The first hop that was probed and did not answer. Everything after it is
|
||||||
|
// unreachable THROUGH THIS CHAIN, whatever its own lamp says. `untested` is
|
||||||
|
// never a break: nothing was measured, so nothing is known to be severed.
|
||||||
|
const breakAt = ordered.findIndex((h) => h.state === 'dead')
|
||||||
|
|
||||||
|
if (ordered.length === 0) {
|
||||||
|
// Say why, in one line, instead of an empty rail. The daemon collapses a
|
||||||
|
// single-target chain into a plain alias and never builds copies to measure,
|
||||||
|
// so we can tell the two absences apart from the config alone.
|
||||||
|
const numbered = defs.filter((d, i) => !(i === 0 && d.startsWith('egress:')))
|
||||||
|
return (
|
||||||
|
<div className="gh gh--absent">
|
||||||
|
<span className="gh-absent-msg">
|
||||||
|
{numbered.length <= 1
|
||||||
|
? 'This chain has a single hop, so the engine points traffic straight at that target instead of building a path to measure. Its health is on that target’s own card.'
|
||||||
|
: 'The engine hasn’t built this chain’s hops yet, so there is nothing measured per hop. They appear once it is running with this config applied.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gh ch-rail">
|
||||||
|
<span className="ch-eyebrow">measured, hop by hop</span>
|
||||||
|
<ol className="ch-hops">
|
||||||
|
{ordered.map((h, i) => {
|
||||||
|
const label = labels[i]
|
||||||
|
const severed = breakAt >= 0 && i > breakAt
|
||||||
|
// Straight off the wire: present ⇒ the walk never reached this hop, so
|
||||||
|
// there is nothing measured here and the daemon has already named the
|
||||||
|
// hop that stopped it. Never inferred from the counters.
|
||||||
|
const blocked = h.blocked_by
|
||||||
|
const cls = [
|
||||||
|
'ch-hop',
|
||||||
|
`ch-hop--${h.state}`,
|
||||||
|
blocked ? 'ch-hop--blocked' : '',
|
||||||
|
severed ? 'ch-hop--severed' : '',
|
||||||
|
h.exit ? 'ch-hop--exit' : '',
|
||||||
|
i === 0 ? 'ch-hop--first' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
const age = fmtAge(h.age_seconds)
|
||||||
|
return (
|
||||||
|
<li key={h.tag || h.index} className={cls}>
|
||||||
|
<span className="ch-num mono" aria-hidden="true">
|
||||||
|
{h.index}
|
||||||
|
</span>
|
||||||
|
<span className="ch-socket">
|
||||||
|
<Led variant={hopLed(h)} />
|
||||||
|
</span>
|
||||||
|
<div className="ch-body">
|
||||||
|
<div className="ch-l1">
|
||||||
|
<span className="ch-name mono" title={`engine outbound ${h.tag}`}>
|
||||||
|
{label ?? (h.kind === 'group' ? 'a group hop' : 'a node hop')}
|
||||||
|
</span>
|
||||||
|
{h.exit && <span className="ch-tag">exit</span>}
|
||||||
|
{h.state === 'alive' && h.delay_ms > 0 && (
|
||||||
|
<span className="ch-delay mono">{h.delay_ms} ms</span>
|
||||||
|
)}
|
||||||
|
{/* Two different silences. A plain untested hop is a timing
|
||||||
|
gap that fills in by itself; a BLOCKED one never will,
|
||||||
|
because the walk stopped above it — so it says which hop
|
||||||
|
stopped it instead of implying someone should wait. */}
|
||||||
|
{blocked ? (
|
||||||
|
<span
|
||||||
|
className="ch-quiet ch-blocked"
|
||||||
|
title={`hop ${blocked.index} did not answer, so nothing was dialled through it (engine outbound ${blocked.tag})`}
|
||||||
|
>
|
||||||
|
no reading — the probe stopped at hop {blocked.index}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
h.state === 'untested' && <span className="ch-quiet">not measured yet</span>
|
||||||
|
)}
|
||||||
|
{age && <span className="ch-age mono">{age}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A node hop IS its own measurement (total 1), so counters would
|
||||||
|
only restate the lamp. A group hop rolls up its per-hop member
|
||||||
|
copies, and those read exactly as they do everywhere else in
|
||||||
|
this app: alive out of TESTED, with the untested remainder as a
|
||||||
|
quiet aside only when there is one. A blocked hop has those
|
||||||
|
counters zeroed by the daemon, so it lands in the tested === 0
|
||||||
|
branch — and there it must say the members were never REACHED,
|
||||||
|
not that they are still waiting their turn. */}
|
||||||
|
{h.kind === 'group' && h.total > 0 && (
|
||||||
|
<div className="ch-l2">
|
||||||
|
{h.tested === 0 ? (
|
||||||
|
<span className="ch-rest mono">
|
||||||
|
{h.total} member{h.total === 1 ? '' : 's'},{' '}
|
||||||
|
{blocked ? 'none of them reached' : 'none measured'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="ch-count mono">
|
||||||
|
<b>{h.alive}</b> / {h.tested}
|
||||||
|
</span>
|
||||||
|
<span className="ch-word">alive</span>
|
||||||
|
{h.dead > 0 && (
|
||||||
|
<span
|
||||||
|
className="ch-dead mono"
|
||||||
|
title={`${h.dead} member${h.dead === 1 ? '' : 's'} were probed at this hop and did not answer`}
|
||||||
|
>
|
||||||
|
{h.dead} not answering
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{h.untested > 0 && (
|
||||||
|
<span className="ch-rest mono">
|
||||||
|
tested {h.tested} of {h.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* The wrapper keeps its pick even when nothing crossed it,
|
||||||
|
so on a blocked hop this is the node it WOULD use — say
|
||||||
|
that, rather than "now", which claims live traffic. */}
|
||||||
|
{h.selected && (
|
||||||
|
<span
|
||||||
|
className="ch-now mono"
|
||||||
|
title={
|
||||||
|
blocked
|
||||||
|
? `Hop ${h.index} of “${chain}” is set to ${h.selected}; nothing crossed it to measure`
|
||||||
|
: `Traffic crossing hop ${h.index} of “${chain}” is on ${h.selected}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{blocked ? 'set to' : 'now'} → {h.selected}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* The sentence the rail's shape implies, written out — because the break is
|
||||||
|
the answer someone came here for, and a graphic alone should never be the
|
||||||
|
only place a finding exists. It names the dead hop, since that is the one
|
||||||
|
thing here anybody can act on. */}
|
||||||
|
{breakAt >= 0 && (
|
||||||
|
<p className="gh-say gh-say--bad">
|
||||||
|
Hop {ordered[breakAt].index}
|
||||||
|
{labels[breakAt] ? ` (${labels[breakAt]})` : ''} was probed and did not answer, so traffic
|
||||||
|
stops there
|
||||||
|
{breakAt < ordered.length - 1
|
||||||
|
? ' — and the hops below it are dialled through it, so nothing reached them and nothing is known about them.'
|
||||||
|
: '.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ChainEditor({
|
function ChainEditor({
|
||||||
initial,
|
initial,
|
||||||
hopOptions,
|
hopOptions,
|
||||||
@@ -2675,8 +3032,8 @@ function RowActions({
|
|||||||
busy: boolean
|
busy: boolean
|
||||||
editLabel: string
|
editLabel: string
|
||||||
deleteLabel: string
|
deleteLabel: string
|
||||||
// Only groups and chains can be tested, so the control is optional and absent
|
// Only groups and chains are probed, so the refresh control is optional and
|
||||||
// everywhere else rather than a disabled stub on every row.
|
// absent everywhere else rather than a disabled stub on every row.
|
||||||
onTest?: () => void
|
onTest?: () => void
|
||||||
testLabel?: string
|
testLabel?: string
|
||||||
testDisabled?: boolean
|
testDisabled?: boolean
|
||||||
@@ -2689,8 +3046,9 @@ function RowActions({
|
|||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={busy || testDisabled}
|
disabled={busy || testDisabled}
|
||||||
aria-label={testLabel}
|
aria-label={testLabel}
|
||||||
|
title="Ask the background prober to measure this target out of turn. It does not open a connection from the panel."
|
||||||
>
|
>
|
||||||
Test
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button className="tg-act" onClick={onEdit} disabled={busy} aria-label={editLabel}>
|
<Button className="tg-act" onClick={onEdit} disabled={busy} aria-label={editLabel}>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// pendingConfirm — the record of an armed auto-rollback, shared by the whole panel.
|
||||||
|
//
|
||||||
|
// Run with `npm test`. The module imports React only for its hook; the plain
|
||||||
|
// functions exercised here touch neither React nor the DOM, and `localStorage` is
|
||||||
|
// absent under node, which is itself one of the cases worth pinning (the panel
|
||||||
|
// must still work, it just forgets on reload).
|
||||||
|
//
|
||||||
|
// What these protect:
|
||||||
|
// - arming when commit-confirm is OFF must record nothing. The daemon does not
|
||||||
|
// arm a window then, and a countdown for a rollback that will never happen is
|
||||||
|
// the same class of lie as the "Confirmed" message this module replaced.
|
||||||
|
// - a window that has elapsed reads as gone, so nothing renders "0 s left".
|
||||||
|
// - expiry notifies exactly once even though several components watch it.
|
||||||
|
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
armPendingConfirm,
|
||||||
|
clearPendingConfirm,
|
||||||
|
confirmTimeout,
|
||||||
|
noteConfirmTimeout,
|
||||||
|
onPendingConfirmExpire,
|
||||||
|
readPendingConfirm,
|
||||||
|
} from './pendingConfirm.ts'
|
||||||
|
|
||||||
|
test('commit-confirm off ⇒ arming records nothing', () => {
|
||||||
|
noteConfirmTimeout(0)
|
||||||
|
assert.equal(confirmTimeout(), 0)
|
||||||
|
armPendingConfirm()
|
||||||
|
assert.equal(readPendingConfirm(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a window is recorded with the timeout the config reported', () => {
|
||||||
|
noteConfirmTimeout(90)
|
||||||
|
armPendingConfirm()
|
||||||
|
const p = readPendingConfirm()
|
||||||
|
assert.notEqual(p, null)
|
||||||
|
assert.equal(p!.total, 90)
|
||||||
|
// Deadline is in the future and within a second of now + the window.
|
||||||
|
const left = (p!.until - Date.now()) / 1000
|
||||||
|
assert.ok(left > 89 && left <= 90, `expected ~90s left, got ${left}`)
|
||||||
|
clearPendingConfirm()
|
||||||
|
assert.equal(readPendingConfirm(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching commit-confirm off drops a window that was already armed', () => {
|
||||||
|
noteConfirmTimeout(60)
|
||||||
|
armPendingConfirm()
|
||||||
|
assert.notEqual(readPendingConfirm(), null)
|
||||||
|
noteConfirmTimeout(0)
|
||||||
|
assert.equal(readPendingConfirm(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an elapsed window reads as gone, never as a countdown at zero', () => {
|
||||||
|
noteConfirmTimeout(1)
|
||||||
|
armPendingConfirm()
|
||||||
|
const p = readPendingConfirm()
|
||||||
|
assert.notEqual(p, null)
|
||||||
|
// Wind the clock forward rather than sleeping through the window.
|
||||||
|
const realNow = Date.now
|
||||||
|
Date.now = () => realNow() + 5000
|
||||||
|
try {
|
||||||
|
assert.equal(readPendingConfirm(), null)
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow
|
||||||
|
}
|
||||||
|
clearPendingConfirm()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirming does NOT fire the expiry listeners', () => {
|
||||||
|
noteConfirmTimeout(30)
|
||||||
|
let fired = 0
|
||||||
|
const off = onPendingConfirmExpire(() => {
|
||||||
|
fired++
|
||||||
|
})
|
||||||
|
armPendingConfirm()
|
||||||
|
clearPendingConfirm()
|
||||||
|
off()
|
||||||
|
assert.equal(fired, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a nonsense timeout is ignored rather than taken as "off"', () => {
|
||||||
|
noteConfirmTimeout(45)
|
||||||
|
noteConfirmTimeout(Number.NaN)
|
||||||
|
noteConfirmTimeout(-1)
|
||||||
|
noteConfirmTimeout(undefined)
|
||||||
|
assert.equal(confirmTimeout(), 45)
|
||||||
|
clearPendingConfirm()
|
||||||
|
noteConfirmTimeout(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// The commit-confirm window, as ONE fact the whole panel can see.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS. `POST /api/apply` always arms an auto-rollback for
|
||||||
|
// `Globals.ConfirmTimeout` seconds (shater/panel/api.go handleApply →
|
||||||
|
// ArmRollback) — EVERY Apply button does that, not just the one on the Apply
|
||||||
|
// page. But the countdown, and the button that stops it, lived in one component's
|
||||||
|
// local state. So:
|
||||||
|
//
|
||||||
|
// - pressing Apply on Routing/DNS/Nodes/Settings/Devices/Profiles/Targets said
|
||||||
|
// "Applied" and nothing else; the operator walked away and the router quietly
|
||||||
|
// reverted a minute later;
|
||||||
|
// - reloading the tab wiped the countdown AND the "Keep this config" button, so
|
||||||
|
// there was no way left to confirm from the panel at all.
|
||||||
|
//
|
||||||
|
// The daemon does not report a deadline, so this module records the one the panel
|
||||||
|
// itself armed, in `localStorage`. That is a deliberately modest claim — it knows
|
||||||
|
// about windows THIS BROWSER opened and says nothing about one opened elsewhere —
|
||||||
|
// but it survives a reload, a new tab and a navigation, which is what the two
|
||||||
|
// failures above needed.
|
||||||
|
//
|
||||||
|
// It is also the answer to "is there anything to confirm?". `apply.Confirm()`
|
||||||
|
// returns nil unconditionally, so a Confirm button that is always live can only
|
||||||
|
// ever report success. Gating it on a record here means the panel offers the
|
||||||
|
// action when it knows a window is open, and then reports an outcome it knows.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** A live commit-confirm window the panel armed. */
|
||||||
|
export interface PendingConfirm {
|
||||||
|
/** Epoch ms at which the daemon auto-rolls back if nobody confirms. */
|
||||||
|
until: number
|
||||||
|
/** The window it started with, in seconds — the progress bar's denominator. */
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'shater.pendingConfirm'
|
||||||
|
|
||||||
|
type Listener = () => void
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
const expiryListeners = new Set<Listener>()
|
||||||
|
|
||||||
|
/** localStorage is absent under SSR/tests and throws in some privacy modes. A
|
||||||
|
* panel that cannot remember a window must still work — it just forgets on
|
||||||
|
* reload, which is exactly the old behaviour and no worse. */
|
||||||
|
function store(): Storage | null {
|
||||||
|
try {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): PendingConfirm | null {
|
||||||
|
const s = store()
|
||||||
|
if (!s) return null
|
||||||
|
try {
|
||||||
|
const raw = s.getItem(KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const v = JSON.parse(raw) as Partial<PendingConfirm>
|
||||||
|
if (typeof v.until !== 'number' || typeof v.total !== 'number') return null
|
||||||
|
if (!Number.isFinite(v.until)) return null
|
||||||
|
return { until: v.until, total: v.total }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single in-process copy. Storage is the durable mirror, not the source of
|
||||||
|
// truth for a running tab: a `storage` event re-hydrates it when another tab
|
||||||
|
// writes.
|
||||||
|
let armed: PendingConfirm | null = load()
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
for (const l of [...listeners]) l()
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(v: PendingConfirm | null) {
|
||||||
|
armed = v
|
||||||
|
const s = store()
|
||||||
|
if (s) {
|
||||||
|
try {
|
||||||
|
if (v) s.setItem(KEY, JSON.stringify(v))
|
||||||
|
else s.removeItem(KEY)
|
||||||
|
} catch {
|
||||||
|
// Storage full or blocked — the in-process copy still drives this tab.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The armed window, or null when there is none or it has already elapsed. */
|
||||||
|
export function readPendingConfirm(): PendingConfirm | null {
|
||||||
|
if (!armed) return null
|
||||||
|
return armed.until > Date.now() ? armed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the window's length ----------------------------------------------------
|
||||||
|
|
||||||
|
// `Globals.ConfirmTimeout` is all that is needed to arm a window, and every page
|
||||||
|
// reads the config anyway — so api.getConfig() feeds it here rather than each
|
||||||
|
// caller threading it through. 0 (or never seen) means commit-confirm is off, and
|
||||||
|
// arming then does nothing: an apply on such a router really is immediate.
|
||||||
|
let timeout = 0
|
||||||
|
|
||||||
|
export function noteConfirmTimeout(seconds: number | undefined) {
|
||||||
|
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return
|
||||||
|
timeout = Math.floor(seconds)
|
||||||
|
// A window armed before commit-confirm was switched off is no longer real —
|
||||||
|
// drop it rather than count down to an event that will not happen.
|
||||||
|
if (timeout === 0 && armed) write(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmTimeout(): number {
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the window an apply just opened. Call only when the apply CHANGED
|
||||||
|
* something: an unchanged apply reconciles nothing and arms nothing. */
|
||||||
|
export function armPendingConfirm() {
|
||||||
|
if (timeout <= 0) return
|
||||||
|
write({ until: Date.now() + timeout * 1000, total: timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm and rollback both end the window. */
|
||||||
|
export function clearPendingConfirm() {
|
||||||
|
if (armed) write(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fires when a window ran out on its own — i.e. the daemon has reverted — and
|
||||||
|
* NOT when it was confirmed or rolled back. Returns an unsubscribe. */
|
||||||
|
export function onPendingConfirmExpire(fn: Listener): () => void {
|
||||||
|
expiryListeners.add(fn)
|
||||||
|
return () => {
|
||||||
|
expiryListeners.delete(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent: several mounted countdowns race to notice the same deadline, and
|
||||||
|
* only the first one gets to announce it. */
|
||||||
|
function expire() {
|
||||||
|
if (!armed) return
|
||||||
|
write(null)
|
||||||
|
for (const l of [...expiryListeners]) l()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another tab confirming, rolling back or applying is the same event as this one
|
||||||
|
// doing it.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
if (e.key !== KEY && e.key !== null) return
|
||||||
|
armed = load()
|
||||||
|
emit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The armed window and its remaining seconds, ticking once a second.
|
||||||
|
*
|
||||||
|
* Returns null when nothing is armed. While non-null `remaining` is at least 1:
|
||||||
|
* reaching zero clears the record and notifies {@link onPendingConfirmExpire}, so
|
||||||
|
* no component ever renders "0 s left" for a window that is already over.
|
||||||
|
*/
|
||||||
|
export function usePendingConfirm(): { pending: PendingConfirm; remaining: number } | null {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => tick((n) => n + 1)
|
||||||
|
listeners.add(sync)
|
||||||
|
sync()
|
||||||
|
return () => {
|
||||||
|
listeners.delete(sync)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (armed && armed.until <= Date.now()) expire()
|
||||||
|
else if (armed) tick((n) => n + 1)
|
||||||
|
}, 1000)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pending = readPendingConfirm()
|
||||||
|
if (!pending) return null
|
||||||
|
return { pending, remaining: Math.max(1, Math.ceil((pending.until - Date.now()) / 1000)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// protectionState — the one sentence the whole panel shows about "am I protected".
|
||||||
|
//
|
||||||
|
// Run with `npm test` (node's built-in test runner + native TypeScript stripping;
|
||||||
|
// no test dependency is added to the SPA, which ships inside the daemon binary).
|
||||||
|
//
|
||||||
|
// The case this file was written for is "plane full, traffic direct": the exact
|
||||||
|
// state of a live router — one enabled rule, `default → direct`, no groups, no
|
||||||
|
// rule-sets — where every part of the data plane was installed and the readout
|
||||||
|
// therefore said "Protected — traffic from your network is going through the
|
||||||
|
// tunnel", under a green LED, while the whole LAN went out the plain WAN.
|
||||||
|
//
|
||||||
|
// planeState.ts has no runtime imports (both of its imports are `import type`),
|
||||||
|
// so this runs against the real module with nothing stubbed.
|
||||||
|
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { engineReadout, engineState, killSwitchReadout, protectionState } from './planeState.ts'
|
||||||
|
import type { Status, Traffic } from './api.ts'
|
||||||
|
|
||||||
|
/** A healthy, fully-installed router; `traffic` is what each case varies. */
|
||||||
|
function status(over: Partial<Status> = {}): Status {
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
table: true,
|
||||||
|
hash: 'abc',
|
||||||
|
version: '1.11.0-shater',
|
||||||
|
kill_switch: 'closed',
|
||||||
|
engine_running: true,
|
||||||
|
plane: 'full',
|
||||||
|
warnings: [],
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTraffic(traffic: Traffic | undefined): Status {
|
||||||
|
return status({ traffic })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the field case ---------------------------------------------------------
|
||||||
|
|
||||||
|
test('plane full + default direct is NOT reported as protected', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'direct', default: 'direct', tunnel_rules: 0 }))
|
||||||
|
assert.notEqual(s.headline, 'Protected')
|
||||||
|
assert.equal(s.variant, 'crit')
|
||||||
|
assert.equal(s.alarm, true)
|
||||||
|
// The claim that was false must not survive anywhere in the copy.
|
||||||
|
assert.doesNotMatch(s.detail, /going through the tunnel/)
|
||||||
|
// ...and the honest consequence must be stated, not implied.
|
||||||
|
assert.match(s.detail, /real address/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- the other verdicts under a full plane ----------------------------------
|
||||||
|
|
||||||
|
test('plane full + default into a tunnel is protected', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'tunnel', default: 'auto', tunnel_rules: 1 }))
|
||||||
|
assert.equal(s.variant, 'on')
|
||||||
|
assert.equal(s.headline, 'Protected')
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plane full + direct default with tunnelling rules is split, not protected', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 3 }))
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.notEqual(s.headline, 'Protected')
|
||||||
|
// Says how much is protected, and that the default is not.
|
||||||
|
assert.match(s.detail, /3 rules/)
|
||||||
|
assert.match(s.detail, /normal internet connection/)
|
||||||
|
// A working selective setup must not raise a banner on every other page.
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('split names a single rule in the singular', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'split', default: 'direct', tunnel_rules: 1 }))
|
||||||
|
assert.match(s.detail, /^One rule sends traffic/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plane full + blocked default with rules leaks nothing and is never crit', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 2 }))
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
assert.match(s.detail, /nothing is leaving unprotected/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plane full + blocked default with no rules says the network has no way out', () => {
|
||||||
|
const s = protectionState(withTraffic({ verdict: 'blocked', default: 'block', tunnel_rules: 0 }))
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.alarm, true)
|
||||||
|
assert.doesNotMatch(s.detail, /going through the tunnel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plane full with no verdict claims nothing either way', () => {
|
||||||
|
for (const t of [undefined, { verdict: '' as const }]) {
|
||||||
|
const s = protectionState(withTraffic(t))
|
||||||
|
assert.notEqual(s.headline, 'Protected')
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- the branches that were already correct ---------------------------------
|
||||||
|
|
||||||
|
test('no status yet', () => {
|
||||||
|
const s = protectionState(null)
|
||||||
|
assert.equal(s.variant, 'off')
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('service switched off is a deliberate state, not a fault', () => {
|
||||||
|
const s = protectionState(status({ enabled: false }))
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.headline, 'Turned off')
|
||||||
|
assert.equal(s.alarm, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hold: the kill-switch caught it — protected, offline', () => {
|
||||||
|
const s = protectionState(status({ plane: 'hold', engine_running: false, active: false }))
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.alarm, true)
|
||||||
|
assert.match(s.headline, /blocked/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('none + fail-closed is the leak, and it is crit', () => {
|
||||||
|
const s = protectionState(status({ plane: 'none', table: false, engine_running: false }))
|
||||||
|
assert.equal(s.variant, 'crit')
|
||||||
|
assert.equal(s.alarm, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('none + fail-open is the operator’s documented choice, stated not alarmed at', () => {
|
||||||
|
const s = protectionState(
|
||||||
|
status({ plane: 'none', table: false, engine_running: false, kill_switch: 'open' }),
|
||||||
|
)
|
||||||
|
assert.equal(s.variant, 'amber')
|
||||||
|
assert.equal(s.alarm, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('daemon too old to send `plane` keeps its own fallback', () => {
|
||||||
|
// Nothing here may depend on `traffic`: a daemon with no `plane` has no
|
||||||
|
// `traffic` either, and this branch reads what it can observe instead.
|
||||||
|
const { plane, ...noPlane } = status()
|
||||||
|
void plane
|
||||||
|
assert.equal(protectionState(noPlane as Status).headline, 'Protected')
|
||||||
|
assert.equal(protectionState({ ...noPlane, running: false } as Status).headline, 'Service stopped')
|
||||||
|
assert.equal(
|
||||||
|
protectionState({ ...noPlane, active: false } as Status).headline,
|
||||||
|
'Starting up',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- engineState: the reading that could not say "down" ----------------------
|
||||||
|
//
|
||||||
|
// `apply.Status.running` was a hardcoded `true` on the daemon, so every panel LED
|
||||||
|
// derived from it was lit before it was read: App's master indicator could not
|
||||||
|
// reach its "Offline" branch, and Apply's "engine: running / stopped" row had one
|
||||||
|
// reachable value. These pin the three answers, and that "up" needs agreement.
|
||||||
|
|
||||||
|
test('engine_running:false is down even while the daemon claims it is running', () => {
|
||||||
|
assert.equal(engineState(status({ running: true, engine_running: false })), 'down')
|
||||||
|
assert.equal(engineReadout(status({ running: true, engine_running: false })).variant, 'crit')
|
||||||
|
assert.equal(engineReadout(status({ running: true, engine_running: false })).word, 'stopped')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a daemon that reports itself stopped is down whatever engine_running says', () => {
|
||||||
|
assert.equal(engineState(status({ running: false, engine_running: true })), 'down')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('up needs both, and then active/idle splits the lamp', () => {
|
||||||
|
assert.equal(engineState(status({ running: true, engine_running: true })), 'up')
|
||||||
|
assert.equal(engineReadout(status({ active: true })).variant, 'on')
|
||||||
|
assert.equal(engineReadout(status({ active: true })).word, 'active')
|
||||||
|
assert.equal(engineReadout(status({ active: false })).variant, 'amber')
|
||||||
|
assert.equal(engineReadout(status({ active: false })).word, 'idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an older daemon with no engine_running is unknown — an unlit lamp, never green', () => {
|
||||||
|
const { engine_running, ...old } = status()
|
||||||
|
void engine_running
|
||||||
|
assert.equal(engineState(old as Status), 'unknown')
|
||||||
|
const r = engineReadout(old as Status)
|
||||||
|
assert.equal(r.variant, 'off')
|
||||||
|
assert.notEqual(r.variant, 'on')
|
||||||
|
assert.equal(r.word, 'not reported')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no status at all is unknown, not down', () => {
|
||||||
|
assert.equal(engineState(null), 'unknown')
|
||||||
|
assert.equal(engineReadout(null).variant, 'off')
|
||||||
|
assert.equal(engineReadout(null).word, 'checking…')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- killSwitchReadout: "I don't know" is not "it's armed" -------------------
|
||||||
|
//
|
||||||
|
// The Overview module read `killArmed && status?.plane !== 'none'`, and
|
||||||
|
// `undefined !== 'none'` is true — so a daemon that never reported `plane`, and
|
||||||
|
// the seconds before the first status arrives, both lit a green lamp over the
|
||||||
|
// word ARMED. These pin the fourth answer that expression could not express.
|
||||||
|
|
||||||
|
test('a daemon that does not report `plane` reads as not reported, never ARMED', () => {
|
||||||
|
const { plane, ...noPlane } = status()
|
||||||
|
void plane
|
||||||
|
const k = killSwitchReadout(noPlane as Status)
|
||||||
|
assert.equal(k.state, 'unknown')
|
||||||
|
assert.notEqual(k.value, 'ARMED')
|
||||||
|
assert.equal(k.variant, 'off')
|
||||||
|
assert.notEqual(k.variant, 'on')
|
||||||
|
assert.equal(k.blockingNow, 'not known')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no status at all is unknown too, and says there is no reading', () => {
|
||||||
|
const k = killSwitchReadout(null)
|
||||||
|
assert.equal(k.state, 'unknown')
|
||||||
|
assert.equal(k.variant, 'off')
|
||||||
|
assert.equal(k.blockingNow, 'no reading yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fail-closed with a plane installed is armed', () => {
|
||||||
|
for (const plane of ['full', 'hold'] as const) {
|
||||||
|
const k = killSwitchReadout(status({ plane }))
|
||||||
|
assert.equal(k.state, 'armed')
|
||||||
|
assert.equal(k.value, 'ARMED')
|
||||||
|
assert.equal(k.variant, 'on')
|
||||||
|
assert.equal(k.blockingNow, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fail-closed with no plane is configured but blocking nothing', () => {
|
||||||
|
const k = killSwitchReadout(status({ plane: 'none', table: false }))
|
||||||
|
assert.equal(k.state, 'inert')
|
||||||
|
assert.equal(k.value, 'NOT IN EFFECT')
|
||||||
|
assert.equal(k.variant, 'crit')
|
||||||
|
assert.equal(k.hot, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fail-open is the operator’s choice — amber, and never a plane question', () => {
|
||||||
|
for (const plane of ['full', 'none', undefined] as const) {
|
||||||
|
const k = killSwitchReadout(status({ kill_switch: 'open', plane }))
|
||||||
|
assert.equal(k.state, 'open')
|
||||||
|
assert.equal(k.value, 'OPEN')
|
||||||
|
assert.equal(k.variant, 'amber')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the live kill_switch wins over the saved one; the saved one only fills a gap', () => {
|
||||||
|
const { kill_switch, ...noKill } = status()
|
||||||
|
void kill_switch
|
||||||
|
// Live says open, config says closed → live wins.
|
||||||
|
assert.equal(killSwitchReadout(status({ kill_switch: 'open' }), 'closed').state, 'open')
|
||||||
|
// Nothing live → fall back to the saved policy.
|
||||||
|
assert.equal(killSwitchReadout(noKill as Status, 'open').state, 'open')
|
||||||
|
assert.equal(killSwitchReadout(noKill as Status, 'closed').state, 'armed')
|
||||||
|
})
|
||||||
+245
-17
@@ -11,7 +11,7 @@
|
|||||||
// same router differently.
|
// same router differently.
|
||||||
|
|
||||||
import type { LedVariant } from './components'
|
import type { LedVariant } from './components'
|
||||||
import type { Status } from './api'
|
import type { Status, Traffic } from './api'
|
||||||
|
|
||||||
export interface ProtectionState {
|
export interface ProtectionState {
|
||||||
variant: LedVariant
|
variant: LedVariant
|
||||||
@@ -21,6 +21,135 @@ export interface ProtectionState {
|
|||||||
alarm: boolean
|
alarm: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Is the engine actually up?
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three answers, and "unknown" is one of them.
|
||||||
|
*
|
||||||
|
* up — the sing-box process is running.
|
||||||
|
* down — it is not. Nothing is being proxied or filtered.
|
||||||
|
* unknown — nobody has told us. Never paint this green.
|
||||||
|
*
|
||||||
|
* THIS EXISTS BECAUSE `status.running` COULD NOT SAY "down". It is the DAEMON's
|
||||||
|
* own liveness, and on the daemons this panel shipped against it was a hardcoded
|
||||||
|
* `true` (apply.go) — so `running ? 'running' : 'stopped'` had exactly one
|
||||||
|
* reachable branch, and every LED derived from it was lit before it was read. A
|
||||||
|
* router whose engine failed to start, with a fail-closed holding plan installed
|
||||||
|
* and no internet on the LAN, showed three green lamps on the page people go to
|
||||||
|
* when they are trying to fix it.
|
||||||
|
*
|
||||||
|
* `engine_running` is the field that answers the question honestly, so it decides
|
||||||
|
* `up`. Either field may still prove a NEGATIVE — a daemon that reports itself
|
||||||
|
* stopped cannot be running an engine — and a negative always wins, so "up" needs
|
||||||
|
* both to agree. Neither field asserting anything leaves `unknown`.
|
||||||
|
*/
|
||||||
|
export type EngineState = 'up' | 'down' | 'unknown'
|
||||||
|
|
||||||
|
export function engineState(status: Status | null): EngineState {
|
||||||
|
if (!status) return 'unknown'
|
||||||
|
if (!status.running) return 'down'
|
||||||
|
if (typeof status.engine_running === 'boolean') return status.engine_running ? 'up' : 'down'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the engine's lamp is painted and what the readout beside it says.
|
||||||
|
*
|
||||||
|
* `unknown` is an UNLIT socket, never amber and never green: amber is this
|
||||||
|
* panel's "degraded", and there is nothing to be degraded about when no reading
|
||||||
|
* has arrived. `down` is crit even when the kill-switch caught it — the engine
|
||||||
|
* being dead is the fault; whether traffic leaks is a separate lamp. */
|
||||||
|
export function engineReadout(status: Status | null): { variant: LedVariant; word: string } {
|
||||||
|
switch (engineState(status)) {
|
||||||
|
case 'down':
|
||||||
|
return { variant: 'crit', word: 'stopped' }
|
||||||
|
case 'up':
|
||||||
|
return status?.active ? { variant: 'on', word: 'active' } : { variant: 'amber', word: 'idle' }
|
||||||
|
default:
|
||||||
|
return { variant: 'off', word: status ? 'not reported' : 'checking…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Is the kill-switch actually blocking anything?
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four answers, and "unknown" is one of them.
|
||||||
|
*
|
||||||
|
* armed — configured fail-closed AND a data plane is installed to enforce it.
|
||||||
|
* inert — configured fail-closed, but there is no plane. Nothing is blocking.
|
||||||
|
* unknown — the daemon has not said how much plane is installed, so whether the
|
||||||
|
* setting is in force is not known. NEVER paint this green.
|
||||||
|
* open — configured fail-open. Nothing is meant to be blocked.
|
||||||
|
*/
|
||||||
|
export type KillSwitchState = 'armed' | 'inert' | 'unknown' | 'open'
|
||||||
|
|
||||||
|
export interface KillSwitchReadout {
|
||||||
|
state: KillSwitchState
|
||||||
|
/** The word the module puts in its readout. */
|
||||||
|
value: string
|
||||||
|
variant: LedVariant
|
||||||
|
/** Is it blocking right now — the row under the readout. `null` ⇒ nothing to add. */
|
||||||
|
blockingNow: string | null
|
||||||
|
/** True when `blockingNow` is bad news and should be drawn hot. */
|
||||||
|
hot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE UNKNOWN BRANCH IS THE WHOLE POINT. This used to be
|
||||||
|
*
|
||||||
|
* killArmed && status?.plane !== 'none'
|
||||||
|
*
|
||||||
|
* and `undefined !== 'none'` is true — so a daemon that had not reported `plane`
|
||||||
|
* at all, and a panel that had not yet received its first status, both landed in
|
||||||
|
* the "ARMED" branch under a green lamp. Every other unknown in this file is an
|
||||||
|
* unlit socket for exactly this reason (see engineReadout): the kill-switch is
|
||||||
|
* the last thing standing between the LAN and the plain WAN, and "I don't know
|
||||||
|
* whether it is installed" must never be dressed as "it is".
|
||||||
|
*
|
||||||
|
* `configured` is the SAVED policy from /api/config, used only while
|
||||||
|
* /api/status has not reported one. The live value wins wherever it exists, as
|
||||||
|
* everywhere else in the panel: this is a status readout, and the config on disk
|
||||||
|
* can already differ from what is installed.
|
||||||
|
*/
|
||||||
|
export function killSwitchReadout(
|
||||||
|
status: Status | null,
|
||||||
|
configured?: string,
|
||||||
|
): KillSwitchReadout {
|
||||||
|
const closed = (status?.kill_switch ?? configured ?? 'closed') === 'closed'
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
return { state: 'open', value: 'OPEN', variant: 'amber', blockingNow: null, hot: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status?.plane) {
|
||||||
|
case 'full':
|
||||||
|
case 'hold':
|
||||||
|
// Something is installed, so the fail-closed guard is really in the path.
|
||||||
|
return { state: 'armed', value: 'ARMED', variant: 'on', blockingNow: null, hot: false }
|
||||||
|
case 'none':
|
||||||
|
return {
|
||||||
|
state: 'inert',
|
||||||
|
value: 'NOT IN EFFECT',
|
||||||
|
variant: 'crit',
|
||||||
|
blockingNow: 'no — nothing installed',
|
||||||
|
hot: true,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
state: 'unknown',
|
||||||
|
value: 'NOT REPORTED',
|
||||||
|
variant: 'off',
|
||||||
|
// Terse on purpose: this is a two-column readout row, and the long form
|
||||||
|
// wrapped onto three lines beside a one-word key.
|
||||||
|
blockingNow: status ? 'not known' : 'no reading yet',
|
||||||
|
hot: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `plane` + `engine_running` express the state more precisely than the three
|
* `plane` + `engine_running` express the state more precisely than the three
|
||||||
* booleans the old status strip exposed (engine active / config enabled / nft
|
* booleans the old status strip exposed (engine active / config enabled / nft
|
||||||
@@ -31,6 +160,24 @@ export interface ProtectionState {
|
|||||||
* hold — the kill-switch caught it. Protected, but offline.
|
* hold — the kill-switch caught it. Protected, but offline.
|
||||||
* none (fail-closed) — there is no protection at all. Online, and exposed.
|
* none (fail-closed) — there is no protection at all. Online, and exposed.
|
||||||
* Collapsing them would erase the only difference that matters.
|
* Collapsing them would erase the only difference that matters.
|
||||||
|
*
|
||||||
|
* PLANE IS NOT THE WHOLE ANSWER, AND THAT USED TO BE A LIE. `plane: 'full'`
|
||||||
|
* returned "Protected — traffic from your network is going through the tunnel",
|
||||||
|
* which is a claim `plane` cannot support: it only says the nft table, the policy
|
||||||
|
* routing and the engine are all installed. Where the diverted packets go once the
|
||||||
|
* engine has them is decided by the engine's default route, and a router in the
|
||||||
|
* field ran with one rule — `default → direct`, no groups, no rule-sets. Fully
|
||||||
|
* installed plane, zero tunnel, whole LAN out the plain WAN with its real address,
|
||||||
|
* green LED, "Protected".
|
||||||
|
*
|
||||||
|
* So `full` now branches on `status.traffic`, the daemon's verdict on the config
|
||||||
|
* it is actually running (see api.ts TrafficVerdict). It is computed on the daemon
|
||||||
|
* because only the daemon knows what was GENERATED and STARTED: the panel's
|
||||||
|
* /api/config is desired state, which diverges from the running one whenever edits
|
||||||
|
* are unapplied or a rollback is pending, and re-deriving the default route from it
|
||||||
|
* would mean a second implementation of the generator's rule loop — schedules,
|
||||||
|
* shadowed catch-alls, targets that failed to resolve and fell back to direct —
|
||||||
|
* drifting against the first.
|
||||||
*/
|
*/
|
||||||
export function protectionState(status: Status | null): ProtectionState {
|
export function protectionState(status: Status | null): ProtectionState {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
@@ -56,12 +203,7 @@ export function protectionState(status: Status | null): ProtectionState {
|
|||||||
|
|
||||||
switch (status.plane) {
|
switch (status.plane) {
|
||||||
case 'full':
|
case 'full':
|
||||||
return {
|
return fullPlaneState(status.traffic)
|
||||||
variant: 'on',
|
|
||||||
headline: 'Protected',
|
|
||||||
detail: 'Traffic from your network is going through the tunnel.',
|
|
||||||
alarm: false,
|
|
||||||
}
|
|
||||||
case 'hold':
|
case 'hold':
|
||||||
return {
|
return {
|
||||||
variant: 'amber',
|
variant: 'amber',
|
||||||
@@ -88,8 +230,18 @@ export function protectionState(status: Status | null): ProtectionState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older daemon with no `plane` field: fall back to what we can observe.
|
// Older daemon with no `plane` field: fall back to what we can observe. The
|
||||||
if (status.running && status.active && status.table) {
|
// engine's own state is asked FIRST — "stopped" is the answer that matters, and
|
||||||
|
// reading it off `running` is what used to make it unreachable (see engineState).
|
||||||
|
if (engineState(status) === 'down') {
|
||||||
|
return {
|
||||||
|
variant: 'crit',
|
||||||
|
headline: 'Service stopped',
|
||||||
|
detail: 'The engine isn’t running, so traffic isn’t being proxied or filtered.',
|
||||||
|
alarm: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.active && status.table) {
|
||||||
return {
|
return {
|
||||||
variant: 'on',
|
variant: 'on',
|
||||||
headline: 'Protected',
|
headline: 'Protected',
|
||||||
@@ -97,14 +249,6 @@ export function protectionState(status: Status | null): ProtectionState {
|
|||||||
alarm: false,
|
alarm: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!status.running) {
|
|
||||||
return {
|
|
||||||
variant: 'crit',
|
|
||||||
headline: 'Service stopped',
|
|
||||||
detail: 'The service isn’t running, so traffic isn’t being handled.',
|
|
||||||
alarm: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
variant: 'amber',
|
variant: 'amber',
|
||||||
headline: 'Starting up',
|
headline: 'Starting up',
|
||||||
@@ -112,3 +256,87 @@ export function protectionState(status: Status | null): ProtectionState {
|
|||||||
alarm: false,
|
alarm: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plane is fully installed — now say where the traffic it carries ends up.
|
||||||
|
*
|
||||||
|
* Only `tunnel` earns "Protected". The other verdicts each describe a real router
|
||||||
|
* someone can be sitting in front of, and they are kept apart because the thing to
|
||||||
|
* DO about them differs:
|
||||||
|
*
|
||||||
|
* split — deliberate for most people who reach it, accidental for the rest
|
||||||
|
* (a default rule that was never pointed anywhere). Amber, but no
|
||||||
|
* alarm: raising a banner on every page of a working selective setup
|
||||||
|
* is how a banner stops being read.
|
||||||
|
* direct — the engine is running and forwarding every connection out the plain
|
||||||
|
* WAN. The traffic outcome is identical to `plane: 'none'` under a
|
||||||
|
* closed kill-switch, so it gets the same weight: crit, and it
|
||||||
|
* interrupts. The wording differs because the fix does — nothing
|
||||||
|
* failed here, the routing simply says "direct".
|
||||||
|
* blocked — the fail-closed default. Nothing is leaking, so this is never crit;
|
||||||
|
* with no tunnelling rules at all it means the network has no way out
|
||||||
|
* and someone should be told why.
|
||||||
|
* unknown — an older daemon, or the seconds between this daemon starting and its
|
||||||
|
* first apply. We do not know, so we do not claim. Saying "Protected"
|
||||||
|
* here is the exact bug being removed.
|
||||||
|
*/
|
||||||
|
function fullPlaneState(traffic: Traffic | undefined): ProtectionState {
|
||||||
|
const tunnelRules = traffic?.tunnel_rules ?? 0
|
||||||
|
|
||||||
|
switch (traffic?.verdict) {
|
||||||
|
case 'tunnel':
|
||||||
|
return {
|
||||||
|
variant: 'on',
|
||||||
|
headline: 'Protected',
|
||||||
|
detail: 'Traffic from your network is going through the tunnel.',
|
||||||
|
alarm: false,
|
||||||
|
}
|
||||||
|
case 'split':
|
||||||
|
return {
|
||||||
|
variant: 'amber',
|
||||||
|
headline: 'Partly protected — the rest goes out directly',
|
||||||
|
detail: `${ruleCount(tunnelRules)} through the tunnel. Everything they don’t match leaves through your normal internet connection, with your real address.`,
|
||||||
|
alarm: false,
|
||||||
|
}
|
||||||
|
case 'direct':
|
||||||
|
return {
|
||||||
|
variant: 'crit',
|
||||||
|
headline: 'Not protected — nothing is going through the tunnel',
|
||||||
|
detail:
|
||||||
|
'The service is running, but your routing sends every connection straight out your normal internet connection, with your real address. On the Routing page, point the default rule at a group or a node.',
|
||||||
|
alarm: true,
|
||||||
|
}
|
||||||
|
case 'blocked':
|
||||||
|
return tunnelRules > 0
|
||||||
|
? {
|
||||||
|
variant: 'amber',
|
||||||
|
headline: 'Partly protected — everything else is blocked',
|
||||||
|
detail: `${ruleCount(tunnelRules)} through the tunnel. Anything they don’t match is blocked instead of being let out, so nothing is leaving unprotected.`,
|
||||||
|
alarm: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
variant: 'amber',
|
||||||
|
headline: 'Nothing is getting out',
|
||||||
|
detail:
|
||||||
|
'No rule sends traffic anywhere, so every connection from your network is being blocked rather than let out unprotected. Add a default rule on the Routing page.',
|
||||||
|
alarm: true,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
variant: 'amber',
|
||||||
|
headline: 'Checking where traffic goes',
|
||||||
|
detail:
|
||||||
|
'The router is up and handling your traffic. It hasn’t reported yet whether that traffic is going through the tunnel.',
|
||||||
|
alarm: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "One rule sends traffic" / "4 rules send traffic", so the detail lines above
|
||||||
|
* can name a number the operator can go and count on the Routing page. Falls
|
||||||
|
* back to the vague form only if the daemon sent a verdict without a count. */
|
||||||
|
function ruleCount(n: number): string {
|
||||||
|
if (n <= 0) return 'Some traffic goes'
|
||||||
|
if (n === 1) return 'One rule sends traffic'
|
||||||
|
return `${n} rules send traffic`
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -18,5 +18,10 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts"],
|
||||||
|
// *.test.ts runs under node's built-in test runner (`npm test`), which strips
|
||||||
|
// types rather than checking them. They are excluded here because they import
|
||||||
|
// node:test / node:assert, and the SPA deliberately carries no @types/node — it
|
||||||
|
// is embedded in the daemon binary, so every devDependency is weight on a router.
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-1
@@ -1,15 +1,62 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// Minimal ambient for the dev-proxy target override — avoids pulling in @types/node
|
// Minimal ambient for the dev-proxy target override — avoids pulling in @types/node
|
||||||
// just for one env read. Vite runs this file under Node where `process` exists.
|
// just for one env read. Vite runs this file under Node where `process` exists.
|
||||||
declare const process: { env: Record<string, string | undefined> }
|
declare const process: { env: Record<string, string | undefined> }
|
||||||
|
|
||||||
|
/** `src/mock.ts`, as the module graph spells it (POSIX-normalised for Windows). */
|
||||||
|
const MOCK_MODULE = 'src/mock.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to emit a production bundle that contains the offline fixture backend.
|
||||||
|
*
|
||||||
|
* `src/mock.ts` describes an invented, healthy router: a full config, 122 nodes
|
||||||
|
* with 119 of them alive, "Protected". It exists so `npm run dev` renders without
|
||||||
|
* a daemon. It shipped inside the binary that goes on real hardware, switched on
|
||||||
|
* by nothing more than a `?dev` on the end of the URL — so a link someone was
|
||||||
|
* sent, or a bookmark they saved, showed an appliance in perfect health while
|
||||||
|
* making no request to the appliance at all.
|
||||||
|
*
|
||||||
|
* api.ts now loads it behind `import.meta.env.DEV`, which Vite folds to a literal
|
||||||
|
* `false` for a build, so Rollup drops the dynamic import and the module never
|
||||||
|
* enters the graph. That is a property of a build tool's optimiser, and an
|
||||||
|
* optimiser is not a promise: one refactor that makes the condition non-static
|
||||||
|
* silently puts the fixtures back. So the property is CHECKED rather than
|
||||||
|
* trusted — if `src/mock.ts` reaches any emitted chunk, the build fails here
|
||||||
|
* instead of shipping.
|
||||||
|
*/
|
||||||
|
function assertNoMockFixtures(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'shater:assert-no-mock-fixtures',
|
||||||
|
apply: 'build',
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const guilty: string[] = []
|
||||||
|
for (const [file, output] of Object.entries(bundle)) {
|
||||||
|
if (output.type !== 'chunk') continue
|
||||||
|
for (const id of output.moduleIds) {
|
||||||
|
if (id.replace(/\\/g, '/').endsWith(MOCK_MODULE)) guilty.push(`${file} ← ${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guilty.length > 0) {
|
||||||
|
this.error(
|
||||||
|
`the offline fixture backend (${MOCK_MODULE}) reached the production bundle:\n ` +
|
||||||
|
guilty.join('\n ') +
|
||||||
|
`\nFixtures describe a router that does not exist. Keep every path to them behind ` +
|
||||||
|
`\`import.meta.env.DEV\` so Rollup can drop them, and never gate them on a runtime ` +
|
||||||
|
`flag such as a query parameter.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The SPA is embedded in the forked sing-box binary and served by the daemon on
|
// The SPA is embedded in the forked sing-box binary and served by the daemon on
|
||||||
// its own port. Relative base so it works under any mount path; single small
|
// its own port. Relative base so it works under any mount path; single small
|
||||||
// bundle (no code-splitting) keeps the embed simple and the flash budget low.
|
// bundle (no code-splitting) keeps the embed simple and the flash budget low.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), assertNoMockFixtures()],
|
||||||
base: './',
|
base: './',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 288 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 280 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB |
@@ -1,93 +0,0 @@
|
|||||||
// lx:begin awg
|
|
||||||
|
|
||||||
package group
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
|
||||||
C "github.com/sagernet/sing-box/constant"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suspendAmneziaWGConsumersOnWireGuardSwitch is called from Selector.SelectOutbound
|
|
||||||
// BEFORE the switch is committed. If the member about to be selected is — or chains
|
|
||||||
// down via detour to — a WireGuard-based endpoint (type "wireguard", covering plain
|
|
||||||
// WG and AmneziaWG), it walks UP from this group to every AmneziaWG endpoint that
|
|
||||||
// detours through it and suspends each one (brings its device down). Rationale:
|
|
||||||
// AmneziaWG traffic encapsulated inside a WireGuard tunnel hangs the kernel on
|
|
||||||
// Android; the static Start-guard cannot cover this because a selector's chosen
|
|
||||||
// member is only known at runtime.
|
|
||||||
//
|
|
||||||
// Called before s.selected is updated, so the race is closed: once the group
|
|
||||||
// points at the WireGuard member, the AmneziaWG consumers are already suspended
|
|
||||||
// (started=false) and a concurrent reconnect fails with "not ready" instead of
|
|
||||||
// sending a junk handshake into WireGuard.
|
|
||||||
func suspendAmneziaWGConsumersOnWireGuardSwitch(outboundManager adapter.OutboundManager, groupTag string, selected adapter.Outbound) {
|
|
||||||
if outboundManager == nil || groupTag == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !chainReachesWireGuard(outboundManager, selected, make(map[string]bool)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
suspendAmneziaWGConsumers(outboundManager, groupTag, make(map[string]bool))
|
|
||||||
}
|
|
||||||
|
|
||||||
// chainReachesWireGuard reports whether outbound is — or transitively detours
|
|
||||||
// down to, or (being a group) contains a member that is — a WireGuard-based
|
|
||||||
// endpoint. visited guards against cycles.
|
|
||||||
func chainReachesWireGuard(outboundManager adapter.OutboundManager, outbound adapter.Outbound, visited map[string]bool) bool {
|
|
||||||
if outbound == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tag := outbound.Tag()
|
|
||||||
if tag != "" {
|
|
||||||
if visited[tag] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
visited[tag] = true
|
|
||||||
}
|
|
||||||
if outbound.Type() == C.TypeWireGuard {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Down the detour chain (vless -> ... -> wireguard).
|
|
||||||
for _, dependency := range outbound.Dependencies() {
|
|
||||||
if member, loaded := outboundManager.Outbound(dependency); loaded {
|
|
||||||
if chainReachesWireGuard(outboundManager, member, visited) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A nested group: any member reaching WireGuard counts.
|
|
||||||
if group, isGroup := outbound.(adapter.OutboundGroup); isGroup {
|
|
||||||
for _, memberTag := range group.All() {
|
|
||||||
if member, loaded := outboundManager.Outbound(memberTag); loaded {
|
|
||||||
if chainReachesWireGuard(outboundManager, member, visited) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// suspendAmneziaWGConsumers walks UP from tag via the reverse-dependency ledger
|
|
||||||
// (ConsumersOf) and suspends every AmneziaWG endpoint that detours through it,
|
|
||||||
// directly or transitively (e.g. AWG -> vless -> group). visited guards cycles.
|
|
||||||
func suspendAmneziaWGConsumers(outboundManager adapter.OutboundManager, tag string, visited map[string]bool) {
|
|
||||||
for _, consumerTag := range outboundManager.ConsumersOf(tag) {
|
|
||||||
if visited[consumerTag] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
visited[consumerTag] = true
|
|
||||||
consumer, loaded := outboundManager.Outbound(consumerTag)
|
|
||||||
if !loaded {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if awg, isAWG := consumer.(adapter.AmneziaWGSuspendable); isAWG && awg.IsAmneziaWG() {
|
|
||||||
awg.SuspendAmneziaWG()
|
|
||||||
}
|
|
||||||
// Keep walking up: a non-AWG hop (vless) or a parent group may itself have
|
|
||||||
// an AmneziaWG consumer above it.
|
|
||||||
suspendAmneziaWGConsumers(outboundManager, consumerTag, visited)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lx:end awg
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user