Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+235
-287
@@ -1,36 +1,45 @@
|
||||
# Shater v0.2 — build the 4-package signed opkg feed and publish it as a rolling
|
||||
# Gitea release consumable as an `src/gz` feed.
|
||||
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
|
||||
# per-arch Gitea releases consumable as an apk repository.
|
||||
#
|
||||
# WHAT CHANGED FROM v0.1
|
||||
# v0.1 shipped 3 packages: xrayctl (SDK-compiled Go) + shater-core +
|
||||
# luci-app-shater (hand-packed data .ipk). v0.2 collapses the runtime into ONE
|
||||
# forked binary and ships 4 packages, all built the canonical SDK way:
|
||||
# WHAT WE SHIP
|
||||
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
|
||||
# SDK way:
|
||||
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
|
||||
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
|
||||
# and staged into openwrt/shaterd/files/ BEFORE the SDK
|
||||
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
|
||||
# the arch-matched artifact. (arch-specific .ipk)
|
||||
# the arch-matched artifact. (arch-specific .apk)
|
||||
# - shater-core data glue, PKGARCH=all
|
||||
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
|
||||
# - byedpi ciadpi, C cross-compiled from source by the SDK (arch-specific)
|
||||
#
|
||||
# TARGET HARDWARE / ARCH MATRIX
|
||||
# x86_64 -> the QEMU testbed VM (generic x86-64).
|
||||
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 + BPI-R4, mediatek/filogic).
|
||||
# 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
|
||||
# PKGARCH=all, so one build of each covers every device. opkg filters by
|
||||
# Architecture at install time, so a single combined feed URL serves all.
|
||||
# PKGARCH=all, so one build of each covers every device — but the RELEASES
|
||||
# are still per-arch (see the release-apk job for why).
|
||||
#
|
||||
# FEED SIGNING (opkg / usign — OpenWrt 24.10 is opkg, not apk; apk lands at 25.12)
|
||||
# The feed index (Packages) is usign-signed with the SECRET key in the Gitea
|
||||
# repo secret KEY_BUILD; routers verify it with the committed public key
|
||||
# dist/shater-feed.pub (fingerprint 5ac4b177689cb8e0). Do NOT regenerate the
|
||||
# key — that invalidates every deployed router's trust.
|
||||
# FORMAT: apk ONLY (25.12+)
|
||||
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
|
||||
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
|
||||
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
|
||||
# device we serve has an opkg binary at all, so building and signing a second
|
||||
# feed served nobody.
|
||||
#
|
||||
# FEED SIGNING (EC / apk)
|
||||
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
|
||||
# secret KEY_APK; routers verify it with the committed public key
|
||||
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
|
||||
# invalidates every deployed router's trust.
|
||||
#
|
||||
# AUTO-RELEASE
|
||||
# push a tag `vX.Y.Z` -> versioned release. workflow_dispatch / (optional) main
|
||||
# -> rolling `latest` pre-release (always-fresh feed). Publish uses the Gitea
|
||||
# API via curl (ci/gitea-release.sh) — no external action needed.
|
||||
# push a tag `vX.Y.Z` -> versioned per-arch releases `apk-vX.Y.Z-<arch>`.
|
||||
# workflow_dispatch -> rolling per-arch `apk-latest-<arch>` (always-fresh
|
||||
# feed). Publish uses the Gitea API via curl (ci/gitea-release.sh) — no
|
||||
# external action needed. NOTE: the apk release tags deliberately do NOT start
|
||||
# with `v` so publishing them cannot re-trigger this workflow's `v*` filter.
|
||||
#
|
||||
# PACKAGE VERSIONING (bug B4)
|
||||
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
|
||||
@@ -41,28 +50,13 @@
|
||||
# exported via $GITHUB_ENV):
|
||||
# tag `vX.Y.Z` -> X.Y.Z-r1
|
||||
# anything else -> <nearest tag>-r<commits since it + 1>
|
||||
# and hands them to the SDK builds as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
|
||||
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
|
||||
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
|
||||
# into the binary's constant.Version. ci/sdk-build*.sh then ASSERT that the
|
||||
# built .ipk/.apk really carry that version, so the failure can never be
|
||||
# silent again. This is also why both build jobs check out with fetch-depth: 0
|
||||
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
|
||||
# built .apk really carry that version, so the failure can never be silent
|
||||
# again. This is also why the build job checks out with fetch-depth: 0
|
||||
# — `git describe` needs tags and ancestry. `byedpi` is excluded: it keeps
|
||||
# upstream ByeDPI's own PKG_VERSION (see openwrt/byedpi/Makefile).
|
||||
#
|
||||
# APK LANE (25.12+, ADDITIVE — T2)
|
||||
# The fleet is migrating to BananaWRT 25.12-mtk-vendor (= ImmortalWrt 25.12
|
||||
# base), where opkg is replaced by Alpine apk (.apk, binary packages.adb
|
||||
# index, EC keys in /etc/apk/keys/). The `build-apk` + `release-apk` jobs
|
||||
# below build the SAME 4 packages through the ImmortalWrt 25.12 apk-SDK and
|
||||
# publish PER-ARCH apk repos as releases `apk-latest-<arch>` (rolling) /
|
||||
# `apk-<tag>-<arch>` (versioned). Per-arch because apk filenames carry no
|
||||
# architecture (shaterd-0.2.0-r1.apk would collide across arches in one flat
|
||||
# release) and apk fetches packages relative to the packages.adb URL.
|
||||
# Signed with the EC key in the Gitea secret KEY_APK; trust anchor
|
||||
# dist/shater-apk.pem (ci/gen-apk-key.sh). The usign/opkg lane above is
|
||||
# UNCHANGED and keeps serving the 24.10 fleet. NOTE: the apk release tags
|
||||
# deliberately do NOT start with `v` so publishing them cannot re-trigger
|
||||
# this workflow's `v*` tag filter.
|
||||
|
||||
# CACHING (T3 — fast CI)
|
||||
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
|
||||
@@ -80,34 +74,33 @@
|
||||
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
|
||||
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
|
||||
# prefix fallback is allowed.
|
||||
# - Go module + build cache — key = hash of go.sum; shared by all 4 build
|
||||
# - Go module + build cache — key = hash of go.sum; shared by both build
|
||||
# jobs (each builds both GOARCHes).
|
||||
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
|
||||
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
|
||||
# - apt .deb archives for the apk lane's debian:bookworm host-deps
|
||||
# (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list is in it).
|
||||
# - usign binary (.cache/tools) — static helper, fixed key.
|
||||
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
|
||||
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
|
||||
# is in it).
|
||||
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
|
||||
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
|
||||
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
|
||||
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
|
||||
# container from the workspace cache; `feeds update` on an existing clone
|
||||
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
|
||||
# always checks out feeds.conf's pins, and ci/sdk-build*.sh wipes the
|
||||
# always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
|
||||
# cache + re-clones fresh if update ever fails on a cached checkout.
|
||||
# Key = lane + SDK release (shared across the two arch jobs of a lane —
|
||||
# same release pins identical feed revs; the sequential runner means the
|
||||
# second arch restores what the first saved). restore-keys lets an SDK
|
||||
# version bump start from the old clones (git fetch delta, not re-clone).
|
||||
# Key = lane + SDK release (shared across the two arch jobs — the same
|
||||
# release pins identical feed revs; the sequential runner means the second
|
||||
# arch restores what the first saved). restore-keys lets an SDK version
|
||||
# bump start from the old clones (git fetch delta, not re-clone).
|
||||
# Act_runner facts this design leans on (verified in run 51 logs):
|
||||
# - the cache backend works: restores/saves confirmed, hashFiles() works;
|
||||
# - docker images (openwrt/sdk, debian:bookworm, runner-images) live on the
|
||||
# PERSISTENT host daemon — "Image is up to date" each run, no re-download;
|
||||
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
|
||||
# host daemon — "Image is up to date" each run, no re-download;
|
||||
# - each actions/cache SAVE is followed by an exact 3-minute act_runner
|
||||
# stall (node process lingers; hit→no-save→no stall). Steady state saves
|
||||
# nothing, so adding cache entries is fine, but keys that change every
|
||||
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
|
||||
|
||||
name: release
|
||||
|
||||
on:
|
||||
@@ -125,54 +118,49 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.arch }}
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
|
||||
# stops the release instead of shipping with it.
|
||||
#
|
||||
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
|
||||
# workflow cannot block another one — they run side by side and a red `test`
|
||||
# workflow would have published anyway. Only a `needs:` edge inside THIS
|
||||
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
|
||||
# call the same scripts/run-tests.sh so they cannot drift.
|
||||
#
|
||||
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
|
||||
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
|
||||
# three named tests. 115 of the 116 test files under shater/** had never run in
|
||||
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
|
||||
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
|
||||
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
|
||||
#
|
||||
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
|
||||
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
|
||||
# transport/wireguard compiles 1 test file without the tag set and 7 with it
|
||||
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
|
||||
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
|
||||
# the named, reasoned exclusions are in scripts/run-tests.sh.
|
||||
test:
|
||||
name: test gate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, sdk: x86_64-24.10.4 } # testbed VM (generic x86-64)
|
||||
- { arch: aarch64_cortex-a53, sdk: mediatek-filogic-24.10.4 } # BPI-R3 + BPI-R4 (mediatek/filogic)
|
||||
steps:
|
||||
# fetch-depth: 0 — the package version is DERIVED from the git tag
|
||||
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
|
||||
# shallow checkout has neither tags nor ancestry, so `git describe` would
|
||||
# fail and every dispatch build would fall back to 0.0.0.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# scripts/build-shaterd.sh builds the engine via a go.mod
|
||||
# `replace => ./submodules/wireguard-go` (AmneziaWG fork), so that submodule
|
||||
# must be present or `go build` dies with "no such file or directory".
|
||||
# actions/checkout does not fetch submodules by default; init ONLY this one
|
||||
# (clients/apple+android are large and unused here).
|
||||
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
|
||||
# this even `go list` fails. Same step/reason as in build-apk below.
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
# 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"
|
||||
|
||||
# Toolchain for scripts/build-shaterd.sh: Go (daemon), Node (Vite SPA), UPX.
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod # pins Go 1.24.7 (go.mod `go` line)
|
||||
cache: false # explicit actions/cache@v3.3.2 below (setup-go's
|
||||
# built-in cache uses the new API act_runner lacks)
|
||||
go-version-file: go.mod
|
||||
cache: false # explicit actions/cache@v3.3.2 below
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # Vite 5 needs Node 18+; 20 LTS
|
||||
|
||||
# ---- caches (see the header comment for keys + version pin rationale) ----
|
||||
# Same cache key as build-apk: this job runs first, so it warms the module
|
||||
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
@@ -183,92 +171,36 @@ jobs:
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
|
||||
# TypeScript run directly by `node --test`, and type stripping only exists
|
||||
# from 22.6 — on node 20 `npm test` dies before running a single case.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache panel node_modules
|
||||
id: npm-cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: panel/node_modules
|
||||
key: npm-${{ hashFiles('panel/package-lock.json') }}
|
||||
# NO restore-keys: node_modules must exactly match the lockfile;
|
||||
# on any lockfile change this misses and `npm ci` runs fresh.
|
||||
|
||||
- name: Cache SDK dl/ (package sources)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/dl
|
||||
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
|
||||
restore-keys: |
|
||||
dl-
|
||||
- name: Panel tests
|
||||
run: bash scripts/run-panel-tests.sh
|
||||
|
||||
# feeds git checkouts (see header): both 24.10.4 arch jobs share one entry
|
||||
# (same release = same feeds.conf.default pins), so derive the release
|
||||
# from the matrix sdk tag (x86_64-24.10.4 -> 24.10.4).
|
||||
- name: Compute feeds cache key
|
||||
id: feedskey
|
||||
run: echo "ver=$(echo '${{ matrix.sdk }}' | sed 's/.*-//')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache SDK feeds checkouts
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/feeds
|
||||
key: feeds-opkg-${{ steps.feedskey.outputs.ver }}
|
||||
restore-keys: |
|
||||
feeds-opkg-
|
||||
|
||||
- name: Cache CI tools (usign)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/tools
|
||||
key: tools-usign-v1
|
||||
|
||||
- name: Install UPX
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
|
||||
|
||||
# Build the SPA-embedded, static-musl, UPX'd shaterd for BOTH arches and
|
||||
# stage dist/shaterd-<a>.upx into openwrt/shaterd/files/. MUST run before
|
||||
# the SDK package build (the openwrt/shaterd package installs the staged
|
||||
# artifact). $SHATER_VERSION (from the version step above) is stamped into
|
||||
# constant.Version, so the binary and the package agree. On an exact
|
||||
# node_modules cache hit, --fast skips the redundant `npm ci`.
|
||||
- name: Build & stage shaterd artifact
|
||||
env:
|
||||
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -eu
|
||||
FAST=""
|
||||
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
|
||||
echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
|
||||
bash scripts/build-shaterd.sh $FAST
|
||||
|
||||
# Compile the 4 packages through the arch-matched OpenWrt SDK and produce a
|
||||
# signed per-arch opkg feed (Packages + Packages.gz + Packages.sig + .ipk).
|
||||
# SHATER_PKG_VERSION/SHATER_PKG_RELEASE reach the package Makefiles through
|
||||
# the SDK container; ci/sdk-build.sh asserts the .ipk really carry them.
|
||||
- name: Build signed feed (SDK)
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/build-feed.sh "${{ matrix.arch }}" "${{ matrix.sdk }}" "out/${{ matrix.arch }}"
|
||||
|
||||
- name: Show feed
|
||||
run: ls -l "out/${{ matrix.arch }}" && cat "out/${{ matrix.arch }}/Packages"
|
||||
|
||||
- name: Upload feed artifact
|
||||
# v4 uses an artifact backend Gitea Actions does not implement
|
||||
# (GHESNotSupportedError); v3 works on Gitea's act_runner.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: shater-${{ matrix.arch }}
|
||||
path: out/${{ matrix.arch }}/*
|
||||
if-no-files-found: error
|
||||
- name: Go tests (shipped tags, linux, + race)
|
||||
run: bash scripts/run-tests.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# APK lane (additive): the same 4 packages through the ImmortalWrt 25.12
|
||||
# apk-SDK for the 25.12/apk fleet (BananaWRT 25.12-mtk-vendor routers + the
|
||||
# future 25.12 VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
||||
# packages.adb + shater-apk.pem. Artifact prefix `apkfeed-` (NOT `shater-`)
|
||||
# so the opkg release job's `artifacts/shater-*` glob never picks these up.
|
||||
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
|
||||
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
|
||||
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
|
||||
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
|
||||
build-apk:
|
||||
name: apk ${{ matrix.arch }}
|
||||
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
|
||||
# (with the guards in release-apk) leaves nothing published.
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -281,8 +213,10 @@ jobs:
|
||||
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
|
||||
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
|
||||
steps:
|
||||
# fetch-depth: 0 — see the opkg lane: the package version comes from
|
||||
# `git describe`, which needs tags + ancestry.
|
||||
# fetch-depth: 0 — the package version is DERIVED from the git tag
|
||||
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
|
||||
# shallow checkout has neither tags nor ancestry, so `git describe` would
|
||||
# fail and every dispatch build would fall back to 0.0.0.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -295,8 +229,10 @@ jobs:
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
# Same single version computation as the opkg lane — both lanes MUST agree
|
||||
# on the version, they package the identical tree.
|
||||
# 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"
|
||||
|
||||
@@ -373,11 +309,27 @@ jobs:
|
||||
restore-keys: |
|
||||
feeds-apk-
|
||||
|
||||
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
|
||||
# everything else in CI builds with the full upstream set, so without this
|
||||
# step the one combination we actually ship is never exercised. That is how
|
||||
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
|
||||
# binary answered a WireGuard node with "gVisor is not included in this
|
||||
# build" (2026-07-25). The check runs the declared-feature/tag comparison
|
||||
# and then constructs one node of every declared protocol through box.New
|
||||
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
|
||||
# that breaks a feature fails the release instead of shipping.
|
||||
- name: Verify the shipped build-tag set (D23)
|
||||
run: bash scripts/check-router-tags.sh
|
||||
|
||||
- name: Install UPX
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
|
||||
|
||||
# Same artifact-order contract as the opkg lane: the SPA-embedded shaterd
|
||||
# binary is built OUT of the SDK and staged before the package build.
|
||||
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
|
||||
# the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
|
||||
# (the openwrt/shaterd package only installs the staged artifact).
|
||||
# $SHATER_VERSION (from the version step above) is stamped into
|
||||
# constant.Version, so the binary and the package agree. On an exact
|
||||
# node_modules cache hit, --fast skips the redundant `npm ci`.
|
||||
- name: Build & stage shaterd artifact
|
||||
env:
|
||||
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
|
||||
@@ -409,124 +361,34 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publish once both arches are built. Rolling `latest` on dispatch, a versioned
|
||||
# release on a `vX.Y.Z` tag. Self-contained (curl -> Gitea API).
|
||||
release:
|
||||
name: release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all arch feeds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Assemble release assets
|
||||
id: assets
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p release
|
||||
# For each downloaded arch feed: one ready-to-serve tarball + loose ipks.
|
||||
for d in artifacts/shater-*; do
|
||||
[ -d "$d" ] || continue
|
||||
arch="${d#artifacts/shater-}"
|
||||
tar -C "$d" -czf "release/shater-feed-${arch}.tar.gz" .
|
||||
# loose .ipk for direct `opkg install <url>` (dedupe shared _all ipks by name)
|
||||
for ipk in "$d"/*.ipk; do
|
||||
[ -e "$ipk" ] || continue
|
||||
cp -n "$ipk" "release/$(basename "$ipk")"
|
||||
done
|
||||
done
|
||||
# ship the feed's public key so routers can verify (see docs-shater/INSTALL.md)
|
||||
cp -f dist/shater-feed.pub release/shater-feed.pub
|
||||
ls -l release
|
||||
echo "count=$(ls release | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# restore the prebuilt usign binary (skips apt + cmake + clone + build)
|
||||
- name: Cache CI tools (usign)
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: .cache/tools
|
||||
key: tools-usign-v1
|
||||
|
||||
- name: Install usign (feed signer)
|
||||
run: bash ci/install-usign.sh
|
||||
|
||||
- name: Build & sign combined opkg feed index
|
||||
# One Packages/Packages.gz over ALL loose .ipk (every arch + arch=all),
|
||||
# with basename Filenames. opkg filters by Architecture, so a single
|
||||
# release URL serves every device: BPI routers pick aarch64_cortex-a53 +
|
||||
# all, the x86 testbed picks x86_64 + all. Signed with KEY_BUILD so
|
||||
# routers keep check_signature on. This is what makes the release directly
|
||||
# consumable as an `src/gz` feed (see docs-shater/INSTALL.md).
|
||||
env:
|
||||
KEY_BUILD: ${{ secrets.KEY_BUILD }}
|
||||
run: bash ci/make-index.sh release
|
||||
|
||||
- name: Determine release identity
|
||||
id: rel
|
||||
run: |
|
||||
set -eu
|
||||
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater ${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=shater latest (main)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "rolling=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
NAME: ${{ steps.rel.outputs.name }}
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
BODY: |
|
||||
Automated build. Packages: shaterd + byedpi (per-arch), shater-core +
|
||||
luci-app-shater (arch=all).
|
||||
Targets: x86_64 (testbed) and aarch64_cortex-a53 (BPI-R3 + BPI-R4, mediatek/filogic).
|
||||
|
||||
── Add as an opkg feed (recommended — then updating is one command) ──
|
||||
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.
|
||||
|
||||
── Update (name our packages — never a bare `opkg upgrade`) ──
|
||||
opkg update
|
||||
opkg upgrade shaterd shater-core luci-app-shater byedpi
|
||||
|
||||
── Or install the loose .ipk directly / from the tarball feed ──
|
||||
wget -O /tmp/f.tgz <this release>/shater-feed-aarch64_cortex-a53.tar.gz
|
||||
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
|
||||
opkg install /tmp/shater/luci-app-shater_*_all.ipk
|
||||
run: bash ci/gitea-release.sh release/*
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publish the apk lane: ONE release PER ARCH (apk package filenames carry no
|
||||
# arch, and apk fetches `<name>-<ver>.apk` relative to the packages.adb URL —
|
||||
# a flat multi-arch release would collide). Rolling `apk-latest-<arch>` on
|
||||
# dispatch, `apk-<tag>-<arch>` on a version tag. The tags do NOT match the
|
||||
# workflow's `v*` trigger, so publishing them cannot re-trigger the build.
|
||||
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
|
||||
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
|
||||
# multi-arch release would collide). Every run refreshes the ROLLING pointer
|
||||
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
|
||||
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
|
||||
# publishing them cannot re-trigger the build.
|
||||
#
|
||||
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
|
||||
# it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
|
||||
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
|
||||
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
|
||||
# while v0.2.9/v0.2.10 published fine, and every router whose
|
||||
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
|
||||
# successful, silent `apk update` with nothing new. Rolling is the whole point
|
||||
# of that URL, so it is now written unconditionally and asserted afterwards.
|
||||
release-apk:
|
||||
name: release apk
|
||||
needs: build-apk
|
||||
needs: [test, build-apk]
|
||||
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
|
||||
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
|
||||
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
|
||||
if: ${{ !cancelled() }}
|
||||
#
|
||||
# `needs.test.result == 'success'` is the second half of the gate. Without
|
||||
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
|
||||
# skipped), this job runs with no artifacts at all, and — see the guard at
|
||||
# the end of the publish step — used to exit 0 having published nothing. Red
|
||||
# tests must SKIP this job, not "succeed" through it.
|
||||
if: ${{ !cancelled() && needs.test.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -537,6 +399,9 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
# Identity of the VERSIONED release only. The rolling pointer is published
|
||||
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
|
||||
# from here.
|
||||
- name: Determine release identity
|
||||
id: rel
|
||||
run: |
|
||||
@@ -558,21 +423,47 @@ jobs:
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
run: |
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
|
||||
# was the step's whole body: with no artifacts the glob stayed
|
||||
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
|
||||
# ended and the step exited 0 — "release apk" went GREEN having
|
||||
# published absolutely nothing. Any upstream failure (all arches
|
||||
# failing to build, an artifact-name change, a download-artifact
|
||||
# hiccup) therefore looked like a successful release.
|
||||
published=0
|
||||
for d in artifacts/apkfeed-*; do
|
||||
[ -d "$d" ] || continue
|
||||
arch="${d#artifacts/apkfeed-}"
|
||||
if [ "$VER" = latest ]; then TAG="apk-latest-$arch"; else TAG="apk-$VER-$arch"; fi
|
||||
ROLL="apk-latest-$arch"
|
||||
|
||||
# The version we just built, read straight off the artifact
|
||||
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
|
||||
# this job checks out shallow, so it has no tags to describe from.
|
||||
pkg=""
|
||||
for a in "$d"/shaterd-*.apk; do
|
||||
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
|
||||
done
|
||||
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
|
||||
want="${pkg#shaterd-}"; want="${want%.apk}"
|
||||
echo "[release-apk] arch=$arch built version=$want"
|
||||
|
||||
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
|
||||
Packages: shaterd + byedpi (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
This build: \`$want\`.
|
||||
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
|
||||
|
||||
── Add as an apk repository ──
|
||||
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$TAG/shater-apk.pem
|
||||
── Add as an apk repository (rolling — install once, then just update) ──
|
||||
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
|
||||
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
|
||||
apk update
|
||||
apk add luci-app-shater # pulls shater-core + shaterd too
|
||||
apk add byedpi # optional: ByeDPI desync egress
|
||||
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
|
||||
assets, so the same repo line keeps serving the newest build. To pin a
|
||||
version instead, point the repo line at
|
||||
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
|
||||
file must be edited by hand for each upgrade.
|
||||
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||
@@ -580,9 +471,66 @@ jobs:
|
||||
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 §6. The opkg/24.10 feed lives in the \`latest\` release."
|
||||
echo "[release-apk] publishing $TAG from $d"
|
||||
TAG="$TAG" NAME="shater apk $VER ($arch)" BODY="$BODY" \
|
||||
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
|
||||
Full guide: docs-shater/INSTALL.md §5."
|
||||
|
||||
# 1) the pinnable versioned release (tag runs only)
|
||||
if [ "$VER" != latest ]; then
|
||||
echo "[release-apk] publishing apk-$VER-$arch from $d"
|
||||
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
|
||||
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
|
||||
bash ci/gitea-release.sh "$d"/*
|
||||
fi
|
||||
|
||||
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
|
||||
# deletes the existing release before recreating it, so the old
|
||||
# version's assets are REPLACED, never accumulated (two versions of
|
||||
# one package in one index would let apk choose, not us).
|
||||
echo "[release-apk] publishing $ROLL from $d"
|
||||
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
|
||||
PRERELEASE=true ROLLING=true \
|
||||
bash ci/gitea-release.sh "$d"/*
|
||||
|
||||
# 3) ASSERT the rolling release really serves THIS build — same class
|
||||
# of check as ci/sdk-build-apk.sh's package-version assert, and for
|
||||
# the same reason: the previous failure mode was silent. Reads the
|
||||
# published release back over the API and requires our three
|
||||
# tag-versioned packages at $want, the index, the key — and NO
|
||||
# left-over package asset at any other version.
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
|
||||
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
|
||||
| tr '{},' '\n\n\n' \
|
||||
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
|
||||
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
|
||||
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
|
||||
# here-string, NOT `printf | grep -q`: under `pipefail` the early
|
||||
# exit of grep -q can SIGPIPE the writer and fail a passing check.
|
||||
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
|
||||
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
|
||||
grep -qxF "$f" <<<"$got" || {
|
||||
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
|
||||
echo " A router pinned to the rolling URL would have silently"
|
||||
echo " stayed on its old version with a successful apk update."
|
||||
exit 13; }
|
||||
done
|
||||
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
|
||||
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
|
||||
-e "luci-app-shater-$want.apk" || true)"
|
||||
[ -z "$stale" ] || {
|
||||
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
|
||||
printf ' %s\n' $stale
|
||||
echo " Two versions of one package in one feed = apk picks by its"
|
||||
echo " own rules, not by our intent."
|
||||
exit 14; }
|
||||
echo "[release-apk] OK — $ROLL serves $want"
|
||||
published=$((published + 1))
|
||||
done
|
||||
|
||||
# The assert the loop above never had. Zero feeds published is a failed
|
||||
# release, not a quiet success — say so with a non-zero exit.
|
||||
if [ "$published" -eq 0 ]; then
|
||||
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
|
||||
echo " NOTHING was published. Downloaded tree:"
|
||||
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
|
||||
exit 10
|
||||
fi
|
||||
echo "[release-apk] published $published arch feed(s)"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Shater — the test gate, on every push to `main`.
|
||||
#
|
||||
# WHY THIS FILE EXISTS (2026-07-26)
|
||||
# The fork had a full suite and no CI that ran it. Upstream's
|
||||
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
|
||||
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
|
||||
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
|
||||
# the 116 test files under shater/** had never once executed in CI, and
|
||||
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
|
||||
# releases.
|
||||
#
|
||||
# RELATIONSHIP TO release.yml
|
||||
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
|
||||
# gate: a separate workflow cannot block another one. The gate is the `test`
|
||||
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
|
||||
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
|
||||
# drift apart.
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs-shater/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: go + panel tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
|
||||
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
|
||||
# starts. Same step, same reason, as in release.yml's build job.
|
||||
- name: Init wireguard-go submodule (awg)
|
||||
run: git submodule update --init --depth 1 submodules/wireguard-go
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false # explicit actions/cache@v3.3.2 below
|
||||
|
||||
# v3.3.2 is the last release speaking the cache API act_runner implements
|
||||
# (see the header of release.yml). Same key as the release build job, so
|
||||
# whichever runs first warms the other.
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
|
||||
# through `node --test`, and type stripping only exists from 22.6. On
|
||||
# node 20 `npm test` dies with a syntax error before running anything.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache panel node_modules
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: panel/node_modules
|
||||
key: npm-${{ hashFiles('panel/package-lock.json') }}
|
||||
|
||||
- name: Panel tests
|
||||
run: bash scripts/run-panel-tests.sh
|
||||
|
||||
- name: Go tests (shipped tags, linux, + race)
|
||||
run: bash scripts/run-tests.sh
|
||||
+1
-1
@@ -63,7 +63,7 @@ nul
|
||||
/venv/
|
||||
/test/cache.db
|
||||
|
||||
# feed artifacts (tracked public key dist/shater-feed.pub is force-added)
|
||||
# feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
|
||||
/dist/
|
||||
|
||||
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
|
||||
|
||||
+7
-1
@@ -7,4 +7,10 @@
|
||||
[submodule "submodules/wireguard-go"]
|
||||
path = submodules/wireguard-go
|
||||
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
|
||||
branch = lx
|
||||
# The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
|
||||
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
|
||||
# conn/bind_std.go at all, so a `git submodule update --remote` against it
|
||||
# would silently restore the bug where ClientBind/StdNetBind shred the
|
||||
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
|
||||
# the line the pin is actually on.
|
||||
branch = lx-awg2-v005
|
||||
|
||||
+19
-23
@@ -32,7 +32,9 @@ single-use token into the standalone SPA the daemon serves on its own port
|
||||
|
||||
## Highlights
|
||||
|
||||
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks.
|
||||
- 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.
|
||||
@@ -49,29 +51,22 @@ Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATUR
|
||||
|
||||
## Install
|
||||
|
||||
Two signed feeds. Pick by the router's OpenWrt version. Verbatim commands and the
|
||||
manual `.ipk`/`.apk` install are in [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
**opkg (OpenWrt 24.10):**
|
||||
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/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 # -> shater-core -> shaterd
|
||||
```
|
||||
|
||||
**apk (OpenWrt / ImmortalWrt / BananaWRT 25.12+):**
|
||||
|
||||
```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
|
||||
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`.
|
||||
@@ -91,16 +86,17 @@ into `openwrt/shaterd/files/`. Details in
|
||||
| `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, feed/release scripts, CI |
|
||||
| `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 signed
|
||||
feeds: opkg (usign, key `5ac4b177689cb8e0`) and apk (EC key `shater-apk.pem`). A
|
||||
`vX.Y.Z` tag → versioned release; `workflow_dispatch` → rolling `latest`.
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
[](LICENSE)
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -128,42 +128,15 @@ data-plane, DNS-flow, apply-flow) — в [`docs-shater/ARCHITECTURE.md`](docs-sh
|
||||
|
||||
## Установка
|
||||
|
||||
shater поставляется двумя подписанными фидами. Выберите по версии OpenWrt на роутере:
|
||||
|
||||
- **OpenWrt 24.10** → фид **opkg** (`.ipk`, `Packages.gz`, ключ usign).
|
||||
- **OpenWrt / ImmortalWrt / BananaWRT 25.12+** → фид **apk** (`.apk`, `packages.adb`,
|
||||
EC-ключ).
|
||||
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` подтягивается автоматически как зависимость.
|
||||
|
||||
### Путь A — фид opkg (OpenWrt 24.10)
|
||||
|
||||
```sh
|
||||
# 1) доверяем ключу фида — ИМЯ файла обязано равняться отпечатку usign-ключа.
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
|
||||
# 2) добавляем фид (один URL обслуживает все арки).
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
|
||||
>> /etc/opkg/customfeeds.conf
|
||||
|
||||
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
|
||||
opkg update
|
||||
opkg install luci-app-shater # -> shater-core -> shaterd
|
||||
opkg install byedpi # опционально: ByeDPI desync-egress
|
||||
```
|
||||
|
||||
Обновление — **только наши пакеты, никогда голый `opkg upgrade`** (без аргументов
|
||||
он тянет обновления и на системные пакеты, это классический способ окирпичить
|
||||
роутер):
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg upgrade shaterd shater-core luci-app-shater byedpi
|
||||
```
|
||||
|
||||
### Путь B — фид apk (OpenWrt / ImmortalWrt / BananaWRT 25.12+)
|
||||
### Фид apk
|
||||
|
||||
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
|
||||
|
||||
@@ -198,13 +171,22 @@ apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||
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.
|
||||
|
||||
> Полные инструкции — раздельная установка из `.ipk`/`.apk` вручную, закрепление
|
||||
> версии (`vX.Y.Z` / `apk-vX.Y.Z-<arch>`), совместимость с BananaWRT
|
||||
> `25.12-mtk-vendor` — в [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
> Полные инструкции — ручная установка из `.apk`, фиксация версии
|
||||
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
|
||||
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
|
||||
|
||||
### Включение
|
||||
|
||||
@@ -258,8 +240,8 @@ arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), s
|
||||
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
|
||||
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
|
||||
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
|
||||
| `ci/` | Скрипты сборки фидов и релизов (SDK, usign/EC, Gitea API) |
|
||||
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанные фиды opkg/apk |
|
||||
| `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-рантайма |
|
||||
@@ -273,16 +255,17 @@ arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), s
|
||||
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
|
||||
публикует **подписанные фиды**:
|
||||
|
||||
- **opkg (24.10):** один комбинированный релиз, подписан usign-ключом (публичный
|
||||
`dist/shater-feed.pub`, отпечаток `5ac4b177689cb8e0`; секрет — в Gitea-secret
|
||||
`KEY_BUILD`).
|
||||
- **apk (25.12+):** параллельная линия, **по релизу на арку**, подписан EC-ключом
|
||||
(`dist/shater-apk.pem`; секрет — `KEY_APK`).
|
||||
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
|
||||
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
|
||||
Gitea-secret `KEY_APK`).
|
||||
|
||||
Триггеры: push тега **`vX.Y.Z`** → версионный релиз; `workflow_dispatch` →
|
||||
плавающий `latest`/`apk-latest-<arch>` (всегда свежий фид). Публикация — через
|
||||
Gitea API (`ci/gitea-release.sh`). Ключи **никогда не перегенерируются** — это
|
||||
инвалидировало бы доверие на всех развёрнутых роутерах.
|
||||
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
|
||||
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
|
||||
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
|
||||
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
|
||||
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
|
||||
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
|
||||
развёрнутых роутерах.
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +290,7 @@ build-тегами и живущий **ребейзом на каждый upstre
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
|
||||
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка обоих фидов (opkg/apk) |
|
||||
| [`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) | Фазовый план |
|
||||
|
||||
@@ -3,7 +3,33 @@
|
||||
| Поле | Значение |
|
||||
|------|----------|
|
||||
| Тип | B (bug) |
|
||||
| Статус | C (complete) |
|
||||
| Статус | C (complete) — guard **снят** (см. баннер ниже) |
|
||||
|
||||
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
|
||||
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
|
||||
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
|
||||
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
|
||||
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
|
||||
>
|
||||
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
|
||||
> возвращал управление). Android для shater не платформа и ей не станет —
|
||||
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
|
||||
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
|
||||
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
|
||||
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
|
||||
> на его применение.
|
||||
>
|
||||
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
|
||||
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
|
||||
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
|
||||
> отклонённая, а как «нода почему-то не работает».
|
||||
>
|
||||
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
|
||||
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
|
||||
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
|
||||
> тест краснел.
|
||||
>
|
||||
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
|
||||
|
||||
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
|
||||
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
|
||||
|
||||
@@ -45,30 +45,8 @@ type OutboundManager interface {
|
||||
Default() Outbound
|
||||
Remove(tag string) error
|
||||
Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, outboundType string, options any) error
|
||||
// lx:begin awg
|
||||
// ConsumersOf returns the tags of outbounds that depend on (detour through)
|
||||
// the given tag — the reverse of Dependencies(). Used by the selector guard to
|
||||
// walk up to AmneziaWG consumers when a group switches to a WireGuard member.
|
||||
ConsumersOf(tag string) []string
|
||||
// lx:end awg
|
||||
}
|
||||
|
||||
// lx:begin awg
|
||||
// AmneziaWGSuspendable is implemented by an AmneziaWG endpoint so the selector
|
||||
// guard can suspend it (bring its device down) when a group it detours through
|
||||
// switches to a WireGuard member — AmneziaWG inside a WireGuard tunnel hangs the
|
||||
// kernel on Android. The marker lives in adapter so protocol/group can act on it
|
||||
// without importing protocol/wireguard.
|
||||
type AmneziaWGSuspendable interface {
|
||||
// IsAmneziaWG reports whether this endpoint runs AmneziaWG (has AWG params).
|
||||
IsAmneziaWG() bool
|
||||
// SuspendAmneziaWG brings the device down so no junk handshake is sent. It is
|
||||
// idempotent and safe to call on a not-yet-started or already-suspended endpoint.
|
||||
SuspendAmneziaWG()
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
|
||||
// lx:begin idle-suspend
|
||||
// IdleSuspendable is implemented by a WG/AWG endpoint so the router's idle tick
|
||||
// (SPEC 020) can suspend it when it is idle and unreachable, without importing
|
||||
|
||||
@@ -208,21 +208,6 @@ func (m *Manager) Outbound(tag string) (adapter.Outbound, bool) {
|
||||
return m.endpoint.Get(tag)
|
||||
}
|
||||
|
||||
// lx:begin awg
|
||||
// ConsumersOf returns a copy of the tags that detour through tag (reverse of
|
||||
// Dependencies()), built from the dependByTag ledger populated at Create time.
|
||||
func (m *Manager) ConsumersOf(tag string) []string {
|
||||
m.access.RLock()
|
||||
defer m.access.RUnlock()
|
||||
consumers := m.dependByTag[tag]
|
||||
if len(consumers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), consumers...)
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
|
||||
func (m *Manager) Default() adapter.Outbound {
|
||||
m.access.RLock()
|
||||
defer m.access.RUnlock()
|
||||
|
||||
+19
-20
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (the 25.12
|
||||
# lane — additive next to ci/build-feed.sh, which stays the opkg/24.10 lane).
|
||||
# ci/build-feed-apk.sh — build the signed **apk** feed for ONE arch (25.12+;
|
||||
# the only packaging lane shater has — see docs-shater/DECISIONS.md D22).
|
||||
#
|
||||
# Usage: ci/build-feed-apk.sh <ARCH> <SDK_URL> <OUTDIR>
|
||||
# e.g. ci/build-feed-apk.sh aarch64_cortex-a53 \
|
||||
@@ -10,14 +10,14 @@
|
||||
# This is the per-arch entrypoint the Gitea workflow's `build-apk` job calls.
|
||||
# It runs on the CI RUNNER and:
|
||||
# 1. asserts the prebuilt shaterd binary for this arch was already staged by
|
||||
# scripts/build-shaterd.sh (same artifact-order contract as the opkg lane);
|
||||
# 2. drives a plain `debian:bookworm` container (workspace shared via
|
||||
# `--volumes-from`, same trick as ci/build-feed.sh) that downloads the
|
||||
# ImmortalWrt 25.12 apk-SDK tarball and runs ci/sdk-build-apk.sh in it:
|
||||
# compile the 4 packages as .apk, then `apk mkndx --sign` the per-arch
|
||||
# `packages.adb` index. Unlike the usign lane (index signed on the runner),
|
||||
# apk indexing NEEDS the SDK's host `apk` tool, so index+sign happen inside
|
||||
# the container.
|
||||
# scripts/build-shaterd.sh (the artifact-order contract);
|
||||
# 2. drives a plain `debian:bookworm` container (the job's workspace volume is
|
||||
# shared into it with `--volumes-from $(hostname)`; a bare `-v $PWD:...`
|
||||
# points at a host path that does not exist under act_runner's DinD) that
|
||||
# downloads the ImmortalWrt 25.12 apk-SDK tarball and runs
|
||||
# ci/sdk-build-apk.sh in it: compile the 4 packages as .apk, then
|
||||
# `apk mkndx --sign` the per-arch `packages.adb` index. Indexing NEEDS the
|
||||
# SDK's host `apk` tool, so index+sign happen inside the container.
|
||||
#
|
||||
# Why the ImmortalWrt SDK (not openwrt/sdk images): the 25.12 fleet runs
|
||||
# BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base (target mediatek/filogic,
|
||||
@@ -25,9 +25,9 @@
|
||||
# mediatek-filogic 25.12 tag — hence the official SDK tarball.
|
||||
#
|
||||
# Env:
|
||||
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret — the apk analog
|
||||
# of KEY_BUILD). If set, packages.adb carries an embedded signature
|
||||
# verifiable by dist/shater-apk.pem (routers: /etc/apk/keys/).
|
||||
# KEY_APK EC (prime256v1) PRIVATE key PEM (Gitea repo secret). If set,
|
||||
# packages.adb carries an embedded signature verifiable by
|
||||
# dist/shater-apk.pem (routers: /etc/apk/keys/).
|
||||
# If unset, an UNSIGNED index is produced (warning; not shippable —
|
||||
# apk signatures are effectively mandatory).
|
||||
set -eu
|
||||
@@ -56,10 +56,9 @@ fi
|
||||
chmod +x "$REPO"/ci/*.sh 2>/dev/null || true
|
||||
|
||||
# --- 0.4) package version from the git tag ------------------------------------
|
||||
# Same contract as the opkg lane (ci/build-feed.sh): 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.
|
||||
# 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
|
||||
@@ -73,7 +72,7 @@ echo "[apk-feed] package version: ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
|
||||
# SDK; PKG_HASH still verifies every file, so stale = re-downloaded.
|
||||
# apt/ debian:bookworm .deb archives for the host-deps install.
|
||||
# The nested container runs the build as an unprivileged user -> must be writable
|
||||
# (same reason as the chmod 0777 "$OUT" in ci/build-feed.sh).
|
||||
# (same reason as the chmod 0777 "$OUT" above).
|
||||
CACHE="$REPO/.cache"
|
||||
mkdir -p "$CACHE/sdk" "$CACHE/dl" "$CACHE/apt"
|
||||
chmod -R a+rwX "$CACHE/dl" "$CACHE/apt" 2>/dev/null || true
|
||||
@@ -96,8 +95,8 @@ sh "$REPO/ci/fetch-sdk.sh" "$SDK_URL" "$SDK_TAR"
|
||||
|
||||
# --- 1) SDK build + index + sign inside a debian container -------------------
|
||||
# `--volumes-from $(hostname)` shares THIS job container's workspace volume into
|
||||
# the nested container (see ci/build-feed.sh for why a bare -v does not work on
|
||||
# the act_runner DinD setup).
|
||||
# the nested container: a bare `-v $PWD:...` points at a host path that does not
|
||||
# exist under the act_runner DinD setup.
|
||||
echo "[apk-feed] SDK build arch=$ARCH (ImmortalWrt 25.12 apk-SDK)"
|
||||
docker pull -q debian:bookworm
|
||||
docker run --rm --volumes-from "$(hostname)" \
|
||||
|
||||
@@ -1,106 +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.4) package version from the git tag ------------------------------------
|
||||
# The workflow normally puts these in the job env (ci/version.sh --env >>
|
||||
# $GITHUB_ENV); recompute here when this script is run standalone so a manual
|
||||
# `ci/build-feed.sh ...` produces the same versions as CI. They are handed to the
|
||||
# SDK container below and read by openwrt/*/Makefile (bug B4 — versions used to
|
||||
# be hand-written literals that nobody bumped, so v0.2.2…v0.2.6 all shipped as
|
||||
# 0.2.0-r3 and no router could ever see an update).
|
||||
if [ -z "${SHATER_PKG_VERSION:-}" ] || [ -z "${SHATER_PKG_RELEASE:-}" ]; then
|
||||
eval "$(sh "$REPO/ci/version.sh" --env)"
|
||||
fi
|
||||
echo "[feed] package version: ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
|
||||
|
||||
# --- 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" \
|
||||
-e SHATER_PKG_VERSION="$SHATER_PKG_VERSION" \
|
||||
-e SHATER_PKG_RELEASE="$SHATER_PKG_RELEASE" \
|
||||
"openwrt/sdk:$SDK_TAG" \
|
||||
sh "$REPO/ci/sdk-build.sh"
|
||||
|
||||
# --- 2) index + sign the per-arch feed (usign, KEY_BUILD passed through) -------
|
||||
sh "$REPO/ci/install-usign.sh"
|
||||
KEY_BUILD="${KEY_BUILD:-}" bash "$REPO/ci/make-index.sh" "$OUT"
|
||||
|
||||
echo "[feed] done arch=$ARCH -> $OUT"
|
||||
ls -l "$OUT"
|
||||
+7
-10
@@ -2,24 +2,21 @@
|
||||
# ci/gen-apk-key.sh — generate the Shater **apk** feed signing keypair (25.12 lane).
|
||||
#
|
||||
# apk (OpenWrt/ImmortalWrt 25.12+) verifies package indexes with EC keys
|
||||
# (prime256v1 PEM), NOT usign — the existing usign identity
|
||||
# (dist/shater-feed.pub, fp 5ac4b177689cb8e0) keeps signing the opkg/24.10 feed
|
||||
# and is NOT touched by this script. This generates a SEPARATE, second identity:
|
||||
# (prime256v1 PEM). This is the ONLY feed identity shater has since the opkg
|
||||
# lane was removed (D22) — the old usign key is history, not a second lane.
|
||||
#
|
||||
# dist/shater-apk.key EC PRIVATE key. NEVER commit (dist/ is gitignored).
|
||||
# Paste its full PEM contents into the Gitea repo secret
|
||||
# KEY_APK (the apk analog of the usign secret KEY_BUILD).
|
||||
# Then delete the local file (or keep it in a password
|
||||
# manager as the offline backup — losing it means every
|
||||
# deployed router must re-trust a new key).
|
||||
# dist/shater-apk.pem PUBLIC key. Commit it next to shater-feed.pub:
|
||||
# KEY_APK. Then delete the local file (or keep it in a
|
||||
# password manager as the offline backup — losing it
|
||||
# means every deployed router must re-trust a new key).
|
||||
# dist/shater-apk.pem PUBLIC key. Commit it:
|
||||
# git add -f dist/shater-apk.pem
|
||||
# (-f because /dist/ is gitignored). Routers install it
|
||||
# as /etc/apk/keys/shater-apk.pem.
|
||||
#
|
||||
# Run ONCE. Refuses to overwrite: regenerating the key invalidates the trust of
|
||||
# every router that already installed shater-apk.pem (same rule as D7 for the
|
||||
# usign key).
|
||||
# every router that already installed shater-apk.pem (see D22).
|
||||
set -eu
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Make `usign` available on the CI runner so ci/make-index.sh can sign the opkg
|
||||
# feed index. The OpenWrt SDK ships usign, but the index/signing step runs on the
|
||||
# bare runner (outside the SDK container), so we build the tiny standalone tool
|
||||
# from source (no libubox — it is intentionally dependency-free so it can
|
||||
# bootstrap a build system). No-op if usign is already on PATH.
|
||||
#
|
||||
# Ported unchanged from Shater v0.1 (ci/install-usign.sh): usign is
|
||||
# format-agnostic and the signing story is identical for the v0.2 4-package feed.
|
||||
#
|
||||
# CI cache: a previously-built binary is reused from $USIGN_CACHE (default:
|
||||
# <repo>/.cache/tools — a workspace dir the workflow persists via actions/cache),
|
||||
# skipping the apt + cmake + clone + build (~1 min). After a fresh build the
|
||||
# binary is copied there so the NEXT run hits the cache. usign is a tiny static
|
||||
# helper with no versioned protocol — a stale cached binary cannot mis-sign.
|
||||
set -eu
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TOOLS="${USIGN_CACHE:-$REPO_ROOT/.cache/tools}"
|
||||
|
||||
# place <binary> — install onto PATH (system-wide if we can, else ~/bin)
|
||||
place() {
|
||||
local SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
|
||||
if $SUDO install -m0755 "$1" /usr/local/bin/usign 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
mkdir -p "$HOME/bin"
|
||||
install -m0755 "$1" "$HOME/bin/usign"
|
||||
echo "$HOME/bin" >> "${GITHUB_PATH:-/dev/null}"
|
||||
export PATH="$HOME/bin:$PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v usign >/dev/null 2>&1; then
|
||||
echo "[usign] already present: $(command -v usign)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -x "$TOOLS/usign" ]; then
|
||||
place "$TOOLS/usign"
|
||||
echo "[usign] restored from cache: $(command -v usign || echo "$HOME/bin/usign")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo"
|
||||
if ! command -v cmake >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq cmake gcc git
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
# Canonical source; fall back to the GitHub mirror if git.openwrt.org is flaky.
|
||||
git clone --depth 1 https://git.openwrt.org/project/usign.git "$tmp/usign" \
|
||||
|| git clone --depth 1 https://github.com/openwrt/usign.git "$tmp/usign"
|
||||
( cd "$tmp/usign" && cmake -DCMAKE_BUILD_TYPE=Release . >/dev/null && make >/dev/null )
|
||||
|
||||
place "$tmp/usign/usign"
|
||||
# seed the cache for the next run (best-effort)
|
||||
mkdir -p "$TOOLS" 2>/dev/null && install -m0755 "$tmp/usign/usign" "$TOOLS/usign" 2>/dev/null || true
|
||||
echo "[usign] built: $(command -v usign || echo "$HOME/bin/usign")"
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the opkg feed index (Packages + Packages.gz) with SHA256 for a dir of
|
||||
# .ipk files, then optionally usign-sign it if $KEY_BUILD (the Gitea repo secret)
|
||||
# is set and usign is present. Arg $1 = feed dir.
|
||||
#
|
||||
# Ported from Shater v0.1 (ci/make-index.sh), unchanged. It is package-count and
|
||||
# package-name agnostic: it indexes whatever .ipk are in the dir, so it serves
|
||||
# BOTH the per-arch feed built by ci/build-feed.sh AND the combined release feed
|
||||
# assembled in the release job (shaterd + byedpi per-arch, shater-core +
|
||||
# luci-app-shater = _all). opkg filters by Architecture at install time, so one
|
||||
# combined URL serves every device.
|
||||
#
|
||||
# Feed format: opkg `src/gz` (.ipk + text Packages index, usign signature).
|
||||
# OpenWrt 24.10 (our SDK) still uses opkg; apk arrives at 25.12. The committed
|
||||
# trust anchor dist/shater-feed.pub is a usign (Ed25519) key, matching this.
|
||||
set -euo pipefail
|
||||
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
|
||||
+3
-4
@@ -10,7 +10,6 @@
|
||||
# the target fleet (BananaWRT 25.12-mtk-vendor = ImmortalWrt 25.12 base, its
|
||||
# distfeeds even point at downloads.immortalwrt.org/releases/25.12-SNAPSHOT) is
|
||||
# ImmortalWrt — so we extract the official ImmortalWrt SDK tarball ourselves.
|
||||
# Same --volumes-from workspace-sharing pattern as ci/sdk-build.sh (opkg lane).
|
||||
#
|
||||
# The OpenWrt buildsystem refuses to run as root, so the SDK build itself runs
|
||||
# as an unprivileged `build` user created here.
|
||||
@@ -36,8 +35,8 @@ echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallba
|
||||
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
||||
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
||||
|
||||
# The prebuilt shaterd artifact must already be staged for this arch (same
|
||||
# contract as the opkg lane — scripts/build-shaterd.sh runs first).
|
||||
# The prebuilt shaterd artifact must already be staged for this arch
|
||||
# (artifact-order contract — scripts/build-shaterd.sh runs first).
|
||||
case "$ARCH" in
|
||||
x86_64) sfx=amd64 ;;
|
||||
aarch64_cortex-a53) sfx=arm64 ;;
|
||||
@@ -113,7 +112,7 @@ export HOME=/home/build
|
||||
cd "$SDKDIR"
|
||||
|
||||
# Register this repo's openwrt/ as a src-link feed named `shater` (absolute
|
||||
# path required) — identical to the opkg lane (ci/sdk-build.sh).
|
||||
# path required).
|
||||
cp -f feeds.conf.default feeds.conf
|
||||
grep -q '^src-link shater ' feeds.conf || echo "src-link shater $REPO/openwrt" >> feeds.conf
|
||||
|
||||
|
||||
-143
@@ -1,143 +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"
|
||||
# Package version, derived from the git tag by ci/version.sh and handed in by
|
||||
# ci/build-feed.sh. openwrt/{shaterd,shater-core,luci-app-shater}/Makefile read
|
||||
# these straight out of the environment ($(if $(SHATER_PKG_VERSION),...)); make
|
||||
# imports every environment variable as a variable, and it propagates through
|
||||
# `make package/<p>/compile`, the metadata dump and the sub-makes alike.
|
||||
# byedpi deliberately keeps its own upstream version (see its Makefile).
|
||||
echo "[sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
|
||||
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; }
|
||||
|
||||
# --- assert the tag-derived version actually reached the packages -------------
|
||||
# The whole point of B4 is that a WRONG-but-plausible version ships silently. The
|
||||
# env -> make hand-off has several layers (docker -e, make's env import, the
|
||||
# metadata dump), so verify the result instead of trusting it: every one of our
|
||||
# three tag-versioned packages must be named `<name>_<ver>-r<rel>_<arch>.ipk`.
|
||||
# byedpi is excluded on purpose — it keeps upstream ByeDPI's own version.
|
||||
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
|
||||
ls "$OUT/${p}_${want}_"*.ipk >/dev/null 2>&1 || {
|
||||
echo "[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 "[sdk] collected:"; ls -1 "$OUT" | sed 's/^/ /'
|
||||
exit 12; }
|
||||
done
|
||||
echo "[sdk] version check OK — our 3 packages are $want"
|
||||
fi
|
||||
|
||||
chmod -R a+rwX "$OUT" 2>/dev/null || true
|
||||
echo "[sdk] OK arch=$ARCH — collected $found of our .ipk:"
|
||||
ls -l "$OUT"
|
||||
+7
-9
@@ -6,9 +6,9 @@
|
||||
# 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 both opkg and apk offer 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.
|
||||
# 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.
|
||||
@@ -21,14 +21,12 @@
|
||||
# rolling `latest`)
|
||||
# no tag / no git at all -> PKG_VERSION=0.0.0 PKG_RELEASE=1 (+ warning)
|
||||
#
|
||||
# Both managers compare `<upstream>-r<rel>` the same way: the dotted upstream
|
||||
# part first (numerically, component by component), the `r<rel>` only as a
|
||||
# tie-break. Verified against the real tools, not from memory:
|
||||
# apk-tools 3.0.3 (`apk version -t`) and apk-tools 2.14.6:
|
||||
# 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
|
||||
# opkg 38eccbb1 from openwrt/rootfs:x86-64-24.10.4 (`opkg compare-versions`):
|
||||
# identical results (opkg implements the Debian algorithm).
|
||||
# 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
|
||||
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
|
||||
@@ -36,6 +51,9 @@ func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do
|
||||
// the wire.
|
||||
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
|
||||
t.Helper()
|
||||
// Every capture in this file funnels through here, so one guard covers the
|
||||
// whole suite and no future test can forget it.
|
||||
requireTCPDump(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
untrusted comment: shater feed signing key
|
||||
RWRaxLF3aJy44JbcxSFujtrFFEQ8lIsnTkd1K5TdjIhdlC2c0wa0fv4V
|
||||
+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 +
|
||||
ucode/rpcd ubus backend): Overview with a live Signal Path, Simple/Advanced
|
||||
toggle, quick-start wizard, Nodes/Subs/Rules/DNS/Live/Profiles/Settings pages.
|
||||
- **CI + signed opkg feed** on Gitea: builds per-arch, signs the feed index with
|
||||
usign, publishes a rolling `latest` Gitea release consumable as `src/gz`. **Feed
|
||||
signing key fingerprint `5ac4b177689cb8e0`**; public key `dist/shater-feed.pub`,
|
||||
secret in the Gitea repo secret `KEY_BUILD`.
|
||||
- **CI + a signed package feed** on Gitea: builds per-arch, signs the feed index,
|
||||
publishes a rolling `latest` Gitea release the router consumes as a feed.
|
||||
(v0.1 shipped `.ipk` signed with a usign key — that lane is retired, D22.)
|
||||
- Verified end-to-end on the VM: real LAN client proxied, DNS anti-leak, honest
|
||||
fail-closed, opkg install/upgrade from the signed feed.
|
||||
fail-closed, install/upgrade from the signed feed.
|
||||
|
||||
v0.1 is engine-locked to **xray-core**; its generator, share-link parser and
|
||||
`run.json` are xray-shaped.
|
||||
@@ -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
|
||||
from).
|
||||
- Until Phase 1 merges the engine in, `main` is the docs-first overlay seed you
|
||||
are reading now (LICENSE, README, `docs-shater/`, `dist/shater-feed.pub`).
|
||||
are reading now (LICENSE, README, `docs-shater/`, the feed signing key).
|
||||
|
||||
## 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)
|
||||
and the flexible **ruleset/list** model — though sing-box has its own share-link
|
||||
parser and config schema we now target.
|
||||
- **CI feed build + usign signing + Gitea release** (adapt to the single forked
|
||||
binary; keep key `5ac4b177689cb8e0`).
|
||||
- **CI feed build + index signing + Gitea release** (adapted to the single forked
|
||||
binary; the format is apk, signed with the EC key — D22).
|
||||
- The LuCI **design system** (the "instrument panel" identity) — reused for the
|
||||
mini-dashboard and as the panel's visual language.
|
||||
|
||||
@@ -122,9 +121,9 @@ filter/stats engine wired into sing-box's DNS.
|
||||
`https://github.com/SagerNet/sing-box`).
|
||||
- **CI:** Gitea Actions (act_runner + Docker). v0.1's workflow was removed from
|
||||
`main`; new CI is added when the v0.2 build exists.
|
||||
- **Feed signing:** usign key `5ac4b177689cb8e0`; secret in repo secret
|
||||
`KEY_BUILD`; public key `dist/shater-feed.pub` (kept so existing installs keep
|
||||
verifying).
|
||||
- **Feed signing:** EC (prime256v1) key for the apk index; secret in the repo
|
||||
secret `KEY_APK`; public key `dist/shater-apk.pem`, installed on routers as
|
||||
`/etc/apk/keys/shater-apk.pem`. Never regenerate it (D22).
|
||||
- **Test VM:** OpenWrt 24.10.3 x86_64 in Docker (`docker ps --filter
|
||||
name=openwrt-vm`). SSH via the ssh-manager MCP server `local_openwrt`
|
||||
(localhost:2222, root/openwrt). LuCI at `http://127.0.0.1:8080` (root/openwrt),
|
||||
|
||||
+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
|
||||
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`,
|
||||
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.
|
||||
|
||||
> **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
|
||||
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,
|
||||
@@ -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
|
||||
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.
|
||||
**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
|
||||
web server and generate never emits a `clash_api` service; the desktop/CLI
|
||||
`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
|
||||
apples), and group settings lose two footgun fields while Settings keeps the
|
||||
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/
|
||||
Host/QUIC sniffing.
|
||||
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
|
||||
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
|
||||
proto → target (outbound/selector/chain/direct/block) + egress.
|
||||
destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
|
||||
A rule names its **destination through a rule-set only** — a reusable named list
|
||||
(inline domains/CIDRs, a local or remote file, or a geosite/geoip category) that is
|
||||
compiled once into a `.srs` and shared by every rule that references it. Domain
|
||||
entries take `full:` (exact), `suffix:` / a leading dot (host + subdomains),
|
||||
`keyword:` (substring) and `regexp:`; a bare entry means host + subdomains.
|
||||
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
|
||||
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
|
||||
interface/tunnel (e.g. an AmneziaWG tunnel).
|
||||
@@ -36,6 +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
|
||||
is decided by in-engine rule-sets — the v0.1 dnsmasq→nftset population
|
||||
mechanism does not exist in v0.2 (see generate/dns.go).
|
||||
The hijack covers the queries a client sends **to the router itself** — the
|
||||
address DHCP hands out — because `globals.dns_intercept` is **ON by default**
|
||||
(D24). With it off, those queries go to dnsmasq and out to the ISP in the clear,
|
||||
so the well-behaved client leaks while the one that hard-codes 8.8.8.8 does not.
|
||||
`.lan` and the private PTR zones are preserved through dnsmasq either way. Two
|
||||
things the promise does NOT cover, both by design: while the engine is DOWN the
|
||||
holding plane hooks `forward` only, so dnsmasq still answers router-addressed
|
||||
:53 unfiltered (client traffic and DNS to external resolvers stay blocked); and
|
||||
with no `config resolver` at all there is no DNS plane to filter with — queries
|
||||
fall through to the system resolver and generate says so.
|
||||
- **[MVP]** Client DoT/DoH blocking (stop devices bypassing the filter).
|
||||
- **[MVP]** **Blocklists** with **flexible sources**: `inline` (type your own) /
|
||||
`file` / `url` (auto-update) / `geosite` category (only when geodata present).
|
||||
@@ -89,8 +103,8 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
|
||||
SIM uplink → different egress); backup/restore; i18n (EN + RU).
|
||||
|
||||
## Ops & distribution
|
||||
- **[MVP]** Single signed binary; signed opkg feed on Gitea (reuse key
|
||||
`5ac4b177689cb8e0`); one-line install; `opkg upgrade`.
|
||||
- **[MVP]** Single signed binary; signed apk feed on Gitea (EC key
|
||||
`dist/shater-apk.pem`); one-line install; named-package `apk upgrade`.
|
||||
- **[T1]** Upstream-rebase cadence (track sing-box-lx tags) with a smoke suite.
|
||||
- **[T2]** apk (OpenWrt 25.x) packaging; multi-router fleet management; REST/gRPC
|
||||
external API; Telegram bot.
|
||||
- **[T2]** Multi-router fleet management; REST/gRPC external API; Telegram bot.
|
||||
(apk packaging landed and is now the only lane — D22.)
|
||||
|
||||
+122
-99
@@ -1,7 +1,7 @@
|
||||
# Shater v0.2 — Build & Install
|
||||
|
||||
How to build the ship artifact (the SPA-embedded `shaterd` binary) and install
|
||||
the OpenWrt feed onto a router.
|
||||
the signed apk repo onto a router.
|
||||
|
||||
## 1. Build the `shaterd` binary
|
||||
|
||||
@@ -28,7 +28,7 @@ Arg / env:
|
||||
- `VERSION` — stamped into `constant.Version`. Resolution: positional arg →
|
||||
`$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 info shaterd` / `opkg status` report.
|
||||
the panel shows always matches what `apk list -I shaterd` reports.
|
||||
- `--fast` — skip `npm ci` when `panel/node_modules` already exists.
|
||||
- `UPX=/path/to/upx` — override the UPX binary (default `upx` on `PATH`). UPX is
|
||||
cross-arch, so one host packs both the amd64 and aarch64 ELFs. (Note: UPX also
|
||||
@@ -43,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 —
|
||||
they are release artifacts, not source.
|
||||
|
||||
Tag set (D9 — keep in sync with `docs-shater/DECISIONS.md`):
|
||||
Tag set (D9/D23) — defined in **one** place, `scripts/router-tags.sh`, which
|
||||
documents every tag and is sourced by the build:
|
||||
|
||||
```
|
||||
with_quic,with_wireguard,with_utls,
|
||||
with_gvisor,with_quic,with_wireguard,with_utls,
|
||||
badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
|
||||
```
|
||||
|
||||
We drop `with_purego,with_naive_outbound`: they pull cronet-go, which forces a
|
||||
glibc `PT_INTERP` even under `CGO_ENABLED=0`, making the binary unusable on musl.
|
||||
We drop `with_gvisor`: the shater data plane is tproxy/redirect and generate
|
||||
never emits a tun inbound, so the userspace gvisor netstack is unreachable code.
|
||||
We drop `with_clash_api`: the admin panel is shater's own web server and the
|
||||
generator never emits a `clash_api` service, so the Clash server is dead code.
|
||||
We drop `with_dhcp`: shater resolver types are `udp/tcp/doh/dot/local/fakeip`;
|
||||
a `dhcp://` DNS transport is never generated or registered.
|
||||
|
||||
`with_gvisor` was dropped in 2026-07 as "unreachable — we emit no tun inbound"
|
||||
and **put back on 2026-07-25**: gVisor is also the netstack of the WireGuard
|
||||
endpoint, so without it every `wg://`/`awg://` node died at apply time with
|
||||
*"gVisor is not included in this build"* while the panel still offered the
|
||||
feature. It costs ~2.8 MB raw / ~0.65 MB UPX per arch. Full story: `DECISIONS.md`
|
||||
D23.
|
||||
|
||||
### Changing the tag set
|
||||
|
||||
Run the guard — it is what stands between a size trim and a silently dead
|
||||
feature, and CI runs it before the artifact is built:
|
||||
|
||||
```sh
|
||||
scripts/check-router-tags.sh # from Windows/macOS it re-execs itself in golang:1.26
|
||||
```
|
||||
|
||||
It (1) fails if a feature declared in `FEATURES.md` lost a build tag it needs to
|
||||
run (`shater/buildtags`, no tags/OS/network required) and (2) constructs one node
|
||||
of every declared protocol through `box.New` **compiled with the shipped tag
|
||||
set** — nothing may be skipped in that run. Adding a protocol to
|
||||
`shater/parse`+`shater/generate` means adding a row to `buildtags.Features` and a
|
||||
probe case in `shater/generate/shipped_tags_linux_test.go`.
|
||||
|
||||
## 2. Packages
|
||||
|
||||
Four OpenWrt packages live under `openwrt/`:
|
||||
@@ -91,9 +113,9 @@ See `openwrt-package-build-ci` for SDK/feed mechanics.
|
||||
`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).
|
||||
Both package managers offer 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.
|
||||
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:
|
||||
|
||||
@@ -103,9 +125,8 @@ could not be updated through the normal path at all.
|
||||
| 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, and both managers agree on it (checked with
|
||||
`apk version -t` on apk-tools 3.0.3 and `opkg compare-versions` on opkg
|
||||
38eccbb1): the dotted part decides first, `-rN` only breaks ties — so
|
||||
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
|
||||
@@ -113,8 +134,9 @@ 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. Both lanes then **assert** the produced `.ipk`/`.apk` really carries it,
|
||||
so a lost variable fails the build instead of shipping a stale version.
|
||||
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
|
||||
@@ -124,21 +146,27 @@ when our packaging of it changes.
|
||||
|
||||
## 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
|
||||
# <ver> = the release version, e.g. 0.2.7-r1 (§2.1 — it comes from the git tag)
|
||||
opkg install shaterd_<ver>_<arch>.ipk # or: apk add shaterd (25.12+)
|
||||
opkg install shater-core_<ver>_all.ipk
|
||||
opkg install luci-app-shater_<ver>_all.ipk
|
||||
opkg install byedpi_0.17.3-r1_<arch>.ipk # optional: ByeDPI egress
|
||||
# --allow-untrusted: our member .apk are unsigned by design — trust lives in the
|
||||
# signed packages.adb index (§5), which a loose file install does not consult.
|
||||
apk add --allow-untrusted ./shaterd-<ver>.apk
|
||||
apk add --allow-untrusted ./shater-core-<ver>.apk
|
||||
apk add --allow-untrusted ./luci-app-shater-<ver>.apk
|
||||
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
|
||||
# add the feed (customfeeds.conf / apk repositories), then:
|
||||
opkg update && opkg install shater-core luci-app-shater # shaterd pulled in as a dep
|
||||
apk update && apk add luci-app-shater # -> shater-core -> shaterd
|
||||
```
|
||||
|
||||
## 4. Enable
|
||||
@@ -158,92 +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
|
||||
"Open panel" button mints a single-use token and hands the browser off to the panel.
|
||||
|
||||
## 5. Add the signed feed (recommended — then `opkg upgrade` just works)
|
||||
### What enabling does to DNS
|
||||
|
||||
CI (`.gitea/workflows/release.yml`) publishes every build as a **rolling `latest`
|
||||
Gitea release** that is itself a signed opkg `src/gz` feed: the release holds the
|
||||
`.ipk` for all arches, a `Packages`/`Packages.gz` index, a usign `Packages.sig`,
|
||||
and the public key `shater-feed.pub`. opkg filters by `Architecture`, so the **same
|
||||
two lines work on every device** (x86 testbed picks `x86_64 + all`; the BPI routers
|
||||
pick `aarch64_cortex-a53 + all`).
|
||||
From the first apply, **every** LAN plaintext `:53` goes into the engine — including
|
||||
the queries a client sends to the router's own address, which is what DHCP hands out.
|
||||
That is `globals.dns_intercept`, and it is **on by default** (D24); without it those
|
||||
queries reach dnsmasq and the ISP unfiltered, i.e. the client with default settings
|
||||
leaks while the one that hard-coded `8.8.8.8` does not. What follows from it:
|
||||
|
||||
> **Format:** OpenWrt 24.10 (our SDK) uses **opkg** (`.ipk`, `Packages.gz`, usign),
|
||||
> so the feed is `src/gz` and the trust anchor is the usign key
|
||||
> `dist/shater-feed.pub` (fingerprint **`5ac4b177689cb8e0`**). apk only replaces
|
||||
> opkg at OpenWrt **25.12** — see §6.
|
||||
- `.lan` and private reverse (PTR) lookups still go to dnsmasq — the engine gets a
|
||||
rule for those suffixes. If you renamed dnsmasq's domain away from `lan`, add a
|
||||
`config dns_rule` for the new suffix.
|
||||
- Configure at least one `config resolver`. With none, the engine has no resolver
|
||||
plane: intercepted queries fall through to the system resolver (dnsmasq → your
|
||||
ISP, in the clear), blocklists and per-device DNS rules are inert, and the apply
|
||||
says so in its warnings.
|
||||
- While the engine is DOWN, DNS is **not** blacked out: the fail-closed holding
|
||||
plane hooks `forward` only, so dnsmasq keeps answering router-addressed `:53`
|
||||
(unfiltered, plaintext) while client traffic and DNS to external resolvers stay
|
||||
blocked. "The tunnel is down" is not "DNS is private".
|
||||
|
||||
One-time setup on the router:
|
||||
To opt out, on the router:
|
||||
|
||||
```sh
|
||||
# 1) trust the feed key — the FILENAME must equal the usign key fingerprint.
|
||||
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
|
||||
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
|
||||
|
||||
# 2) add the feed (one URL serves every arch).
|
||||
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" \
|
||||
>> /etc/opkg/customfeeds.conf
|
||||
|
||||
# 3) refresh + install (shaterd is pulled in as a dependency).
|
||||
opkg update
|
||||
opkg install luci-app-shater # -> shater-core -> shaterd
|
||||
opkg install byedpi # optional: ByeDPI desync egress
|
||||
uci set shater.globals.dns_intercept=0
|
||||
uci commit shater
|
||||
shaterd apply
|
||||
```
|
||||
|
||||
With the key installed, opkg's default `check_signature 1` verifies the feed on
|
||||
every `opkg update`; no `--nocheck-signature` needed. A **tagged** release
|
||||
(`vX.Y.Z`) publishes the identical layout at
|
||||
`.../releases/download/vX.Y.Z` if you prefer to pin a version instead of tracking
|
||||
`latest`.
|
||||
Your `0` is kept: `/etc/config/shater` is a conffile (upgrades never replace it) and
|
||||
the daemon always writes the option back explicitly, so it is never re-enabled by a
|
||||
default.
|
||||
|
||||
### Updating
|
||||
## 5. The signed apk repo (the normal install path)
|
||||
|
||||
Name the packages. **Never run a bare `opkg upgrade`** — with no arguments it
|
||||
tries to upgrade *every* installed package from *every* configured feed, which on
|
||||
OpenWrt means base/system packages on the overlay and is a well-known way to
|
||||
brick a router.
|
||||
OpenWrt/ImmortalWrt **25.12** packages with Alpine's **apk**: `.apk` files, a
|
||||
binary `packages.adb` index, EC (prime256v1) keys in `/etc/apk/keys/`, and
|
||||
effectively mandatory signatures (unsigned needs `--allow-untrusted`). This is
|
||||
the only format shater publishes — the `.ipk`/opkg lane was removed in 2026-07
|
||||
(`DECISIONS.md` D22); every device we serve is on 25.12 with apk-tools 3.
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg upgrade shaterd shater-core luci-app-shater byedpi # only our own packages
|
||||
```
|
||||
|
||||
Drop `byedpi` from the list if you never installed it. An upgrade is offered only
|
||||
when the feed's `Version` differs from the installed one — that is exactly what
|
||||
bug B4 broke (v0.2.2…v0.2.6 all published as `0.2.0-r3`). Since then CI derives
|
||||
the version from the git tag on every build (§2.1), so there is nothing to bump
|
||||
by hand any more; check with:
|
||||
|
||||
```sh
|
||||
opkg list-installed | grep -E 'shaterd|shater-core|luci-app-shater|byedpi'
|
||||
```
|
||||
|
||||
## 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
|
||||
CI (`v*` tag push or `workflow_dispatch`) compiles the 4 packages through the
|
||||
official **ImmortalWrt 25.12 SDK** (tarballs from
|
||||
`downloads.immortalwrt.org/releases/25.12.1/targets/{x86/64,mediatek/filogic}/`)
|
||||
and publish **one release per arch** — rolling `apk-latest-x86_64` /
|
||||
`apk-latest-aarch64_cortex-a53`, or `apk-vX.Y.Z-<arch>` for a tagged version.
|
||||
Per-arch (unlike the combined opkg release) because apk filenames carry no
|
||||
architecture and packages are fetched relative to the `packages.adb` URL.
|
||||
and publishes **one release per arch**: the rolling `apk-latest-x86_64` /
|
||||
`apk-latest-aarch64_cortex-a53`, plus `apk-vX.Y.Z-<arch>` on a tag. Per-arch
|
||||
because apk filenames carry no architecture and packages are fetched *relative to
|
||||
the `packages.adb` URL*, so one flat multi-arch release would collide.
|
||||
|
||||
> **Key:** apk cannot use the usign key. The apk trust anchor is the separate EC
|
||||
> public key **`dist/shater-apk.pem`** (generated once by `ci/gen-apk-key.sh`;
|
||||
> private half lives ONLY in the Gitea secret **`KEY_APK`**, the apk analog of
|
||||
> `KEY_BUILD`). Never regenerate either key — that invalidates every deployed
|
||||
> router's trust. The usign identity `shater-feed.pub` keeps signing the
|
||||
> opkg/24.10 feed, untouched.
|
||||
> **Key:** the trust anchor is the EC public key **`dist/shater-apk.pem`**
|
||||
> (generated once by `ci/gen-apk-key.sh`; the private half lives ONLY in the
|
||||
> Gitea secret **`KEY_APK`**). Never regenerate it — that invalidates every
|
||||
> deployed router's trust.
|
||||
|
||||
One-time setup on a 25.12 router (BananaWRT `25.12-mtk-vendor` on the BPI-R3
|
||||
mini, BPI-R4 on 25.12, or the future 25.12 VM — `/etc/apk/arch` picks the right
|
||||
per-arch release automatically):
|
||||
### 5.1 Rolling or pinned — pick the repo URL deliberately
|
||||
|
||||
The repo line names an **index file**, and which one you name is the whole
|
||||
update policy:
|
||||
|
||||
| Repo line points at | Behaviour | Cost |
|
||||
|---|---|---|
|
||||
| `apk-latest-<arch>/packages.adb` (**rolling**) | Every release run REPLACES this release's assets, so `apk update && apk upgrade <our packages>` always sees the newest build. Install once, never touch the file again. | You get whatever CI published last; there is no per-router pin. |
|
||||
| `apk-vX.Y.Z-<arch>/packages.adb` (**pinned**) | The router stays on exactly that build. `apk update` will never offer a newer shater. | `/etc/apk/repositories.d/shater.list` must be edited **by hand on every upgrade**, on every router. |
|
||||
|
||||
`mini_router` is deliberately on a **pinned** URL — a considered choice, and the
|
||||
hand-edit per release is its price. Use rolling unless you specifically want to
|
||||
freeze a device.
|
||||
|
||||
> The rolling release used to go stale silently: publishing was an either/or, so
|
||||
> tag runs wrote only `apk-vX.Y.Z-<arch>` and `apk-latest-<arch>` was last
|
||||
> refreshed on 2026-07-24 at `0.2.0` while v0.2.9/v0.2.10 shipped. A router on
|
||||
> the rolling URL kept getting a successful `apk update` with nothing new. Fixed
|
||||
> 2026-07-25: `release-apk` writes the rolling pointer on **every** run and then
|
||||
> reads the release back over the Gitea API, asserting it holds our three
|
||||
> tag-versioned packages at exactly the version just built and **no** leftover
|
||||
> asset at another version (two versions of one package in one index would let
|
||||
> apk choose instead of us).
|
||||
|
||||
### 5.2 One-time setup on the router
|
||||
|
||||
BananaWRT `25.12-mtk-vendor` on the BPI-R3 mini, OpenWrt 25.12 on the BPI-R4, or
|
||||
the testbed VM — `/etc/apk/arch` picks the right per-arch release automatically:
|
||||
|
||||
```sh
|
||||
# 1) trust the apk feed key (any *.pem filename under /etc/apk/keys works).
|
||||
@@ -251,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"
|
||||
|
||||
# 2) add the repo — the line points at the packages.adb INDEX FILE itself.
|
||||
# (rolling; for a pinned router put apk-vX.Y.Z-$(cat /etc/apk/arch) here — §5.1)
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
|
||||
> /etc/apk/repositories.d/shater.list
|
||||
|
||||
@@ -260,7 +284,7 @@ apk add luci-app-shater # -> shater-core -> shaterd
|
||||
apk add byedpi # optional: ByeDPI desync egress
|
||||
```
|
||||
|
||||
### Updating
|
||||
### 5.3 Updating
|
||||
|
||||
**Never run a bare `apk upgrade`.** With no arguments apk reconciles *every*
|
||||
installed package against *every* configured repository at once; on a router
|
||||
@@ -286,9 +310,8 @@ 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). Pin a version instead of tracking
|
||||
rolling by pointing the repo line at
|
||||
`.../download/apk-vX.Y.Z-$(cat /etc/apk/arch)/packages.adb`.
|
||||
as `0.2.0-r3` and `apk update` offered nothing). Rolling vs pinned repo URL —
|
||||
§5.1.
|
||||
|
||||
### 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 Rule struct {
|
||||
Name string; Enabled bool; Order int
|
||||
Src []string; DstDomain,DstRuleset,DstIP []string; DstPort,Proto string
|
||||
Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
|
||||
Target string // chain:|group:|node:|direct|block
|
||||
Egress,Kill string
|
||||
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
|
||||
@@ -251,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).
|
||||
|
||||
### 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 subscription`: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.
|
||||
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
|
||||
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
|
||||
- `config chain`: name, list hop. `config egress`: name, type, interface, target.
|
||||
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url), url, path, format, update_interval, list entry.
|
||||
- `config rule`: name, enabled, order, list src/dst_domain/dst_ruleset/dst_ip, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end/tz.
|
||||
- `config 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, 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 resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ OpenWrt). Лицо репозитория и быстрый старт — в к
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [CONTEXT.md](CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, решения в кратце, testbed/инфра |
|
||||
| [INSTALL.md](INSTALL.md) | Сборка ship-артефакта (`shaterd`) и установка обоих фидов — opkg (24.10) и apk (25.12+) |
|
||||
| [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) | Фазовый план |
|
||||
|
||||
@@ -104,7 +104,7 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
|
||||
|
||||
## Phase 8 — Ship it ✅ DONE
|
||||
- Adapt CI to build/sign the single forked binary for both arches; publish the
|
||||
signed opkg feed (reuse key `5ac4b177689cb8e0`); install/upgrade docs.
|
||||
signed feed (apk since D22, EC key `dist/shater-apk.pem`); install/upgrade docs.
|
||||
- Set an upstream-rebase cadence (merge new sing-box-lx tags, run the smoke suite).
|
||||
|
||||
## Cross-cutting (every phase)
|
||||
|
||||
@@ -21,8 +21,8 @@ PKG_NAME:=byedpi
|
||||
# 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 every version comparator (apk-tools 3 and
|
||||
# opkg alike, verified) reads 0.2.7 < 0.17.3 — component-wise numerically, 2 < 17
|
||||
# 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.
|
||||
|
||||
@@ -23,6 +23,32 @@ config globals 'globals'
|
||||
option kill_switch 'closed'
|
||||
# There is no dns_mode option: routing is decided by in-engine rule-sets and
|
||||
# fake-IP is a resolver type (`config resolver` with type=fakeip + pool).
|
||||
#
|
||||
# Force ALL LAN plaintext DNS (:53) into the engine, INCLUDING queries the
|
||||
# client sends to the router itself (the address DHCP hands out). ON by
|
||||
# default: with it off, a client using the router as its resolver is answered
|
||||
# by dnsmasq and forwarded to the ISP in the clear — no blocklists, no
|
||||
# per-device DNS rules, no resolver detour — while a client that hard-codes
|
||||
# 8.8.8.8 IS intercepted. The obedient client leaked; the evader did not.
|
||||
#
|
||||
# Set to '0' to opt out (dnsmasq answers router-addressed :53 again). Your
|
||||
# explicit value is never overwritten: this file is a conffile, and the daemon
|
||||
# always writes the option back as '1'/'0'.
|
||||
#
|
||||
# .lan and the private reverse (PTR) zones keep working: with at least one
|
||||
# `config resolver` present the engine gets a synthetic server pointed at
|
||||
# dnsmasq on 127.0.0.1:53 plus a rule that sends those suffixes to it; with no
|
||||
# resolver at all the engine falls back to the system resolver, which is
|
||||
# dnsmasq too. If you changed dnsmasq's domain away from `lan`, add a
|
||||
# `config dns_rule` for it (only `lan` + RFC6303 reverse zones are built in).
|
||||
#
|
||||
# While the engine is DOWN the LAN is NOT left without DNS: the fail-closed
|
||||
# holding plane hooks `forward` only, so dnsmasq still answers router-addressed
|
||||
# :53 — unfiltered and in the clear, the documented trade-off (blocking it
|
||||
# would also cut the daemon's own name resolution and its chance to recover).
|
||||
# Queries aimed at an EXTERNAL resolver are dropped with the rest of the LAN's
|
||||
# forwarded traffic.
|
||||
option dns_intercept '1'
|
||||
option ipv6 '1'
|
||||
# Reserved fwmark base and routing-table base (do not overlap fw4/other apps).
|
||||
option fwmark_base '0x2000'
|
||||
@@ -62,7 +88,29 @@ config inbound
|
||||
# list node 'my-node'
|
||||
#
|
||||
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
|
||||
# Match on src / dst_domain / dst_ruleset / dst_ip / dst_port / proto.
|
||||
# Match on src / dst_ruleset / dst_port / proto. A rule with NO matcher at all is
|
||||
# the default route for everything that reached it.
|
||||
#
|
||||
# WHERE the traffic is going is named ONLY by dst_ruleset — one or more
|
||||
# `config ruleset` names; the rule matches when ANY of them matches. There is no
|
||||
# inline domain or address list on a rule (`dst_domain`/`dst_ip` were removed in
|
||||
# schema v2): a destination list is written once as a ruleset, compiled into a
|
||||
# .srs and shared by every rule that references it. `shaterd migrate` converts
|
||||
# older configs automatically, creating a `rule-<name>` ruleset per rule.
|
||||
#config ruleset
|
||||
# option name 'blocked-video'
|
||||
# option type 'domain'
|
||||
# option source 'inline'
|
||||
# list entry 'youtube.com'
|
||||
# list entry 'suffix:googlevideo.com'
|
||||
#
|
||||
#config rule
|
||||
# option name 'video-via-main'
|
||||
# option enabled '1'
|
||||
# option order '50'
|
||||
# list dst_ruleset 'blocked-video'
|
||||
# option target 'group:main'
|
||||
#
|
||||
#config rule
|
||||
# option name 'all-via-main'
|
||||
# option enabled '1'
|
||||
@@ -83,11 +131,16 @@ config inbound
|
||||
# option type 'direct'
|
||||
# option dpi 'fragment'
|
||||
#
|
||||
#config ruleset
|
||||
# option name 'youtube'
|
||||
# option source 'geosite'
|
||||
# list category 'youtube'
|
||||
#
|
||||
#config rule
|
||||
# option name 'youtube-fragment'
|
||||
# option enabled '1'
|
||||
# option order '50'
|
||||
# list dst_domain 'geosite:youtube'
|
||||
# list dst_ruleset 'youtube'
|
||||
# option target 'egress:frag'
|
||||
#
|
||||
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
|
||||
|
||||
@@ -152,7 +152,21 @@ start_service() {
|
||||
|
||||
# Bring the UCI schema forward before the daemon reads it (idempotent;
|
||||
# refuses a newer schema) so an upgraded package never applies a stale config.
|
||||
"$PROG" migrate >/dev/null 2>&1
|
||||
#
|
||||
# THE FAILURE IS 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
|
||||
# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
|
||||
|
||||
@@ -38,9 +38,9 @@ PKG_NAME:=shaterd
|
||||
# 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.sh / ci/build-feed-apk.sh export them into the SDK build env of
|
||||
# both lanes. Both lanes then ASSERT that the produced .ipk/.apk really carries
|
||||
# that version, so a lost env can never silently ship a stale one again.
|
||||
# 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)
|
||||
@@ -104,8 +104,8 @@ define Package/shaterd/install
|
||||
$(INSTALL_BIN) $(CURDIR)/files/$(SHATERD_BIN) $(1)/usr/bin/shaterd
|
||||
endef
|
||||
|
||||
# This package ships ONLY the binary — no init script — so opkg's default
|
||||
# postinst never touches the running service. On `opkg upgrade shaterd` the new
|
||||
# This package ships ONLY the binary — no init script — so the package manager's
|
||||
# postinst never touches the running service. On `apk upgrade shaterd` the new
|
||||
# ELF lands at /usr/bin/shaterd while the OLD image keeps running from its
|
||||
# unlinked inode: the upgrade silently has no effect until the next reboot, and
|
||||
# meanwhile the new CLI (`shaterd reconcile`, `status`, `mint-token` — invoked by
|
||||
|
||||
@@ -18,6 +18,32 @@ type URLTestOutboundOptions struct {
|
||||
// lx: SPEC 019 v2 — load-balancing.
|
||||
Mode string `json:"mode,omitempty"` // least_test (default) | round_robin
|
||||
Balancer *URLTestBalancerOptions `json:"balancer,omitempty"`
|
||||
// lx: health board §5.C — SelfCheck stands the group's OWN background
|
||||
// health-check up or down. nil/absent == true, so every existing config keeps
|
||||
// today's behaviour.
|
||||
//
|
||||
// Why this exists at all: a urltest group probes its members BY ITSELF — a
|
||||
// warm-up sweep at PostStart and a ticker for as long as traffic keeps
|
||||
// touching it — and it dials the members' outbounds DIRECTLY, from the
|
||||
// router, over whatever the default WAN route is. For a group that traffic
|
||||
// actually flows through, that is exactly right: the probe travels the same
|
||||
// path the connections do. But for a group NO routing rule reaches, that
|
||||
// same probe measures a path nothing uses — and it stores the result under
|
||||
// the members' BASE tags, which every health consumer then reads as "the
|
||||
// node's health". A node that is blocked on the direct WAN and perfectly
|
||||
// alive behind a tunnel therefore reads "dead" the moment such a group
|
||||
// probes it; the reading is not merely stale, it is FALSE, and it poisons
|
||||
// the shared board for everyone (selection, the panel, the observatory's
|
||||
// freshness gate). SelfCheck=false is how the control plane stands such a
|
||||
// group's own schedule down: the shater engine computes which groups the
|
||||
// applied rules actually reach (the observatory's used-set) and disables
|
||||
// the self-check on the rest, so the ONLY prober left is the observatory —
|
||||
// which probes along the real dial paths and nothing else.
|
||||
//
|
||||
// The flag suppresses only the group's own SCHEDULE (the PostStart warm-up
|
||||
// and the Touch ticker). An EXPLICIT CheckOutbounds/URLTest call — the
|
||||
// adapter interface a human or an API invokes on purpose — still works.
|
||||
SelfCheck *bool `json:"self_check,omitempty"`
|
||||
}
|
||||
|
||||
// URLTestBalancerOptions configures round_robin: a fixed-size pool of live nodes, lazily
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test src/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -262,6 +262,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- commit-confirm band (every page except Apply, which has the full panel) ----
|
||||
Same plate as the protection banner so the two read as one family; the seconds
|
||||
are the loud element because they are the only thing that is running out. */
|
||||
.cfm-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1.5);
|
||||
margin-top: calc(var(--u, 8px) * 2);
|
||||
padding: 10px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--groove));
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 10%, var(--raised)), var(--raised));
|
||||
box-shadow: 0 1px 0 var(--edge) inset;
|
||||
}
|
||||
.cfm-band-count {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--amber);
|
||||
}
|
||||
.cfm-band-num {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.cfm-band-unit {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.cfm-band-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cfm-band-headline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cfm-band-detail {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--dim);
|
||||
max-width: 76ch;
|
||||
}
|
||||
.cfm-band-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u, 8px) * 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cfm-band-link {
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--raised);
|
||||
}
|
||||
.cfm-band-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cfm-band {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfm-band-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- last-apply findings (Overview) ----
|
||||
Severity carries the colour; the accent is reserved for interactive controls. */
|
||||
.findings {
|
||||
@@ -405,3 +490,73 @@
|
||||
color: var(--dim);
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
/* ---- inline rename (shared) ----
|
||||
The pencil-in-the-row interaction: click the ✎ beside a name, type over it,
|
||||
Enter commits / Esc cancels / blur commits. Lifted out of Devices.css when
|
||||
Nodes grew the same affordance — one interaction, one set of rules, so the two
|
||||
pages can never drift apart. `--locked` is the same control with the action
|
||||
withheld: it stays visible and focusable-looking so a missing rename reads as
|
||||
a stated rule, not a dead button. */
|
||||
.inline-rename {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.inline-rename:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.inline-rename:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inline-rename:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
/* Withheld, not broken: keep the glyph readable and let the cursor say "there is
|
||||
a reason" rather than dimming it into invisibility. */
|
||||
.inline-rename--locked {
|
||||
opacity: 0.75;
|
||||
cursor: help;
|
||||
}
|
||||
.inline-rename--locked:hover {
|
||||
color: var(--dim);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.inline-rename-input {
|
||||
min-width: 0;
|
||||
max-width: 24ch;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--sink);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 1px 2px var(--shadow) inset;
|
||||
}
|
||||
.inline-rename-input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.inline-rename-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
+87
-6
@@ -1,12 +1,13 @@
|
||||
import './App.css'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Faceplate, FaceplateHeader, Led, Module } from './components'
|
||||
import { Button, Faceplate, FaceplateHeader, Led, Module } from './components'
|
||||
import type { LedVariant } from './components'
|
||||
import { ApiError, MOCK, getStatus } from './api'
|
||||
import { ApiError, MOCK, confirm as apiConfirm, getStatus } from './api'
|
||||
import type { Status } from './api'
|
||||
import { usePendingConfirm } from './pendingConfirm'
|
||||
import { bootstrapSession } from './session'
|
||||
import { ROUTES, navigate, useRoute } from './router'
|
||||
import { protectionState } from './planeState'
|
||||
import { engineState, protectionState } from './planeState'
|
||||
import type { Route } from './router'
|
||||
import { Overview, Placeholder, Nodes, Routing, Apply, DNS, Devices, Targets, Settings, Profiles, Insights, Networks } from './pages'
|
||||
|
||||
@@ -100,11 +101,75 @@ export function App() {
|
||||
>
|
||||
<Nav route={route} />
|
||||
<PlaneBanner status={status} route={route} />
|
||||
<ConfirmBand route={route} onChanged={() => void refreshStatus()} />
|
||||
<Page route={route} status={status} onStatusChange={() => void refreshStatus()} />
|
||||
</Faceplate>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The commit-confirm countdown, on every page.
|
||||
*
|
||||
* The daemon arms an auto-rollback on EVERY apply, but only the Apply page ever
|
||||
* said so: press Apply on Routing, read "Applied", walk away, and the router
|
||||
* reverts a minute later with nothing on screen having mentioned it. This band
|
||||
* carries that deadline — and the button that stops it — to wherever the operator
|
||||
* actually is.
|
||||
*
|
||||
* Suppressed on Apply, which renders the full control room for the same window
|
||||
* (and reads the same record, so a reload no longer loses the countdown there
|
||||
* either).
|
||||
*/
|
||||
function ConfirmBand({ route, onChanged }: { route: Route; onChanged: () => void }) {
|
||||
const armed = usePendingConfirm()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Keeping the config is the only action offered here; rolling back early is a
|
||||
// deliberate act with its own before/after readout, and that lives on Apply.
|
||||
const keep = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await apiConfirm()
|
||||
if (r.error) setError(r.error)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'request failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
onChanged()
|
||||
}
|
||||
}, [onChanged])
|
||||
|
||||
if (!armed || route === 'apply') return null
|
||||
|
||||
return (
|
||||
<div className="cfm-band" role="alert">
|
||||
<Led variant="amber" pulse />
|
||||
<div className="cfm-band-count" role="timer" aria-label={`${armed.remaining} seconds until auto-rollback`}>
|
||||
<span className="cfm-band-num">{armed.remaining}</span>
|
||||
<span className="cfm-band-unit">s</span>
|
||||
</div>
|
||||
<div className="cfm-band-copy">
|
||||
<span className="cfm-band-headline">This config is live but not kept</span>
|
||||
<span className="cfm-band-detail">
|
||||
{error
|
||||
? `Couldn’t keep it — ${error}. Try again, or open Apply.`
|
||||
: 'Every apply arms an auto-rollback. Keep this config before the timer runs out, or the router reverts to the last-good one.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cfm-band-actions">
|
||||
<Button variant="primary" onClick={() => void keep()} disabled={busy}>
|
||||
{busy ? 'Keeping…' : 'Keep this config'}
|
||||
</Button>
|
||||
<a className="cfm-band-link" href="#/apply" onClick={() => navigate('apply')}>
|
||||
Apply page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -225,14 +290,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(
|
||||
phase: Phase,
|
||||
status: Status | null,
|
||||
): { label: string; variant: LedVariant; pulse?: boolean } {
|
||||
if (phase === 'loading' || !status) return { label: 'Linking', variant: 'off' }
|
||||
if (status.running && status.active) return { label: 'Online', variant: 'on', pulse: true }
|
||||
if (status.running) return { label: 'Standby', variant: 'amber' }
|
||||
return { label: 'Offline', variant: 'crit' }
|
||||
switch (engineState(status)) {
|
||||
case 'down':
|
||||
return { label: 'Engine down', variant: 'crit' }
|
||||
case 'up':
|
||||
return status.active
|
||||
? { label: 'Online', variant: 'on', pulse: true }
|
||||
: { label: 'Standby', variant: 'amber' }
|
||||
default:
|
||||
return { label: 'Unknown', variant: 'off' }
|
||||
}
|
||||
}
|
||||
|
||||
function UnauthPlate() {
|
||||
|
||||
+262
-26
@@ -15,6 +15,7 @@
|
||||
// reachable instead via the Vite proxy in vite.config.ts (no flag ⇒ real fetch).
|
||||
|
||||
import * as mock from './mock'
|
||||
import { armPendingConfirm, clearPendingConfirm, noteConfirmTimeout } from './pendingConfirm'
|
||||
|
||||
// --- error type -------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +52,38 @@ export class ApiError extends Error {
|
||||
*/
|
||||
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
|
||||
* rather than refusing the whole config (the alternative was taking the network
|
||||
@@ -88,6 +121,10 @@ export interface Status {
|
||||
// How much of the data plane is installed. Absent on older daemons ⇒ unknown,
|
||||
// in which case the UI shows nothing rather than guessing "full".
|
||||
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);
|
||||
// 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".
|
||||
@@ -354,19 +391,142 @@ export interface GroupHealth {
|
||||
* 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
|
||||
* §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
|
||||
* "unused" instead of an exit-test readout. A chain has no membership counters —
|
||||
* it is a fixed path, and its end-to-end health is the exit test's job. */
|
||||
* observatory's plan, so nothing probes it and the Targets card says so instead
|
||||
* of rendering a health reading. A chain has no membership counters of its own —
|
||||
* it is a fixed path, and its health lives on its {@link ChainHopHealth} hops. */
|
||||
export interface ChainHealth {
|
||||
name: string
|
||||
/** 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 probing and its end-to-end health stays untested. That is an
|
||||
* "unused" note about the ROUTING CONFIG, never a health problem. */
|
||||
* background probing and its health stays untested. That is an "unused" note
|
||||
* about the ROUTING CONFIG, never a health problem. */
|
||||
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 {
|
||||
@@ -809,9 +969,17 @@ export interface Rule {
|
||||
Enabled: boolean
|
||||
Order: number
|
||||
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
|
||||
DstIP?: string[] | null
|
||||
DstPort?: string
|
||||
/**
|
||||
* Narrow the rule to one transport or one sniffed application protocol. A
|
||||
@@ -1009,8 +1177,13 @@ export function getStatus(): Promise<Status> {
|
||||
return MOCK ? mock.getStatus() : req<Status>('api/status')
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<Model> {
|
||||
return MOCK ? mock.getConfig() : req<Model>('api/config')
|
||||
export async function getConfig(): Promise<Model> {
|
||||
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 }> {
|
||||
@@ -1019,16 +1192,32 @@ export function putConfig(m: Model): Promise<{ ok: boolean; applied: boolean }>
|
||||
: 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> {
|
||||
return MOCK ? mock.confirm() : req<ApplyResult>('api/confirm', { method: 'POST' })
|
||||
export async function confirm(): Promise<ApplyResult> {
|
||||
const r = await (MOCK ? mock.confirm() : req<ApplyResult>('api/confirm', { method: 'POST' }))
|
||||
if (!r.error) clearPendingConfirm()
|
||||
return r
|
||||
}
|
||||
|
||||
export function rollback(): Promise<ApplyResult> {
|
||||
return MOCK ? mock.rollback() : req<ApplyResult>('api/rollback', { method: 'POST' })
|
||||
export async function rollback(): Promise<ApplyResult> {
|
||||
const r = await (MOCK ? mock.rollback() : req<ApplyResult>('api/rollback', { method: 'POST' }))
|
||||
if (!r.error) clearPendingConfirm()
|
||||
return r
|
||||
}
|
||||
|
||||
export function getStats(): Promise<Stats> {
|
||||
@@ -1218,6 +1407,17 @@ export interface RuleReach {
|
||||
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
|
||||
@@ -1377,8 +1577,21 @@ export function getGroupsHealth(
|
||||
}
|
||||
|
||||
/**
|
||||
* One group's (or chain's) last test: which member the balancer picked, how fast
|
||||
* it answered, and what the internet saw as the source address.
|
||||
* What the OBSERVATORY measured for one group or chain — not a dial the panel
|
||||
* 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,
|
||||
* not a partial failure: the delay was measured but the exit address could not be
|
||||
@@ -1387,10 +1600,23 @@ export function getGroupsHealth(
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* field is meaningless. `tested_unix` is the router's clock, in seconds.
|
||||
* `ok:false` ⇒ there is no usable measurement and `error` carries the human
|
||||
* 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 {
|
||||
group: string // group name — or a chain name for a chain row
|
||||
@@ -1407,7 +1633,11 @@ export interface GroupTestResult {
|
||||
* 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
|
||||
* 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 {
|
||||
running: boolean
|
||||
@@ -1440,10 +1670,16 @@ export interface GroupTestStart {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/groups/test — measure a target's delay and exit address. Pass a
|
||||
* group or chain name to test one; pass nothing (or '') to test every group
|
||||
* and every chain. Singleton: a second call while a run is in flight resolves
|
||||
* to `{started:false, reason:'already running'}` rather than failing.
|
||||
* POST /api/groups/test — ask the observatory for an out-of-turn refresh pass,
|
||||
* then report what it measured. Pass a group or chain name to refresh one; pass
|
||||
* nothing (or '') for every group and every chain.
|
||||
*
|
||||
* 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> {
|
||||
return MOCK
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange. */
|
||||
/* Buttons — mono, uppercase. .btn is ghost; .btn.primary is solid orange;
|
||||
* .btn.crit is the solid-red destructive commit. */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 7px 12px;
|
||||
@@ -30,3 +31,16 @@
|
||||
color: #fff;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Destructive commit. The fill is crit stepped a little toward black so white
|
||||
* label text clears 4.5:1 in BOTH themes — the raw --crit is bright enough in
|
||||
* dark mode to fall under it. Red here always means "this removes something". */
|
||||
.btn.crit {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--crit) 88%, #000);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.crit:hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import './Button.css'
|
||||
import { forwardRef } from 'react'
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** `primary` is the solid-orange call to action; `ghost` is the default. */
|
||||
variant?: 'ghost' | 'primary'
|
||||
/**
|
||||
* `primary` is the solid-orange call to action; `crit` is the solid-red
|
||||
* destructive commit (delete, remove) — semantic crit, never the accent;
|
||||
* `ghost` is the default.
|
||||
*/
|
||||
variant?: 'ghost' | 'primary' | 'crit'
|
||||
}
|
||||
|
||||
export function Button({ variant = 'ghost', className, type, ...rest }: ButtonProps) {
|
||||
/** Ref-forwarding so a dialog can park focus on a specific button. */
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'ghost', className, type, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? 'button'}
|
||||
className={['btn', variant === 'primary' ? 'primary' : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
className={['btn', variant === 'ghost' ? '' : variant, className].filter(Boolean).join(' ')}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The panel's wall clock, in the SAME timezone as every timestamp under it.
|
||||
*
|
||||
* It used to read `getUTCHours()` and print "UTC", while `format.ts` renders every
|
||||
* log line, connection event and date through `toLocaleTimeString` — i.e. the
|
||||
* browser's zone. In Moscow that put two clocks three hours apart on one plate,
|
||||
* and the header was the one nobody could reconcile: the router's "started" time
|
||||
* read later than the current time while the uptime said it had been up for hours.
|
||||
*
|
||||
* So the clock follows the rest of the panel — local, and it SAYS which offset
|
||||
* that is, because a bare "12:41:07" beside a router in another zone is the
|
||||
* ambiguity that started this. The zone label is the browser's UTC offset, not an
|
||||
* abbreviation: "MSK"/"CEST" are not derivable everywhere, an offset always is.
|
||||
*
|
||||
* This is the BROWSER's clock, not the router's — the appliance has no RTC. Every
|
||||
* router-sourced instant in the panel is converted to this clock before it is
|
||||
* shown, which is what makes one label at the top honest for the whole page.
|
||||
*/
|
||||
function zoneLabel(d: Date): string {
|
||||
// getTimezoneOffset() is minutes WEST of UTC, so the sign is inverted.
|
||||
const min = -d.getTimezoneOffset()
|
||||
if (min === 0) return 'UTC'
|
||||
const sign = min < 0 ? '−' : '+'
|
||||
const a = Math.abs(min)
|
||||
const h = Math.floor(a / 60)
|
||||
const m = a % 60
|
||||
return `UTC${sign}${h}${m ? `:${String(m).padStart(2, '0')}` : ''}`
|
||||
}
|
||||
|
||||
function format(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} UTC`
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${zoneLabel(d)}`
|
||||
}
|
||||
|
||||
/** Live UTC readout, tabular digits, ticking once a second. */
|
||||
/** The full zone name, for the title — "Europe/Moscow" says more than "+3" does. */
|
||||
function zoneName(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Live local readout, tabular digits, ticking once a second. */
|
||||
export function Clock({ className }: { className?: string }) {
|
||||
const [now, setNow] = useState(() => format(new Date()))
|
||||
|
||||
@@ -14,5 +52,13 @@ export function Clock({ className }: { className?: string }) {
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return <span className={['clock', className].filter(Boolean).join(' ')}>{now}</span>
|
||||
const zone = zoneName()
|
||||
return (
|
||||
<span
|
||||
className={['clock', className].filter(Boolean).join(' ')}
|
||||
title={zone ? `Your device's clock — ${zone}. Every time in the panel is shown in this zone.` : undefined}
|
||||
>
|
||||
{now}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* <ConfirmDialog> — the safety interlock plate.
|
||||
*
|
||||
* This replaces the browser's native confirm dialog, which a browser can mute for
|
||||
* good ("prevent this page from creating additional dialogs"): after that it
|
||||
* returns false with no dialog at all, so every delete button in the panel goes
|
||||
* dead and silent with no way to recover short of a page reload. We draw the
|
||||
* plate ourselves, so nothing can suppress it.
|
||||
*
|
||||
* Faceplate language: a small rack module lifted off the panel — corner screws
|
||||
* (reused from Faceplate.css), an engraved label, a groove above the actions.
|
||||
* Destructive intent is carried by the crit semantic, never by the orange accent:
|
||||
* accent means "this control is active", crit means "this destroys something".
|
||||
*/
|
||||
|
||||
/* The veil is a fixed dark wash in both themes — a light scrim over a light
|
||||
* panel would not read as "the panel is out of reach". Follows the tokens.css
|
||||
* pattern: light base, dark via media query, data-theme overrides win both ways. */
|
||||
.cfm-scrim {
|
||||
--cfm-veil: rgba(33, 29, 21, 0.52);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cfm-scrim {
|
||||
--cfm-veil: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
}
|
||||
:root[data-theme='light'] .cfm-scrim {
|
||||
--cfm-veil: rgba(33, 29, 21, 0.52);
|
||||
}
|
||||
:root[data-theme='dark'] .cfm-scrim {
|
||||
--cfm-veil: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
.cfm-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Short viewports: the plate scrolls with the veil instead of being clipped. */
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--u, 8px) * 2);
|
||||
background: var(--cfm-veil);
|
||||
animation: cfm-veil-in 0.14s ease-out;
|
||||
}
|
||||
|
||||
.cfm-card {
|
||||
position: relative;
|
||||
width: min(32rem, 100%);
|
||||
max-height: calc(100dvh - var(--u, 8px) * 4);
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--u, 8px) * 3.25);
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 12px;
|
||||
/* same brushed plate as <Faceplate>, one step brighter so it reads as lifted */
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0 2px,
|
||||
color-mix(in srgb, var(--edge) 30%, transparent) 2px 3px
|
||||
),
|
||||
linear-gradient(180deg, var(--raised), color-mix(in srgb, var(--raised) 82%, var(--panel)));
|
||||
box-shadow:
|
||||
0 1px 0 var(--edge) inset,
|
||||
0 30px 60px -22px var(--shadow),
|
||||
0 4px 12px var(--shadow);
|
||||
animation: cfm-card-in 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
.cfm-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* `still` is set from usePrefersReducedMotion — the plate appears, it never
|
||||
* travels. (The global reduced-motion rule in tokens.css also neutralises the
|
||||
* duration; this keeps the intent explicit at the component.) */
|
||||
.cfm-scrim.still,
|
||||
.cfm-scrim.still .cfm-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes cfm-veil-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes cfm-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- header: engraved label + state LED ---- */
|
||||
.cfm-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: calc(var(--u, 8px) * 1.5);
|
||||
}
|
||||
.cfm-label {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: var(--track-label-wide, 0.24em);
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---- copy ---- */
|
||||
.cfm-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
/* names can be long and unbroken — wrap rather than push the plate wide */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cfm-body {
|
||||
margin: calc(var(--u, 8px) * 1.5) 0 0;
|
||||
max-width: 52ch;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- action bar ---- */
|
||||
.cfm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(var(--u, 8px));
|
||||
margin-top: calc(var(--u, 8px) * 3);
|
||||
padding-top: calc(var(--u, 8px) * 2);
|
||||
border-top: 1px solid var(--groove);
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.cfm-card {
|
||||
padding: calc(var(--u, 8px) * 2.5);
|
||||
}
|
||||
.cfm-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfm-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
/* screws crowd a small plate — drop them rather than collide with the copy */
|
||||
.cfm-card > .screw {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import './ConfirmDialog.css'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from './Button'
|
||||
import { Led } from './Led'
|
||||
import { usePrefersReducedMotion } from './usePrefersReducedMotion'
|
||||
|
||||
/**
|
||||
* How the confirming button is painted.
|
||||
*
|
||||
* crit — the action destroys something. Semantic crit, never the accent.
|
||||
* neutral — the action is a normal commit the operator should read first
|
||||
* (a warning before saving); the accent's call-to-action is correct.
|
||||
*/
|
||||
export type ConfirmTone = 'crit' | 'neutral'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** Engraved eyebrow, e.g. "DELETE RULE". Names the operation, not the object. */
|
||||
label?: string
|
||||
/** The question. One line, ends in "?". */
|
||||
title: string
|
||||
/** The consequence — what changes on the router if this goes through. */
|
||||
body?: ReactNode
|
||||
/** Verb on the confirming button. Defaults to "Delete". */
|
||||
confirmLabel?: string
|
||||
/** Verb on the dismissing button. Defaults to "Cancel". */
|
||||
cancelLabel?: string
|
||||
/** Defaults to `crit` — the overwhelmingly common case is a delete. */
|
||||
tone?: ConfirmTone
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps extends ConfirmOptions {
|
||||
open: boolean
|
||||
/** Called exactly once per dialog, with the operator's answer. */
|
||||
onResolve: (confirmed: boolean) => void
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* The modal plate itself. Normally reached through `useConfirm()`; exported so a
|
||||
* page that wants to own the open state can render it directly.
|
||||
*
|
||||
* Keyboard contract:
|
||||
* - focus moves to Cancel on open, so a reflex Enter dismisses, never deletes;
|
||||
* - Tab / Shift+Tab cycle inside the plate and cannot reach the page behind it;
|
||||
* - Esc answers "no";
|
||||
* - on close, focus returns to whatever opened the dialog.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onResolve,
|
||||
label,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
tone = 'crit',
|
||||
}: ConfirmDialogProps) {
|
||||
const titleId = useId()
|
||||
const bodyId = useId()
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const openerRef = useRef<HTMLElement | null>(null)
|
||||
const reduced = usePrefersReducedMotion()
|
||||
|
||||
// Take the page out of the tab order, park focus on Cancel, and hand focus
|
||||
// back to the opener when the plate goes away.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const opener = document.activeElement
|
||||
openerRef.current = opener instanceof HTMLElement ? opener : null
|
||||
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
// Cancel is the resting place: an Enter or a Space meant for the page lands
|
||||
// on "no". The destructive button is one Tab away, deliberately.
|
||||
;(cancelRef.current ?? cardRef.current)?.focus()
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow
|
||||
const back = openerRef.current
|
||||
openerRef.current = null
|
||||
if (back && document.contains(back)) back.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Esc answers no; Tab is caged. Capture phase so a page-level key handler
|
||||
// never sees keys aimed at the dialog.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onResolve(false)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const card = cardRef.current
|
||||
if (!card) return
|
||||
const list = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
if (list.length === 0) {
|
||||
e.preventDefault()
|
||||
card.focus()
|
||||
return
|
||||
}
|
||||
const first = list[0]
|
||||
const last = list[list.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : first).focus()
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [open, onResolve])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={['cfm-scrim', reduced ? 'still' : ''].filter(Boolean).join(' ')}
|
||||
// A click on the field around the plate means "not now". Mousedown (not
|
||||
// click) so a text selection dragged out of the plate can't dismiss it.
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onResolve(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`cfm-card tone-${tone}`}
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={body != null ? bodyId : undefined}
|
||||
>
|
||||
<i className="screw tl" aria-hidden="true" />
|
||||
<i className="screw tr" aria-hidden="true" />
|
||||
<i className="screw bl" aria-hidden="true" />
|
||||
<i className="screw br" aria-hidden="true" />
|
||||
|
||||
{/* Lamp first, then the engraved label — the way a real panel reads, and
|
||||
it keeps the LED off the corner screw. */}
|
||||
<div className="cfm-hd">
|
||||
<Led variant={tone === 'crit' ? 'crit' : 'amber'} />
|
||||
<span className="cfm-label">{label ?? (tone === 'crit' ? 'Confirm delete' : 'Confirm')}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="cfm-title" id={titleId}>
|
||||
{title}
|
||||
</h2>
|
||||
{body != null && (
|
||||
<p className="cfm-body" id={bodyId}>
|
||||
{body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="cfm-actions">
|
||||
<Button ref={cancelRef} onClick={() => onResolve(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={tone === 'crit' ? 'crit' : 'primary'} onClick={() => onResolve(true)}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- provider + hook --------------------------------------------------------
|
||||
|
||||
interface Request extends ConfirmOptions {
|
||||
id: number
|
||||
resolve: (v: boolean) => void
|
||||
}
|
||||
|
||||
const ConfirmCtx = createContext<((o: ConfirmOptions) => Promise<boolean>) | null>(null)
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Everything below can then ask a question and await
|
||||
* the answer.
|
||||
*/
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [req, setReq] = useState<Request | null>(null)
|
||||
const pending = useRef<Request | null>(null)
|
||||
const seq = useRef(0)
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOptions) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
// A second question while one is open answers the first with "no" rather
|
||||
// than leaving its promise — and its caller — hanging forever.
|
||||
pending.current?.resolve(false)
|
||||
seq.current += 1
|
||||
const next: Request = { ...opts, id: seq.current, resolve }
|
||||
pending.current = next
|
||||
setReq(next)
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const settle = useCallback((confirmed: boolean) => {
|
||||
const open = pending.current
|
||||
pending.current = null
|
||||
setReq(null)
|
||||
open?.resolve(confirmed)
|
||||
}, [])
|
||||
|
||||
// Teardown must not strand a caller mid-await.
|
||||
useEffect(
|
||||
() => () => {
|
||||
pending.current?.resolve(false)
|
||||
pending.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// A question belongs to the page that asked it. The provider outlives the
|
||||
// hash router, so a navigation would otherwise leave a stale plate floating
|
||||
// over a page it has nothing to do with — answer it "no" and clear it.
|
||||
useEffect(() => {
|
||||
const onNav = () => {
|
||||
if (pending.current) settle(false)
|
||||
}
|
||||
window.addEventListener('hashchange', onNav)
|
||||
return () => window.removeEventListener('hashchange', onNav)
|
||||
}, [settle])
|
||||
|
||||
return (
|
||||
<ConfirmCtx.Provider value={confirm}>
|
||||
{children}
|
||||
{req !== null && <ConfirmDialog key={req.id} open onResolve={settle} {...req} />}
|
||||
</ConfirmCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the operator, get a definite answer:
|
||||
*
|
||||
* const confirm = useConfirm()
|
||||
* if (!(await confirm({ title: 'Delete rule "x"?', body: '…' }))) return
|
||||
*
|
||||
* The returned function is stable, so it is safe in a useCallback dep list. It
|
||||
* always settles — cancel, Esc, click-outside and teardown all resolve `false`;
|
||||
* only the confirming button resolves `true`.
|
||||
*
|
||||
* Name it `confirm` at the call site on purpose: the local binding shadows the
|
||||
* global one inside that component, so an accidental bare `confirm(...)` cannot
|
||||
* reach the suppressible native dialog.
|
||||
*/
|
||||
export function useConfirm(): (o: ConfirmOptions) => Promise<boolean> {
|
||||
const ctx = useContext(ConfirmCtx)
|
||||
if (!ctx) {
|
||||
// Loud on purpose. A fallback that quietly resolved false would rebuild the
|
||||
// exact bug this component exists to kill.
|
||||
throw new Error('useConfirm() needs <ConfirmProvider> above it (mounted in main.tsx)')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export { Button } from './Button'
|
||||
export type { ButtonProps } from './Button'
|
||||
export { Select } 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 { CatSuggest } from './CatSuggest'
|
||||
export { SrcPicker } from './SrcPicker'
|
||||
|
||||
@@ -77,6 +77,39 @@ export function fmtDateTime(unix: number): string {
|
||||
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.
|
||||
*
|
||||
|
||||
+6
-1
@@ -2,12 +2,17 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './tokens.css'
|
||||
import { App } from './App'
|
||||
import { ConfirmProvider } from './components'
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) throw new Error('#root not found')
|
||||
|
||||
// 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>
|
||||
<App />
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+230
-37
@@ -6,7 +6,7 @@
|
||||
// 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.
|
||||
import type { ApplyResult, ChainHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, QueryLogEntry, RuleReach, RulesReachability, 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 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: 'fallback', Source: 'subscription', Subscription: 'backup', Strategy: 'roundrobin', Egress: '' },
|
||||
],
|
||||
// One multi-hop chain so `?mock` exercises the chain card's Test button and
|
||||
// its result readout: enters through the awg tunnel, exits via the auto group.
|
||||
Chains: [{ Name: 'relay', Hops: ['egress:awg', 'group:auto'] }],
|
||||
// Three chains, one per state the hop readout has to render.
|
||||
Chains: [
|
||||
// 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: [
|
||||
{ Name: 'wan', Type: 'interface', Interface: 'wan' },
|
||||
// An AmneziaWG tunnel — the whole point of a group-level egress binding.
|
||||
@@ -145,6 +161,13 @@ const CONFIG: Model = {
|
||||
Rules: [
|
||||
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
|
||||
{ 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' },
|
||||
// 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,
|
||||
@@ -171,6 +194,7 @@ const CONFIG: Model = {
|
||||
// to an official remote list; the others are the usual url / inline lists.
|
||||
Blocklists: [
|
||||
{ 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' },
|
||||
],
|
||||
Resolvers: [
|
||||
@@ -300,34 +324,98 @@ const RULESET_STATUS: RulesetStatus[] = [
|
||||
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 },
|
||||
// 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. */
|
||||
* 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.DstDomain ?? []).length &&
|
||||
!(r.DstRuleset ?? []).length &&
|
||||
!(r.DstIP ?? []).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 }))
|
||||
.filter(({ r }) => r.Enabled && conditionless(r) && target(r))
|
||||
// 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) {
|
||||
@@ -407,6 +495,11 @@ export async function getRulesetCategories(source: string): Promise<RulesetCateg
|
||||
// ?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
|
||||
// makes the untunnelable policy inert (F8 case 4)
|
||||
// ?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.
|
||||
function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSwitch: string } {
|
||||
const q = typeof location === 'undefined' ? '' : location.search
|
||||
const params = new URLSearchParams(q)
|
||||
@@ -418,6 +511,30 @@ function mockPlane(): { plane: 'full' | 'hold' | 'none'; engine: boolean; killSw
|
||||
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'): 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[] = [
|
||||
{
|
||||
severity: 'critical',
|
||||
@@ -520,6 +637,7 @@ export async function getStatus(): Promise<Status> {
|
||||
can_rollback: armed || hasLastGood,
|
||||
engine_running: engine,
|
||||
plane,
|
||||
traffic: mockTraffic(plane),
|
||||
warnings: mockWarnings(killSwitch),
|
||||
// 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.
|
||||
@@ -1096,20 +1214,59 @@ function healthList(): GroupHealth[] {
|
||||
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
|
||||
* NOT referenced by any rule in CONFIG.Rules (they target group:auto / block /
|
||||
* 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. */
|
||||
/**
|
||||
* Per-hop health, keyed by chain name — what the observatory measured at each
|
||||
* position of the path, in WIRE order.
|
||||
*
|
||||
* `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[] {
|
||||
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)
|
||||
* 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
|
||||
* config would mark the ones rules point at used=true. */
|
||||
* Two of the mock's rules do (`media-via-chain` → ewan-wg-subs, `spare-via-chain`
|
||||
* → 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 {
|
||||
const target = `chain:${name}`
|
||||
return (CONFIG.Rules ?? []).some(
|
||||
@@ -1161,15 +1318,23 @@ class ApiErrorLike extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Mock group/chain test. Deliberately covers every state the UI has to render,
|
||||
// one per target, so a single offline run exercises all of them:
|
||||
// auto → ok WITH an exit address
|
||||
// stealth → ok WITHOUT one (delay measured, address undeterminable) — a
|
||||
// SUCCESS, and the case the UI most easily gets wrong
|
||||
// relay → the chain: same wire shape, `group` carries the CHAIN's name and
|
||||
// `selected` the node its exit group picked
|
||||
// fallback → a failure carrying a human reason
|
||||
// Results land one per GET poll, so the running/progress state is visible too.
|
||||
// Mock refresh results. The endpoint no longer dials anything: it asks the
|
||||
// observatory to measure out of turn and reports what the observatory found, so
|
||||
// every row here is a READ of a background measurement. Deliberately covers every
|
||||
// state the UI has to render, one per target, so a single offline run exercises
|
||||
// all of them:
|
||||
// auto → ok WITH an exit address
|
||||
// stealth → ok WITHOUT one (delay measured, address undeterminable) — a
|
||||
// SUCCESS, and the case the UI most easily gets wrong
|
||||
// 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'>> = {
|
||||
auto: {
|
||||
selected: 'nl-reality-2',
|
||||
@@ -1187,32 +1352,60 @@ const GROUP_TEST_SHAPE: Record<string, Omit<GroupTestResult, 'group' | 'tested_u
|
||||
ok: true,
|
||||
error: '',
|
||||
},
|
||||
// The chain — Selected is the node the chain's exit group (auto) picked.
|
||||
relay: {
|
||||
selected: 'nl-reality-2',
|
||||
delay_ms: 61,
|
||||
exit_ip: '185.12.34.56',
|
||||
exit_country: 'NL',
|
||||
ok: true,
|
||||
error: '',
|
||||
// The chain, and the pairing that makes the whole feature worth building. A
|
||||
// chain is one series path, so with hop 3 dead the end-to-end probe is never
|
||||
// even attempted — the daemon stops walking there. This row and the hop rail
|
||||
// therefore have to tell one story, not two: both name hop 3, and neither
|
||||
// offers hop 4 as a second suspect. Note the row does NOT say "the probe
|
||||
// failed" — no probe of this chain's exit ran at all — which is why the daemon
|
||||
// has a separate message for it.
|
||||
'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
|
||||
// test and the member health tell the same story about the same group.
|
||||
// The one real health failure in the fixture: the observatory's probe ran along
|
||||
// this path and did not come back.
|
||||
'via-tunnel': {
|
||||
selected: '',
|
||||
delay_ms: 0,
|
||||
exit_ip: '',
|
||||
exit_country: '',
|
||||
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: {
|
||||
selected: '',
|
||||
delay_ms: 0,
|
||||
exit_ip: '',
|
||||
exit_country: '',
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+67
-56
@@ -11,6 +11,8 @@ import {
|
||||
ApiError,
|
||||
} from '../api'
|
||||
import type { Globals, Status } from '../api'
|
||||
import { engineReadout } from '../planeState'
|
||||
import { onPendingConfirmExpire, usePendingConfirm } from '../pendingConfirm'
|
||||
|
||||
// Short, readable config hash — drops the "sha256:" prefix like the footer does.
|
||||
function short(hash: string): string {
|
||||
@@ -25,13 +27,6 @@ function msg(e: unknown): string {
|
||||
|
||||
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'
|
||||
interface ActionResult {
|
||||
kind: ActionKind
|
||||
@@ -57,7 +52,11 @@ export default function Apply() {
|
||||
const [configError, setConfigError] = useState<string | null>(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 [confirmingRollback, setConfirmingRollback] = useState(false)
|
||||
|
||||
@@ -104,32 +103,30 @@ export default function Apply() {
|
||||
void loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
// ---- commit-confirm countdown: a calm 1s numeric tick, effect-scoped so the
|
||||
// timer is always cleared on unmount / confirm / rollback (no leaked intervals) ----
|
||||
useEffect(() => {
|
||||
if (!armed) return
|
||||
if (armed.remaining <= 0) {
|
||||
// Window elapsed — the daemon reverts to last-good on its own. Observe it.
|
||||
const before = armed.appliedHash
|
||||
setArmed(null)
|
||||
flash('Auto-rolled back')
|
||||
void (async () => {
|
||||
const after = (await refreshStatus())?.hash ?? ''
|
||||
setResult({
|
||||
kind: 'expire',
|
||||
tone: 'warn',
|
||||
text: 'Confirm window elapsed — daemon auto-rolled back to last-good config.',
|
||||
before,
|
||||
after,
|
||||
})
|
||||
})()
|
||||
return
|
||||
}
|
||||
const id = window.setTimeout(() => {
|
||||
setArmed((a) => (a ? { ...a, remaining: a.remaining - 1 } : a))
|
||||
}, 1000)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [armed, flash, refreshStatus])
|
||||
// The window running out is the daemon reverting on its own — observe it and
|
||||
// say so. The countdown itself ticks inside usePendingConfirm; this only reacts
|
||||
// to the end of it, and the store makes sure that fires exactly once even with
|
||||
// the app-wide band mounted alongside.
|
||||
const liveHashRef = useRef('')
|
||||
liveHashRef.current = status?.hash ?? ''
|
||||
useEffect(
|
||||
() =>
|
||||
onPendingConfirmExpire(() => {
|
||||
const before = liveHashRef.current
|
||||
flash('Auto-rolled back')
|
||||
void (async () => {
|
||||
const after = (await refreshStatus())?.hash ?? ''
|
||||
setResult({
|
||||
kind: 'expire',
|
||||
tone: 'warn',
|
||||
text: 'Confirm window elapsed — daemon auto-rolled back to last-good config.',
|
||||
before,
|
||||
after,
|
||||
})
|
||||
})()
|
||||
}),
|
||||
[flash, refreshStatus],
|
||||
)
|
||||
|
||||
const confirmWindow = globals?.ConfirmTimeout ?? 0
|
||||
|
||||
@@ -146,8 +143,9 @@ export default function Apply() {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
setArmed({ total: confirmWindow, remaining: confirmWindow, appliedHash: after })
|
||||
setResult({
|
||||
kind: 'apply',
|
||||
tone: 'good',
|
||||
@@ -179,7 +177,8 @@ export default function Apply() {
|
||||
const doConfirm = useCallback(async () => {
|
||||
const before = status?.hash ?? ''
|
||||
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 {
|
||||
const r = await apiConfirm()
|
||||
if (r.error) {
|
||||
@@ -208,7 +207,7 @@ export default function Apply() {
|
||||
const before = status?.hash ?? ''
|
||||
setConfirmingRollback(false)
|
||||
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 {
|
||||
const r = await apiRollback()
|
||||
if (r.error) {
|
||||
@@ -237,19 +236,26 @@ export default function Apply() {
|
||||
}, [status, flash, refreshStatus])
|
||||
|
||||
// ---- derived display state (mirrors Overview's LED semantics) ----
|
||||
const killArmed = globals ? globals.KillSwitch === 'closed' : false
|
||||
const engineVariant: LedVariant = !status
|
||||
? 'off'
|
||||
: status.running && status.active
|
||||
? 'on'
|
||||
: status.running
|
||||
? 'amber'
|
||||
: 'crit'
|
||||
const dataVariant: LedVariant = status?.table ? 'on' : status?.running ? 'amber' : 'off'
|
||||
//
|
||||
// The LIVE kill-switch wins over the saved one, exactly as on Overview: this row
|
||||
// is a status readout, and the config on disk can already differ from what is
|
||||
// installed. Falls back to the config only while /api/status is unread.
|
||||
const killArmed = (status?.kill_switch ?? globals?.KillSwitch ?? 'closed') === 'closed'
|
||||
// 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 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
|
||||
// commit-confirm snapshot, or an engine last-good predecessor. When false there
|
||||
@@ -264,9 +270,7 @@ export default function Apply() {
|
||||
label="Engine"
|
||||
variant={engineVariant}
|
||||
pulse={engineVariant === 'on'}
|
||||
value={
|
||||
!status ? 'checking…' : status.running ? (status.active ? 'active' : 'idle') : 'stopped'
|
||||
}
|
||||
value={engine.word}
|
||||
/>
|
||||
<StatusPip
|
||||
label="Config"
|
||||
@@ -310,8 +314,12 @@ export default function Apply() {
|
||||
unit="· sha256"
|
||||
led={{ variant: configVariant }}
|
||||
rows={[
|
||||
{ k: 'engine', v: status?.running ? 'running' : 'stopped', hot: !status?.running },
|
||||
{ k: 'data plane', v: status?.table ? 'nft installed' : 'no table' },
|
||||
{ k: 'engine', v: engine.word, hot: engineVariant === 'crit' },
|
||||
{
|
||||
k: 'data plane',
|
||||
v: status?.table ? 'nft installed' : 'no table',
|
||||
hot: dataVariant === 'crit',
|
||||
},
|
||||
{ k: 'kill-switch', v: killArmed ? 'fail-closed' : 'open', hot: !killArmed },
|
||||
]}
|
||||
/>
|
||||
@@ -325,7 +333,7 @@ export default function Apply() {
|
||||
}
|
||||
led={{ variant: engineVariant }}
|
||||
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: 'schema', v: globals ? `v${globals.SchemaVersion}` : '—' },
|
||||
]}
|
||||
@@ -362,9 +370,12 @@ export default function Apply() {
|
||||
</div>
|
||||
<div className="cc-info">
|
||||
<p className="cc-copy">
|
||||
Applied config <span className="mono">{short(armed.appliedHash)}</span> is live but
|
||||
not yet kept. Confirm to keep it — otherwise the daemon rolls back to the last-good
|
||||
config when the timer hits zero.
|
||||
{/* The live hash IS the applied one while a window is open — that
|
||||
is what "live but not kept" means — so the readout survives a
|
||||
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 — otherwise the daemon rolls back to the last-good config when
|
||||
the timer hits zero.
|
||||
</p>
|
||||
<div className="cc-bar" aria-hidden="true">
|
||||
<span className="cc-bar-fill" style={{ width: `${pct}%` }} />
|
||||
|
||||
+80
-1
@@ -387,6 +387,79 @@
|
||||
.dns-row-state[data-active='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) */
|
||||
.dns-badge {
|
||||
@@ -646,9 +719,15 @@
|
||||
.dns-skel {
|
||||
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-input,
|
||||
.dns-seg-btn {
|
||||
.dns-seg-btn,
|
||||
.dns-sync-update {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+245
-28
@@ -1,8 +1,16 @@
|
||||
import './DNS.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, CatSuggest, Led, SrcPicker, Toggle } from '../components'
|
||||
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
|
||||
import type { Alert, DNSRule, Model, Resolver } from '../api'
|
||||
import { Button, CatSuggest, Led, SrcPicker, Toggle, useConfirm } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getConfig,
|
||||
getRulesetStatus,
|
||||
putConfig,
|
||||
updateRuleset as apiUpdateRuleset,
|
||||
ApiError,
|
||||
} from '../api'
|
||||
import type { Alert, DNSRule, Model, Resolver, RulesetStatus } from '../api'
|
||||
import { everyLabel, relFetch } from '../format'
|
||||
|
||||
// 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
|
||||
@@ -220,6 +228,7 @@ function describeDetour(
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function DNS() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<DNSModel | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -309,6 +318,68 @@ export default function DNS() {
|
||||
[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 alOn = allowlists.filter((a) => a.Enabled).length
|
||||
const blNames = useMemo(() => new Set(blocklists.map((b) => b.Name)), [blocklists])
|
||||
@@ -422,15 +493,19 @@ export default function DNS() {
|
||||
)
|
||||
|
||||
const removeBlocklist = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = blocklists[idx]
|
||||
if (!window.confirm(`Delete blocklist “${target.Name}”? This removes it from the config.`))
|
||||
return
|
||||
const ok = await confirm({
|
||||
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)
|
||||
void save({ ...config, Blocklists: next }, `Deleted ${target.Name}`)
|
||||
},
|
||||
[config, blocklists, save],
|
||||
[config, blocklists, save, confirm],
|
||||
)
|
||||
|
||||
// ---- allowlist mutations --------------------------------------------------
|
||||
@@ -457,15 +532,19 @@ export default function DNS() {
|
||||
)
|
||||
|
||||
const removeAllowlist = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = allowlists[idx]
|
||||
if (!window.confirm(`Delete allowlist “${target.Name}”? This removes it from the config.`))
|
||||
return
|
||||
const ok = await confirm({
|
||||
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)
|
||||
void save({ ...config, Allowlists: next }, `Deleted ${target.Name}`)
|
||||
},
|
||||
[config, allowlists, save],
|
||||
[config, allowlists, save, confirm],
|
||||
)
|
||||
|
||||
// ---- resolver mutations ---------------------------------------------------
|
||||
@@ -491,15 +570,58 @@ export default function DNS() {
|
||||
[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(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
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 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[] = []
|
||||
if (g.ResolverDefault === target.Name) {
|
||||
g.ResolverDefault = ''
|
||||
@@ -509,12 +631,16 @@ export default function DNS() {
|
||||
g.ResolverFallback = ''
|
||||
cleared.push('fallback')
|
||||
}
|
||||
if (g.EndpointResolver === target.Name) {
|
||||
g.EndpointResolver = ''
|
||||
cleared.push('endpoint')
|
||||
}
|
||||
const msg = cleared.length
|
||||
? `Deleted ${target.Name} — cleared ${cleared.join(' & ')}`
|
||||
: `Deleted ${target.Name}`
|
||||
void save({ ...config, Globals: g, Resolvers: next }, msg)
|
||||
},
|
||||
[config, resolvers, save],
|
||||
[config, resolvers, dnsRules, save, confirm],
|
||||
)
|
||||
|
||||
const setResolverDefault = useCallback(
|
||||
@@ -578,15 +704,19 @@ export default function DNS() {
|
||||
)
|
||||
|
||||
const removeDNSRule = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = dnsRules[idx]
|
||||
if (!window.confirm(`Delete this DNS rule? Matching queries fall back to the default resolver.`))
|
||||
return
|
||||
const ok = await confirm({
|
||||
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)
|
||||
void save({ ...config, DNSRules: next }, `Deleted DNS rule → ${target.Resolver}`)
|
||||
},
|
||||
[config, dnsRules, save],
|
||||
[config, dnsRules, save, confirm],
|
||||
)
|
||||
|
||||
// ---- alert mutations ------------------------------------------------------
|
||||
@@ -610,14 +740,19 @@ export default function DNS() {
|
||||
)
|
||||
|
||||
const removeAlert = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = alerts[idx]
|
||||
if (!window.confirm(`Delete alert “${target.Name}”? This removes it from the config.`)) return
|
||||
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 save({ ...config, Alerts: next }, `Deleted ${target.Name}`)
|
||||
},
|
||||
[config, alerts, save],
|
||||
[config, alerts, save, confirm],
|
||||
)
|
||||
|
||||
const setAlertVia = useCallback(
|
||||
@@ -883,8 +1018,11 @@ export default function DNS() {
|
||||
categories={b.Categories}
|
||||
response={b.Response}
|
||||
filterOn={dnsFilterOn}
|
||||
statuses={listStatus.get(`blocklist:${b.Name}`) ?? null}
|
||||
updating={updatingLists.has(`blocklist:${b.Name}`)}
|
||||
busy={busy}
|
||||
onToggle={(on) => toggleBlocklist(i, on)}
|
||||
onUpdateNow={() => void updateList('blocklist', b.Name)}
|
||||
onDelete={() => removeBlocklist(i)}
|
||||
/>
|
||||
))}
|
||||
@@ -942,9 +1080,13 @@ export default function DNS() {
|
||||
url={a.URL}
|
||||
path={a.Path}
|
||||
entries={a.Entries}
|
||||
categories={a.Categories}
|
||||
filterOn={dnsFilterOn}
|
||||
statuses={listStatus.get(`allowlist:${a.Name}`) ?? null}
|
||||
updating={updatingLists.has(`allowlist:${a.Name}`)}
|
||||
busy={busy}
|
||||
onToggle={(on) => toggleAllowlist(i, on)}
|
||||
onUpdateNow={() => void updateList('allowlist', a.Name)}
|
||||
onDelete={() => removeAllowlist(i)}
|
||||
/>
|
||||
))}
|
||||
@@ -1695,8 +1837,11 @@ function ListRow({
|
||||
categories,
|
||||
response,
|
||||
filterOn,
|
||||
statuses,
|
||||
updating,
|
||||
busy,
|
||||
onToggle,
|
||||
onUpdateNow,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string
|
||||
@@ -1708,8 +1853,14 @@ function ListRow({
|
||||
categories?: string[] | null
|
||||
response?: BlockResponse
|
||||
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
|
||||
onToggle: (on: boolean) => void
|
||||
onUpdateNow: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const detail = useMemo<{ text: string; masked: boolean; title?: string }>(() => {
|
||||
@@ -1733,8 +1884,45 @@ function ListRow({
|
||||
}
|
||||
}, [source, url, path, entries, categories])
|
||||
|
||||
// A list only actually filters when both it and the master switch are on.
|
||||
const active = enabled && filterOn
|
||||
// url and geosite lists are FETCHED by the engine; inline and file ones are read
|
||||
// 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 (
|
||||
<li className="dns-row">
|
||||
@@ -1764,10 +1952,39 @@ function ListRow({
|
||||
token hidden
|
||||
</span>
|
||||
)}
|
||||
<span className="dns-row-state" data-active={active ? 'on' : 'off'}>
|
||||
{active ? 'filtering' : 'inactive'}
|
||||
<span className="dns-row-state" data-active={state.tone}>
|
||||
{state.text}
|
||||
</span>
|
||||
</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>
|
||||
<Button
|
||||
className="dns-del"
|
||||
|
||||
@@ -138,57 +138,9 @@
|
||||
|
||||
/* 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. */
|
||||
.dev-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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* The pencil button and the name input now live in App.css as .inline-rename /
|
||||
.inline-rename-input — Nodes grew the same affordance and the two pages must
|
||||
not drift. */
|
||||
.dev-id-l2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './Devices.css'
|
||||
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 { apply as apiApply, getConfig, getDevices, putConfig, ApiError } from '../api'
|
||||
import type { Device, DiscoveredDevice, Model } from '../api'
|
||||
@@ -81,6 +81,7 @@ function networkLabel(row: DeviceRow): string {
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Devices() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | 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 removeControl = useCallback(
|
||||
(row: DeviceRow) => {
|
||||
async (row: DeviceRow) => {
|
||||
if (!config) return
|
||||
const devs = asArray(config.Devices)
|
||||
const idx = matchDevice(devs, row.mac, row.ip)
|
||||
if (idx < 0) return
|
||||
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.`))
|
||||
return
|
||||
const ok = await confirm({
|
||||
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}`)
|
||||
},
|
||||
[config, save],
|
||||
[config, save, confirm],
|
||||
)
|
||||
|
||||
const loading = config === null && loadError === null && devices === null && devError === null
|
||||
@@ -471,7 +477,7 @@ function DeviceCard({
|
||||
{renaming ? (
|
||||
<input
|
||||
ref={nameInput}
|
||||
className="dev-name-input mono"
|
||||
className="inline-rename-input mono"
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
@@ -497,7 +503,7 @@ function DeviceCard({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="dev-rename"
|
||||
className="inline-rename"
|
||||
onClick={beginRename}
|
||||
disabled={busy}
|
||||
aria-label={`Rename ${name}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './Networks.css'
|
||||
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 type { Inbound, Interface, Model, Status } from '../api'
|
||||
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
|
||||
* "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`.
|
||||
*
|
||||
* 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'
|
||||
|
||||
@@ -98,7 +111,10 @@ function normUntunnelable(raw: string | undefined): Untunnelable {
|
||||
|
||||
const UNTUNNELABLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
@@ -110,13 +126,18 @@ interface PolicyCopy {
|
||||
|
||||
const UNTUNNELABLE_COPY: Record<Untunnelable, PolicyCopy> = {
|
||||
block: {
|
||||
works: 'Nothing leaves except through the tunnel.',
|
||||
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.',
|
||||
// Scoped to "this traffic" on purpose. The old line — "Nothing leaves except
|
||||
// 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',
|
||||
},
|
||||
icmp: {
|
||||
works: 'Ping and traceroute work, 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.',
|
||||
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. 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',
|
||||
},
|
||||
direct: {
|
||||
@@ -242,6 +263,7 @@ function computeWarnings(inbounds: Inbound[], ifaces: Interface[]): Warning[] {
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Networks({ status }: { status?: Status | null }) {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const ifaces = useInterfaces()
|
||||
@@ -392,23 +414,21 @@ export default function Networks({ status }: { status?: Status | null }) {
|
||||
)
|
||||
|
||||
const removeInbound = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = inbounds[idx]
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete inbound “${target.Name}”?${
|
||||
intercepts(target)
|
||||
? ` ${target.Network || 'Its network'} stops going through the tunnel.`
|
||||
: ''
|
||||
}`,
|
||||
)
|
||||
)
|
||||
return
|
||||
const ok = await confirm({
|
||||
label: 'Delete inbound',
|
||||
title: `Delete inbound “${target.Name}”?`,
|
||||
body: intercepts(target)
|
||||
? `${target.Network || 'Its network'} stops going through the tunnel.`
|
||||
: undefined,
|
||||
})
|
||||
if (!ok) return
|
||||
const next = inbounds.filter((_, i) => i !== idx)
|
||||
void save({ ...config, Inbounds: next }, `Deleted ${target.Name}`)
|
||||
},
|
||||
[config, inbounds, save],
|
||||
[config, inbounds, save, confirm],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -552,7 +572,7 @@ export default function Networks({ status }: { status?: Status | null }) {
|
||||
|
||||
{untunnelable === 'block' && (
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -727,3 +727,53 @@ select.fp-input {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
+507
-15
@@ -2,7 +2,7 @@ import './Nodes.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LedVariant } from '../components'
|
||||
import { Button, Led, Toggle } from '../components'
|
||||
import { Button, Led, Toggle, useConfirm } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getConfig,
|
||||
@@ -158,6 +158,219 @@ function uniqueName(base: string, taken: Set<string>): string {
|
||||
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
|
||||
// doesn't become one endless scroll; an active search overrides it.
|
||||
const LARGE_GROUP = 20
|
||||
@@ -289,6 +502,7 @@ function DetourSelect({
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Nodes() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -385,6 +599,9 @@ export default function Nodes() {
|
||||
const [nodeInput, setNodeInput] = useState('')
|
||||
const [nodeErr, setNodeErr] = useState<string | null>(null)
|
||||
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)
|
||||
|
||||
// ---- node search + collapsible grouping -----------------------------------
|
||||
@@ -445,8 +662,12 @@ export default function Nodes() {
|
||||
try {
|
||||
const { uri, name } = await importWg(conf)
|
||||
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 = {
|
||||
Name: uniqueName(name || 'wireguard', taken),
|
||||
Name: wanted || uniqueName(name || 'wireguard', taken),
|
||||
Enabled: true,
|
||||
URI: uri,
|
||||
FromSub: '',
|
||||
@@ -455,6 +676,7 @@ export default function Nodes() {
|
||||
const ok = await save({ ...config, Nodes: [...nodes, node] }, `Added ${node.Name}`)
|
||||
if (ok) {
|
||||
setNodeInput('')
|
||||
setNodeName('')
|
||||
setAddMode('link')
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -463,11 +685,20 @@ export default function Nodes() {
|
||||
setImporting(false)
|
||||
}
|
||||
},
|
||||
[config, nodes, save, flash],
|
||||
[config, nodes, nodeName, save, flash],
|
||||
)
|
||||
|
||||
const addNode = useCallback(async () => {
|
||||
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.
|
||||
if (nodeInput.includes(WG_MARKER)) {
|
||||
await addWgConf(nodeInput)
|
||||
@@ -486,10 +717,20 @@ export default function Nodes() {
|
||||
const parsed = parseShareLink(uri)
|
||||
const taken = new Set(nodes.map((n) => n.Name))
|
||||
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}`)
|
||||
if (ok) setNodeInput('')
|
||||
}, [config, nodeInput, nodes, save, addMode, addWgConf])
|
||||
if (ok) {
|
||||
setNodeInput('')
|
||||
setNodeName('')
|
||||
}
|
||||
}, [config, nodeInput, nodeName, nodes, save, addMode, addWgConf])
|
||||
|
||||
const toggleNode = useCallback(
|
||||
(idx: number, on: boolean) => {
|
||||
@@ -500,15 +741,110 @@ export default function Nodes() {
|
||||
[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(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
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)
|
||||
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
|
||||
@@ -563,16 +899,20 @@ export default function Nodes() {
|
||||
)
|
||||
|
||||
const removeSub = useCallback(
|
||||
(idx: number) => {
|
||||
async (idx: number) => {
|
||||
if (!config) return
|
||||
const target = subs[idx]
|
||||
const hasCache = nodes.some((n) => n.FromSub === target.Name)
|
||||
const extra = hasCache ? ' Its cached nodes stay until you next apply.' : ''
|
||||
if (!window.confirm(`Delete subscription “${target.Name}”?${extra}`)) return
|
||||
const ok = await confirm({
|
||||
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)
|
||||
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
|
||||
@@ -736,6 +1076,20 @@ export default function Nodes() {
|
||||
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}>
|
||||
{importing ? 'Importing…' : saving ? 'Saving…' : addMode === 'conf' ? 'Import' : 'Add node'}
|
||||
</Button>
|
||||
@@ -743,7 +1097,8 @@ export default function Nodes() {
|
||||
<p className="add-hint">
|
||||
{addMode === 'conf'
|
||||
? '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>
|
||||
</div>
|
||||
{nodeErr && (
|
||||
@@ -800,6 +1155,7 @@ export default function Nodes() {
|
||||
onToggle={() => toggleGroup(g)}
|
||||
onToggleNode={toggleNode}
|
||||
onRemoveNode={removeNode}
|
||||
onRenameNode={renameNode}
|
||||
onSetEgress={setNodeEgress}
|
||||
/>
|
||||
))}
|
||||
@@ -911,6 +1267,7 @@ function NodeGroup({
|
||||
onToggle,
|
||||
onToggleNode,
|
||||
onRemoveNode,
|
||||
onRenameNode,
|
||||
onSetEgress,
|
||||
}: {
|
||||
group: NodeGroupData
|
||||
@@ -920,6 +1277,7 @@ function NodeGroup({
|
||||
onToggle: () => void
|
||||
onToggleNode: (idx: number, on: boolean) => void
|
||||
onRemoveNode: (idx: number) => void
|
||||
onRenameNode: (idx: number, name: string, onError: (msg: string) => void) => Promise<boolean>
|
||||
onSetEgress: (idx: number, egress: string) => Promise<boolean>
|
||||
}) {
|
||||
const panelId = `node-group-${group.key || 'manual'}`
|
||||
@@ -940,6 +1298,12 @@ function NodeGroup({
|
||||
<span className="group-count mono">{count}</span>
|
||||
</button>
|
||||
</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 && (
|
||||
<ul id={panelId} className="rows-list group-rows">
|
||||
{group.items.map(({ node, idx }) => (
|
||||
@@ -950,6 +1314,7 @@ function NodeGroup({
|
||||
egressNames={egressNames}
|
||||
onToggle={(on) => onToggleNode(idx, on)}
|
||||
onDelete={() => onRemoveNode(idx)}
|
||||
onRename={(name, onError) => onRenameNode(idx, name, onError)}
|
||||
onSetEgress={(egress) => onSetEgress(idx, egress)}
|
||||
/>
|
||||
))}
|
||||
@@ -965,6 +1330,7 @@ function NodeRow({
|
||||
egressNames,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onRename,
|
||||
onSetEgress,
|
||||
}: {
|
||||
node: NodeCfg
|
||||
@@ -972,6 +1338,7 @@ function NodeRow({
|
||||
egressNames: string[]
|
||||
onToggle: (on: boolean) => void
|
||||
onDelete: () => void
|
||||
onRename: (name: string, onError: (msg: string) => void) => Promise<boolean>
|
||||
onSetEgress: (egress: string) => Promise<boolean>
|
||||
}) {
|
||||
const { proto, host, hasCreds } = useMemo(() => parseShareLink(node.URI), [node.URI])
|
||||
@@ -982,6 +1349,68 @@ function NodeRow({
|
||||
const [open, setOpen] = useState(false)
|
||||
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 (
|
||||
<li className={`row-item node-row${open ? ' node-row--open' : ''}`}>
|
||||
<div className="row-head">
|
||||
@@ -993,10 +1422,73 @@ function NodeRow({
|
||||
/>
|
||||
<div className="row-main">
|
||||
<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>
|
||||
{node.Stale && <span className="badge badge--warn">stale</span>}
|
||||
</div>
|
||||
{renameErr && (
|
||||
<p className="row-err" role="alert">
|
||||
{renameErr}
|
||||
</p>
|
||||
)}
|
||||
<div className="row-line2 mono">
|
||||
<span className="row-host">{host}</span>
|
||||
{hasCreds && (
|
||||
|
||||
+121
-41
@@ -4,17 +4,18 @@ import type { LedVariant } from '../components'
|
||||
import { fmtDateTime, fmtDuration } from '../format'
|
||||
import {
|
||||
apply as apiApply,
|
||||
confirm as apiConfirm,
|
||||
rollback as apiRollback,
|
||||
getConfig,
|
||||
getRulesReachability,
|
||||
getStats,
|
||||
ApiError,
|
||||
} from '../api'
|
||||
import type { Model, Stats, Status, StatusWarning } from '../api'
|
||||
import { confirmTimeout } from '../pendingConfirm'
|
||||
import { navigate } from '../router'
|
||||
import type { Route } from '../router'
|
||||
import { attentionFindings } from '../findings'
|
||||
import { protectionState } from '../planeState'
|
||||
import { engineReadout, protectionState } from '../planeState'
|
||||
|
||||
// null-safe length for a Go slice that may arrive as null.
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -42,17 +44,32 @@ type ControlKind = 'apply' | 'confirm' | 'rollback'
|
||||
* Returns null when the daemon doesn't report uptime (older builds) — the caller
|
||||
* 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)
|
||||
// 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 reported = status?.uptime_seconds
|
||||
useEffect(() => {
|
||||
if (typeof reported !== 'number' || !Number.isFinite(reported)) {
|
||||
base.current = null
|
||||
started.current = null
|
||||
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)
|
||||
}, [reported])
|
||||
|
||||
@@ -64,8 +81,11 @@ function useUptime(status: Status | null): number | null {
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
if (!base.current) return null
|
||||
return base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000)
|
||||
if (!base.current || started.current === null) return null
|
||||
return {
|
||||
seconds: base.current.uptime + Math.max(0, (Date.now() - base.current.at) / 1000),
|
||||
startedUnix: started.current,
|
||||
}
|
||||
}
|
||||
|
||||
export function Overview({
|
||||
@@ -93,6 +113,29 @@ export function Overview({
|
||||
void 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 ----
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -121,7 +164,7 @@ export function Overview({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- apply / confirm / rollback ----
|
||||
// ---- apply / rollback ----
|
||||
const [busy, setBusy] = useState<ControlKind | null>(null)
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
@@ -139,22 +182,25 @@ export function Overview({
|
||||
setBusy(kind)
|
||||
setResult(null)
|
||||
try {
|
||||
const fn = kind === 'apply' ? apiApply : kind === 'confirm' ? apiConfirm : apiRollback
|
||||
const r = await fn()
|
||||
const r = kind === 'apply' ? await apiApply() : await apiRollback()
|
||||
if (r.error) {
|
||||
setResult({ ok: false, msg: r.error })
|
||||
flash(`${kind} failed`)
|
||||
} 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 =
|
||||
kind === 'apply'
|
||||
? 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'
|
||||
: kind === 'confirm'
|
||||
? 'Confirmed — auto-rollback cancelled'
|
||||
: 'Rolled back to last-good config'
|
||||
: 'Rolled back to last-good config'
|
||||
setResult({ ok: true, msg })
|
||||
flash(kind === 'apply' ? 'Applied' : kind === 'confirm' ? 'Confirmed' : 'Rolled back')
|
||||
flash(kind === 'apply' ? 'Applied' : 'Rolled back')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'request failed'
|
||||
@@ -163,16 +209,20 @@ export function Overview({
|
||||
} finally {
|
||||
setBusy(null)
|
||||
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") ----
|
||||
const uptime = useUptime(status)
|
||||
const uptimeText = uptime === null ? '' : fmtDuration(uptime)
|
||||
const startedAt = status?.started_unix ? fmtDateTime(status.started_unix) : ''
|
||||
const uptimeText = uptime === null ? '' : fmtDuration(uptime.seconds)
|
||||
// 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 ----
|
||||
const g = config?.Globals
|
||||
@@ -251,13 +301,12 @@ export function Overview({
|
||||
? `${worstGroup.group} — no answer`
|
||||
: `${worstGroup.group} — ${worstGroup.dead} down`
|
||||
|
||||
const engineVariant: LedVariant = !status
|
||||
? 'off'
|
||||
: status.running && status.active
|
||||
? 'on'
|
||||
: status.running
|
||||
? 'amber'
|
||||
: 'crit'
|
||||
// One reading for the engine, and it is able to say "stopped": `status.running`
|
||||
// was a constant `true` on the daemon, so this LED could never go crit and the
|
||||
// Engine module was green through a process that had failed to start. See
|
||||
// planeState.engineState.
|
||||
const engine = engineReadout(status)
|
||||
const engineVariant: LedVariant = engine.variant
|
||||
|
||||
const protection = protectionState(status)
|
||||
// Configured fail-closed AND actually enforcing it. `none` means nothing is
|
||||
@@ -334,14 +383,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
|
||||
name="Routing"
|
||||
value={String(enabledCount(config?.Rules))}
|
||||
unit={`/ ${len(config?.Rules)} rules`}
|
||||
led={{ variant: len(config?.Rules) ? 'on' : 'amber' }}
|
||||
value={inForce === null ? String(enabledCount(config?.Rules)) : String(inForce)}
|
||||
unit={
|
||||
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={[
|
||||
{ k: 'egresses', v: String(len(config?.Egresses)) },
|
||||
{ k: 'default', v: defaultTarget(config), hot: true },
|
||||
{ k: 'default', v: defaultTarget(status, config), hot: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -414,6 +480,7 @@ export function Overview({
|
||||
unit={status?.version?.includes('-') ? '· ' + status.version.split('-').slice(1).join('-') : ''}
|
||||
led={{ variant: engineVariant }}
|
||||
rows={[
|
||||
{ k: 'process', v: engine.word, hot: engineVariant === 'crit' },
|
||||
{ k: 'config hash', v: <span className="mono">{short(status?.hash ?? '')}</span> },
|
||||
// Uptime of the daemon PROCESS. "started" is the moment it came up,
|
||||
// by the router's clock — not the moment a config was applied.
|
||||
@@ -431,9 +498,14 @@ export function Overview({
|
||||
<Button variant="primary" onClick={() => void run('apply')} disabled={busy !== null}>
|
||||
{busy === 'apply' ? 'Applying…' : 'Apply config'}
|
||||
</Button>
|
||||
<Button onClick={() => void run('confirm')} disabled={busy !== null}>
|
||||
{busy === 'confirm' ? 'Confirming…' : 'Confirm'}
|
||||
</Button>
|
||||
{/* A "Confirm" button used to sit here permanently, and pressing it
|
||||
always printed "Confirmed — auto-rollback cancelled": `apply.Confirm()`
|
||||
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 && (
|
||||
<Button onClick={() => void run('rollback')} disabled={busy !== null}>
|
||||
{busy === 'rollback' ? 'Rolling back…' : 'Rollback'}
|
||||
@@ -563,12 +635,20 @@ const NAV_LABEL: Record<Route, string> = {
|
||||
// for the apply/rollback flow, where the individual flags are the actual
|
||||
// subject of the page.)
|
||||
|
||||
function defaultTarget(config: Model | null): string {
|
||||
const rules = config?.Rules ?? []
|
||||
if (rules.length === 0) return '—'
|
||||
// The highest Order enabled rule is the effective catch-all.
|
||||
const enabled = rules.filter((r) => r.Enabled)
|
||||
if (enabled.length === 0) return 'none'
|
||||
const last = enabled.reduce((a, b) => (b.Order >= a.Order ? b : a))
|
||||
return last.Target || last.Egress || last.Name
|
||||
/** Where everything not matched by a rule goes — the engine's route `final`.
|
||||
*
|
||||
* Taken from the daemon (status.traffic.default), which reads it off the config
|
||||
* it is running. The guess this replaced was "the highest-Order enabled rule",
|
||||
* and that is not what the default is: a rule only becomes the default by having
|
||||
* NO conditions at all, whatever its Order (model.IsCatchAll), so a specific
|
||||
* high-Order rule was routinely printed here as the router's default. It also
|
||||
* 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 { 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 type { Interface, Model, Profile } from '../api'
|
||||
|
||||
@@ -36,6 +36,7 @@ function namesOf(v: unknown): string[] {
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Profiles() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -192,9 +193,14 @@ export default function Profiles() {
|
||||
)
|
||||
|
||||
const deleteProfile = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
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 g =
|
||||
config.Globals.ActiveProfile === name
|
||||
@@ -202,7 +208,7 @@ export default function Profiles() {
|
||||
: config.Globals
|
||||
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) -------------------
|
||||
|
||||
@@ -58,6 +58,45 @@
|
||||
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 ---- */
|
||||
.rt-empty {
|
||||
padding: calc(var(--u, 8px) * 4) 0 calc(var(--u, 8px) * 3);
|
||||
@@ -289,6 +328,51 @@
|
||||
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) ---- */
|
||||
.rt-target {
|
||||
display: inline-flex;
|
||||
@@ -396,9 +480,6 @@
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-field-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.rt-flabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
@@ -515,6 +596,8 @@ select.rt-input {
|
||||
border-color: var(--accent);
|
||||
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 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
@@ -837,9 +920,6 @@ select.rt-input {
|
||||
justify-content: flex-start;
|
||||
align-self: start;
|
||||
}
|
||||
.rt-field-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
.rt-rs-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 10px;
|
||||
|
||||
+436
-176
@@ -1,7 +1,7 @@
|
||||
import './Routing.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import { Button, CatSuggest, SrcPicker, Toggle } from '../components'
|
||||
import { Button, CatSuggest, SrcPicker, Toggle, useConfirm } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getConfig,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ApiError,
|
||||
} from '../api'
|
||||
import type { Model, Rule, RuleReach, Ruleset, RulesetStatus } from '../api'
|
||||
import { everyLabel, relFetch } from '../format'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The api.ts `Rule` is a deliberately thin subset (Name/Enabled/Order/Target/
|
||||
@@ -22,9 +23,7 @@ import type { Model, Rule, RuleReach, Ruleset, RulesetStatus } from '../api'
|
||||
// ---------------------------------------------------------------------------
|
||||
type RRule = Rule & {
|
||||
Src?: string[] | null
|
||||
DstDomain?: string[] | null
|
||||
DstRuleset?: string[] | null
|
||||
DstIP?: string[] | null
|
||||
DstPort?: string
|
||||
Proto?: string
|
||||
Kill?: string
|
||||
@@ -35,6 +34,29 @@ type RRule = Rule & {
|
||||
// Minutes east of UTC anchoring the schedule's wall-clock times; captured
|
||||
// from the editing browser on save (the router has no tzdata). 0 ⇒ UTC.
|
||||
SchedUTCOffset?: number
|
||||
// The daemon's unmigrated-rule tripwire (model.Rule.LegacyDst), read-only here.
|
||||
// Non-empty ⇒ the config STILL carries the schema-v1 `dst_domain`/`dst_ip` that
|
||||
// schema v2 removed, i.e. `shaterd migrate` never ran or could not commit. Each
|
||||
// element is the raw `<option>=<value>` text so the panel can quote what was
|
||||
// found. The daemon holds such a rule disabled; the panel only reports it (it
|
||||
// is never rendered back to UCI, so a config write from here drains it out).
|
||||
// Field name is the Go one: model.Rule has no json tags.
|
||||
LegacyDst?: string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a rule is in force, kept strictly apart from whether it is switched on.
|
||||
*
|
||||
* `on` is the EFFECTIVE state — what the router is actually doing — and every mark
|
||||
* on the row is drawn from it. `profile`/`dir` are set only when the active WAN
|
||||
* profile is the reason the two differ, so the row can name who overrode the
|
||||
* saved setting instead of leaving the operator to guess why a switch that reads
|
||||
* "on" routes nothing.
|
||||
*/
|
||||
type RuleForce = {
|
||||
on: boolean
|
||||
profile: string | null
|
||||
dir: 'enabled' | 'disabled' | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +119,6 @@ function ProtoOptions({ value }: { value: string }) {
|
||||
|
||||
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
|
||||
const byOrder = (a: RRule, b: RRule): number => a.Order - b.Order
|
||||
const csv = (s: string): string[] => s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
|
||||
// --- ruleset helpers --------------------------------------------------------
|
||||
// A `config ruleset` (api.ts Ruleset) is a named domain/ipcidr list a rule
|
||||
@@ -183,48 +204,113 @@ function errMsg(e: unknown): string {
|
||||
// --- remote-list freshness (feedback #9) ------------------------------------
|
||||
// A url-source ruleset re-fetches on a cadence; the engine reports when it last
|
||||
// pulled and how many rules the list holds. Match a ruleset to its status by the
|
||||
// engine tag `rs-<name>`.
|
||||
// engine tag `rs-<name>`. The two readings (relFetch / everyLabel) live in
|
||||
// format.ts because the DNS page shows the same ones for blocklists.
|
||||
|
||||
/** "updated 3h ago" / "never updated" for a remote list's last fetch. */
|
||||
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). */
|
||||
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 rule with no matcher of any kind is the effective catch-all (route Final). */
|
||||
/** A rule with no matcher of any kind is the effective catch-all (route Final).
|
||||
* Mirrors model.IsCatchAll on the daemon side — the two must agree or the
|
||||
* "never applies" badge lands on a different row than the apply warning.
|
||||
*
|
||||
* AN UNMIGRATED RULE IS NEVER A CATCH-ALL, and that is the first thing checked
|
||||
* here, exactly as on the Go side (model/reachability.go). When LegacyDst is
|
||||
* non-empty the rule's destination is still written in the schema-v1 options
|
||||
* the parser no longer reads, so its lack of matchers means "the destination is
|
||||
* unreadable", not "matches everything" — reading it the other way is precisely
|
||||
* what turned an uncommitted `shaterd migrate` into route Final for the whole
|
||||
* router. The daemon holds such a rule disabled and reports false here; if this
|
||||
* copy disagreed, the panel would paint the row "default route · final" while
|
||||
* the daemon routes nothing through it. */
|
||||
function isCatchAll(r: RRule): boolean {
|
||||
if (len(r.LegacyDst) > 0) return false
|
||||
return (
|
||||
len(r.Src) === 0 &&
|
||||
len(r.DstDomain) === 0 &&
|
||||
len(r.DstRuleset) === 0 &&
|
||||
len(r.DstIP) === 0 &&
|
||||
!(r.DstPort && r.DstPort.trim()) &&
|
||||
!(r.Proto && r.Proto.trim())
|
||||
)
|
||||
}
|
||||
|
||||
/** isCatchAll's twin for a form still being edited: the live fields of either
|
||||
* rule form with no matcher left in them.
|
||||
*
|
||||
* Such a rule is not "matches all" in the ordinary sense — the engine emits it
|
||||
* as route.Final, and among several the LAST one in rule order owns it. So what
|
||||
* saving one actually does depends on what is last right now, and there are
|
||||
* three cases:
|
||||
* - no rules at all, or the last rule is a conditional one → the new rule
|
||||
* lands last and TAKES the default, silently retargeting every otherwise
|
||||
* unmatched flow (e.g. the whole LAN to `direct`, past the tunnel);
|
||||
* - the last rule is already a catch-all → nextOrder() deliberately inserts
|
||||
* the new one BEFORE it (and bumps the old one up), so the existing default
|
||||
* keeps route.Final and the new rule is dead on arrival — the daemon
|
||||
* reports it as shadowed and the row renders as such.
|
||||
* Both outcomes are worth a warning, and neither form knows which it will be
|
||||
* (the add form has no rule list), so the shared text says only what is certain:
|
||||
* the rule has no matchers. It is a legal configuration either way, so neither
|
||||
* form blocks it — they warn, from this one predicate, so the flag cannot drift
|
||||
* out of sync between add and edit. */
|
||||
function formHasNoMatchers(f: {
|
||||
src: string[]
|
||||
port: string
|
||||
rulesets: string[]
|
||||
proto: string
|
||||
}): boolean {
|
||||
return (
|
||||
f.src.length === 0 && f.port.trim() === '' && f.rulesets.length === 0 && f.proto.trim() === ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What happens to the network when the default route stops being emitted —
|
||||
* whether it is deleted or merely switched off (the engine emits neither).
|
||||
*
|
||||
* The old text was one sentence for every rule: "Traffic it matched will fall
|
||||
* through to the next rule." For an ordinary rule that is true. For the catch-all
|
||||
* there IS no next rule, and what happens instead is decided by the kill-switch:
|
||||
* generate/route.go sets `final := tagBlock` and only `kill_switch=open` swaps
|
||||
* that for `direct`. So removing the default either takes the whole network
|
||||
* offline or puts the whole network on the naked WAN — and the page said "falls
|
||||
* through to the next rule" for both, on a row it had already badged
|
||||
* "DEFAULT ROUTE · FINAL".
|
||||
*
|
||||
* `successor` is the rule that would inherit route.Final instead (a config can
|
||||
* carry more than one conditionless rule; the last one wins). When there is one,
|
||||
* nothing is lost — the honest warning is that the destination changes.
|
||||
*/
|
||||
function defaultRouteConsequence(
|
||||
killSwitch: string,
|
||||
successor: { name: string; order: number; target: string } | null,
|
||||
): ReactNode {
|
||||
if (successor) {
|
||||
return (
|
||||
<>
|
||||
This is the router’s <strong>default route</strong> — everything no other rule matches
|
||||
follows it. Remove it and “{successor.name}” (order {successor.order}) has no conditions
|
||||
either, so it takes over: unmatched traffic goes to{' '}
|
||||
<strong className="mono">{successor.target}</strong> instead.
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (killSwitch === 'open') {
|
||||
return (
|
||||
<>
|
||||
This is the router’s <strong>default route</strong> — everything no other rule matches
|
||||
follows it, and no other rule matches everything. With the kill-switch set to{' '}
|
||||
<strong>fail-open</strong>, unmatched traffic then leaves through your normal internet
|
||||
connection with your real address — unproxied and unfiltered.
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
This is the router’s <strong>default route</strong> — everything no other rule matches
|
||||
follows it, and no other rule matches everything. With the kill-switch set to{' '}
|
||||
<strong>fail-closed</strong>, unmatched traffic is then <strong>blocked</strong>: devices on
|
||||
your network lose the internet until you add a default back.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Effective routing target for a rule (Target wins; a bare Egress is a target too). */
|
||||
function effectiveTarget(r: RRule): string {
|
||||
if (r.Target && r.Target.trim()) return r.Target.trim()
|
||||
@@ -263,16 +349,19 @@ interface TargetGroups {
|
||||
nodes: TargetOpt[] // node:<n> (huge — rendered last)
|
||||
}
|
||||
|
||||
// Free-text destination matchers offered by the ADD form. Domains are NOT one of
|
||||
// them (the user's call): domain matching goes through named rulesets — that's
|
||||
// what they exist for. 'none' = the rule matches by rulesets/source/proto alone.
|
||||
// (Legacy rules that already carry DstDomain stay editable in the edit form.)
|
||||
type MatchKind = 'none' | 'ip' | 'port'
|
||||
// The add form's fields. WHERE traffic is going is a ruleset choice and nothing
|
||||
// else — a rule has no inline domain or address list any more, so the old
|
||||
// Match-kind picker (rulesets / ip / port) collapsed into a plain Port field
|
||||
// beside the ruleset picker. The cost is real and accepted: routing a single
|
||||
// domain is no longer done here — you leave for the Rulesets panel, create the
|
||||
// list, fill it, and come back to check it. A "create a list from here" shortcut
|
||||
// was proposed and rejected (DECISIONS.md D21): a second place to author a list
|
||||
// is a second place for its entry semantics and duplicate-name rules to drift,
|
||||
// which is the exact thing D21 removed.
|
||||
interface AddForm {
|
||||
name: string
|
||||
src: string[]
|
||||
matchKind: MatchKind
|
||||
matchValue: string
|
||||
port: string
|
||||
rulesets: string[]
|
||||
proto: string
|
||||
target: string
|
||||
@@ -284,8 +373,7 @@ interface AddForm {
|
||||
const EMPTY_FORM: AddForm = {
|
||||
name: '',
|
||||
src: [],
|
||||
matchKind: 'none',
|
||||
matchValue: '',
|
||||
port: '',
|
||||
rulesets: [],
|
||||
proto: '',
|
||||
target: 'direct',
|
||||
@@ -318,6 +406,7 @@ const browserTZName = (): string => {
|
||||
}
|
||||
|
||||
export default function Routing() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
@@ -435,7 +524,7 @@ export default function Routing() {
|
||||
}, [config])
|
||||
|
||||
/**
|
||||
* The verdict for one rule, or null when it can fire.
|
||||
* The daemon's verdict for one rule, or null when we have none that describes it.
|
||||
*
|
||||
* Verdicts are fetched separately from the config, so between an optimistic edit
|
||||
* and the refetch they can describe the PREVIOUS rule list. Re-checking the
|
||||
@@ -443,18 +532,53 @@ export default function Routing() {
|
||||
* badge on a working rule: a mismatch means the verdict is not about this row,
|
||||
* and no badge is the honest answer.
|
||||
*/
|
||||
const shadowOf = useCallback(
|
||||
(r: RRule): { by: string; byOrder: number; reason: string } | null => {
|
||||
const verdictOf = useCallback(
|
||||
(r: RRule): RuleReach | null => {
|
||||
const i = modelIndex.get(r)
|
||||
if (i === undefined) return null
|
||||
const v = reach.get(i)
|
||||
if (!v || !v.unreachable || !v.shadowed_by) return null
|
||||
if (v.name !== r.Name || v.order !== r.Order) return null
|
||||
return { by: v.shadowed_by, byOrder: v.shadowed_by_order ?? 0, reason: v.reason ?? '' }
|
||||
if (!v || v.name !== r.Name || v.order !== r.Order) return null
|
||||
return v
|
||||
},
|
||||
[modelIndex, reach],
|
||||
)
|
||||
|
||||
const shadowOf = useCallback(
|
||||
(r: RRule): { by: string; byOrder: number; reason: string } | null => {
|
||||
const v = verdictOf(r)
|
||||
if (!v || !v.unreachable || !v.shadowed_by) return null
|
||||
return { by: v.shadowed_by, byOrder: v.shadowed_by_order ?? 0, reason: v.reason ?? '' }
|
||||
},
|
||||
[verdictOf],
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a rule is IN FORCE, and who decided that — the two states this page
|
||||
* used to conflate.
|
||||
*
|
||||
* `Rule.Enabled` from /api/config is the DESIRED state: what the operator saved,
|
||||
* what the switch edits, what gets PUT back. The active WAN profile can override
|
||||
* it in either direction, and then the desired state is no longer what the router
|
||||
* is doing. Drawing the row from `Enabled` is what let a config with two rules
|
||||
* `enabled '1'` show two live switches while the engine ran one chain.
|
||||
*
|
||||
* With no verdict — an older daemon, a stopped one, or one still describing the
|
||||
* previous config — the desired state is all we know, so the row falls back to it
|
||||
* and claims no profile rather than inventing one. The `typeof` guard is for the
|
||||
* older daemon specifically: it answers without `effective_enabled` at all, and
|
||||
* reading `undefined` as false would gray out every rule on the page.
|
||||
*/
|
||||
const forceOf = useCallback(
|
||||
(r: RRule): RuleForce => {
|
||||
const v = verdictOf(r)
|
||||
if (!v || typeof v.effective_enabled !== 'boolean') {
|
||||
return { on: !!r.Enabled, profile: null, dir: null }
|
||||
}
|
||||
return { on: v.effective_enabled, profile: v.overridden_by ?? null, dir: v.override ?? null }
|
||||
},
|
||||
[verdictOf],
|
||||
)
|
||||
|
||||
// Rulesets are named domain/IP lists rules match against (rule.DstRuleset).
|
||||
const rulesets = useMemo<Ruleset[]>(
|
||||
() => [...((config?.Rulesets as Ruleset[] | null | undefined) ?? [])],
|
||||
@@ -555,13 +679,17 @@ export default function Routing() {
|
||||
// Delete the ruleset AND strip its name from any rule that referenced it, so no
|
||||
// rule is left pointing at a matcher that no longer exists (one atomic persist).
|
||||
const deleteRuleset = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
if (!config) return
|
||||
const used = rulesetUsage.get(name) ?? 0
|
||||
const warn = used
|
||||
? `Delete ruleset "${name}"? It'll be removed from ${used} rule${used === 1 ? '' : 's'} that match it.`
|
||||
: `Delete ruleset "${name}"?`
|
||||
if (!window.confirm(warn)) return
|
||||
const ok = await confirm({
|
||||
label: 'Delete ruleset',
|
||||
title: `Delete ruleset "${name}"?`,
|
||||
body: used
|
||||
? `It'll be removed from ${used} rule${used === 1 ? '' : 's'} that match it.`
|
||||
: undefined,
|
||||
})
|
||||
if (!ok) return
|
||||
const nextRulesets = rulesets.filter((r) => r.Name !== name)
|
||||
const nextRules = rules.map((r) => {
|
||||
const cur = r.DstRuleset ?? []
|
||||
@@ -572,20 +700,64 @@ export default function Routing() {
|
||||
`ruleset ${name} deleted`,
|
||||
)
|
||||
},
|
||||
[config, rules, rulesets, rulesetUsage, persist],
|
||||
[config, rules, rulesets, rulesetUsage, persist, confirm],
|
||||
)
|
||||
|
||||
/**
|
||||
* Is this rule the one the ENGINE uses as route.Final right now, and in force?
|
||||
*
|
||||
* Both halves matter. A conditionless rule the daemon reports as shadowed owns
|
||||
* nothing (the row already says "never applies"), and one that is switched off
|
||||
* is not being emitted either — removing either changes no traffic, so neither
|
||||
* earns a warning.
|
||||
*/
|
||||
const isLiveDefault = useCallback(
|
||||
(r: RRule): boolean => isCatchAll(r) && shadowOf(r) === null && forceOf(r).on,
|
||||
[shadowOf, forceOf],
|
||||
)
|
||||
|
||||
/** Which rule would inherit route.Final if `name` stopped being emitted: the
|
||||
* LAST remaining conditionless, switched-on rule. null when there is none. */
|
||||
const successorDefault = useCallback(
|
||||
(name: string): { name: string; order: number; target: string } | null => {
|
||||
for (let i = rules.length - 1; i >= 0; i--) {
|
||||
const r = rules[i]
|
||||
if (r.Name === name) continue
|
||||
if (r.Enabled && isCatchAll(r)) {
|
||||
return { name: r.Name, order: r.Order, target: effectiveTarget(r) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[rules],
|
||||
)
|
||||
|
||||
const killSwitch = (config?.Globals?.KillSwitch ?? 'closed') === 'open' ? 'open' : 'closed'
|
||||
|
||||
const onToggle = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
const target = rules.find((r) => r.Name === name)
|
||||
if (!target) return
|
||||
const nextState = !target.Enabled
|
||||
// Switching the default route OFF is the same event as deleting it — the
|
||||
// generator emits only enabled rules — so it asks the same question. It used
|
||||
// to ask nothing at all, which made the least reversible control on the page
|
||||
// the only one with no confirmation.
|
||||
if (!nextState && isLiveDefault(target)) {
|
||||
const ok = await confirm({
|
||||
label: 'Turn off default route',
|
||||
title: `Turn off the default route “${name}”?`,
|
||||
body: defaultRouteConsequence(killSwitch, successorDefault(name)),
|
||||
confirmLabel: 'Turn it off',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
commitRules(
|
||||
rules.map((r) => (r.Name === name ? { ...r, Enabled: nextState } : r)),
|
||||
`${name} ${nextState ? 'enabled' : 'disabled'}`,
|
||||
)
|
||||
},
|
||||
[rules, commitRules],
|
||||
[rules, commitRules, confirm, isLiveDefault, killSwitch, successorDefault],
|
||||
)
|
||||
|
||||
const onMove = useCallback(
|
||||
@@ -612,17 +784,33 @@ export default function Routing() {
|
||||
)
|
||||
|
||||
const onDelete = useCallback(
|
||||
(name: string) => {
|
||||
if (!window.confirm(`Delete rule "${name}"? Traffic it matched will fall through to the next rule.`)) return
|
||||
async (name: string) => {
|
||||
const target = rules.find((r) => r.Name === name)
|
||||
if (!target) return
|
||||
// The catch-all has no "next rule" to fall through to — see
|
||||
// defaultRouteConsequence. Every other rule keeps the plain sentence.
|
||||
const isDefault = isLiveDefault(target)
|
||||
const ok = await confirm({
|
||||
label: isDefault ? 'Delete default route' : 'Delete rule',
|
||||
title: isDefault ? `Delete the default route “${name}”?` : `Delete rule “${name}”?`,
|
||||
body: isDefault
|
||||
? defaultRouteConsequence(killSwitch, successorDefault(name))
|
||||
: 'Traffic it matched will fall through to the next rule.',
|
||||
})
|
||||
if (!ok) return
|
||||
commitRules(
|
||||
rules.filter((r) => r.Name !== name),
|
||||
`${name} deleted`,
|
||||
)
|
||||
},
|
||||
[rules, commitRules],
|
||||
[rules, commitRules, confirm, isLiveDefault, killSwitch, successorDefault],
|
||||
)
|
||||
|
||||
// Insert a new rule just above the catch-all (so a specific rule can actually match).
|
||||
// isCatchAll() is false for an unmigrated rule, which is the right answer here too:
|
||||
// such a rule is held disabled and owns no default route, so there is nothing to
|
||||
// insert ahead of — the new rule simply goes last, where a rule with no matchers
|
||||
// does become the default.
|
||||
const nextOrder = useCallback((): { order: number; bumpCatchAll?: { name: string; order: number } } => {
|
||||
if (rules.length === 0) return { order: 10 }
|
||||
const last = rules[rules.length - 1]
|
||||
@@ -674,18 +862,15 @@ export default function Routing() {
|
||||
return
|
||||
}
|
||||
setFormError(null)
|
||||
const mv = form.matchValue.trim()
|
||||
const rule: RRule = {
|
||||
Name: name,
|
||||
Enabled: true,
|
||||
Order: 0,
|
||||
Src: form.src,
|
||||
// Domains are matched via rulesets only — the add form has no free-text
|
||||
// domain matcher by design.
|
||||
DstDomain: [],
|
||||
// Destination = rulesets, always. Domains and addresses live in a
|
||||
// `config ruleset` so one list serves every rule that needs it.
|
||||
DstRuleset: form.rulesets,
|
||||
DstIP: form.matchKind === 'ip' ? csv(mv) : [],
|
||||
DstPort: form.matchKind === 'port' ? mv : '',
|
||||
DstPort: form.port.trim(),
|
||||
Proto: form.proto,
|
||||
Target: form.target,
|
||||
Egress: '',
|
||||
@@ -756,7 +941,14 @@ export default function Routing() {
|
||||
)
|
||||
}
|
||||
|
||||
const enabledCount = rules.filter((r) => r.Enabled).length
|
||||
// One force verdict per displayed rule, computed once and handed down — the row,
|
||||
// the counter and the banner must all be reading the SAME answer.
|
||||
const force = rules.map((r) => forceOf(r))
|
||||
// EFFECTIVE, not configured. A counter that added up saved switches said "2 / 2
|
||||
// active" for a config the router was running one rule of.
|
||||
const enabledCount = force.filter((f) => f.on).length
|
||||
const overridden = force.filter((f) => f.profile !== null)
|
||||
const overrideProfile = overridden[0]?.profile ?? null
|
||||
|
||||
return (
|
||||
<section className="page" aria-label="Routing rules">
|
||||
@@ -765,12 +957,28 @@ export default function Routing() {
|
||||
Rules run top to bottom on the bus — the <strong>first match wins</strong>. Traffic that
|
||||
reaches the bottom follows the default route.
|
||||
</p>
|
||||
<span className="rt-count mono" aria-label={`${enabledCount} of ${rules.length} rules active`}>
|
||||
<span className="rt-count mono" aria-label={`${enabledCount} of ${rules.length} rules in force`}>
|
||||
{enabledCount}
|
||||
<small> / {rules.length} active</small>
|
||||
<small> / {rules.length} in force</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Said once at the top, so the per-row badges below read as consequences of
|
||||
one thing rather than as N unrelated oddities. Only shown when a profile
|
||||
actually changed something: a router that uses no profiles, or one whose
|
||||
profile agrees with every saved switch, gets no banner at all. */}
|
||||
{overrideProfile && (
|
||||
<p className="rt-prof-banner" role="status">
|
||||
<span className="rt-prof-tag mono">profile</span>
|
||||
<span>
|
||||
<strong className="mono">{overrideProfile}</strong> is the active WAN profile and is
|
||||
overriding {overridden.length === 1 ? '1 rule' : `${overridden.length} rules`} below. The
|
||||
switches keep showing what you saved; the rows show what the router is running. Change
|
||||
which rules a profile forces on the <strong>Profiles</strong> page.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<p className="page-error" role="alert">
|
||||
{actionError}
|
||||
@@ -789,7 +997,7 @@ export default function Routing() {
|
||||
{rules.length === 0 ? (
|
||||
<div className="rt-empty">
|
||||
<p>No rules — all traffic follows the default route.</p>
|
||||
<p className="rt-empty-sub">Add a rule below to steer a domain, address, or port.</p>
|
||||
<p className="rt-empty-sub">Add a rule below to steer a destination list, source, or port.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="rt-list" aria-label="Routing rules in first-match order">
|
||||
@@ -818,6 +1026,7 @@ export default function Routing() {
|
||||
busy={saving}
|
||||
editingOther={editingRule !== null}
|
||||
shadow={shadowOf(r)}
|
||||
force={force[i]}
|
||||
onEdit={onEditRule}
|
||||
onToggle={onToggle}
|
||||
onMove={onMove}
|
||||
@@ -868,6 +1077,7 @@ function RuleRow({
|
||||
busy,
|
||||
editingOther,
|
||||
shadow,
|
||||
force,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onMove,
|
||||
@@ -880,6 +1090,9 @@ function RuleRow({
|
||||
editingOther: boolean
|
||||
/** Set when the daemon reports this rule can never fire; null when it can. */
|
||||
shadow: { by: string; byOrder: number; reason: string } | null
|
||||
/** Whether the rule is IN FORCE, and which profile decided that (see RuleForce).
|
||||
* Every mark on this row comes from here; `rule.Enabled` drives only the switch. */
|
||||
force: RuleForce
|
||||
onEdit: (name: string) => void
|
||||
onToggle: (name: string) => void
|
||||
onMove: (name: string, dir: 'up' | 'down') => void
|
||||
@@ -895,17 +1108,26 @@ function RuleRow({
|
||||
// matched above" line): those are the claim that made two `default` rules
|
||||
// indistinguishable in the first place.
|
||||
const dead = shadow !== null
|
||||
// The unmigrated-rule tripwire (see RRule.LegacyDst / model.IsCatchAll): this
|
||||
// rule's destination is still in the removed schema-v1 options, so the daemon
|
||||
// holds it disabled. It is NOT an ordinary disabled rule — nobody switched it
|
||||
// off — so it gets the same "wired but not connected" amber treatment as a
|
||||
// shadowed rule, plus a badge and a line saying what to run. isCatchAll()
|
||||
// already refuses to call it the default, so `final` marks cannot land here.
|
||||
const legacyDst = (rule.LegacyDst ?? []).filter(Boolean)
|
||||
const unmigrated = legacyDst.length > 0
|
||||
const inert = dead || unmigrated
|
||||
const isDefault = isCatchAll(rule) && !dead
|
||||
const target = effectiveTarget(rule)
|
||||
const tone = targetTone(target)
|
||||
const cls = [
|
||||
'rt-rule',
|
||||
rule.Enabled ? '' : 'off',
|
||||
isDefault ? 'final' : '',
|
||||
dead ? 'dead' : '',
|
||||
]
|
||||
// Dimmed by the EFFECTIVE state, never by the saved one. A rule the active
|
||||
// profile switched off is not in force, and the row has to read that way even
|
||||
// though its switch — which edits the saved setting — is still on.
|
||||
const cls = ['rt-rule', force.on ? '' : 'off', isDefault ? 'final' : '', inert ? 'dead' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
// What the switch says, spelled out, for the moment the two disagree.
|
||||
const savedState = rule.Enabled ? 'on' : 'off'
|
||||
|
||||
return (
|
||||
<li className={cls}>
|
||||
@@ -919,7 +1141,7 @@ function RuleRow({
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<span className={isDefault ? 'rt-ord final' : dead ? 'rt-ord dead' : 'rt-ord'}>
|
||||
<span className={isDefault ? 'rt-ord final' : inert ? 'rt-ord dead' : 'rt-ord'}>
|
||||
{isDefault ? '·' : rule.Order}
|
||||
</span>
|
||||
<button
|
||||
@@ -937,10 +1159,26 @@ function RuleRow({
|
||||
<div className="rt-head">
|
||||
<span className="rt-name">{rule.Name}</span>
|
||||
{isDefault && <span className="rt-badge">default route · final</span>}
|
||||
{unmigrated && <span className="rt-badge dead">held off · not migrated</span>}
|
||||
{dead && <span className="rt-badge dead">never applies</span>}
|
||||
{/* Amber for the rule the profile switched OFF (warn semantics: wired but
|
||||
not connected), accent for the one it switched ON — orange is the
|
||||
faceplate's active state, and a force-enabled rule is exactly that. */}
|
||||
{force.dir === 'disabled' && <span className="rt-badge dead">off · by profile</span>}
|
||||
{force.dir === 'enabled' && <span className="rt-badge prof-on">on · by profile</span>}
|
||||
</div>
|
||||
<div className="rt-match">
|
||||
{dead ? (
|
||||
{unmigrated ? (
|
||||
// Why the rule is off and what fixes it. Same voice as the shadow note:
|
||||
// state, cause, one command. The daemon says the same thing through the
|
||||
// apply warnings (model.ValidateRules); this puts it on the row it is about.
|
||||
<span className="rt-dead-note">
|
||||
Destination still written the old way (<span className="mono">{legacyDst.join(', ')}</span>
|
||||
) — this config was never migrated, so shaterd cannot read where this rule sends
|
||||
traffic and holds it disabled. Run <span className="mono">shaterd migrate</span> on the
|
||||
router to turn those entries into a ruleset, then enable the rule again.
|
||||
</span>
|
||||
) : dead ? (
|
||||
// The badge says it never fires; this line says what beat it and what to
|
||||
// do. Visible text, not a tooltip — the operator has to be able to find
|
||||
// the other rule, and two rows can carry the same name.
|
||||
@@ -955,6 +1193,22 @@ function RuleRow({
|
||||
<Matchers rule={rule} />
|
||||
)}
|
||||
</div>
|
||||
{/* Added BELOW the matchers, not instead of them: the rule's conditions are
|
||||
still worth reading — the operator is deciding whether to change the
|
||||
profile or the rule.
|
||||
Two clauses only. The banner at the top of the page already carries the
|
||||
general explanation and the way to change it, and a profile that
|
||||
overrides several rules would otherwise repeat that paragraph on every
|
||||
one of them. What is left is the part only this row can say: whether it
|
||||
is in force, and what its own switch is showing instead. */}
|
||||
{force.profile && (
|
||||
<p className="rt-dead-note rt-prof-note">
|
||||
{force.dir === 'disabled' ? 'Not in force' : 'In force'} — profile{' '}
|
||||
<strong className="mono">{force.profile}</strong> switches this rule{' '}
|
||||
{force.dir === 'disabled' ? 'off' : 'on'}. The switch still reads{' '}
|
||||
<strong>{savedState}</strong>: that is the saved setting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rt-target ${tone}`} title={`target: ${target}`}>
|
||||
@@ -974,12 +1228,40 @@ function RuleRow({
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Toggle
|
||||
pressed={rule.Enabled}
|
||||
onChange={() => onToggle(rule.Name)}
|
||||
label={`${rule.Enabled ? 'Disable' : 'Enable'} rule ${rule.Name}`}
|
||||
disabled={frozen}
|
||||
/>
|
||||
{/* An unmigrated rule cannot be switched on from here, and the switch says
|
||||
so rather than pretending: the daemon holds it disabled, but a config
|
||||
write from the panel DROPS the unreadable legacy options (render.go
|
||||
emits neither), so enabling it here would save a live rule with no
|
||||
destination left at all — the catch-all this tripwire exists to
|
||||
prevent. `shaterd migrate` clears LegacyDst and the switch comes back. */}
|
||||
{/* The switch edits the SAVED setting and nothing else, so it keeps showing
|
||||
rule.Enabled even while the active profile forces the opposite. Mirroring
|
||||
the effective state here would be worse than the bug it replaces: the
|
||||
operator would flip a switch that was never theirs, and the PUT would
|
||||
write the profile's decision into UCI as if they had chosen it. The row
|
||||
above says what the router is doing; the "saved" caption says what this
|
||||
control is for. */}
|
||||
<span className="rt-switch">
|
||||
<Toggle
|
||||
pressed={rule.Enabled}
|
||||
onChange={() => onToggle(rule.Name)}
|
||||
label={
|
||||
unmigrated
|
||||
? `Rule ${rule.Name} is held disabled until the config is migrated`
|
||||
: force.profile
|
||||
? `Saved setting for rule ${rule.Name} is ${savedState}; profile ${force.profile} is forcing it ${
|
||||
force.dir === 'disabled' ? 'off' : 'on'
|
||||
}. This switch changes the saved setting only.`
|
||||
: `${rule.Enabled ? 'Disable' : 'Enable'} rule ${rule.Name}`
|
||||
}
|
||||
disabled={frozen || unmigrated}
|
||||
/>
|
||||
{force.profile && (
|
||||
<span className="rt-switch-note" aria-hidden="true">
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rt-del"
|
||||
@@ -1012,9 +1294,7 @@ function Matchers({ rule }: { rule: RRule }): ReactNode {
|
||||
)
|
||||
}
|
||||
listChip('src', rule.Src, 'src')
|
||||
listChip('dns', rule.DstDomain, 'dom')
|
||||
listChip('ruleset', rule.DstRuleset, 'rs')
|
||||
listChip('ip', rule.DstIP, 'ip')
|
||||
if (rule.DstPort && rule.DstPort.trim()) {
|
||||
chips.push(
|
||||
<span className="rt-chip" key="port">
|
||||
@@ -1130,7 +1410,16 @@ function TargetOptions({ targets, current }: { targets: TargetGroups; current?:
|
||||
)
|
||||
}
|
||||
|
||||
/** The dst_ruleset checkbox group. Renders nothing when no rulesets exist. */
|
||||
/**
|
||||
* The destination picker: which rulesets this rule matches (dst_ruleset).
|
||||
*
|
||||
* Checkboxes and nothing else. This is the ONLY way a rule names a destination,
|
||||
* so it renders even when the config has no lists yet — an empty picker that says
|
||||
* where lists come from is the honest answer, and hiding it would leave the rule
|
||||
* form with no destination control at all. Building and filling a list is the
|
||||
* Rulesets panel's job, deliberately kept out of the rule editor so a list is
|
||||
* created in exactly one place.
|
||||
*/
|
||||
function RulesetPicker({
|
||||
options,
|
||||
selected,
|
||||
@@ -1142,24 +1431,26 @@ function RulesetPicker({
|
||||
busy: boolean
|
||||
onToggle: (name: string) => void
|
||||
}): ReactNode {
|
||||
if (options.length === 0) return null
|
||||
return (
|
||||
<div className="rt-rsel">
|
||||
<span className="rt-flabel">Match rulesets — dst_ruleset</span>
|
||||
<div className="rt-rsel-opts" role="group" aria-label="Match these rulesets">
|
||||
{options.map((n) => {
|
||||
const on = selected.includes(n)
|
||||
return (
|
||||
<label key={n} className={on ? 'rt-rsel-opt on' : 'rt-rsel-opt'}>
|
||||
<input type="checkbox" checked={on} onChange={() => onToggle(n)} disabled={busy} />
|
||||
<span className="mono">{n}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="rt-flabel">Destination — dst_ruleset</span>
|
||||
{options.length > 0 && (
|
||||
<div className="rt-rsel-opts" role="group" aria-label="Match these rulesets">
|
||||
{options.map((n) => {
|
||||
const on = selected.includes(n)
|
||||
return (
|
||||
<label key={n} className={on ? 'rt-rsel-opt on' : 'rt-rsel-opt'}>
|
||||
<input type="checkbox" checked={on} onChange={() => onToggle(n)} disabled={busy} />
|
||||
<span className="mono">{n}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="rt-rsel-hint">
|
||||
The rule also matches any traffic in the checked list(s). Combine with a domain, address, or
|
||||
port, or use a ruleset on its own.
|
||||
{options.length === 0
|
||||
? 'No rulesets yet. Add one under Rulesets below, then come back and check it here — a rule matches a destination through a ruleset only.'
|
||||
: 'The rule matches traffic in ANY checked list. Narrow it further with a source, port or protocol.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -1285,7 +1576,14 @@ function AddRule({
|
||||
set('rulesets', form.rulesets.includes(n) ? form.rulesets.filter((x) => x !== n) : [...form.rulesets, n])
|
||||
const toggleDay = (d: string) =>
|
||||
set('schedDays', form.schedDays.includes(d) ? form.schedDays.filter((x) => x !== d) : [...form.schedDays, d])
|
||||
const matchPlaceholder = form.matchKind === 'ip' ? '10.0.0.0/8, 100.64.0.0/10' : '443, 8080-8090'
|
||||
|
||||
// Same flag as the edit form, from the same predicate: no matcher = this rule
|
||||
// asks to be route.Final. Whether it wins depends on what is last — it takes
|
||||
// the default when there is no catch-all yet (or the last rule is conditional),
|
||||
// and lands dead when there is one, because nextOrder() inserts ahead of it.
|
||||
// Both are worth flagging, so the text states the fact and not the outcome.
|
||||
// Shown, never blocking: a default route is a legal thing to write.
|
||||
const noMatchers = formHasNoMatchers(form)
|
||||
|
||||
return (
|
||||
<form className="rt-add" onSubmit={onSubmit} aria-label="Add a routing rule">
|
||||
@@ -1318,32 +1616,17 @@ function AddRule({
|
||||
</label>
|
||||
|
||||
<label className="rt-field">
|
||||
<span className="rt-flabel">Match</span>
|
||||
<select
|
||||
<span className="rt-flabel">Port(s)</span>
|
||||
<input
|
||||
className="rt-input mono"
|
||||
value={form.matchKind}
|
||||
onChange={(e) => set('matchKind', e.target.value as MatchKind)}
|
||||
>
|
||||
<option value="none">rulesets only</option>
|
||||
<option value="ip">ip / cidr</option>
|
||||
<option value="port">port</option>
|
||||
</select>
|
||||
value={form.port}
|
||||
onChange={(e) => set('port', e.target.value)}
|
||||
placeholder="443, 8080-8090"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{form.matchKind !== 'none' && (
|
||||
<label className="rt-field rt-field-wide">
|
||||
<span className="rt-flabel">{form.matchKind === 'port' ? 'Port(s)' : 'Address(es)'}</span>
|
||||
<input
|
||||
className="rt-input mono"
|
||||
value={form.matchValue}
|
||||
onChange={(e) => set('matchValue', e.target.value)}
|
||||
placeholder={matchPlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="rt-field">
|
||||
<span className="rt-flabel">Proto</span>
|
||||
<select
|
||||
@@ -1390,6 +1673,9 @@ function AddRule({
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Add rule'}
|
||||
</Button>
|
||||
{noMatchers && !error && (
|
||||
<span className="rt-edit-warn">no matchers — matches everything</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="rt-add-error" role="alert">
|
||||
{error}
|
||||
@@ -1401,11 +1687,11 @@ function AddRule({
|
||||
}
|
||||
|
||||
// --- edit-a-rule plate (inline, replaces the row it edits) ------------------
|
||||
// Unlike AddRule, editing exposes all three destination matchers at once
|
||||
// (Domain(s) / IP-CIDR(s) / Port) rather than a single Match picker — a real rule
|
||||
// can carry several matcher kinds simultaneously and none may be silently dropped.
|
||||
// The full original rule is spread into the result on save, so Order / Enabled /
|
||||
// Kill / Egress (and anything else off-form) survive untouched.
|
||||
// Same fields as AddRule, on purpose: a rule carries exactly one destination
|
||||
// mechanism (rulesets) plus port/proto/source, so there is nothing an edit can
|
||||
// reveal that the add form hides. The full original rule is spread into the
|
||||
// result on save, so Order / Enabled / Kill / Egress (and anything else off-form)
|
||||
// survive untouched.
|
||||
function RuleEditForm({
|
||||
initial,
|
||||
names,
|
||||
@@ -1425,8 +1711,6 @@ function RuleEditForm({
|
||||
}) {
|
||||
const [name, setName] = useState(initial.Name)
|
||||
const [src, setSrc] = useState<string[]>([...(initial.Src ?? [])])
|
||||
const [domain, setDomain] = useState((initial.DstDomain ?? []).join(', '))
|
||||
const [ip, setIp] = useState((initial.DstIP ?? []).join(', '))
|
||||
const [port, setPort] = useState(initial.DstPort ?? '')
|
||||
const [proto, setProto] = useState(initial.Proto ?? '')
|
||||
const [target, setTarget] = useState(effectiveTarget(initial))
|
||||
@@ -1442,14 +1726,15 @@ function RuleEditForm({
|
||||
const toggleDay = (d: string) =>
|
||||
setSchedDays((cur) => (cur.includes(d) ? cur.filter((x) => x !== d) : [...cur, d]))
|
||||
|
||||
// This rule's destination may still be in the schema-v1 options (RRule.LegacyDst).
|
||||
// The form cannot show or edit them — they are not fields any more — and saving
|
||||
// writes the model back through render.go, which does not emit them. So a save
|
||||
// here silently DISCARDS that destination. Say so before it happens; the fix is
|
||||
// `shaterd migrate`, which converts them into a ruleset this form can check.
|
||||
const legacyDst = (initial.LegacyDst ?? []).filter(Boolean)
|
||||
|
||||
// A rule with no matcher of any kind is a catch-all — legal, but worth flagging.
|
||||
const noMatchers =
|
||||
src.length === 0 &&
|
||||
csv(domain).length === 0 &&
|
||||
csv(ip).length === 0 &&
|
||||
port.trim() === '' &&
|
||||
rulesets.length === 0 &&
|
||||
proto.trim() === ''
|
||||
const noMatchers = formHasNoMatchers({ src, port, rulesets, proto })
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -1471,8 +1756,6 @@ function RuleEditForm({
|
||||
...initial,
|
||||
Name: nm,
|
||||
Src: src,
|
||||
DstDomain: csv(domain),
|
||||
DstIP: csv(ip),
|
||||
DstPort: port.trim(),
|
||||
DstRuleset: rulesets,
|
||||
Proto: proto,
|
||||
@@ -1491,7 +1774,15 @@ function RuleEditForm({
|
||||
<form className="rt-add rt-edit-form" onSubmit={submit} aria-label={`Edit rule ${initial.Name}`}>
|
||||
<div className="rt-add-hd">
|
||||
<span className="rt-add-title">Edit {initial.Name}</span>
|
||||
<span className="rt-add-sub">Empty a field to drop that matcher. Save, then Apply.</span>
|
||||
<span className="rt-add-sub">
|
||||
Uncheck a list or clear a field to drop that matcher. Save, then Apply.
|
||||
</span>
|
||||
{legacyDst.length > 0 && (
|
||||
<span className="rt-edit-warn">
|
||||
not migrated — saving discards its old destination ({legacyDst.join(', ')}); run
|
||||
shaterd migrate first
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rt-fields">
|
||||
@@ -1521,37 +1812,6 @@ function RuleEditForm({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Domains are matched via rulesets by design — this legacy field only
|
||||
appears when the rule ALREADY carries free-text domains, so they
|
||||
stay visible and clearable rather than silently preserved. */}
|
||||
{(initial.DstDomain ?? []).length > 0 && (
|
||||
<label className="rt-field rt-field-wide">
|
||||
<span className="rt-flabel">Domain(s) — legacy</span>
|
||||
<input
|
||||
className="rt-input mono"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="youtube.com, *.googlevideo.com"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="rt-field rt-field-wide">
|
||||
<span className="rt-flabel">IP / CIDR(s)</span>
|
||||
<input
|
||||
className="rt-input mono"
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
placeholder="10.0.0.0/8, 100.64.0.0/10"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="rt-field">
|
||||
<span className="rt-flabel">Port(s)</span>
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './Settings.css'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Led, Select, Toggle } from '../components'
|
||||
import { Button, Led, Select, Toggle, useConfirm } from '../components'
|
||||
import { apply as apiApply, downloadLog, getConfig, putConfig, ApiError } from '../api'
|
||||
import type { Globals, LogRange, Model } from '../api'
|
||||
|
||||
@@ -125,6 +125,7 @@ const STATS_BACKENDS: ReadonlyArray<{ value: string; label: string }> = [
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Settings() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -257,6 +258,48 @@ export default function Settings() {
|
||||
const groupHealthOn = globals?.GroupHealth !== false
|
||||
|
||||
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 =
|
||||
killSwitch === 'open'
|
||||
? 'Fail-open — if the engine stops, traffic falls back to the direct WAN. Stays online, but unprotected.'
|
||||
@@ -296,10 +339,13 @@ export default function Settings() {
|
||||
<div className="set-groups">
|
||||
{/* ---- SERVICE ---- */}
|
||||
<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
|
||||
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'}
|
||||
size="md"
|
||||
disabled={busy || !ready}
|
||||
|
||||
@@ -704,6 +704,13 @@
|
||||
.tg-test--bad .tg-test-msg {
|
||||
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 {
|
||||
color: var(--amber);
|
||||
}
|
||||
@@ -1038,6 +1045,235 @@
|
||||
}
|
||||
|
||||
/* ---- 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) {
|
||||
.tg-sec-hd {
|
||||
flex-wrap: wrap;
|
||||
@@ -1060,6 +1296,20 @@
|
||||
.gh-now {
|
||||
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 {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
+458
-100
@@ -1,6 +1,6 @@
|
||||
import './Targets.css'
|
||||
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 {
|
||||
apply as apiApply,
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
GroupTestResult,
|
||||
GroupTestStatus,
|
||||
Chain,
|
||||
ChainHealth,
|
||||
ChainHopHealth,
|
||||
Egress,
|
||||
Interface,
|
||||
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,
|
||||
* and what happens to it. Empty list ⇒ an explicit "nothing references it", so
|
||||
* the operator can delete a stray with confidence instead of guessing.
|
||||
* The body of a delete confirmation: what still points at this target, and what
|
||||
* happens to it. Empty list ⇒ an explicit "nothing references it", so the
|
||||
* operator can delete a stray with confidence instead of guessing.
|
||||
*/
|
||||
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 more = refs.length - shown.length
|
||||
const list = `${shown.join(', ')}${more > 0 ? `, and ${more} more` : ''}`
|
||||
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 ${refs.length} places — ${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).`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* they pressed; "1 target" tells them nothing they didn't already know.
|
||||
*/
|
||||
function scopeLabel(scope: string[], targetCount: number): string {
|
||||
if (scope.length === 1) return scope[0]
|
||||
if (scope.length === 0) return 'exits' // pre-scope daemon — say nothing false
|
||||
return scope.length >= targetCount ? 'every exit' : `${scope.length} exits`
|
||||
if (scope.length === 0) return 'targets' // pre-scope daemon — say nothing false
|
||||
return scope.length >= targetCount ? 'every target' : `${scope.length} targets`
|
||||
}
|
||||
|
||||
/** Which editor (add or edit-by-name) is open within a section. */
|
||||
@@ -457,6 +459,7 @@ interface Opt {
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
export default function Targets() {
|
||||
const confirm = useConfirm()
|
||||
const [config, setConfig] = useState<Model | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
@@ -589,10 +592,12 @@ export default function Targets() {
|
||||
[health],
|
||||
)
|
||||
|
||||
// ---- group/chain exit test: 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
|
||||
// plus every result so far. One endpoint covers groups and chains alike:
|
||||
// POST with a group or chain name tests that one; an empty name tests them all.
|
||||
// ---- out-of-turn refresh: how fast, through which node, out which address ----
|
||||
// The POST does NOT dial. It asks the observatory — the only thing in the daemon
|
||||
// that measures anything, and it measures along the real dial path — to come
|
||||
// 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 [gtestErr, setGtestErr] = useState<string | null>(null)
|
||||
const [polling, setPolling] = useState(false)
|
||||
@@ -635,7 +640,7 @@ export default function Targets() {
|
||||
void readTest().then((st) => {
|
||||
if (!alive || !st || st.running) return
|
||||
setPolling(false)
|
||||
flash('Group test complete')
|
||||
flash('Readings refreshed')
|
||||
})
|
||||
}, 2000)
|
||||
return () => {
|
||||
@@ -651,16 +656,16 @@ export default function Targets() {
|
||||
if (r.started) {
|
||||
setGtestErr(null)
|
||||
setPolling(true)
|
||||
flash(name ? `Testing ${name}…` : 'Testing every exit…')
|
||||
flash(name ? `Refreshing ${name}…` : 'Refreshing every reading…')
|
||||
void readTest()
|
||||
} else if (r.reason === 'already running') {
|
||||
setPolling(true) // pick up the run someone else started
|
||||
flash('A group test is already running')
|
||||
setPolling(true) // pick up the pass someone else started
|
||||
flash('The prober is already refreshing')
|
||||
} 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) {
|
||||
flash(`Couldn’t start the test — ${errText(e)}`)
|
||||
flash(`Couldn’t ask for a refresh — ${errText(e)}`)
|
||||
}
|
||||
},
|
||||
[flash, readTest],
|
||||
@@ -787,13 +792,18 @@ export default function Targets() {
|
||||
)
|
||||
|
||||
const removeGroup = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
if (!config) return
|
||||
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}`)
|
||||
},
|
||||
[config, groups, save],
|
||||
[config, groups, save, confirm],
|
||||
)
|
||||
|
||||
// ---- chain mutations ------------------------------------------------------
|
||||
@@ -820,13 +830,18 @@ export default function Targets() {
|
||||
)
|
||||
|
||||
const removeChain = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
if (!config) return
|
||||
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}`)
|
||||
},
|
||||
[config, chains, save],
|
||||
[config, chains, save, confirm],
|
||||
)
|
||||
|
||||
// ---- egress mutations -----------------------------------------------------
|
||||
@@ -853,13 +868,18 @@ export default function Targets() {
|
||||
)
|
||||
|
||||
const removeEgress = useCallback(
|
||||
(name: string) => {
|
||||
async (name: string) => {
|
||||
if (!config) return
|
||||
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}`)
|
||||
},
|
||||
[config, egresses, save],
|
||||
[config, egresses, save, confirm],
|
||||
)
|
||||
|
||||
const busy = saving || applying
|
||||
@@ -894,10 +914,11 @@ export default function Targets() {
|
||||
<h2 className="tg-sec-title">Groups</h2>
|
||||
<span className="tg-sec-count mono">{groups.length} configured</span>
|
||||
{/* The observatory's background probing is invisible by design — it
|
||||
keeps every used group's and chain's numbers fresh on its own. The
|
||||
one manual run left is the exit test: it is scoped to the groups
|
||||
and chains it names, so its progress says WHICH, and its badge
|
||||
lands only on those cards. */}
|
||||
keeps every used group's and chain's numbers fresh on its own, along
|
||||
the path traffic actually takes. The one manual control left does
|
||||
not measure anything itself: it asks that prober to come round out
|
||||
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">
|
||||
{groupHealthOn && (
|
||||
<>
|
||||
@@ -905,11 +926,11 @@ export default function Targets() {
|
||||
<span
|
||||
className="tg-run tg-run--exit"
|
||||
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 />
|
||||
<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 className="tg-run-n mono">
|
||||
{gtest.done}/{gtest.total}
|
||||
@@ -919,9 +940,9 @@ export default function Targets() {
|
||||
<Button
|
||||
onClick={() => void runTest()}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
@@ -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
|
||||
in one group and dead in another.
|
||||
</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 && (
|
||||
<p className="tg-test-err" role="alert">
|
||||
@@ -951,7 +979,7 @@ export default function Targets() {
|
||||
|
||||
{groupHealthOn && gtestErr && (
|
||||
<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()}>
|
||||
Retry
|
||||
</button>
|
||||
@@ -1094,7 +1122,9 @@ export default function Targets() {
|
||||
chain={c}
|
||||
busy={busy}
|
||||
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)}
|
||||
// The badge is this card's business only when the run names it.
|
||||
testing={gtest.running && testScope.has(c.Name)}
|
||||
@@ -1216,7 +1246,7 @@ function GroupRow({
|
||||
group: Group
|
||||
busy: boolean
|
||||
/** 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
|
||||
/** This group's membership health, or undefined when the engine hasn't built
|
||||
* it (not applied yet, or dropped for having no usable members). */
|
||||
@@ -1226,14 +1256,14 @@ function GroupRow({
|
||||
healthKnown: boolean
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
onTest: () => void
|
||||
onEdit: () => void
|
||||
@@ -1293,7 +1323,11 @@ function GroupRow({
|
||||
health={health}
|
||||
healthKnown={healthKnown}
|
||||
/>
|
||||
<GroupTestReadout test={test} pending={testing && !test} />
|
||||
<GroupTestReadout
|
||||
test={test}
|
||||
pending={testing && !test}
|
||||
hideAbsence={health?.used === false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1304,7 +1338,7 @@ function GroupRow({
|
||||
editLabel={`Edit group ${group.Name}`}
|
||||
deleteLabel={`Delete group ${group.Name}`}
|
||||
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}
|
||||
/>
|
||||
</li>
|
||||
@@ -1363,21 +1397,7 @@ function GroupHealthReadout({
|
||||
// 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
|
||||
// read as a permanent unknown, the card says so, quietly: unused, not unwell.
|
||||
if (!health.used) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
if (!health.used) return <NotRoutedNote kind="group" />
|
||||
|
||||
const v = verdictOf(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.
|
||||
*
|
||||
@@ -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
|
||||
* 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,
|
||||
* 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) {
|
||||
// "testing", never "measuring": the health run owns that word and covers every
|
||||
// group at once. Two runs that read the same on a card is how one group's test
|
||||
// came to look like all four were busy.
|
||||
// Names who is working and on what: the prober, on this target. The badge is
|
||||
// scoped to the cards the run covers, so it can say "this one" honestly.
|
||||
return (
|
||||
<div className="tg-test tg-test--wait" role="status">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1670,10 +1772,22 @@ function GroupTestReadout({ test, pending }: { test?: GroupTestResult; pending:
|
||||
const at = test.tested_unix ? fmtClock(test.tested_unix) : ''
|
||||
|
||||
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 (
|
||||
<div className="tg-test tg-test--bad" role="status">
|
||||
<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>}
|
||||
</div>
|
||||
)
|
||||
@@ -2098,7 +2212,7 @@ function ChainRow({
|
||||
chain,
|
||||
busy,
|
||||
showHealth,
|
||||
used,
|
||||
health,
|
||||
test,
|
||||
testing,
|
||||
testBusy,
|
||||
@@ -2109,31 +2223,41 @@ function ChainRow({
|
||||
chain: Chain
|
||||
busy: boolean
|
||||
/** 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
|
||||
/** This chain's reachability (GroupHealth.Used's chain analogue, plan §5.E).
|
||||
* undefined ⇒ the health endpoint hasn't reported this chain (not applied yet, or
|
||||
* a daemon version without chains): no badge. false ⇒ no enabled rule routes
|
||||
* through the chain, so the observatory never probes it and the card renders
|
||||
* "unused" instead of an exit-test readout. */
|
||||
used?: boolean
|
||||
/** Everything the observatory knows about this chain: whether any enabled rule
|
||||
* routes through it, and the per-hop measurements along it.
|
||||
* undefined ⇒ the health endpoint hasn't reported this chain at all (not
|
||||
* applied yet, or a daemon version without chains): the card says nothing
|
||||
* rather than guessing. */
|
||||
health?: ChainHealth
|
||||
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). */
|
||||
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
|
||||
onTest: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
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 (
|
||||
<li className="tg-row">
|
||||
<div className="tg-row-main">
|
||||
<div className="tg-row-l1">
|
||||
<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 className="tg-row-l2">
|
||||
{hops.length === 0 ? (
|
||||
@@ -2165,25 +2289,21 @@ function ChainRow({
|
||||
{showHealth && (
|
||||
<>
|
||||
{/* A chain no enabled rule routes through is never probed (the
|
||||
observatory walks only reachable paths), so instead of an exit-test
|
||||
readout the card says so, quietly — the same "unused" pattern the
|
||||
group card uses (GroupHealthReadout), not a new design. `used` is
|
||||
undefined until the health endpoint reports this chain (or from a
|
||||
daemon version without chains): no badge then. */}
|
||||
{used === false && (
|
||||
<div className="gh gh--unused">
|
||||
<div className="gh-line">
|
||||
<span
|
||||
className="gh-unused"
|
||||
title="No enabled rule routes through this chain, so its exit is not probed. Add it to a rule to see health."
|
||||
>
|
||||
unused
|
||||
</span>
|
||||
<span className="gh-quiet">not probed — no enabled rule routes through this chain</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<GroupTestReadout test={test} pending={testing && !test} />
|
||||
observatory walks only reachable paths), so instead of a health
|
||||
readout the card says so — the same "unused" note the group card
|
||||
uses, not a new design. `health` is undefined until the endpoint
|
||||
reports this chain (or on a daemon without chains): say nothing
|
||||
then rather than guess. */}
|
||||
{health?.used === false ? (
|
||||
<NotRoutedNote kind="chain" />
|
||||
) : health?.used ? (
|
||||
<ChainHopRail chain={chain.Name} defs={hops} hops={health.hops} />
|
||||
) : null}
|
||||
<GroupTestReadout
|
||||
test={test}
|
||||
pending={testing && !test}
|
||||
hideAbsence={health?.used === false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -2194,13 +2314,250 @@ function ChainRow({
|
||||
editLabel={`Edit chain ${chain.Name}`}
|
||||
deleteLabel={`Delete chain ${chain.Name}`}
|
||||
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}
|
||||
/>
|
||||
</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({
|
||||
initial,
|
||||
hopOptions,
|
||||
@@ -2675,8 +3032,8 @@ function RowActions({
|
||||
busy: boolean
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
// Only groups and chains can be tested, so the control is optional and absent
|
||||
// everywhere else rather than a disabled stub on every row.
|
||||
// Only groups and chains are probed, so the refresh control is optional and
|
||||
// absent everywhere else rather than a disabled stub on every row.
|
||||
onTest?: () => void
|
||||
testLabel?: string
|
||||
testDisabled?: boolean
|
||||
@@ -2689,8 +3046,9 @@ function RowActions({
|
||||
onClick={onTest}
|
||||
disabled={busy || testDisabled}
|
||||
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 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,191 @@
|
||||
// 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, 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…')
|
||||
})
|
||||
+166
-17
@@ -11,7 +11,7 @@
|
||||
// same router differently.
|
||||
|
||||
import type { LedVariant } from './components'
|
||||
import type { Status } from './api'
|
||||
import type { Status, Traffic } from './api'
|
||||
|
||||
export interface ProtectionState {
|
||||
variant: LedVariant
|
||||
@@ -21,6 +21,56 @@ export interface ProtectionState {
|
||||
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…' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `plane` + `engine_running` express the state more precisely than the three
|
||||
* booleans the old status strip exposed (engine active / config enabled / nft
|
||||
@@ -31,6 +81,24 @@ export interface ProtectionState {
|
||||
* hold — the kill-switch caught it. Protected, but offline.
|
||||
* none (fail-closed) — there is no protection at all. Online, and exposed.
|
||||
* 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 {
|
||||
if (!status) {
|
||||
@@ -56,12 +124,7 @@ export function protectionState(status: Status | null): ProtectionState {
|
||||
|
||||
switch (status.plane) {
|
||||
case 'full':
|
||||
return {
|
||||
variant: 'on',
|
||||
headline: 'Protected',
|
||||
detail: 'Traffic from your network is going through the tunnel.',
|
||||
alarm: false,
|
||||
}
|
||||
return fullPlaneState(status.traffic)
|
||||
case 'hold':
|
||||
return {
|
||||
variant: 'amber',
|
||||
@@ -88,8 +151,18 @@ export function protectionState(status: Status | null): ProtectionState {
|
||||
}
|
||||
}
|
||||
|
||||
// Older daemon with no `plane` field: fall back to what we can observe.
|
||||
if (status.running && status.active && status.table) {
|
||||
// Older daemon with no `plane` field: fall back to what we can observe. The
|
||||
// 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 {
|
||||
variant: 'on',
|
||||
headline: 'Protected',
|
||||
@@ -97,14 +170,6 @@ export function protectionState(status: Status | null): ProtectionState {
|
||||
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 {
|
||||
variant: 'amber',
|
||||
headline: 'Starting up',
|
||||
@@ -112,3 +177,87 @@ export function protectionState(status: Status | null): ProtectionState {
|
||||
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,
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,120 +0,0 @@
|
||||
// lx:begin awg
|
||||
|
||||
package group
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
)
|
||||
|
||||
// fakeOutbound is a minimal adapter.Outbound; only Type/Tag/Dependencies are read.
|
||||
type fakeOutbound struct {
|
||||
adapter.Outbound
|
||||
tag string
|
||||
outboundTyp string
|
||||
detour string
|
||||
}
|
||||
|
||||
func (o *fakeOutbound) Type() string { return o.outboundTyp }
|
||||
func (o *fakeOutbound) Tag() string { return o.tag }
|
||||
func (o *fakeOutbound) Dependencies() []string {
|
||||
if o.detour == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{o.detour}
|
||||
}
|
||||
|
||||
// fakeAWG implements adapter.AmneziaWGSuspendable and records suspension.
|
||||
type fakeAWG struct {
|
||||
fakeOutbound
|
||||
awg bool
|
||||
suspended bool
|
||||
}
|
||||
|
||||
func (a *fakeAWG) IsAmneziaWG() bool { return a.awg }
|
||||
func (a *fakeAWG) SuspendAmneziaWG() { a.suspended = true }
|
||||
|
||||
// fakeManager resolves tags and reverse-deps (ConsumersOf) from fixed maps.
|
||||
type fakeManager struct {
|
||||
adapter.OutboundManager
|
||||
byTag map[string]adapter.Outbound
|
||||
consumers map[string][]string
|
||||
}
|
||||
|
||||
func (m *fakeManager) Outbound(tag string) (adapter.Outbound, bool) {
|
||||
ob, ok := m.byTag[tag]
|
||||
return ob, ok
|
||||
}
|
||||
func (m *fakeManager) ConsumersOf(tag string) []string { return m.consumers[tag] }
|
||||
|
||||
func TestChainReachesWireGuard(t *testing.T) {
|
||||
wg := &fakeOutbound{tag: "wg", outboundTyp: C.TypeWireGuard}
|
||||
vlessToWG := &fakeOutbound{tag: "v2wg", outboundTyp: C.TypeVLESS, detour: "wg"}
|
||||
vlessLeaf := &fakeOutbound{tag: "vleaf", outboundTyp: C.TypeVLESS}
|
||||
mgr := &fakeManager{byTag: map[string]adapter.Outbound{
|
||||
"wg": wg, "v2wg": vlessToWG, "vleaf": vlessLeaf,
|
||||
}}
|
||||
|
||||
if !chainReachesWireGuard(mgr, wg, map[string]bool{}) {
|
||||
t.Fatal("direct wireguard member must reach wireguard")
|
||||
}
|
||||
if !chainReachesWireGuard(mgr, vlessToWG, map[string]bool{}) {
|
||||
t.Fatal("vless detouring to wireguard must reach wireguard")
|
||||
}
|
||||
if chainReachesWireGuard(mgr, vlessLeaf, map[string]bool{}) {
|
||||
t.Fatal("plain vless must not reach wireguard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendAmneziaWGConsumers(t *testing.T) {
|
||||
// awg-direct detours through the group "sel"
|
||||
awgDirect := &fakeAWG{fakeOutbound: fakeOutbound{tag: "awg-direct", outboundTyp: C.TypeWireGuard, detour: "sel"}, awg: true}
|
||||
// awg-via-hop -> vless-hop -> sel
|
||||
awgViaHop := &fakeAWG{fakeOutbound: fakeOutbound{tag: "awg-hop", outboundTyp: C.TypeWireGuard, detour: "vless-hop"}, awg: true}
|
||||
vlessHop := &fakeOutbound{tag: "vless-hop", outboundTyp: C.TypeVLESS, detour: "sel"}
|
||||
// plain-wg detours through sel but is NOT amneziawg — must stay untouched
|
||||
plainWG := &fakeAWG{fakeOutbound: fakeOutbound{tag: "plain-wg", outboundTyp: C.TypeWireGuard, detour: "sel"}, awg: false}
|
||||
|
||||
mgr := &fakeManager{
|
||||
byTag: map[string]adapter.Outbound{
|
||||
"awg-direct": awgDirect, "awg-hop": awgViaHop,
|
||||
"vless-hop": vlessHop, "plain-wg": plainWG,
|
||||
},
|
||||
consumers: map[string][]string{
|
||||
"sel": {"awg-direct", "vless-hop", "plain-wg"},
|
||||
"vless-hop": {"awg-hop"},
|
||||
},
|
||||
}
|
||||
|
||||
suspendAmneziaWGConsumers(mgr, "sel", map[string]bool{})
|
||||
|
||||
if !awgDirect.suspended {
|
||||
t.Error("direct AmneziaWG consumer must be suspended")
|
||||
}
|
||||
if !awgViaHop.suspended {
|
||||
t.Error("transitive AmneziaWG consumer (via vless hop) must be suspended")
|
||||
}
|
||||
if plainWG.suspended {
|
||||
t.Error("plain (non-AmneziaWG) wireguard consumer must NOT be suspended")
|
||||
}
|
||||
}
|
||||
|
||||
// A switch to a non-wireguard member must suspend nothing.
|
||||
func TestSuspendSkippedForNonWireGuardSwitch(t *testing.T) {
|
||||
awg := &fakeAWG{fakeOutbound: fakeOutbound{tag: "awg", outboundTyp: C.TypeWireGuard, detour: "sel"}, awg: true}
|
||||
vlessLeaf := &fakeOutbound{tag: "vleaf", outboundTyp: C.TypeVLESS}
|
||||
mgr := &fakeManager{
|
||||
byTag: map[string]adapter.Outbound{"awg": awg, "vleaf": vlessLeaf},
|
||||
consumers: map[string][]string{"sel": {"awg"}},
|
||||
}
|
||||
|
||||
// selected member is plain vless (does not reach wireguard) → no suspension
|
||||
suspendAmneziaWGConsumersOnWireGuardSwitch(mgr, "sel", vlessLeaf)
|
||||
if awg.suspended {
|
||||
t.Error("must not suspend when the selected member does not reach wireguard")
|
||||
}
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
@@ -128,16 +128,6 @@ func (s *Selector) SelectOutbound(tag string) bool {
|
||||
if s.selected.Load() == detour {
|
||||
return true
|
||||
}
|
||||
// lx:begin awg
|
||||
// Suspend AmneziaWG consumers BEFORE switching: if the new member is (or chains
|
||||
// to) a WireGuard endpoint, any AmneziaWG endpoint that detours through this
|
||||
// group would tunnel AWG inside WireGuard and hang the kernel on Android. Doing
|
||||
// this before s.selected.Swap closes the race — by the time the group points at
|
||||
// the WireGuard member, those consumers are already down (started=false), so a
|
||||
// concurrent reconnect fails with "not ready" instead of sending a junk
|
||||
// handshake into WireGuard.
|
||||
suspendAmneziaWGConsumersOnWireGuardSwitch(s.outbound, s.Tag(), detour)
|
||||
// lx:end awg
|
||||
s.selected.Store(detour)
|
||||
invalidateReachability(s.ctx) // lx: SPEC 020 — active selection changed
|
||||
if s.Tag() != "" {
|
||||
|
||||
+292
-45
@@ -44,6 +44,20 @@ type URLTest struct {
|
||||
group *URLTestGroup
|
||||
interruptExternalConnections bool
|
||||
balancer *balancer // lx: SPEC 019 — nil for least_test (default)
|
||||
// lx: health board §5.C — true when options.SelfCheck == false: the group's
|
||||
// OWN probing schedule (PostStart warm-up + Touch ticker) is stood down and
|
||||
// the observatory is the only thing that measures its members. Stored
|
||||
// INVERTED so the zero value keeps today's behaviour for every construction
|
||||
// path that does not go through NewURLTest (hand-built groups in tests).
|
||||
// See option.URLTestOutboundOptions.SelfCheck for the full reasoning.
|
||||
selfCheckDisabled bool
|
||||
// lx: health board §5.C — the RUNTIME half of the same question, read from
|
||||
// the context registry at construction. selfCheckDisabled above says "this
|
||||
// group is not used by the config"; this says "this group cannot be reached
|
||||
// right now", which changes while the box runs and is therefore asked
|
||||
// afresh at every scheduled check rather than stored. nil = no gate.
|
||||
// See urltest.ProbeGate for why the two must stay separate.
|
||||
probeGate urltest.ProbeGate
|
||||
}
|
||||
|
||||
func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) {
|
||||
@@ -71,6 +85,12 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
idleTimeout: time.Duration(options.IdleTimeout),
|
||||
interruptExternalConnections: options.InterruptExistConnections,
|
||||
balancer: balancer,
|
||||
// nil/absent means true (self-check on) — the documented default, so a
|
||||
// config written before the flag existed behaves exactly as it always has.
|
||||
selfCheckDisabled: options.SelfCheck != nil && !*options.SelfCheck,
|
||||
// Absent from the registry (plain sing-box, tests) yields nil, which
|
||||
// means "no gate" — every scheduled probe proceeds, as before.
|
||||
probeGate: service.FromContext[urltest.ProbeGate](ctx),
|
||||
}
|
||||
if len(outbound.tags) == 0 {
|
||||
return nil, E.New("missing tags")
|
||||
@@ -92,6 +112,13 @@ func (s *URLTest) Start() error {
|
||||
return err
|
||||
}
|
||||
group.balancer = s.balancer // lx: SPEC 019 v2 — health-check drives the pool through it
|
||||
// lx: health board §5.C — carry the stand-down flag onto the group the same
|
||||
// way the balancer travels: set after construction, immutable from then on.
|
||||
group.selfCheckDisabled = s.selfCheckDisabled
|
||||
// The gate and the tag to ask it about travel together: the gate answers
|
||||
// per-outbound, and the group is the thing whose schedule is being gated.
|
||||
group.probeGate = s.probeGate
|
||||
group.tag = s.Tag()
|
||||
if s.balancer != nil {
|
||||
// lx: health board §5.B — slot liveness reads through the board verdict, so a
|
||||
// death recorded by any prober or a failed dial takes effect on the next pick,
|
||||
@@ -122,12 +149,14 @@ func (s *URLTest) Now() string {
|
||||
if s.balancer != nil {
|
||||
return s.group.lastSelected.Load()
|
||||
}
|
||||
if s.group.selectedOutboundTCP != nil {
|
||||
return s.group.selectedOutboundTCP.Tag()
|
||||
} else if s.group.selectedOutboundUDP != nil {
|
||||
return s.group.selectedOutboundUDP.Tag()
|
||||
// One load, so the two halves reported here are the SAME decision.
|
||||
selected := s.group.selected.Load()
|
||||
if selected.tcp != nil {
|
||||
return selected.tcp.Tag()
|
||||
} else if selected.udp != nil {
|
||||
return selected.udp.Tag()
|
||||
}
|
||||
// lx: SPEC 019 — cold start: before the first URL-test, selectedOutbound* is nil but
|
||||
// lx: SPEC 019 — cold start: before the first URL-test the pair is empty but
|
||||
// traffic already flows via the Select() fallback (outbounds[0] when no history yet).
|
||||
// Mirror exactly what the next DialContext would pick, so the UI shows the real node
|
||||
// instead of blank. Select() is the same source of truth DialContext uses.
|
||||
@@ -296,29 +325,155 @@ func (s *URLTest) NewPacketConnection(ctx context.Context, conn N.PacketConn, me
|
||||
}
|
||||
|
||||
type URLTestGroup struct {
|
||||
ctx context.Context
|
||||
outbound adapter.OutboundManager
|
||||
pause pause.Manager
|
||||
pauseCallback *list.Element[pause.Callback]
|
||||
logger log.Logger
|
||||
outbounds []adapter.Outbound
|
||||
link string
|
||||
interval time.Duration
|
||||
tolerance uint16
|
||||
idleTimeout time.Duration
|
||||
history *urltest.HistoryStorage
|
||||
checking atomic.Bool
|
||||
selectedOutboundTCP adapter.Outbound
|
||||
selectedOutboundUDP adapter.Outbound
|
||||
ctx context.Context
|
||||
outbound adapter.OutboundManager
|
||||
pause pause.Manager
|
||||
pauseCallback *list.Element[pause.Callback]
|
||||
logger log.Logger
|
||||
outbounds []adapter.Outbound
|
||||
link string
|
||||
interval time.Duration
|
||||
tolerance uint16
|
||||
idleTimeout time.Duration
|
||||
history *urltest.HistoryStorage
|
||||
checking atomic.Bool
|
||||
// selected is the least_test cache: the member this group currently prefers, per
|
||||
// network. It is written by the probing goroutine and read on EVERY dial through
|
||||
// the group (selectExcluding / dialSelect) and by the panel (Now), so it is an
|
||||
// atomic value rather than two plain fields — the same thing Selector does one file
|
||||
// over (selector.go, common.TypedValue[adapter.Outbound]). An interface field is two
|
||||
// words; a torn read of one hands a dial a type descriptor with the wrong data
|
||||
// pointer, which is not a wrong node but a corrupt one.
|
||||
//
|
||||
// The TCP and UDP halves live in ONE value on purpose. They are decided together, by
|
||||
// one pass over one board reading, and publishing them separately let a reader pick
|
||||
// up the new TCP choice against the previous UDP choice — the group's hysteresis
|
||||
// silently applied to a decision that was never made.
|
||||
selected common.TypedValue[selectedPair]
|
||||
interruptGroup *interrupt.Group
|
||||
interruptExternalConnections bool
|
||||
access sync.Mutex
|
||||
ticker *time.Ticker
|
||||
close chan struct{}
|
||||
started bool
|
||||
lastActive common.TypedValue[time.Time]
|
||||
lastSelected common.TypedValue[string] // lx: SPEC 019 — Now() in balanced modes
|
||||
balancer *balancer // lx: SPEC 019 v2 — round_robin pool; nil for least_test
|
||||
// started is read by Touch on every dial, outside g.access, and written by
|
||||
// PostStart under it — an atomic because that is what it always was in effect.
|
||||
started atomic.Bool
|
||||
// closed latches in Close and is what makes Close FINAL. Guarded by access.
|
||||
//
|
||||
// It exists because "has a ticker" is not the same question as "is shut down", and
|
||||
// Close used to ask the first one: with no ticker armed it returned before closing
|
||||
// g.close, leaving the group indistinguishable from a running one. A Touch arriving
|
||||
// afterwards — an outbound snapshot taken before an Apply is still dialable for up
|
||||
// to two minutes, see shater/engine/grouptest.go — then armed a fresh ticker whose
|
||||
// loopCheck waits on a channel nobody will ever close, in a box whose context is
|
||||
// already cancelled. Every tick of it fails instantly and files a "dead" verdict on
|
||||
// the SHARED health board that the live generation selects nodes from. One retired
|
||||
// group can go on declaring the whole node set dead for the uptime of the daemon.
|
||||
closed bool
|
||||
lastActive common.TypedValue[time.Time]
|
||||
lastSelected common.TypedValue[string] // lx: SPEC 019 — Now() in balanced modes
|
||||
balancer *balancer // lx: SPEC 019 v2 — round_robin pool; nil for least_test
|
||||
// lx: health board §5.C — mirrors URLTest.selfCheckDisabled (set by Start,
|
||||
// immutable afterwards, zero value = probing on). Guards ONLY the group's
|
||||
// own schedule: the PostStart warm-up sweep and the Touch ticker. An
|
||||
// explicit CheckOutbounds/URLTest call is untouched — the flag stands down
|
||||
// the schedule, not the capability.
|
||||
selfCheckDisabled bool
|
||||
// lx: health board §5.C — the runtime gate and the tag it is asked about.
|
||||
// Both mirror URLTest's fields (set by Start, immutable afterwards); nil
|
||||
// gate or empty tag means every scheduled check proceeds. Consulted only
|
||||
// through selfCheckAllowed, and only on the SCHEDULE.
|
||||
probeGate urltest.ProbeGate
|
||||
tag string
|
||||
}
|
||||
|
||||
// selectedPair is one published least_test decision: the member chosen for TCP and the
|
||||
// member chosen for UDP, as of the same probing round. Either half may be nil (nothing
|
||||
// picked yet for that network).
|
||||
type selectedPair struct {
|
||||
tcp adapter.Outbound
|
||||
udp adapter.Outbound
|
||||
}
|
||||
|
||||
// selectedFor returns the cached choice for one network (nil when there is none, or when
|
||||
// network is neither TCP nor UDP — the caller then falls through to a fresh selection).
|
||||
func (g *URLTestGroup) selectedFor(network string) adapter.Outbound {
|
||||
pair := g.selected.Load()
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
return pair.tcp
|
||||
case N.NetworkUDP:
|
||||
return pair.udp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSelected publishes a decision. It is the ONLY writer of g.selected, and it writes
|
||||
// the pair whole — see the field comment for why the two halves may not be split.
|
||||
func (g *URLTestGroup) setSelected(tcp, udp adapter.Outbound) {
|
||||
g.selected.Store(selectedPair{tcp: tcp, udp: udp})
|
||||
}
|
||||
|
||||
// selfCheckAllowed reports whether the group's OWN probing schedule may dial
|
||||
// right now. lx: health board §5.C.
|
||||
//
|
||||
// Two independent refusals, in the order they can be answered cheapest first:
|
||||
//
|
||||
// selfCheckDisabled — the config says no rule reaches this group. Fixed for
|
||||
// the life of the box; see standDownUnusedSelfCheck.
|
||||
// probeGate — the world says this group cannot be reached right now,
|
||||
// typically a chain hop sitting behind a dead hop. Asked
|
||||
// fresh EVERY time, which is the entire mechanism by which
|
||||
// a recovered hop resumes probing: there is no state here
|
||||
// to reset, so there is none to get stuck.
|
||||
//
|
||||
// Neither refusal touches an explicit CheckOutbounds/URLTest — a deliberate
|
||||
// request is never a scheduled one.
|
||||
func (g *URLTestGroup) selfCheckAllowed() bool {
|
||||
if g.selfCheckDisabled {
|
||||
return false
|
||||
}
|
||||
if g.probeGate == nil || g.tag == "" {
|
||||
return true
|
||||
}
|
||||
return g.probeGate.ProbeAllowed(g.tag)
|
||||
}
|
||||
|
||||
// scheduledCheck is one firing of the group's own schedule — the warm-up sweep
|
||||
// and every ticker tick go through here, and nothing else does. Having exactly
|
||||
// one gated entry point is what keeps the two callers from drifting apart, and
|
||||
// it is the seam the tests drive to assert that a gated group makes no dial
|
||||
// attempt at all.
|
||||
func (g *URLTestGroup) scheduledCheck() {
|
||||
if !g.selfCheckAllowed() {
|
||||
return
|
||||
}
|
||||
g.CheckOutbounds(false)
|
||||
}
|
||||
|
||||
// keepWarm reports whether this group must keep measuring with no traffic
|
||||
// flowing through it. lx: health board §5.C — see urltest.ProbeGate.ProbeWhenIdle.
|
||||
//
|
||||
// The default is NO, in every direction: no gate, no tag, or a group whose
|
||||
// self-check is stood down anyway. Only a gate that positively says "the routing
|
||||
// config reaches this group" turns the idle timeout off, so plain sing-box and
|
||||
// every hand-built group keep the lifecycle they have always had.
|
||||
func (g *URLTestGroup) keepWarm() bool {
|
||||
if g.selfCheckDisabled || g.probeGate == nil || g.tag == "" {
|
||||
return false
|
||||
}
|
||||
return g.probeGate.ProbeWhenIdle(g.tag)
|
||||
}
|
||||
|
||||
// startTickerLocked arms the group's own probing ticker. g.access MUST be held
|
||||
// and g.ticker MUST be nil. Extracted so PostStart and Touch arm it identically
|
||||
// — two ways in, one construction, no chance of one of them forgetting the pause
|
||||
// registration.
|
||||
func (g *URLTestGroup) startTickerLocked() {
|
||||
ticker := time.NewTicker(g.interval)
|
||||
g.ticker = ticker
|
||||
g.pauseCallback = pause.RegisterTicker(g.pause, ticker, g.interval, nil)
|
||||
go g.loopCheck(ticker, g.close)
|
||||
}
|
||||
|
||||
func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) {
|
||||
@@ -358,41 +513,112 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage
|
||||
func (g *URLTestGroup) PostStart() {
|
||||
g.access.Lock()
|
||||
defer g.access.Unlock()
|
||||
g.started = true
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
g.started.Store(true)
|
||||
g.lastActive.Store(time.Now())
|
||||
// lx: SPEC 019 v2 — seed the pool so round_robin can route from the first connection,
|
||||
// before the first health-check completes (history-warm nodes first, else config order).
|
||||
// The seed only READS the board, so it runs even with the self-check stood down.
|
||||
g.seedPool()
|
||||
go g.CheckOutbounds(false)
|
||||
// lx: health board §5.C — the warm-up sweep is the first half of the group's
|
||||
// own probing schedule, and it fires for EVERY group at box start, including
|
||||
// groups no routing rule reaches. For those, the sweep dials every member
|
||||
// directly from the router — a path nothing uses — and records the outcome
|
||||
// under the members' base tags, forging the board reading the observatory
|
||||
// exists to keep honest. A stood-down group therefore skips it entirely; the
|
||||
// observatory (or nothing, for a truly unused group) is what measures its
|
||||
// members.
|
||||
//
|
||||
// The same call is now also where a chain hop behind a DEAD hop declines to
|
||||
// sweep: every member of such a group dials through the broken hop, so the
|
||||
// sweep would measure that hop once per member and file the result against
|
||||
// this one. selfCheckAllowed keeps both refusals in one place.
|
||||
go g.scheduledCheck()
|
||||
// A group the routing config REACHES keeps measuring whether or not anybody
|
||||
// dials it, so its ticker is armed here instead of waiting for a Touch that
|
||||
// may never come. Without this, a used group with no traffic gets this one
|
||||
// warm-up sweep and then nothing: its members age past the verdict TTL and
|
||||
// the panel reports "untested" about a rule that is in force, while the first
|
||||
// real request pays a cold probe. Nothing else would fill the gap — the
|
||||
// observatory stands off a urltest group's members entirely (probeplan.go
|
||||
// SelfChecked), which is the whole point of one dialler per target.
|
||||
//
|
||||
// lastActive was stored a moment ago, so loopCheck's opening "idle longer
|
||||
// than the interval" check does not fire and this cannot double up with the
|
||||
// sweep above.
|
||||
if g.keepWarm() && g.ticker == nil {
|
||||
g.startTickerLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *URLTestGroup) Touch() {
|
||||
if !g.started {
|
||||
if !g.started.Load() {
|
||||
return
|
||||
}
|
||||
// lx: health board §5.C — Touch's only job is to keep the group's OWN
|
||||
// probing ticker alive while traffic flows. With the self-check stood down
|
||||
// there is deliberately no ticker to start or feed: the observatory owns the
|
||||
// schedule, and a stray dial through an unused group (a stale rule cache, a
|
||||
// manual pin) must not arm 30 minutes of direct probing under the members'
|
||||
// base tags. Checked before the lock because the flag is immutable after
|
||||
// Start, exactly like the started fast-path above.
|
||||
//
|
||||
// The runtime gate is deliberately NOT consulted here. Touch only arms the
|
||||
// ticker; refusing to arm it would mean a hop that recovers has no ticker
|
||||
// left to notice — the block would outlive the failure, which is the one
|
||||
// outcome this must never have. The ticker runs and each tick re-asks the
|
||||
// gate (loopCheck -> scheduledCheck), so a blocked hop costs a predicate
|
||||
// call per interval and resumes the moment the hop in front answers.
|
||||
if g.selfCheckDisabled {
|
||||
return
|
||||
}
|
||||
g.access.Lock()
|
||||
defer g.access.Unlock()
|
||||
// A closed group arms nothing. Touch is reachable long after Close — a caller
|
||||
// holding an outbound from a snapshot taken before an Apply keeps dialling it (up
|
||||
// to the 120s budget of shater/engine/grouptest.go) — and the ticker it would arm
|
||||
// has no way left to stop: see the `closed` field for what that costs.
|
||||
if g.closed {
|
||||
return
|
||||
}
|
||||
if g.ticker != nil {
|
||||
g.lastActive.Store(time.Now())
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(g.interval)
|
||||
g.ticker = ticker
|
||||
g.pauseCallback = pause.RegisterTicker(g.pause, ticker, g.interval, nil)
|
||||
go g.loopCheck(ticker, g.close)
|
||||
g.startTickerLocked()
|
||||
}
|
||||
|
||||
// Close shuts the group down for good. It is idempotent, and it is FINAL: no later Touch
|
||||
// can bring the probing schedule back.
|
||||
//
|
||||
// It used to return early when no ticker happened to be armed, without ever closing
|
||||
// g.close — so a group that was closed while idle stayed, from the point of view of every
|
||||
// other method, a perfectly live group. That is the whole defect: the close channel is the
|
||||
// only way a loopCheck goroutine ever exits (its idle-timeout escape does not fire for a
|
||||
// group the routing config reaches, keepWarm), so a ticker armed after such a Close is
|
||||
// immortal, and every one of its ticks writes a failure to the shared health board on
|
||||
// behalf of a box that no longer exists.
|
||||
func (g *URLTestGroup) Close() error {
|
||||
g.access.Lock()
|
||||
defer g.access.Unlock()
|
||||
if g.ticker == nil {
|
||||
if g.closed {
|
||||
return nil
|
||||
}
|
||||
g.ticker.Stop()
|
||||
g.ticker = nil
|
||||
g.pause.UnregisterCallback(g.pauseCallback)
|
||||
g.pauseCallback = nil
|
||||
close(g.close)
|
||||
g.closed = true
|
||||
// Unconditionally, BEFORE looking at the ticker: this is the signal every loopCheck
|
||||
// waits on, including any that a Touch armed after the last one was retired by the
|
||||
// idle timeout.
|
||||
if g.close != nil {
|
||||
close(g.close)
|
||||
}
|
||||
if g.ticker != nil {
|
||||
g.ticker.Stop()
|
||||
g.ticker = nil
|
||||
g.pause.UnregisterCallback(g.pauseCallback)
|
||||
g.pauseCallback = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -405,10 +631,15 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) {
|
||||
return g.selectExcluding(network, nil)
|
||||
}
|
||||
|
||||
// loopCheck is the group's own schedule. lx: health board §5.C — every probe it
|
||||
// fires goes through scheduledCheck, so a stood-down or currently-unreachable
|
||||
// group ticks without dialling. The ticker's LIFECYCLE (the idle timeout below)
|
||||
// is deliberately left alone: a gated group keeps its ticker exactly as long as
|
||||
// an ungated one would, because the ticker is what will notice the recovery.
|
||||
func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{}) {
|
||||
if time.Since(g.lastActive.Load()) > g.interval {
|
||||
g.lastActive.Store(time.Now())
|
||||
g.CheckOutbounds(false)
|
||||
g.scheduledCheck()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
@@ -416,7 +647,13 @@ func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{})
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
if time.Since(g.lastActive.Load()) > g.idleTimeout {
|
||||
// The idle timeout retires the ticker of a group nobody is dialling —
|
||||
// unless the routing config reaches it, in which case its health is a
|
||||
// live question whether or not traffic is flowing and the ticker must
|
||||
// outlive the silence. Asked here rather than remembered from PostStart
|
||||
// so it tracks the running config, and asked OUTSIDE g.access because the
|
||||
// answer comes from the engine, which has locks of its own.
|
||||
if !g.keepWarm() && time.Since(g.lastActive.Load()) > g.idleTimeout {
|
||||
g.access.Lock()
|
||||
if g.ticker == ticker {
|
||||
g.ticker.Stop()
|
||||
@@ -427,7 +664,7 @@ func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{})
|
||||
g.access.Unlock()
|
||||
return
|
||||
}
|
||||
g.CheckOutbounds(false)
|
||||
g.scheduledCheck()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,19 +762,29 @@ func (g *URLTestGroup) testNodes(ctx context.Context, outbounds []adapter.Outbou
|
||||
return result
|
||||
}
|
||||
|
||||
// performUpdateCheck re-ranks the members after a probing round and publishes the result.
|
||||
// It is the only writer of g.selected: it reads the current pair ONCE, decides both
|
||||
// networks against that one snapshot, and stores the outcome as a single value, so no
|
||||
// reader can ever observe a half-applied decision. Callers are serialised by g.checking
|
||||
// (urlTest), which is what makes the read-decide-store sequence safe without a lock.
|
||||
func (g *URLTestGroup) performUpdateCheck() {
|
||||
current := g.selected.Load()
|
||||
next := current
|
||||
var updated bool
|
||||
if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) {
|
||||
if g.selectedOutboundTCP != nil {
|
||||
if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (current.tcp == nil || (exists && outbound != current.tcp)) {
|
||||
if current.tcp != nil {
|
||||
updated = true
|
||||
}
|
||||
g.selectedOutboundTCP = outbound
|
||||
next.tcp = outbound
|
||||
}
|
||||
if outbound, exists := g.Select(N.NetworkUDP); outbound != nil && (g.selectedOutboundUDP == nil || (exists && outbound != g.selectedOutboundUDP)) {
|
||||
if g.selectedOutboundUDP != nil {
|
||||
if outbound, exists := g.Select(N.NetworkUDP); outbound != nil && (current.udp == nil || (exists && outbound != current.udp)) {
|
||||
if current.udp != nil {
|
||||
updated = true
|
||||
}
|
||||
g.selectedOutboundUDP = outbound
|
||||
next.udp = outbound
|
||||
}
|
||||
if next != current {
|
||||
g.setSelected(next.tcp, next.udp)
|
||||
}
|
||||
if updated {
|
||||
g.interruptGroup.Interrupt(g.interruptExternalConnections)
|
||||
|
||||
@@ -74,13 +74,7 @@ func (g *URLTestGroup) selectExcluding(network string, exclude map[string]bool)
|
||||
var minOutbound adapter.Outbound
|
||||
// Keep the upstream hysteresis: the currently selected outbound only yields to a
|
||||
// member faster by more than tolerance — but only while it is still alive itself.
|
||||
var current adapter.Outbound
|
||||
switch network {
|
||||
case N.NetworkTCP:
|
||||
current = g.selectedOutboundTCP
|
||||
case N.NetworkUDP:
|
||||
current = g.selectedOutboundUDP
|
||||
}
|
||||
current := g.selectedFor(network)
|
||||
if current != nil {
|
||||
currentTag := RealTag(current)
|
||||
if !exclude[currentTag] && g.history.Verdict(currentTag, ttl) == urltest.VerdictAlive {
|
||||
@@ -144,13 +138,7 @@ func (s *URLTest) dialSelect(ctx context.Context, network string, destination M.
|
||||
if s.balancer != nil {
|
||||
return s.selectBalanced(ctx, network, destination, tried)
|
||||
}
|
||||
var outbound adapter.Outbound
|
||||
switch N.NetworkName(network) {
|
||||
case N.NetworkTCP:
|
||||
outbound = s.group.selectedOutboundTCP
|
||||
case N.NetworkUDP:
|
||||
outbound = s.group.selectedOutboundUDP
|
||||
}
|
||||
outbound := s.group.selectedFor(N.NetworkName(network))
|
||||
if outbound != nil {
|
||||
realTag := RealTag(outbound)
|
||||
if !tried[realTag] && s.group.history.Verdict(realTag, s.group.healthTTL()) != urltest.VerdictDead {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -32,10 +33,31 @@ type healthNode struct {
|
||||
func (n *healthNode) Tag() string { return n.tag }
|
||||
func (n *healthNode) Network() []string { return []string{N.NetworkTCP, N.NetworkUDP} }
|
||||
|
||||
func (n *healthNode) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
if n.dialed != nil {
|
||||
*n.dialed = append(*n.dialed, n.tag)
|
||||
// healthDialMu guards the shared dialed slice. testNodes probes a group's
|
||||
// members CONCURRENTLY (a batch of 10), so two nodes pointing at one slice
|
||||
// append from two goroutines; the append is what the -race build trips on, not
|
||||
// the code under test.
|
||||
var healthDialMu sync.Mutex
|
||||
|
||||
func (n *healthNode) record() {
|
||||
if n.dialed == nil {
|
||||
return
|
||||
}
|
||||
healthDialMu.Lock()
|
||||
*n.dialed = append(*n.dialed, n.tag)
|
||||
healthDialMu.Unlock()
|
||||
}
|
||||
|
||||
// dialsOf reads a dial log under the same lock. Every assertion on a log a
|
||||
// concurrent sweep may still be writing must go through it.
|
||||
func dialsOf(dialed *[]string) []string {
|
||||
healthDialMu.Lock()
|
||||
defer healthDialMu.Unlock()
|
||||
return append([]string(nil), *dialed...)
|
||||
}
|
||||
|
||||
func (n *healthNode) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
n.record()
|
||||
if n.fail {
|
||||
return nil, errors.New("dial refused")
|
||||
}
|
||||
@@ -45,9 +67,7 @@ func (n *healthNode) DialContext(ctx context.Context, network string, destinatio
|
||||
}
|
||||
|
||||
func (n *healthNode) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
if n.dialed != nil {
|
||||
*n.dialed = append(*n.dialed, n.tag)
|
||||
}
|
||||
n.record()
|
||||
if n.fail {
|
||||
return nil, errors.New("listen refused")
|
||||
}
|
||||
@@ -85,6 +105,9 @@ func healthTestGroup(hist *urltest.HistoryStorage, manager adapter.OutboundManag
|
||||
tolerance: 50,
|
||||
logger: log.NewNOPFactory().Logger(),
|
||||
interruptGroup: interrupt.NewGroup(),
|
||||
// Real groups always have this channel (NewURLTestGroup); it is what Close
|
||||
// signals every loopCheck through, so a hand-built group needs it too.
|
||||
close: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +184,7 @@ func TestSelectHysteresisKeepsAliveCurrent(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
|
||||
g := healthTestGroup(hist, nil, a, b)
|
||||
g.selectedOutboundTCP = a
|
||||
g.setSelected(a, nil)
|
||||
storeAlive(hist, "a", 100)
|
||||
storeAlive(hist, "b", 60) // within tolerance (100 ≤ 60+50) → keep a
|
||||
if selected, _ := g.Select(N.NetworkTCP); selected != adapter.Outbound(a) {
|
||||
@@ -178,7 +201,7 @@ func TestSelectDeadCurrentLosesToAlive(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a, b := &balNode{tag: "a"}, &balNode{tag: "b"}
|
||||
g := healthTestGroup(hist, nil, a, b)
|
||||
g.selectedOutboundTCP = a
|
||||
g.setSelected(a, nil)
|
||||
storeAlive(hist, "a", 10)
|
||||
hist.MarkFailed("a")
|
||||
storeAlive(hist, "b", 500)
|
||||
@@ -331,7 +354,7 @@ func TestDialContextRetriesThroughNextAlive(t *testing.T) {
|
||||
s := healthURLTest(g, nil, nil)
|
||||
storeAlive(hist, "a", 10)
|
||||
storeAlive(hist, "b", 100)
|
||||
g.selectedOutboundTCP = a // the checker had picked a; it dies between ticks
|
||||
g.setSelected(a, nil) // the checker had picked a; it dies between ticks
|
||||
conn, err := s.DialContext(context.Background(), N.NetworkTCP, destDomain("example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("DialContext failed despite live member b: %v", err)
|
||||
@@ -427,7 +450,7 @@ func TestListenPacketRetriesBeforeFirstSend(t *testing.T) {
|
||||
s := healthURLTest(g, nil, nil)
|
||||
storeAlive(hist, "a", 10)
|
||||
storeAlive(hist, "b", 100)
|
||||
g.selectedOutboundUDP = a
|
||||
g.setSelected(nil, a)
|
||||
conn, err := s.ListenPacket(context.Background(), destDomain("example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("ListenPacket failed despite live member b: %v", err)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package group
|
||||
|
||||
// Concurrency tests for the urltest group: the group's own probing schedule running at
|
||||
// the same time as traffic going through it.
|
||||
//
|
||||
// This combination had no coverage at all. Every existing test either probes OR dials,
|
||||
// never both at once, so the race detector had nothing to detect: the cached least_test
|
||||
// choice was written by the prober goroutine and read on every single dial, with no
|
||||
// synchronisation whatsoever, and the suite stayed green for as long as those two things
|
||||
// never happened in the same test.
|
||||
//
|
||||
// An unsynchronised interface field is not a "usually fine" race. It is two words — type
|
||||
// descriptor and data pointer — and a reader that catches the store half way holds a
|
||||
// descriptor addressing the wrong value. What comes out is not a suboptimal node, it is a
|
||||
// corrupt one, on the path of every connection the group carries.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/urltest"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service/pause"
|
||||
)
|
||||
|
||||
// TestURLTestDialRacesProbeTicker drives real dials through a least_test group while the
|
||||
// group's own probing schedule keeps re-deciding which member to use — the situation on
|
||||
// every router where a urltest group carries traffic, since the ticker fires on its own
|
||||
// interval regardless of what the connections are doing.
|
||||
//
|
||||
// It is a -race test first and an assertion test second: the failure it was written for
|
||||
// is reported by the detector, not by a wrong value. Run it under -race or it proves
|
||||
// almost nothing (the gate's [4/4] pass does).
|
||||
func TestURLTestDialRacesProbeTicker(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a, b := &healthNode{tag: "a"}, &healthNode{tag: "b"}
|
||||
manager := managerOf(a, b)
|
||||
g := healthTestGroup(hist, manager, a, b)
|
||||
s := healthURLTest(g, nil, manager)
|
||||
storeAlive(hist, "a", 20)
|
||||
storeAlive(hist, "b", 500)
|
||||
|
||||
var proberWG, dialWG sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// The prober: one full turn of the group's own schedule per iteration. CheckOutbounds
|
||||
// is the real tick (probe every member, then publish); the probes cannot reach
|
||||
// anything from a unit test, so the board is then re-armed with a winner that MOVES
|
||||
// and the publish step is run again — otherwise the cached choice is written once and
|
||||
// the window in which a reader can catch a torn write is a few nanoseconds wide.
|
||||
proberWG.Add(1)
|
||||
go func() {
|
||||
defer proberWG.Done()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
g.CheckOutbounds(true)
|
||||
fast, slow := "a", "b"
|
||||
if i%2 == 1 {
|
||||
fast, slow = "b", "a"
|
||||
}
|
||||
storeAlive(hist, fast, 20)
|
||||
storeAlive(hist, slow, 500)
|
||||
g.performUpdateCheck()
|
||||
}
|
||||
}()
|
||||
|
||||
// The traffic: every dial reads the cached choice (dialSelect), and so does the panel
|
||||
// (Now). Both are the read side of the race.
|
||||
var dials, nows atomic.Int64
|
||||
for range 4 {
|
||||
dialWG.Add(1)
|
||||
go func() {
|
||||
defer dialWG.Done()
|
||||
for range 300 {
|
||||
if conn, err := s.DialContext(context.Background(), N.NetworkTCP, M.Socksaddr{}); err == nil {
|
||||
_ = conn.Close()
|
||||
dials.Add(1)
|
||||
}
|
||||
if pc, err := s.ListenPacket(context.Background(), M.Socksaddr{}); err == nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
// The panel polls this while everything above is happening.
|
||||
if tag := s.Now(); tag != "" && tag != "a" && tag != "b" {
|
||||
t.Errorf("Now() = %q, which is not a member of the group — a torn read of the cached choice", tag)
|
||||
}
|
||||
nows.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// The dialers are the bounded side; the prober runs until they are done.
|
||||
dialersDone := make(chan struct{})
|
||||
go func() { dialWG.Wait(); close(dialersDone) }()
|
||||
select {
|
||||
case <-dialersDone:
|
||||
case <-time.After(60 * time.Second):
|
||||
close(stop)
|
||||
proberWG.Wait()
|
||||
t.Fatal("dialers did not finish — the group deadlocked against its own prober")
|
||||
}
|
||||
close(stop)
|
||||
proberWG.Wait()
|
||||
|
||||
if dials.Load() == 0 {
|
||||
t.Fatal("no dial succeeded — the test never exercised the read side it exists to race")
|
||||
}
|
||||
if nows.Load() == 0 {
|
||||
t.Fatal("Now() was never polled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectedPairPublishedTogether pins the pairing half of the same defect: the TCP and
|
||||
// UDP choices are one decision, taken from one board reading, and they become visible
|
||||
// together. They used to be two separate field writes, so a reader could take the new TCP
|
||||
// choice against the previous UDP one — a combination no probing round ever decided, and
|
||||
// the group's hysteresis silently applied to it.
|
||||
func TestSelectedPairPublishedTogether(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a, b := &healthNode{tag: "a"}, &healthNode{tag: "b"}
|
||||
g := healthTestGroup(hist, managerOf(a, b), a, b)
|
||||
|
||||
// Round 1: a wins both networks.
|
||||
storeAlive(hist, "a", 20)
|
||||
storeAlive(hist, "b", 500)
|
||||
g.performUpdateCheck()
|
||||
if got := g.selected.Load(); got.tcp != adapter.Outbound(a) || got.udp != adapter.Outbound(a) {
|
||||
t.Fatalf("after round 1 the pair is (%v, %v), want (a, a)", tagOrNil(got.tcp), tagOrNil(got.udp))
|
||||
}
|
||||
|
||||
// Round 2: b wins both, by more than the tolerance.
|
||||
storeAlive(hist, "a", 500)
|
||||
storeAlive(hist, "b", 20)
|
||||
g.performUpdateCheck()
|
||||
got := g.selected.Load()
|
||||
if got.tcp != adapter.Outbound(b) || got.udp != adapter.Outbound(b) {
|
||||
t.Fatalf("after round 2 the pair is (%v, %v), want (b, b) — both halves move together",
|
||||
tagOrNil(got.tcp), tagOrNil(got.udp))
|
||||
}
|
||||
|
||||
// And what the dial path reads per network agrees with the published pair.
|
||||
if g.selectedFor(N.NetworkTCP) != got.tcp || g.selectedFor(N.NetworkUDP) != got.udp {
|
||||
t.Fatal("selectedFor disagrees with the published pair")
|
||||
}
|
||||
if g.selectedFor("icmp") != nil {
|
||||
t.Fatal("selectedFor on an unknown network must yield nothing, not a TCP choice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLTestGroupProbeRacesPanelRead is the narrower of the pair: the panel's Now() poll
|
||||
// against the prober, with no dialling at all. shater/engine/grouphealth.go,
|
||||
// shater/stats/stats.go and shater/engine/grouptest.go all call Now() from their own
|
||||
// goroutines while the group's ticker runs.
|
||||
func TestURLTestGroupProbeRacesPanelRead(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a, b := &healthNode{tag: "a"}, &healthNode{tag: "b"}
|
||||
manager := managerOf(a, b)
|
||||
g := healthTestGroup(hist, manager, a, b)
|
||||
s := healthURLTest(g, nil, manager)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fast, slow := "a", "b"
|
||||
if i%2 == 1 {
|
||||
fast, slow = "b", "a"
|
||||
}
|
||||
storeAlive(hist, fast, 20)
|
||||
storeAlive(hist, slow, 500)
|
||||
g.performUpdateCheck()
|
||||
}
|
||||
}()
|
||||
for range 3 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 2000 {
|
||||
_ = s.Now()
|
||||
}
|
||||
}()
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// tagOrNil renders a possibly-nil outbound for a failure message.
|
||||
func tagOrNil(o adapter.Outbound) string {
|
||||
if o == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return o.Tag()
|
||||
}
|
||||
|
||||
// --- Close is final ---------------------------------------------------------
|
||||
|
||||
// TestGroupCloseIsFinalForALaterTouch is the immortal-ticker regression.
|
||||
//
|
||||
// Close used to return early whenever no ticker happened to be armed — which is the
|
||||
// normal state of a group nobody is dialling — WITHOUT closing g.close. Nothing else
|
||||
// records that a group was shut down (started is never cleared), so a Touch arriving
|
||||
// afterwards armed a fresh ticker and a fresh loopCheck goroutine waiting on a channel
|
||||
// that would never be closed. Its only other exit, the idle timeout, does not fire for a
|
||||
// group the routing config reaches.
|
||||
//
|
||||
// A later Touch is not hypothetical: shater/engine/grouptest.go dials through outbounds
|
||||
// taken from a snapshot at the start of a run and keeps doing so for up to 120s, so
|
||||
// "press Test in the panel, then apply a config within two minutes" is enough. The
|
||||
// retired group then probes forever through a cancelled context — every probe fails
|
||||
// instantly — and files "dead" for its members on the SHARED health board that the LIVE
|
||||
// generation picks nodes from.
|
||||
func TestGroupCloseIsFinalForALaterTouch(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
var dialed []string
|
||||
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
|
||||
g := healthTestGroup(hist, managerOf(a), a)
|
||||
g.pause = pause.ManagerFromContext(pause.WithDefaultManager(context.Background()))
|
||||
// Fast enough that a surviving ticker proves itself within the test's patience.
|
||||
g.interval = 10 * time.Millisecond
|
||||
g.idleTimeout = time.Hour
|
||||
// Started, but idle: no ticker armed. This is the state Close mishandled.
|
||||
g.started.Store(true)
|
||||
|
||||
if err := g.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
g.Touch()
|
||||
|
||||
g.access.Lock()
|
||||
ticker := g.ticker
|
||||
g.access.Unlock()
|
||||
if ticker != nil {
|
||||
t.Fatal("Touch armed a probing ticker on a CLOSED group — nothing can stop it: " +
|
||||
"its loopCheck waits on a channel that will never be closed")
|
||||
}
|
||||
|
||||
// The consequence, stated in the terms that actually hurt: no probe, so no forged
|
||||
// verdict on the shared board.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if got := dialsOf(&dialed); len(got) != 0 {
|
||||
t.Fatalf("a closed group dialled %v — a retired generation is writing to the live health board", got)
|
||||
}
|
||||
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictUntested {
|
||||
t.Fatalf("verdict(a) = %v after closing the group, want untested — the dead marks are forged", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupCloseStopsAnArmedTicker keeps the original behaviour honest: when a ticker IS
|
||||
// armed, Close still stops it, unregisters the pause callback and signals loopCheck.
|
||||
func TestGroupCloseStopsAnArmedTicker(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
a := &healthNode{tag: "a", fail: true}
|
||||
g := healthTestGroup(hist, managerOf(a), a)
|
||||
g.pause = pause.ManagerFromContext(pause.WithDefaultManager(context.Background()))
|
||||
g.interval = 10 * time.Millisecond
|
||||
g.idleTimeout = time.Hour
|
||||
g.started.Store(true)
|
||||
g.lastActive.Store(time.Now())
|
||||
|
||||
g.Touch()
|
||||
g.access.Lock()
|
||||
armed := g.ticker != nil
|
||||
g.access.Unlock()
|
||||
if !armed {
|
||||
t.Fatal("Touch did not arm the ticker on a live group")
|
||||
}
|
||||
|
||||
if err := g.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
g.access.Lock()
|
||||
stillArmed := g.ticker != nil
|
||||
g.access.Unlock()
|
||||
if stillArmed {
|
||||
t.Fatal("Close left the ticker armed")
|
||||
}
|
||||
select {
|
||||
case <-g.close:
|
||||
default:
|
||||
t.Fatal("Close did not signal loopCheck")
|
||||
}
|
||||
// Idempotent: a second Close must not close an already-closed channel (panic) or
|
||||
// undo anything.
|
||||
if err := g.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupPostStartAfterCloseDoesNothing: the other way a retired group can be woken.
|
||||
// PostStart is called on every member of a box at start-up; a group closed by a racing
|
||||
// shutdown must not be brought back by it.
|
||||
func TestGroupPostStartAfterCloseDoesNothing(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
var dialed []string
|
||||
a := &healthNode{tag: "a", fail: true, dialed: &dialed}
|
||||
g := healthTestGroup(hist, managerOf(a), a)
|
||||
g.pause = pause.ManagerFromContext(pause.WithDefaultManager(context.Background()))
|
||||
g.interval = 10 * time.Millisecond
|
||||
g.idleTimeout = time.Hour
|
||||
|
||||
_ = g.Close()
|
||||
g.PostStart()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
if g.started.Load() {
|
||||
t.Fatal("PostStart marked a closed group as started")
|
||||
}
|
||||
if got := dialsOf(&dialed); len(got) != 0 {
|
||||
t.Fatalf("PostStart on a closed group ran the warm-up sweep: dialled %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package group
|
||||
|
||||
// lx: health board §5.C tests — SelfCheck stands the group's OWN probing
|
||||
// schedule down: no PostStart warm-up sweep, no Touch ticker. The explicit
|
||||
// CheckOutbounds path stays available, and the nil default keeps probing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/common/urltest"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/service/pause"
|
||||
)
|
||||
|
||||
// waitForHistory polls until the store holds an entry for tag or the deadline
|
||||
// passes; reports whether it appeared. PostStart's sweep runs on its own
|
||||
// goroutine, so both directions of the assertion need a bounded wait.
|
||||
func waitForHistory(hist *urltest.HistoryStorage, tag string, deadline time.Duration) bool {
|
||||
stop := time.Now().Add(deadline)
|
||||
for time.Now().Before(stop) {
|
||||
if hist.LoadURLTestHistory(tag) != nil {
|
||||
return true
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A group with the self-check stood down writes NOTHING to the history storage
|
||||
// on PostStart: the warm-up sweep — which would dial the member directly from
|
||||
// the router and mark the failure under its base tag — must not fire. And
|
||||
// Touch, the other half of the schedule, must not start a ticker either.
|
||||
func TestSelfCheckDisabledPostStartWritesNothing(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a := &healthNode{tag: "a", fail: true}
|
||||
manager := managerOf(a)
|
||||
g := healthTestGroup(hist, manager, a)
|
||||
g.selfCheckDisabled = true
|
||||
|
||||
g.PostStart()
|
||||
// The absence of a write is the assertion, so give the (non-existent) sweep
|
||||
// real time to have happened before declaring victory.
|
||||
if waitForHistory(hist, "a", 150*time.Millisecond) {
|
||||
t.Fatal("a stood-down group's PostStart wrote to the board; the warm-up sweep must not fire")
|
||||
}
|
||||
|
||||
g.Touch()
|
||||
g.access.Lock()
|
||||
ticker := g.ticker
|
||||
g.access.Unlock()
|
||||
if ticker != nil {
|
||||
t.Fatal("Touch armed the probing ticker on a stood-down group")
|
||||
}
|
||||
}
|
||||
|
||||
// The default (SelfCheck nil, i.e. the zero-value field on a hand-built group)
|
||||
// keeps today's behaviour: PostStart's warm-up sweep runs and records the
|
||||
// failing member on the board.
|
||||
func TestSelfCheckDefaultStillProbesOnPostStart(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a := &healthNode{tag: "a", fail: true}
|
||||
manager := managerOf(a)
|
||||
g := healthTestGroup(hist, manager, a)
|
||||
|
||||
g.PostStart()
|
||||
if !waitForHistory(hist, "a", 5*time.Second) {
|
||||
t.Fatal("default group's PostStart never probed; the self-check must stay on unless stood down")
|
||||
}
|
||||
if v := hist.Verdict("a", 10*time.Minute); v != urltest.VerdictDead {
|
||||
t.Fatalf("verdict(a) = %v, want dead from the warm-up sweep", v)
|
||||
}
|
||||
}
|
||||
|
||||
// An EXPLICIT CheckOutbounds still probes a stood-down group: the flag
|
||||
// suppresses the group's own schedule, never a deliberate request (the adapter
|
||||
// interface a human or an API invokes on purpose).
|
||||
func TestSelfCheckDisabledExplicitCheckStillProbes(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
a := &healthNode{tag: "a", fail: true}
|
||||
manager := managerOf(a)
|
||||
g := healthTestGroup(hist, manager, a)
|
||||
g.selfCheckDisabled = true
|
||||
|
||||
g.CheckOutbounds(true)
|
||||
if hist.LoadURLTestHistory("a") == nil {
|
||||
t.Fatal("an explicit CheckOutbounds(true) did not probe; the flag must only stand down the schedule")
|
||||
}
|
||||
}
|
||||
|
||||
// The option → outbound plumbing: nil/absent means on, an explicit false means
|
||||
// stood down, an explicit true means on. NewURLTest is the only place the
|
||||
// option is read, so this is where a plumbing regression would hide.
|
||||
func TestSelfCheckOptionPlumbing(t *testing.T) {
|
||||
build := func(selfCheck *bool) *URLTest {
|
||||
t.Helper()
|
||||
opts := option.URLTestOutboundOptions{Outbounds: []string{"a"}}
|
||||
opts.SelfCheck = selfCheck
|
||||
ob, err := NewURLTest(context.Background(), nil, log.NewNOPFactory().Logger(), "t", opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewURLTest: %v", err)
|
||||
}
|
||||
return ob.(*URLTest)
|
||||
}
|
||||
if build(nil).selfCheckDisabled {
|
||||
t.Fatal("nil SelfCheck must keep the self-check ON (the compatibility default)")
|
||||
}
|
||||
on, off := true, false
|
||||
if build(&on).selfCheckDisabled {
|
||||
t.Fatal("SelfCheck=true must keep the self-check on")
|
||||
}
|
||||
if !build(&off).selfCheckDisabled {
|
||||
t.Fatal("SelfCheck=false must stand the self-check down")
|
||||
}
|
||||
}
|
||||
|
||||
// lx: health board §5.C — the RUNTIME gate (urltest.ProbeGate). The self-check
|
||||
// flag above says "the config reaches nothing here"; the gate says "the path in
|
||||
// front of this group is down right now". Both stand the SCHEDULE down; neither
|
||||
// touches an explicit check; and only the gate is allowed to change its mind
|
||||
// while the box runs.
|
||||
|
||||
// fakeGate answers from a mutable set of blocked tags, so one test can watch a
|
||||
// group stop dialling and start again without rebuilding anything.
|
||||
type fakeGate struct {
|
||||
mu sync.Mutex
|
||||
blocked map[string]bool
|
||||
warm map[string]bool
|
||||
asked int
|
||||
}
|
||||
|
||||
func (g *fakeGate) ProbeAllowed(tag string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.asked++
|
||||
return !g.blocked[tag]
|
||||
}
|
||||
|
||||
func (g *fakeGate) ProbeWhenIdle(tag string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.warm[tag]
|
||||
}
|
||||
|
||||
func (g *fakeGate) set(tag string, blocked bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.blocked[tag] = blocked
|
||||
}
|
||||
|
||||
func (g *fakeGate) asks() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.asked
|
||||
}
|
||||
|
||||
// A gated group makes NO DIAL ATTEMPT on its own schedule — the assertion is on
|
||||
// the attempt log, not on the board, because a probe that ran and failed leaves
|
||||
// the same "nothing useful known" as one that never ran, and only the attempt
|
||||
// log tells them apart. This is the waste half of the chain-hop fix: a hop
|
||||
// sitting behind a dead hop would otherwise spend one probe timeout per member
|
||||
// rediscovering the same broken hop.
|
||||
func TestProbeGateBlocksScheduledDials(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
var dialed []string
|
||||
a := &healthNode{tag: "chain-c-h3-a", fail: true, dialed: &dialed}
|
||||
b := &healthNode{tag: "chain-c-h3-b", fail: true, dialed: &dialed}
|
||||
gate := &fakeGate{blocked: map[string]bool{"chain-c-h3": true}}
|
||||
|
||||
g := healthTestGroup(hist, managerOf(a, b), a, b)
|
||||
g.tag = "chain-c-h3"
|
||||
g.probeGate = gate
|
||||
// Touch arms a real ticker, so this group needs the two things
|
||||
// healthTestGroup leaves out because nothing else in that suite starts one:
|
||||
// a pause manager to register the ticker with, and the close channel Close
|
||||
// shuts the loop down through.
|
||||
g.pause = pause.ManagerFromContext(pause.WithDefaultManager(context.Background()))
|
||||
g.close = make(chan struct{})
|
||||
|
||||
// The warm-up sweep: gated, so nothing is dialled. Give the (non-existent)
|
||||
// sweep real time to have happened — the absence is the assertion.
|
||||
g.PostStart()
|
||||
if waitForHistory(hist, "chain-c-h3-a", 150*time.Millisecond) {
|
||||
t.Fatal("a gated group's PostStart wrote to the board")
|
||||
}
|
||||
// A ticker tick, driven directly: this is the exact call loopCheck makes.
|
||||
g.scheduledCheck()
|
||||
if got := dialsOf(&dialed); len(got) != 0 {
|
||||
t.Fatalf("gated group dialled %v; a hop behind a dead hop must not dial at all", got)
|
||||
}
|
||||
|
||||
// Touch still arms the ticker. Refusing to arm it would leave a recovered
|
||||
// hop with nothing to notice — the block would outlive the failure.
|
||||
g.Touch()
|
||||
g.access.Lock()
|
||||
ticker := g.ticker
|
||||
g.access.Unlock()
|
||||
if ticker == nil {
|
||||
t.Fatal("Touch did not arm the ticker on a gated group; nothing would be left to spot the recovery")
|
||||
}
|
||||
_ = g.Close()
|
||||
|
||||
// The hop in front comes back. Nothing is reset, nothing is reapplied — the
|
||||
// next scheduled check simply asks again and gets a different answer.
|
||||
gate.set("chain-c-h3", false)
|
||||
before := gate.asks()
|
||||
g.scheduledCheck()
|
||||
if gate.asks() <= before {
|
||||
t.Error("scheduledCheck did not re-ask the gate; a cached answer is a block that outlives its cause")
|
||||
}
|
||||
if len(dialsOf(&dialed)) == 0 {
|
||||
t.Fatal("the group did not resume dialling after the hop in front recovered")
|
||||
}
|
||||
}
|
||||
|
||||
// An EXPLICIT check is a deliberate request and is never gated — the same rule
|
||||
// SelfCheck already follows. The gate stands down the schedule, not the
|
||||
// capability.
|
||||
func TestProbeGateDoesNotBlockExplicitCheck(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
var dialed []string
|
||||
a := &healthNode{tag: "chain-c-h3-a", fail: true, dialed: &dialed}
|
||||
g := healthTestGroup(hist, managerOf(a), a)
|
||||
g.tag = "chain-c-h3"
|
||||
g.probeGate = &fakeGate{blocked: map[string]bool{"chain-c-h3": true}}
|
||||
|
||||
g.CheckOutbounds(true)
|
||||
if len(dialsOf(&dialed)) == 0 {
|
||||
t.Fatal("an explicit CheckOutbounds was refused by the gate")
|
||||
}
|
||||
}
|
||||
|
||||
// The two refusals are independent and compose the obvious way; and the absent
|
||||
// cases (no gate at all, an ungated tag) leave today's behaviour untouched,
|
||||
// which is what every plain sing-box config and every hand-built group relies
|
||||
// on.
|
||||
func TestSelfCheckAllowedCombinations(t *testing.T) {
|
||||
hist := urltest.NewHistoryStorage()
|
||||
defer hist.Close()
|
||||
a := &healthNode{tag: "a"}
|
||||
base := func() *URLTestGroup { return healthTestGroup(hist, managerOf(a), a) }
|
||||
|
||||
if g := base(); !g.selfCheckAllowed() {
|
||||
t.Error("a plain group with no gate must probe (the zero value is the compatibility default)")
|
||||
}
|
||||
g := base()
|
||||
g.selfCheckDisabled = true
|
||||
g.tag, g.probeGate = "chain-c-h3", &fakeGate{blocked: map[string]bool{}}
|
||||
if g.selfCheckAllowed() {
|
||||
t.Error("an UNUSED group must stay down even when the path in front is fine")
|
||||
}
|
||||
g = base()
|
||||
g.tag, g.probeGate = "chain-c-h3", &fakeGate{blocked: map[string]bool{"chain-c-h3": true}}
|
||||
if g.selfCheckAllowed() {
|
||||
t.Error("a group behind a dead hop must not run its schedule")
|
||||
}
|
||||
g = base()
|
||||
g.tag, g.probeGate = "auto", &fakeGate{blocked: map[string]bool{"chain-c-h3": true}}
|
||||
if !g.selfCheckAllowed() {
|
||||
t.Error("an unrelated group was gated by another tag's block")
|
||||
}
|
||||
g = base()
|
||||
g.probeGate = &fakeGate{blocked: map[string]bool{"": true}}
|
||||
if !g.selfCheckAllowed() {
|
||||
t.Error("a group with no tag must not be gated; there is nothing to ask about")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build with_gvisor && with_awg
|
||||
|
||||
// lx: regression for the removal of the AmneziaWG-over-WireGuard start guard.
|
||||
//
|
||||
// The guard refused to bring up an AmneziaWG endpoint whose detour chain reached
|
||||
// a WireGuard-based endpoint — and refused *silently*: Start returned nil with
|
||||
// started=false, so the endpoint looked configured but every dial through it
|
||||
// failed with "WireGuard is not ready yet". The root cause it protected against
|
||||
// (a kernel hang on Android) is gone on this graft (ClientBind reserved-gate),
|
||||
// and Android is not a supported platform here at all.
|
||||
//
|
||||
// This test builds a real AmneziaWG endpoint (junk + ranged magic headers) whose
|
||||
// detour points at an outbound of type "wireguard", drives both start stages,
|
||||
// and asserts the endpoint reports itself started. With the guard in place the
|
||||
// first stage short-circuits and started stays false — this test fails.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/pause"
|
||||
)
|
||||
|
||||
// wgTypedOutbound is an adapter.Outbound that reports type "wireguard" — the hop
|
||||
// the guard used to refuse to start behind. Dialling through it always fails:
|
||||
// the point of the test is that the upper endpoint comes UP, not that it carries
|
||||
// traffic (that is the job of the transport-level e2e stand).
|
||||
type wgTypedOutbound struct {
|
||||
adapter.Outbound
|
||||
tag string
|
||||
}
|
||||
|
||||
func (o *wgTypedOutbound) Type() string { return C.TypeWireGuard }
|
||||
func (o *wgTypedOutbound) Tag() string { return o.tag }
|
||||
func (o *wgTypedOutbound) Dependencies() []string { return nil }
|
||||
|
||||
func (o *wgTypedOutbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
|
||||
func (o *wgTypedOutbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
|
||||
// startChainManager resolves tags from a fixed map. adapter.OutboundManager is
|
||||
// embedded so this compiles against either shape of the interface.
|
||||
type startChainManager struct {
|
||||
adapter.OutboundManager
|
||||
byTag map[string]adapter.Outbound
|
||||
}
|
||||
|
||||
func (m *startChainManager) Outbound(tag string) (adapter.Outbound, bool) {
|
||||
ob, loaded := m.byTag[tag]
|
||||
return ob, loaded
|
||||
}
|
||||
|
||||
func randomKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Clamp so wireguard-go accepts it as a curve25519 private key.
|
||||
key[0] &= 248
|
||||
key[31] = (key[31] & 127) | 64
|
||||
return base64.StdEncoding.EncodeToString(key[:])
|
||||
}
|
||||
|
||||
// TestAmneziaWGOverWireGuardDetourStarts pins the invariant: an AmneziaWG
|
||||
// endpoint detouring through a WireGuard hop must come up like any other.
|
||||
func TestAmneziaWGOverWireGuardDetourStarts(t *testing.T) {
|
||||
ctx := pause.WithDefaultManager(context.Background())
|
||||
ctx = service.ContextWith[adapter.OutboundManager](ctx, &startChainManager{
|
||||
byTag: map[string]adapter.Outbound{
|
||||
"wg-hop": &wgTypedOutbound{tag: "wg-hop"},
|
||||
},
|
||||
})
|
||||
options := option.WireGuardEndpointOptions{
|
||||
MTU: 1280,
|
||||
Address: badoption.Listable[netip.Prefix]{netip.MustParsePrefix("10.7.0.2/32")},
|
||||
PrivateKey: randomKey(t),
|
||||
Peers: []option.WireGuardPeer{{
|
||||
Address: "10.9.9.9",
|
||||
Port: 51820,
|
||||
PublicKey: randomKey(t),
|
||||
AllowedIPs: badoption.Listable[netip.Prefix]{netip.MustParsePrefix("0.0.0.0/0")},
|
||||
}},
|
||||
AmneziaWGOptions: option.AmneziaWGOptions{
|
||||
Jc: 3,
|
||||
Jmin: 8,
|
||||
Jmax: 80,
|
||||
S4: 16,
|
||||
H1: "10-20",
|
||||
H2: "30-40",
|
||||
H3: "50-60",
|
||||
H4: "70-80",
|
||||
},
|
||||
}
|
||||
options.Detour = "wg-hop"
|
||||
|
||||
ep, err := NewEndpoint(ctx, nil, log.NewNOPFactory().NewLogger("wg-awg"), "wg-awg", options)
|
||||
if err != nil {
|
||||
t.Fatal("create amneziawg endpoint over a wireguard detour: ", err)
|
||||
}
|
||||
defer ep.Close()
|
||||
|
||||
if err = ep.Start(adapter.StartStateStart); err != nil {
|
||||
t.Fatal("start stage: ", err)
|
||||
}
|
||||
if err = ep.Start(adapter.StartStatePostStart); err != nil {
|
||||
t.Fatal("post-start stage: ", err)
|
||||
}
|
||||
if !ep.(*Endpoint).started.Load() {
|
||||
t.Fatal("an amneziawg endpoint behind a wireguard hop must start; it is silently held down")
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// lx:begin awg
|
||||
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
)
|
||||
|
||||
// fakeOutbound is a minimal adapter.Outbound for the start-guard chain walk:
|
||||
// only Type() and Dependencies() (the detour) are consulted. The embedded
|
||||
// interface is nil — any other method would panic, which never happens here.
|
||||
type fakeOutbound struct {
|
||||
adapter.Outbound
|
||||
tag string
|
||||
outboundTyp string
|
||||
detour string
|
||||
}
|
||||
|
||||
func (o *fakeOutbound) Type() string { return o.outboundTyp }
|
||||
func (o *fakeOutbound) Tag() string { return o.tag }
|
||||
func (o *fakeOutbound) Dependencies() []string {
|
||||
if o.detour == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{o.detour}
|
||||
}
|
||||
|
||||
// fakeGroup is an adapter.OutboundGroup (selector/urltest stand-in); the chain
|
||||
// walk must stop at it without expanding All().
|
||||
type fakeGroup struct {
|
||||
fakeOutbound
|
||||
members []string
|
||||
}
|
||||
|
||||
func (g *fakeGroup) Now() string { return "" }
|
||||
func (g *fakeGroup) All() []string { return g.members }
|
||||
|
||||
type fakeOutboundManager struct {
|
||||
adapter.OutboundManager
|
||||
byTag map[string]adapter.Outbound
|
||||
}
|
||||
|
||||
func (m *fakeOutboundManager) Outbound(tag string) (adapter.Outbound, bool) {
|
||||
ob, loaded := m.byTag[tag]
|
||||
return ob, loaded
|
||||
}
|
||||
|
||||
func TestAwgDetourChainReachesWireGuard(t *testing.T) {
|
||||
mgr := &fakeOutboundManager{byTag: map[string]adapter.Outbound{
|
||||
// AWG -> wg-out (direct)
|
||||
"wg-out": &fakeOutbound{tag: "wg-out", outboundTyp: C.TypeWireGuard},
|
||||
// AWG -> vless-hop -> wg-deep (transitive)
|
||||
"vless-hop": &fakeOutbound{tag: "vless-hop", outboundTyp: C.TypeVLESS, detour: "wg-deep"},
|
||||
"wg-deep": &fakeOutbound{tag: "wg-deep", outboundTyp: C.TypeWireGuard},
|
||||
// AWG -> vless-leaf -> direct-leaf (no wireguard anywhere)
|
||||
"vless-leaf": &fakeOutbound{tag: "vless-leaf", outboundTyp: C.TypeVLESS, detour: "direct-leaf"},
|
||||
"direct-leaf": &fakeOutbound{tag: "direct-leaf", outboundTyp: C.TypeDirect},
|
||||
// AWG -> sel (selector hiding a wireguard member) — walk must stop, return ""
|
||||
"sel": &fakeGroup{
|
||||
fakeOutbound: fakeOutbound{tag: "sel", outboundTyp: C.TypeSelector},
|
||||
members: []string{"wg-out"},
|
||||
},
|
||||
// cyclic detour: a -> b -> a, no wireguard
|
||||
"cyc-a": &fakeOutbound{tag: "cyc-a", outboundTyp: C.TypeVLESS, detour: "cyc-b"},
|
||||
"cyc-b": &fakeOutbound{tag: "cyc-b", outboundTyp: C.TypeVLESS, detour: "cyc-a"},
|
||||
}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
start string
|
||||
wantEmpty bool
|
||||
wantTag string
|
||||
}{
|
||||
{"direct wireguard", "wg-out", false, "wg-out"},
|
||||
{"transitive via vless", "vless-hop", false, "wg-deep"},
|
||||
{"no wireguard in chain", "vless-leaf", true, ""},
|
||||
{"selector in the middle is skipped", "sel", true, ""},
|
||||
{"cyclic chain terminates", "cyc-a", true, ""},
|
||||
{"unknown tag", "nope", true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := awgDetourChainReachesWireGuard(mgr, tc.start, make(map[string]bool))
|
||||
if tc.wantEmpty {
|
||||
if got != "" {
|
||||
t.Fatalf("expected no wireguard in chain, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tc.wantTag {
|
||||
t.Fatalf("expected blocked-by %q, got %q", tc.wantTag, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
+20
-118
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -45,28 +44,14 @@ type Endpoint struct {
|
||||
localAddresses []netip.Prefix
|
||||
endpoint *wireguard.Endpoint
|
||||
started atomic.Bool
|
||||
// lx:begin awg
|
||||
// awgActive marks this endpoint as running AmneziaWG (AmneziaWGOptions.IsSet());
|
||||
// detour is its configured upstream tag. Start uses them to refuse to bring up
|
||||
// an AmneziaWG-over-WireGuard chain, which hangs the kernel on Android — see
|
||||
// awgDetourChainReachesWireGuard. The ledger lives here (not just in the dialer
|
||||
// guard) because the hang happens synchronously in Start, before any dial.
|
||||
awgActive bool
|
||||
detour string
|
||||
// awgChainBlocked is set by Start when the AmneziaWG-over-WireGuard guard
|
||||
// fires: the device is left unstarted (started stays false) so no junk
|
||||
// handshake runs and the kernel cannot hang, while the rest of the instance
|
||||
// comes up. PostStart then skips this endpoint too.
|
||||
awgChainBlocked bool
|
||||
// lx:end awg
|
||||
// lx:begin idle-suspend
|
||||
// SPEC 020 idle-suspend state. lastActivity is the unix-nano timestamp of the
|
||||
// last dial through this endpoint, stamped at PostStart and on every dial entry.
|
||||
// idleAsleep is true while the endpoint is Down due to idle-suspend (distinct
|
||||
// from a guard-suspend, which sets started=false and clears idleAsleep, so a
|
||||
// guard-suspended endpoint fast-paths out of resumeOnDial and is never
|
||||
// idle-woken). resumeMu serialises the idle tick's suspend decision, a dial's
|
||||
// wake, and the AmneziaWG guard-suspend against one another.
|
||||
// from a deliberately-stopped endpoint, which has started=false and
|
||||
// idleAsleep=false, so it fast-paths out of resumeOnDial and is never
|
||||
// idle-woken). resumeMu serialises the idle tick's suspend decision against a
|
||||
// dial's wake.
|
||||
lastActivity atomic.Int64
|
||||
idleAsleep atomic.Bool
|
||||
resumeMu sync.Mutex
|
||||
@@ -74,6 +59,16 @@ type Endpoint struct {
|
||||
}
|
||||
|
||||
func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.WireGuardEndpointOptions) (adapter.Endpoint, error) {
|
||||
// lx: allow OS-level fragmentation of the OUTER UDP socket by default, the
|
||||
// same opt-out direct/hysteria/hysteria2/tuic already take. Without it the
|
||||
// dialer sets DF (IP_MTU_DISCOVER=IP_PMTUDISC_DO on linux), and an outer
|
||||
// datagram over the path MTU — routine once anything is encapsulated: WG's
|
||||
// own ~32 B header, AmneziaWG s4 transport junk, or this endpoint carrying a
|
||||
// nested tunnel — is dropped by the kernel ("message too long") instead of
|
||||
// fragmented, so the tunnel comes up and then carries nothing. An explicit
|
||||
// `udp_fragment: false` on the node still restores DF (UDPFragment wins over
|
||||
// UDPFragmentDefault in common/dialer).
|
||||
options.UDPFragmentDefault = true
|
||||
ep := &Endpoint{
|
||||
Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeWireGuard, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions),
|
||||
ctx: ctx,
|
||||
@@ -81,10 +76,6 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
dnsRouter: service.FromContext[adapter.DNSRouter](ctx),
|
||||
logger: logger,
|
||||
localAddresses: options.Address,
|
||||
// lx:begin awg
|
||||
awgActive: options.AmneziaWGOptions.IsSet(),
|
||||
detour: options.Detour,
|
||||
// lx:end awg
|
||||
}
|
||||
if options.Detour != "" && options.ListenPort != 0 {
|
||||
return nil, E.New("`listen_port` is conflict with `detour`")
|
||||
@@ -116,7 +107,8 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
Dialer: outboundDialer,
|
||||
CreateDialer: func(interfaceName string) N.Dialer {
|
||||
return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{
|
||||
BindInterface: interfaceName,
|
||||
BindInterface: interfaceName,
|
||||
UDPFragmentDefault: true, // lx: same reason as above — this is the bind-to-interface twin of the outer socket
|
||||
}))
|
||||
},
|
||||
Name: options.Name,
|
||||
@@ -157,33 +149,6 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
|
||||
}
|
||||
|
||||
func (w *Endpoint) Start(stage adapter.StartStage) error {
|
||||
// lx:begin awg
|
||||
// Refuse to bring up an AmneziaWG endpoint whose detour chain reaches a
|
||||
// WireGuard-based endpoint: encapsulating AWG (junk handshake) inside a
|
||||
// WireGuard tunnel hangs the kernel on Android. The hang happens here, in the
|
||||
// synchronous Start path (peer-domain resolution over the detour, then the
|
||||
// device's junk handshake) — before any dial — so the lazy DetourDialer guard
|
||||
// never gets a chance to fire. We must catch it at Start instead.
|
||||
//
|
||||
// Behaviour is "variant B": do NOT return an error (that would abort the whole
|
||||
// instance start). Instead log, skip device startup, and leave started=false
|
||||
// so the rest of the config comes up and every dial through this endpoint
|
||||
// fails cleanly with "WireGuard is not ready yet". A selector/urltest in the
|
||||
// middle hides the real target at start time, so the chain walk stops at a
|
||||
// group and that case is left to the lazy DetourDialer guard at dial time.
|
||||
if stage == adapter.StartStateStart && w.awgActive && w.detour != "" {
|
||||
if outboundManager := service.FromContext[adapter.OutboundManager](w.ctx); outboundManager != nil {
|
||||
if blockedBy := awgDetourChainReachesWireGuard(outboundManager, w.detour, make(map[string]bool)); blockedBy != "" {
|
||||
w.awgChainBlocked = true
|
||||
w.logger.Error("amneziawg endpoint will not start: its detour chain reaches wireguard-based endpoint ", strconv.Quote(blockedBy), " — amneziawg over wireguard is not supported. Use a non-wireguard detour (e.g. vless).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.awgChainBlocked {
|
||||
return nil
|
||||
}
|
||||
// lx:end awg
|
||||
switch stage {
|
||||
case adapter.StartStateStart:
|
||||
return w.endpoint.Start(false)
|
||||
@@ -200,69 +165,6 @@ func (w *Endpoint) Start(stage adapter.StartStage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// lx:begin awg
|
||||
// awgDetourChainReachesWireGuard walks the transitive detour chain starting at
|
||||
// tag and returns the tag of the first WireGuard-based outbound it reaches
|
||||
// (type "wireguard", covering plain WireGuard and AmneziaWG), or "" if none. It
|
||||
// follows each outbound's detour dependency; it deliberately does NOT expand
|
||||
// selector/urltest groups, whose chosen member is only known at runtime — that
|
||||
// case is handled lazily by the DetourDialer guard. visited guards against cyclic
|
||||
// detour configs. All outbounds are registered before any Start, so every tag in
|
||||
// the chain is resolvable here even though some may not have started yet.
|
||||
func awgDetourChainReachesWireGuard(outboundManager adapter.OutboundManager, tag string, visited map[string]bool) string {
|
||||
if tag == "" || visited[tag] {
|
||||
return ""
|
||||
}
|
||||
visited[tag] = true
|
||||
outbound, loaded := outboundManager.Outbound(tag)
|
||||
if !loaded {
|
||||
return ""
|
||||
}
|
||||
if outbound.Type() == C.TypeWireGuard {
|
||||
return tag
|
||||
}
|
||||
if _, isGroup := outbound.(adapter.OutboundGroup); isGroup {
|
||||
// Runtime-resolved target — leave it to the lazy DetourDialer guard.
|
||||
return ""
|
||||
}
|
||||
for _, dependency := range outbound.Dependencies() {
|
||||
if blockedBy := awgDetourChainReachesWireGuard(outboundManager, dependency, visited); blockedBy != "" {
|
||||
return blockedBy
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsAmneziaWG reports whether this endpoint runs AmneziaWG. Implements
|
||||
// adapter.AmneziaWGSuspendable.
|
||||
func (w *Endpoint) IsAmneziaWG() bool {
|
||||
return w.awgActive
|
||||
}
|
||||
|
||||
// SuspendAmneziaWG brings the device down and marks the endpoint not-ready, so a
|
||||
// junk handshake is never sent and every dial fails with "WireGuard is not ready
|
||||
// yet". Called by the selector guard when a group this endpoint detours through
|
||||
// switches to a WireGuard member (AmneziaWG over WireGuard hangs the kernel on
|
||||
// Android). Idempotent. Implements adapter.AmneziaWGSuspendable.
|
||||
func (w *Endpoint) SuspendAmneziaWG() {
|
||||
// Take resumeMu so this is ordered against resumeOnDial/SuspendIfIdle: without
|
||||
// it, a dial that already passed resumeOnDial's idleAsleep checks could wake
|
||||
// the endpoint back up right after we clear the flag, defeating the guard.
|
||||
w.resumeMu.Lock()
|
||||
defer w.resumeMu.Unlock()
|
||||
if w.started.CompareAndSwap(true, false) {
|
||||
w.logger.Error("amneziawg endpoint suspended: a selector in its detour chain switched to a wireguard-based member — amneziawg over wireguard is not supported")
|
||||
}
|
||||
// Clear any idle-suspend state so resumeOnDial does not resurrect a
|
||||
// guard-suspended endpoint: if it was idle-asleep first, idleAsleep would still
|
||||
// be true and the next dial would wake it (SPEC 022 #2). With idleAsleep=false
|
||||
// resumeOnDial's fast path returns started (now false) and the endpoint stays down.
|
||||
w.idleAsleep.Store(false)
|
||||
w.endpoint.Suspend()
|
||||
}
|
||||
|
||||
// lx:end awg
|
||||
|
||||
// lx:begin idle-suspend
|
||||
|
||||
// stampActivity records the current time as the last dial through this endpoint.
|
||||
@@ -286,8 +188,8 @@ func (w *Endpoint) IdleSince() time.Duration {
|
||||
// holder — when it is unreachable from the active routing tree AND has been idle
|
||||
// past the threshold. Silent on every non-transition (edge-triggered logging).
|
||||
//
|
||||
// It never touches a guard-suspended endpoint: that one already has
|
||||
// started==false but idleAsleep==false, and the `!started` guard below short-
|
||||
// It never touches a deliberately-stopped endpoint: that one already has
|
||||
// started==false but idleAsleep==false, and the `!started` check below short-
|
||||
// circuits before the CAS. resumeMu mutually excludes this against resumeOnDial.
|
||||
func (w *Endpoint) SuspendIfIdle(reachable bool, threshold time.Duration) {
|
||||
w.resumeMu.Lock()
|
||||
@@ -296,7 +198,7 @@ func (w *Endpoint) SuspendIfIdle(reachable bool, threshold time.Duration) {
|
||||
return
|
||||
}
|
||||
if !w.started.Load() {
|
||||
// Already down some other way (guard-suspend, awg-chain-blocked, closed).
|
||||
// Already down some other way (deliberately stopped, closed).
|
||||
return
|
||||
}
|
||||
if w.idleAsleep.CompareAndSwap(false, true) {
|
||||
@@ -313,7 +215,7 @@ func (w *Endpoint) SuspendIfIdle(reachable bool, threshold time.Duration) {
|
||||
// session); that cost is on the first packet, as for any cold WG dial.
|
||||
//
|
||||
// Returns true if the endpoint is dialable (awake), false if it must stay down
|
||||
// (guard-suspend / chain-blocked — not an idle-suspend, so we do not resurrect it).
|
||||
// (deliberately stopped / closed — not an idle-suspend, so we do not resurrect it).
|
||||
func (w *Endpoint) resumeOnDial() bool {
|
||||
w.stampActivity()
|
||||
if !w.idleAsleep.Load() {
|
||||
|
||||
@@ -103,21 +103,21 @@ func TestSuspendIfIdle_idempotentCAS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspendIfIdle_guardSuspendedNotTouched is the §8 invariant verified live on
|
||||
// an AWG-over-WG endpoint (wg-3 in the prod run): a guard-suspended endpoint has
|
||||
// started=false WITHOUT idleAsleep. The idle tick must early-return on !started and
|
||||
// NOT flip idleAsleep — otherwise a later resumeOnDial would idle-wake it and
|
||||
// re-trigger the AWG-over-WG kernel hang the guard exists to prevent.
|
||||
func TestSuspendIfIdle_guardSuspendedNotTouched(t *testing.T) {
|
||||
// TestSuspendIfIdle_stoppedNotTouched is the §8 invariant: a deliberately-stopped
|
||||
// endpoint (Close, or a start that never completed) has started=false WITHOUT
|
||||
// idleAsleep. The idle tick must early-return on !started and NOT flip idleAsleep
|
||||
// — otherwise a later resumeOnDial would idle-wake a device that was
|
||||
// intentionally down.
|
||||
func TestSuspendIfIdle_stoppedNotTouched(t *testing.T) {
|
||||
w := newIdleTestEndpoint()
|
||||
w.started.Store(false) // guard-suspend (device.Down at Start), idleAsleep stays false
|
||||
w.started.Store(false) // stopped, idleAsleep stays false
|
||||
w.lastActivity.Store(time.Now().Add(-time.Hour).UnixNano())
|
||||
w.SuspendIfIdle(false, 30*time.Second)
|
||||
if w.idleAsleep.Load() {
|
||||
t.Fatal("a guard-suspended endpoint must NOT be flagged idleAsleep by the tick")
|
||||
t.Fatal("a stopped endpoint must NOT be flagged idleAsleep by the tick")
|
||||
}
|
||||
if w.started.Load() {
|
||||
t.Fatal("the tick must not change started for a guard-suspended endpoint")
|
||||
t.Fatal("the tick must not change started for a stopped endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,17 +152,17 @@ func TestResumeOnDial_dialBeforeTickRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeOnDial_guardSuspendedNotWoken(t *testing.T) {
|
||||
// A guard-suspended endpoint has started=false but idleAsleep=false.
|
||||
// resumeOnDial must NOT wake it (returns started, i.e. false).
|
||||
func TestResumeOnDial_stoppedNotWoken(t *testing.T) {
|
||||
// A deliberately-stopped endpoint (Close / failed start) has started=false but
|
||||
// idleAsleep=false. resumeOnDial must NOT wake it (returns started, i.e. false).
|
||||
w := newIdleTestEndpoint()
|
||||
w.started.Store(false) // simulate guard/awg-chain suspend (not idle)
|
||||
w.started.Store(false) // stopped, not idle-suspended
|
||||
ok := w.resumeOnDial()
|
||||
if ok {
|
||||
t.Fatal("resumeOnDial must not resurrect a guard-suspended (non-idle) endpoint")
|
||||
t.Fatal("resumeOnDial must not resurrect a stopped (non-idle) endpoint")
|
||||
}
|
||||
if w.idleAsleep.Load() {
|
||||
t.Fatal("guard-suspended endpoint must not be flagged idleAsleep")
|
||||
t.Fatal("stopped endpoint must not be flagged idleAsleep")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-13
@@ -18,9 +18,10 @@
|
||||
# OpenWrt package can $(INSTALL_BIN) the arch-matched artifact.
|
||||
# 5. Prints a size table + a per-arch static check (ELF type / no PT_INTERP).
|
||||
#
|
||||
# Router build tag set = D9 (musl-static). We deliberately DROP with_purego and
|
||||
# with_naive_outbound: they pull cronet-go, which forces a glibc PT_INTERP even
|
||||
# with CGO_ENABLED=0, making the binary unusable on musl OpenWrt.
|
||||
# Router build tag set = D9/D23 (musl-static). It is DEFINED IN, and only in,
|
||||
# scripts/router-tags.sh (sourced below) — that file documents every tag and is
|
||||
# machine-checked against the declared feature list by shater/buildtags's test.
|
||||
# Run scripts/check-router-tags.sh after touching it.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-shaterd.sh [VERSION] [--fast]
|
||||
@@ -82,16 +83,15 @@ fi
|
||||
[ -n "$VERSION" ] || VERSION="v0.2.0-dev"
|
||||
|
||||
# --- config -----------------------------------------------------------------
|
||||
# D9 router tag set (musl-static). Keep in sync with docs-shater/DECISIONS.md D9.
|
||||
# No with_gvisor: the data plane is tproxy/redirect (netplane), generate never
|
||||
# emits a tun inbound, so the userspace gvisor stack was 3.6 MB of dead weight
|
||||
# (tun would fall back to the system stack anyway).
|
||||
# No with_clash_api: the panel is shater's own; generate never emits a clash_api
|
||||
# service ("the shater generator emits none of those" — shater/engine/engine.go).
|
||||
# No with_dhcp: shater resolvers are udp/tcp/doh/dot/local/fakeip — no "dhcp://"
|
||||
# DNS transport is ever generated, and the slim registry never registers it.
|
||||
ROUTER_TAGS="with_quic,with_wireguard,with_utls,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command"
|
||||
LDFLAGS="-X github.com/sagernet/sing-box/constant.Version=${VERSION} -checklinkname=0 -s -w -buildid="
|
||||
# D9/D23 router tag set (musl-static). The set itself lives in ONE place —
|
||||
# scripts/router-tags.sh — because it is also parsed by shater/buildtags's test,
|
||||
# which proves it still covers every feature docs-shater/FEATURES.md declares.
|
||||
# Do not re-inline it here: that split is exactly how `with_gvisor` went missing
|
||||
# while `with_wireguard` stayed (D23).
|
||||
# shellcheck source=router-tags.sh
|
||||
. "$SCRIPT_DIR/router-tags.sh"
|
||||
ROUTER_TAGS="$SHATER_ROUTER_TAGS"
|
||||
LDFLAGS="-X github.com/sagernet/sing-box/constant.Version=${VERSION} ${SHATER_ROUTER_LDFLAGS} -s -w -buildid="
|
||||
|
||||
UPX_BIN="${UPX:-upx}"
|
||||
# UPX itself treats the environment variable UPX as extra command-line options, so
|
||||
@@ -111,6 +111,20 @@ echo " version : $VERSION"
|
||||
echo " tags : $ROUTER_TAGS"
|
||||
echo " upx : $UPX_BIN"
|
||||
echo " go : $(go version)"
|
||||
|
||||
# --- gate: does this tag set still support what we declare? (D23) ------------
|
||||
# Cheap (one tiny tag-less package, no network, ~1 s) and it travels with the
|
||||
# BUILD rather than with a CI config, so an artifact produced by hand on a
|
||||
# developer's machine gets the same guarantee. The heavier half — actually
|
||||
# constructing every declared protocol under these tags — is
|
||||
# scripts/check-router-tags.sh, which CI runs before this script.
|
||||
if ! (cd "$REPO" && go test -count=1 ./shater/buildtags/ >/dev/null); then
|
||||
echo >&2
|
||||
echo " ABORT: the router tag set no longer covers a declared feature." >&2
|
||||
echo " Details: go test ./shater/buildtags/" >&2
|
||||
echo " Full check: scripts/check-router-tags.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- step 1: build the SPA --------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# check-router-tags.sh — prove the SHIPPED build-tag set still supports every
|
||||
# feature shater declares (D23).
|
||||
#
|
||||
# WHY (2026-07-25): the router tag set is a trimmed subset of upstream's, but the
|
||||
# test suite builds with the FULL upstream set — so the one combination we
|
||||
# actually ship was never exercised. `with_gvisor` got trimmed while
|
||||
# `with_wireguard` stayed, and every shipped binary answered a WireGuard node
|
||||
# with "gVisor is not included in this build". Compiling is not evidence.
|
||||
#
|
||||
# WHAT IT RUNS
|
||||
# 1. shater/buildtags, TAG-LESS — reads scripts/router-tags.sh and fails if a
|
||||
# declared feature (buildtags.Features) lost a build tag it needs. Cheap,
|
||||
# hostable anywhere, catches the trim at the moment it happens.
|
||||
# 2. shater/generate + shater/buildtags, WITH THE SHIPPED TAG SET on linux —
|
||||
# constructs one node of every declared protocol through box.New+Start, and
|
||||
# cross-checks that the tag detectors match the set the compiler was given.
|
||||
# This is the half that catches "the tag is there but insufficient".
|
||||
#
|
||||
# The run is unprivileged (no tproxy inbound is built) and offline apart from Go
|
||||
# module downloads.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-router-tags.sh
|
||||
#
|
||||
# Env:
|
||||
# SHATER_GO_IMAGE docker image used to reach linux from a non-linux host
|
||||
# (default golang:1.26 — keep it >= go.mod's toolchain).
|
||||
# SHATER_NO_DOCKER=1 fail instead of falling back to docker.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
# shellcheck source=router-tags.sh
|
||||
. "$SCRIPT_DIR/router-tags.sh"
|
||||
|
||||
echo "== router build-tag check =="
|
||||
echo " tags : $SHATER_ROUTER_TAGS"
|
||||
echo " ldflags: $SHATER_ROUTER_LDFLAGS"
|
||||
echo
|
||||
|
||||
# --- 1. static: does the set still cover the declared features? --------------
|
||||
# No tags, no OS constraint: this is the check that would have caught the outage
|
||||
# on the developer's own machine.
|
||||
echo "== [1/2] declared features vs. the shipped tag set (no tags needed) =="
|
||||
go test -count=1 ./shater/buildtags/
|
||||
echo
|
||||
|
||||
# --- 2. behavioural: does the shipped combination actually construct? --------
|
||||
# The protocol-construction test is linux-only (box.New validates the loop-guard
|
||||
# routing_mark only there). From a non-linux host, re-exec this script inside a
|
||||
# golang container rather than silently skipping — a skipped guard is no guard.
|
||||
if [ "$(go env GOOS)" != "linux" ] && [ "${SHATER_TAGCHECK_IN_DOCKER:-0}" != "1" ]; then
|
||||
if [ "${SHATER_NO_DOCKER:-0}" = "1" ] || ! command -v docker >/dev/null 2>&1; then
|
||||
echo " ERROR: step 2 needs linux (GOOS=$(go env GOOS)) and docker is unavailable/disabled." >&2
|
||||
echo " Run this script on the linux CI runner or the OpenWrt VM." >&2
|
||||
exit 1
|
||||
fi
|
||||
image="${SHATER_GO_IMAGE:-golang:1.26}"
|
||||
echo "== [2/2] re-exec on linux via docker ($image) =="
|
||||
host_repo="$REPO"
|
||||
command -v cygpath >/dev/null 2>&1 && host_repo="$(cygpath -w "$REPO")"
|
||||
# Named volumes keep the module/build cache warm between runs; MSYS2_ARG_CONV_EXCL
|
||||
# stops Git Bash from rewriting the container-side paths into windows ones.
|
||||
MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$host_repo":/src \
|
||||
-v shater-tagcheck-gomod:/go/pkg/mod \
|
||||
-v shater-tagcheck-gocache:/root/.cache/go-build \
|
||||
-w /src \
|
||||
-e SHATER_TAGCHECK_IN_DOCKER=1 \
|
||||
"$image" bash scripts/check-router-tags.sh
|
||||
exit $?
|
||||
fi
|
||||
|
||||
echo "== [2/2] every declared protocol constructs under the SHIPPED tags =="
|
||||
out="$(mktemp)"
|
||||
trap 'rm -f "$out"' EXIT
|
||||
set +e
|
||||
SHATER_ROUTER_TAG_CHECK=1 go test -count=1 -v \
|
||||
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
|
||||
-run 'TestShippedTagSetConstructsDeclaredProtocols|TestEveryTagGatedFeatureIsProbed' \
|
||||
./shater/generate/ >"$out" 2>&1
|
||||
rc_gen=$?
|
||||
set -e
|
||||
sed 's/^/ /' "$out"
|
||||
|
||||
set +e
|
||||
SHATER_ROUTER_TAG_CHECK=1 go test -count=1 -v \
|
||||
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
|
||||
-run 'TestCompiledTagsMatchTheShippedSet' \
|
||||
./shater/buildtags/ >"$out" 2>&1
|
||||
rc_tags=$?
|
||||
set -e
|
||||
sed 's/^/ /' "$out"
|
||||
|
||||
if [ "$rc_gen" -ne 0 ] || [ "$rc_tags" -ne 0 ]; then
|
||||
echo
|
||||
echo " FAILED: the tag set we SHIP cannot do what we declare." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A guard that silently runs nothing is worse than no guard: prove the tests were
|
||||
# actually compiled in and executed (build tags / file renames could exclude them).
|
||||
for want in TestShippedTagSetConstructsDeclaredProtocols TestCompiledTagsMatchTheShippedSet; do
|
||||
if ! SHATER_ROUTER_TAG_CHECK=1 go test -count=1 -v \
|
||||
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
|
||||
-run "$want" ./shater/generate/ ./shater/buildtags/ 2>&1 | grep -q -- "--- PASS: $want"; then
|
||||
echo " FAILED: $want did not run (build-tag/file-name drift?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== OK: the shipped tag set covers every declared feature, and every =="
|
||||
echo "== declared protocol constructs through box.New under it. =="
|
||||
@@ -0,0 +1,78 @@
|
||||
# shellcheck shell=sh
|
||||
#
|
||||
# router-tags.sh — THE build-tag set of the shipped `shaterd` router binary.
|
||||
#
|
||||
# This file is DATA, not a program: it is `.`-sourced by
|
||||
# - scripts/build-shaterd.sh (the ship build)
|
||||
# - scripts/check-router-tags.sh (the guard that proves the set is complete)
|
||||
# and it is PARSED by shater/buildtags/buildtags_test.go, which asserts that
|
||||
# every feature docs-shater/FEATURES.md declares supported has its build tags
|
||||
# present here. Change the set here and nowhere else.
|
||||
#
|
||||
# WHY A TRIMMED SET AT ALL (D9): upstream's DEFAULT_BUILD_TAGS registers the
|
||||
# whole sing-box zoo. We drop what shater/generate can never emit, because a
|
||||
# router binary pays for every tag twice — flash and (UPX unpacks into anonymous
|
||||
# pages) resident RAM. We do NOT drop what a declared feature needs to run.
|
||||
#
|
||||
# WHY THIS FILE EXISTS (the 2026-07-25 WireGuard outage): the set used to be a
|
||||
# string literal inside build-shaterd.sh, with nothing connecting it to the
|
||||
# feature list. `with_gvisor` was trimmed as "unreachable code" while
|
||||
# `with_wireguard` stayed — so every shipped binary answered a WireGuard node
|
||||
# with "gVisor is not included in this build". No test caught it: the test suite
|
||||
# builds with the FULL upstream tag set, so the SHIPPED combination was never
|
||||
# exercised. One file + one test now hold the two halves together (D23).
|
||||
#
|
||||
# ---- the set -----------------------------------------------------------------
|
||||
# with_gvisor userspace netstack. REQUIRED BY with_wireguard: both
|
||||
# transport/wireguard device constructors
|
||||
# (newStackDevice AND newSystemStackDevice) are stubs
|
||||
# returning tun.ErrGVisorNotIncluded without it, so
|
||||
# box.New dies at "create WireGuard device". Not
|
||||
# optional as long as we ship WireGuard/AmneziaWG.
|
||||
# with_quic hysteria2 + tuic outbounds, QUIC/HTTP3 DNS
|
||||
# transports, and the vless/vmess `type=quic` transport
|
||||
# (shater/registry/registry_quic.go).
|
||||
# with_wireguard registers the wireguard endpoint
|
||||
# (shater/registry/registry_wireguard.go).
|
||||
# with_awg AmneziaWG obfuscation params (jc/jmin/jmax, s1-s4,
|
||||
# h1-h4, i1-i5) actually reach the device
|
||||
# (transport/wireguard/device_awg.go). A driving
|
||||
# product requirement — FEATURES.md marks it [MVP].
|
||||
# with_utls uTLS fingerprints AND REALITY: common/tls/
|
||||
# reality_client.go is itself `//go:build with_utls`.
|
||||
# with_xhttp the XHTTP/SplitHTTP v2ray transport
|
||||
# (shater/parse emits type=xhttp).
|
||||
# badlinkname badtls fast path (common/badtls/*.go are
|
||||
# `go1.25 && badlinkname`). Needs -checklinkname=0 in
|
||||
# SHATER_ROUTER_LDFLAGS below or the LINK step fails.
|
||||
# tfogo_checklinkname0 same deal for tfo-go's linkname use.
|
||||
# with_lx_command lx daemon command server. Inert for shaterd (nothing
|
||||
# under shater/ imports sing-box/daemon or libbox —
|
||||
# `go list -deps ./shater/cmd/shaterd` links neither),
|
||||
# kept only so the router set stays a subset of the lx
|
||||
# desktop set. Costs nothing; safe to drop later.
|
||||
#
|
||||
# Deliberately NOT here (each is unreachable for shater, not merely unused):
|
||||
# with_purego,with_naive_outbound cronet-go forces a glibc PT_INTERP even at
|
||||
# CGO_ENABLED=0 -> will not run on musl (D9).
|
||||
# with_clash_api the panel is shater's own web server;
|
||||
# generate emits no clash_api service.
|
||||
# with_dhcp resolver types are udp/tcp/doh/dot/local/
|
||||
# fakeip; no dhcp:// transport is generated.
|
||||
# with_tailscale,with_acme,with_ech,with_usbip,with_cloudflared,with_ocm,
|
||||
# with_ccm,with_v2ray_api,with_reality_server
|
||||
# nothing in shater/parse or shater/generate
|
||||
# can produce them; hysteria2 `ech=` is
|
||||
# refused with a warning in the parser.
|
||||
#
|
||||
# Adding a tag here is cheap. REMOVING one is a product decision: run
|
||||
# `scripts/check-router-tags.sh` — it fails if the set no longer covers a
|
||||
# declared feature, and it fails if the shipped combination cannot construct
|
||||
# every protocol through box.New.
|
||||
|
||||
SHATER_ROUTER_TAGS="with_gvisor,with_quic,with_wireguard,with_utls,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command"
|
||||
|
||||
# Linker flags the tag set REQUIRES (they are not optional trimming: `badlinkname`
|
||||
# without -checklinkname=0 fails at link time with
|
||||
# "invalid reference to crypto/tls.(*Conn).handlePostHandshakeMessage").
|
||||
SHATER_ROUTER_LDFLAGS="-checklinkname=0"
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# run-panel-tests.sh — the admin-panel half of the release test gate.
|
||||
#
|
||||
# panel/package.json has declared a `test` script since the SPA was scaffolded
|
||||
# and nothing had ever called it: not release.yml, not build-shaterd.sh (which
|
||||
# only runs `npm ci` + `npm run build`). This script is what CI calls, and it
|
||||
# does the one thing `npm test` alone cannot: prove that tests actually RAN.
|
||||
#
|
||||
# `node --test src/*.test.ts` with no matching file leaves the glob unexpanded;
|
||||
# node then reports `pass 0` and exits 0 — a green CI step that ran nothing,
|
||||
# which is the exact failure class this whole change is about. So: the test
|
||||
# files are counted BEFORE the run (a rename to *.spec.ts is named as such
|
||||
# rather than showing up as a mystery), and the pass count is asserted > 0 and
|
||||
# the fail count 0 after it.
|
||||
#
|
||||
# KNOWN LIMIT: node --test counts a *.test.ts file that declares no cases at
|
||||
# all as one passing "test" (the module loaded). So an emptied-out file still
|
||||
# reads as pass 1 here. Deleting, renaming or breaking the file is caught;
|
||||
# gutting its contents while keeping the name is not.
|
||||
#
|
||||
# NODE VERSION: >= 22.6. The tests are TypeScript executed directly by
|
||||
# `node --test`; type stripping does not exist before then, so on node 20 the
|
||||
# run dies with a syntax error. CI pins node 24 for this step (the SPA *build*
|
||||
# still uses node 20 — that one goes through vite/tsc and does not care).
|
||||
#
|
||||
# Usage: scripts/run-panel-tests.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO/panel"
|
||||
|
||||
node_major="$(node -p 'process.versions.node.split(".")[0]')"
|
||||
node_minor="$(node -p 'process.versions.node.split(".")[1]')"
|
||||
if [ "$node_major" -lt 22 ] || { [ "$node_major" -eq 22 ] && [ "$node_minor" -lt 6 ]; }; then
|
||||
echo " ERROR: panel tests are TypeScript under \`node --test\` and need node >= 22.6" >&2
|
||||
echo " (got $(node --version)). Type stripping does not exist before that." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The glob `npm test` itself uses. Counted here so "somebody renamed the tests"
|
||||
# is reported as that, instead of as a suspiciously fast green step.
|
||||
shopt -s nullglob
|
||||
files=(src/*.test.ts)
|
||||
shopt -u nullglob
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo " ERROR: no panel/src/*.test.ts — panel/package.json's \`test\` script" >&2
|
||||
echo " would match nothing and still exit 0. Fix the glob or the files." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== panel tests (node $(node --version)), ${#files[@]} file(s) =="
|
||||
if [ ! -d node_modules ]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
out=""
|
||||
rc=0
|
||||
set +e
|
||||
out="$(npm test --silent 2>&1)"
|
||||
rc=$?
|
||||
set -e
|
||||
sed 's/^/ /' <<<"$out"
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo " FAILED: npm test exited $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# node --test's summary is `ℹ pass N` (spec reporter) or `# pass N` (tap).
|
||||
passed="$(sed -n 's/.*[[:space:]]pass[[:space:]]\{1,\}\([0-9]\{1,\}\).*/\1/p' <<<"$out" | tail -1)"
|
||||
failed="$(sed -n 's/.*[[:space:]]fail[[:space:]]\{1,\}\([0-9]\{1,\}\).*/\1/p' <<<"$out" | tail -1)"
|
||||
if [ -z "$passed" ]; then
|
||||
echo " FAILED: could not find a pass count in node --test output — the gate" >&2
|
||||
echo " cannot tell a green run from an empty one." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$passed" -lt 1 ]; then
|
||||
echo " FAILED: 0 panel tests ran. \`npm test\` returned success having done nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$failed" ] && [ "$failed" -gt 0 ]; then
|
||||
echo " FAILED: $failed panel test(s) failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " OK: $passed panel test(s) passed."
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# run-tests.sh — THE test gate of the release tract.
|
||||
#
|
||||
# WHY THIS EXISTS (2026-07-26)
|
||||
# Until now the release tract ran almost no tests. The only `go test` calls in
|
||||
# the whole publishing path were scripts/build-shaterd.sh's one-package
|
||||
# buildtags check and the three named tests scripts/check-router-tags.sh runs.
|
||||
# The upstream .github/workflows/test.yml triggers on `stable`/`testing`/
|
||||
# `unstable` — branches this fork does not have — and Gitea does not read
|
||||
# .github/workflows at all once .gitea/workflows exists. Net effect: 115 of the
|
||||
# 116 test files under shater/** had never executed in CI, and
|
||||
# TestDNSFilterRemoteBlocklistHTTPClient shipped red through two releases
|
||||
# before anyone ran it by hand.
|
||||
#
|
||||
# WHAT IT GUARANTEES
|
||||
# 1. The suite runs under the SHIPPED build tags (scripts/router-tags.sh), not
|
||||
# under some CI-local tag set. This is not cosmetic: the AmneziaWG tests in
|
||||
# transport/wireguard are `//go:build with_awg` — 1 test file compiles
|
||||
# without the tag set, 7 with it. The 2026-07-25 WireGuard outage was
|
||||
# exactly a "built with X, verified with Y" gap.
|
||||
# 2. It runs on linux. shater/generate has 44 test files on linux against 32 on
|
||||
# windows/darwin; the linux-only half is where the routing, ruleset, DNS and
|
||||
# health tests live.
|
||||
# 3. Nothing is skipped SILENTLY. Two machine checks:
|
||||
# - the tag set may only ADD test files, never hide them (a test behind
|
||||
# `//go:build !with_awg` would vanish from the gate — this fails first);
|
||||
# - every package that has tests must report `ok` by name; a suite that
|
||||
# compiles down to "no test files" fails the gate instead of passing it.
|
||||
# A guard that silently runs nothing is worse than no guard (same rule as
|
||||
# scripts/check-router-tags.sh).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/run-tests.sh # full gate (~3 min warm on the runner)
|
||||
# scripts/run-tests.sh --no-race # skip the -race pass (faster; local loop)
|
||||
#
|
||||
# Env:
|
||||
# SHATER_GO_IMAGE docker image used to reach linux from a non-linux host
|
||||
# (default golang:1.26 — keep it >= go.mod's toolchain).
|
||||
# SHATER_NO_DOCKER=1 fail instead of falling back to docker.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
RACE=1
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--no-race) RACE=0 ;;
|
||||
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "run-tests: unknown flag: $a" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# shellcheck source=router-tags.sh
|
||||
. "$SCRIPT_DIR/router-tags.sh"
|
||||
|
||||
# The fork's own trees plus the upstream trees the fork edits (adapter/, route/,
|
||||
# option/, dns/ all carry shater changes). ROOTS, not a hand-kept package list: a
|
||||
# new package with tests joins the gate the moment it is created, which is the
|
||||
# whole point.
|
||||
ROOTS=(./shater/... ./protocol/... ./transport/... ./adapter/... ./route/... ./option/... ./dns/...)
|
||||
|
||||
# common/ is mostly upstream, but common/tls, common/tlsfragment, common/sniff
|
||||
# and common/urltest carry fork behaviour (D13 DPI-bypass, urltest health), so it
|
||||
# is in — minus the privileged integration tests below.
|
||||
ROOTS_COMMON=(./common/...)
|
||||
# SKIP, WITH REASON: common/tlsspoof's TestIntegration* enter TCP_REPAIR and
|
||||
# need CAP_NET_ADMIN. The act_runner job container runs as root but WITHOUT
|
||||
# that capability, so they do not skip — they FAIL. Excluded by name so the
|
||||
# rest of common/ can be a real gate instead of a permanently red one. (On
|
||||
# linux every tlsspoof test is a TestIntegration*, so that package is
|
||||
# effectively uncovered here; it is covered by the VM runs.)
|
||||
SKIP_COMMON='^TestIntegration'
|
||||
|
||||
# SKIP, WITH REASON: the first -race run over this tree (2026-07-26 — nobody
|
||||
# had ever run one) turned up two failures. One was a REAL product race:
|
||||
# ClientBind.connect() touched its fields from both the Send() path and
|
||||
# RoutineReceiveIncoming() with no lock, caught by
|
||||
# transport/wireguard.TestAwgDetourClientBindDelivers; that one has since been
|
||||
# fixed in client_bind.go and is NOT skipped — it is exactly what this pass is
|
||||
# for. What is left:
|
||||
# - shater/alert.TestExpiryDedupWithinDay — the test's own closure
|
||||
# (expiry_test.go:78) reads a variable the test body writes at :85 while
|
||||
# Notifier.dispatch's goroutine is still delivering. A test-side bug, ~one
|
||||
# mutex to fix, but it lives in shater/ and is nobody's blocker to ship.
|
||||
# Naming it here keeps the gate a gate from day one. It is skipped ONLY in the
|
||||
# -race pass — it still runs, and still has to pass, in the main pass below.
|
||||
# DELETE THE ENTRY THE MOMENT THE RACE IS FIXED.
|
||||
RACE_SKIP='^TestExpiryDedupWithinDay$'
|
||||
|
||||
echo "== shater test gate =="
|
||||
echo " tags : $SHATER_ROUTER_TAGS"
|
||||
echo " ldflags: $SHATER_ROUTER_LDFLAGS"
|
||||
echo " race : $([ "$RACE" -eq 1 ] && echo yes || echo no)"
|
||||
echo
|
||||
|
||||
# --- linux, or re-exec on linux ---------------------------------------------
|
||||
# The linux-only half of the suite is the half worth running (see header). From a
|
||||
# non-linux host, re-exec inside a golang container rather than quietly testing
|
||||
# 32 of shater/generate's 44 files — a partial gate reads exactly like a passing
|
||||
# one.
|
||||
if [ "$(go env GOOS)" != "linux" ] && [ "${SHATER_TESTS_IN_DOCKER:-0}" != "1" ]; then
|
||||
if [ "${SHATER_NO_DOCKER:-0}" = "1" ] || ! command -v docker >/dev/null 2>&1; then
|
||||
echo " ERROR: the gate needs linux (GOOS=$(go env GOOS)) and docker is unavailable/disabled." >&2
|
||||
echo " Run it on the linux CI runner or the OpenWrt VM." >&2
|
||||
exit 1
|
||||
fi
|
||||
image="${SHATER_GO_IMAGE:-golang:1.26}"
|
||||
echo "== re-exec on linux via docker ($image) =="
|
||||
host_repo="$REPO"
|
||||
command -v cygpath >/dev/null 2>&1 && host_repo="$(cygpath -w "$REPO")"
|
||||
MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$host_repo":/src \
|
||||
-v shater-tagcheck-gomod:/go/pkg/mod \
|
||||
-v shater-tagcheck-gocache:/root/.cache/go-build \
|
||||
-w /src \
|
||||
-e SHATER_TESTS_IN_DOCKER=1 \
|
||||
"$image" bash scripts/run-tests.sh "$@"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
ALL_ROOTS=("${ROOTS[@]}" "${ROOTS_COMMON[@]}")
|
||||
|
||||
# --- [1/4] the tag set may only ADD test files, never hide them --------------
|
||||
# `go list` counts the test files the compiler would actually take. If adding the
|
||||
# shipped tags REMOVES a test file from any package, that test exists but the
|
||||
# gate would never see it — which is the failure mode this whole script is about,
|
||||
# just pointed the other way.
|
||||
echo "== [1/4] no test file is hidden by the shipped tag set =="
|
||||
LISTFMT='{{.ImportPath}} {{len .TestGoFiles}} {{len .XTestGoFiles}}'
|
||||
plain="$(go list -f "$LISTFMT" "${ALL_ROOTS[@]}")"
|
||||
tagged="$(go list -tags "$SHATER_ROUTER_TAGS" -f "$LISTFMT" "${ALL_ROOTS[@]}")"
|
||||
hidden=0
|
||||
while read -r pkg t x; do
|
||||
[ -n "${pkg:-}" ] || continue
|
||||
n_plain=$((t + x))
|
||||
[ "$n_plain" -gt 0 ] || continue
|
||||
line="$(awk -v p="$pkg" '$1 == p { print; exit }' <<<"$tagged")"
|
||||
if [ -z "$line" ]; then
|
||||
echo " HIDDEN: $pkg has $n_plain test file(s) untagged but no package at all under the shipped tags" >&2
|
||||
hidden=1
|
||||
continue
|
||||
fi
|
||||
read -r _ tt tx <<<"$line"
|
||||
n_tagged=$((tt + tx))
|
||||
if [ "$n_tagged" -lt "$n_plain" ]; then
|
||||
echo " HIDDEN: $pkg — $n_plain test file(s) untagged, only $n_tagged under the shipped tags" >&2
|
||||
hidden=1
|
||||
elif [ "$n_tagged" -gt "$n_plain" ]; then
|
||||
echo " +$((n_tagged - n_plain)) tag-gated test file(s): $pkg ($n_plain -> $n_tagged)"
|
||||
fi
|
||||
done <<<"$plain"
|
||||
if [ "$hidden" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo " FAILED: a test file is invisible to the tag set we ship. Either the" >&2
|
||||
echo " constraint is wrong or the tag set is — do not paper over it" >&2
|
||||
echo " by testing with different tags than we build with." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- the runner --------------------------------------------------------------
|
||||
# Runs one suite and then PROVES it ran: every package `go list` says has tests
|
||||
# must appear as `ok <pkg>` in the output. `go test` over a package whose tests
|
||||
# all vanished behind a build constraint prints "[no test files]" and exits 0 —
|
||||
# a green run that verified nothing.
|
||||
LOG="$(mktemp)"
|
||||
trap 'rm -f "$LOG"' EXIT
|
||||
FAILED=0
|
||||
|
||||
run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
|
||||
local label="$1" extra="$2"
|
||||
shift 2
|
||||
local pkgs=("$@") rc=0 expect missing=0 pkg
|
||||
|
||||
expect="$(go list -tags "$SHATER_ROUTER_TAGS" \
|
||||
-f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' \
|
||||
"${pkgs[@]}" | grep -v '^$' || true)"
|
||||
if [ -z "$expect" ]; then
|
||||
echo " FAILED [$label]: go list reports no package with tests here — the gate" >&2
|
||||
echo " would have run nothing and passed." >&2
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
echo " packages with tests: $(wc -l <<<"$expect" | tr -d ' ')"
|
||||
|
||||
set +e
|
||||
# shellcheck disable=SC2086 # $extra is a deliberate word-split flag list
|
||||
go test -count=1 $extra \
|
||||
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
|
||||
"${pkgs[@]}" >"$LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
sed 's/^/ /' "$LOG"
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo " FAILED [$label]: go test exited $rc" >&2
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
|
||||
while read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
grep -qE "^ok[[:space:]]+$pkg([[:space:]]|\$)" "$LOG" || {
|
||||
echo " DID NOT RUN [$label]: $pkg" >&2
|
||||
missing=1
|
||||
}
|
||||
done <<<"$expect"
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo " FAILED [$label]: package(s) above have test files but produced no 'ok'" >&2
|
||||
echo " line. Build-constraint or file-name drift emptied them." >&2
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
echo " OK [$label]"
|
||||
}
|
||||
|
||||
# --- [2/4] the fork's trees, shipped tags, linux -----------------------------
|
||||
echo "== [2/4] go test — the fork's trees (shipped tags, linux) =="
|
||||
run_suite main "" "${ROOTS[@]}"
|
||||
echo
|
||||
|
||||
# --- [3/4] common/, minus the tests that need CAP_NET_ADMIN ------------------
|
||||
echo "== [3/4] go test — common/ (minus the CAP_NET_ADMIN integration tests) =="
|
||||
run_suite common "-skip $SKIP_COMMON" "${ROOTS_COMMON[@]}"
|
||||
echo
|
||||
|
||||
# --- [4/4] -race over the same trees -----------------------------------------
|
||||
# Everything, not a subset: shater/netplane alone is ~110 s under -race and it is
|
||||
# the single most concurrency-critical package we own (the nft data plane), so
|
||||
# once it is in, adding the rest costs ~40 s more. common/ is left out — it is
|
||||
# upstream code exercised by upstream CI.
|
||||
if [ "$RACE" -eq 1 ]; then
|
||||
echo "== [4/4] go test -race — the fork's trees =="
|
||||
echo " known-red under -race, skipped BY NAME (fix it and delete from RACE_SKIP):"
|
||||
echo " TestExpiryDedupWithinDay shater/alert (test-side race, expiry_test.go:78/85)"
|
||||
run_suite race "-race -skip $RACE_SKIP" "${ROOTS[@]}"
|
||||
else
|
||||
echo "== [4/4] -race pass skipped (--no-race) =="
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "== TEST GATE FAILED — nothing may be published from this run. ==" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "== OK: the shipped tag set, on linux, passes every test we own. =="
|
||||
+339
-69
@@ -74,6 +74,19 @@ type Applier struct {
|
||||
stateMu sync.RWMutex
|
||||
holding bool
|
||||
|
||||
// traffic is WHERE THE TRAFFIC GOES under the config that is currently running:
|
||||
// tunnelled, split, straight out, or blocked (see generate.TrafficOf). It is
|
||||
// computed from the generated option.Options at the moment they are handed to
|
||||
// the engine, so it describes what runs rather than what is on disk.
|
||||
//
|
||||
// Deliberately separate from `holding` and from Plane, which answer "how much of
|
||||
// the data plane is installed". The panel used to read Plane == "full" as
|
||||
// "protected" and said so under a green LED on a router whose only rule was
|
||||
// `default -> direct`; the plane really was fully installed, and the whole LAN
|
||||
// really was going out the plain WAN. Zero value = not known (no successful
|
||||
// apply in this process yet), which is NOT the same as "tunnel".
|
||||
traffic generate.Traffic // guarded by stateMu
|
||||
|
||||
// lastWarnings is the normalised warning set from the last SUCCESSFUL apply,
|
||||
// published through Status so the panel can show fail-open degradations
|
||||
// (a blocklist that did not load, a DoH host left reachable) instead of
|
||||
@@ -192,11 +205,15 @@ func (a *Applier) configureObservatory(m *model.Model, opts option.Options) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGroups launches the engine's one-shot exit test (delay + exit address, F2)
|
||||
// of the named groups/chains, returning started=false when a run is already in
|
||||
// flight or the engine is absent. names empty/nil = every group and every chain.
|
||||
// It takes NEITHER the apply mutex nor the flock, so kicking off a test never
|
||||
// blocks behind an Apply.
|
||||
// TestGroups launches the engine's one-shot group/chain test (F2): the engine
|
||||
// asks its observatory for an out-of-turn pass and reports what it measured,
|
||||
// plus the exit address for alive rule-routed targets — it no longer dials
|
||||
// health probes of its own. Returns started=false when a run is already in
|
||||
// flight or the engine is absent. names empty/nil = every group and every
|
||||
// chain. probeURL is passed through for signature stability and IGNORED by the
|
||||
// engine (the probe URL is a global observatory setting now). It takes NEITHER
|
||||
// the apply mutex nor the flock, so kicking off a test never blocks behind an
|
||||
// Apply.
|
||||
func (a *Applier) TestGroups(names []string, probeURL string) (started bool) {
|
||||
if a.eng == nil {
|
||||
return false
|
||||
@@ -401,7 +418,7 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
|
||||
// (2) engine swap. On error the old engine keeps running and we do NOT touch
|
||||
// netplane — abort and surface the error.
|
||||
changed, err := a.eng.Apply(opts)
|
||||
changed, err := engineApply(a, opts)
|
||||
if err != nil {
|
||||
// ...unless the engine is not running AT ALL. A failed apply that left the
|
||||
// PREVIOUS engine running still has a valid, loaded data plane — replacing
|
||||
@@ -417,7 +434,87 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
|
||||
// (3) netplane, fail-closed: on any failure return the error WITHOUT tearing
|
||||
// the engine/table down (kill-switch/table stay up; the watchdog decides).
|
||||
//
|
||||
plane, perr := applyDataPlane(a, m, opts, now)
|
||||
if perr != nil {
|
||||
// The engine is ALREADY running the new config at this point, and the data
|
||||
// plane is not — so nothing the previous apply published is true any more.
|
||||
// Publishing is what this branch used to skip entirely; see abortAfterSwap.
|
||||
return changed, a.abortAfterSwap(m, plane, warnings, configWarnings, perr)
|
||||
}
|
||||
|
||||
// (4) success. Bump the effective-state generation ONLY when something really
|
||||
// moved: a no-op reconcile must not invalidate an armed commit-confirm window
|
||||
// (cron reconciles every minute — counting those would cancel every rollback
|
||||
// that commit-confirm exists to guarantee).
|
||||
if changed || plane.changed {
|
||||
a.stateGen.Add(1)
|
||||
}
|
||||
a.setHolding(false)
|
||||
a.lastGood = m
|
||||
// Publish where this config actually sends traffic, read off the very options
|
||||
// the engine was just handed (a.eng.Apply above). The engine's hash fast-path
|
||||
// may have skipped a swap, in which case these options are hash-equal to what is
|
||||
// already running — either way they are the running config, which is the only
|
||||
// config this verdict may describe.
|
||||
a.setTraffic(generate.TrafficOf(opts))
|
||||
// Publish the warnings of THIS successful apply, in one normalised set, and log
|
||||
// them in one consistent format. Status carries them to the panel so a
|
||||
// fail-open degradation is visible in the UI instead of only in logread.
|
||||
// routeWarnings joins the netplane channel, which is graded CRITICAL wholesale —
|
||||
// correctly so here: an egress that cannot reach off its own subnet is a configured
|
||||
// path that silently carries nothing, exactly the class of fault that channel exists
|
||||
// for.
|
||||
ws := collectWarnings(m.Globals, warnings,
|
||||
append(plane.nftWarnings, plane.routeWarnings...), configWarnings, plane.planNotes...)
|
||||
a.setWarnings(ws)
|
||||
// The log only hears about a CHANGE. Status above always carries the full set;
|
||||
// reprinting it on every no-op reconcile (cron, once a minute, plus every
|
||||
// hotplug event) is what buries a real warning under a thousand identical
|
||||
// lines a day and evicts incident history from the in-memory ring buffer.
|
||||
a.logWarningsIfChanged(ws)
|
||||
// (Re)configure the observatory against the config that is now running: the
|
||||
// applied options are exactly what its reachability plan is built from, and
|
||||
// this is the only place they can change.
|
||||
a.configureObservatory(m, opts)
|
||||
raiseActiveFlag(a.log)
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// planeOutcome is what the netplane half of an apply did, carried back to
|
||||
// applyLocked so the SAME facts can be published whether it succeeded or failed.
|
||||
// Before it existed, everything the netplane stage learned — the warnings it
|
||||
// rendered, the notes the untunnelable plan produced — was thrown away on any
|
||||
// error, which is why a failed apply left the previous config's verdict standing.
|
||||
type planeOutcome struct {
|
||||
// changed is true when the nft ruleset was actually (re)loaded, i.e. the data
|
||||
// plane moved. Distinct from the engine's own `changed`: the engine hash covers
|
||||
// option.Options only, so a purely netplane-visible change hashes identical.
|
||||
changed bool
|
||||
// stage names the netplane step that failed, in operator words, or "" on
|
||||
// success. It is what the failure warning is addressed to.
|
||||
stage string
|
||||
planNotes []string
|
||||
nftWarnings []string
|
||||
routeWarnings []string
|
||||
}
|
||||
|
||||
// engineApply and applyDataPlane are the two heavy halves of applyLocked, behind
|
||||
// package-level seams for exactly one reason: the PUBLISHING behaviour around
|
||||
// them (what Status says after a stage fails) is the thing this file gets wrong
|
||||
// most easily and can otherwise only be tested on a router, with root, a real
|
||||
// sing-box instance and a real nft binary — i.e. never, in the gate. Production
|
||||
// always runs the real methods; a test substitutes a stage that fails and asserts
|
||||
// what the operator is then told. Same seam pattern as applyHoldNft.
|
||||
var (
|
||||
engineApply = func(a *Applier, opts option.Options) (bool, error) { return a.eng.Apply(opts) }
|
||||
applyDataPlane = (*Applier).applyDataPlaneLocked
|
||||
)
|
||||
|
||||
// applyDataPlaneLocked is step (3) of the pipeline: nft ruleset, policy routing
|
||||
// and sysctls, in that order, fail-closed. Caller holds a.mu and has ALREADY
|
||||
// swapped the engine, so every failure here leaves the router in a mixed state —
|
||||
// which is why the outcome is returned even on error.
|
||||
func (a *Applier) applyDataPlaneLocked(m *model.Model, opts option.Options, now time.Time) (planeOutcome, error) {
|
||||
// Render FIRST, then decide whether anything needs re-asserting. Gating the
|
||||
// whole data plane on the engine's `changed` flag alone was wrong: the engine
|
||||
// hash covers option.Options only, so a purely netplane-visible change (the
|
||||
@@ -434,12 +531,16 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
// check below sees them: a refreshed geoip list changes the text and triggers a
|
||||
// real reload, rather than leaving a stale plan loaded (the D3 trap).
|
||||
untunPlan := a.untunnelablePlanFor(m, opts)
|
||||
out := planeOutcome{planNotes: untunPlan.Notes()}
|
||||
|
||||
ruleset, nftWarnings, err := netplane.RenderNftPlanAt(m, untunPlan, now)
|
||||
out.nftWarnings = nftWarnings
|
||||
if err != nil {
|
||||
// Includes the refusal on an unusable interface name with a closed
|
||||
// kill-switch: the previous ruleset stays loaded and keeps protecting the
|
||||
// LAN while the operator fixes the name.
|
||||
return changed, err
|
||||
out.stage = "rendering the nft ruleset"
|
||||
return out, err
|
||||
}
|
||||
nftCurrent := ruleset == a.lastNft && netplane.TableExists()
|
||||
if !nftCurrent {
|
||||
@@ -451,9 +552,11 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
if !netplane.TableExists() {
|
||||
a.holdLocked(m, err)
|
||||
}
|
||||
return changed, err
|
||||
out.stage = "loading the nft ruleset"
|
||||
return out, err
|
||||
}
|
||||
a.lastNft = ruleset
|
||||
out.changed = true
|
||||
}
|
||||
// ApplyRouting is idempotent by del-then-add, which means it opens a brief
|
||||
// window with NO fwmark rule installed — during it, diverted packets miss the
|
||||
@@ -465,16 +568,17 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
// such an egress looks applied everywhere in the UI while being unable to reach
|
||||
// anything off its own subnet. On the fast path nothing was rebuilt, so there is
|
||||
// nothing new to report and the previous set stands.
|
||||
var routeWarnings []string
|
||||
if !nftCurrent || !netplane.RoutingPresent(m.Globals) {
|
||||
var rerr error
|
||||
routeWarnings, rerr = netplane.ApplyRoutingWithWarnings(m)
|
||||
routeWarnings, rerr := netplane.ApplyRoutingWithWarnings(m)
|
||||
out.routeWarnings = routeWarnings
|
||||
if rerr != nil {
|
||||
return changed, rerr
|
||||
out.stage = "installing the policy routing"
|
||||
return out, rerr
|
||||
}
|
||||
}
|
||||
if err := netplane.ApplySysctl(); err != nil {
|
||||
return changed, err
|
||||
out.stage = "setting the kernel sysctls"
|
||||
return out, err
|
||||
}
|
||||
// Per-diverted-ingress-iface knobs (accept_local/rp_filter): the static
|
||||
// ApplySysctl above cannot know the LAN device names, and without
|
||||
@@ -485,7 +589,8 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
// defaults, silently un-setting accept_local behind our back. Fail-closed like
|
||||
// the other netplane steps: return without teardown.
|
||||
if err := netplane.ApplyIfaceSysctlsAt(m, now); err != nil {
|
||||
return changed, err
|
||||
out.stage = "setting the per-interface sysctls"
|
||||
return out, err
|
||||
}
|
||||
|
||||
// The plane is COMPLETE only here: table + policy routing + sysctls. ApplyNft
|
||||
@@ -497,43 +602,63 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
// "no entry survives the transition" actually true. Only on a real change (the
|
||||
// fast path assembled nothing), best-effort, and cheap: the :53 entry count is
|
||||
// bounded by the number of clients.
|
||||
if !nftCurrent {
|
||||
if out.changed {
|
||||
if n, ferr := netplane.FlushDNSConntrack(); ferr != nil {
|
||||
a.log.Debug("flush DNS conntrack after plane change: ", ferr)
|
||||
} else if n > 0 {
|
||||
a.log.Debug("plane changed: dropped ", n, " stale DNS conntrack entries")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// (4) success. Bump the effective-state generation ONLY when something really
|
||||
// moved: a no-op reconcile must not invalidate an armed commit-confirm window
|
||||
// (cron reconciles every minute — counting those would cancel every rollback
|
||||
// that commit-confirm exists to guarantee).
|
||||
if changed || !nftCurrent {
|
||||
a.stateGen.Add(1)
|
||||
// abortAfterSwap publishes an honest status for an apply that got PAST the engine
|
||||
// swap and then failed in the data plane, and returns the cause unchanged.
|
||||
//
|
||||
// The defect it exists for: every netplane failure used to `return changed, err`
|
||||
// before setTraffic/setWarnings, so Status kept serving the verdict and the
|
||||
// findings of the PREVIOUS config while the engine was already running the new
|
||||
// one. Worse, it did not self-heal — the next cron reconcile hashes identical,
|
||||
// fails at the same stage, and returns at the same place, so the stale verdict
|
||||
// stood for as long as the fault did. A green "Protected" over a half-installed
|
||||
// plane is the exact inversion this audit is about: not an error shown when
|
||||
// things are fine, but calm shown when they are not.
|
||||
//
|
||||
// What it publishes:
|
||||
//
|
||||
// - Traffic goes back to UNKNOWN (the zero value). It is tempting to publish
|
||||
// TrafficOf(opts) here, since the ENGINE really is running those options — but
|
||||
// the verdict describes where the LAN's traffic ends up, and that is decided by
|
||||
// the engine and the data plane together. With one of them from this config and
|
||||
// the other from the last one, the honest answer is that we do not know; the
|
||||
// panel renders unknown, and is required never to render it as protected.
|
||||
// - The warning set is replaced by THIS config's warnings, with a critical entry
|
||||
// naming the stage that failed at the front. The operator gets the news about
|
||||
// the config that is actually loaded, plus the fact that it is only half loaded.
|
||||
//
|
||||
// Caller holds a.mu.
|
||||
func (a *Applier) abortAfterSwap(m *model.Model, plane planeOutcome, generateWarnings []string, configWarnings []model.Warning, cause error) error {
|
||||
a.setTraffic(generate.Traffic{})
|
||||
|
||||
stage := plane.stage
|
||||
if stage == "" {
|
||||
stage = "installing the data plane"
|
||||
}
|
||||
a.setHolding(false)
|
||||
a.lastGood = m
|
||||
// Publish the warnings of THIS successful apply, in one normalised set, and log
|
||||
// them in one consistent format. Status carries them to the panel so a
|
||||
// fail-open degradation is visible in the UI instead of only in logread.
|
||||
// routeWarnings joins the netplane channel, which is graded CRITICAL wholesale —
|
||||
// correctly so here: an egress that cannot reach off its own subnet is a configured
|
||||
// path that silently carries nothing, exactly the class of fault that channel exists
|
||||
// for.
|
||||
ws := collectWarnings(m.Globals, warnings, append(nftWarnings, routeWarnings...), configWarnings, untunPlan.Notes()...)
|
||||
ws := gatherWarnings(m.Globals, generateWarnings,
|
||||
append(plane.nftWarnings, plane.routeWarnings...), configWarnings, plane.planNotes...)
|
||||
ws = finalizeWarnings(append([]Warning{{
|
||||
Severity: SeverityCritical,
|
||||
Section: "netplane",
|
||||
Name: stage,
|
||||
Message: fmt.Sprintf("the engine was switched to this configuration but the data plane could NOT be "+
|
||||
"completed — %s failed: %v. What the kernel holds is part of this configuration and part of the "+
|
||||
"previous one, so where your traffic goes is UNKNOWN: treat this router as unprotected until a "+
|
||||
"reconcile succeeds. It is retried every minute; if it keeps failing, fix the cause or roll back.",
|
||||
stage, cause),
|
||||
}}, ws...))
|
||||
a.setWarnings(ws)
|
||||
// The log only hears about a CHANGE. Status above always carries the full set;
|
||||
// reprinting it on every no-op reconcile (cron, once a minute, plus every
|
||||
// hotplug event) is what buries a real warning under a thousand identical
|
||||
// lines a day and evicts incident history from the in-memory ring buffer.
|
||||
a.logWarningsIfChanged(ws)
|
||||
// (Re)configure the observatory against the config that is now running: the
|
||||
// applied options are exactly what its reachability plan is built from, and
|
||||
// this is the only place they can change.
|
||||
a.configureObservatory(m, opts)
|
||||
raiseActiveFlag(a.log)
|
||||
return changed, nil
|
||||
return cause
|
||||
}
|
||||
|
||||
// holdLocked installs the fail-closed HOLDING PLANE when the engine is not
|
||||
@@ -557,6 +682,11 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
// With the kill-switch OPEN nothing is installed — fail-open is the operator's
|
||||
// documented choice and this must not quietly override it.
|
||||
func (a *Applier) holdLocked(m *model.Model, cause error) {
|
||||
// The engine is not carrying anything, so whatever the last running config did
|
||||
// with traffic is no longer true of this router. Forget it either way — a stale
|
||||
// "tunnel" verdict left behind by a config that is no longer running is the same
|
||||
// reassuring lie in a different place.
|
||||
a.setTraffic(generate.Traffic{})
|
||||
if !killSwitchClosed(m.Globals) {
|
||||
a.log.Warn("engine is down and kill_switch=open: LAN traffic is NOT protected (documented fail-open): ", cause)
|
||||
return
|
||||
@@ -640,24 +770,52 @@ func (a *Applier) setHolding(v bool) {
|
||||
a.stateMu.Unlock()
|
||||
}
|
||||
|
||||
// Traffic returns where the traffic of the CURRENTLY RUNNING config goes. The
|
||||
// zero value means no config of this process's is running (nothing applied yet,
|
||||
// or the plane was torn down / put on hold), and callers must render that as
|
||||
// "unknown", never as protected.
|
||||
func (a *Applier) Traffic() generate.Traffic {
|
||||
a.stateMu.RLock()
|
||||
defer a.stateMu.RUnlock()
|
||||
return a.traffic
|
||||
}
|
||||
|
||||
func (a *Applier) setTraffic(t generate.Traffic) {
|
||||
a.stateMu.Lock()
|
||||
a.traffic = t
|
||||
a.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Applier) setWarnings(ws []Warning) {
|
||||
a.stateMu.Lock()
|
||||
a.lastWarnings = ws
|
||||
a.stateMu.Unlock()
|
||||
}
|
||||
|
||||
// Warnings returns the normalised warning set from the last successful apply.
|
||||
// Never nil: an empty slice means "the last apply was clean", which the panel
|
||||
// must render differently from "no apply has run yet" (Active/Plane cover that).
|
||||
// Warnings returns the normalised warning set from the last successful apply,
|
||||
// PLUS whatever is wrong right now that no apply can describe. Never nil: an
|
||||
// empty slice means "the last apply was clean", which the panel must render
|
||||
// differently from "no apply has run yet" (Active/Plane cover that).
|
||||
//
|
||||
// The live half is currently the engine's abandoned generations
|
||||
// (engineTeardownWarnings). It is computed at READ time rather than folded into
|
||||
// lastWarnings on purpose: a superseded box that will not shut down is a
|
||||
// condition of the process, not a property of a config. Folding it in would make
|
||||
// it appear only after the NEXT successful apply and then stay published long
|
||||
// after the shutdown finally completed — reporting a leak that is over, and
|
||||
// staying silent about one that is not. Read-time means it shows up the instant
|
||||
// it happens and clears itself the instant it resolves.
|
||||
func (a *Applier) Warnings() []Warning {
|
||||
var out []Warning
|
||||
if a.eng != nil {
|
||||
out = engineTeardownWarnings(a.eng.PendingCloses())
|
||||
}
|
||||
a.stateMu.RLock()
|
||||
defer a.stateMu.RUnlock()
|
||||
if a.lastWarnings == nil {
|
||||
if out == nil && a.lastWarnings == nil {
|
||||
return []Warning{}
|
||||
}
|
||||
out := make([]Warning, len(a.lastWarnings))
|
||||
copy(out, a.lastWarnings)
|
||||
return out
|
||||
return append(out, a.lastWarnings...)
|
||||
}
|
||||
|
||||
// Reconcile re-reads UCI and either tears down (disabled) or re-applies (enabled).
|
||||
@@ -723,6 +881,7 @@ func (a *Applier) Teardown() error {
|
||||
a.lastGood = nil
|
||||
a.lastNft = ""
|
||||
a.setHolding(false)
|
||||
a.setTraffic(generate.Traffic{})
|
||||
a.setWarnings(nil)
|
||||
// The plane is gone, so the logged set no longer describes anything. Forget it,
|
||||
// and the next apply re-announces its warnings in full rather than staying
|
||||
@@ -883,12 +1042,28 @@ func (a *Applier) Rollback() error {
|
||||
defer release()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.eng.Rollback(); err != nil {
|
||||
m, err := rollbackEngineAndPlane(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.publishEngineRollback(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackEngineAndPlane is the ACTION half of the no-snapshot rollback: drive the
|
||||
// engine back to its predecessor config and re-assert the data plane from current
|
||||
// UCI. It returns the model the plane was rebuilt from. Caller holds a.mu.
|
||||
//
|
||||
// It is a variable for the same reason as engineApply/applyDataPlane: what this
|
||||
// rollback PUBLISHES afterwards is the part that was wrong, and it cannot be
|
||||
// exercised at all without two real engine generations and a real nft binary.
|
||||
var rollbackEngineAndPlane = func(a *Applier) (*model.Model, error) {
|
||||
if err := a.eng.Rollback(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := model.ReadUCI()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// One clock for the whole re-assert, same as applyLocked: the ruleset's
|
||||
// divert set and the iface sysctls below must agree on the profile-effective
|
||||
@@ -900,14 +1075,14 @@ func (a *Applier) Rollback() error {
|
||||
a.log.Warn("netplane: ", w)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := netplane.ApplyNft(ruleset); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
a.lastNft = ruleset
|
||||
if err := netplane.ApplyRouting(m); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// The sysctl half must be re-asserted here too. It used to be missing: a
|
||||
// rollback that changes the set of diverted ingress devices (a different
|
||||
@@ -916,9 +1091,60 @@ func (a *Applier) Rollback() error {
|
||||
// then never reaches the engine socket and that network goes dark after a
|
||||
// rollback, which is precisely when the operator can least afford it.
|
||||
if err := netplane.ApplySysctl(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return netplane.ApplyIfaceSysctlsAt(m, now)
|
||||
if err := netplane.ApplyIfaceSysctlsAt(m, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// publishEngineRollback makes Status describe the router the no-snapshot rollback
|
||||
// just produced, instead of the one it rolled away FROM. Caller holds a.mu.
|
||||
//
|
||||
// The defect: this path touched none of the publishers. Apply a tunnel config,
|
||||
// Confirm it (which consumes the snapshot), then roll back later — the engine goes
|
||||
// to its predecessor, which may well be the `default -> direct` config, and the
|
||||
// panel keeps showing the tunnel verdict and the tunnel config's warnings, in
|
||||
// green, indefinitely. The whole LAN is on the plain WAN with its real address and
|
||||
// the UI says Protected. Nothing else corrects it: the verdict is only ever
|
||||
// rewritten by a successful apply, and a rollback is not one.
|
||||
//
|
||||
// The verdict published is UNKNOWN, not a computed one, and that is the honest
|
||||
// answer rather than a lazy one: engine.Rollback re-applies option.Options that
|
||||
// this process no longer holds (the engine keeps them, apply does not), so there
|
||||
// is nothing here to run generate.TrafficOf over. Guessing from current UCI would
|
||||
// be worse than saying nothing — UCI is the config we rolled AWAY from. Unknown is
|
||||
// rendered as unknown by the panel and never as protected, and the next reconcile
|
||||
// (cron, within a minute) replaces it with the truth.
|
||||
func (a *Applier) publishEngineRollback(m *model.Model) {
|
||||
a.setTraffic(generate.Traffic{})
|
||||
a.setHolding(false) // a full plane was just loaded; whatever hold there was is over
|
||||
// lastGood is the teardown/rollback target, and the data plane was just built
|
||||
// from m — so m is what a later Teardown must know about to remove the right
|
||||
// marks and routing tables.
|
||||
a.lastGood = m
|
||||
// The observatory's reachability plan was built from the config we rolled away
|
||||
// from: left running it probes outbounds that may no longer exist and files the
|
||||
// results against tags the running box does not have. There is no plan to
|
||||
// replace it with (see above), so stop probing until the next apply installs one.
|
||||
if a.eng != nil {
|
||||
a.eng.StopObservatory()
|
||||
}
|
||||
ws := finalizeWarnings([]Warning{{
|
||||
Severity: SeverityCritical,
|
||||
Section: "engine",
|
||||
Name: "rollback",
|
||||
Message: "the engine was rolled back to the configuration that ran before the current one. " +
|
||||
"That configuration is not the one on disk, so where your traffic goes and what was left " +
|
||||
"un-applied are both UNKNOWN until the next reconcile (within a minute) re-applies the " +
|
||||
"saved config and reports on it. Do not read this router as protected in the meantime.",
|
||||
}})
|
||||
a.setWarnings(ws)
|
||||
a.logWarningsIfChanged(ws)
|
||||
// The plane moved, so an armed commit-confirm watcher must see a changed
|
||||
// generation and stand down rather than clobber what we just restored.
|
||||
a.stateGen.Add(1)
|
||||
}
|
||||
|
||||
// canRollback reports whether Rollback would actually revert something: an armed
|
||||
@@ -962,11 +1188,30 @@ func ActiveFlagPresent() bool {
|
||||
|
||||
// Status is the read-side snapshot printed by `shaterd status` as JSON.
|
||||
//
|
||||
// running the daemon process is up (a live socket reply => true; the offline
|
||||
// stub reports false). Whether the ENGINE is intercepting is carried by
|
||||
// active/table/hash, not by running — a daemon can be up but inert.
|
||||
// running SHATER IS RUNNING: the daemon answered AND its engine has a started
|
||||
// sing-box instance carrying a config. false therefore covers every way
|
||||
// of not proxying — daemon down, daemon up with a dead engine, disabled,
|
||||
// torn down — and the fields below say which.
|
||||
//
|
||||
// It used to be the literal `true`, on the reasoning that Status() is
|
||||
// only ever called from inside the live daemon. That was true and it was
|
||||
// useless: a constant cannot report anything, and the panel built its
|
||||
// headline on `running && active`, so the "not running" branch was
|
||||
// physically unreachable and an engine that never started showed green.
|
||||
// A field whose only possible value is the reassuring one is worse than
|
||||
// no field: it is a promise the code cannot break.
|
||||
//
|
||||
// "Is the daemon process alive?" is a different question and is answered
|
||||
// by whether the status call returned at all (plus uptime_seconds, which
|
||||
// only a live daemon can produce).
|
||||
// enabled globals.enabled in UCI.
|
||||
// active ACTIVE_FLAG present (a successful enabled apply raised it).
|
||||
// active ACTIVE_FLAG present. This is the "the service is meant to be running"
|
||||
// latch that gates hotplug and cron, NOT a health signal: it is raised by
|
||||
// a successful enabled apply and cleared only by teardown, so it stays up
|
||||
// while the engine is down and the fail-closed holding plane is blocking
|
||||
// the LAN — deliberately, because clearing it would switch off the very
|
||||
// cron reconcile that brings the engine back. Never render it as "we are
|
||||
// proxying"; that is what running/plane/traffic are for.
|
||||
// table the `inet shater` nft table is loaded.
|
||||
// hash the running engine's config hash ("" when the engine is not started).
|
||||
// kill_switch globals.kill_switch in UCI (closed = fail-closed, open = leaky).
|
||||
@@ -987,9 +1232,14 @@ type Status struct {
|
||||
PanelPort int `json:"panel_port"`
|
||||
CanRollback bool `json:"can_rollback"`
|
||||
|
||||
// EngineRunning is whether a sing-box instance is actually started. It is the
|
||||
// honest answer to "are we proxying?", which running/active/table each only
|
||||
// approximate.
|
||||
// EngineRunning is whether a sing-box instance is actually started.
|
||||
//
|
||||
// It was added as the honest field to stand beside a `running` that was hard-wired
|
||||
// true, and no consumer ever read it. Now that running carries the same fact it is
|
||||
// kept as its explicit, unambiguous name — the two are equal by construction from
|
||||
// the daemon — because it is already in the published API and reading
|
||||
// `engine_running` in a client is self-documenting where `running` needs this
|
||||
// comment.
|
||||
EngineRunning bool `json:"engine_running"`
|
||||
|
||||
// Plane describes what is loaded in the kernel RIGHT NOW:
|
||||
@@ -1003,6 +1253,20 @@ type Status struct {
|
||||
// say so rather than looking healthy.
|
||||
Plane string `json:"plane"`
|
||||
|
||||
// Traffic is WHERE THE TRAFFIC GOES under the running config — tunnelled, split,
|
||||
// straight out, or blocked (see generate.Traffic).
|
||||
//
|
||||
// Plane does NOT answer this, and reading it as if it did is the defect this
|
||||
// field exists for. Plane == "full" only means the table, the policy routing and
|
||||
// the engine are all in place; a router whose one rule is `default -> direct` has
|
||||
// all three and sends every packet out the plain WAN with its real address. The
|
||||
// panel showed that as "Protected — traffic is going through the tunnel".
|
||||
//
|
||||
// Zero value (Verdict == "") means unknown: no successful apply has run in this
|
||||
// daemon process yet, or the plane is on hold / torn down. A consumer must render
|
||||
// that as unknown and never as protected.
|
||||
Traffic generate.Traffic `json:"traffic"`
|
||||
|
||||
// Warnings is the normalised warning set from the last successful apply:
|
||||
// everything that was skipped, degraded or left un-applied while the apply
|
||||
// still succeeded. Always non-nil so the panel can map over it unconditionally.
|
||||
@@ -1070,18 +1334,24 @@ func processUptime(now time.Time) (startedUnix, uptimeSeconds int64) {
|
||||
return now.Unix() - uptimeSeconds, uptimeSeconds
|
||||
}
|
||||
|
||||
// Status returns the live status from this daemon's engine + kernel state. It is
|
||||
// only ever called from within the running daemon, so running=true; engine state
|
||||
// is reflected by Active/Table/Hash (an inert daemon reports running=true but
|
||||
// active=false/table=false/hash="").
|
||||
// Status returns the live status from this daemon's engine + kernel state.
|
||||
//
|
||||
// It is only ever called from within the running daemon, which is exactly why
|
||||
// `running` is read off the engine rather than set to true: from in here the
|
||||
// daemon's own liveness is a tautology, and the only thing left worth reporting
|
||||
// under that name is whether shater is carrying any traffic. A daemon that is up
|
||||
// with a dead engine reports running=false, plane="hold"/"none" and an unknown
|
||||
// traffic verdict — which is the state this field exists to make expressible.
|
||||
func (a *Applier) Status() Status {
|
||||
engineUp := a.eng != nil && a.eng.Running()
|
||||
s := Status{
|
||||
Running: true,
|
||||
Running: engineUp,
|
||||
Active: ActiveFlagPresent(),
|
||||
Table: netplane.TableExists(),
|
||||
Hash: a.eng.Hash(),
|
||||
CanRollback: a.canRollback(),
|
||||
EngineRunning: a.eng.Running(),
|
||||
EngineRunning: engineUp,
|
||||
Traffic: a.Traffic(),
|
||||
Warnings: a.Warnings(),
|
||||
}
|
||||
s.StartedUnix, s.UptimeSeconds = processUptime(time.Now())
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package apply
|
||||
|
||||
// Regression tests for the three ways this package used to report calm over a
|
||||
// router that was not doing what its config said. Each of them is the INVERTED
|
||||
// failure — not an error shown when things are fine, but green shown when they
|
||||
// are not — which is the only kind that gets someone hurt.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/shater/engine"
|
||||
"github.com/sagernet/sing-box/shater/generate"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// TestStatusRunningReportsTheEngine pins the contract the panel headline is built
|
||||
// on.
|
||||
//
|
||||
// `running` used to be the literal `true` in the only code path that produces it,
|
||||
// so `running && active` — what the panel reads — could not go false however dead
|
||||
// the engine was, and the "not running" branch was unreachable code. A status
|
||||
// field that can only ever hold the reassuring value is not a weak signal, it is
|
||||
// an unfalsifiable claim.
|
||||
func TestStatusRunningReportsTheEngine(t *testing.T) {
|
||||
a := New(engine.New(), nil)
|
||||
|
||||
// A fresh applier's engine has never started: nothing is being proxied, and
|
||||
// the status must be able to say so.
|
||||
s := a.Status()
|
||||
if s.Running {
|
||||
t.Errorf("Status().Running = true with a stopped engine — the field is a constant again")
|
||||
}
|
||||
if s.Running != s.EngineRunning {
|
||||
t.Errorf("running (%v) and engine_running (%v) must agree: they are the same fact",
|
||||
s.Running, s.EngineRunning)
|
||||
}
|
||||
|
||||
// And the hold state — engine down, LAN blocked — must not read as running
|
||||
// either. This is the three-green-lamps case: holding does not clear the
|
||||
// ACTIVE flag (cron needs it to keep retrying), so `active` alone cannot say it.
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "open" // the early-return branch of holdLocked
|
||||
a.mu.Lock()
|
||||
a.holdLocked(&model.Model{Globals: g}, errors.New("engine start failed"))
|
||||
a.mu.Unlock()
|
||||
if a.Status().Running {
|
||||
t.Errorf("Status().Running = true while the engine is down and the plane is held")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFailingAfterEngineSwapDropsTheOldVerdict is the defect-3 regression.
|
||||
//
|
||||
// Everything after the engine swap used to `return changed, err` before
|
||||
// setTraffic/setWarnings, so a netplane failure left Status serving the VERDICT
|
||||
// and the FINDINGS of the configuration that no longer runs. It did not self-heal
|
||||
// either: the next cron reconcile hashes identical, fails at the same stage and
|
||||
// returns at the same place, so the stale green stood for as long as the fault.
|
||||
func TestApplyFailingAfterEngineSwapDropsTheOldVerdict(t *testing.T) {
|
||||
a := New(engine.New(), nil)
|
||||
|
||||
// What the previous, fully successful apply published.
|
||||
a.setTraffic(generate.Traffic{Verdict: generate.VerdictTunnel, Default: "auto", TunnelRules: 3})
|
||||
a.setWarnings([]Warning{{
|
||||
Severity: SeverityCritical, Section: "ruleset", Name: "stale",
|
||||
Message: "a finding of the configuration that is no longer running",
|
||||
}})
|
||||
|
||||
// The engine swap succeeds; the data plane does not.
|
||||
restore := stubApplyStages(t,
|
||||
func(a *Applier, opts option.Options) (bool, error) { return true, nil },
|
||||
func(a *Applier, m *model.Model, opts option.Options, now time.Time) (planeOutcome, error) {
|
||||
return planeOutcome{stage: "loading the nft ruleset"}, errors.New("nft: permission denied")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
a.mu.Lock()
|
||||
_, err := a.applyLocked(holdModel("closed"))
|
||||
a.mu.Unlock()
|
||||
if err == nil {
|
||||
t.Fatalf("applyLocked must surface the netplane failure")
|
||||
}
|
||||
|
||||
s := a.Status()
|
||||
if s.Traffic.Verdict != "" {
|
||||
t.Errorf("Traffic.Verdict = %q after a half-installed plane, want \"\" (unknown): "+
|
||||
"the engine runs the new config and the kernel does not, so nobody knows where traffic goes",
|
||||
s.Traffic.Verdict)
|
||||
}
|
||||
var sawAbort bool
|
||||
for _, w := range s.Warnings {
|
||||
if strings.Contains(w.Message, "a finding of the configuration that is no longer running") {
|
||||
t.Errorf("the previous config's findings are still published: %+v", w)
|
||||
}
|
||||
if w.Section == "netplane" && strings.Contains(w.Message, "could NOT be completed") {
|
||||
sawAbort = true
|
||||
if w.Severity != SeverityCritical {
|
||||
t.Errorf("an incomplete data plane is critical, got %q", w.Severity)
|
||||
}
|
||||
if !strings.Contains(w.Message, "loading the nft ruleset") {
|
||||
t.Errorf("the warning must name the stage that failed: %q", w.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawAbort {
|
||||
t.Errorf("no warning says the data plane is incomplete; warnings = %+v", s.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollbackWithoutSnapshotRepublishes is the defect-2 regression.
|
||||
//
|
||||
// Apply a tunnel config, Confirm it (which consumes the commit-confirm snapshot),
|
||||
// then roll back later. The no-snapshot branch drives engine.Rollback and rebuilds
|
||||
// the plane — and used to touch none of the publishers, so the panel kept showing
|
||||
// the tunnel verdict and the tunnel config's warnings in green while the engine
|
||||
// had gone back to a predecessor that may route `default -> direct`. The entire
|
||||
// LAN on the plain WAN, under a green "Protected", indefinitely.
|
||||
func TestRollbackWithoutSnapshotRepublishes(t *testing.T) {
|
||||
a := New(engine.New(), nil)
|
||||
a.setTraffic(generate.Traffic{Verdict: generate.VerdictTunnel, Default: "auto", TunnelRules: 2})
|
||||
a.setWarnings([]Warning{{
|
||||
Severity: SeverityWarning, Section: "chain", Name: "hop",
|
||||
Message: "a finding of the configuration we are rolling away from",
|
||||
}})
|
||||
|
||||
m := holdModel("closed")
|
||||
orig := rollbackEngineAndPlane
|
||||
rollbackEngineAndPlane = func(*Applier) (*model.Model, error) { return m, nil }
|
||||
defer func() { rollbackEngineAndPlane = orig }()
|
||||
|
||||
before := a.stateGen.Load()
|
||||
if err := a.Rollback(); err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
|
||||
s := a.Status()
|
||||
if s.Traffic.Verdict != "" {
|
||||
t.Errorf("Traffic.Verdict = %q after an engine rollback, want \"\" (unknown): the running "+
|
||||
"config is one this process cannot describe", s.Traffic.Verdict)
|
||||
}
|
||||
var sawRollback bool
|
||||
for _, w := range s.Warnings {
|
||||
if strings.Contains(w.Message, "rolling away from") {
|
||||
t.Errorf("the pre-rollback findings are still published: %+v", w)
|
||||
}
|
||||
if w.Section == "engine" && w.Name == "rollback" {
|
||||
sawRollback = true
|
||||
if w.Severity != SeverityCritical {
|
||||
t.Errorf("an undescribable running config is critical, got %q", w.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRollback {
|
||||
t.Errorf("nothing says the router is running a rolled-back config; warnings = %+v", s.Warnings)
|
||||
}
|
||||
if a.LastGood() != m {
|
||||
t.Errorf("last-good must become the model the data plane was rebuilt from")
|
||||
}
|
||||
if a.stateGen.Load() == before {
|
||||
t.Errorf("the plane moved but stateGen did not: an armed commit-confirm watcher " +
|
||||
"would clobber the config we just restored")
|
||||
}
|
||||
}
|
||||
|
||||
// stubApplyStages replaces the two heavy halves of applyLocked for the duration of
|
||||
// a test and returns the restore func.
|
||||
func stubApplyStages(t *testing.T,
|
||||
eng func(*Applier, option.Options) (bool, error),
|
||||
plane func(*Applier, *model.Model, option.Options, time.Time) (planeOutcome, error),
|
||||
) func() {
|
||||
t.Helper()
|
||||
origEngine, origPlane := engineApply, applyDataPlane
|
||||
engineApply, applyDataPlane = eng, plane
|
||||
return func() { engineApply, applyDataPlane = origEngine, origPlane }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package apply
|
||||
|
||||
// Regression cover for the leaked-engine-generation defect.
|
||||
//
|
||||
// Observed on the router: one shaterd process was carrying up to FOUR sing-box
|
||||
// instances at once. sing-box stamps every log line with the elapsed seconds of
|
||||
// ITS OWN instance, so the same process printed `ERROR[2015]` and `ERROR[0129]`
|
||||
// in the same second — two engines half an hour apart in age, both alive, both
|
||||
// dialling, both holding WireGuard devices built from the same private keys. A
|
||||
// full daemon stop+start collapsed it back to one generation, which places the
|
||||
// leak squarely on the config re-apply path rather than on startup.
|
||||
//
|
||||
// The tests below pin the two halves of the fix:
|
||||
//
|
||||
// TestApplySwapsLeaveExactlyOneEngineGeneration — the healthy path really
|
||||
// retires the old instance (its listener is provably gone), N times in a row.
|
||||
// TestStuckEngineCloseDoesNotBlockTheApply — a shutdown that never returns
|
||||
// is bounded, does not stall the apply, and is REPORTED as a critical warning
|
||||
// for exactly as long as it is true.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/shater/engine"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
)
|
||||
|
||||
// mixedOn builds a minimal but REAL engine config: a mixed inbound bound to
|
||||
// 127.0.0.1:port plus a direct outbound. Two configs with different ports hash
|
||||
// differently, so each Apply is a genuine swap rather than a hash-gate no-op —
|
||||
// and the bound port is the observable that proves whether the old instance
|
||||
// actually died.
|
||||
func mixedOn(port uint16) option.Options {
|
||||
listen := badoption.Addr(netip.MustParseAddr("127.0.0.1"))
|
||||
return option.Options{
|
||||
Log: &option.LogOptions{Level: "error"},
|
||||
Inbounds: []option.Inbound{{
|
||||
Type: C.TypeMixed,
|
||||
Tag: "mixed-in",
|
||||
Options: &option.HTTPMixedInboundOptions{
|
||||
ListenOptions: option.ListenOptions{Listen: &listen, ListenPort: port},
|
||||
},
|
||||
}},
|
||||
Outbounds: []option.Outbound{{
|
||||
Type: C.TypeDirect,
|
||||
Tag: "direct-out",
|
||||
Options: &option.DirectOutboundOptions{},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// portFree reports whether 127.0.0.1:port can be bound right now — i.e. whether
|
||||
// the instance that used to listen there is really gone. Retried briefly because
|
||||
// a listener is released by Close, not by the return of Close's caller.
|
||||
func portFree(port uint16) bool {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))))
|
||||
if err == nil {
|
||||
_ = ln.Close()
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplySwapsLeaveExactlyOneEngineGeneration is the core regression: after N
|
||||
// sequential applies the process must be carrying ONE engine, not N.
|
||||
//
|
||||
// "Carrying" is checked two ways on purpose. Generations() is the engine's own
|
||||
// accounting (running instance + every retirement still in flight) and would
|
||||
// catch a retirement that silently never completes. The port check is
|
||||
// independent of that accounting: if the superseded instance were still alive it
|
||||
// would still hold its listener, and the bind would fail. A fix that only
|
||||
// reset a pointer would pass the first check and fail the second.
|
||||
func TestApplySwapsLeaveExactlyOneEngineGeneration(t *testing.T) {
|
||||
const (
|
||||
firstPort = 18801
|
||||
applies = 5
|
||||
)
|
||||
|
||||
a := New(engine.New(), nil)
|
||||
t.Cleanup(func() { _ = a.eng.Close() })
|
||||
|
||||
for i := 0; i < applies; i++ {
|
||||
port := uint16(firstPort + i)
|
||||
changed, err := a.eng.Apply(mixedOn(port))
|
||||
if err != nil {
|
||||
t.Fatalf("apply #%d (port %d): %v", i+1, port, err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("apply #%d: every config here differs, so the swap must be real", i+1)
|
||||
}
|
||||
if got := a.eng.Generations(); got != 1 {
|
||||
t.Fatalf("after apply #%d the process carries %d engine instances, want exactly 1 — "+
|
||||
"a superseded generation is still alive (this is the four-generations-in-one-process leak)",
|
||||
i+1, got)
|
||||
}
|
||||
if stuck := a.eng.PendingCloses(); len(stuck) != 0 {
|
||||
t.Fatalf("after apply #%d: %d generation(s) abandoned, want none: %+v", i+1, len(stuck), stuck)
|
||||
}
|
||||
if i > 0 {
|
||||
prev := uint16(firstPort + i - 1)
|
||||
if !portFree(prev) {
|
||||
t.Fatalf("after apply #%d the PREVIOUS generation still holds 127.0.0.1:%d — "+
|
||||
"the old engine was replaced in the field but never actually stopped", i+1, prev)
|
||||
}
|
||||
}
|
||||
// The warning set must stay clean while teardown is healthy: a critical
|
||||
// warning that cries wolf on every apply is worse than none.
|
||||
for _, w := range a.Warnings() {
|
||||
if w.Section == "engine" {
|
||||
t.Fatalf("after apply #%d a healthy swap produced an engine warning: %+v", i+1, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.eng.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
if got := a.eng.Generations(); got != 0 {
|
||||
t.Fatalf("after Close the process carries %d engine instances, want 0", got)
|
||||
}
|
||||
if !portFree(firstPort + applies - 1) {
|
||||
t.Fatalf("after Close the last generation still holds its listener")
|
||||
}
|
||||
}
|
||||
|
||||
// hangingCloser returns a box closer that BLOCKS the first close it is handed
|
||||
// until release() is called, and performs every later close normally. That is the
|
||||
// shape of the real fault: one subsystem of one generation (a WireGuard endpoint)
|
||||
// refuses to come down, while the rest of the process is fine.
|
||||
func hangingCloser() (closer func(io.Closer) error, release func()) {
|
||||
gate := make(chan struct{})
|
||||
first := make(chan struct{}, 1)
|
||||
first <- struct{}{}
|
||||
return func(c io.Closer) error {
|
||||
select {
|
||||
case <-first:
|
||||
<-gate // the stuck generation: never returns until released
|
||||
return c.Close() // ...and then really does close, so the port frees
|
||||
default:
|
||||
return c.Close()
|
||||
}
|
||||
}, func() {
|
||||
close(gate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStuckEngineCloseDoesNotBlockTheApply pins all four requirements of the
|
||||
// bounded teardown at once:
|
||||
//
|
||||
// 1. the apply COMPLETES — a shutdown that never returns must not hold the
|
||||
// control plane (and therefore the panel) hostage;
|
||||
// 2. the new engine is running afterwards — fail-closed semantics are unchanged,
|
||||
// the swap succeeded;
|
||||
// 3. the abandoned generation is REPORTED as a critical warning, by name, for as
|
||||
// long as it is still running — it is not silently swallowed;
|
||||
// 4. generations do not stack: a further apply while the leak persists leaves
|
||||
// one live instance plus the one abandoned one, not three.
|
||||
func TestStuckEngineCloseDoesNotBlockTheApply(t *testing.T) {
|
||||
const budget = 200 * time.Millisecond
|
||||
restoreBudget := engine.SetCloseBudget(budget)
|
||||
defer restoreBudget()
|
||||
|
||||
a := New(engine.New(), nil)
|
||||
|
||||
// Generation 1 comes up with the REAL closer still installed.
|
||||
if _, err := a.eng.Apply(mixedOn(18811)); err != nil {
|
||||
t.Fatalf("apply #1: %v", err)
|
||||
}
|
||||
|
||||
closer, release := hangingCloser()
|
||||
restoreCloser := engine.SetBoxCloser(closer)
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
release()
|
||||
}
|
||||
restoreCloser()
|
||||
_ = a.eng.Close()
|
||||
}()
|
||||
|
||||
// (1) Generation 2: the retirement of generation 1 will never return.
|
||||
start := time.Now()
|
||||
changed, err := a.eng.Apply(mixedOn(18812))
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("apply #2 must SUCCEED despite the stuck teardown: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("apply #2: expected a real swap")
|
||||
}
|
||||
// Generously bounded: the budget plus box.New+Start. The point is that it
|
||||
// returned at all — before the fix this waited on Box.Close forever.
|
||||
if elapsed > budget+20*time.Second {
|
||||
t.Fatalf("apply #2 took %s: a stuck teardown must not stall the apply", elapsed)
|
||||
}
|
||||
|
||||
// (2) fail-closed semantics unchanged: the new engine really is up.
|
||||
if !a.eng.Running() {
|
||||
t.Fatalf("apply #2: the new engine must be running")
|
||||
}
|
||||
|
||||
// (3) the leak is visible, named, and critical.
|
||||
stuck := a.eng.PendingCloses()
|
||||
if len(stuck) != 1 {
|
||||
t.Fatalf("PendingCloses() = %+v, want exactly the one abandoned generation", stuck)
|
||||
}
|
||||
if stuck[0].Generation != 1 {
|
||||
t.Errorf("abandoned generation = %d, want 1", stuck[0].Generation)
|
||||
}
|
||||
ws := a.Status().Warnings // the exact set `shaterd status` and the panel read
|
||||
var found *Warning
|
||||
for i := range ws {
|
||||
if ws[i].Section == "engine" {
|
||||
found = &ws[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("a superseded engine that will not shut down produced NO warning; "+
|
||||
"Status would show a healthy router: %+v", ws)
|
||||
}
|
||||
if found.Severity != SeverityCritical {
|
||||
t.Errorf("stuck-teardown warning severity = %q, want %q", found.Severity, SeverityCritical)
|
||||
}
|
||||
if !strings.Contains(found.Name, "generation 1") {
|
||||
t.Errorf("stuck-teardown warning must name the generation, got Name=%q", found.Name)
|
||||
}
|
||||
if !strings.Contains(found.Message, "STILL RUNNING") {
|
||||
t.Errorf("stuck-teardown warning must say the instance is still running, got %q", found.Message)
|
||||
}
|
||||
if got := a.eng.Generations(); got != 2 {
|
||||
t.Fatalf("Generations() = %d, want 2 (one live + one abandoned)", got)
|
||||
}
|
||||
|
||||
// (4) another apply while the leak persists must not add a THIRD generation:
|
||||
// with a generation abandoned the swap goes close-old-then-start-new, so the
|
||||
// process still holds one live instance plus the one that will not die.
|
||||
if _, err := a.eng.Apply(mixedOn(18813)); err != nil {
|
||||
t.Fatalf("apply #3: %v", err)
|
||||
}
|
||||
if got := a.eng.Generations(); got != 2 {
|
||||
t.Fatalf("Generations() = %d after a third apply, want 2 — generations are stacking, "+
|
||||
"which is exactly the four-live-engines fault", got)
|
||||
}
|
||||
if stuck := a.eng.PendingCloses(); len(stuck) != 1 || stuck[0].Generation != 1 {
|
||||
t.Fatalf("PendingCloses() = %+v, want only the original abandoned generation 1", stuck)
|
||||
}
|
||||
|
||||
// (5) and it CLEARS: when the shutdown finally completes the warning goes away
|
||||
// on its own. A leak report that outlives the leak trains the operator to
|
||||
// ignore the panel.
|
||||
release()
|
||||
released = true
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(a.eng.PendingCloses()) > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := a.eng.PendingCloses(); len(got) != 0 {
|
||||
t.Fatalf("the finished shutdown is still reported as abandoned: %+v", got)
|
||||
}
|
||||
for _, w := range a.Warnings() {
|
||||
if w.Section == "engine" {
|
||||
t.Fatalf("the engine warning outlived the leak it describes: %+v", w)
|
||||
}
|
||||
}
|
||||
if got := a.eng.Generations(); got != 1 {
|
||||
t.Fatalf("Generations() = %d after the stuck shutdown completed, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/engine"
|
||||
"github.com/sagernet/sing-box/shater/generate"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// TestStatusReportsTraffic pins the wiring the panel's headline depends on.
|
||||
//
|
||||
// Plane says how much of the data plane is installed; it does NOT say where the
|
||||
// traffic goes, and reading it as if it did put "Protected — traffic is going
|
||||
// through the tunnel" on a router whose only rule was `default -> direct`. The
|
||||
// verdict that answers the real question travels in Status.Traffic, so it must
|
||||
// (a) start unknown, (b) surface what the last successful apply published, and
|
||||
// (c) go back to unknown the moment the engine stops carrying that config.
|
||||
func TestStatusReportsTraffic(t *testing.T) {
|
||||
a := New(engine.New(), nil)
|
||||
|
||||
// A fresh applier has applied nothing, so it knows nothing. The zero value must
|
||||
// NOT read as any verdict — least of all "tunnel".
|
||||
if got := a.Status().Traffic; got.Verdict != "" {
|
||||
t.Fatalf("fresh applier: Traffic.Verdict = %q, want \"\" (unknown)", got.Verdict)
|
||||
}
|
||||
|
||||
a.setTraffic(generate.Traffic{Verdict: generate.VerdictTunnel, Default: "auto", TunnelRules: 2})
|
||||
got := a.Status().Traffic
|
||||
if got.Verdict != generate.VerdictTunnel || got.Default != "auto" || got.TunnelRules != 2 {
|
||||
t.Fatalf("Status().Traffic = %+v, want the verdict the last apply published", got)
|
||||
}
|
||||
|
||||
// The engine is down and the config it was running is no longer in force. A
|
||||
// verdict left over from it would be the same reassuring lie, one layer down.
|
||||
// (kill_switch=open takes holdLocked's early return, which is precisely the path
|
||||
// that must still forget the verdict.)
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "open"
|
||||
a.mu.Lock()
|
||||
a.holdLocked(&model.Model{Globals: g}, errors.New("engine start failed"))
|
||||
a.mu.Unlock()
|
||||
if got := a.Status().Traffic; got.Verdict != "" {
|
||||
t.Fatalf("after hold: Traffic.Verdict = %q, want \"\" (unknown) — the engine is not carrying that config", got.Verdict)
|
||||
}
|
||||
}
|
||||
+217
-16
@@ -1,13 +1,19 @@
|
||||
package apply
|
||||
|
||||
// Deciding WHERE the untunnelable-protocol drop applies.
|
||||
// Deciding WHERE the untunnelable-protocol drop applies, for the ONE policy that
|
||||
// asks: `icmp`.
|
||||
//
|
||||
// The data plane cannot tunnel anything that is not TCP or UDP (kernel TPROXY
|
||||
// needs a socket; the proxy protocols carry TCP streams and UDP datagrams). The
|
||||
// drop that follows from that is only justified for destinations the routing
|
||||
// rules actually send THROUGH the tunnel: where a rule routes direct, the
|
||||
// client's real address already reaches that destination over TCP, so dropping
|
||||
// its ICMP hides nothing and merely breaks diagnostics.
|
||||
// needs a socket; the proxy protocols carry TCP streams and UDP datagrams). Where
|
||||
// a rule routes direct, the client's real address already reaches that
|
||||
// destination over TCP, so dropping its ICMP hides nothing and merely breaks
|
||||
// diagnostics.
|
||||
//
|
||||
// That reasoning used to govern `block` as well, which made `block` identical to
|
||||
// `direct` under the ordinary "tunnel the blocked list, send the rest direct"
|
||||
// configuration — see the essay in netplane/untunnelable.go. `block` now drops
|
||||
// unconditionally and `direct` allows unconditionally; neither reads this file,
|
||||
// and untunnelablePlanFor no longer builds a plan for them at all.
|
||||
//
|
||||
// This file computes the difference, by walking the FULLY RESOLVED routing rules
|
||||
// that generate produced. Using generate's output rather than the raw model is
|
||||
@@ -20,6 +26,7 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
@@ -30,6 +37,110 @@ import (
|
||||
// engine.RuleSetIPCIDRs).
|
||||
type ruleSetCIDRs func(tag string) ([]netip.Prefix, bool)
|
||||
|
||||
// SCHEMA v2 MOVED A RULE'S DESTINATION OUT OF THE RULE, AND THIS FILE HAD TO FOLLOW.
|
||||
//
|
||||
// Until D21 a routing rule spelled its destination inline: `dst_domain` became
|
||||
// DefaultRule.Domain*, `dst_ip` became DefaultRule.IPCIDR. Both were visible right
|
||||
// on the rule, so matchesUntunnelable could read them off it and the ADDRESS half
|
||||
// could only ever be resolved through the engine for the rare geosite/geoip case.
|
||||
//
|
||||
// Since v2 a rule names a `config ruleset` and generate materialises it into
|
||||
// Route.RuleSet, so BOTH halves arrive by reference. That broke two things at once,
|
||||
// in opposite directions:
|
||||
//
|
||||
// - DOMAINS WENT INVISIBLE. A v1 rule `dst_domain=[bank.ru] dst_ip=[10/8]` ANDed
|
||||
// its matchers, so it could not match an ICMP packet (no domain in one) and this
|
||||
// file skipped it. After migration the same rule reads `rule_set: [rule-x,
|
||||
// rule-x-ip]`, whose two sets are ORed — so the address set alone would make the
|
||||
// rule look applicable, and the plan would start emitting an ALLOW (target
|
||||
// direct) or a DENY (target block) for 10/8 that the operator never asked for.
|
||||
// The ALLOW is the dangerous half: it sends previously-tunnelled ICMP out with
|
||||
// the client's real address, i.e. an upgrade silently opening a leak. So a rule
|
||||
// whose rule-sets are KNOWN to carry domain matchers is treated exactly as its
|
||||
// v1 form was: inapplicable to untunnelable traffic. (The OR is deliberate and
|
||||
// documented in D21 for the ENGINE's TCP/UDP path; it does not follow that a
|
||||
// leak-guard should widen itself during an upgrade without a word.)
|
||||
// - ADDRESSES WENT ENGINE-ONLY. `rule-x-ip` is an INLINE rule-set: its prefixes
|
||||
// are sitting right there in the generated options. Asking the engine for them
|
||||
// — and, when the engine is not up, truncating the whole walk and denying
|
||||
// everything — was needless. Inline sets are now read from the options, so an
|
||||
// engine that has not started yet no longer costs the operator their ping.
|
||||
//
|
||||
// Both come from the same index, built once per plan: what the CONFIG ITSELF can
|
||||
// say about each rule-set. Remote/local sets stay the engine's business.
|
||||
|
||||
// ruleSetFacts is what the generated options alone reveal about one rule-set.
|
||||
//
|
||||
// opaque means "not knowable from here" — a local/remote set (only the engine has
|
||||
// its contents) or an inline set with a rule shape this code does not model. An
|
||||
// opaque set is never used to conclude anything; it falls through to the engine
|
||||
// lookup exactly as before.
|
||||
type ruleSetFacts struct {
|
||||
opaque bool
|
||||
hasDomain bool // carries a matcher only a NAMED destination can satisfy
|
||||
cidrs []netip.Prefix // addresses, when they are knowable here
|
||||
}
|
||||
|
||||
// indexRuleSets summarises every rule-set in the generated options by tag.
|
||||
func indexRuleSets(sets []option.RuleSet) map[string]ruleSetFacts {
|
||||
out := make(map[string]ruleSetFacts, len(sets))
|
||||
for _, rs := range sets {
|
||||
var f ruleSetFacts
|
||||
// option.RuleSet leaves Type empty for inline in some marshalled forms; the
|
||||
// generator always sets it, and both spellings mean the same thing here.
|
||||
if rs.Type != C.RuleSetTypeInline && rs.Type != "" {
|
||||
f.opaque = true
|
||||
out[rs.Tag] = f
|
||||
continue
|
||||
}
|
||||
for _, hr := range rs.InlineOptions.Rules {
|
||||
if hr.Type != C.RuleTypeDefault && hr.Type != "" {
|
||||
// A logical headless rule, or a shape sing-box adds later. Never guessed
|
||||
// at — the same rule the walk itself follows for a logical route rule.
|
||||
f = ruleSetFacts{opaque: true}
|
||||
break
|
||||
}
|
||||
d := hr.DefaultOptions
|
||||
if headlessNeedsUnavailableMatcher(d) {
|
||||
f.hasDomain = true
|
||||
}
|
||||
f.cidrs = append(f.cidrs, parsePrefixes(d.IPCIDR)...)
|
||||
}
|
||||
out[rs.Tag] = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// headlessNeedsUnavailableMatcher is matchesUntunnelable's predicate for the
|
||||
// HEADLESS rule shape a rule-set carries. It reports whether the rule demands
|
||||
// something a packet with no domain, no ports, no sniffed protocol and no process
|
||||
// identity cannot supply — in which case that rule-set cannot claim such a packet.
|
||||
//
|
||||
// The generator only ever emits domain matchers or ip_cidr into an inline routing
|
||||
// rule-set, so in practice this is the domain test; the rest is there so a future
|
||||
// matcher is refused rather than silently ignored.
|
||||
func headlessNeedsUnavailableMatcher(d option.DefaultHeadlessRule) bool {
|
||||
switch {
|
||||
case len(d.Domain) > 0, len(d.DomainSuffix) > 0, len(d.DomainKeyword) > 0,
|
||||
len(d.DomainRegex) > 0, len(d.AdGuardDomain) > 0:
|
||||
return true // no domain in an ICMP/ESP/GRE packet
|
||||
case len(d.Port) > 0, len(d.PortRange) > 0, len(d.SourcePort) > 0, len(d.SourcePortRange) > 0:
|
||||
return true
|
||||
case len(d.Network) > 0, len(d.QueryType) > 0:
|
||||
return true
|
||||
case len(d.ProcessName) > 0, len(d.ProcessPath) > 0, len(d.ProcessPathRegex) > 0,
|
||||
len(d.PackageName) > 0, len(d.PackageNameRegex) > 0:
|
||||
return true
|
||||
case len(d.WIFISSID) > 0, len(d.WIFIBSSID) > 0:
|
||||
return true
|
||||
case d.Invert:
|
||||
// Same reasoning as the route-rule case: inverting flips every conservative
|
||||
// assumption, so the set may only ever withhold, never grant.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildUntunnelablePlan reduces the resolved routing rules to the first-match
|
||||
// walk the nft forward chain can evaluate for a packet that has no domain, no
|
||||
// ports and no sniffable protocol.
|
||||
@@ -57,6 +168,9 @@ func buildUntunnelablePlan(opts option.Options, lookup ruleSetCIDRs) *netplane.U
|
||||
// No routing at all: nothing is provably direct.
|
||||
return plan
|
||||
}
|
||||
// Since schema v2 a rule's destination lives in a rule-set, so the rule-sets
|
||||
// have to be read alongside the rules. See the block above ruleSetFacts.
|
||||
sets := indexRuleSets(opts.Route.RuleSet)
|
||||
|
||||
for _, r := range opts.Route.Rules {
|
||||
if r.Type == "logical" {
|
||||
@@ -78,7 +192,10 @@ func buildUntunnelablePlan(opts option.Options, lookup ruleSetCIDRs) *netplane.U
|
||||
// and the destination logic never allowed anything. Its `protocol: dns`
|
||||
// matcher makes it inapplicable to a packet with no stream to sniff, which
|
||||
// is the correct and obvious reading once the checks are in this order.
|
||||
if !matchesUntunnelable(d) {
|
||||
if !matchesUntunnelable(d, sets) {
|
||||
if note, ok := namedDestinationNote(d, sets); ok {
|
||||
plan.Warnings = append(plan.Warnings, note)
|
||||
}
|
||||
continue // needs a domain/port/protocol: cannot apply to this traffic
|
||||
}
|
||||
|
||||
@@ -96,21 +213,28 @@ func buildUntunnelablePlan(opts option.Options, lookup ruleSetCIDRs) *netplane.U
|
||||
mt := netplane.UntunnelableMatch{Allow: verdict}
|
||||
|
||||
// Destination predicate: explicit ip_cidr plus every rule-set's addresses.
|
||||
// An INLINE rule-set is answered straight out of the config — it is fully
|
||||
// present there — so a rule whose lists are all inline no longer depends on
|
||||
// the engine being up. Only local/remote sets still have to be asked for.
|
||||
dst := parsePrefixes(d.IPCIDR)
|
||||
resolvable := true
|
||||
var unresolved string
|
||||
for _, tag := range d.RuleSet {
|
||||
if f, known := sets[tag]; known && !f.opaque {
|
||||
dst = append(dst, f.cidrs...)
|
||||
continue
|
||||
}
|
||||
prefixes, ok := lookup(tag)
|
||||
if !ok {
|
||||
resolvable = false
|
||||
unresolved = tag
|
||||
break
|
||||
}
|
||||
dst = append(dst, prefixes...)
|
||||
}
|
||||
if !resolvable {
|
||||
if unresolved != "" {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
|
||||
"list %q is not loaded yet, so ping/IPTV/VPN passthrough stays blocked for the "+
|
||||
"addresses it covers (it resolves itself once the list downloads)",
|
||||
strings.Join(d.RuleSet, ", ")))
|
||||
unresolved))
|
||||
return plan
|
||||
}
|
||||
hasDstMatcher := len(d.IPCIDR) > 0 || len(d.RuleSet) > 0
|
||||
@@ -121,9 +245,31 @@ func buildUntunnelablePlan(opts option.Options, lookup ruleSetCIDRs) *netplane.U
|
||||
}
|
||||
mt.AnyDst = !hasDstMatcher
|
||||
mt.Dst4, mt.Dst6 = netplane.PrefixStrings(dst)
|
||||
if !mt.AnyDst && len(mt.Dst4) == 0 && len(mt.Dst6) == 0 {
|
||||
// The rule names addresses and not one of them survived into a form the
|
||||
// data plane can express — unparseable, or IPv4-mapped IPv6, which
|
||||
// PrefixStrings drops because nftables has no set type for it. The step
|
||||
// would render no line at all, so the walk would silently step OVER a
|
||||
// rule that CAN claim this traffic and let a later rule decide in its
|
||||
// place. That is the over-permissive mistake this file exists to avoid,
|
||||
// so it is undecidable rather than skippable.
|
||||
plan.Warnings = append(plan.Warnings,
|
||||
"a routing rule's addresses cannot be expressed by the firewall, so ping/IPTV/"+
|
||||
"VPN-passthrough traffic is blocked from that rule onwards")
|
||||
return plan
|
||||
}
|
||||
|
||||
// Source predicate, when the rule is scoped to particular clients.
|
||||
// Source predicate, when the rule is scoped to particular clients. Same
|
||||
// reasoning as above, and here the consequence is worse than a skipped step:
|
||||
// an empty source pair reads as "any source", so an ALLOW scoped to three lab
|
||||
// machines would render as an allow for the whole LAN.
|
||||
mt.Src4, mt.Src6 = netplane.PrefixStrings(parsePrefixes(d.SourceIPCIDR))
|
||||
if len(d.SourceIPCIDR) > 0 && len(mt.Src4) == 0 && len(mt.Src6) == 0 {
|
||||
plan.Warnings = append(plan.Warnings,
|
||||
"a routing rule's source addresses cannot be expressed by the firewall, so ping/IPTV/"+
|
||||
"VPN-passthrough traffic is blocked from that rule onwards")
|
||||
return plan
|
||||
}
|
||||
|
||||
plan.Matches = append(plan.Matches, mt)
|
||||
|
||||
@@ -198,7 +344,20 @@ func classifyAction(a option.RuleAction) (isDirect bool, class int) {
|
||||
// Every matcher listed here is one the packet cannot satisfy, so a rule carrying
|
||||
// any of them is inapplicable rather than universally matching. Matchers ANDed
|
||||
// within a rule mean one unsatisfiable matcher makes the whole rule unsatisfiable.
|
||||
func matchesUntunnelable(d option.DefaultRule) bool {
|
||||
//
|
||||
// sets carries the same test for the matchers that are no longer written on the
|
||||
// rule: since schema v2 a destination is a rule-set reference, so a rule's domains
|
||||
// live in Route.RuleSet rather than in d.Domain*. A rule referencing a set that is
|
||||
// KNOWN to match by name is inapplicable here for exactly the reason a d.Domain
|
||||
// rule is — the packet has no name to match. Sets whose contents this code cannot
|
||||
// see (local/remote) say nothing either way; the address walk below already refuses
|
||||
// to conclude anything from them.
|
||||
func matchesUntunnelable(d option.DefaultRule, sets map[string]ruleSetFacts) bool {
|
||||
for _, tag := range d.RuleSet {
|
||||
if sets[tag].hasDomain {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(d.Domain) > 0, len(d.DomainSuffix) > 0, len(d.DomainKeyword) > 0,
|
||||
len(d.DomainRegex) > 0, len(d.Geosite) > 0:
|
||||
@@ -229,6 +388,41 @@ func matchesUntunnelable(d option.DefaultRule) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// namedDestinationNote explains the one skip an operator can actually be
|
||||
// surprised by: a rule that names BOTH a domain list and an address list.
|
||||
//
|
||||
// A domain-only rule contributes nothing here and always has, so saying so would be
|
||||
// noise. A mixed rule is different — its addresses look like something this plan
|
||||
// could act on, and it is precisely the shape `shaterd migrate` produces from a v1
|
||||
// rule that carried dst_domain and dst_ip together. The rule is skipped so the
|
||||
// upgrade cannot quietly change what ping does (see the block above ruleSetFacts);
|
||||
// that decision is worth one line in the operator's warning list rather than none.
|
||||
//
|
||||
// ok=false when there is nothing surprising to report.
|
||||
func namedDestinationNote(d option.DefaultRule, sets map[string]ruleSetFacts) (string, bool) {
|
||||
var named []string
|
||||
addressed := len(d.IPCIDR) > 0
|
||||
for _, tag := range d.RuleSet {
|
||||
f, known := sets[tag]
|
||||
if known && f.hasDomain {
|
||||
named = append(named, tag)
|
||||
continue
|
||||
}
|
||||
// An unknown/opaque set may well hold addresses; so may a knowable one.
|
||||
if !known || f.opaque || len(f.cidrs) > 0 {
|
||||
addressed = true
|
||||
}
|
||||
}
|
||||
if len(named) == 0 || !addressed {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"a routing rule matches by name (%s) as well as by address, and ping/IPTV/VPN-passthrough traffic "+
|
||||
"carries no name — so that rule is left out of the ping/IPTV decision entirely and later rules "+
|
||||
"decide those addresses. Split it into a name rule and an address rule if you want the addresses "+
|
||||
"decided here", strings.Join(named, ", ")), true
|
||||
}
|
||||
|
||||
// parsePrefixes converts CIDR/bare-address strings to prefixes, dropping anything
|
||||
// unparseable (it cannot be matched on, so it must not silently widen a set).
|
||||
func parsePrefixes(in []string) []netip.Prefix {
|
||||
@@ -253,10 +447,17 @@ func parsePrefixes(in []string) []netip.Prefix {
|
||||
// rule-set addresses against the RUNNING engine. A nil engine (or a stopped one)
|
||||
// yields lookups that always report "not loaded", so the plan degrades to the
|
||||
// conservative blanket drop on its own.
|
||||
//
|
||||
// ONLY `icmp` has a use for it. The other two rungs are unconditional — `direct`
|
||||
// allows every untunnelable protocol wherever it was going, `block` allows none —
|
||||
// and netplane.untunnelableRules refuses to consult the plan for either. Building
|
||||
// one anyway would push the routing rules' whole address space into kernel memory
|
||||
// (geoip-us alone is ~159 000 prefixes, ~20 MB) to answer a question nothing asks;
|
||||
// worse, it would render the sets into the ruleset text, so a geoip refresh would
|
||||
// churn the data plane for a policy that cannot use it. Since `block` is the
|
||||
// DEFAULT, this is the stock install's path.
|
||||
func (a *Applier) untunnelablePlanFor(m *model.Model, opts option.Options) *netplane.UntunnelablePlan {
|
||||
if netplane.EffectiveUntunnelable(m.Globals) == netplane.UntunnelableDirect {
|
||||
// Everything untunnelable is allowed regardless of destination; computing
|
||||
// (and loading into the kernel) thousands of prefixes would change nothing.
|
||||
if netplane.EffectiveUntunnelable(m.Globals) != netplane.UntunnelableICMP {
|
||||
return nil
|
||||
}
|
||||
lookup := func(tag string) ([]netip.Prefix, bool) { return nil, false }
|
||||
|
||||
@@ -22,6 +22,16 @@ func tunnelModel() *model.Model {
|
||||
return m
|
||||
}
|
||||
|
||||
// pinnedIPSet declares the inline `type=ipcidr` rule-set a rule pins its
|
||||
// destination addresses with. Since schema v2 a routing rule has no dst_ip of its
|
||||
// own: addresses are a `config ruleset`, so the generated rule carries a rule_set
|
||||
// reference and the plan resolves the actual prefixes through the lookup below —
|
||||
// i.e. through the RUNNING engine in production (engine.RuleSetIPCIDRs), not out
|
||||
// of the config text. planFor's table stands in for that.
|
||||
func pinnedIPSet(name string, cidrs ...string) model.Ruleset {
|
||||
return model.Ruleset{Name: name, Type: "ipcidr", Source: "inline", Entries: cidrs}
|
||||
}
|
||||
|
||||
// planFor generates m and builds the untunnelable plan, resolving rule-set tags
|
||||
// from the supplied table. A tag absent from the table reports "not loaded".
|
||||
func planFor(t *testing.T, m *model.Model, sets map[string][]string) *netplane.UntunnelablePlan {
|
||||
@@ -53,16 +63,37 @@ func renderPlan(t *testing.T, m *model.Model, plan *netplane.UntunnelablePlan) s
|
||||
return rs
|
||||
}
|
||||
|
||||
// icmpPolicy puts the model on the ONE policy that consults the destination plan.
|
||||
//
|
||||
// Every assertion about the WALK's rendered form has to be made under it, and
|
||||
// that is a change of contract rather than test bookkeeping: `block` now drops
|
||||
// every untunnelable protocol unconditionally and `direct` accepts every one of
|
||||
// them unconditionally, so netplane.untunnelableRules refuses to read the plan for
|
||||
// either. A render-level test left on the default policy would be asserting
|
||||
// against a section the renderer no longer writes — which is exactly how the
|
||||
// defect survived: it was `block` rendering `direct`, under a test that read the
|
||||
// resulting accept as the feature working.
|
||||
func icmpPolicy(m *model.Model) *model.Model {
|
||||
m.Globals.Untunnelable = netplane.UntunnelableICMP
|
||||
return m
|
||||
}
|
||||
|
||||
// TestOnlyPinnedAddressIsTunnelled is the first scenario from the brief: a rule
|
||||
// sends ONLY 8.8.8.8/32 through the tunnel and everything else goes direct, so
|
||||
// only 8.8.8.8 may be un-pingable and the rest of the internet must answer.
|
||||
//
|
||||
// The PLAN half is unchanged — the walk still resolves the pinned address to a
|
||||
// deny and everything else to the routing default. Only the rendering moved to
|
||||
// `icmp` (see icmpPolicy). What `block` renders for this same configuration is
|
||||
// TestBlockDropsEvenWhenEverythingRoutesDirect, and it is nothing at all.
|
||||
func TestOnlyPinnedAddressIsTunnelled(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "pin", Enabled: true, Order: 10, DstIP: []string{"8.8.8.8/32"}, Target: "group:auto"},
|
||||
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
plan := planFor(t, m, nil)
|
||||
plan := planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}})
|
||||
|
||||
if !plan.DefaultAllow {
|
||||
t.Fatalf("a catch-all `direct` rule must make the default ALLOW; plan=%+v", plan)
|
||||
@@ -95,10 +126,128 @@ func TestOnlyPinnedAddressIsTunnelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockDropsEvenWhenEverythingRoutesDirect is the defect, in the exact
|
||||
// configuration that makes it bite.
|
||||
//
|
||||
// "Tunnel the pinned address, send the rest direct" leaves the ROUTING DEFAULT
|
||||
// direct, so the plan's DefaultAllow is true — and `block` used to walk the plan
|
||||
// like `icmp` and inherit that default as a blanket
|
||||
// `meta l4proto != { tcp, udp } accept`. ICMP, ESP, AH, GRE, IGMP and SCTP all
|
||||
// left with the client's real address. That includes a client-run IPsec or PPTP
|
||||
// tunnel: a standing second tunnel beside ours, carrying arbitrary traffic under a
|
||||
// peer we neither route nor filter, for as long as it stays up — which is the very
|
||||
// thing the middle rung exists to keep out of "I just want ping". Meanwhile the
|
||||
// panel promised "Nothing leaves except through the tunnel."
|
||||
//
|
||||
// `block` is the DEFAULT policy, so this was the stock install.
|
||||
//
|
||||
// RED BEFORE THE FIX: with the old untunnelableRules the rendered forward chain
|
||||
// carries `... meta l4proto != { tcp, udp } accept`, emitted from plan.DefaultAllow.
|
||||
func TestBlockDropsEvenWhenEverythingRoutesDirect(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m.Globals.IPv6 = true
|
||||
m.Globals.Untunnelable = netplane.UntunnelableBlock // the default, spelled out: it is the subject
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
plan := planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}})
|
||||
if !plan.DefaultAllow {
|
||||
t.Fatalf("precondition: this config must yield a plan whose default is ALLOW, or the "+
|
||||
"test is not exercising the defect at all; plan=%+v", plan)
|
||||
}
|
||||
|
||||
fwd := renderPlan(t, m, plan)
|
||||
for _, line := range strings.Split(fwd, "\n") {
|
||||
if !strings.Contains(line, netplane.UntunnelableFilterExpr()) &&
|
||||
!strings.Contains(line, "echo-request") {
|
||||
continue
|
||||
}
|
||||
t.Errorf("block emitted an untunnelable exception although its whole promise is that "+
|
||||
"there are none: %q", strings.TrimSpace(line))
|
||||
}
|
||||
// The drops now carry the entire policy, so losing one would be silent.
|
||||
if !strings.Contains(fwd, "meta nfproto ipv4 drop") ||
|
||||
!strings.Contains(fwd, "meta nfproto ipv6 drop") {
|
||||
t.Fatalf("block lost a fail-closed drop, so nothing enforces it:\n%s", fwd)
|
||||
}
|
||||
|
||||
// And the applier must not even BUILD a plan for block: nothing reads it, and
|
||||
// building one pushes the routing rules' whole address space into kernel memory
|
||||
// and into the ruleset text (a geoip refresh would then churn the data plane
|
||||
// for a policy that cannot use it).
|
||||
opts, _, err := generate.GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
var a Applier
|
||||
if got := a.untunnelablePlanFor(m, opts); got != nil {
|
||||
t.Errorf("block must build no destination plan at all, got %d step(s) / defaultAllow=%v",
|
||||
len(got.Matches), got.DefaultAllow)
|
||||
}
|
||||
m.Globals.Untunnelable = netplane.UntunnelableDirect
|
||||
if got := a.untunnelablePlanFor(m, opts); got != nil {
|
||||
t.Errorf("direct must build no destination plan either, got %+v", got)
|
||||
}
|
||||
if got := a.untunnelablePlanFor(icmpPolicy(m), opts); got == nil {
|
||||
t.Errorf("icmp is the policy that needs the plan; it must still get one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceScopedRuleDoesNotWidenTheOtherFamily: a step scoped to IPv6 clients
|
||||
// must emit no IPv4 line at all.
|
||||
//
|
||||
// emit() only wrote `ip saddr @set` when THAT family had prefixes, so a rule whose
|
||||
// source_ip_cidr held only IPv6 prefixes rendered an IPv4 line with no source
|
||||
// clause whatsoever — an accept for every IPv4 host on the LAN, out of a rule the
|
||||
// operator scoped to a handful of v6 addresses. The destination half had always
|
||||
// skipped in that situation (`!AnyDst && len(dst) == 0`); the source half was the
|
||||
// asymmetry, and the catch-all collapse in buildUntunnelablePlan reads "scoped"
|
||||
// as the union of both families, so the two disagreed.
|
||||
//
|
||||
// RED BEFORE THE FIX: `... ip daddr @unt_d4_0 accept`, with no `ip saddr`.
|
||||
func TestSourceScopedRuleDoesNotWidenTheOtherFamily(t *testing.T) {
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Globals.IPv6 = true
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("lab", "198.51.100.0/24", "2001:db8:70::/48")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "lab", Enabled: true, Order: 10, Src: []string{"2001:db8:9::/48"},
|
||||
DstRuleset: []string{"lab"}, Target: "direct"},
|
||||
{Name: "dflt", Enabled: true, Order: 99, Target: "group:auto"},
|
||||
}
|
||||
plan := planFor(t, m, map[string][]string{"rs-lab": {"198.51.100.0/24", "2001:db8:70::/48"}})
|
||||
if len(plan.Matches) != 1 {
|
||||
t.Fatalf("expected one step, got %+v", plan.Matches)
|
||||
}
|
||||
step := plan.Matches[0]
|
||||
if len(step.Src4) != 0 || len(step.Src6) != 1 {
|
||||
t.Fatalf("the step must carry v6 sources only: src4=%v src6=%v", step.Src4, step.Src6)
|
||||
}
|
||||
if len(step.Dst4) != 1 || len(step.Dst6) != 1 {
|
||||
t.Fatalf("the destination list is dual-family: dst4=%v dst6=%v", step.Dst4, step.Dst6)
|
||||
}
|
||||
|
||||
fwd := renderPlan(t, m, plan)
|
||||
for _, line := range strings.Split(fwd, "\n") {
|
||||
if !strings.Contains(line, "@unt_d4_0") {
|
||||
continue
|
||||
}
|
||||
t.Errorf("a rule scoped to IPv6 sources emitted an IPv4 line; with no source clause on "+
|
||||
"it that is an accept for the whole LAN: %q", strings.TrimSpace(line))
|
||||
}
|
||||
// ...and the family the rule really does scope must survive, or the guard
|
||||
// over-corrected into dropping the step entirely.
|
||||
if !strings.Contains(fwd, "ip6 saddr @unt_s6_0") ||
|
||||
!strings.Contains(fwd, "ip6 daddr @unt_d6_0 accept") {
|
||||
t.Errorf("the v6 half of the step was lost:\n%s", fwd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatchAllTunnelDropsEverything is the mirror case: a catch-all rule into the
|
||||
// tunnel means nothing is provably direct, so everything untunnelable is dropped.
|
||||
func TestCatchAllTunnelDropsEverything(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Rules = []model.Rule{{Name: "all", Enabled: true, Order: 99, Target: "group:auto"}}
|
||||
plan := planFor(t, m, nil)
|
||||
|
||||
@@ -121,7 +270,7 @@ func TestCatchAllTunnelDropsEverything(t *testing.T) {
|
||||
// `ru-direct` routes a geoip list direct while everything else is tunnelled, so
|
||||
// exactly those addresses become pingable.
|
||||
func TestGeoIPRulesetResolvesToPingableAddresses(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Globals.IPv6 = true
|
||||
m.Rulesets = []model.Ruleset{{
|
||||
Name: "ru", Type: "ipcidr", Source: "geoip", Categories: []string{"ru"},
|
||||
@@ -162,7 +311,7 @@ func TestGeoIPRulesetResolvesToPingableAddresses(t *testing.T) {
|
||||
// NOT read as "contains no addresses". That would let later rules decide and
|
||||
// could allow traffic the plan cannot actually account for.
|
||||
func TestUnloadedRuleSetStaysConservative(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Rulesets = []model.Ruleset{{
|
||||
Name: "ru", Type: "ipcidr", Source: "geoip", Categories: []string{"ru"},
|
||||
}}
|
||||
@@ -221,14 +370,148 @@ func TestDomainRuleDoesNotAffectUntunnelable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedRuleDenies: an explicitly blocked destination stays dropped.
|
||||
func TestBlockedRuleDenies(t *testing.T) {
|
||||
// --- schema v2: the destination moved into rule-sets, and so did the analysis ---
|
||||
|
||||
// migratedRule reproduces exactly what `shaterd migrate` makes of a v1 rule that
|
||||
// carried BOTH lists: `dst_domain=[bank.ru] dst_ip=[203.0.113.0/24]` becomes two
|
||||
// inline rule-sets, `rule-x` and `rule-x-ip`, and the rule references both.
|
||||
func migratedRule(target string) *model.Model {
|
||||
m := tunnelModel()
|
||||
m.Rulesets = []model.Ruleset{
|
||||
{Name: "rule-x", Type: "domain", Source: "inline", Entries: []string{"full:bank.ru"}},
|
||||
pinnedIPSet("rule-x-ip", "203.0.113.0/24"),
|
||||
}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "bad", Enabled: true, Order: 10, DstIP: []string{"203.0.113.0/24"}, Target: "block"},
|
||||
{Name: "x", Enabled: true, Order: 10,
|
||||
DstRuleset: []string{"rule-x", "rule-x-ip"}, Target: target},
|
||||
{Name: "dflt", Enabled: true, Order: 99, Target: "group:auto"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestMigratedDomainAndIPRuleStaysOutOfTheUntunnelablePlan is the upgrade
|
||||
// regression. In v1 the rule ANDed its domain and its addresses, so it could never
|
||||
// claim a packet that carries no domain and this plan skipped it. After the
|
||||
// migration the same rule reads `rule_set: [rule-x, rule-x-ip]`, and rule_set
|
||||
// entries are ORed — so the address set alone made the rule look applicable and the
|
||||
// plan started emitting a verdict for 203.0.113.0/24 that nobody asked for.
|
||||
//
|
||||
// With target=direct that verdict is an ALLOW, i.e. an upgrade quietly sending
|
||||
// previously-tunnelled ICMP out with the client's real source address. That is the
|
||||
// half that makes this a leak and not just a surprise, so it is checked first.
|
||||
func TestMigratedDomainAndIPRuleStaysOutOfTheUntunnelablePlan(t *testing.T) {
|
||||
// Both engine states, because they fail differently: with the engine UP the old
|
||||
// code emitted a live verdict for the addresses, and with it DOWN the same
|
||||
// mistake hid behind the unresolvable-list truncation. Neither may happen.
|
||||
for _, target := range []string{"direct", "block", "group:auto"} {
|
||||
for _, engine := range []string{"up", "down"} {
|
||||
t.Run(target+"/engine-"+engine, func(t *testing.T) {
|
||||
m := icmpPolicy(migratedRule(target))
|
||||
var loaded map[string][]string
|
||||
if engine == "up" {
|
||||
loaded = map[string][]string{"rs-rule-x": {}, "rs-rule-x-ip": {"203.0.113.0/24"}}
|
||||
}
|
||||
plan := planFor(t, m, loaded)
|
||||
|
||||
if len(plan.Matches) != 0 {
|
||||
t.Fatalf("a rule that matches by NAME as well as by address must contribute no "+
|
||||
"address step (it cannot match a packet that carries no name): %+v", plan.Matches)
|
||||
}
|
||||
if plan.DefaultAllow {
|
||||
t.Errorf("the catch-all routes into the tunnel, so the default must still deny")
|
||||
}
|
||||
fwd := renderPlan(t, m, plan)
|
||||
if strings.Contains(fwd, "203.0.113.0/24") {
|
||||
t.Errorf("the migrated address list must not reach the data plane through the "+
|
||||
"untunnelable policy:\n%s", fwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigratedDomainAndIPRuleSaysWhyItWasSkipped: skipping is the safe answer, but
|
||||
// a silently different ping after an upgrade is the complaint this whole change
|
||||
// exists to answer. The mixed rule — the exact shape migration produces — is named.
|
||||
func TestMigratedDomainAndIPRuleSaysWhyItWasSkipped(t *testing.T) {
|
||||
plan := planFor(t, migratedRule("direct"), nil)
|
||||
var seen bool
|
||||
for _, w := range plan.Warnings {
|
||||
if strings.Contains(w, "matches by name") && strings.Contains(w, "rs-rule-x") {
|
||||
seen = true
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Fatalf("the skip must be explained and the list named; warnings=%v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDomainOnlyRuleSetIsQuiet: a rule whose only destination is a NAME list has
|
||||
// always contributed nothing here and is not a surprise, so it must not produce a
|
||||
// note. Only the mixed shape is worth a line.
|
||||
func TestDomainOnlyRuleSetIsQuiet(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m.Rulesets = []model.Ruleset{
|
||||
{Name: "names", Type: "domain", Source: "inline", Entries: []string{"full:bank.ru"}},
|
||||
}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "names", Enabled: true, Order: 10, DstRuleset: []string{"names"}, Target: "group:auto"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
plan := planFor(t, m, nil)
|
||||
if !plan.DefaultAllow {
|
||||
t.Errorf("a name-only rule must not withhold the catch-all allow; plan=%+v", plan)
|
||||
}
|
||||
for _, w := range plan.Warnings {
|
||||
if strings.Contains(w, "matches by name") {
|
||||
t.Errorf("a name-only rule is not a surprise and needs no note: %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineRuleSetNeedsNoEngine: an inline rule-set's addresses are sitting in the
|
||||
// generated config. Asking the engine for them — and, while it is down, truncating
|
||||
// the whole walk so ping/IPTV/VPN passthrough dies everywhere — was a conservative
|
||||
// answer to a question that did not have to be asked. The verdict must now be the
|
||||
// same whether or not the engine is up.
|
||||
func TestInlineRuleSetNeedsNoEngine(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
|
||||
down := planFor(t, m, nil) // engine not up
|
||||
up := planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}}) // engine up
|
||||
|
||||
for name, plan := range map[string]*netplane.UntunnelablePlan{"engine-down": down, "engine-up": up} {
|
||||
if !plan.DefaultAllow {
|
||||
t.Fatalf("%s: the catch-all direct must allow the rest of the internet; plan=%+v", name, plan)
|
||||
}
|
||||
if len(plan.Matches) != 1 || plan.Matches[0].Allow {
|
||||
t.Fatalf("%s: the tunnelled address must produce one DENY step, got %+v", name, plan.Matches)
|
||||
}
|
||||
if got := plan.Matches[0].Dst4; len(got) != 1 || got[0] != "8.8.8.8/32" {
|
||||
t.Fatalf("%s: step destinations = %v, want [8.8.8.8/32]", name, got)
|
||||
}
|
||||
}
|
||||
for _, w := range down.Warnings {
|
||||
if strings.Contains(w, "not loaded yet") {
|
||||
t.Errorf("an inline list is never 'not loaded': %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedRuleDenies: an explicitly blocked destination stays dropped.
|
||||
func TestBlockedRuleDenies(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("bad", "203.0.113.0/24")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "block"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
plan := planFor(t, m, map[string][]string{"rs-bad": {"203.0.113.0/24"}})
|
||||
if len(plan.Matches) != 1 || plan.Matches[0].Allow {
|
||||
t.Fatalf("a blocked destination must deny untunnelable traffic too: %+v", plan.Matches)
|
||||
}
|
||||
@@ -250,7 +533,7 @@ func TestHugePlanLoadsInFullAndReportsItsSize(t *testing.T) {
|
||||
addr := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)})
|
||||
huge = append(huge, netip.PrefixFrom(addr, 32).String())
|
||||
}
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Rulesets = []model.Ruleset{{
|
||||
Name: "big", Type: "ipcidr", Source: "geoip", Categories: []string{"us"},
|
||||
}}
|
||||
@@ -361,11 +644,12 @@ func TestPlanNeverAcceptsTCPOrUDP(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m.Globals.IPv6 = true
|
||||
m.Globals.Untunnelable = policy
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "pin", Enabled: true, Order: 10, DstIP: []string{"8.8.8.8/32"}, Target: "group:auto"},
|
||||
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
|
||||
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
|
||||
}
|
||||
fwd := renderPlan(t, m, planFor(t, m, nil))
|
||||
fwd := renderPlan(t, m, planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}}))
|
||||
|
||||
// Only the lines the untunnelable policy emits are in scope: the tproxy
|
||||
// diverts in prerouting legitimately match TCP/UDP, which is their job.
|
||||
@@ -386,7 +670,14 @@ func TestPlanNeverAcceptsTCPOrUDP(t *testing.T) {
|
||||
strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
if policyLines == 0 {
|
||||
// `block` is the exception, and now it is the point: it emits NO
|
||||
// exception line whatsoever. That silence IS the policy — everything
|
||||
// untunnelable falls through to the fail-closed drops below.
|
||||
if policy == netplane.UntunnelableBlock {
|
||||
if policyLines != 0 {
|
||||
t.Errorf("block must emit no untunnelable exception at all:\n%s", fwd)
|
||||
}
|
||||
} else if policyLines == 0 {
|
||||
t.Errorf("policy %q emitted no lines at all:\n%s", policy, fwd)
|
||||
}
|
||||
if !strings.Contains(fwd, "meta nfproto ipv4 drop") ||
|
||||
@@ -423,13 +714,14 @@ func TestLocalPlaneSurvivesEveryPlan(t *testing.T) {
|
||||
// TestSourceScopedRuleNarrowsTheAllow: a rule scoped to particular clients must
|
||||
// only grant those clients, not everyone.
|
||||
func TestSourceScopedRuleNarrowsTheAllow(t *testing.T) {
|
||||
m := tunnelModel()
|
||||
m := icmpPolicy(tunnelModel())
|
||||
m.Rulesets = []model.Ruleset{pinnedIPSet("lab", "198.51.100.0/24")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "lab", Enabled: true, Order: 10, Src: []string{"192.168.9.0/24"},
|
||||
DstIP: []string{"198.51.100.0/24"}, Target: "direct"},
|
||||
DstRuleset: []string{"lab"}, Target: "direct"},
|
||||
{Name: "dflt", Enabled: true, Order: 99, Target: "group:auto"},
|
||||
}
|
||||
plan := planFor(t, m, nil)
|
||||
plan := planFor(t, m, map[string][]string{"rs-lab": {"198.51.100.0/24"}})
|
||||
if len(plan.Matches) != 1 {
|
||||
t.Fatalf("expected one step, got %+v", plan.Matches)
|
||||
}
|
||||
|
||||
+221
-25
@@ -24,7 +24,9 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/engine"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
@@ -72,25 +74,76 @@ type Warning struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// notAppliedTags are the SCREAMING-KEBAB prefixes generate stamps on the one
|
||||
// class of warning that means "you configured this protection and it is NOT in
|
||||
// force right now". They exist precisely so the condition is greppable and
|
||||
// machine-recognisable (generate/ruleset.go, generate/dnsfilter.go say so where
|
||||
// they emit them), which makes them a STRUCTURAL signal rather than a guess at
|
||||
// wording — so they decide severity outright, before anything else is consulted.
|
||||
//
|
||||
// This is the fix for the defect that made this whole classifier untrustworthy:
|
||||
// the tagged texts say "NOT ACTIVE"/"unreachable right now" in words that matched
|
||||
// none of the old markers ("UNREACHABLE" upper-case against "unreachable"
|
||||
// lower-case, "is NOT applied" against "is configured but NOT ACTIVE"), and the
|
||||
// tag also breaks entityRe below, so a blocklist that failed to download — the
|
||||
// single most common real-world fault on this router, and the one the panel has
|
||||
// no other way to show — was published as a plain `warning` under section
|
||||
// "generate" with no name. Meanwhile `ruleset "x": url source with empty url`
|
||||
// parsed cleanly and was graded critical. Severity was, in effect, inverted:
|
||||
// a typo shouted, a network outage whispered.
|
||||
var notAppliedTags = []string{
|
||||
"RULESET-NOT-APPLIED", // generate/ruleset.go:821,997,1242
|
||||
"DNS-FILTER-NOT-APPLIED", // generate/dnsfilter.go:132
|
||||
}
|
||||
|
||||
// criticalMarkers are substrings that identify a warning as "protection you
|
||||
// configured is not in effect".
|
||||
// configured is not in effect", for the texts that carry neither a tag above nor
|
||||
// a protection section below.
|
||||
//
|
||||
// This is a heuristic over free text, and it is one on purpose: generate emits
|
||||
// plain strings today, and inventing a parallel structured warning API across a
|
||||
// package boundary owned by another agent would be a far larger change than the
|
||||
// problem warrants. The markers below are taken verbatim from the actual warning
|
||||
// texts, so they are exact rather than speculative. If generate ever emits its
|
||||
// own severity, this list becomes dead code and the conversion simplifies.
|
||||
// problem warrants. Every entry below is quoted from a warning that a producer
|
||||
// ACTUALLY emits, with the file it comes from — because the previous list had
|
||||
// drifted into fiction: five of its nine entries matched no living text at all.
|
||||
// Three of those five ("not covered", "fail-closed", "REJECTED") described ONE
|
||||
// netplane message (netplane/nft.go:606), which reaches us on the netplane
|
||||
// channel and is graded critical wholesale before classify() ever runs; one
|
||||
// ("left un-blocked") named a message generate/doh.go:178 records as deleted;
|
||||
// one ("UNREACHABLE") was upper-case against a lower-case text. A marker with no
|
||||
// producer is not harmless: it reads as coverage, and it is what let the real
|
||||
// texts go ungraded for as long as they did.
|
||||
//
|
||||
// If generate ever emits its own severity, this list becomes dead code and the
|
||||
// conversion simplifies.
|
||||
var criticalMarkers = []string{
|
||||
"UNREACHABLE", // remote rule-set/blocklist not applied
|
||||
"is NOT applied", // ''
|
||||
"NOT emitted", // block_doh NXDOMAIN rules missing
|
||||
"left un-blocked", // block_doh: upstream resolver excluded
|
||||
"inert", // dns_filter / per-device DNS configured but not working
|
||||
"has NO effect", // dns_mode=fakeip with no fakeip resolver
|
||||
"not covered", // an interface outside the fail-closed guard
|
||||
"fail-closed", // ''
|
||||
"REJECTED", // an unusable interface name
|
||||
// The DoH NXDOMAIN rules were not built (generate/dns.go:41,67), and a routing
|
||||
// rule whose sources the engine cannot see is not built either
|
||||
// (generate/route.go:113) — in both cases the operator's block simply is not there.
|
||||
"NOT emitted",
|
||||
// dns_filter / per-device DNS / dns_intercept configured but not working
|
||||
// (generate/dns.go:32,35,38,58,61,64) — the filter is on in the UI and filtering nothing.
|
||||
"inert",
|
||||
// A dns_rule that survived parsing but matches nothing (generate/dns.go:987).
|
||||
"has NO effect",
|
||||
// A routing rule that was emitted but whose target is never reached
|
||||
// (generate/route.go:113,115): the traffic the operator sent through a tunnel
|
||||
// follows the rules below it and the default instead. Present tense on purpose —
|
||||
// "never applied" (past) is warnUnreachableRules' wording, which is graded by
|
||||
// consequence a few lines below, not swept in here.
|
||||
"never applies",
|
||||
// A rule scoped to one source that now matches the WHOLE network
|
||||
// (generate/route.go:407, generate/dns.go:992). Whatever the rule does — send a
|
||||
// device direct, point it at another resolver — it now does it to every client,
|
||||
// and nothing else in the UI shows that the scope collapsed.
|
||||
"applies to EVERY client on the router",
|
||||
"apply to ALL clients",
|
||||
// DNS that leaves the router in plaintext to the provider while the UI shows a
|
||||
// configured resolver (generate/dns.go:38,64,410) and node hostnames resolved
|
||||
// direct from the real address (generate/dns.go:469). These are leaks of exactly
|
||||
// the kind the tunnel exists to prevent.
|
||||
"in the clear",
|
||||
"your provider sees",
|
||||
// A condition-less rule retired by a later condition-less rule whose target is
|
||||
// `direct` (generate/route.go warnUnreachableRules): the operator's default
|
||||
// policy — a tunnel, or a block — is not the one the router uses, so everything
|
||||
@@ -115,6 +168,22 @@ var protectionSections = map[string]bool{
|
||||
"allowlist": true,
|
||||
}
|
||||
|
||||
// degradedProtectionMarkers are the exceptions to the section rule above: texts
|
||||
// about a protection list that is STILL IN EFFECT.
|
||||
//
|
||||
// Grading these critical is the same defect pointed the other way. The panel's
|
||||
// alarm banner lights on critical and on nothing else, so every critical that
|
||||
// turns out to be cosmetic teaches the operator that the banner means nothing —
|
||||
// and the next one, the one about the blocklist that really did not load, is the
|
||||
// one they will not read. In particular the refresh failure is the NORMAL state
|
||||
// of a Russian router for minutes at a time: the list is served from the copy
|
||||
// compiled earlier and keeps blocking, which is a degradation, not a gap.
|
||||
var degradedProtectionMarkers = []string{
|
||||
"continuing with the copy compiled earlier", // generate/ruleset.go:819 — stale but blocking
|
||||
"is IGNORED", // generate/ruleset.go:445,472 — a redundant field, the list loads
|
||||
"bad update_interval", // generate/ruleset.go:862,1010 — falls back to the default interval
|
||||
}
|
||||
|
||||
// infoMarkers identify operational notes that are not protection gaps.
|
||||
var infoMarkers = []string{"cache:"}
|
||||
|
||||
@@ -122,26 +191,67 @@ var infoMarkers = []string{"cache:"}
|
||||
// consistently, so Section/Name can be recovered from a plain string.
|
||||
var entityRe = regexp.MustCompile(`^([a-z_]+) "([^"]*)": (.*)$`)
|
||||
|
||||
// tagRe matches the SCREAMING-KEBAB prefix of a tagged warning (see notAppliedTags).
|
||||
var tagRe = regexp.MustCompile(`^([A-Z][A-Z0-9-]*): `)
|
||||
|
||||
// taggedEntityRe recovers the entity from a TAGGED warning, whose shape is
|
||||
//
|
||||
// RULESET-NOT-APPLIED: ruleset "ads" is configured but NOT ACTIVE: ...
|
||||
//
|
||||
// i.e. the tag sits where entityRe expects the kind, and the entity is followed by
|
||||
// prose rather than by ": ". Without this the single most important warning on the
|
||||
// router arrived with Section "generate" and no Name, so the panel could neither
|
||||
// group it nor link to the list it is about.
|
||||
var taggedEntityRe = regexp.MustCompile(`^[A-Z][A-Z0-9-]*: ([a-z_]+) "([^"]*)"`)
|
||||
|
||||
// warningFromText normalises one free-text warning. defaultSection is used when
|
||||
// the text carries no `kind "name":` prefix.
|
||||
func warningFromText(text, defaultSection, severity string) Warning {
|
||||
w := Warning{Severity: severity, Section: defaultSection, Message: strings.TrimSpace(text)}
|
||||
if m := entityRe.FindStringSubmatch(w.Message); m != nil {
|
||||
w.Section, w.Name, w.Message = m[1], m[2], m[3]
|
||||
return w
|
||||
}
|
||||
if m := taggedEntityRe.FindStringSubmatch(w.Message); m != nil {
|
||||
// Attribution only — the message is deliberately left WHOLE. The tag is the
|
||||
// operator's grep handle into `logread` (it is documented as such where it is
|
||||
// emitted), so stripping it to save one repetition of the list's name would
|
||||
// cost the one thing the tag exists for.
|
||||
w.Section, w.Name = m[1], m[2]
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// classify picks a severity from the parsed section plus the message text.
|
||||
// Section wins where it is decisive (see protectionSections); the markers then
|
||||
// catch the global warnings that carry no entity prefix at all.
|
||||
//
|
||||
// Order is the whole design:
|
||||
//
|
||||
// 1. a not-applied TAG is structural and decides outright — it is the producer
|
||||
// saying "this protection is off", not us guessing from prose;
|
||||
// 2. info markers, so a cache relocation never reads as a fault;
|
||||
// 3. the section, for the entity kinds whose entire purpose is to block
|
||||
// something — minus the handful of texts that say the list still works;
|
||||
// 4. the free-text markers, which catch the global warnings that carry no entity
|
||||
// prefix at all.
|
||||
func classify(section, text string) string {
|
||||
if m := tagRe.FindStringSubmatch(text); m != nil {
|
||||
for _, tag := range notAppliedTags {
|
||||
if m[1] == tag {
|
||||
return SeverityCritical
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range infoMarkers {
|
||||
if strings.Contains(text, m) {
|
||||
return SeverityInfo
|
||||
}
|
||||
}
|
||||
if protectionSections[section] {
|
||||
for _, m := range degradedProtectionMarkers {
|
||||
if strings.Contains(text, m) {
|
||||
return SeverityWarning
|
||||
}
|
||||
}
|
||||
return SeverityCritical
|
||||
}
|
||||
for _, m := range criticalMarkers {
|
||||
@@ -161,6 +271,15 @@ func classify(section, text string) string {
|
||||
// fail-closed guard does not cover that interface — always critical.
|
||||
// - configWarnings come from model.Validate (already structured).
|
||||
func collectWarnings(g model.Globals, generateWarnings, netplaneWarnings []string, configWarnings []model.Warning, planWarnings ...string) []Warning {
|
||||
return finalizeWarnings(gatherWarnings(g, generateWarnings, netplaneWarnings, configWarnings, planWarnings...))
|
||||
}
|
||||
|
||||
// gatherWarnings is collectWarnings without the sort and the cap, so a caller
|
||||
// that must FOLD IN a warning of its own (applyLocked's post-swap failure, which
|
||||
// has to say that the data plane is incomplete) can do so and then finalize once.
|
||||
// Sorting and capping a list twice is not equivalent: the second pass would drop
|
||||
// the "N further warning(s) suppressed" disclosure the first pass appended.
|
||||
func gatherWarnings(g model.Globals, generateWarnings, netplaneWarnings []string, configWarnings []model.Warning, planWarnings ...string) []Warning {
|
||||
out := make([]Warning, 0, len(generateWarnings)+len(netplaneWarnings)+len(configWarnings)+1)
|
||||
// The untunnelable-protocol policy always reports what it costs the user; it is
|
||||
// the only one of these that describes correct behaviour rather than a fault.
|
||||
@@ -184,7 +303,12 @@ func collectWarnings(g model.Globals, generateWarnings, netplaneWarnings []strin
|
||||
Message: cw.Message,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finalizeWarnings sorts critical-first and applies the cap. Call it exactly once
|
||||
// per published set.
|
||||
func finalizeWarnings(out []Warning) []Warning {
|
||||
// Stable sort by descending severity so the cap can only drop the least
|
||||
// important entries, and the panel gets the worst news first.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
@@ -239,36 +363,108 @@ func untunnelablePolicyWarnings(g model.Globals, planNotes []string) []Warning {
|
||||
// With the kill switch open the forward chain has no drops at all, so nothing
|
||||
// is restricted whatever the policy says. Saying that is more useful than
|
||||
// repeating a promise which is not being kept.
|
||||
//
|
||||
// Neither this note nor the `direct` one below may claim IPTV, for the same
|
||||
// reason the `block` note disclaims it: multicast does not cross this router
|
||||
// under ANY of the three settings. The stream itself is WAN-side inbound and
|
||||
// these rules never match it, and a client's outbound multicast UDP is dropped
|
||||
// by the fail-closed guard regardless of the policy. Promising it here would be
|
||||
// the identical lie to the one just removed from `block`, only in the branch
|
||||
// where the operator is least likely to go looking for the cause.
|
||||
if !killSwitchClosed(g) {
|
||||
if policy == netplane.UntunnelableDirect {
|
||||
return out
|
||||
}
|
||||
return note(policy,
|
||||
"This setting has no effect while the kill switch is open: with the kill switch open "+
|
||||
"nothing is blocked, so ping, IPTV and VPN passthrough all work — and all of them "+
|
||||
"reach the internet with your real IP address.")
|
||||
"This setting has no effect while the kill switch is open: with the kill switch open the "+
|
||||
"forward chain has no drops at all, so ping, traceroute and raw VPN passthrough "+
|
||||
"(IPsec ESP/AH, PPTP/GRE) all work — and every one of them reaches the internet with "+
|
||||
"your real IP address. IPTV is not part of that: multicast does not pass this router "+
|
||||
"on any setting, which is a separate matter from this one.")
|
||||
}
|
||||
|
||||
switch policy {
|
||||
case netplane.UntunnelableDirect:
|
||||
return note(policy,
|
||||
"Ping, IPTV and VPN passthrough (IPsec/PPTP) work everywhere, but they go straight out "+
|
||||
"with your real IP address instead of through the tunnel — they are the kinds of "+
|
||||
"traffic a tunnel cannot carry.")
|
||||
"Ping and traceroute work everywhere, and so does raw VPN passthrough (IPsec ESP/AH, "+
|
||||
"PPTP/GRE) — but all of it goes straight out with your real IP address instead of "+
|
||||
"through the tunnel, because a tunnel cannot carry this kind of traffic. VPNs that "+
|
||||
"run over UDP (WireGuard, OpenVPN-UDP, IPsec through NAT) are ordinary tunnelled "+
|
||||
"traffic and are unaffected either way. IPTV is not covered by this setting at all: "+
|
||||
"multicast does not pass this router on any of the three, so switching to `direct` "+
|
||||
"will not bring it back.")
|
||||
case netplane.UntunnelableICMP:
|
||||
return note(policy,
|
||||
"Ping and traceroute work everywhere, including addresses you send through the tunnel; "+
|
||||
"the host you ping sees your real IP address. IPTV and VPN passthrough (IPsec/PPTP) "+
|
||||
"work only toward addresses your rules route directly.")
|
||||
default:
|
||||
// This text used to say these things "work only toward addresses your rules
|
||||
// route directly". That was written when a `direct` route final made the
|
||||
// untunnelable drop degenerate into a blanket accept — i.e. when the note was
|
||||
// describing the bug rather than the policy. netplane now blocks what it says
|
||||
// it blocks, so the honest sentence is that none of it works at all, and the
|
||||
// note has to name what is and is NOT affected: "ping does not work" sends an
|
||||
// operator hunting a fault, and the difference between raw ESP and IPsec
|
||||
// through NAT is the difference between "my VPN broke" and "my VPN is fine".
|
||||
return note(netplane.UntunnelableBlock,
|
||||
"Ping, traceroute, IPTV and VPN passthrough work only toward addresses your rules route "+
|
||||
"directly — those already see your real IP address anyway. Toward addresses you send "+
|
||||
"through the tunnel they will not work, because a tunnel cannot carry them and they "+
|
||||
"would otherwise leak your real IP address.")
|
||||
"Ping, traceroute, IPsec/PPTP VPN passthrough and IPTV do not work from your devices at "+
|
||||
"all — not even toward addresses your rules route directly. None of this traffic can "+
|
||||
"travel through a tunnel, so rather than let it out with your real IP address it is "+
|
||||
"dropped. Concretely: ping and Windows tracert fail (on Linux and macOS traceroute "+
|
||||
"sends UDP probes instead, which ARE tunnelled — the hops it prints are the tunnel's "+
|
||||
"path, not your own), and so do raw IPsec (ESP/AH) and PPTP/GRE — a PPTP session will "+
|
||||
"even look connected, because its control channel is TCP and only the payload is "+
|
||||
"dropped. VPNs that run over UDP are NOT affected: WireGuard, OpenVPN-UDP and IPsec "+
|
||||
"through NAT (IKE on UDP 500, NAT-T on UDP 4500) keep working normally. Multicast "+
|
||||
"IPTV does not cross this router under any setting; that one is not this policy.")
|
||||
}
|
||||
}
|
||||
|
||||
// engineTeardownWarnings turns the engine's ABANDONED generations — superseded
|
||||
// sing-box instances whose shutdown overran the hard close budget and are still
|
||||
// running inside this process — into operator-facing warnings.
|
||||
//
|
||||
// Critical, without hesitation. A leaked generation is not untidiness:
|
||||
//
|
||||
// - it still holds its WireGuard devices, and two devices built from the same
|
||||
// private key evict each other at the peer (one session per public key), so
|
||||
// the leak reproduces BETWEEN generations exactly the fault
|
||||
// generate/wgdedup.go removes WITHIN a config — the tunnel flaps and neither
|
||||
// end can say why;
|
||||
// - it still holds its outbound connections and keeps probing nodes, so the
|
||||
// log fills with errors attributed to a config that is no longer applied;
|
||||
// - on a 512 MiB router each one costs real memory that is never returned.
|
||||
//
|
||||
// The generation number is carried in Name so two consecutive status reads can
|
||||
// tell "the same stuck generation" from "another one just leaked", and the
|
||||
// elapsed time is in the message because a shutdown at 8s and one at 40 minutes
|
||||
// are different problems.
|
||||
func engineTeardownWarnings(stuck []engine.StuckClose) []Warning {
|
||||
if len(stuck) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Warning, 0, len(stuck))
|
||||
for _, s := range stuck {
|
||||
config := "unknown config"
|
||||
if len(s.Hash) >= 12 {
|
||||
config = "config " + s.Hash[:12]
|
||||
}
|
||||
out = append(out, Warning{
|
||||
Severity: SeverityCritical,
|
||||
Section: "engine",
|
||||
Name: fmt.Sprintf("generation %d", s.Generation),
|
||||
Message: fmt.Sprintf(
|
||||
"a superseded engine instance (%s) has been shutting down for %s and is STILL RUNNING: "+
|
||||
"it keeps its outbound connections and its WireGuard devices, so it can evict the "+
|
||||
"live tunnel at the peer and it keeps writing to the log. The current configuration "+
|
||||
"is applied and running; restart shaterd if this does not clear.",
|
||||
config, s.Elapsed.Round(time.Second)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func severityRank(s string) int {
|
||||
switch s {
|
||||
case SeverityCritical:
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func TestCollectWarningsAttributesEntities(t *testing.T) {
|
||||
got := collectWarnings(blockGlobals(),
|
||||
[]string{
|
||||
`ruleset "ads": remote list "https://x/y.srs" is UNREACHABLE right now, so it is NOT applied`,
|
||||
ruleSetNotApplied,
|
||||
`device "kids-tablet": no current IP (ip unset and MAC "aa:bb" not leased), skipped`,
|
||||
`chain "hop": has no hops, target skipped`,
|
||||
`dns_filter enabled but no resolvers configured; filter inert`,
|
||||
@@ -38,14 +38,23 @@ func TestCollectWarningsAttributesEntities(t *testing.T) {
|
||||
if w.Severity != SeverityCritical {
|
||||
t.Errorf("an unapplied blocklist is a protection gap; severity = %q, want critical", w.Severity)
|
||||
}
|
||||
if strings.Contains(w.Message, `ruleset "ads":`) {
|
||||
t.Errorf("the entity prefix must move into Section/Name, not stay in Message: %q", w.Message)
|
||||
// The RULESET-NOT-APPLIED tag stays in the message on purpose: generate
|
||||
// documents it as the operator's grep handle into logread.
|
||||
if !strings.HasPrefix(w.Message, "RULESET-NOT-APPLIED:") {
|
||||
t.Errorf("a tagged warning must keep its greppable tag in the message: %q", w.Message)
|
||||
}
|
||||
}
|
||||
if w, ok := byName["device/kids-tablet"]; !ok {
|
||||
t.Errorf("device warning not attributed; got %+v", got)
|
||||
} else if w.Severity != SeverityWarning {
|
||||
t.Errorf("a skipped device is not a protection gap; severity = %q, want warning", w.Severity)
|
||||
} else {
|
||||
if w.Severity != SeverityWarning {
|
||||
t.Errorf("a skipped device is not a protection gap; severity = %q, want warning", w.Severity)
|
||||
}
|
||||
// The plain `kind "name": message` prefix, by contrast, MOVES into
|
||||
// Section/Name — it carries no information the fields do not.
|
||||
if strings.Contains(w.Message, `device "kids-tablet":`) {
|
||||
t.Errorf("the entity prefix must move into Section/Name, not stay in Message: %q", w.Message)
|
||||
}
|
||||
}
|
||||
if w, ok := byName["chain/hop"]; !ok || w.Severity != SeverityWarning {
|
||||
t.Errorf("chain warning: got %+v", w)
|
||||
@@ -115,7 +124,7 @@ func TestCollectWarningsCapKeepsCriticals(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
noisy = append(noisy, `chain "c": has no hops, target skipped`)
|
||||
}
|
||||
noisy = append(noisy, `ruleset "ads": remote list is UNREACHABLE right now, so it is NOT applied`)
|
||||
noisy = append(noisy, ruleSetNotApplied)
|
||||
|
||||
got := collectWarnings(blockGlobals(), noisy, nil, nil)
|
||||
if len(got) != maxStatusWarnings {
|
||||
@@ -232,6 +241,170 @@ func TestWarningsAgainstRealGenerateOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ruleSetNotApplied is the message generate/ruleset.go:997 actually produces when
|
||||
// a remote blocklist cannot be fetched — the single most common real fault on a
|
||||
// router in Russia, and the one the panel has no other way to show (an omitted
|
||||
// rule-set produces no row in GET /api/ruleset/status, so the UI is
|
||||
// indistinguishable from "not configured").
|
||||
//
|
||||
// Quoted verbatim, tag and all, because the classifier is a heuristic over free
|
||||
// text and a test written against invented text proves nothing about it. The
|
||||
// version this replaced asserted on `... is UNREACHABLE ... is NOT applied`, a
|
||||
// sentence no producer has ever emitted; it passed for as long as the real
|
||||
// sentence was being graded a plain `warning` under section "generate" with no
|
||||
// name at all.
|
||||
const ruleSetNotApplied = `RULESET-NOT-APPLIED: ruleset "ads" is configured but NOT ACTIVE: ` +
|
||||
`its source "https://big.oisd.nl/domainswild" is unreachable right now, so rule-set "ads" was ` +
|
||||
`omitted and matches NOTHING until it loads (a blocklist blocks nothing; a routing rule is skipped). ` +
|
||||
`Handing an unusable list to the engine would abort engine start and take the LAN down instead. ` +
|
||||
`Retried automatically on the next reconcile (~1 min) — no action needed unless this persists.`
|
||||
|
||||
// TestClassifyRealGenerateTexts grades the sentences generate REALLY emits, by
|
||||
// consequence.
|
||||
//
|
||||
// The defect this pins: severity was inverted. A blocklist that could not be
|
||||
// downloaded — protection the operator configured, not in force — came out
|
||||
// `warning`, because its tag broke the entity regexp and its wording matched no
|
||||
// marker. A typo in the same list's URL came out `critical`, because that one
|
||||
// parsed cleanly into section "ruleset". The panel's alarm banner lights on
|
||||
// critical and on nothing else, so the router shouted about the typo and stayed
|
||||
// quiet about the outage.
|
||||
func TestClassifyRealGenerateTexts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
// section/entity attribution, when the panel must be able to deep-link.
|
||||
section, entity string
|
||||
}{{
|
||||
name: "remote blocklist could not be fetched",
|
||||
text: ruleSetNotApplied,
|
||||
want: SeverityCritical,
|
||||
section: "ruleset", entity: "ads",
|
||||
}, {
|
||||
name: "not one DNS filter list could be built",
|
||||
text: "DNS-FILTER-NOT-APPLIED: dns_filter is ON and lists are enabled, but NOT ONE of them " +
|
||||
"could be built right now — nothing is being filtered or allowed. See the per-list " +
|
||||
"warnings above for why. Rebuilt on the next reconcile (~1 min).",
|
||||
want: SeverityCritical,
|
||||
section: "generate",
|
||||
}, {
|
||||
// generate/ruleset.go:1214 — the counterpart the outage used to be graded
|
||||
// BELOW. It stays critical: the consequence is identical (the list is not
|
||||
// loaded and matches nothing), which is the whole point — the inversion is
|
||||
// fixed by lifting the outage, not by lowering the typo.
|
||||
name: "url blocklist with an empty url",
|
||||
text: `ruleset "ads": url source with empty url, skipped`,
|
||||
want: SeverityCritical,
|
||||
section: "ruleset", entity: "ads",
|
||||
}, {
|
||||
// generate/ruleset.go:819 — the list IS still in force, from the copy
|
||||
// compiled earlier. Grading this critical would light the alarm banner on
|
||||
// most reconciles of a healthy router behind a flaky link, and a banner that
|
||||
// is always on is a banner nobody reads when it finally matters.
|
||||
name: "blocklist refresh failed but the compiled copy still blocks",
|
||||
text: `ruleset "ads": could not refresh the list from "https://big.oisd.nl/domainswild" ` +
|
||||
`(dial tcp: i/o timeout); continuing with the copy compiled earlier. Retried on the next reconcile.`,
|
||||
want: SeverityWarning,
|
||||
section: "ruleset", entity: "ads",
|
||||
}, {
|
||||
// generate/route.go:407 — a rule the operator scoped to one device now
|
||||
// applies to the entire network. Whatever it does, it now does to everyone.
|
||||
name: "a rule's source scope collapsed to the whole LAN",
|
||||
text: `rule "kids": none of its source entries can be matched by the engine (interface/zone/MAC ` +
|
||||
`selectors and invalid addresses are dropped), so the rule now applies to EVERY client on ` +
|
||||
`the router instead of that source — check it is still what you want, and use IP ` +
|
||||
`addresses/subnets as the source`,
|
||||
want: SeverityCritical,
|
||||
section: "rule", entity: "kids",
|
||||
}, {
|
||||
// generate/route.go:115 — the rule exists in the UI and routes nothing.
|
||||
name: "a rule whose target is never reached",
|
||||
text: `rule "work": no matcher the engine can evaluate, skipped — its target "group:auto" ` +
|
||||
`never applies and the traffic follows the rules below it and the default`,
|
||||
want: SeverityCritical,
|
||||
section: "rule", entity: "work",
|
||||
}, {
|
||||
// generate/ruleset.go:445 — a redundant field on a list that loads fine.
|
||||
name: "a redundant field on a working blocklist",
|
||||
text: `ruleset "ads": format "binary" is IGNORED for source=geosite — the category decides. Remove it to avoid confusion.`,
|
||||
want: SeverityWarning,
|
||||
section: "ruleset", entity: "ads",
|
||||
}, {
|
||||
// generate/chain.go:108 — a configured path that does not resolve. Its
|
||||
// traffic is blocked fail-closed, so no protection claim is broken.
|
||||
name: "a chain with no hops",
|
||||
text: `chain "hop": has no hops, target skipped`,
|
||||
want: SeverityWarning,
|
||||
section: "chain", entity: "hop",
|
||||
}, {
|
||||
// generate/cache.go:92 — operational, the lists still compile.
|
||||
name: "the compiled lists moved to tmpfs",
|
||||
text: "cache: only 3 MiB free on /overlay (need 8 MiB), using tmpfs /tmp/shater instead — " +
|
||||
"remote rule-sets will be re-downloaded after every reboot, so free some space",
|
||||
want: SeverityInfo,
|
||||
section: "generate",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got *Warning
|
||||
for _, w := range collectWarnings(blockGlobals(), []string{tc.text}, nil, nil) {
|
||||
if w.Section == "untunnelable" {
|
||||
continue // the always-present policy notice
|
||||
}
|
||||
w := w
|
||||
got = &w
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("the warning was dropped entirely")
|
||||
}
|
||||
if got.Severity != tc.want {
|
||||
t.Errorf("severity = %q, want %q\n text: %s", got.Severity, tc.want, tc.text)
|
||||
}
|
||||
if got.Section != tc.section || got.Name != tc.entity {
|
||||
t.Errorf("attribution = %q/%q, want %q/%q — the panel deep-links on these",
|
||||
got.Section, got.Name, tc.section, tc.entity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnreachableRuleSetFromRealGenerate drives the ACTUAL producer, so this stays
|
||||
// correct if generate rewords or re-tags the message. A url rule-set pointing at a
|
||||
// closed local port fails the reachability probe exactly the way an unreachable
|
||||
// public blocklist does, with no network needed.
|
||||
func TestUnreachableRuleSetFromRealGenerate(t *testing.T) {
|
||||
m := holdModel("closed")
|
||||
m.Rulesets = []model.Ruleset{
|
||||
{Name: "ads", Type: "domain", Source: "url", URL: "http://127.0.0.1:1/blocklist.srs", Format: "binary"},
|
||||
}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "blockads", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "block"},
|
||||
}
|
||||
|
||||
_, genWarnings, err := generate.GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
t.Logf("real generate warnings: %q", genWarnings)
|
||||
|
||||
var found *Warning
|
||||
for _, w := range collectWarnings(blockGlobals(), genWarnings, nil, nil) {
|
||||
if w.Section == "ruleset" && w.Name == "ads" {
|
||||
w := w
|
||||
found = &w
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("an unfetchable blocklist produced no warning attributed to it: %q", genWarnings)
|
||||
}
|
||||
if found.Severity != SeverityCritical {
|
||||
t.Errorf("a blocklist that did not load is a protection gap the operator cannot otherwise "+
|
||||
"see; severity = %q, want critical (message: %q)", found.Severity, found.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// blockGlobals is the default policy fixture: kill-switch closed, untunnelable
|
||||
// traffic blocked — i.e. what a stock install runs.
|
||||
func blockGlobals() model.Globals {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package buildtags is the contract between what shater DECLARES it supports
|
||||
// and the build tags the shipped router binary is actually compiled with.
|
||||
//
|
||||
// # Why this package exists
|
||||
//
|
||||
// The router binary is built with a deliberately trimmed tag set (D9/D23,
|
||||
// scripts/router-tags.sh) — upstream's full set registers a zoo shater/generate
|
||||
// can never emit, and a router pays for every tag in flash and in RAM. Trimming
|
||||
// is right; trimming BLIND is not. On 2026-07-25 a production router 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
|
||||
//
|
||||
// because `with_gvisor` had been trimmed as "unreachable code" (true for the tun
|
||||
// inbound we never emit — false for the WireGuard endpoint we ship and declare
|
||||
// [MVP]) while `with_wireguard` stayed. Nothing caught it: the test suite builds
|
||||
// with the FULL upstream tag set, so the SHIPPED tag combination was, at that
|
||||
// point, the one configuration nothing in the repo ever exercised.
|
||||
//
|
||||
// # What holds it together now
|
||||
//
|
||||
// 1. Features below names each declared feature and the build tags it needs to
|
||||
// RUN (not merely to compile). shater/buildtags's own test parses
|
||||
// scripts/router-tags.sh and fails if the shipped set does not cover them —
|
||||
// it needs no tags, no Linux and no network, so it runs in every plain
|
||||
// `go test ./...`.
|
||||
// 2. shater/generate's TestShippedTagSetConstructsDeclaredProtocols drives one
|
||||
// node of every declared protocol through box.New under whatever tags the
|
||||
// test binary was built with, skipping only what is genuinely not compiled
|
||||
// in. scripts/check-router-tags.sh runs it with the SHIPPED set, so the
|
||||
// combination we ship is proven to construct, not merely to link.
|
||||
//
|
||||
// (1) catches a trimmed dependency the moment it is trimmed; (2) catches the
|
||||
// class of failure (1) cannot model — a tag that is present but insufficient.
|
||||
//
|
||||
// Adding a protocol to shater/parse + shater/generate means adding a row here.
|
||||
package buildtags
|
||||
|
||||
import "sort"
|
||||
|
||||
// Feature is one capability the product declares, together with the build tags
|
||||
// the binary must carry for it to work at runtime.
|
||||
type Feature struct {
|
||||
// Name is the feature as a user would name it.
|
||||
Name string
|
||||
// Declared points at where we promise it (docs-shater/FEATURES.md section,
|
||||
// or the generator/registry that emits it).
|
||||
Declared string
|
||||
// Tags are ALL build tags required for the feature to work — including
|
||||
// transitive ones (with_awg alone is useless without with_wireguard, which
|
||||
// is useless without with_gvisor). Listing them transitively is deliberate:
|
||||
// the check must not depend on a dependency graph nobody maintains.
|
||||
Tags []string
|
||||
// Why explains what breaks without those tags, with the code anchor. It is
|
||||
// printed by the failing test, so a future trimmer reads the reason instead
|
||||
// of rediscovering it on a router.
|
||||
Why string
|
||||
}
|
||||
|
||||
// Features is the authoritative list. Only tag-GATED capabilities belong here:
|
||||
// tproxy, routing rules, rule-sets, the DNS filter, nft/policy routing and the
|
||||
// panel are compiled unconditionally and cannot be lost to a tag trim.
|
||||
var Features = []Feature{
|
||||
{
|
||||
Name: "WireGuard nodes (wg:// / wireguard:// links, wg-quick .conf import)",
|
||||
Declared: "FEATURES.md §Proxy engine — “VLESS, VMess, Trojan, Shadowsocks, WireGuard” [MVP]",
|
||||
Tags: []string{"with_wireguard", "with_gvisor"},
|
||||
Why: "with_wireguard registers the endpoint (shater/registry/registry_wireguard.go); " +
|
||||
"with_gvisor supplies the userspace netstack EVERY WireGuard device needs — without it " +
|
||||
"transport/wireguard/device_stack_stub.go returns tun.ErrGVisorNotIncluded from BOTH " +
|
||||
"newStackDevice and newSystemStackDevice, so box.New fails with " +
|
||||
"\"create WireGuard device: gVisor is not included in this build\" and the node is dead. " +
|
||||
"system_interface=true is not an escape hatch: it hits the same stub.",
|
||||
},
|
||||
{
|
||||
Name: "AmneziaWG obfuscation (awg:// links; jc/jmin/jmax, s1-s4, h1-h4, i1-i5)",
|
||||
Declared: "FEATURES.md §Proxy engine — “AmneziaWG 2.0 … a driving requirement” [MVP]",
|
||||
Tags: []string{"with_awg", "with_wireguard", "with_gvisor"},
|
||||
Why: "with_awg makes the AWG params reach the device (transport/wireguard/device_awg.go); " +
|
||||
"without it they parse and are silently ignored (option/wireguard.go). It rides on the " +
|
||||
"WireGuard endpoint, so it needs that feature's tags too.",
|
||||
},
|
||||
{
|
||||
Name: "Hysteria2 nodes (hysteria2:// / hy2://)",
|
||||
Declared: "FEATURES.md §Proxy engine [T1]; shater/registry registerQUICOutbounds",
|
||||
Tags: []string{"with_quic"},
|
||||
Why: "hysteria2.RegisterOutbound is compiled only under with_quic (shater/registry/registry_quic.go); without it box.New rejects the outbound as an unknown type.",
|
||||
},
|
||||
{
|
||||
Name: "TUIC nodes (tuic://)",
|
||||
Declared: "FEATURES.md §Proxy engine [T1]; shater/registry registerQUICOutbounds",
|
||||
Tags: []string{"with_quic"},
|
||||
Why: "tuic.RegisterOutbound is compiled only under with_quic (shater/registry/registry_quic.go).",
|
||||
},
|
||||
{
|
||||
Name: "VLESS/VMess over the QUIC v2ray transport (type=quic)",
|
||||
Declared: "FEATURES.md §Proxy engine — “Transports: TCP/WS/gRPC/HTTPUpgrade/H2/QUIC” [MVP]",
|
||||
Tags: []string{"with_quic"},
|
||||
Why: "transport/v2rayquic registers its constructor from an init() blank-imported only under with_quic; without it NewQUICClient returns os.ErrInvalid at dial time.",
|
||||
},
|
||||
{
|
||||
Name: "QUIC / HTTP3 DNS transports (quic://, h3://)",
|
||||
Declared: "shater/registry registerQUICTransports",
|
||||
Tags: []string{"with_quic"},
|
||||
Why: "dns/transport/quic is registered only under with_quic (shater/registry/registry_quic.go).",
|
||||
},
|
||||
{
|
||||
Name: "REALITY (vless security=reality, pbk/sid)",
|
||||
Declared: "FEATURES.md §Proxy engine — “Reality/XTLS” [MVP]; shater/parse security=reality",
|
||||
Tags: []string{"with_utls"},
|
||||
Why: "the REALITY client lives in common/tls/reality_client.go, which is itself `//go:build with_utls`; without the tag a reality config is rejected by the TLS layer.",
|
||||
},
|
||||
{
|
||||
Name: "uTLS ClientHello fingerprints (fp=chrome/firefox/safari/…)",
|
||||
Declared: "shater/generate/outbound.go TLS mapping (UTLS options)",
|
||||
Tags: []string{"with_utls"},
|
||||
Why: "common/tls/utls_client.go is `//go:build with_utls`; the stub (utls_stub.go) refuses a config that sets a fingerprint.",
|
||||
},
|
||||
{
|
||||
Name: "XHTTP / SplitHTTP transport (type=xhttp, type=splithttp)",
|
||||
Declared: "FEATURES.md §Proxy engine [T1]; shater/parse/sharelink.go case \"xhttp\"",
|
||||
Tags: []string{"with_xhttp"},
|
||||
Why: "transport/v2rayxhttp registers the \"xhttp\" transport from an init() blank-imported only under with_xhttp (shater/registry/registry_xhttp.go); without it the transport type is unknown at box.New.",
|
||||
},
|
||||
{
|
||||
Name: "badtls fast path (zero-copy TLS read-wait / ktls, used by every TLS outbound)",
|
||||
Declared: "common/badtls — linked unconditionally by the TLS client",
|
||||
Tags: []string{"badlinkname", "tfogo_checklinkname0"},
|
||||
Why: "common/badtls/*.go are `go1.25 && badlinkname`; without the tag the package degrades to read_wait_stub.go. " +
|
||||
"These two tags additionally REQUIRE -checklinkname=0 in the linker flags — the build fails at link time otherwise " +
|
||||
"(\"invalid reference to crypto/tls.(*Conn).handlePostHandshakeMessage\"), which is why " +
|
||||
"scripts/router-tags.sh carries SHATER_ROUTER_LDFLAGS next to the tag set.",
|
||||
},
|
||||
}
|
||||
|
||||
// RequiredTags is the union of every declared feature's tags, sorted.
|
||||
func RequiredTags() []string {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range Features {
|
||||
for _, t := range f.Tags {
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// Compiled reports the shater-relevant build tags THIS binary was compiled with,
|
||||
// sorted. It is populated by the one-line tag_*.go twins in this package; a tag
|
||||
// with no file here is simply not tracked (and must not appear in Features).
|
||||
func Compiled() []string { return sortedKeys(compiled) }
|
||||
|
||||
// Has reports whether this binary was compiled with tag.
|
||||
func Has(tag string) bool { return compiled[tag] }
|
||||
|
||||
// MissingTags returns the tags f needs that this binary lacks, sorted. Empty
|
||||
// means the feature is fully compiled in.
|
||||
func MissingTags(f Feature) []string {
|
||||
missing := map[string]bool{}
|
||||
for _, t := range f.Tags {
|
||||
if !compiled[t] {
|
||||
missing[t] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(missing)
|
||||
}
|
||||
|
||||
// Tracked reports whether tag has a detector file (tag_*.go) in this package.
|
||||
// Features must only reference tracked tags — an untracked tag would silently
|
||||
// read as "not compiled" and turn a real check into a skip. TestFeatureTagsAreTracked
|
||||
// enforces that, and scripts/check-router-tags.sh additionally proves the
|
||||
// detectors match the tag set the compiler was actually handed.
|
||||
func Tracked(tag string) bool { return tracked[tag] }
|
||||
|
||||
// TrackedTags is every tag this package can observe, i.e. exactly the tags with
|
||||
// a tag_*.go detector. Keep the two in sync — the check script fails loudly if
|
||||
// they drift.
|
||||
func TrackedTags() []string { return sortedKeys(tracked) }
|
||||
|
||||
var tracked = map[string]bool{
|
||||
"with_gvisor": true,
|
||||
"with_quic": true,
|
||||
"with_wireguard": true,
|
||||
"with_awg": true,
|
||||
"with_utls": true,
|
||||
"with_xhttp": true,
|
||||
"with_lx_command": true,
|
||||
"badlinkname": true,
|
||||
"tfogo_checklinkname0": true,
|
||||
}
|
||||
|
||||
// compiled is filled by the tag_*.go detectors' init(). A tag with no detector
|
||||
// file compiled in is absent from the map, which reads as "not compiled".
|
||||
var compiled = map[string]bool{}
|
||||
|
||||
// mark records that tag is compiled into this binary.
|
||||
func mark(tag string) { compiled[tag] = true }
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package buildtags
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// repoFile reads a file relative to the repo root (this package sits at
|
||||
// <repo>/shater/buildtags).
|
||||
func repoFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", rel, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// shVar pulls VAR="…" out of a POSIX sh fragment.
|
||||
func shVar(t *testing.T, script, name string) string {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `="([^"]*)"`)
|
||||
m := re.FindStringSubmatch(script)
|
||||
if m == nil {
|
||||
t.Fatalf("scripts/router-tags.sh: %s=\"…\" not found (single line, double quotes)", name)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// routerTagSet returns the shipped tag set as a set, read from the ONE file that
|
||||
// defines it.
|
||||
func routerTagSet(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
set := map[string]bool{}
|
||||
for _, tag := range strings.Split(shVar(t, repoFile(t, "scripts/router-tags.sh"), "SHATER_ROUTER_TAGS"), ",") {
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
set[tag] = true
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
t.Fatal("SHATER_ROUTER_TAGS is empty")
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// TestRouterTagSetCoversDeclaredFeatures is THE guard the 2026-07-25 WireGuard
|
||||
// outage was missing (D23): it reads the tag set the router binary is actually
|
||||
// built with and fails if a feature we DECLARE supported has lost the build tag
|
||||
// it needs to run.
|
||||
//
|
||||
// It deliberately needs no build tags, no Linux, no privileges and no network,
|
||||
// so it runs in every plain `go test ./...` — including on the Windows dev host,
|
||||
// where nothing else can exercise the shipped configuration. The behavioural
|
||||
// half (does the shipped combination actually CONSTRUCT?) is
|
||||
// shater/generate.TestShippedTagSetConstructsDeclaredProtocols, run with this
|
||||
// same set by scripts/check-router-tags.sh.
|
||||
func TestRouterTagSetCoversDeclaredFeatures(t *testing.T) {
|
||||
shipped := routerTagSet(t)
|
||||
|
||||
for _, f := range Features {
|
||||
var missing []string
|
||||
for _, tag := range f.Tags {
|
||||
if !shipped[tag] {
|
||||
missing = append(missing, tag)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("the shipped router binary would NOT support a feature we declare.\n"+
|
||||
" feature : %s\n"+
|
||||
" declared: %s\n"+
|
||||
" missing : %s (not in SHATER_ROUTER_TAGS, scripts/router-tags.sh)\n"+
|
||||
" why : %s\n"+
|
||||
"Either add the tag back, or stop declaring the feature — those are the only two honest options.",
|
||||
f.Name, f.Declared, strings.Join(missing, ", "), f.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureTagsAreTracked keeps Features honest: every tag it names must have
|
||||
// a tag_*.go detector, or Compiled()/MissingTags() would report it absent even
|
||||
// when it is compiled in — and the behavioural test would silently SKIP the
|
||||
// feature instead of checking it. A false green is worse than a red.
|
||||
func TestFeatureTagsAreTracked(t *testing.T) {
|
||||
for _, f := range Features {
|
||||
for _, tag := range f.Tags {
|
||||
if !Tracked(tag) {
|
||||
t.Errorf("feature %q requires tag %q, which has no detector: add shater/buildtags/tag_%s.go and the entry in the tracked map", f.Name, tag, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrackedTagsHaveDetectorFiles pairs the tracked map with the files on disk,
|
||||
// so a renamed/deleted detector cannot quietly make a tag read as absent.
|
||||
func TestTrackedTagsHaveDetectorFiles(t *testing.T) {
|
||||
for _, tag := range TrackedTags() {
|
||||
name := "tag_" + tag + ".go"
|
||||
body, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Errorf("tracked tag %q has no detector file %s: %v", tag, name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(string(body), "//go:build "+tag) || !strings.Contains(string(body), `mark("`+tag+`")`) {
|
||||
t.Errorf("%s must be `//go:build %s` and call mark(%q)", name, tag, tag)
|
||||
}
|
||||
}
|
||||
files, err := filepath.Glob("tag_*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range files {
|
||||
tag := strings.TrimSuffix(strings.TrimPrefix(f, "tag_"), ".go")
|
||||
if !Tracked(tag) {
|
||||
t.Errorf("detector %s exists but %q is not in the tracked map", f, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildScriptUsesTheSharedTagSet stops the split that caused the outage from
|
||||
// coming back: the ship build must SOURCE scripts/router-tags.sh, not carry its
|
||||
// own copy of the tag list. A second copy is a second truth, and the second one
|
||||
// is the one nobody checks.
|
||||
func TestBuildScriptUsesTheSharedTagSet(t *testing.T) {
|
||||
build := repoFile(t, "scripts/build-shaterd.sh")
|
||||
if !strings.Contains(build, "router-tags.sh") {
|
||||
t.Fatal("scripts/build-shaterd.sh must source scripts/router-tags.sh")
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^\s*ROUTER_TAGS="with_`).MatchString(build) {
|
||||
t.Fatal("scripts/build-shaterd.sh re-inlines a literal tag list; the set must come from scripts/router-tags.sh only")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterLdflagsSatisfyTagRequirements: `badlinkname` is not self-contained —
|
||||
// the LINK step fails without -checklinkname=0. The flag therefore belongs to
|
||||
// the tag set, and lives beside it; assert the pair never separates.
|
||||
func TestRouterLdflagsSatisfyTagRequirements(t *testing.T) {
|
||||
script := repoFile(t, "scripts/router-tags.sh")
|
||||
ldflags := shVar(t, script, "SHATER_ROUTER_LDFLAGS")
|
||||
if routerTagSet(t)["badlinkname"] && !strings.Contains(ldflags, "-checklinkname=0") {
|
||||
t.Fatalf("SHATER_ROUTER_TAGS carries badlinkname but SHATER_ROUTER_LDFLAGS (%q) lacks -checklinkname=0: the build will fail at link time", ldflags)
|
||||
}
|
||||
if !strings.Contains(repoFile(t, "scripts/build-shaterd.sh"), "SHATER_ROUTER_LDFLAGS") {
|
||||
t.Fatal("scripts/build-shaterd.sh must use $SHATER_ROUTER_LDFLAGS, not a hand-copied -checklinkname=0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompiledTagsMatchTheShippedSet proves the DETECTORS are telling the truth:
|
||||
// when the test binary is compiled with exactly the shipped tag set, Compiled()
|
||||
// must equal that set (restricted to tracked tags). Without this, a typo'd or
|
||||
// deleted detector would make the behavioural test skip a protocol and pass.
|
||||
//
|
||||
// It only runs under scripts/check-router-tags.sh (which compiles with that very
|
||||
// set and exports SHATER_ROUTER_TAG_CHECK=1); a plain `go test ./...` compiles
|
||||
// with no tags at all, where the comparison is meaningless.
|
||||
func TestCompiledTagsMatchTheShippedSet(t *testing.T) {
|
||||
if os.Getenv("SHATER_ROUTER_TAG_CHECK") != "1" {
|
||||
t.Skip("not a router-tag-set run; use scripts/check-router-tags.sh")
|
||||
}
|
||||
var want []string
|
||||
for tag := range routerTagSet(t) {
|
||||
if Tracked(tag) {
|
||||
want = append(want, tag)
|
||||
}
|
||||
}
|
||||
sort.Strings(want)
|
||||
got := Compiled()
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("compiled tags do not match the shipped set\n compiled: %v\n shipped : %v\n"+
|
||||
"Either the build ran with the wrong -tags, or a tag_*.go detector is broken.", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build badlinkname
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the badlinkname build tag — see buildtags.go. There is no !badlinkname twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("badlinkname") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build tfogo_checklinkname0
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the tfogo_checklinkname0 build tag — see buildtags.go. There is no !tfogo_checklinkname0 twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("tfogo_checklinkname0") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_awg
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_awg build tag — see buildtags.go. There is no !with_awg twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_awg") }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user