Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1746d4d0ef | ||
|
|
f86501bf77 | ||
|
|
eccfc6136c | ||
|
|
244b7c4199 | ||
|
|
a8ef887c56 |
+162
-315
@@ -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,148 +118,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.arch }}
|
||||
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).
|
||||
- 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)
|
||||
|
||||
- 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) ----
|
||||
- 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-
|
||||
|
||||
- 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-
|
||||
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -281,8 +137,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 +153,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 +233,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,117 +285,21 @@ 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
|
||||
@@ -537,6 +317,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 +341,39 @@ jobs:
|
||||
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
|
||||
ROLLING: ${{ steps.rel.outputs.rolling }}
|
||||
run: |
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
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 +381,55 @@ 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"
|
||||
done
|
||||
|
||||
+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)
|
||||
|
||||
+16
-22
@@ -49,29 +49,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 +84,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) | Фазовый план |
|
||||
|
||||
+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
|
||||
|
||||
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),
|
||||
|
||||
+243
-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,235 @@ 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.
|
||||
|
||||
+10
-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).
|
||||
@@ -89,8 +93,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.)
|
||||
|
||||
+98
-107
@@ -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,55 @@ 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)
|
||||
## 5. The signed apk repo (the normal install path)
|
||||
|
||||
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`).
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
One-time setup 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
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### Updating
|
||||
|
||||
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.
|
||||
|
||||
```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 +242,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 +252,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 +278,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -257,8 +257,12 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
|
||||
- `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.
|
||||
|
||||
@@ -62,7 +62,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 +105,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
|
||||
|
||||
+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",
|
||||
|
||||
@@ -405,3 +405,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;
|
||||
}
|
||||
|
||||
+46
-2
@@ -51,6 +51,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 +120,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".
|
||||
@@ -809,9 +845,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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
+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>,
|
||||
)
|
||||
|
||||
+31
-3
@@ -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, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, 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)
|
||||
@@ -318,9 +318,7 @@ export async function getRulesReachability(): Promise<RulesReachability> {
|
||||
}))
|
||||
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 =>
|
||||
@@ -407,6 +405,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 +421,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 +547,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.
|
||||
|
||||
+41
-19
@@ -1,6 +1,6 @@
|
||||
import './DNS.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, CatSuggest, Led, SrcPicker, Toggle } from '../components'
|
||||
import { Button, CatSuggest, Led, SrcPicker, Toggle, useConfirm } from '../components'
|
||||
import { apply as apiApply, getConfig, putConfig, ApiError } from '../api'
|
||||
import type { Alert, DNSRule, Model, Resolver } from '../api'
|
||||
|
||||
@@ -220,6 +220,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)
|
||||
|
||||
@@ -422,15 +423,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 +462,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 ---------------------------------------------------
|
||||
@@ -492,11 +501,15 @@ export default function DNS() {
|
||||
)
|
||||
|
||||
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 ok = await confirm({
|
||||
label: 'Delete resolver',
|
||||
title: `Delete resolver “${target.Name}”?`,
|
||||
body: 'This removes it from the config.',
|
||||
})
|
||||
if (!ok) 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 }
|
||||
@@ -578,15 +591,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 +627,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(
|
||||
|
||||
@@ -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'
|
||||
@@ -242,6 +242,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 +393,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 (
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
+486
-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) => {
|
||||
@@ -501,14 +742,88 @@ export default function Nodes() {
|
||||
)
|
||||
|
||||
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 ok = await confirm({
|
||||
label: 'Delete node',
|
||||
title: `Delete node “${target.Name}”?`,
|
||||
body: 'This removes it from the config.',
|
||||
})
|
||||
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 +878,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 +1055,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 +1076,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 +1134,7 @@ export default function Nodes() {
|
||||
onToggle={() => toggleGroup(g)}
|
||||
onToggleNode={toggleNode}
|
||||
onRemoveNode={removeNode}
|
||||
onRenameNode={renameNode}
|
||||
onSetEgress={setNodeEgress}
|
||||
/>
|
||||
))}
|
||||
@@ -911,6 +1246,7 @@ function NodeGroup({
|
||||
onToggle,
|
||||
onToggleNode,
|
||||
onRemoveNode,
|
||||
onRenameNode,
|
||||
onSetEgress,
|
||||
}: {
|
||||
group: NodeGroupData
|
||||
@@ -920,6 +1256,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 +1277,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 +1293,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 +1309,7 @@ function NodeRow({
|
||||
egressNames,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onRename,
|
||||
onSetEgress,
|
||||
}: {
|
||||
node: NodeCfg
|
||||
@@ -972,6 +1317,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 +1328,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 +1401,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 && (
|
||||
|
||||
@@ -341,7 +341,7 @@ export function Overview({
|
||||
led={{ variant: len(config?.Rules) ? 'on' : 'amber' }}
|
||||
rows={[
|
||||
{ k: 'egresses', v: String(len(config?.Egresses)) },
|
||||
{ k: 'default', v: defaultTarget(config), hot: true },
|
||||
{ k: 'default', v: defaultTarget(status, config), hot: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -563,12 +563,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) -------------------
|
||||
|
||||
@@ -396,9 +396,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 +512,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 +836,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;
|
||||
|
||||
+194
-127
@@ -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,
|
||||
@@ -22,9 +22,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 +33,14 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +103,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
|
||||
@@ -213,18 +218,59 @@ function everyLabel(sec: number): string {
|
||||
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() === ''
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 +309,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 +333,7 @@ interface AddForm {
|
||||
const EMPTY_FORM: AddForm = {
|
||||
name: '',
|
||||
src: [],
|
||||
matchKind: 'none',
|
||||
matchValue: '',
|
||||
port: '',
|
||||
rulesets: [],
|
||||
proto: '',
|
||||
target: 'direct',
|
||||
@@ -318,6 +366,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)
|
||||
@@ -555,13 +604,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,7 +625,7 @@ export default function Routing() {
|
||||
`ruleset ${name} deleted`,
|
||||
)
|
||||
},
|
||||
[config, rules, rulesets, rulesetUsage, persist],
|
||||
[config, rules, rulesets, rulesetUsage, persist, confirm],
|
||||
)
|
||||
|
||||
const onToggle = useCallback(
|
||||
@@ -612,17 +665,26 @@ 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 ok = await confirm({
|
||||
label: 'Delete rule',
|
||||
title: `Delete rule "${name}"?`,
|
||||
body: '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],
|
||||
)
|
||||
|
||||
// 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 +736,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: '',
|
||||
@@ -789,7 +848,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">
|
||||
@@ -895,6 +954,15 @@ 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)
|
||||
@@ -902,7 +970,7 @@ function RuleRow({
|
||||
'rt-rule',
|
||||
rule.Enabled ? '' : 'off',
|
||||
isDefault ? 'final' : '',
|
||||
dead ? 'dead' : '',
|
||||
inert ? 'dead' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -919,7 +987,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 +1005,21 @@ 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>}
|
||||
</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.
|
||||
@@ -974,11 +1053,21 @@ function RuleRow({
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<Toggle
|
||||
pressed={rule.Enabled}
|
||||
onChange={() => onToggle(rule.Name)}
|
||||
label={`${rule.Enabled ? 'Disable' : 'Enable'} rule ${rule.Name}`}
|
||||
disabled={frozen}
|
||||
label={
|
||||
unmigrated
|
||||
? `Rule ${rule.Name} is held disabled until the config is migrated`
|
||||
: `${rule.Enabled ? 'Disable' : 'Enable'} rule ${rule.Name}`
|
||||
}
|
||||
disabled={frozen || unmigrated}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1012,9 +1101,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 +1217,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 +1238,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 +1383,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 +1423,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 +1480,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 +1494,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 +1518,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 +1533,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 +1563,6 @@ function RuleEditForm({
|
||||
...initial,
|
||||
Name: nm,
|
||||
Src: src,
|
||||
DstDomain: csv(domain),
|
||||
DstIP: csv(ip),
|
||||
DstPort: port.trim(),
|
||||
DstRuleset: rulesets,
|
||||
Proto: proto,
|
||||
@@ -1491,7 +1581,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 +1619,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
|
||||
|
||||
+32
-16
@@ -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,
|
||||
@@ -161,18 +161,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).`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,6 +457,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)
|
||||
|
||||
@@ -787,13 +788,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 +826,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 +864,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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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 { 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',
|
||||
)
|
||||
})
|
||||
+104
-7
@@ -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
|
||||
@@ -31,6 +31,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 +74,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',
|
||||
@@ -112,3 +125,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"]
|
||||
}
|
||||
|
||||
+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"
|
||||
@@ -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
|
||||
@@ -514,6 +527,12 @@ func (a *Applier) applyLocked(m *model.Model) (bool, error) {
|
||||
}
|
||||
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.
|
||||
@@ -557,6 +576,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,6 +664,22 @@ 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
|
||||
@@ -723,6 +763,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
|
||||
@@ -1003,6 +1044,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.
|
||||
@@ -1082,6 +1137,7 @@ func (a *Applier) Status() Status {
|
||||
Hash: a.eng.Hash(),
|
||||
CanRollback: a.canRollback(),
|
||||
EngineRunning: a.eng.Running(),
|
||||
Traffic: a.Traffic(),
|
||||
Warnings: a.Warnings(),
|
||||
}
|
||||
s.StartedUnix, s.UptimeSeconds = processUptime(time.Now())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,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 +31,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 +162,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 +186,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 +207,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
|
||||
@@ -198,7 +316,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 +360,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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -58,11 +68,12 @@ func renderPlan(t *testing.T, m *model.Model, plan *netplane.UntunnelablePlan) s
|
||||
// only 8.8.8.8 may be un-pingable and the rest of the internet must answer.
|
||||
func TestOnlyPinnedAddressIsTunnelled(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, 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)
|
||||
@@ -221,14 +232,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 := 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)
|
||||
}
|
||||
@@ -361,11 +506,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.
|
||||
@@ -424,12 +570,13 @@ func TestLocalPlaneSurvivesEveryPlan(t *testing.T) {
|
||||
// only grant those clients, not everyone.
|
||||
func TestSourceScopedRuleNarrowsTheAllow(t *testing.T) {
|
||||
m := 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)
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_gvisor
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_gvisor build tag — see buildtags.go. There is no !with_gvisor twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_gvisor") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_lx_command
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_lx_command build tag — see buildtags.go. There is no !with_lx_command twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_lx_command") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_quic
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_quic build tag — see buildtags.go. There is no !with_quic twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_quic") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_utls
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_utls build tag — see buildtags.go. There is no !with_utls twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_utls") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_wireguard
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_wireguard build tag — see buildtags.go. There is no !with_wireguard twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_wireguard") }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build with_xhttp
|
||||
|
||||
package buildtags
|
||||
|
||||
// Detector for the with_xhttp build tag — see buildtags.go. There is no !with_xhttp twin:
|
||||
// an absent detector means "not compiled in", which is exactly the truth.
|
||||
func init() { mark("with_xhttp") }
|
||||
@@ -68,6 +68,12 @@ type nodeView struct {
|
||||
// ParseError is the reason the engine will SKIP this node, verbatim from
|
||||
// parse.ParseShareLink. Empty on every usable node.
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
// ParseWarnings lists what the share link asked for that the engine cannot
|
||||
// do (hysteria2 port hopping, tuic congestion control, …). The node IS
|
||||
// usable — that is the difference from parse_error — but it does not behave
|
||||
// exactly as its link describes, and that gap belongs on screen rather than
|
||||
// in a code comment.
|
||||
ParseWarnings []string `json:"parse_warnings,omitempty"`
|
||||
}
|
||||
|
||||
// readModel is the model source. A package var so the tests can exercise the
|
||||
@@ -111,6 +117,7 @@ func nodeViews(m *model.Model) []nodeView {
|
||||
}
|
||||
v.Server = p.Server
|
||||
v.Port = p.Port
|
||||
v.ParseWarnings = p.Warnings
|
||||
} else if n.URI != "" {
|
||||
v.ParseError = err.Error()
|
||||
}
|
||||
|
||||
@@ -610,44 +610,60 @@ func splitDomainMarker(e string) (marker, value string, ok bool) {
|
||||
// reader can tell a deliberate difference from an oversight (R9.3). Verified
|
||||
// against the code on both sides, not against upstream docs — this is a fork.
|
||||
//
|
||||
// marker domain lists (this file) routing rules (ruleMatchers, route.go)
|
||||
// There are now only TWO contexts, not three. A routing rule no longer classifies
|
||||
// domains at all: `dst_domain` was removed in schema v2 and a rule names its
|
||||
// destination through a `config ruleset`, so every domain entry in the system —
|
||||
// filter list, device list, inline rule-set — arrives at classifyDomainEntries
|
||||
// below. The one caller that adds something on top is inlineRulesetRule
|
||||
// (ruleset.go), which peels `regexp:` off first.
|
||||
//
|
||||
// marker DNS filter / device lists inline rule-sets (inlineRulesetRule)
|
||||
// ---------- ---------------------------- --------------------------------------
|
||||
// full: yes — exact domain yes — exact domain
|
||||
// suffix: yes — domain + subdomains yes — domain + subdomains
|
||||
// .example SAME AS suffix: (dot stripped) SAME AS suffix: (dot stripped)
|
||||
// keyword: yes — substring yes — substring
|
||||
// (bare) SUFFIX in filter/device/ EXACT domain
|
||||
// inline-ruleset lists
|
||||
// (bare) SUFFIX (domain + subdomains) SUFFIX (domain + subdomains)
|
||||
// regexp: NO — warns yes — DomainRegex (pattern validated)
|
||||
// geosite: NO — warns NO — warns, rule matcher omitted
|
||||
// geosite: NO — warns NO — warns, entry dropped
|
||||
//
|
||||
// Two corrections this table used to get wrong, both of the "claimed a behaviour
|
||||
// that does not exist" kind:
|
||||
// A THIRD context exists and has NO row here on purpose: the body of a
|
||||
// `source=url` plain-text list. That is a hosts/one-domain-per-line FILE parsed by
|
||||
// parseDomainList (ruleset.go), not a list of typed entries, and it understands no
|
||||
// marker at all — every line is a domain plus its subdomains. An entry written in
|
||||
// the vocabulary above is dropped there (a domain cannot contain ":") and reported
|
||||
// per list by warnListEntryVocabulary. The long block above parseDomainList states
|
||||
// why unifying the two was rejected; do not read this table as covering it.
|
||||
//
|
||||
// - `.example.com` was documented as "subdomains only" on both sides. It is not:
|
||||
// The bare-entry row is now the SAME on both sides, and that uniformity is the
|
||||
// point of schema v2 — a routing rule used to read a bare entry as an EXACT host
|
||||
// while every list read it as a suffix, a difference nothing in the UI showed.
|
||||
// model.migrate1to2 is what preserves the old meaning of existing configs: it
|
||||
// rewrites a rule's bare `dst_domain` entry as `full:` when it moves it into the
|
||||
// generated rule-set.
|
||||
//
|
||||
// Corrections this table used to get wrong, all of the "claimed a behaviour that
|
||||
// does not exist" kind:
|
||||
//
|
||||
// - `.example.com` was documented as "subdomains only". It is not:
|
||||
// classifyDomainEntries strips the dot, so it is a synonym of `suffix:` and
|
||||
// matches the apex too. See the leading-dot branch above for why the synonym
|
||||
// is kept rather than the distinction implemented.
|
||||
// - `geosite:` was documented as a working routing matcher. It is not: the
|
||||
// route-rule geosite field was REMOVED from this engine, so route.go warns and
|
||||
// omits the matcher (a rule left with no other matcher is skipped entirely).
|
||||
// Use a `config ruleset` with source=geosite.
|
||||
// route-rule geosite field was REMOVED from this engine. Use a `config
|
||||
// ruleset` with source=geosite and category chips.
|
||||
//
|
||||
// The two absences in the domain-list column are DELIBERATE, not gaps:
|
||||
// The two absences in the FILTER column are DELIBERATE, not gaps:
|
||||
//
|
||||
// - regexp: an invalid regular expression is only detected when the rule is
|
||||
// built, where it aborts box.New and takes the whole config down — the exact
|
||||
// fail-open violation this audit spent its time removing. Supporting it here
|
||||
// would require compiling and validating every pattern at generate time.
|
||||
// Warning is the honest answer until that is done.
|
||||
// - geosite: filter lists and rulesets already express geosite properly, via
|
||||
// - regexp: an invalid regular expression aborts box.New and takes the whole
|
||||
// config down, so it may only be accepted where every pattern is compiled and
|
||||
// validated at generate time. Inline rule-sets do exactly that
|
||||
// (peelDomainRegexes, ruleset.go), which is why the right-hand column says
|
||||
// yes; the DNS-filter path has no such validation and warns instead.
|
||||
// - geosite: filter lists and rule-sets already express geosite properly, via
|
||||
// `source=geosite` plus category chips, which fetches the official compiled
|
||||
// .srs. A `geosite:` entry inside an inline list would be a second, weaker
|
||||
// path to the same feature.
|
||||
//
|
||||
// The BARE-entry difference is also deliberate and long-standing: a blocklist
|
||||
// entry is meant to cover subdomains, while a routing rule's bare entry is an
|
||||
// exact host. Both are documented at their call sites.
|
||||
|
||||
// toASCIIDomain punycodes a unicode domain entry so it can match the punycoded
|
||||
// names that actually arrive in DNS queries. Lenient by design: an entry idna
|
||||
|
||||
@@ -289,8 +289,9 @@ func TestFailoverWarnsOnceAcrossChainCopy(t *testing.T) {
|
||||
m := twoNodeGroupModel("failover")
|
||||
m.Nodes = append(m.Nodes, model.Node{Name: "hop", Enabled: true, URI: ss("203.0.113.9")})
|
||||
m.Chains = []model.Chain{{Name: "ch", Hops: []string{"node:hop", "group:g"}}}
|
||||
m.Rulesets = []model.Ruleset{inlineDomainSet("ex", "example.com")}
|
||||
m.Rules = []model.Rule{
|
||||
{Name: "r", Enabled: true, DstDomain: []string{"example.com"}, Target: "chain:ch"},
|
||||
{Name: "r", Enabled: true, DstRuleset: []string{"ex"}, Target: "chain:ch"},
|
||||
}
|
||||
_, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
|
||||
@@ -506,16 +506,21 @@ func validPortRange(s string) bool {
|
||||
// the classifier actually drops.
|
||||
//
|
||||
// `suffix:` belongs here. The list used to omit it on the theory that suffix:/
|
||||
// regexp: are route-rule-only and are peeled off by ruleMatchers before the shared
|
||||
// classifier sees them. That is true of route.go — which handles a bare `suffix:`
|
||||
// in its own switch and warns there, so this list cannot double-report — but NOT
|
||||
// of the classifier itself: classifyDomainEntries has a `case "suffix"`, and every
|
||||
// other caller (devices.go, dnsfilter.go) reaches it directly. A lone `suffix:`
|
||||
// arriving that way was dropped by add()'s empty-value guard and reported by
|
||||
// nobody, which is the one outcome this pair of functions exists to prevent.
|
||||
// regexp: were route-rule-only and were peeled off before the shared classifier
|
||||
// saw them. classifyDomainEntries has a `case "suffix"`, and every caller
|
||||
// (devices.go, dnsfilter.go, inlineRulesetRule) reaches it directly, so a lone
|
||||
// `suffix:` was dropped by add()'s empty-value guard and reported by nobody —
|
||||
// the one outcome this pair of functions exists to prevent. (The route-rule half
|
||||
// of that old reasoning is gone entirely: `dst_domain` was removed in schema v2,
|
||||
// so route.go classifies no domains at all and there is no second warner to
|
||||
// double-report with.)
|
||||
//
|
||||
// `regexp:` is correctly absent: the classifier has no case for it, so a bare
|
||||
// `regexp:` is an UNRECOGNISED prefix and is reported by unrecognisedDomainPrefix.
|
||||
// `regexp:` is correctly absent, for two different reasons depending on the
|
||||
// caller. In a filter/device list the classifier has no case for it, so a bare
|
||||
// `regexp:` is an UNRECOGNISED prefix reported by unrecognisedDomainPrefix. In an
|
||||
// inline rule-set peelDomainRegexes (ruleset.go) strips every `regexp:` entry
|
||||
// BEFORE this predicate runs and reports a valueless one itself, so it can never
|
||||
// reach here either way.
|
||||
var domainMarkers = []string{"keyword:", "full:", "suffix:", "."}
|
||||
|
||||
// isDomainMarkerOnly reports whether an entry is a bare classification marker
|
||||
|
||||
@@ -227,6 +227,12 @@ func TestAllReachableProtocols(t *testing.T) {
|
||||
{Name: "vmess-ws", Enabled: true, URI: vmessLink},
|
||||
{Name: "trojan1", Enabled: true, URI: "trojan://password@example.com:443?sni=example.com#trojan1"},
|
||||
{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"},
|
||||
// QUIC protocols travel the same road: share-link -> parse.Proxy ->
|
||||
// outbound -> box.New. Before parse grew these two branches the links
|
||||
// were dropped at the parser and the outbounds below never existed.
|
||||
{Name: "hy2-1", Enabled: true, URI: "hysteria2://secret@example.com:8443/?sni=example.com&alpn=h3#hy2-1"},
|
||||
{Name: "hy2-alias", Enabled: true, URI: "hy2://secret@example.org#hy2-alias"},
|
||||
{Name: "tuic1", Enabled: true, URI: "tuic://22222222-2222-2222-2222-222222222222:secret@example.com:443/?sni=example.com&alpn=h3&congestion_control=cubic&udp_relay_mode=native#tuic1"},
|
||||
},
|
||||
}
|
||||
opts, warns, changed := applyAndClose(t, m)
|
||||
@@ -236,16 +242,34 @@ func TestAllReachableProtocols(t *testing.T) {
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
for _, tag := range []string{"vless-ws", "vless-grpc", "vmess-ws", "trojan1", "ss1"} {
|
||||
for _, tag := range []string{"vless-ws", "vless-grpc", "vmess-ws", "trojan1", "ss1", "hy2-1", "hy2-alias", "tuic1"} {
|
||||
if findOutbound(opts, tag) == nil {
|
||||
t.Fatalf("outbound %q not emitted", tag)
|
||||
}
|
||||
}
|
||||
// The hy2 alias link carries no port and no sni: parse must have supplied the
|
||||
// scheme default (443) and the server name, or the node would dial nowhere.
|
||||
if ob := findOutbound(opts, "hy2-alias"); ob != nil {
|
||||
o, ok := ob.Options.(*option.Hysteria2OutboundOptions)
|
||||
if !ok {
|
||||
t.Fatalf("hy2-alias options type = %T", ob.Options)
|
||||
}
|
||||
if o.ServerPort != 443 {
|
||||
t.Fatalf("hy2-alias port = %d, want the scheme default 443", o.ServerPort)
|
||||
}
|
||||
if o.TLS == nil || o.TLS.ServerName != "example.org" {
|
||||
t.Fatalf("hy2-alias tls = %+v", o.TLS)
|
||||
}
|
||||
if o.TLS.UTLS != nil {
|
||||
t.Fatalf("uTLS must never reach a QUIC outbound: %+v", o.TLS.UTLS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- White-box: hysteria2 / tuic / shadowtls option mapping validates. -------
|
||||
// These protocols are not yet produced by parse.ParseShareLink, so we drive the
|
||||
// mapping directly with synthetic parse.Proxy values and validate via box.New.
|
||||
// shadowtls is not produced by parse.ParseShareLink (hysteria2 and tuic now
|
||||
// are, see TestAllReachableProtocols), so the mapping is driven directly with
|
||||
// synthetic parse.Proxy values and validated via box.New.
|
||||
|
||||
func TestQUICAndShadowTLSMappingValidates(t *testing.T) {
|
||||
b := newBuilder(&model.Model{Globals: model.DefaultGlobals()})
|
||||
@@ -323,8 +347,9 @@ func TestEgressDPISpoofValidates(t *testing.T) {
|
||||
Globals: model.DefaultGlobals(),
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12366, TCP: true, UDP: true}},
|
||||
Egresses: []model.Egress{{Name: "spf", Type: "direct", DPI: "spoof"}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("blocked", "blocked.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "spoof-rule", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "egress:spf"},
|
||||
{Name: "spoof-rule", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "egress:spf"},
|
||||
},
|
||||
}
|
||||
opts, warns, changed := applyAndClose(t, m)
|
||||
@@ -346,8 +371,9 @@ func TestByedpiEgressValidates(t *testing.T) {
|
||||
Globals: model.DefaultGlobals(),
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12367, TCP: true, UDP: true}},
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("blocked", "blocked.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "desync", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "egress:bd"},
|
||||
{Name: "desync", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "egress:bd"},
|
||||
},
|
||||
}
|
||||
opts, warns, changed := applyAndClose(t, m)
|
||||
|
||||
@@ -21,9 +21,10 @@ func TestProfileAppliesCleanly(t *testing.T) {
|
||||
Globals: g,
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12370, TCP: true, UDP: true}},
|
||||
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("ads", "ads.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "lan-proxy", Enabled: true, Order: 10, Src: []string{"192.168.1.0/24"}, Target: "node:ss1"},
|
||||
{Name: "adblock", Enabled: true, Order: 20, DstDomain: []string{"ads.example"}, Target: "block"},
|
||||
{Name: "adblock", Enabled: true, Order: 20, DstRuleset: []string{"ads"}, Target: "block"},
|
||||
},
|
||||
Profiles: []model.Profile{
|
||||
{Name: "home", Enabled: true, Priority: 1, DisableRules: []string{"adblock"}},
|
||||
@@ -35,7 +36,7 @@ func TestProfileAppliesCleanly(t *testing.T) {
|
||||
t.Fatalf("expected Apply changed==true (warnings: %v)", warns)
|
||||
}
|
||||
// Profile-disabled 'adblock' rule must be absent.
|
||||
if opts.Route == nil || hasDomainRule(opts.Route, "ads.example") {
|
||||
if opts.Route == nil || hasRulesetRule(opts.Route, "ads") {
|
||||
t.Fatalf("profile-disabled rule 'adblock' should not be emitted")
|
||||
}
|
||||
// The lan-proxy rule still routes to ss1.
|
||||
|
||||
@@ -49,9 +49,13 @@ func TestNoProfilesUnchanged(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(), // kill-switch closed => Final "block"
|
||||
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("a", "a.example"),
|
||||
inlineDomainSet("b", "b.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "a", Enabled: true, Order: 10, DstDomain: []string{"a.example"}, Target: "direct"},
|
||||
{Name: "b", Enabled: true, Order: 20, DstDomain: []string{"b.example"}, Target: "node:ss1"},
|
||||
{Name: "a", Enabled: true, Order: 10, DstRuleset: []string{"a"}, Target: "direct"},
|
||||
{Name: "b", Enabled: true, Order: 20, DstRuleset: []string{"b"}, Target: "node:ss1"},
|
||||
},
|
||||
}
|
||||
rt, b := buildRouteAt(m, wed12UTC)
|
||||
@@ -70,7 +74,7 @@ func TestNoProfilesUnchanged(t *testing.T) {
|
||||
if len(rt.Rules) != 4 {
|
||||
t.Fatalf("route rule count = %d, want 4 (sniff+hijack+2 user)", len(rt.Rules))
|
||||
}
|
||||
if !hasDomainRule(rt, "a.example") || !hasDomainRule(rt, "b.example") {
|
||||
if !hasRulesetRule(rt, "a") || !hasRulesetRule(rt, "b") {
|
||||
t.Fatalf("both user rules should survive unchanged")
|
||||
}
|
||||
}
|
||||
@@ -83,9 +87,13 @@ func TestActiveProfileDisablesRule(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: g,
|
||||
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("blocked", "blocked.example"),
|
||||
inlineDomainSet("keep", "keep.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "blockme", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "block"},
|
||||
{Name: "keep", Enabled: true, Order: 20, DstDomain: []string{"keep.example"}, Target: "direct"},
|
||||
{Name: "blockme", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "block"},
|
||||
{Name: "keep", Enabled: true, Order: 20, DstRuleset: []string{"keep"}, Target: "direct"},
|
||||
},
|
||||
Profiles: []model.Profile{
|
||||
// Manual pin: honored regardless of conditions. Disables "blockme".
|
||||
@@ -94,10 +102,10 @@ func TestActiveProfileDisablesRule(t *testing.T) {
|
||||
}
|
||||
rt, b := buildRouteAt(m, wed12UTC)
|
||||
|
||||
if hasDomainRule(rt, "blocked.example") {
|
||||
if hasRulesetRule(rt, "blocked") {
|
||||
t.Fatalf("profile disabled rule 'blockme' but its matcher is still emitted")
|
||||
}
|
||||
if !hasDomainRule(rt, "keep.example") {
|
||||
if !hasRulesetRule(rt, "keep") {
|
||||
t.Fatalf("rule 'keep' should be untouched")
|
||||
}
|
||||
if rt.Final != tagBlock {
|
||||
@@ -115,9 +123,13 @@ func TestActiveProfileDisablesRule(t *testing.T) {
|
||||
func TestAutoSelectHighestPriority(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("lo", "lo.example"),
|
||||
inlineDomainSet("hi", "hi.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "rLo", Enabled: true, Order: 10, DstDomain: []string{"lo.example"}, Target: "direct"},
|
||||
{Name: "rHi", Enabled: true, Order: 20, DstDomain: []string{"hi.example"}, Target: "direct"},
|
||||
{Name: "rLo", Enabled: true, Order: 10, DstRuleset: []string{"lo"}, Target: "direct"},
|
||||
{Name: "rHi", Enabled: true, Order: 20, DstRuleset: []string{"hi"}, Target: "direct"},
|
||||
},
|
||||
Profiles: []model.Profile{
|
||||
{Name: "lo", Enabled: true, Priority: 5, DisableRules: []string{"rLo"}},
|
||||
@@ -125,10 +137,10 @@ func TestAutoSelectHighestPriority(t *testing.T) {
|
||||
},
|
||||
}
|
||||
rt, _ := buildRouteAt(m, wed12UTC)
|
||||
if hasDomainRule(rt, "hi.example") {
|
||||
if hasRulesetRule(rt, "hi") {
|
||||
t.Fatalf("the highest-priority profile must win and disable rHi")
|
||||
}
|
||||
if !hasDomainRule(rt, "lo.example") {
|
||||
if !hasRulesetRule(rt, "lo") {
|
||||
t.Fatalf("only the winning profile applies (rLo should survive)")
|
||||
}
|
||||
}
|
||||
@@ -142,9 +154,13 @@ func TestIfaceProfileSkippedByAutoButHonoredNamed(t *testing.T) {
|
||||
g.ActiveProfile = active
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("hi", "hi.example"),
|
||||
inlineDomainSet("if", "if.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "rHi", Enabled: true, Order: 10, DstDomain: []string{"hi.example"}, Target: "direct"},
|
||||
{Name: "rIf", Enabled: true, Order: 20, DstDomain: []string{"if.example"}, Target: "direct"},
|
||||
{Name: "rHi", Enabled: true, Order: 10, DstRuleset: []string{"hi"}, Target: "direct"},
|
||||
{Name: "rIf", Enabled: true, Order: 20, DstRuleset: []string{"if"}, Target: "direct"},
|
||||
},
|
||||
Profiles: []model.Profile{
|
||||
{Name: "plain", Enabled: true, Priority: 10, DisableRules: []string{"rHi"}},
|
||||
@@ -156,19 +172,19 @@ func TestIfaceProfileSkippedByAutoButHonoredNamed(t *testing.T) {
|
||||
|
||||
// Auto-select (no pin): iface profile skipped (the watcher owns it); 'plain' wins.
|
||||
rt, _ := buildRouteAt(mk(""), wed12UTC)
|
||||
if hasDomainRule(rt, "hi.example") {
|
||||
if hasRulesetRule(rt, "hi") {
|
||||
t.Fatalf("auto-select should apply 'plain' and disable rHi")
|
||||
}
|
||||
if !hasDomainRule(rt, "if.example") {
|
||||
if !hasRulesetRule(rt, "if") {
|
||||
t.Fatalf("iface profile 'wwan' must be skipped by auto-select (rIf should survive)")
|
||||
}
|
||||
|
||||
// Named explicitly: iface profile honored regardless of its iface condition.
|
||||
rt, _ = buildRouteAt(mk("wwan"), wed12UTC)
|
||||
if hasDomainRule(rt, "if.example") {
|
||||
if hasRulesetRule(rt, "if") {
|
||||
t.Fatalf("explicitly named iface profile must be honored and disable rIf")
|
||||
}
|
||||
if !hasDomainRule(rt, "hi.example") {
|
||||
if !hasRulesetRule(rt, "hi") {
|
||||
t.Fatalf("only the named profile applies; rHi should survive")
|
||||
}
|
||||
}
|
||||
@@ -179,14 +195,15 @@ func TestUnknownActiveProfileFallsBackToAuto(t *testing.T) {
|
||||
g := model.DefaultGlobals()
|
||||
g.ActiveProfile = "ghost"
|
||||
m := &model.Model{
|
||||
Globals: g,
|
||||
Rules: []model.Rule{{Name: "rX", Enabled: true, Order: 10, DstDomain: []string{"x.example"}, Target: "direct"}},
|
||||
Globals: g,
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("x", "x.example")},
|
||||
Rules: []model.Rule{{Name: "rX", Enabled: true, Order: 10, DstRuleset: []string{"x"}, Target: "direct"}},
|
||||
Profiles: []model.Profile{
|
||||
{Name: "auto", Enabled: true, Priority: 1, DisableRules: []string{"rX"}},
|
||||
},
|
||||
}
|
||||
rt, b := buildRouteAt(m, wed12UTC)
|
||||
if hasDomainRule(rt, "x.example") {
|
||||
if hasRulesetRule(rt, "x") {
|
||||
t.Fatalf("fallback auto-select should apply 'auto' and disable rX")
|
||||
}
|
||||
if !hasWarning(b, "falling back to auto-select") {
|
||||
@@ -208,16 +225,17 @@ func TestUnknownActiveProfileFallsBackToAuto(t *testing.T) {
|
||||
// suppress it or to warn about it.
|
||||
func TestPlainProfileIsSelectableAfterProbeRemoval(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("hi", "hi.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "rHi", Enabled: true, Order: 10, DstDomain: []string{"hi.example"}, Target: "direct"},
|
||||
{Name: "rHi", Enabled: true, Order: 10, DstRuleset: []string{"hi"}, Target: "direct"},
|
||||
},
|
||||
Profiles: []model.Profile{
|
||||
{Name: "plain", Enabled: true, Priority: 10, DisableRules: []string{"rHi"}},
|
||||
},
|
||||
}
|
||||
rt, b := buildRouteAt(m, wed12UTC)
|
||||
if hasDomainRule(rt, "hi.example") {
|
||||
if hasRulesetRule(rt, "hi") {
|
||||
t.Fatalf("a plain profile must apply and disable rHi")
|
||||
}
|
||||
// No leftover diagnostic: warning about an option the parser no longer reads
|
||||
|
||||
+8
-110
@@ -1,8 +1,6 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -321,114 +319,14 @@ func (b *builder) ruleMatchers(r model.Rule) (raw option.RawDefaultRule, matched
|
||||
matched = true
|
||||
}
|
||||
|
||||
// Destination domains. The route-rule-specific prefixes (geosite:/regexp:/
|
||||
// suffix:) are peeled off here; everything else goes through the shared
|
||||
// classifier in dnsfilter.go with bareIsSuffix=FALSE — a bare entry in a
|
||||
// ROUTING rule is an exact domain, unlike the DNS/filter lists where it means
|
||||
// "and all subdomains". classifyDomainEntries is also what drops marker-only
|
||||
// entries ("." / "full:" / "keyword:"), which must never reach the engine:
|
||||
// an empty domain/domain_suffix aborts box.New, and an empty domain_keyword
|
||||
// is strings.Contains(host, "") — a silent match on EVERY host.
|
||||
var explicitSuffix []string
|
||||
var plain []string
|
||||
for _, d := range r.DstDomain {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(d, "geosite:"):
|
||||
// A `geosite:` matcher is NOT emittable: the route-rule geosite field was
|
||||
// removed in this engine and route/rule.NewDefaultRule hard-errors on it,
|
||||
// which aborts box.New for the WHOLE config. Mirroring the geoip handling
|
||||
// below, it is treated as inert: warned and omitted, so a legacy/UCI rule
|
||||
// carrying one degrades to "this rule does nothing" instead of taking the
|
||||
// entire tunnel down. Use a `config ruleset` with source=geosite instead.
|
||||
b.warnf("rule %q: geosite matcher %q is removed from this engine — use a ruleset with source=geosite instead, omitted (inert)", r.Name, d)
|
||||
case strings.HasPrefix(d, "regexp:"):
|
||||
// route/rule.NewDomainRegexItem hard-errors on an uncompilable pattern and
|
||||
// takes box.New with it; validate here and drop the bad one with a warning.
|
||||
// A BARE "regexp:" compiles fine but matches every host — same silent
|
||||
// match-all hazard as an empty keyword, so it is dropped too.
|
||||
re := strings.TrimSpace(strings.TrimPrefix(d, "regexp:"))
|
||||
if re == "" {
|
||||
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty regexp matches EVERY host)", r.Name, d)
|
||||
break
|
||||
}
|
||||
if _, err := regexp.Compile(re); err != nil {
|
||||
b.warnf("rule %q: domain regexp %q is invalid (%v), omitted", r.Name, re, err)
|
||||
break
|
||||
}
|
||||
raw.DomainRegex = append(raw.DomainRegex, re)
|
||||
matched = true
|
||||
case strings.HasPrefix(d, "suffix:"):
|
||||
// Label-aware suffix (apex + subdomains): sing-box domain_suffix stored
|
||||
// in bare form matches both "example.com" and "*.example.com" (see
|
||||
// sing/common/domain matcher). A leading-dot entry, by contrast, matches
|
||||
// subdomains ONLY, so the explicit `suffix:` form is how presets/rules
|
||||
// ask for apex-inclusive suffix matching.
|
||||
if s := strings.TrimSpace(strings.TrimPrefix(d, "suffix:")); s != "" {
|
||||
explicitSuffix = append(explicitSuffix, s)
|
||||
} else {
|
||||
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New)", r.Name, d)
|
||||
}
|
||||
default:
|
||||
plain = append(plain, d)
|
||||
}
|
||||
}
|
||||
domain, suffix, keyword := classifyDomainEntries(plain, false)
|
||||
for _, d := range plain {
|
||||
if isDomainMarkerOnly(d) {
|
||||
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New; an empty keyword matches EVERY host)", r.Name, d)
|
||||
}
|
||||
}
|
||||
// An unrecognised `word:` prefix is DROPPED by classifyDomainEntries (a domain
|
||||
// cannot contain ":", so keeping it would load a provably unmatchable literal).
|
||||
// It has to be reported here or the rule silently loses that destination — the
|
||||
// v0.1/xray spelling `domain:example.com` is exactly what someone migrating
|
||||
// writes, and it used to disappear without a trace. geosite:/regexp:/suffix:
|
||||
// were already peeled off above, so `plain` carries only the shared markers.
|
||||
b.warnUnrecognisedPrefixes(fmt.Sprintf("rule %q", r.Name), plain)
|
||||
suffix = append(explicitSuffix, suffix...)
|
||||
if len(domain) > 0 {
|
||||
raw.Domain = badoption.Listable[string](domain)
|
||||
matched = true
|
||||
}
|
||||
if len(suffix) > 0 {
|
||||
raw.DomainSuffix = badoption.Listable[string](suffix)
|
||||
matched = true
|
||||
}
|
||||
if len(keyword) > 0 {
|
||||
raw.DomainKeyword = badoption.Listable[string](keyword)
|
||||
matched = true
|
||||
}
|
||||
// Destination IPs. A `geoip:<code>` entry is NOT a CIDR: route-rule geoip was
|
||||
// removed in this engine (box.New hard-errors on it), so — mirroring how the
|
||||
// ruleset geoip source is handled — it is treated as inert: warned and omitted
|
||||
// (never emitted as an ip_cidr, which would also abort box.New). This keeps a
|
||||
// geoip-driven rule/preset (e.g. the ru-bypass pack) fail-open instead of fatal.
|
||||
var ipcidr []string
|
||||
for _, ip := range r.DstIP {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(ip), "geoip:") {
|
||||
b.warnf("rule %q: geoip matcher %q is removed from this engine — use a ruleset with source=geoip instead, omitted (inert)", r.Name, ip)
|
||||
continue
|
||||
}
|
||||
ipcidr = append(ipcidr, ip)
|
||||
}
|
||||
// Same fail-open validation as the source list: an unparseable ip_cidr aborts
|
||||
// box.New for the whole config, so drop it with a warning instead.
|
||||
ipcidr, badIP := validPrefixes(ipcidr)
|
||||
for _, s := range badIP {
|
||||
b.warnf("rule %q: destination %q is not a valid IP/CIDR, omitted", r.Name, s)
|
||||
}
|
||||
if len(ipcidr) > 0 {
|
||||
raw.IPCIDR = badoption.Listable[string](ipcidr)
|
||||
matched = true
|
||||
}
|
||||
// Destination: a rule's ONLY destination matcher is DstRuleset (schema v2).
|
||||
// The inline `dst_domain` / `dst_ip` lists that used to be classified here are
|
||||
// gone. A destination list is a `config ruleset` — compiled once into a .srs and
|
||||
// shared by every rule that references it — so the prefix vocabulary
|
||||
// (full:/suffix:/keyword:/regexp:, a leading dot) and the geosite/geoip sources
|
||||
// live in exactly one place (generate/ruleset.go inlineRulesetRule). Existing
|
||||
// configs were rewritten by model.migrate1to2, which preserves each entry's
|
||||
// meaning 1:1.
|
||||
|
||||
// DstRuleset -> reference the rs-<name> rule-sets materialised by
|
||||
// buildRoutingRuleSets. A dst_ruleset naming an UNDEFINED ruleset is warned +
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
@@ -22,26 +23,45 @@ func killModel(kill string) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "closed"
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Nodes: []model.Node{{Name: "n1", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
||||
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"n1"}}},
|
||||
Globals: g,
|
||||
Nodes: []model.Node{{Name: "n1", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
||||
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"n1"}}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("social", "social.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "social", Enabled: true, Order: 10, DstDomain: []string{"social.example"}, Target: "group:grp", Kill: kill},
|
||||
{Name: "social", Enabled: true, Order: 10, DstRuleset: []string{"social"}, Target: "group:grp", Kill: kill},
|
||||
{Name: "catch-tcp", Enabled: true, Order: 20, Proto: "tcp", Target: "direct"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// domainRuleTarget returns the outbound the rule matching domain routes to.
|
||||
func domainRuleTarget(rt *option.RouteOptions, domain string) (string, bool) {
|
||||
for _, r := range generalRules(rt) {
|
||||
for _, d := range r.DefaultOptions.RawDefaultRule.Domain {
|
||||
if d == domain {
|
||||
return r.DefaultOptions.RuleAction.RouteOptions.Outbound, true
|
||||
}
|
||||
}
|
||||
// rulesetRuleTarget returns the outbound the rule referencing the rule-set named
|
||||
// name routes to. It is the post-schema-v2 replacement for looking a rule up by
|
||||
// its dst-domain matcher: a rule's destination is a rule_set reference now, so
|
||||
// the matcher that identifies it is the rs-<name> tag.
|
||||
func rulesetRuleTarget(rt *option.RouteOptions, name string) (string, bool) {
|
||||
dr := findRouteRuleWithRuleSet(rt, routeRulesetTagPrefix+name)
|
||||
if dr == nil {
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
return dr.RuleAction.RouteOptions.Outbound, true
|
||||
}
|
||||
|
||||
// soleInlineRule returns the single default headless rule an inline rule-set is
|
||||
// expected to carry, failing the ASSERTION (rather than panicking on an index) when
|
||||
// the set turns out to be remote/local or to hold a different rule shape.
|
||||
func soleInlineRule(t *testing.T, rs option.RuleSet) option.DefaultHeadlessRule {
|
||||
t.Helper()
|
||||
if rs.Type != C.RuleSetTypeInline && rs.Type != "" {
|
||||
t.Fatalf("rule-set %q is type %q, not inline — it has no rules in the config to inspect", rs.Tag, rs.Type)
|
||||
}
|
||||
if len(rs.InlineOptions.Rules) != 1 {
|
||||
t.Fatalf("rule-set %q must carry exactly one headless rule, got %d", rs.Tag, len(rs.InlineOptions.Rules))
|
||||
}
|
||||
hr := rs.InlineOptions.Rules[0]
|
||||
if hr.Type != C.RuleTypeDefault && hr.Type != "" {
|
||||
t.Fatalf("rule-set %q carries a %q headless rule, not a default one", rs.Tag, hr.Type)
|
||||
}
|
||||
return hr.DefaultOptions
|
||||
}
|
||||
|
||||
// TestRuleKillDefaultBlocks: kill="" / "default" is fail-closed — the rule is
|
||||
@@ -54,7 +74,7 @@ func TestRuleKillDefaultBlocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("%q: Generate: %v", kill, err)
|
||||
}
|
||||
got, ok := domainRuleTarget(opts.Route, "social.example")
|
||||
got, ok := rulesetRuleTarget(opts.Route, "social")
|
||||
if !ok {
|
||||
t.Fatalf("%q: rule must be emitted routing to block, but it was dropped", kill)
|
||||
}
|
||||
@@ -76,7 +96,7 @@ func TestRuleKillClosedBlocksHere(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
got, ok := domainRuleTarget(opts.Route, "social.example")
|
||||
got, ok := rulesetRuleTarget(opts.Route, "social")
|
||||
if !ok {
|
||||
t.Fatalf("kill=closed must still emit the rule (warnings %v)", warns)
|
||||
}
|
||||
@@ -103,7 +123,7 @@ func TestRuleKillOpenGoesDirect(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
got, ok := domainRuleTarget(opts.Route, "social.example")
|
||||
got, ok := rulesetRuleTarget(opts.Route, "social")
|
||||
if !ok {
|
||||
t.Fatalf("kill=open must still emit the rule (warnings %v)", warns)
|
||||
}
|
||||
@@ -122,7 +142,7 @@ func TestRuleKillUnknownBlocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
got, ok := domainRuleTarget(opts.Route, "social.example")
|
||||
got, ok := rulesetRuleTarget(opts.Route, "social")
|
||||
if !ok || got != tagBlock {
|
||||
t.Fatalf("unknown kill policy must block, got %q (ok=%v)", got, ok)
|
||||
}
|
||||
@@ -145,7 +165,7 @@ func TestRuleKillPreservesFailClosedInvariant(t *testing.T) {
|
||||
if opts.Route.Final != tagBlock {
|
||||
t.Fatalf("%q: Final = %q, want block", kill, opts.Route.Final)
|
||||
}
|
||||
if tgt, ok := domainRuleTarget(opts.Route, "social.example"); ok && tgt == tagDirect {
|
||||
if tgt, ok := rulesetRuleTarget(opts.Route, "social"); ok && tgt == tagDirect {
|
||||
t.Fatalf("%q: dead group leaked direct", kill)
|
||||
}
|
||||
}
|
||||
@@ -158,17 +178,18 @@ func TestRuleKillOnlyAppliesToUnresolvedTargets(t *testing.T) {
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "closed"
|
||||
m := &model.Model{
|
||||
Globals: g,
|
||||
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
||||
Globals: g,
|
||||
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("social", "social.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "ok", Enabled: true, Order: 10, DstDomain: []string{"social.example"}, Target: "node:n1", Kill: kill},
|
||||
{Name: "ok", Enabled: true, Order: 10, DstRuleset: []string{"social"}, Target: "node:n1", Kill: kill},
|
||||
},
|
||||
}
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: Generate: %v", kill, err)
|
||||
}
|
||||
got, ok := domainRuleTarget(opts.Route, "social.example")
|
||||
got, ok := rulesetRuleTarget(opts.Route, "social")
|
||||
if !ok || got != "n1" {
|
||||
t.Fatalf("%q: healthy target must win, got %q (ok=%v)", kill, got, ok)
|
||||
}
|
||||
@@ -176,29 +197,54 @@ func TestRuleKillOnlyAppliesToUnresolvedTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
// --- R4: marker-only domain entries ------------------------------------------
|
||||
//
|
||||
// R4 moved with the destination list itself: a rule's domains are an inline
|
||||
// `config ruleset` now (schema v2), so the marker-only guard has to hold in
|
||||
// inlineRulesetRule rather than in ruleMatchers. The hazard is unchanged.
|
||||
|
||||
// TestRuleMarkerOnlyDomainEntriesDropped: an entry that is nothing but its marker
|
||||
// must never reach the engine. An empty domain/domain_suffix makes NewDomainItem
|
||||
// return "empty item is not allowed" and aborts box.New (whole LAN down from one
|
||||
// stray "."); an empty domain_keyword is SILENT and matches every host.
|
||||
func TestRuleMarkerOnlyDomainEntriesDropped(t *testing.T) {
|
||||
// TestRulesetMarkerOnlyDomainEntriesDropped: an entry that is nothing but its
|
||||
// marker must never reach the engine. An empty domain/domain_suffix makes
|
||||
// NewDomainItem return "empty item is not allowed" and aborts box.New (whole LAN
|
||||
// down from one stray "."); an empty domain_keyword is SILENT and matches every
|
||||
// host. As the sole entry it also leaves the rule-set with nothing usable, so the
|
||||
// list is skipped and the rule referencing it is not emitted — never promoted to
|
||||
// "matches everything".
|
||||
func TestRulesetMarkerOnlyDomainEntriesDropped(t *testing.T) {
|
||||
for _, entry := range []string{".", "full:", "keyword:", "suffix:", "regexp:", " keyword: "} {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "m", Enabled: true, Order: 10,
|
||||
DstDomain: []string{entry}, Target: "node:n1",
|
||||
})
|
||||
for _, r := range rt.Rules {
|
||||
raw := r.DefaultOptions.RawDefaultRule
|
||||
for _, list := range [][]string{raw.Domain, raw.DomainSuffix, raw.DomainKeyword, raw.DomainRegex} {
|
||||
for _, v := range list {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
t.Fatalf("%q: emitted an EMPTY matcher token", entry)
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("m", entry)},
|
||||
model.Rule{Name: "m", Enabled: true, Order: 10, DstRuleset: []string{"m"}, Target: "node:n1"},
|
||||
)
|
||||
// Only an INLINE rule-set has its rules in the options at all; a remote or
|
||||
// local one (a geosite chip, a compiled url list) carries a URL or a path and
|
||||
// an empty InlineOptions. Indexing Rules[0] unconditionally turned any such
|
||||
// fixture into an index-out-of-range PANIC instead of a failed assertion, so
|
||||
// the shape is checked rather than assumed.
|
||||
for _, rs := range rt.RuleSet {
|
||||
if rs.Type != C.RuleSetTypeInline && rs.Type != "" {
|
||||
continue
|
||||
}
|
||||
for i, hrule := range rs.InlineOptions.Rules {
|
||||
if hrule.Type != C.RuleTypeDefault && hrule.Type != "" {
|
||||
continue // a logical headless rule has no matcher lists of its own
|
||||
}
|
||||
hr := hrule.DefaultOptions
|
||||
for name, list := range map[string][]string{
|
||||
"domain": hr.Domain, "domain_suffix": hr.DomainSuffix,
|
||||
"domain_keyword": hr.DomainKeyword, "domain_regex": hr.DomainRegex,
|
||||
} {
|
||||
for _, v := range list {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
t.Fatalf("%q: rule-set %q rule %d emitted an EMPTY %s token (an empty domain aborts box.New; an empty keyword matches every host)",
|
||||
entry, rs.Tag, i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sole entry => nothing left to match on => the rule must be skipped, never
|
||||
// silently promoted to "matches everything".
|
||||
if _, ok := ruleSetByTag(rt, "rs-m"); ok {
|
||||
t.Fatalf("%q: a marker-only list must not materialise a rule-set", entry)
|
||||
}
|
||||
if n := len(generalRules(rt)); n != 0 {
|
||||
t.Fatalf("%q: marker-only sole entry must skip the rule, got %d rules", entry, n)
|
||||
}
|
||||
@@ -208,46 +254,55 @@ func TestRuleMarkerOnlyDomainEntriesDropped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleMarkerOnlyBesideRealEntriesKeepsTheRest: the real entries must survive
|
||||
// the marker-only ones.
|
||||
func TestRuleMarkerOnlyBesideRealEntriesKeepsTheRest(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "m", Enabled: true, Order: 10,
|
||||
DstDomain: []string{".", "keyword:", "exact.example", "keyword:ads", ".sub.example", "suffix:apex.example"},
|
||||
Target: "node:n1",
|
||||
})
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("want 1 rule, got %d (warnings %v)", len(gen), warns)
|
||||
// TestRulesetMarkerOnlyBesideRealEntriesKeepsTheRest: the real entries must
|
||||
// survive the marker-only ones.
|
||||
func TestRulesetMarkerOnlyBesideRealEntriesKeepsTheRest(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("m",
|
||||
".", "keyword:", "full:exact.example", "keyword:ads", ".sub.example", "suffix:apex.example")},
|
||||
model.Rule{Name: "m", Enabled: true, Order: 10, DstRuleset: []string{"m"}, Target: "node:n1"},
|
||||
)
|
||||
rs, ok := ruleSetByTag(rt, "rs-m")
|
||||
if !ok {
|
||||
t.Fatalf("the usable entries must keep the list alive (warnings %v)", warns)
|
||||
}
|
||||
raw := gen[0].DefaultOptions.RawDefaultRule
|
||||
if len(raw.Domain) != 1 || raw.Domain[0] != "exact.example" {
|
||||
t.Fatalf("domain = %v, want [exact.example] (bare entry in a ROUTING rule is exact)", raw.Domain)
|
||||
hr := soleInlineRule(t, rs)
|
||||
if len(hr.Domain) != 1 || hr.Domain[0] != "exact.example" {
|
||||
t.Fatalf("domain = %v, want [exact.example]", hr.Domain)
|
||||
}
|
||||
if len(raw.DomainKeyword) != 1 || raw.DomainKeyword[0] != "ads" {
|
||||
t.Fatalf("keyword = %v, want [ads]", raw.DomainKeyword)
|
||||
if len(hr.DomainKeyword) != 1 || hr.DomainKeyword[0] != "ads" {
|
||||
t.Fatalf("keyword = %v, want [ads]", hr.DomainKeyword)
|
||||
}
|
||||
if len(raw.DomainSuffix) != 2 {
|
||||
t.Fatalf("suffix = %v, want both apex.example and sub.example", raw.DomainSuffix)
|
||||
if len(hr.DomainSuffix) != 2 {
|
||||
t.Fatalf("suffix = %v, want both apex.example and sub.example", hr.DomainSuffix)
|
||||
}
|
||||
if findRouteRuleWithRuleSet(rt, "rs-m") == nil {
|
||||
t.Fatalf("the rule must be emitted referencing rs-m; rules=%+v", rt.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleBareEntryIsExactDomain pins the routing convention (bare == exact),
|
||||
// which deliberately differs from the DNS/filter lists (bare == suffix).
|
||||
func TestRuleBareEntryIsExactDomain(t *testing.T) {
|
||||
rt, _ := genRules(t, model.Rule{
|
||||
Name: "b", Enabled: true, Order: 10,
|
||||
DstDomain: []string{"example.com"}, Target: "node:n1",
|
||||
})
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("want 1 rule, got %d", len(gen))
|
||||
// TestRulesetBareEntryIsDomainSuffix pins the convention a destination list now
|
||||
// follows — and it is the OPPOSITE of the one the old dst_domain used.
|
||||
//
|
||||
// A bare entry in a routing rule meant one EXACT domain; a bare entry in a
|
||||
// rule-set means the domain AND its subdomains (the DNS/filter/device convention,
|
||||
// classifyDomainEntries with bareIsSuffix=true). `full:` is how an exact match is
|
||||
// written now. model.migrate1to2 rewrites old dst_domain entries accordingly, so
|
||||
// this asymmetry is the thing that migration has to get right.
|
||||
func TestRulesetBareEntryIsDomainSuffix(t *testing.T) {
|
||||
rt, _ := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("b", "example.com", "full:exact.example")},
|
||||
model.Rule{Name: "b", Enabled: true, Order: 10, DstRuleset: []string{"b"}, Target: "node:n1"},
|
||||
)
|
||||
rs, ok := ruleSetByTag(rt, "rs-b")
|
||||
if !ok {
|
||||
t.Fatalf("rs-b not emitted; route=%+v", rt)
|
||||
}
|
||||
raw := gen[0].DefaultOptions.RawDefaultRule
|
||||
if len(raw.Domain) != 1 || raw.Domain[0] != "example.com" {
|
||||
t.Fatalf("bare entry must be an exact Domain, got domain=%v suffix=%v", raw.Domain, raw.DomainSuffix)
|
||||
hr := soleInlineRule(t, rs)
|
||||
if len(hr.DomainSuffix) != 1 || hr.DomainSuffix[0] != "example.com" {
|
||||
t.Fatalf("bare entry must become a domain_suffix, got suffix=%v domain=%v", hr.DomainSuffix, hr.Domain)
|
||||
}
|
||||
if len(raw.DomainSuffix) != 0 {
|
||||
t.Fatalf("bare entry must NOT become a suffix, got %v", raw.DomainSuffix)
|
||||
if len(hr.Domain) != 1 || hr.Domain[0] != "exact.example" {
|
||||
t.Fatalf("full: must be the exact form, got domain=%v", hr.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ import (
|
||||
)
|
||||
|
||||
// TestMalformedMatchersStillApply drives ONE model carrying every previously
|
||||
// fatal matcher — a geosite: domain, a bad ip_cidr, a bad source cidr, an
|
||||
// fatal matcher — a geosite: entry, a bad ip_cidr, a bad source cidr, an
|
||||
// uncompilable regexp and a malformed port range — plus a healthy rule, through
|
||||
// engine.Apply. It must come up, the healthy rule must survive, and the
|
||||
// kill-switch backstop must stay closed.
|
||||
// kill-switch backstop must stay closed. The destination lists are inline
|
||||
// `config ruleset`s (schema v2), which is where the domain/IP guards live now;
|
||||
// a rule-set left with no usable entry is skipped, and so is the rule whose only
|
||||
// matcher it was.
|
||||
func TestMalformedMatchersStillApply(t *testing.T) {
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "closed"
|
||||
@@ -29,13 +32,19 @@ func TestMalformedMatchersStillApply(t *testing.T) {
|
||||
Globals: g,
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12395, TCP: true, UDP: true}},
|
||||
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("geosite", "geosite:youtube"),
|
||||
inlineIPSet("badip", "999.1.1.1/24"),
|
||||
inlineDomainSet("badre", "regexp:*broken("),
|
||||
inlineDomainSet("ok", "ok.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "geosite", Enabled: true, Order: 1, DstDomain: []string{"geosite:youtube"}, Target: "node:n1"},
|
||||
{Name: "badip", Enabled: true, Order: 2, DstIP: []string{"999.1.1.1/24"}, Target: "node:n1"},
|
||||
{Name: "geosite", Enabled: true, Order: 1, DstRuleset: []string{"geosite"}, Target: "node:n1"},
|
||||
{Name: "badip", Enabled: true, Order: 2, DstRuleset: []string{"badip"}, Target: "node:n1"},
|
||||
{Name: "badsrc", Enabled: true, Order: 3, Src: []string{"192.168.0.0/99"}, Target: "node:n1"},
|
||||
{Name: "badre", Enabled: true, Order: 4, DstDomain: []string{"regexp:*broken("}, Target: "node:n1"},
|
||||
{Name: "badre", Enabled: true, Order: 4, DstRuleset: []string{"badre"}, Target: "node:n1"},
|
||||
{Name: "badport", Enabled: true, Order: 5, DstPort: "a-b", Target: "node:n1"},
|
||||
{Name: "healthy", Enabled: true, Order: 6, DstDomain: []string{"ok.example"}, DstPort: "443", Target: "node:n1"},
|
||||
{Name: "healthy", Enabled: true, Order: 6, DstRuleset: []string{"ok"}, DstPort: "443", Target: "node:n1"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,14 +55,21 @@ func TestMalformedMatchersStillApply(t *testing.T) {
|
||||
if opts.Route.Final != tagBlock {
|
||||
t.Fatalf("Final = %q, want block", opts.Route.Final)
|
||||
}
|
||||
if !hasDomainRule(opts.Route, "ok.example") {
|
||||
if !hasRulesetRule(opts.Route, "ok") {
|
||||
t.Fatalf("the healthy rule must survive alongside the malformed ones")
|
||||
}
|
||||
if !hasRouteToOutbound(opts, "n1") {
|
||||
t.Fatalf("expected a route rule to n1")
|
||||
}
|
||||
// Every malformed matcher reported itself rather than failing silently.
|
||||
for _, want := range []string{"geosite matcher", "is not a valid IP/CIDR", "domain regexp", "is not a valid port/range"} {
|
||||
for _, want := range []string{
|
||||
"unrecognised prefix", // geosite: in a domain list
|
||||
"no usable entries", // ...leaving that list empty
|
||||
"bad ip_cidr entry", // 999.1.1.1/24
|
||||
"is not a valid IP/CIDR", // the source cidr
|
||||
"domain regexp", // regexp:*broken(
|
||||
"is not a valid port/range", // a-b
|
||||
} {
|
||||
if !routeWarnsHave(warns, want) {
|
||||
t.Fatalf("missing diagnostic %q in %v", want, warns)
|
||||
}
|
||||
@@ -155,10 +171,15 @@ func TestRuleKillPoliciesApply(t *testing.T) {
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12398, TCP: true, UDP: true}},
|
||||
Nodes: []model.Node{{Name: "dead", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#dead"}},
|
||||
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"dead"}}},
|
||||
Rulesets: []model.Ruleset{
|
||||
inlineDomainSet("k-closed", "closed.example"),
|
||||
inlineDomainSet("k-open", "open.example"),
|
||||
inlineDomainSet("k-default", "default.example"),
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "k-closed", Enabled: true, Order: 1, DstDomain: []string{"closed.example"}, Target: "group:grp", Kill: "closed"},
|
||||
{Name: "k-open", Enabled: true, Order: 2, DstDomain: []string{"open.example"}, Target: "group:grp", Kill: "open"},
|
||||
{Name: "k-default", Enabled: true, Order: 3, DstDomain: []string{"default.example"}, Target: "group:grp"},
|
||||
{Name: "k-closed", Enabled: true, Order: 1, DstRuleset: []string{"k-closed"}, Target: "group:grp", Kill: "closed"},
|
||||
{Name: "k-open", Enabled: true, Order: 2, DstRuleset: []string{"k-open"}, Target: "group:grp", Kill: "open"},
|
||||
{Name: "k-default", Enabled: true, Order: 3, DstRuleset: []string{"k-default"}, Target: "group:grp"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,13 +187,13 @@ func TestRuleKillPoliciesApply(t *testing.T) {
|
||||
if !changed {
|
||||
t.Fatalf("expected Apply changed==true (warnings: %v)", warns)
|
||||
}
|
||||
if got, ok := domainRuleTarget(opts.Route, "closed.example"); !ok || got != tagBlock {
|
||||
if got, ok := rulesetRuleTarget(opts.Route, "k-closed"); !ok || got != tagBlock {
|
||||
t.Fatalf("kill=closed must emit a rule routed to block, got %q (ok=%v)", got, ok)
|
||||
}
|
||||
if got, ok := domainRuleTarget(opts.Route, "open.example"); !ok || got != tagDirect {
|
||||
if got, ok := rulesetRuleTarget(opts.Route, "k-open"); !ok || got != tagDirect {
|
||||
t.Fatalf("kill=open must emit a rule routed to direct, got %q (ok=%v)", got, ok)
|
||||
}
|
||||
if got, ok := domainRuleTarget(opts.Route, "default.example"); !ok || got != tagBlock {
|
||||
if got, ok := rulesetRuleTarget(opts.Route, "k-default"); !ok || got != tagBlock {
|
||||
t.Fatalf("kill=default must emit a rule routed to block, got %q (ok=%v)", got, ok)
|
||||
}
|
||||
if opts.Route.Final != tagBlock {
|
||||
|
||||
@@ -45,6 +45,40 @@ func genRules(t *testing.T, rules ...model.Rule) (*option.RouteOptions, []string
|
||||
return opts.Route, warns
|
||||
}
|
||||
|
||||
// ruleModelWithSets is ruleModel plus the `config ruleset` definitions the rules'
|
||||
// DstRuleset entries point at. A rule's ONLY destination matcher is a rule-set
|
||||
// (schema v2), so every case below that just needs "some destination the engine
|
||||
// can match on" declares one here rather than writing an inline domain/IP list.
|
||||
func ruleModelWithSets(sets []model.Ruleset, rules ...model.Rule) *model.Model {
|
||||
m := ruleModel(rules...)
|
||||
m.Rulesets = sets
|
||||
return m
|
||||
}
|
||||
|
||||
// genRulesWithSets is genRules for a model that also carries rule-sets.
|
||||
func genRulesWithSets(t *testing.T, sets []model.Ruleset, rules ...model.Rule) (*option.RouteOptions, []string) {
|
||||
t.Helper()
|
||||
opts, warns, err := GenerateWithWarnings(ruleModelWithSets(sets, rules...))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
return opts.Route, warns
|
||||
}
|
||||
|
||||
// inlineDomainSet builds an inline DOMAIN `config ruleset`.
|
||||
//
|
||||
// Mind the convention the move to rule-sets brought with it: a BARE entry here is
|
||||
// a DomainSuffix (the apex AND its subdomains), whereas the routing rule's old
|
||||
// dst_domain read a bare entry as an EXACT domain. `full:` is the exact form.
|
||||
func inlineDomainSet(name string, entries ...string) model.Ruleset {
|
||||
return model.Ruleset{Name: name, Type: "domain", Source: "inline", Entries: entries}
|
||||
}
|
||||
|
||||
// inlineIPSet builds an inline IP-RANGE `config ruleset`.
|
||||
func inlineIPSet(name string, entries ...string) model.Ruleset {
|
||||
return model.Ruleset{Name: name, Type: "ipcidr", Source: "inline", Entries: entries}
|
||||
}
|
||||
|
||||
func routeWarnsHave(warns []string, substr string) bool {
|
||||
for _, w := range warns {
|
||||
if strings.Contains(w, substr) {
|
||||
@@ -68,69 +102,117 @@ func generalRules(rt *option.RouteOptions) []option.Rule {
|
||||
|
||||
// --- geosite: the landmine ---------------------------------------------------
|
||||
|
||||
// TestGeositeMatcherIsInertNotFatal: route-rule `geosite` was REMOVED from this
|
||||
// engine — route/rule.NewDefaultRule returns "geosite database is deprecated ...
|
||||
// removed in sing-box 1.12.0" for a non-empty Geosite list, and that error aborts
|
||||
// box.New for the whole config. A `geosite:` entry must therefore be warned and
|
||||
// omitted (exactly like the geoip matcher below), never emitted.
|
||||
func TestGeositeMatcherIsInertNotFatal(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "geo", Enabled: true, Order: 10,
|
||||
DstDomain: []string{"geosite:youtube"}, Target: "node:n1",
|
||||
})
|
||||
// TestGeositeEntryInRulesetIsInertNotFatal: route-rule `geosite` was REMOVED from
|
||||
// this engine — route/rule.NewDefaultRule returns "geosite database is deprecated
|
||||
// ... removed in sing-box 1.12.0" for a non-empty Geosite list, and that error
|
||||
// aborts box.New for the whole config. Destinations now live in a `config
|
||||
// ruleset`, so a `geosite:` entry lands in an inline domain list, where it is an
|
||||
// unrecognised `word:` prefix: dropped by the classifier, reported, and — being
|
||||
// the list's only entry — leaving the rule-set with nothing to match, so it is
|
||||
// skipped and the rule that referenced it is not emitted either. Nothing about
|
||||
// that path may ever put a value in RawDefaultRule.Geosite. (`source=geosite` on
|
||||
// the ruleset itself is the working way to route a category; see
|
||||
// TestRoutingRuleSetGeositeCategory.)
|
||||
func TestGeositeEntryInRulesetIsInertNotFatal(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("geo", "geosite:youtube")},
|
||||
model.Rule{Name: "geo", Enabled: true, Order: 10, DstRuleset: []string{"geo"}, Target: "node:n1"},
|
||||
)
|
||||
for _, r := range rt.Rules {
|
||||
if len(r.DefaultOptions.RawDefaultRule.Geosite) > 0 {
|
||||
t.Fatalf("geosite must never be emitted (aborts box.New), got %v", r.DefaultOptions.RawDefaultRule.Geosite)
|
||||
}
|
||||
}
|
||||
if !routeWarnsHave(warns, "geosite matcher") {
|
||||
t.Fatalf("expected an inert-geosite warning, got %v", warns)
|
||||
if _, ok := ruleSetByTag(rt, "rs-geo"); ok {
|
||||
t.Fatalf("a rule-set with no usable entry must not be emitted (an empty list would match everything)")
|
||||
}
|
||||
if findRouteRuleWithRuleSet(rt, "rs-geo") != nil {
|
||||
t.Fatalf("no rule may reference the skipped rule-set")
|
||||
}
|
||||
if !routeWarnsHave(warns, "unrecognised prefix") {
|
||||
t.Fatalf("expected an unrecognised-prefix warning for geosite:, got %v", warns)
|
||||
}
|
||||
if !routeWarnsHave(warns, "no usable entries") {
|
||||
t.Fatalf("expected a no-usable-entries warning, got %v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeositeMixedWithRealDomainKeepsTheRest: a rule carrying BOTH a geosite entry
|
||||
// and a real domain keeps the real matcher and still routes — only the geosite
|
||||
// part is dropped.
|
||||
// TestGeositeMixedWithRealDomainKeepsTheRest: a rule-set carrying BOTH a geosite
|
||||
// entry and a real domain keeps the real matcher, and the rule referencing it
|
||||
// still routes — only the geosite entry is dropped.
|
||||
func TestGeositeMixedWithRealDomainKeepsTheRest(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "mixed", Enabled: true, Order: 10,
|
||||
DstDomain: []string{"geosite:youtube", "example.com"}, Target: "node:n1",
|
||||
})
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("want 1 general rule, got %d (warnings %v)", len(gen), warns)
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("mixed", "geosite:youtube", "full:example.com")},
|
||||
model.Rule{Name: "mixed", Enabled: true, Order: 10, DstRuleset: []string{"mixed"}, Target: "node:n1"},
|
||||
)
|
||||
rs, ok := ruleSetByTag(rt, "rs-mixed")
|
||||
if !ok {
|
||||
t.Fatalf("rs-mixed must survive the geosite entry (warnings %v)", warns)
|
||||
}
|
||||
raw := gen[0].DefaultOptions.RawDefaultRule
|
||||
if len(raw.Geosite) != 0 {
|
||||
t.Fatalf("geosite leaked: %v", raw.Geosite)
|
||||
hr := rs.InlineOptions.Rules[0].DefaultOptions
|
||||
if len(hr.Domain) != 1 || hr.Domain[0] != "example.com" {
|
||||
t.Fatalf("real domain matcher lost: %+v", hr)
|
||||
}
|
||||
if len(raw.Domain) != 1 || raw.Domain[0] != "example.com" {
|
||||
t.Fatalf("real domain matcher lost: %+v", raw.Domain)
|
||||
if len(hr.DomainSuffix)+len(hr.DomainKeyword)+len(hr.DomainRegex) != 0 {
|
||||
t.Fatalf("geosite: must be dropped, not reinterpreted: %+v", hr)
|
||||
}
|
||||
if got := gen[0].DefaultOptions.RuleAction.RouteOptions.Outbound; got != "n1" {
|
||||
dr := findRouteRuleWithRuleSet(rt, "rs-mixed")
|
||||
if dr == nil {
|
||||
t.Fatalf("the rule must be emitted referencing rs-mixed; rules=%+v", rt.Rules)
|
||||
}
|
||||
if got := dr.RuleAction.RouteOptions.Outbound; got != "n1" {
|
||||
t.Fatalf("target = %q, want n1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeoipEntryInRulesetIsInertNotFatal is the IP-side twin: model.migrate1to2
|
||||
// moves an old `dst_ip geoip:ru` verbatim into an inline type=ipcidr rule-set
|
||||
// (deliberately — see TestMigrate1to2KeepsGeoMarkersInert: it must not silently
|
||||
// become a working geoip source, because the operator never asked to download
|
||||
// anything). Here it is an unparseable prefix, so it must be dropped LOUDLY
|
||||
// rather than reach NewIPCIDRItem, which errors and aborts box.New.
|
||||
func TestGeoipEntryInRulesetIsInertNotFatal(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineIPSet("geo", "geoip:ru")},
|
||||
model.Rule{Name: "geo", Enabled: true, Order: 10, DstRuleset: []string{"geo"}, Target: "node:n1"},
|
||||
)
|
||||
for _, r := range rt.Rules {
|
||||
if len(r.DefaultOptions.RawDefaultRule.GeoIP) > 0 {
|
||||
t.Fatalf("geoip must never be emitted, got %v", r.DefaultOptions.RawDefaultRule.GeoIP)
|
||||
}
|
||||
}
|
||||
if _, ok := ruleSetByTag(rt, "rs-geo"); ok {
|
||||
t.Fatalf("a list whose only entry is unparseable must not materialise")
|
||||
}
|
||||
if !routeWarnsHave(warns, `bad ip_cidr entry "geoip:ru"`) {
|
||||
t.Fatalf("expected a bad-ip_cidr warning naming the entry, got %v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// --- malformed matchers that used to abort box.New ---------------------------
|
||||
|
||||
// TestBadDstCIDRWarnsAndSkips: an unparseable ip_cidr makes
|
||||
// route/rule.NewIPCIDRItem error, which aborts box.New. It must be dropped.
|
||||
func TestBadDstCIDRWarnsAndSkips(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "bad", Enabled: true, Order: 10,
|
||||
DstIP: []string{"999.1.1.1/24", "198.51.100.0/24"}, Target: "node:n1",
|
||||
})
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("want 1 general rule, got %d", len(gen))
|
||||
// TestRulesetBadIPCIDREntryWarnsAndSkips: an unparseable ip_cidr makes
|
||||
// route/rule.NewIPCIDRItem error, which aborts box.New. Destination addresses are
|
||||
// an inline `type=ipcidr` rule-set now, so the guard lives in inlineRulesetRule:
|
||||
// the typo is dropped, the valid entry survives and the rule still routes.
|
||||
func TestRulesetBadIPCIDREntryWarnsAndSkips(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineIPSet("bad", "999.1.1.1/24", "198.51.100.0/24")},
|
||||
model.Rule{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "node:n1"},
|
||||
)
|
||||
rs, ok := ruleSetByTag(rt, "rs-bad")
|
||||
if !ok {
|
||||
t.Fatalf("one bad entry must not take the whole list down (warnings %v)", warns)
|
||||
}
|
||||
raw := gen[0].DefaultOptions.RawDefaultRule
|
||||
if len(raw.IPCIDR) != 1 || raw.IPCIDR[0] != "198.51.100.0/24" {
|
||||
t.Fatalf("ip_cidr = %v, want only the valid entry", raw.IPCIDR)
|
||||
got := rs.InlineOptions.Rules[0].DefaultOptions.IPCIDR
|
||||
if len(got) != 1 || got[0] != "198.51.100.0/24" {
|
||||
t.Fatalf("ip_cidr = %v, want only the valid entry", got)
|
||||
}
|
||||
if !routeWarnsHave(warns, `destination "999.1.1.1/24" is not a valid IP/CIDR`) {
|
||||
t.Fatalf("expected a bad-destination warning, got %v", warns)
|
||||
if !routeWarnsHave(warns, `bad ip_cidr entry "999.1.1.1/24"`) {
|
||||
t.Fatalf("expected a bad-ip_cidr warning, got %v", warns)
|
||||
}
|
||||
if findRouteRuleWithRuleSet(rt, "rs-bad") == nil {
|
||||
t.Fatalf("the rule must still be emitted referencing rs-bad; rules=%+v", rt.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,24 +234,30 @@ func TestBadSrcCIDRWarnsAndSkips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBadDomainRegexWarnsAndSkips: an uncompilable `regexp:` pattern makes
|
||||
// route/rule.NewDomainRegexItem error and abort box.New.
|
||||
func TestBadDomainRegexWarnsAndSkips(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "bad", Enabled: true, Order: 10,
|
||||
DstDomain: []string{"regexp:*broken(", `regexp:^ok\.example$`}, Target: "node:n1",
|
||||
})
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("want 1 general rule, got %d", len(gen))
|
||||
// TestRulesetBadDomainRegexWarnsAndSkips: an uncompilable `regexp:` pattern makes
|
||||
// route/rule.NewDomainRegexItem error and abort box.New. The pattern vocabulary
|
||||
// moved into the inline rule-set with the rest of the destination list, so the
|
||||
// validation moved with it (peelDomainRegexes): the broken pattern is dropped and
|
||||
// the compilable one survives.
|
||||
func TestRulesetBadDomainRegexWarnsAndSkips(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("bad", "regexp:*broken(", `regexp:^ok\.example$`)},
|
||||
model.Rule{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "node:n1"},
|
||||
)
|
||||
rs, ok := ruleSetByTag(rt, "rs-bad")
|
||||
if !ok {
|
||||
t.Fatalf("one broken pattern must not take the whole list down (warnings %v)", warns)
|
||||
}
|
||||
got := gen[0].DefaultOptions.RawDefaultRule.DomainRegex
|
||||
got := rs.InlineOptions.Rules[0].DefaultOptions.DomainRegex
|
||||
if len(got) != 1 || got[0] != `^ok\.example$` {
|
||||
t.Fatalf("domain_regex = %v, want only the compilable one", got)
|
||||
}
|
||||
if !routeWarnsHave(warns, "domain regexp") {
|
||||
t.Fatalf("expected a bad-regexp warning, got %v", warns)
|
||||
}
|
||||
if findRouteRuleWithRuleSet(rt, "rs-bad") == nil {
|
||||
t.Fatalf("the rule must still be emitted referencing rs-bad; rules=%+v", rt.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBadPortRangeWarnsAndSkips: a malformed range reaches
|
||||
@@ -911,10 +999,12 @@ func TestRuleProtoKnownValuesAreSilent(t *testing.T) {
|
||||
// that would break existing configs either open or closed), but the widening is
|
||||
// now reported with its consequence.
|
||||
func TestIfaceOnlySourceRuleIsNotSilentlyNetworkWide(t *testing.T) {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "guest", Enabled: true, Order: 10,
|
||||
Src: []string{"iface:guest"}, DstDomain: []string{"youtube.com"}, Target: "block",
|
||||
})
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("yt", "youtube.com")},
|
||||
model.Rule{
|
||||
Name: "guest", Enabled: true, Order: 10,
|
||||
Src: []string{"iface:guest"}, DstRuleset: []string{"yt"}, Target: "block",
|
||||
})
|
||||
if !routeWarnsHave(warns, "applies to EVERY client") {
|
||||
t.Fatalf("expected a rule-widening warning, got %v", warns)
|
||||
}
|
||||
@@ -930,11 +1020,13 @@ func TestIfaceOnlySourceRuleIsNotSilentlyNetworkWide(t *testing.T) {
|
||||
// TestMixedSourceRuleReportsTheDroppedHalf: with one usable IP source alongside
|
||||
// an unmatchable one, the rule narrows to the IP source only.
|
||||
func TestMixedSourceRuleReportsTheDroppedHalf(t *testing.T) {
|
||||
_, warns := genRules(t, model.Rule{
|
||||
Name: "mixed", Enabled: true, Order: 10,
|
||||
Src: []string{"iface:guest", "aa:bb:cc:dd:ee:ff", "192.168.5.0/24"},
|
||||
DstDomain: []string{"youtube.com"}, Target: "block",
|
||||
})
|
||||
_, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("yt", "youtube.com")},
|
||||
model.Rule{
|
||||
Name: "mixed", Enabled: true, Order: 10,
|
||||
Src: []string{"iface:guest", "aa:bb:cc:dd:ee:ff", "192.168.5.0/24"},
|
||||
DstRuleset: []string{"yt"}, Target: "block",
|
||||
})
|
||||
if !routeWarnsHave(warns, "could not be used") {
|
||||
t.Fatalf("expected a dropped-source warning, got %v", warns)
|
||||
}
|
||||
@@ -1041,41 +1133,73 @@ func TestRuleTargetKindsResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleDomainUnrecognisedPrefixWarns: an unknown `word:` prefix is dropped by
|
||||
// the shared classifier (a domain cannot contain ":"), so the rule silently lost
|
||||
// that destination. `domain:example.com` is the v0.1/xray spelling a migrating
|
||||
// user writes, and it must not disappear without a trace.
|
||||
func TestRuleDomainUnrecognisedPrefixWarns(t *testing.T) {
|
||||
// TestRulesetDomainUnrecognisedPrefixWarns: an unknown `word:` prefix is dropped
|
||||
// by the shared classifier (a domain cannot contain ":"), so the list silently
|
||||
// lost that destination. `domain:example.com` is the v0.1/xray spelling a
|
||||
// migrating user writes, and it must not disappear without a trace — the more so
|
||||
// now that a destination list is ALWAYS a rule-set, i.e. the one place a typo can
|
||||
// hide.
|
||||
func TestRulesetDomainUnrecognisedPrefixWarns(t *testing.T) {
|
||||
for _, entry := range []string{"domain:example.com", "regex:example.com", "ext:foo.dat:cn"} {
|
||||
rt, warns := genRules(t, model.Rule{
|
||||
Name: "mig", Enabled: true, Order: 10,
|
||||
DstDomain: []string{entry, "keep.example"}, Target: "block",
|
||||
})
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("mig", entry, "keep.example")},
|
||||
model.Rule{Name: "mig", Enabled: true, Order: 10, DstRuleset: []string{"mig"}, Target: "block"},
|
||||
)
|
||||
if !routeWarnsHave(warns, "unrecognised prefix") {
|
||||
t.Fatalf("%q: expected an unrecognised-prefix warning, got %v", entry, warns)
|
||||
}
|
||||
gen := generalRules(rt)
|
||||
if len(gen) != 1 {
|
||||
t.Fatalf("%q: want 1 rule, got %d", entry, len(gen))
|
||||
rs, ok := ruleSetByTag(rt, "rs-mig")
|
||||
if !ok {
|
||||
t.Fatalf("%q: the usable entry must keep the rule-set alive; warnings %v", entry, warns)
|
||||
}
|
||||
for _, d := range gen[0].DefaultOptions.RawDefaultRule.Domain {
|
||||
if strings.Contains(d, ":") {
|
||||
t.Fatalf("%q: a prefixed literal reached the matcher: %q", entry, d)
|
||||
hr := rs.InlineOptions.Rules[0].DefaultOptions
|
||||
for _, list := range [][]string{hr.Domain, hr.DomainSuffix, hr.DomainKeyword, hr.DomainRegex} {
|
||||
for _, d := range list {
|
||||
if strings.Contains(d, ":") {
|
||||
t.Fatalf("%q: a prefixed literal reached the matcher: %q", entry, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if findRouteRuleWithRuleSet(rt, "rs-mig") == nil {
|
||||
t.Fatalf("%q: the rule must still be emitted; rules=%+v", entry, rt.Rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleDomainKnownPrefixesAreSilent guards the warning against false
|
||||
// positives on the vocabulary routing rules really support.
|
||||
func TestRuleDomainKnownPrefixesAreSilent(t *testing.T) {
|
||||
_, warns := genRules(t, model.Rule{
|
||||
Name: "ok", Enabled: true, Order: 10, Target: "block",
|
||||
DstDomain: []string{"full:a.example", "suffix:b.example", "keyword:c", `regexp:^d\.`, ".e.example", "f.example"},
|
||||
})
|
||||
// TestRulesetDomainKnownPrefixesAreSilent guards the warning against false
|
||||
// positives on the vocabulary an inline domain rule-set really supports.
|
||||
//
|
||||
// `regexp:` is the load-bearing case: the shared classifier has no branch for it,
|
||||
// so it WOULD be reported as an unknown prefix — inlineRulesetRule peels the
|
||||
// regexes off first (peelDomainRegexes) precisely so it is not. A regression there
|
||||
// would both warn about a working matcher and drop it.
|
||||
func TestRulesetDomainKnownPrefixesAreSilent(t *testing.T) {
|
||||
rt, warns := genRulesWithSets(t,
|
||||
[]model.Ruleset{inlineDomainSet("ok",
|
||||
"full:a.example", "suffix:b.example", "keyword:c", `regexp:^d\.`, ".e.example", "f.example")},
|
||||
model.Rule{Name: "ok", Enabled: true, Order: 10, DstRuleset: []string{"ok"}, Target: "block"},
|
||||
)
|
||||
if routeWarnsHave(warns, "unrecognised prefix") {
|
||||
t.Fatalf("the supported prefixes must not warn: %v", warns)
|
||||
}
|
||||
rs, ok := ruleSetByTag(rt, "rs-ok")
|
||||
if !ok {
|
||||
t.Fatalf("rs-ok not emitted; warnings %v", warns)
|
||||
}
|
||||
hr := rs.InlineOptions.Rules[0].DefaultOptions
|
||||
if len(hr.Domain) != 1 || hr.Domain[0] != "a.example" {
|
||||
t.Fatalf("full: must be an exact Domain, got %v", hr.Domain)
|
||||
}
|
||||
if len(hr.DomainRegex) != 1 || hr.DomainRegex[0] != `^d\.` {
|
||||
t.Fatalf("regexp: must survive as a domain_regex matcher, got %v", hr.DomainRegex)
|
||||
}
|
||||
// suffix:, the leading dot and the BARE entry all collapse to domain_suffix.
|
||||
if len(hr.DomainSuffix) != 3 {
|
||||
t.Fatalf("domain_suffix = %v, want b/e/f.example (bare entry is a suffix in a rule-set)", hr.DomainSuffix)
|
||||
}
|
||||
if len(hr.DomainKeyword) != 1 || hr.DomainKeyword[0] != "c" {
|
||||
t.Fatalf("domain_keyword = %v, want [c]", hr.DomainKeyword)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceDomainUnrecognisedPrefixWarns is the same guarantee in the place it
|
||||
|
||||
+231
-7
@@ -25,6 +25,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -500,6 +501,47 @@ func ruleSetURLIsEngineNative(rawURL string) (format string, native bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// TWO WAYS TO WRITE A DESTINATION LIST, AND WHY THEY DO NOT SHARE A VOCABULARY.
|
||||
//
|
||||
// D21 promises "one destination mechanism, one vocabulary". The vocabulary half of
|
||||
// that promise is about the ENTRIES AN OPERATOR TYPES, and those live in exactly
|
||||
// one place: an inline `config ruleset` (inlineRulesetRule -> peelDomainRegexes +
|
||||
// classifyDomainEntries), where `full:` / `suffix:` / `keyword:` / `regexp:` / a
|
||||
// leading dot / a bare name all mean what docs-shater/DECISIONS.md D21 says.
|
||||
//
|
||||
// A `source=url` list that is not engine-native is NOT another spelling of that.
|
||||
// It is a FILE FORMAT — the hosts / plain-domain / AdBlock-ish text third parties
|
||||
// publish — and parseDomainList is a parser for that format, not for our entry
|
||||
// vocabulary. `source=file` is a third thing again: a compiled .srs or a rule-set
|
||||
// .json handed straight to the engine, which never sees shater's entry syntax at
|
||||
// all. (An earlier review read this as "the same list written two ways behaves
|
||||
// differently"; it is closer to "a typed list and a downloaded file are different
|
||||
// artifacts". The diagnostics below exist so an operator never has to guess which
|
||||
// one they are looking at.)
|
||||
//
|
||||
// Unifying them was considered and REJECTED, on three grounds:
|
||||
//
|
||||
// - The formats collide. A real AdGuard/OISD list is full of colon-bearing lines
|
||||
// that are not our markers at all (`example.com##.banner:has(...)`, `$domain=`
|
||||
// options, absolute URL rules). Feeding those through the marker classifier
|
||||
// would either mis-import them or, if we reported every `word:`-shaped token as
|
||||
// an unknown prefix, drown the operator in hundreds of warnings per list — a
|
||||
// louder dishonesty than the quiet one it replaces.
|
||||
// - The shapes collide. A hosts line carries SEVERAL names ("127.0.0.1 a.com
|
||||
// b.com"), so this parser works per TOKEN; the entry vocabulary works per LINE
|
||||
// and allows a space after the marker ("keyword: ads"). There is no split rule
|
||||
// that serves both.
|
||||
// - `regexp:` from a URL is regex supplied by a third party, compiled into the
|
||||
// router's matcher and evaluated per query on a 512 MB box. The inline path can
|
||||
// accept it because the operator typed it; a downloaded list is not that.
|
||||
//
|
||||
// So the difference STAYS, and is paid for in diagnostics instead: a text list that
|
||||
// carries our marker vocabulary is reported per list (see listEntryMarker and
|
||||
// warnListEntryVocabulary), naming the entries and where they DO work. The check is
|
||||
// narrow on purpose — only the four markers D21 defines, never the general `word:`
|
||||
// shape — so it fires on an operator's mistake and stays silent on ordinary filter
|
||||
// syntax.
|
||||
//
|
||||
// parseDomainList extracts domains from the formats public blocklists ship in:
|
||||
//
|
||||
// - HOSTS "0.0.0.0 ads.example.com", "127.0.0.1 a.com b.com"
|
||||
@@ -517,8 +559,12 @@ func ruleSetURLIsEngineNative(rawURL string) (format string, native bool) {
|
||||
// de-duplication map is kept — domain.NewMatcher already de-duplicates internally
|
||||
// while building the succinct set, so a second map would just double the largest
|
||||
// allocation in the pipeline. See listMaxDomains for the measured budget.
|
||||
func parseDomainList(content []byte) []string {
|
||||
//
|
||||
// The second return reports the inline-vocabulary entries seen on the way past, so
|
||||
// the caller can say so instead of dropping them without a word.
|
||||
func parseDomainList(content []byte) ([]string, listMarkerNote) {
|
||||
out := make([]string, 0, 4096)
|
||||
var note listMarkerNote
|
||||
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||
// Public lists are one domain per line; 64 KiB is far beyond any real line, and
|
||||
// an over-long line is skipped rather than aborting the parse.
|
||||
@@ -544,17 +590,123 @@ func parseDomainList(content []byte) []string {
|
||||
// AdBlock-ish "||domain^" -> domain.
|
||||
f = strings.TrimPrefix(f, "||")
|
||||
f = strings.TrimSuffix(f, "^")
|
||||
if _, isMarker := listEntryMarker(f); isMarker {
|
||||
// normaliseListDomain would drop this silently (a domain cannot contain
|
||||
// ":"). Record it so the caller can name it; it is the one class of junk
|
||||
// in a text list that is provably an operator mistake rather than filter
|
||||
// syntax we simply do not import.
|
||||
note.record(f)
|
||||
continue
|
||||
}
|
||||
d, ok := normaliseListDomain(f)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
if len(out) >= listMaxDomains {
|
||||
return out
|
||||
return out, note
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, note
|
||||
}
|
||||
|
||||
// listEntryVocabulary is EXACTLY the marker set an INLINE rule-set entry may use
|
||||
// (D21). It is deliberately NOT the general `word:` shape unrecognisedDomainPrefix
|
||||
// tests for: a published filter list legitimately contains hundreds of colon-
|
||||
// bearing tokens, and reporting those would make the diagnostic useless. These
|
||||
// four, by contrast, appear in a downloaded text list only when a human wrote them
|
||||
// there expecting shater to honour them.
|
||||
var listEntryVocabulary = []string{"full:", "suffix:", "keyword:", "regexp:"}
|
||||
|
||||
// listEntryMarker reports whether a text-list token is written in the inline entry
|
||||
// vocabulary, and which marker it used.
|
||||
func listEntryMarker(token string) (string, bool) {
|
||||
lower := strings.ToLower(strings.TrimSpace(token))
|
||||
for _, m := range listEntryVocabulary {
|
||||
if strings.HasPrefix(lower, m) {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// listMarkerSamples bounds how many offending entries a warning quotes. A list is
|
||||
// remote content: it must not be able to write an unbounded amount into our log.
|
||||
const listMarkerSamples = 5
|
||||
|
||||
// listMarkerNote records the inline-vocabulary entries one plain-text list carried.
|
||||
type listMarkerNote struct {
|
||||
Count int
|
||||
Samples []string
|
||||
}
|
||||
|
||||
func (n *listMarkerNote) record(entry string) {
|
||||
n.Count++
|
||||
if len(n.Samples) < listMarkerSamples {
|
||||
n.Samples = append(n.Samples, strings.TrimSpace(entry))
|
||||
}
|
||||
}
|
||||
|
||||
func (n listMarkerNote) empty() bool { return n.Count == 0 }
|
||||
|
||||
// listMarkerMemo remembers, per list URL, what the last COMPILATION of that list
|
||||
// found. Without it the diagnostic would exist for exactly one reconcile — the one
|
||||
// that happened to refresh the artifact — and then vanish for a whole
|
||||
// update_interval, which is precisely the "reported to nobody" failure it is meant
|
||||
// to fix. Same shape (and same reasoning) as ruleSetProbeCache above; a refresh
|
||||
// that finds nothing clears the entry, so fixing the list silences it.
|
||||
var (
|
||||
listMarkerMu sync.Mutex
|
||||
listMarkerMemo = map[string]listMarkerNote{}
|
||||
)
|
||||
|
||||
func rememberListMarkers(url string, note listMarkerNote) {
|
||||
listMarkerMu.Lock()
|
||||
if note.empty() {
|
||||
delete(listMarkerMemo, url)
|
||||
} else {
|
||||
listMarkerMemo[url] = note
|
||||
}
|
||||
listMarkerMu.Unlock()
|
||||
}
|
||||
|
||||
func recallListMarkers(url string) listMarkerNote {
|
||||
listMarkerMu.Lock()
|
||||
defer listMarkerMu.Unlock()
|
||||
return listMarkerMemo[url]
|
||||
}
|
||||
|
||||
// resetListMarkerMemo clears the memo (tests).
|
||||
func resetListMarkerMemo() {
|
||||
listMarkerMu.Lock()
|
||||
listMarkerMemo = map[string]listMarkerNote{}
|
||||
listMarkerMu.Unlock()
|
||||
}
|
||||
|
||||
// warnListEntryVocabulary tells the operator that entries written in the INLINE
|
||||
// entry vocabulary were found in a downloaded TEXT list, where they mean nothing.
|
||||
// See the parseDomainList block above for why the two vocabularies are separate
|
||||
// and why saying so is the whole of the fix.
|
||||
func (b *builder) warnListEntryVocabulary(diag, url string) {
|
||||
note := recallListMarkers(url)
|
||||
if note.empty() {
|
||||
return
|
||||
}
|
||||
b.warnf("%s: %q is a plain-text list (a hosts file or one domain per line), but %d of its entries are written in the "+
|
||||
"inline rule-set vocabulary (e.g. %s) — a plain-text list has NO markers, so every line is read as a domain plus its "+
|
||||
"subdomains and anything containing \":\" is dropped, because a domain name cannot contain one. Those entries match NOTHING. "+
|
||||
"Put them in a rule-set with source=inline, which is the one place full:/suffix:/keyword:/regexp: are honoured.",
|
||||
diag, url, note.Count, quoteList(note.Samples))
|
||||
}
|
||||
|
||||
// quoteList renders sample entries for a diagnostic.
|
||||
func quoteList(in []string) string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, fmt.Sprintf("%q", s))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// hostsBoilerplate are the names every hosts file carries for its own bookkeeping.
|
||||
@@ -675,6 +827,11 @@ func (b *builder) compiledListRuleSet(tag, url, updateInterval, diag string) (op
|
||||
}
|
||||
}
|
||||
|
||||
// Reported on EVERY generate, not only on the one that refreshed the artifact:
|
||||
// an entry that matches nothing is exactly as wrong the day after it was
|
||||
// compiled as the moment it was.
|
||||
b.warnListEntryVocabulary(diag, url)
|
||||
|
||||
return option.RuleSet{
|
||||
Type: C.RuleSetTypeLocal,
|
||||
Tag: tag,
|
||||
@@ -716,8 +873,11 @@ func (b *builder) refreshCompiledList(path, url, diag string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domains := parseDomainList(body)
|
||||
domains, markers := parseDomainList(body)
|
||||
body = nil // release the source text before the matcher allocates
|
||||
// Remember (or clear) what this compilation saw, so the diagnostic survives the
|
||||
// reconciles that do no I/O at all. See warnListEntryVocabulary.
|
||||
rememberListMarkers(url, markers)
|
||||
|
||||
if len(domains) == 0 {
|
||||
return fmt.Errorf("no usable domains found at %s (fetched %s, but nothing in it parsed as a domain)", url, "the file")
|
||||
@@ -1163,7 +1323,8 @@ func (b *builder) buildRoutingRuleSetRaw(rs model.Ruleset) ([]option.RuleSet, []
|
||||
// its Entries, keyed by Type: an ipcidr ruleset fills ip_cidr; a domain ruleset
|
||||
// (the default) is classified with inlineDomainRule — bare entry => DomainSuffix
|
||||
// (so subdomains match), full: => Domain, keyword: => DomainKeyword, . => suffix
|
||||
// — the same classification the DNS filter uses. ok=false when nothing usable.
|
||||
// — the same classification the DNS filter uses, plus `regexp:` (see
|
||||
// peelDomainRegexes). ok=false when nothing usable.
|
||||
func (b *builder) inlineRulesetRule(rs model.Ruleset) (option.DefaultHeadlessRule, bool) {
|
||||
b.warnUnknownRuleSetType(fmt.Sprintf("ruleset %q", rs.Name), rs.Type)
|
||||
if ruleSetTypeIsIPCIDR(rs.Type) {
|
||||
@@ -1190,10 +1351,73 @@ func (b *builder) inlineRulesetRule(rs model.Ruleset) (option.DefaultHeadlessRul
|
||||
return option.DefaultHeadlessRule{IPCIDR: badoption.Listable[string](cidrs)}, true
|
||||
}
|
||||
// "domain" (and empty, and anything unrecognised => domain, warned above).
|
||||
// `regexp:` is peeled off first: it is a routing-rule matcher the DNS-filter
|
||||
// classifier does not know, and it must not be reported as an unknown prefix.
|
||||
diag := fmt.Sprintf("ruleset %q", rs.Name)
|
||||
rest, regexes := b.peelDomainRegexes(diag, rs.Entries)
|
||||
// R4, on the path every destination list now takes. classifyDomainEntries
|
||||
// DROPS an entry that is nothing but its marker (".", "full:", "keyword:"),
|
||||
// silently — and the silence is the dangerous half: an empty domain token
|
||||
// aborts box.New for the whole config, and an empty keyword is
|
||||
// strings.Contains(host, "") i.e. EVERY host. The drop is right; not saying so
|
||||
// is not. (devices.go reports the same class for a device's own lists; the bare
|
||||
// `regexp:` form is reported by peelDomainRegexes above, which is why it is
|
||||
// peeled off before this loop and cannot be double-reported.)
|
||||
for _, e := range rest {
|
||||
if isDomainMarkerOnly(e) {
|
||||
b.warnf("%s: entry %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New; an empty keyword would match EVERY host)", diag, strings.TrimSpace(e))
|
||||
}
|
||||
}
|
||||
// Only the DOMAIN branch reports unknown `word:` prefixes — the ipcidr branch
|
||||
// above is full of legitimate colons (IPv6) and must never be checked (R9.2).
|
||||
b.warnUnrecognisedPrefixes(fmt.Sprintf("ruleset %q", rs.Name), rs.Entries)
|
||||
return inlineDomainRule(rs.Entries)
|
||||
b.warnUnrecognisedPrefixes(diag, rest)
|
||||
hr, ok := inlineDomainRule(rest)
|
||||
if len(regexes) > 0 {
|
||||
hr.DomainRegex = badoption.Listable[string](regexes)
|
||||
ok = true
|
||||
}
|
||||
return hr, ok
|
||||
}
|
||||
|
||||
// peelDomainRegexes splits `regexp:<pattern>` entries out of a domain rule-set's
|
||||
// entry list, returning the remaining entries and the validated patterns.
|
||||
//
|
||||
// It exists because a destination list is now ALWAYS a rule-set (schema v2), so
|
||||
// every matcher a `dst_domain` used to express has to be expressible here —
|
||||
// including the regex form, which the shared DNS-filter classifier
|
||||
// (classifyDomainEntries) deliberately does not know about. Validation mirrors
|
||||
// what the routing rule did before the move, and for the same reason:
|
||||
// route/rule.NewDomainRegexItem returns an error for an uncompilable pattern and
|
||||
// that aborts box.New for the WHOLE config, so a bad pattern must degrade to a
|
||||
// warning. A BARE `regexp:` compiles fine but matches every host — the same
|
||||
// silent match-all hazard as an empty keyword — so it is dropped too.
|
||||
//
|
||||
// SCOPE: this is the INLINE entry path only, and deliberately so. A `source=url`
|
||||
// text list is a hosts/plain-domain FILE, parsed by parseDomainList, which has no
|
||||
// marker vocabulary at all — see the block above parseDomainList for why the two
|
||||
// are not unified and how an entry written in the wrong one is reported.
|
||||
func (b *builder) peelDomainRegexes(diag string, entries []string) (rest, regexes []string) {
|
||||
for _, e := range entries {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(e), "regexp:") {
|
||||
rest = append(rest, e)
|
||||
continue
|
||||
}
|
||||
re := strings.TrimSpace(e[len("regexp:"):])
|
||||
if re == "" {
|
||||
b.warnf("%s: %q is a bare matcher marker with no value, omitted (an empty regexp matches EVERY host)", diag, e)
|
||||
continue
|
||||
}
|
||||
if _, err := regexp.Compile(re); err != nil {
|
||||
b.warnf("%s: domain regexp %q is invalid (%v), omitted", diag, re, err)
|
||||
continue
|
||||
}
|
||||
regexes = append(regexes, re)
|
||||
}
|
||||
return rest, regexes
|
||||
}
|
||||
|
||||
// Ruleset.Type — the two shapes a rule-set can match, and the accepted spellings.
|
||||
|
||||
@@ -33,6 +33,14 @@ func findRouteRuleWithRuleSet(rt *option.RouteOptions, tag string) *option.Defau
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasRulesetRule reports whether any emitted route rule references the rule-set
|
||||
// named name (tag rs-<name>). Since schema v2 a rule's destination is ALWAYS a
|
||||
// rule-set reference, so this is how a test says "that rule was emitted" — the
|
||||
// former "does any rule carry this dst domain" question has no answer any more.
|
||||
func hasRulesetRule(rt *option.RouteOptions, name string) bool {
|
||||
return findRouteRuleWithRuleSet(rt, routeRulesetTagPrefix+name) != nil
|
||||
}
|
||||
|
||||
func ruleSetByTag(rt *option.RouteOptions, tag string) (option.RuleSet, bool) {
|
||||
if rt == nil {
|
||||
return option.RuleSet{}, false
|
||||
@@ -117,6 +125,50 @@ func TestRoutingRuleSetInlineDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoutingRuleSetInlineDomainRegex: `regexp:` is a matcher the shared domain
|
||||
// classifier does NOT know — it belongs to the routing plane, and it used to be
|
||||
// peeled off inside ruleMatchers, which no longer sees any domains at all. It
|
||||
// therefore had to move into the inline rule-set with the rest of the destination
|
||||
// vocabulary (peelDomainRegexes), or every migrated `regexp:` entry would have
|
||||
// been reported as an unknown prefix and silently dropped: a routing rule that
|
||||
// looks configured and matches nothing.
|
||||
func TestRoutingRuleSetInlineDomainRegex(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rulesets: []model.Ruleset{
|
||||
{Name: "ads", Type: "domain", Source: "inline", Entries: []string{`regexp:^ads\.`}},
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "block-ads", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "block"},
|
||||
},
|
||||
}
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("a valid regexp: entry must not warn: %v", warns)
|
||||
}
|
||||
rs, ok := ruleSetByTag(opts.Route, "rs-ads")
|
||||
if !ok {
|
||||
t.Fatalf("a regexp-only rule-set must still materialise; route=%+v", opts.Route)
|
||||
}
|
||||
hr := rs.InlineOptions.Rules[0].DefaultOptions
|
||||
if len(hr.DomainRegex) != 1 || hr.DomainRegex[0] != `^ads\.` {
|
||||
t.Fatalf("domain_regex = %+v, want [^ads\\.]", hr.DomainRegex)
|
||||
}
|
||||
if len(hr.Domain)+len(hr.DomainSuffix)+len(hr.DomainKeyword)+len(hr.IPCIDR) != 0 {
|
||||
t.Fatalf("the regexp entry must not leak into another matcher: %+v", hr)
|
||||
}
|
||||
dr := findRouteRuleWithRuleSet(opts.Route, "rs-ads")
|
||||
if dr == nil {
|
||||
t.Fatalf("no route rule references rs-ads; rules=%+v", opts.Route.Rules)
|
||||
}
|
||||
if dr.RuleAction.RouteOptions.Outbound != tagBlock {
|
||||
t.Fatalf("route rule must route to %q, got %+v", tagBlock, dr.RuleAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoutingRuleSetInlineIPCIDR: an ipcidr ruleset fills ip_cidr (not domain*),
|
||||
// and the route rule references it.
|
||||
func TestRoutingRuleSetInlineIPCIDR(t *testing.T) {
|
||||
@@ -1501,7 +1553,7 @@ func TestURLBlocklistNotRefetchedWhileFresh(t *testing.T) {
|
||||
// wildcards, IPs and bare labels can never match a domain query, so importing them
|
||||
// would be a silent dud (the R9 lesson applied to fetched content).
|
||||
func TestParseDomainListRejectsJunk(t *testing.T) {
|
||||
got := parseDomainList([]byte(strings.Join([]string{
|
||||
got, _ := parseDomainList([]byte(strings.Join([]string{
|
||||
"good.example.com",
|
||||
"*.wildcard.example", // wildcard syntax
|
||||
"/regex/", // regex rule
|
||||
@@ -1532,12 +1584,12 @@ func TestParseDomainListRejectsJunk(t *testing.T) {
|
||||
|
||||
// TestParseDomainListHostsEdgeCases covers the messy real-world shapes.
|
||||
func TestParseDomainListHostsEdgeCases(t *testing.T) {
|
||||
got := parseDomainList([]byte(stevenBlackSample))
|
||||
got, _ := parseDomainList([]byte(stevenBlackSample))
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("expected 5 domains from the sample, got %d (%v)", len(got), got)
|
||||
}
|
||||
// Unicode is punycoded, matching what actually arrives in a DNS query.
|
||||
uni := parseDomainList([]byte("0.0.0.0 реклама.рф\n"))
|
||||
uni, _ := parseDomainList([]byte("0.0.0.0 реклама.рф\n"))
|
||||
if len(uni) != 1 || !strings.HasPrefix(uni[0], "xn--") {
|
||||
t.Fatalf("a unicode entry must be punycoded, got %v", uni)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package generate
|
||||
|
||||
// The destination-list vocabulary has TWO homes, not one, and the difference is
|
||||
// reported rather than silent.
|
||||
//
|
||||
// docs-shater/DECISIONS.md D21 defines one entry vocabulary — full: / suffix: /
|
||||
// keyword: / regexp: / a leading dot / a bare name — and it belongs to the entries
|
||||
// an operator TYPES, i.e. an inline `config ruleset`. A `source=url` list that is
|
||||
// not engine-native is a hosts/plain-domain FILE, parsed by parseDomainList, which
|
||||
// has no markers at all. See the block above parseDomainList (ruleset.go) for why
|
||||
// unifying the two was rejected.
|
||||
//
|
||||
// These tests pin the consequence that makes that acceptable: an entry written in
|
||||
// the inline vocabulary inside a text list is NAMED, on every generate, and the
|
||||
// check is narrow enough that an ordinary published filter list stays silent.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// urlDomainSet builds a routing `config ruleset` fed from a plain-text URL.
|
||||
func urlDomainSet(name, url string) model.Ruleset {
|
||||
return model.Ruleset{Name: name, Type: "domain", Source: "url", URL: url}
|
||||
}
|
||||
|
||||
// textListModel routes one rule at a url-sourced destination list.
|
||||
func textListModel(url string) (sets []model.Ruleset, rule model.Rule) {
|
||||
return []model.Ruleset{urlDomainSet("dest", url)},
|
||||
model.Rule{Name: "dest", Enabled: true, Order: 10, DstRuleset: []string{"dest"}, Target: "node:n1"}
|
||||
}
|
||||
|
||||
// TestTextListInlineVocabularyIsReported is the regression: `regexp:^ads\.` (and
|
||||
// every other inline marker) works in an inline rule-set and matches NOTHING in a
|
||||
// plain-text list, because normaliseListDomain drops anything containing ":". The
|
||||
// drop is correct — a domain name cannot contain a colon — but it used to happen
|
||||
// without a word, so the same string appeared to work in one spelling of "a list of
|
||||
// destinations" and to do nothing in the other, with no way to tell which.
|
||||
func TestTextListInlineVocabularyIsReported(t *testing.T) {
|
||||
resetListMarkerMemo()
|
||||
t.Cleanup(resetListMarkerMemo)
|
||||
withListFetcher(t, func(string) ([]byte, error) {
|
||||
return []byte(strings.Join([]string{
|
||||
"# a list someone hand-wrote in the inline vocabulary",
|
||||
"0.0.0.0 ads.example.com",
|
||||
`regexp:^ads\.`,
|
||||
"full:exact.example",
|
||||
"keyword:track",
|
||||
"suffix:apex.example",
|
||||
"tracker.example.org",
|
||||
}, "\n")), nil
|
||||
})
|
||||
|
||||
sets, rule := textListModel("https://lists.example/dest.txt")
|
||||
rt, warns := genRulesWithSets(t, sets, rule)
|
||||
|
||||
// The list itself still works — the plain entries are imported as usual.
|
||||
if _, ok := ruleSetByTag(rt, "rs-dest"); !ok {
|
||||
t.Fatalf("the plain entries must still compile into a rule-set; warnings=%v", warns)
|
||||
}
|
||||
if !hasRulesetRule(rt, "dest") {
|
||||
t.Fatalf("the rule referencing the list must still be emitted; warnings=%v", warns)
|
||||
}
|
||||
|
||||
// ...and the four entries that silently vanished are named.
|
||||
if !routeWarnsHave(warns, "inline rule-set vocabulary") {
|
||||
t.Fatalf("marker entries in a text list must be reported, got %v", warns)
|
||||
}
|
||||
// %q-escaped in the message, so match the stable head of the pattern.
|
||||
if !routeWarnsHave(warns, `regexp:^ads`) {
|
||||
t.Fatalf("the warning must quote the offending entry, got %v", warns)
|
||||
}
|
||||
if !routeWarnsHave(warns, "source=inline") {
|
||||
t.Fatalf("the warning must say where those markers DO work, got %v", warns)
|
||||
}
|
||||
if !routeWarnsHave(warns, "4 of its entries") {
|
||||
t.Fatalf("the warning must count every dropped marker entry (4), got %v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextListVocabularyWarningSurvivesAFreshArtifact: the parse that can see the
|
||||
// offending entries happens only when the artifact is refreshed, which is once per
|
||||
// update_interval. A diagnostic that existed for exactly that one reconcile and
|
||||
// then disappeared for a day would be no diagnostic at all, so the finding is
|
||||
// remembered per URL and re-reported on every generate.
|
||||
func TestTextListVocabularyWarningSurvivesAFreshArtifact(t *testing.T) {
|
||||
resetListMarkerMemo()
|
||||
t.Cleanup(resetListMarkerMemo)
|
||||
var fetches int
|
||||
withListFetcher(t, func(string) ([]byte, error) {
|
||||
fetches++
|
||||
return []byte("keyword:track\ngood.example.com\n"), nil
|
||||
})
|
||||
|
||||
sets, rule := textListModel("https://lists.example/sticky.txt")
|
||||
for pass := 1; pass <= 3; pass++ {
|
||||
_, warns := genRulesWithSets(t, sets, rule)
|
||||
if !routeWarnsHave(warns, "inline rule-set vocabulary") {
|
||||
t.Fatalf("pass %d: the warning must persist while the list does; warnings=%v", pass, warns)
|
||||
}
|
||||
}
|
||||
if fetches != 1 {
|
||||
t.Fatalf("a fresh artifact must not be re-downloaded, fetched %d times", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextListVocabularyWarningClearsWhenTheListIsFixed: the memo is a finding
|
||||
// about the list, not a sticky flag. Once a refresh sees a clean list the warning
|
||||
// stops, or an operator who fixed the problem would never know they had.
|
||||
func TestTextListVocabularyWarningClearsWhenTheListIsFixed(t *testing.T) {
|
||||
resetListMarkerMemo()
|
||||
t.Cleanup(resetListMarkerMemo)
|
||||
body := "keyword:track\ngood.example.com\n"
|
||||
withListFetcher(t, func(string) ([]byte, error) { return []byte(body), nil })
|
||||
|
||||
sets, rule := textListModel("https://lists.example/fixed.txt")
|
||||
if _, warns := genRulesWithSets(t, sets, rule); !routeWarnsHave(warns, "inline rule-set vocabulary") {
|
||||
t.Fatalf("setup: the first pass must report the marker entry; warnings=%v", warns)
|
||||
}
|
||||
body = "good.example.com\nbetter.example.org\n"
|
||||
// The artifact from the first pass is fresh, so force the refresh the operator's
|
||||
// next update_interval would have done anyway.
|
||||
resetListMarkerMemo()
|
||||
listsDirOverride = t.TempDir()
|
||||
if _, warns := genRulesWithSets(t, sets, rule); routeWarnsHave(warns, "inline rule-set vocabulary") {
|
||||
t.Fatalf("a clean list must not keep warning; warnings=%v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishedFilterListDoesNotTripTheVocabularyWarning is the other half of the
|
||||
// trade, and the reason the check tests only the four D21 markers instead of the
|
||||
// general `word:` shape unrecognisedDomainPrefix uses. A real AdGuard/OISD list is
|
||||
// full of colon-bearing tokens that are ordinary filter syntax; warning about those
|
||||
// would put hundreds of lines per list in front of the operator, which is a louder
|
||||
// dishonesty than the silence it replaced.
|
||||
func TestPublishedFilterListDoesNotTripTheVocabularyWarning(t *testing.T) {
|
||||
resetListMarkerMemo()
|
||||
t.Cleanup(resetListMarkerMemo)
|
||||
withListFetcher(t, func(string) ([]byte, error) {
|
||||
return []byte(strings.Join([]string{
|
||||
"! Title: Example filter list",
|
||||
"! Homepage: https://lists.example/",
|
||||
"||ads.example.com^$third-party",
|
||||
"example.com##.banner:has(> .ad)",
|
||||
"https://tracker.example/pixel.gif",
|
||||
"@@||allowed.example.net^",
|
||||
"0.0.0.0 good.example.net",
|
||||
"fe80::1 ip6-localhost",
|
||||
}, "\n")), nil
|
||||
})
|
||||
|
||||
sets, rule := textListModel("https://lists.example/adguard.txt")
|
||||
rt, warns := genRulesWithSets(t, sets, rule)
|
||||
if _, ok := ruleSetByTag(rt, "rs-dest"); !ok {
|
||||
t.Fatalf("the list must still compile; warnings=%v", warns)
|
||||
}
|
||||
if routeWarnsHave(warns, "inline rule-set vocabulary") {
|
||||
t.Fatalf("ordinary filter syntax must not be reported as a vocabulary mistake: %v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDomainListReportsOnlyTheInlineMarkers pins the predicate itself, away
|
||||
// from the generate machinery: the four markers are recorded, everything else that
|
||||
// the parser refuses stays a silent format detail.
|
||||
func TestParseDomainListReportsOnlyTheInlineMarkers(t *testing.T) {
|
||||
domains, note := parseDomainList([]byte(strings.Join([]string{
|
||||
"0.0.0.0 kept.example.com",
|
||||
"FULL:Exact.Example", // markers are case-insensitive, like splitDomainMarker
|
||||
"regexp:^ads\\.",
|
||||
"*.wildcard.example", // junk, but not a vocabulary mistake
|
||||
"2001:db8::1", // colons, but an address — never a marker
|
||||
"nodot",
|
||||
}, "\n")))
|
||||
|
||||
if len(domains) != 1 || domains[0] != "kept.example.com" {
|
||||
t.Fatalf("imported domains = %v, want [kept.example.com]", domains)
|
||||
}
|
||||
if note.Count != 2 {
|
||||
t.Fatalf("marker count = %d, want 2 (FULL: and regexp:); samples=%v", note.Count, note.Samples)
|
||||
}
|
||||
for _, want := range []string{"FULL:Exact.Example", `regexp:^ads\.`} {
|
||||
var seen bool
|
||||
for _, s := range note.Samples {
|
||||
if s == want {
|
||||
seen = true
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Fatalf("sample %q missing from %v", want, note.Samples)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextListVocabularyWarningIsBounded: the body is remote content, so it must
|
||||
// not be able to write an unbounded amount into the operator's warning list.
|
||||
func TestTextListVocabularyWarningIsBounded(t *testing.T) {
|
||||
var lines []string
|
||||
for i := 0; i < 500; i++ {
|
||||
lines = append(lines, "keyword:junk")
|
||||
}
|
||||
_, note := parseDomainList([]byte(strings.Join(lines, "\n")))
|
||||
if note.Count != 500 {
|
||||
t.Fatalf("count = %d, want the true total 500", note.Count)
|
||||
}
|
||||
if len(note.Samples) != listMarkerSamples {
|
||||
t.Fatalf("quoted %d entries, want at most %d", len(note.Samples), listMarkerSamples)
|
||||
}
|
||||
}
|
||||
@@ -12,47 +12,36 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// scheduledDomain is the distinctive dst-domain matcher used to detect whether a
|
||||
// scheduled rule survived into the generated route rules.
|
||||
const scheduledDomain = "sched.example"
|
||||
// scheduledSet is the distinctive destination rule-set used to detect whether a
|
||||
// scheduled rule survived into the generated route rules. Since schema v2 a
|
||||
// rule's destination is a rule-set reference, so this doubles as a check that
|
||||
// buildRoutingRuleSets honours the same schedule gate buildRoute does: outside
|
||||
// the window neither the rule nor its rs- rule-set may be emitted.
|
||||
const scheduledSet = "sched"
|
||||
|
||||
// emittedAt reports whether the given scheduled rule is present in the route
|
||||
// rules when generate's clock is `now`.
|
||||
func emittedAt(now time.Time, r model.Rule) bool {
|
||||
b := newBuilder(&model.Model{Globals: model.DefaultGlobals(), Rules: []model.Rule{r}})
|
||||
b := newBuilder(&model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rulesets: []model.Ruleset{{Name: scheduledSet, Type: "domain", Source: "inline", Entries: []string{"sched.example"}}},
|
||||
Rules: []model.Rule{r},
|
||||
})
|
||||
b.now = now
|
||||
return hasDomainRule(b.buildRoute(), scheduledDomain)
|
||||
return hasRulesetRule(b.buildRoute(), scheduledSet)
|
||||
}
|
||||
|
||||
// hasDomainRule reports whether any route rule carries the given exact dst-domain
|
||||
// matcher.
|
||||
func hasDomainRule(rt *option.RouteOptions, domain string) bool {
|
||||
if rt == nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range rt.Rules {
|
||||
for _, d := range r.DefaultOptions.RawDefaultRule.Domain {
|
||||
if d == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// schedRule builds a scheduled dst-domain rule (target direct) from the schedule
|
||||
// fields. It always carries the scheduledDomain matcher so emittedAt can find it.
|
||||
// schedRule builds a scheduled destination rule (target direct) from the schedule
|
||||
// fields. It always references the scheduledSet rule-set so emittedAt can find it.
|
||||
func schedRule(days []string, start, end string) model.Rule {
|
||||
return model.Rule{
|
||||
Name: "sched",
|
||||
Enabled: true,
|
||||
Order: 10,
|
||||
DstDomain: []string{scheduledDomain},
|
||||
DstRuleset: []string{scheduledSet},
|
||||
Target: "direct",
|
||||
SchedEnabled: true,
|
||||
SchedDays: days,
|
||||
@@ -127,9 +116,13 @@ func TestScheduleAllDayWeekend(t *testing.T) {
|
||||
// warning instead.
|
||||
func TestScheduleInvalidTimeIsAlwaysOn(t *testing.T) {
|
||||
rule := schedRule(nil, "9am", "17:00") // "9am" is not HH:MM
|
||||
b := newBuilder(&model.Model{Globals: model.DefaultGlobals(), Rules: []model.Rule{rule}})
|
||||
b := newBuilder(&model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rulesets: []model.Ruleset{{Name: scheduledSet, Type: "domain", Source: "inline", Entries: []string{"sched.example"}}},
|
||||
Rules: []model.Rule{rule},
|
||||
})
|
||||
b.now = time.Date(2026, 7, 15, 3, 0, 0, 0, time.UTC) // 03:00 — would be OUTSIDE a 09–17 window
|
||||
if !hasDomainRule(b.buildRoute(), scheduledDomain) {
|
||||
if !hasRulesetRule(b.buildRoute(), scheduledSet) {
|
||||
t.Errorf("invalid start time should fail OPEN (rule emitted always-on)")
|
||||
}
|
||||
if len(b.warnings) == 0 {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//go:build linux
|
||||
|
||||
// The behavioural half of the build-tag contract (D23).
|
||||
//
|
||||
// shater/buildtags's test proves, statically and on any host, that the shipped
|
||||
// tag set (scripts/router-tags.sh) still NAMES every tag a declared feature
|
||||
// needs. That is necessary but not sufficient: a tag can be present and still
|
||||
// insufficient, and a tag list is only a hypothesis until something is built
|
||||
// with it. This file is the experiment — one node of every declared protocol,
|
||||
// driven through engine.Apply (box.New + Start) under WHATEVER tags the test
|
||||
// binary was compiled with.
|
||||
//
|
||||
// Run it with the shipped set via scripts/check-router-tags.sh (CI does, before
|
||||
// the artifact is built). Then the two halves compose:
|
||||
//
|
||||
// tag set covers the declared features (buildtags test, tag-less)
|
||||
// + everything compiled in constructs (this test, run WITH the shipped set)
|
||||
// = the binary we ship supports what we say it does.
|
||||
//
|
||||
// The 2026-07-25 WireGuard outage — `with_gvisor` trimmed while `with_wireguard`
|
||||
// stayed, so every shipped binary died with "gVisor is not included in this
|
||||
// build" on the first WireGuard node — is caught here at "wg"/"awg", because
|
||||
// box.New initialises endpoints and the WireGuard device constructor is the
|
||||
// gVisor stub without the tag.
|
||||
package generate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/buildtags"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// protoCase is one declared protocol, expressed the way a user would add it: a
|
||||
// share link. feature keys it to a row of buildtags.Features (substring match on
|
||||
// the feature name) — that row supplies the build tags the case needs. An empty
|
||||
// feature means "compiled in unconditionally".
|
||||
type protoCase struct {
|
||||
tag string
|
||||
uri string
|
||||
feature string
|
||||
endpoint bool // arrives as an option.Endpoint, not an option.Outbound
|
||||
}
|
||||
|
||||
func shippedProtoCases(t *testing.T) []protoCase {
|
||||
t.Helper()
|
||||
const uuid = "11111111-1111-1111-1111-111111111111"
|
||||
// A vmess ws+tls link (same fixture the parse suite uses).
|
||||
const vmessLink = "vmess://eyJ2IjoiMiIsInBzIjoidm1lc3MtdyIsImFkZCI6ImV4YW1wbGUubmV0IiwicG9ydCI6IjQ0MyIsImlkIjoiMzMzMzMzMzMtMzMzMy0zMzMzLTMzMzMtMzMzMzMzMzMzMzMzIiwiYWlkIjoiMCIsInNjeSI6ImF1dG8iLCJuZXQiOiJ3cyIsImhvc3QiOiJleGFtcGxlLm5ldCIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMifQ=="
|
||||
|
||||
priv, pub := validKey(1), validKey(9)
|
||||
wgURI := fmt.Sprintf(
|
||||
"wireguard://%s@203.0.113.10:51820?publickey=%s&address=10.13.13.2/32&allowedips=0.0.0.0/0#wg",
|
||||
url.QueryEscape(priv), url.QueryEscape(pub),
|
||||
)
|
||||
awgURI := fmt.Sprintf(
|
||||
"awg://%s@203.0.113.11:51820?publickey=%s&address=10.13.13.3/32&allowedips=0.0.0.0/0&jc=4&jmin=40&jmax=70&s1=30&s2=40&h1=1111111111&h2=2222222222&h3=3333333333&h4=444444444#awg",
|
||||
url.QueryEscape(priv), url.QueryEscape(pub),
|
||||
)
|
||||
|
||||
return []protoCase{
|
||||
// --- always compiled in -------------------------------------------
|
||||
{tag: "ss", uri: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss"},
|
||||
{tag: "vmess-ws", uri: vmessLink},
|
||||
{tag: "trojan", uri: "trojan://password@example.com:443?sni=example.com#trojan"},
|
||||
{tag: "vless-ws", uri: "vless://" + uuid + "@example.com:443?type=ws&security=tls&path=/vl&host=cdn.example.com&sni=cdn.example.com#vless-ws"},
|
||||
{tag: "vless-grpc", uri: "vless://" + uuid + "@example.com:443?type=grpc&security=tls&serviceName=gsvc&sni=example.com#vless-grpc"},
|
||||
{tag: "vless-httpupgrade", uri: "vless://" + uuid + "@example.com:443?type=httpupgrade&security=tls&path=/hu&host=cdn.example.com&sni=cdn.example.com#vless-httpupgrade"},
|
||||
|
||||
// --- tag-gated ------------------------------------------------------
|
||||
{tag: "vless-reality", feature: "REALITY",
|
||||
uri: "vless://" + uuid + "@example.com:443?type=tcp&security=reality&pbk=" + url.QueryEscape("jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0") + "&sid=0123456789abcdef&sni=example.com&fp=chrome&flow=xtls-rprx-vision#vless-reality"},
|
||||
{tag: "vless-utls", feature: "uTLS ClientHello",
|
||||
uri: "vless://" + uuid + "@example.com:443?type=ws&security=tls&path=/u&sni=example.com&fp=firefox#vless-utls"},
|
||||
{tag: "vless-quic", feature: "QUIC v2ray transport",
|
||||
uri: "vless://" + uuid + "@example.com:443?type=quic&security=tls&sni=example.com#vless-quic"},
|
||||
{tag: "vless-xhttp", feature: "XHTTP",
|
||||
uri: "vless://" + uuid + "@example.com:443?type=xhttp&security=tls&path=/xh&host=cdn.example.com&sni=cdn.example.com#vless-xhttp"},
|
||||
{tag: "hy2", feature: "Hysteria2",
|
||||
uri: "hysteria2://secret@example.com:8443/?sni=example.com&alpn=h3#hy2"},
|
||||
{tag: "tuic", feature: "TUIC",
|
||||
uri: "tuic://22222222-2222-2222-2222-222222222222:secret@example.com:443/?sni=example.com&alpn=h3&congestion_control=cubic&udp_relay_mode=native#tuic"},
|
||||
{tag: "wg", feature: "WireGuard nodes", endpoint: true, uri: wgURI},
|
||||
{tag: "awg", feature: "AmneziaWG", endpoint: true, uri: awgURI},
|
||||
}
|
||||
}
|
||||
|
||||
// featuresWithoutProbe are declared features this file cannot express as a node,
|
||||
// with the reason. Anything NOT listed here must be exercised by a protoCase —
|
||||
// TestEveryTagGatedFeatureIsProbed enforces that, so a new tag-gated feature
|
||||
// cannot be added without either a probe or a conscious exemption.
|
||||
var featuresWithoutProbe = map[string]string{
|
||||
"QUIC / HTTP3 DNS transports (quic://, h3://)": "shater's resolver types are udp/tcp/doh/dot/local/fakeip — the model cannot express a quic:// resolver; the tag is already proven by the hysteria2/tuic/quic-transport cases",
|
||||
"badtls fast path (zero-copy TLS read-wait / ktls, used by every TLS outbound)": "a link-time/performance path, not a constructible config object; its absence is caught at link time (-checklinkname=0) and by the buildtags test",
|
||||
}
|
||||
|
||||
// featureFor resolves a protoCase's feature key to its buildtags row.
|
||||
func featureFor(t *testing.T, key string) buildtags.Feature {
|
||||
t.Helper()
|
||||
for _, f := range buildtags.Features {
|
||||
if strings.Contains(f.Name, key) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("protoCase names feature %q, which is in no buildtags.Features row — keep the two lists linked", key)
|
||||
return buildtags.Feature{}
|
||||
}
|
||||
|
||||
// TestShippedTagSetConstructsDeclaredProtocols builds one node per declared
|
||||
// protocol and proves box.New+Start accepts every one of them under the tags
|
||||
// this binary was compiled with. Protocols whose tags are genuinely absent are
|
||||
// SKIPPED loudly (with the missing tags), never silently dropped — and the
|
||||
// shipped set is guaranteed to contain those tags by
|
||||
// shater/buildtags.TestRouterTagSetCoversDeclaredFeatures, so under
|
||||
// scripts/check-router-tags.sh nothing is skipped.
|
||||
func TestShippedTagSetConstructsDeclaredProtocols(t *testing.T) {
|
||||
t.Logf("compiled build tags: %v", buildtags.Compiled())
|
||||
|
||||
var (
|
||||
nodes []model.Node
|
||||
wantOut []string
|
||||
wantEnd []string
|
||||
skipped []string
|
||||
)
|
||||
for _, c := range shippedProtoCases(t) {
|
||||
if c.feature != "" {
|
||||
f := featureFor(t, c.feature)
|
||||
if missing := buildtags.MissingTags(f); len(missing) > 0 {
|
||||
skipped = append(skipped, fmt.Sprintf("%s (missing %s)", c.tag, strings.Join(missing, ",")))
|
||||
continue
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, model.Node{Name: c.tag, Enabled: true, URI: c.uri})
|
||||
if c.endpoint {
|
||||
wantEnd = append(wantEnd, c.tag)
|
||||
} else {
|
||||
wantOut = append(wantOut, c.tag)
|
||||
}
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
// Under a plain `go test` (no tags) a protocol that is not compiled in
|
||||
// cannot be constructed — skipping is the only honest thing to do, and
|
||||
// shater/buildtags is what guards the tag list itself.
|
||||
//
|
||||
// Under scripts/check-router-tags.sh we were compiled with the SHIPPED
|
||||
// set, which buildtags has already proven covers every declared feature.
|
||||
// So a skip here means the two disagree — a trimmed tag, or a check run
|
||||
// with the wrong -tags. Either way the run must be red, not "PASS (2
|
||||
// protocols skipped)".
|
||||
if os.Getenv("SHATER_ROUTER_TAG_CHECK") == "1" {
|
||||
t.Fatalf("the SHIPPED tag set does not compile in %d declared protocol(s): %s\n"+
|
||||
"Nothing may be skipped in a router-tag-set run — add the missing tags to scripts/router-tags.sh.",
|
||||
len(skipped), strings.Join(skipped, "; "))
|
||||
}
|
||||
t.Logf("NOT compiled in, skipped: %s", strings.Join(skipped, "; "))
|
||||
}
|
||||
|
||||
// No inbound on purpose: this test is about protocol construction, and a
|
||||
// tproxy listener would demand CAP_NET_ADMIN from every runner. The tproxy
|
||||
// path is covered by the rest of the suite.
|
||||
m := &model.Model{Globals: model.DefaultGlobals(), Nodes: nodes}
|
||||
|
||||
opts, warns, changed := applyAndClose(t, m)
|
||||
if !changed {
|
||||
t.Fatalf("expected Apply changed==true (warnings: %v)", warns)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("a declared protocol produced generator warnings — it is not fully supported: %v", warns)
|
||||
}
|
||||
|
||||
for _, tag := range wantOut {
|
||||
if findOutbound(opts, tag) == nil {
|
||||
t.Errorf("outbound %q was not emitted (warnings: %v)", tag, warns)
|
||||
}
|
||||
}
|
||||
for _, tag := range wantEnd {
|
||||
var found *option.Endpoint
|
||||
for i := range opts.Endpoints {
|
||||
if opts.Endpoints[i].Tag == tag {
|
||||
found = &opts.Endpoints[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Errorf("endpoint %q was not emitted (warnings: %v)", tag, warns)
|
||||
continue
|
||||
}
|
||||
if found.Type != C.TypeWireGuard {
|
||||
t.Errorf("endpoint %q type = %s, want wireguard", tag, found.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// AmneziaWG is the driving requirement: if it is compiled in, the obfuscation
|
||||
// params must actually be on the endpoint, not parsed-and-dropped.
|
||||
if buildtags.Has("with_awg") {
|
||||
for i := range opts.Endpoints {
|
||||
if opts.Endpoints[i].Tag != "awg" {
|
||||
continue
|
||||
}
|
||||
wg, ok := opts.Endpoints[i].Options.(*option.WireGuardEndpointOptions)
|
||||
if !ok {
|
||||
t.Fatalf("awg endpoint options type = %T", opts.Endpoints[i].Options)
|
||||
}
|
||||
if !wg.AmneziaWGOptions.IsSet() || wg.Jc != 4 {
|
||||
t.Errorf("AmneziaWG params did not reach the endpoint: %+v", wg.AmneziaWGOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryTagGatedFeatureIsProbed keeps this file from rotting: a feature added
|
||||
// to buildtags.Features must be constructed here, or explicitly exempted with a
|
||||
// reason. Otherwise the next trimmed tag is invisible again.
|
||||
func TestEveryTagGatedFeatureIsProbed(t *testing.T) {
|
||||
probed := map[string]bool{}
|
||||
for _, c := range shippedProtoCases(t) {
|
||||
if c.feature != "" {
|
||||
probed[featureFor(t, c.feature).Name] = true
|
||||
}
|
||||
}
|
||||
for _, f := range buildtags.Features {
|
||||
if probed[f.Name] {
|
||||
continue
|
||||
}
|
||||
if _, ok := featuresWithoutProbe[f.Name]; ok {
|
||||
continue
|
||||
}
|
||||
t.Errorf("declared feature %q has no protoCase and no featuresWithoutProbe exemption: add one, or the tag it needs can be trimmed unnoticed", f.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package generate
|
||||
|
||||
// Where the router's traffic actually ENDS UP, read off the engine config that
|
||||
// was generated for it.
|
||||
//
|
||||
// WHY THIS EXISTS. The panel's headline readout was derived from apply.Status's
|
||||
// `plane` field, and `plane` answers a different question than the one the
|
||||
// readout asked. `plane` says how much of the DATA PLANE is installed — is the
|
||||
// `inet shater` table loaded, is policy routing in place, is the engine up. It
|
||||
// says nothing about where the diverted packets go once the engine has them.
|
||||
//
|
||||
// A router in the field ran with a single enabled rule, `default -> direct`, no
|
||||
// groups and no rule-sets. Every piece of the plane was installed, so
|
||||
// plane == "full", so the panel said "Protected — traffic from your network is
|
||||
// going through the tunnel". There was no tunnel: route.Final was `direct` and
|
||||
// the whole LAN went out the plain WAN with its real address, under a green LED.
|
||||
// That is the product's worst defect class — a silent lie in the reassuring
|
||||
// direction — so the verdict below is computed from the thing that actually
|
||||
// decides the answer.
|
||||
//
|
||||
// THE SOURCE IS THE GENERATED CONFIG, NOT THE DESIRED STATE. TrafficOf reads
|
||||
// option.Options: route.Final, the emitted route rules, and the outbound table
|
||||
// they name. That is the config the engine was handed, so the verdict already
|
||||
// accounts for everything that happens between "what the operator wrote" and
|
||||
// "what runs": a schedule outside its window (the rule is simply not emitted), a
|
||||
// catch-all shadowed by a later one (only the winner reached Final), a rule whose
|
||||
// target did not resolve and fell back to direct/block (ruleKillFallback), a rule
|
||||
// with no engine-evaluable matcher (dropped with a warning). Re-deriving any of
|
||||
// that from *model.Model would be a second implementation of buildRoute, and the
|
||||
// two would drift — which is exactly the failure mode model/reachability.go was
|
||||
// written to stop.
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
)
|
||||
|
||||
// Traffic verdicts. Four states, because collapsing them is how the readout
|
||||
// started lying in the first place.
|
||||
const (
|
||||
// VerdictTunnel — route.Final points into a tunnel, so everything that is not
|
||||
// matched by a more specific rule is proxied.
|
||||
VerdictTunnel = "tunnel"
|
||||
// VerdictSplit — the default leaves the router directly, but at least one rule
|
||||
// does send its traffic into a tunnel. Selective protection: legitimate and
|
||||
// common, but NOT "protected".
|
||||
VerdictSplit = "split"
|
||||
// VerdictDirect — the default leaves directly and nothing is tunnelled at all.
|
||||
// The engine is running and carrying traffic straight out. This is the field
|
||||
// case above.
|
||||
VerdictDirect = "direct"
|
||||
// VerdictBlocked — the default is `block`, the kill-switch backstop: unmatched
|
||||
// traffic is DROPPED, not let out. Nothing leaks; whether anything works at all
|
||||
// depends on TunnelRules.
|
||||
VerdictBlocked = "blocked"
|
||||
)
|
||||
|
||||
// Traffic is the verdict for one generated engine config.
|
||||
//
|
||||
// The zero value (Verdict == "") means "not known" — no config has been
|
||||
// generated/applied by this daemon yet. A consumer must not read it as any of the
|
||||
// four verdicts; in particular it is NOT "tunnel".
|
||||
type Traffic struct {
|
||||
// Verdict is one of the four constants above, or "" when unknown.
|
||||
Verdict string `json:"verdict"`
|
||||
// Default is the outbound tag route.Final names — the engine's own vocabulary
|
||||
// ("direct", "block", a node/group tag, a chain's entry-hop tag). Published for
|
||||
// `shaterd status` and debugging; the wording in the UI is driven by Verdict,
|
||||
// not by parsing this.
|
||||
Default string `json:"default"`
|
||||
// TunnelRules counts the emitted route rules whose matched traffic goes into a
|
||||
// tunnel. It is what separates "selective protection" from "none", and with
|
||||
// VerdictBlocked it separates "only the listed traffic gets out" from "nothing
|
||||
// gets out at all".
|
||||
TunnelRules int `json:"tunnel_rules"`
|
||||
}
|
||||
|
||||
// TrafficOf computes the verdict for a generated engine config.
|
||||
//
|
||||
// A nil Route yields the zero Traffic (unknown) rather than a guess: every config
|
||||
// this package produces has one, so a missing Route means the caller handed us
|
||||
// something we did not build.
|
||||
func TrafficOf(opts option.Options) Traffic {
|
||||
if opts.Route == nil {
|
||||
return Traffic{}
|
||||
}
|
||||
idx := indexOutbounds(opts)
|
||||
final := opts.Route.Final
|
||||
t := Traffic{Default: final}
|
||||
for _, r := range opts.Route.Rules {
|
||||
tag, ok := ruleRouteOutbound(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if idx.tunnels(tag) {
|
||||
t.TunnelRules++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case idx.tunnels(final):
|
||||
t.Verdict = VerdictTunnel
|
||||
case idx.kind[final] == kindBlock:
|
||||
t.Verdict = VerdictBlocked
|
||||
case t.TunnelRules > 0:
|
||||
t.Verdict = VerdictSplit
|
||||
default:
|
||||
// Includes an unknown/empty Final: an unrecognised default is reported as the
|
||||
// unprotected one. Erring toward the alarm is allowed here; erring toward
|
||||
// reassurance is the bug.
|
||||
t.Verdict = VerdictDirect
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Outbound kinds, from the point of view of "does traffic that leaves through
|
||||
// this tag leave the router by some path other than the plain WAN".
|
||||
const (
|
||||
kindDirect = iota // direct outbound, incl. every interface/plain egress
|
||||
kindBlock // block outbound
|
||||
kindGroup // selector/urltest: a tunnel iff one of its members is
|
||||
kindProxy // a real remote proxy outbound or endpoint
|
||||
)
|
||||
|
||||
// outboundIndex maps every outbound/endpoint tag to its kind, plus the member
|
||||
// lists of the group outbounds so a group can be resolved to what it dials.
|
||||
type outboundIndex struct {
|
||||
kind map[string]int
|
||||
members map[string][]string
|
||||
}
|
||||
|
||||
func indexOutbounds(opts option.Options) outboundIndex {
|
||||
idx := outboundIndex{
|
||||
kind: make(map[string]int, len(opts.Outbounds)+len(opts.Endpoints)),
|
||||
members: make(map[string][]string),
|
||||
}
|
||||
for _, ob := range opts.Outbounds {
|
||||
switch ob.Type {
|
||||
case C.TypeDirect:
|
||||
// Covers the baseline `direct` AND every interface/plain `egress-*`
|
||||
// outbound: an egress picks WHICH uplink the packet leaves by, never
|
||||
// whether it leaves in the clear.
|
||||
idx.kind[ob.Tag] = kindDirect
|
||||
case C.TypeBlock:
|
||||
idx.kind[ob.Tag] = kindBlock
|
||||
case C.TypeSelector:
|
||||
idx.kind[ob.Tag] = kindGroup
|
||||
if o, ok := ob.Options.(*option.SelectorOutboundOptions); ok && o != nil {
|
||||
idx.members[ob.Tag] = o.Outbounds
|
||||
}
|
||||
case C.TypeURLTest:
|
||||
idx.kind[ob.Tag] = kindGroup
|
||||
if o, ok := ob.Options.(*option.URLTestOutboundOptions); ok && o != nil {
|
||||
idx.members[ob.Tag] = o.Outbounds
|
||||
}
|
||||
default:
|
||||
if dialsLoopback(ob.Options) {
|
||||
// A proxy that dials this very router — the `byedpi` egress is a SOCKS5
|
||||
// outbound to 127.0.0.1, where the local ciadpi desync helper listens.
|
||||
// It reshapes the packets, which is worth having, but the connection
|
||||
// still leaves over the plain WAN with this router's own address. Calling
|
||||
// that "going through the tunnel" would be a smaller version of the same
|
||||
// lie this file exists to remove.
|
||||
idx.kind[ob.Tag] = kindDirect
|
||||
} else {
|
||||
idx.kind[ob.Tag] = kindProxy
|
||||
}
|
||||
}
|
||||
}
|
||||
// Endpoints share the outbound tag namespace and are all real tunnels today
|
||||
// (wireguard / AmneziaWG).
|
||||
for _, ep := range opts.Endpoints {
|
||||
idx.kind[ep.Tag] = kindProxy
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// tunnels reports whether traffic sent to tag leaves the router through a tunnel.
|
||||
// An unknown tag is NOT a tunnel: the honest answer to "we have never heard of
|
||||
// this outbound" is "we cannot claim it protects anything".
|
||||
func (idx outboundIndex) tunnels(tag string) bool {
|
||||
return idx.tunnelsSeen(tag, make(map[string]bool, 4))
|
||||
}
|
||||
|
||||
func (idx outboundIndex) tunnelsSeen(tag string, seen map[string]bool) bool {
|
||||
if seen[tag] {
|
||||
// A group cycle cannot survive box.New, but this function also runs against
|
||||
// hand-built options in tests; refusing to recurse forever is free.
|
||||
return false
|
||||
}
|
||||
seen[tag] = true
|
||||
switch idx.kind[tag] {
|
||||
case kindProxy:
|
||||
return true
|
||||
case kindGroup:
|
||||
// A group is a tunnel when ANY member is one. A group of nodes is the normal
|
||||
// case; a group that also holds `direct` still tunnels some of the time, and
|
||||
// "sometimes tunnelled" must not be reported as "never".
|
||||
for _, m := range idx.members[tag] {
|
||||
if idx.tunnelsSeen(m, seen) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ruleRouteOutbound returns the outbound tag a route rule sends matched traffic
|
||||
// to, and false for every rule that routes nothing — the leading sniff and
|
||||
// hijack-dns rules, and the DoH-block reject rules.
|
||||
func ruleRouteOutbound(r option.Rule) (string, bool) {
|
||||
var action option.RuleAction
|
||||
switch r.Type {
|
||||
case C.RuleTypeDefault:
|
||||
action = r.DefaultOptions.RuleAction
|
||||
case C.RuleTypeLogical:
|
||||
action = r.LogicalOptions.RuleAction
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
if action.Action != C.RuleActionTypeRoute {
|
||||
return "", false
|
||||
}
|
||||
tag := action.RouteOptions.Outbound
|
||||
return tag, tag != ""
|
||||
}
|
||||
|
||||
// dialsLoopback reports whether an outbound's server address is this machine.
|
||||
// Only the two protocols a local helper is ever wired as are inspected (SOCKS is
|
||||
// what `byedpi` emits; HTTP is here so the answer does not depend on which of the
|
||||
// two a future helper picks).
|
||||
func dialsLoopback(o any) bool {
|
||||
var server string
|
||||
switch v := o.(type) {
|
||||
case *option.SOCKSOutboundOptions:
|
||||
server = v.Server
|
||||
case *option.HTTPOutboundOptions:
|
||||
server = v.Server
|
||||
default:
|
||||
return false
|
||||
}
|
||||
server = strings.TrimSpace(server)
|
||||
if strings.EqualFold(server, "localhost") {
|
||||
return true
|
||||
}
|
||||
addr, err := netip.ParseAddr(server)
|
||||
return err == nil && addr.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Where the traffic actually ends up (TrafficOf). These are pure option-struct
|
||||
// assertions over a generated config — no box.New — so they build everywhere.
|
||||
//
|
||||
// The case that matters most is TestTrafficProductionDefaultDirectIsNotProtected:
|
||||
// it is the config found on a live router (one enabled rule, `default -> direct`,
|
||||
// no groups, no rule-sets) that the panel painted green and called "Protected".
|
||||
package generate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// trafficOf generates m and returns the verdict, failing on a generate error.
|
||||
func trafficOf(t *testing.T, m *model.Model) Traffic {
|
||||
t.Helper()
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
return TrafficOf(opts)
|
||||
}
|
||||
|
||||
// ssNode is a parseable shadowsocks node — enough for a real proxy outbound.
|
||||
func ssNode(name string) model.Node {
|
||||
return model.Node{Name: name, Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#" + name}
|
||||
}
|
||||
|
||||
// --- the field case ---------------------------------------------------------
|
||||
|
||||
// TestTrafficProductionDefaultDirectIsNotProtected is the regression for the
|
||||
// defect this file exists for. mini_router v0.2.10-r1 ran with exactly this
|
||||
// config: ONE enabled rule named `default`, order 100, target `direct`, zero
|
||||
// rule-sets and zero groups. Every part of the data plane was installed, so
|
||||
// status reported plane="full", and the panel's headline read
|
||||
// "Protected — traffic from your network is going through the tunnel".
|
||||
//
|
||||
// route.Final was `direct`. Nothing was tunnelled at all.
|
||||
func TestTrafficProductionDefaultDirectIsNotProtected(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Rules: []model.Rule{
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "direct"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q — a router whose only rule is `default -> direct` proxies nothing", got.Verdict, VerdictDirect)
|
||||
}
|
||||
if got.Default != tagDirect {
|
||||
t.Errorf("Default = %q, want %q", got.Default, tagDirect)
|
||||
}
|
||||
if got.TunnelRules != 0 {
|
||||
t.Errorf("TunnelRules = %d, want 0", got.TunnelRules)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the three healthy-ish shapes -------------------------------------------
|
||||
|
||||
// A catch-all pointing at a group: everything unmatched is proxied.
|
||||
func TestTrafficDefaultToGroupIsTunnel(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Nodes: []model.Node{ssNode("ss1")},
|
||||
Groups: []model.Group{{Name: "auto", Nodes: []string{"ss1"}}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "group:auto"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictTunnel {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictTunnel, got.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// A conditional rule into a group with a `direct` default: selective protection.
|
||||
// The default still leaves in the clear, and that is what must be said.
|
||||
func TestTrafficDirectDefaultWithProxyRuleIsSplit(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Nodes: []model.Node{ssNode("ss1")},
|
||||
Groups: []model.Group{{Name: "auto", Nodes: []string{"ss1"}}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "lan-via-proxy", Enabled: true, Order: 10, Src: []string{"192.168.1.0/24"}, Target: "group:auto"},
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "direct"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictSplit {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q rules=%d", got.Verdict, VerdictSplit, got.Default, got.TunnelRules)
|
||||
}
|
||||
if got.TunnelRules != 1 {
|
||||
t.Errorf("TunnelRules = %d, want 1", got.TunnelRules)
|
||||
}
|
||||
}
|
||||
|
||||
// No catch-all rule at all with the kill-switch closed: Final is the fail-closed
|
||||
// backstop, so unmatched traffic is DROPPED. Nothing leaks — reporting this as
|
||||
// "going out directly" would be a lie in the other direction.
|
||||
func TestTrafficNoDefaultRuleIsBlocked(t *testing.T) {
|
||||
m := &model.Model{Globals: model.DefaultGlobals()}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictBlocked {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictBlocked, got.Default)
|
||||
}
|
||||
if got.TunnelRules != 0 {
|
||||
t.Errorf("TunnelRules = %d, want 0", got.TunnelRules)
|
||||
}
|
||||
}
|
||||
|
||||
// Kill-switch open with no catch-all: Final is `direct`, so everything unmatched
|
||||
// leaves in the clear.
|
||||
func TestTrafficNoDefaultRuleOpenKillSwitchIsDirect(t *testing.T) {
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "open"
|
||||
got := trafficOf(t, &model.Model{Globals: g})
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictDirect, got.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the shapes only the GENERATED config can reveal ------------------------
|
||||
|
||||
// Two condition-less rules: the LAST one owns route.Final (model.RuleReachability).
|
||||
// A `default -> group` at order 20 followed by a `default -> direct` at order 100
|
||||
// looks protected in the rule list and proxies nothing. Reading the model instead
|
||||
// of the generated config is how a verdict gets this wrong.
|
||||
func TestTrafficShadowedCatchAllFollowsTheWinner(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Nodes: []model.Node{ssNode("ss1")},
|
||||
Groups: []model.Group{{Name: "auto", Nodes: []string{"ss1"}}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "default-proxy", Enabled: true, Order: 20, Target: "group:auto"},
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "direct"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q — the later condition-less rule owns Final", got.Verdict, VerdictDirect)
|
||||
}
|
||||
}
|
||||
|
||||
// An interface egress is a choice of UPLINK, never a tunnel: `default -> egress:wan2`
|
||||
// sends everything out a second WAN, in the clear.
|
||||
func TestTrafficEgressDefaultIsNotATunnel(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Egresses: []model.Egress{{Name: "wan2", Type: "interface", Interface: "eth1"}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "egress:wan2"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictDirect, got.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// A byedpi egress is a SOCKS5 outbound to 127.0.0.1 — the local desync helper.
|
||||
// It reshapes the packets but the connection still leaves over the plain WAN with
|
||||
// this router's address, so it must not be reported as a tunnel.
|
||||
func TestTrafficByedpiEgressIsNotATunnel(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "default", Enabled: true, Order: 100, Target: "egress:bd"},
|
||||
},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictDirect, got.Default)
|
||||
}
|
||||
}
|
||||
|
||||
// --- unit-level: the outbound index ------------------------------------------
|
||||
|
||||
func TestTrafficOfNilRouteIsUnknown(t *testing.T) {
|
||||
if got := (TrafficOf(option.Options{})); got.Verdict != "" {
|
||||
t.Fatalf("Verdict = %q, want \"\" (unknown) for options we did not build", got.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
// A group is a tunnel when any member is, and only then. A selector holding
|
||||
// nothing but `direct` routes nothing anywhere special.
|
||||
func TestTrafficGroupMembershipDecidesTunnel(t *testing.T) {
|
||||
base := []option.Outbound{
|
||||
{Type: C.TypeDirect, Tag: tagDirect, Options: &option.DirectOutboundOptions{}},
|
||||
{Type: C.TypeBlock, Tag: tagBlock, Options: &option.StubOptions{}},
|
||||
{Type: C.TypeShadowsocks, Tag: "ss1", Options: &option.ShadowsocksOutboundOptions{}},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
members []string
|
||||
want string
|
||||
}{
|
||||
{"proxy member", []string{"ss1", tagDirect}, VerdictTunnel},
|
||||
{"direct only", []string{tagDirect}, VerdictDirect},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := option.Options{
|
||||
Outbounds: append(append([]option.Outbound{}, base...), option.Outbound{
|
||||
Type: C.TypeSelector, Tag: "grp",
|
||||
Options: &option.SelectorOutboundOptions{Outbounds: tc.members},
|
||||
}),
|
||||
Route: &option.RouteOptions{Final: "grp"},
|
||||
}
|
||||
if got := TrafficOf(opts); got.Verdict != tc.want {
|
||||
t.Fatalf("Verdict = %q, want %q", got.Verdict, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A group that references itself must not hang the status poll.
|
||||
func TestTrafficGroupCycleTerminates(t *testing.T) {
|
||||
opts := option.Options{
|
||||
Outbounds: []option.Outbound{
|
||||
{Type: C.TypeSelector, Tag: "a", Options: &option.SelectorOutboundOptions{Outbounds: []string{"b"}}},
|
||||
{Type: C.TypeSelector, Tag: "b", Options: &option.SelectorOutboundOptions{Outbounds: []string{"a"}}},
|
||||
},
|
||||
Route: &option.RouteOptions{Final: "a"},
|
||||
}
|
||||
if got := TrafficOf(opts); got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q for a cyclic group with no real member", got.Verdict, VerdictDirect)
|
||||
}
|
||||
}
|
||||
+365
-4
@@ -14,17 +14,40 @@ import (
|
||||
|
||||
// CurrentSchemaVersion is the schema this build understands. Bump it when adding
|
||||
// a migration step below.
|
||||
const CurrentSchemaVersion = 1
|
||||
const CurrentSchemaVersion = 2
|
||||
|
||||
// uciRunner abstracts uci get/set/delete/commit/import so migrations AND the
|
||||
// config-write path (WriteUCI) are unit-testable. Import feeds `uci export`-format
|
||||
// text to `uci import <pkg>` on stdin, replacing the package's staged sections.
|
||||
//
|
||||
// Export/Add/AddList exist for migrations that have to READ the config they are
|
||||
// rewriting and GROW it. migrate1to2 needs both: it reads options the current
|
||||
// Model no longer parses (`dst_domain`/`dst_ip` were removed from Rule) and adds
|
||||
// the `config ruleset` sections it folds them into. Add returns the generated
|
||||
// section id — the anonymous-index drift trap is real (`@ruleset[3]` means
|
||||
// something different after one more add), so every write goes through the id.
|
||||
type uciRunner interface {
|
||||
Get(key string) (string, bool)
|
||||
Set(key, val string) error
|
||||
Delete(key string) error
|
||||
Commit(pkg string) error
|
||||
// Revert drops the package's STAGED (uncommitted) changes.
|
||||
//
|
||||
// A migration stages many writes and commits once, so a failure halfway
|
||||
// through leaves a half-migration sitting in /tmp/.uci — and staged changes
|
||||
// are not private to us: the next `uci commit shater` from ANY process (the
|
||||
// panel saving one setting through WriteUCI, an operator at the shell) flushes
|
||||
// them to disk, producing a config that is half v1 and half v2. Every error
|
||||
// path in a migration must therefore revert before returning.
|
||||
Revert(pkg string) error
|
||||
Import(pkg, text string) error
|
||||
// Export returns the package in `uci export` format. ok=false when the package
|
||||
// does not exist (nothing to migrate), which is NOT an error.
|
||||
Export(pkg string) (text string, ok bool)
|
||||
// Add appends an anonymous `config <secType>` and returns its section id.
|
||||
Add(pkg, secType string) (id string, err error)
|
||||
// AddList appends one value to a list option (`uci add_list <key>=<val>`).
|
||||
AddList(key, val string) error
|
||||
}
|
||||
|
||||
type execUCI struct{}
|
||||
@@ -39,6 +62,7 @@ func (execUCI) Get(k string) (string, bool) {
|
||||
func (execUCI) Set(k, v string) error { return exec.Command("uci", "set", k+"="+v).Run() }
|
||||
func (execUCI) Delete(k string) error { return exec.Command("uci", "-q", "delete", k).Run() }
|
||||
func (execUCI) Commit(p string) error { return exec.Command("uci", "commit", p).Run() }
|
||||
func (execUCI) Revert(p string) error { return exec.Command("uci", "revert", p).Run() }
|
||||
|
||||
func (execUCI) Import(pkg, text string) error {
|
||||
cmd := exec.Command("uci", "import", pkg)
|
||||
@@ -46,6 +70,30 @@ func (execUCI) Import(pkg, text string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (execUCI) Export(pkg string) (string, bool) {
|
||||
out, err := exec.Command("uci", "-q", "export", pkg).Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
func (execUCI) Add(pkg, secType string) (string, error) {
|
||||
out, err := exec.Command("uci", "add", pkg, secType).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("uci add %s %s: %w", pkg, secType, err)
|
||||
}
|
||||
id := strings.TrimSpace(string(out))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("uci add %s %s: no section id returned", pkg, secType)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (execUCI) AddList(k, v string) error {
|
||||
return exec.Command("uci", "add_list", k+"="+v).Run()
|
||||
}
|
||||
|
||||
// uci is the active runner (overridable in tests).
|
||||
var uci uciRunner = execUCI{}
|
||||
|
||||
@@ -56,6 +104,7 @@ type migration struct {
|
||||
|
||||
var migrations = []migration{
|
||||
{from: 0, to: 1, apply: migrate0to1},
|
||||
{from: 1, to: 2, apply: migrate1to2},
|
||||
}
|
||||
|
||||
func readSchemaVersion(u uciRunner) int {
|
||||
@@ -75,9 +124,23 @@ func ensureGlobals(u uciRunner) {
|
||||
func setSchemaVersion(u uciRunner, v int) error {
|
||||
ensureGlobals(u)
|
||||
if err := u.Set("shater.globals.schema_version", strconv.Itoa(v)); err != nil {
|
||||
return err
|
||||
return staged(u, err)
|
||||
}
|
||||
return u.Commit("shater")
|
||||
if err := u.Commit("shater"); err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// staged discards the package's staged writes and returns err unchanged. It is
|
||||
// the one-liner every migration error path goes through: leaving a partial
|
||||
// migration staged lets somebody else's `uci commit shater` write it to disk (see
|
||||
// uciRunner.Revert). A failing revert cannot be reported on top of the original
|
||||
// failure without hiding it, so it is deliberately ignored — err is the one the
|
||||
// operator has to act on, and the revert is best-effort cleanup.
|
||||
func staged(u uciRunner, err error) error {
|
||||
_ = u.Revert("shater")
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate runs pending migrations to CurrentSchemaVersion using the active runner.
|
||||
@@ -121,5 +184,303 @@ func migrate0to1(u uciRunner) error {
|
||||
}
|
||||
_ = u.Delete("shater.globals.kill")
|
||||
}
|
||||
return u.Commit("shater")
|
||||
if err := u.Commit("shater"); err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- v1 -> v2: a rule's destination is a rule-set, never an inline list ------
|
||||
//
|
||||
// WHAT CHANGED. `config rule` lost `dst_domain` and `dst_ip`. A rule now names
|
||||
// its destination through `dst_ruleset` only, so there is ONE destination
|
||||
// mechanism, one matcher vocabulary, and one place a list is edited — and the
|
||||
// list is compiled once into a .srs that every referencing rule shares.
|
||||
//
|
||||
// WHAT THIS STEP DOES. For every rule that still carries one of the two options
|
||||
// it creates an inline `config ruleset` named `rule-<rule name>` (and
|
||||
// `rule-<rule name>-ip` for the address list, because a rule-set is EITHER a
|
||||
// domain list or an ip_cidr list), moves the entries into it, appends the new
|
||||
// name to the rule's `dst_ruleset`, and deletes the legacy option. Nothing is
|
||||
// dropped and nothing is guessed: a rule with both lists gets both rule-sets.
|
||||
//
|
||||
// ENTRY SEMANTICS ARE PRESERVED 1:1, and that needs one real conversion. The two
|
||||
// contexts disagree about exactly one form: a BARE domain is an EXACT match in a
|
||||
// routing rule (generate/route.go classified `dst_domain` with bareIsSuffix=false)
|
||||
// and a SUFFIX match inside a rule-set (inlineDomainRule, bareIsSuffix=true).
|
||||
// Copying `example.com` across verbatim would therefore silently widen the rule to
|
||||
// every subdomain, so a bare entry is rewritten as `full:example.com`. Every other
|
||||
// form already means the same thing on both sides and is copied byte-for-byte:
|
||||
// `full:`, `suffix:`, `keyword:`, `regexp:` (see generate.peelDomainRegexes, added
|
||||
// with this change so the regex form survives the move) and a leading dot, which
|
||||
// is a synonym of `suffix:` in both. `geosite:`/`geoip:` entries are copied
|
||||
// unchanged too: they are INERT in a routing rule on this engine (warned and
|
||||
// omitted — the route-rule geosite/geoip fields no longer exist), and an
|
||||
// unrecognised marker is equally inert inside a rule-set, so their meaning is
|
||||
// unchanged and the operator's text is not thrown away. Converting them into a
|
||||
// `source=geosite` rule-set would have made a dead matcher start routing traffic
|
||||
// during an upgrade — a behaviour change, not a migration.
|
||||
//
|
||||
// ONE DELIBERATE SEMANTIC IMPROVEMENT, stated out loud: a rule that carried BOTH
|
||||
// a domain list and an address list matched them with AND (an engine route rule
|
||||
// ANDs its matcher fields), which is almost never what "these sites and these
|
||||
// networks" was meant to say. 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 is called out here, in the docs, and it only
|
||||
// affects configs that used both fields at once.
|
||||
//
|
||||
// IDEMPOTENCE. The legacy options are deleted as the last step per rule, so a
|
||||
// second run finds nothing to do. A run interrupted between "create the rule-set"
|
||||
// and "delete the option" is also safe: a rule that ALREADY references a rule-set
|
||||
// of the expected name AND WHOSE ENTRIES ARE IN IT reuses it instead of creating
|
||||
// `rule-<name>-2` (see ensureMigratedRuleset — the entry check is what tells our
|
||||
// own half-finished work apart from an operator's hand-written list that happens
|
||||
// to carry the same name).
|
||||
//
|
||||
// A LEGACY OPTION IS DELETED ONLY WHEN ITS CONTENT HAS A NEW HOME. Deleting is
|
||||
// per-list and conditional on that list having produced (or confirmed) a
|
||||
// dst_ruleset reference. Unconditional deletion had a hole: a list whose values
|
||||
// are all blank (`list dst_domain ' '` — a space survives parseSections' empty
|
||||
// check but trims away in migrateDomainEntry) produced no rule-set, and deleting
|
||||
// it anyway left the rule with NO matchers at all, i.e. a catch-all that takes
|
||||
// over the router's default route. Keeping the option leaves the rule visibly
|
||||
// unmigrated instead, which ParseUCIExport holds disabled and ValidateRules
|
||||
// reports.
|
||||
//
|
||||
// ERRORS ABORT AND REVERT. Every write here is staged and committed once at the
|
||||
// end, so a failure that returned without reverting would leave a half-migration
|
||||
// in /tmp/.uci for the next `uci commit shater` (from the panel, say) to flush.
|
||||
// Every error path goes through staged(). Deletes are checked too: a delete that
|
||||
// silently failed left a legacy option in the config forever, because migrateWith
|
||||
// bumps schema_version to 2 on return and readSchemaVersion never asks for this
|
||||
// step again.
|
||||
func migrate1to2(u uciRunner) error {
|
||||
text, ok := u.Export("shater")
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil // no config yet (fresh install): nothing to migrate
|
||||
}
|
||||
secs, err := parseSections(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the current config: %w", err)
|
||||
}
|
||||
|
||||
// Every rule-set name already in use -> its entries, so a generated name can
|
||||
// never collide with a hand-written list (which would make `uci` hold two
|
||||
// `config ruleset` blocks claiming the same name, and the generator drops one as
|
||||
// a duplicate tag). The ENTRIES are carried, not just the name, because
|
||||
// "reuse the existing list" is only correct when that list is the one a previous
|
||||
// run of this migration created.
|
||||
taken := map[string][]string{}
|
||||
for _, s := range secs {
|
||||
if s.Type == "ruleset" {
|
||||
if n := firstNonEmpty(s.opt("name"), s.Name); n != "" {
|
||||
taken[n] = s.list("entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ruleIdx := -1
|
||||
for _, s := range secs {
|
||||
if s.Type != "rule" {
|
||||
continue
|
||||
}
|
||||
// Anonymous sections are addressed positionally and the index is PER TYPE, so
|
||||
// it counts rules only. Appending `config ruleset` sections below cannot shift
|
||||
// it (a new section goes to the end, and it is not a rule).
|
||||
ruleIdx++
|
||||
|
||||
domains := s.list("dst_domain")
|
||||
ips := s.list("dst_ip")
|
||||
if len(domains) == 0 && len(ips) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rulePath := fmt.Sprintf("shater.@rule[%d]", ruleIdx)
|
||||
base := rulesetBaseName(firstNonEmpty(s.opt("name"), s.Name), ruleIdx)
|
||||
refs := s.list("dst_ruleset")
|
||||
|
||||
domainsDone, ipsDone := false, false
|
||||
if len(domains) > 0 {
|
||||
entries := make([]string, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
if e := migrateDomainEntry(d); e != "" {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
domainsDone, err = ensureMigratedRuleset(u, rulePath, base, "domain", entries, refs, taken)
|
||||
if err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
entries := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
if v := strings.TrimSpace(ip); v != "" {
|
||||
entries = append(entries, v)
|
||||
}
|
||||
}
|
||||
ipsDone, err = ensureMigratedRuleset(u, rulePath, base+"-ip", "ipcidr", entries, refs, taken)
|
||||
if err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Last, so an interrupted run still has the legacy list to redo the work from
|
||||
// — and only for a list whose entries actually reached a rule-set.
|
||||
if domainsDone {
|
||||
if err := u.Delete(rulePath + ".dst_domain"); err != nil {
|
||||
return staged(u, fmt.Errorf("drop the migrated dst_domain of rule %d: %w", ruleIdx, err))
|
||||
}
|
||||
}
|
||||
if ipsDone {
|
||||
if err := u.Delete(rulePath + ".dst_ip"); err != nil {
|
||||
return staged(u, fmt.Errorf("drop the migrated dst_ip of rule %d: %w", ruleIdx, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.Commit("shater"); err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMigratedRuleset creates the inline `config ruleset` holding entries and
|
||||
// points rulePath's dst_ruleset at it. want is the preferred name; a collision
|
||||
// with an existing list picks want-2, want-3, ... rsType is "domain" or "ipcidr".
|
||||
// taken maps every rule-set name already in the config to its entries, and is
|
||||
// updated with whatever this call adds.
|
||||
//
|
||||
// It reports whether the entries now have a home — which is what tells the caller
|
||||
// it may delete the legacy option they came from. false with a nil error means
|
||||
// "nothing was written and nothing may be deleted": the only such case is an
|
||||
// entry list that came out EMPTY (a legacy list of blanks). An empty inline
|
||||
// rule-set matches nothing and the generator would skip it, so creating one would
|
||||
// be pure noise — but the legacy option must then survive, or the rule is left
|
||||
// with no matchers at all and silently becomes the default route.
|
||||
//
|
||||
// RESUMING AN INTERRUPTED RUN, WITHOUT SWALLOWING A HAND-WRITTEN LIST. A rule
|
||||
// that already references a rule-set of the expected name is the signature of a
|
||||
// previous run that died between "create the rule-set" and "delete the option" —
|
||||
// but it is ALSO what an operator's own `config ruleset name='rule-ads'` plus a
|
||||
// rule referencing it looks like. Telling them apart takes one more question:
|
||||
// does that rule-set actually CONTAIN these entries? Our own leftover does, by
|
||||
// construction. The operator's list (a remote URL list, say) does not, and
|
||||
// treating it as "already migrated" threw the legacy entries away with no trace.
|
||||
// When the entries are not in it, this falls through to the ordinary collision
|
||||
// path and creates want-2, so the rule ends up referencing both lists — the two
|
||||
// references are ORed, so nothing the operator wrote stops matching.
|
||||
func ensureMigratedRuleset(u uciRunner, rulePath, want, rsType string, entries, existingRefs []string, taken map[string][]string) (bool, error) {
|
||||
if len(entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if have, ok := taken[want]; ok && containsString(existingRefs, want) && containsAll(have, entries) {
|
||||
return true, nil // already migrated (interrupted run); nothing to add
|
||||
}
|
||||
name := want
|
||||
for i := 2; ; i++ {
|
||||
if _, clash := taken[name]; !clash {
|
||||
break
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d", want, i)
|
||||
}
|
||||
taken[name] = entries
|
||||
|
||||
id, err := u.Add("shater", "ruleset")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sec := "shater." + id
|
||||
if err := u.Set(sec+".name", name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := u.Set(sec+".type", rsType); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := u.Set(sec+".source", "inline"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := u.AddList(sec+".entry", e); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if containsString(existingRefs, name) {
|
||||
return true, nil
|
||||
}
|
||||
if err := u.AddList(rulePath+".dst_ruleset", name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// rulesetBaseName builds `rule-<name>` from a rule's name, reduced to characters
|
||||
// that are safe in a rule-set name (it becomes an engine rule-set TAG, `rs-<name>`,
|
||||
// and a UCI option value). An unnamed rule falls back to its position so two of
|
||||
// them cannot produce the same base.
|
||||
func rulesetBaseName(ruleName string, idx int) string {
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range strings.TrimSpace(ruleName) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.':
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
default:
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
slug := strings.Trim(b.String(), "-.")
|
||||
if slug == "" {
|
||||
slug = strconv.Itoa(idx)
|
||||
}
|
||||
return "rule-" + slug
|
||||
}
|
||||
|
||||
// migrateDomainEntry rewrites ONE `dst_domain` entry into the rule-set spelling
|
||||
// with the same meaning. Only the bare form differs between the two contexts
|
||||
// (exact in a rule, suffix in a rule-set), so only it is rewritten; see the
|
||||
// migrate1to2 doc comment for the full table and the reasoning.
|
||||
func migrateDomainEntry(e string) string {
|
||||
v := strings.TrimSpace(e)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
// A leading dot already means `suffix:` on both sides.
|
||||
if strings.HasPrefix(v, ".") {
|
||||
return v
|
||||
}
|
||||
// Any `word:` marker — recognised (full/suffix/keyword/regexp) or not
|
||||
// (geosite/geoip/typos) — carries its meaning across unchanged. A domain label
|
||||
// cannot contain a colon, so this cannot misfire on a real host name.
|
||||
if strings.Contains(v, ":") {
|
||||
return v
|
||||
}
|
||||
return "full:" + v
|
||||
}
|
||||
|
||||
// containsString reports whether list holds want (trimmed comparison).
|
||||
func containsString(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if strings.TrimSpace(v) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAll reports whether every entry in want is present in have. It answers
|
||||
// exactly one question — "is this rule-set the one a previous run of this
|
||||
// migration wrote?" — so it is a SUBSET test, not equality: a rule-set that also
|
||||
// holds entries the operator added by hand since is still ours to reuse.
|
||||
func containsAll(have, want []string) bool {
|
||||
for _, w := range want {
|
||||
if !containsString(have, strings.TrimSpace(w)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+919
-21
@@ -1,53 +1,951 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeUCI is an in-memory uciRunner for the migration + write tests (no router
|
||||
// needed). imported records the last `uci import` text so WriteUCI can be
|
||||
// asserted without a device.
|
||||
// fakeUCI is an in-memory stand-in for the `uci` CLI: enough of a section model
|
||||
// that a migration can EXPORT the config, rewrite it, and export it again and see
|
||||
// its own writes. That is what makes the idempotence assertions below real —
|
||||
// against a flat key/value map a second migration run would re-read the original
|
||||
// text and "prove" nothing.
|
||||
//
|
||||
// Addressing mirrors uci: `shater.globals.opt` (named section), `shater.@rule[2]`
|
||||
// (positional, index is PER TYPE), `shater.cfg001.opt` (the id `uci add` returns).
|
||||
type fakeUCI struct {
|
||||
kv map[string]string
|
||||
secs []*fakeSection
|
||||
imported string
|
||||
commits int
|
||||
reverts int
|
||||
deleted []string
|
||||
nextID int
|
||||
// missing makes Export report "no such package", the fresh-install case.
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Get(k string) (string, bool) { v, ok := f.kv[k]; return v, ok }
|
||||
func (f *fakeUCI) Set(k, v string) error { f.kv[k] = v; return nil }
|
||||
func (f *fakeUCI) Delete(k string) error { f.deleted = append(f.deleted, k); delete(f.kv, k); return nil }
|
||||
func (f *fakeUCI) Commit(string) error { f.commits++; return nil }
|
||||
func (f *fakeUCI) Import(pkg, text string) error { f.imported = text; return nil }
|
||||
type fakeSection struct {
|
||||
id string
|
||||
typ string
|
||||
name string // "" for an anonymous section
|
||||
opts map[string]string
|
||||
oKeys []string // option order, so the rendered export is deterministic
|
||||
lists map[string][]string
|
||||
lKeys []string
|
||||
}
|
||||
|
||||
// loadSections copies parsed sections into the fake, in a DETERMINISTIC order.
|
||||
//
|
||||
// uciSection carries its options and lists in maps, and Go randomises map
|
||||
// iteration, so feeding them to setOpt/addList in range order made oKeys/lKeys —
|
||||
// and therefore the rendered Export — differ run to run. That is not cosmetic
|
||||
// here: TestMigrateIdempotent and TestMigrate1to2IsIdempotent compare two export
|
||||
// strings, so a random key order turned them into coin flips that fail a few
|
||||
// percent of the time and look like a migration bug. Sorting by key restores the
|
||||
// determinism the oKeys/lKeys fields were added for.
|
||||
func (f *fakeUCI) loadSections(secs []uciSection) {
|
||||
for _, s := range secs {
|
||||
sec := f.newSection(s.Type, s.Name)
|
||||
for _, k := range sortedKeys(s.Options) {
|
||||
sec.setOpt(k, s.Options[k])
|
||||
}
|
||||
for _, k := range sortedKeys(s.Lists) {
|
||||
for _, v := range s.Lists[k] {
|
||||
sec.addList(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func newFakeUCI(export string) *fakeUCI {
|
||||
f := &fakeUCI{}
|
||||
if strings.TrimSpace(export) == "" {
|
||||
return f
|
||||
}
|
||||
secs, err := parseSections(export)
|
||||
if err != nil {
|
||||
panic("fakeUCI fixture: " + err.Error())
|
||||
}
|
||||
f.loadSections(secs)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeUCI) newSection(typ, name string) *fakeSection {
|
||||
sec := &fakeSection{
|
||||
id: fmt.Sprintf("cfg%03d", f.nextID),
|
||||
typ: typ,
|
||||
name: name,
|
||||
opts: map[string]string{},
|
||||
lists: map[string][]string{},
|
||||
}
|
||||
f.nextID++
|
||||
f.secs = append(f.secs, sec)
|
||||
return sec
|
||||
}
|
||||
|
||||
func (s *fakeSection) setOpt(k, v string) {
|
||||
if _, seen := s.opts[k]; !seen {
|
||||
s.oKeys = append(s.oKeys, k)
|
||||
}
|
||||
s.opts[k] = v
|
||||
}
|
||||
|
||||
func (s *fakeSection) addList(k, v string) {
|
||||
if _, seen := s.lists[k]; !seen {
|
||||
s.lKeys = append(s.lKeys, k)
|
||||
}
|
||||
s.lists[k] = append(s.lists[k], v)
|
||||
}
|
||||
|
||||
// resolve finds the section a `<pkg>.<sel>` selector names.
|
||||
func (f *fakeUCI) resolve(sel string) *fakeSection {
|
||||
if strings.HasPrefix(sel, "@") && strings.HasSuffix(sel, "]") {
|
||||
open := strings.IndexByte(sel, '[')
|
||||
if open < 0 {
|
||||
return nil
|
||||
}
|
||||
typ := sel[1:open]
|
||||
idx, err := strconv.Atoi(sel[open+1 : len(sel)-1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n := 0
|
||||
for _, s := range f.secs {
|
||||
if s.typ != typ {
|
||||
continue
|
||||
}
|
||||
if n == idx {
|
||||
return s
|
||||
}
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, s := range f.secs {
|
||||
if s.name == sel || s.id == sel {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitKey cuts "shater.@rule[0].dst_domain" into ("@rule[0]", "dst_domain").
|
||||
// A key with no option part yields opt == "".
|
||||
func splitKey(key string) (sel, opt string) {
|
||||
rest := strings.TrimPrefix(key, "shater")
|
||||
rest = strings.TrimPrefix(rest, ".")
|
||||
if rest == "" {
|
||||
return "", ""
|
||||
}
|
||||
// The selector may contain a dot only inside a name, which the fixtures never
|
||||
// use, so a plain LastIndex is enough — except for `@type[i]`, where the index
|
||||
// brackets hold no dots either.
|
||||
if i := strings.LastIndexByte(rest, '.'); i >= 0 {
|
||||
return rest[:i], rest[i+1:]
|
||||
}
|
||||
return rest, ""
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Get(k string) (string, bool) {
|
||||
sel, opt := splitKey(k)
|
||||
sec := f.resolve(sel)
|
||||
if sec == nil {
|
||||
return "", false
|
||||
}
|
||||
if opt == "" {
|
||||
return sec.typ, true
|
||||
}
|
||||
v, ok := sec.opts[opt]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Set(k, v string) error {
|
||||
sel, opt := splitKey(k)
|
||||
sec := f.resolve(sel)
|
||||
if sec == nil {
|
||||
if opt != "" {
|
||||
return fmt.Errorf("uci set %s: no such section", k)
|
||||
}
|
||||
// `uci set shater.globals=globals` creates the named section.
|
||||
f.newSection(v, sel)
|
||||
return nil
|
||||
}
|
||||
if opt == "" {
|
||||
sec.typ = v
|
||||
return nil
|
||||
}
|
||||
sec.setOpt(opt, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Delete(k string) error {
|
||||
f.deleted = append(f.deleted, k)
|
||||
if k == "shater" {
|
||||
f.secs = nil
|
||||
return nil
|
||||
}
|
||||
sel, opt := splitKey(k)
|
||||
sec := f.resolve(sel)
|
||||
if sec == nil {
|
||||
return nil // `uci -q delete` on an absent key is a no-op
|
||||
}
|
||||
if opt == "" {
|
||||
for i, s := range f.secs {
|
||||
if s == sec {
|
||||
f.secs = append(f.secs[:i], f.secs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
delete(sec.opts, opt)
|
||||
delete(sec.lists, opt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Commit(string) error { f.commits++; return nil }
|
||||
|
||||
// Revert is COUNTED, not simulated: this fake applies every write immediately
|
||||
// (there is no staging area to roll back), so what a test can assert is that the
|
||||
// migration ASKED for a revert on its way out of a failure. That is the property
|
||||
// that matters — a real `uci` keeps the staged delta in /tmp/.uci until somebody
|
||||
// commits it, and the migration's job is to not leave it there.
|
||||
func (f *fakeUCI) Revert(string) error { f.reverts++; return nil }
|
||||
|
||||
func (f *fakeUCI) Import(_, text string) error {
|
||||
f.imported = text
|
||||
secs, err := parseSections(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.loadSections(secs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Export(string) (string, bool) {
|
||||
if f.missing {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("package shater\n")
|
||||
for _, s := range f.secs {
|
||||
b.WriteString("\nconfig " + s.typ)
|
||||
if s.name != "" {
|
||||
b.WriteString(" '" + s.name + "'")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, k := range s.oKeys {
|
||||
if v, ok := s.opts[k]; ok {
|
||||
b.WriteString("\toption " + k + " '" + v + "'\n")
|
||||
}
|
||||
}
|
||||
for _, k := range s.lKeys {
|
||||
for _, v := range s.lists[k] {
|
||||
b.WriteString("\tlist " + k + " '" + v + "'\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
func (f *fakeUCI) Add(_, secType string) (string, error) {
|
||||
return f.newSection(secType, "").id, nil
|
||||
}
|
||||
|
||||
func (f *fakeUCI) AddList(k, v string) error {
|
||||
sel, opt := splitKey(k)
|
||||
sec := f.resolve(sel)
|
||||
if sec == nil {
|
||||
return fmt.Errorf("uci add_list %s: no such section", k)
|
||||
}
|
||||
sec.addList(opt, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ruleset returns the `config ruleset` section carrying option name == name.
|
||||
func (f *fakeUCI) ruleset(name string) *fakeSection {
|
||||
for _, s := range f.secs {
|
||||
if s.typ == "ruleset" && s.opts["name"] == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rule returns the n-th `config rule` section.
|
||||
func (f *fakeUCI) rule(idx int) *fakeSection { return f.resolve(fmt.Sprintf("@rule[%d]", idx)) }
|
||||
|
||||
func (f *fakeUCI) rulesetNames() []string {
|
||||
var out []string
|
||||
for _, s := range f.secs {
|
||||
if s.typ == "ruleset" {
|
||||
out = append(out, s.opts["name"])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func eqStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMigrate0to1TransformsFixture(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.kill": "open"}}
|
||||
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption kill 'open'\n")
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if f.kv["shater.globals.kill_switch"] != "open" {
|
||||
t.Fatalf("kill_switch = %q", f.kv["shater.globals.kill_switch"])
|
||||
if v, _ := f.Get("shater.globals.kill_switch"); v != "open" {
|
||||
t.Fatalf("kill_switch = %q", v)
|
||||
}
|
||||
if _, ok := f.kv["shater.globals.kill"]; ok {
|
||||
if _, ok := f.Get("shater.globals.kill"); ok {
|
||||
t.Fatal("legacy kill not removed")
|
||||
}
|
||||
if f.kv["shater.globals.schema_version"] != "1" {
|
||||
t.Fatalf("schema_version = %q", f.kv["shater.globals.schema_version"])
|
||||
if v, _ := f.Get("shater.globals.schema_version"); v != strconv.Itoa(CurrentSchemaVersion) {
|
||||
t.Fatalf("schema_version = %q, want %d", v, CurrentSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "1"}}
|
||||
before := len(f.kv)
|
||||
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption schema_version '" +
|
||||
strconv.Itoa(CurrentSchemaVersion) + "'\n")
|
||||
before, _ := f.Export("shater")
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if len(f.kv) != before {
|
||||
t.Fatal("idempotent migrate changed state")
|
||||
after, _ := f.Export("shater")
|
||||
if before != after {
|
||||
t.Fatalf("idempotent migrate changed state:\n--- before\n%s\n--- after\n%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesNewer(t *testing.T) {
|
||||
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "99"}}
|
||||
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption schema_version '99'\n")
|
||||
if err := migrateWith(f); err == nil {
|
||||
t.Fatal("expected refusal of newer schema")
|
||||
}
|
||||
}
|
||||
|
||||
// --- v1 -> v2: dst_domain / dst_ip fold into generated rule-sets -------------
|
||||
|
||||
// legacyConfig is the shape a v1 config has: rules matching destinations inline.
|
||||
// The `ru-direct` rule is copied verbatim from the live router this change was
|
||||
// written against (BananaWRT 25.12.1, shater 0.2.7-r1).
|
||||
const legacyConfig = `package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config rule
|
||||
option name 'ru-direct'
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list dst_domain 'suffix:ru'
|
||||
list dst_domain 'suffix:yandex.net'
|
||||
list dst_domain 'full:vk.com'
|
||||
list dst_domain 'keyword:sberbank'
|
||||
list dst_domain '.gosuslugi.ru'
|
||||
list dst_domain 'plain.example'
|
||||
option target 'direct'
|
||||
|
||||
config rule
|
||||
option name 'corp nets!'
|
||||
option enabled '1'
|
||||
option order '20'
|
||||
list dst_ip '10.0.0.0/8'
|
||||
list dst_ip '192.168.44.0/24'
|
||||
option target 'group:corp'
|
||||
|
||||
config rule
|
||||
option name 'default'
|
||||
option enabled '1'
|
||||
option order '100'
|
||||
option target 'direct'
|
||||
`
|
||||
|
||||
func TestMigrate1to2FoldsDomainsIntoARuleset(t *testing.T) {
|
||||
f := newFakeUCI(legacyConfig)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
rs := f.ruleset("rule-ru-direct")
|
||||
if rs == nil {
|
||||
t.Fatalf("no rule-ru-direct ruleset; got %v", f.rulesetNames())
|
||||
}
|
||||
if rs.opts["type"] != "domain" || rs.opts["source"] != "inline" {
|
||||
t.Fatalf("ruleset type/source = %q/%q, want domain/inline", rs.opts["type"], rs.opts["source"])
|
||||
}
|
||||
// Every entry keeps its meaning. Only the BARE one is rewritten: bare means
|
||||
// EXACT in a rule and SUFFIX in a rule-set, so it becomes `full:`.
|
||||
want := []string{
|
||||
"suffix:ru", "suffix:yandex.net", "full:vk.com",
|
||||
"keyword:sberbank", ".gosuslugi.ru", "full:plain.example",
|
||||
}
|
||||
if !eqStrings(rs.lists["entry"], want) {
|
||||
t.Fatalf("entries = %q\nwant %q", rs.lists["entry"], want)
|
||||
}
|
||||
|
||||
r0 := f.rule(0)
|
||||
if !eqStrings(r0.lists["dst_ruleset"], []string{"rule-ru-direct"}) {
|
||||
t.Fatalf("dst_ruleset = %q", r0.lists["dst_ruleset"])
|
||||
}
|
||||
if _, ok := r0.lists["dst_domain"]; ok {
|
||||
t.Fatal("dst_domain survived the migration")
|
||||
}
|
||||
// Order and every other option are untouched.
|
||||
if r0.opts["order"] != "10" || r0.opts["target"] != "direct" || r0.opts["name"] != "ru-direct" {
|
||||
t.Fatalf("rule 0 mangled: %v", r0.opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate1to2FoldsCIDRsIntoAnIPRuleset(t *testing.T) {
|
||||
f := newFakeUCI(legacyConfig)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// The rule name is slugged: a rule-set name becomes an engine tag.
|
||||
rs := f.ruleset("rule-corp-nets-ip")
|
||||
if rs == nil {
|
||||
t.Fatalf("no rule-corp-nets-ip ruleset; got %v", f.rulesetNames())
|
||||
}
|
||||
if rs.opts["type"] != "ipcidr" {
|
||||
t.Fatalf("ruleset type = %q, want ipcidr", rs.opts["type"])
|
||||
}
|
||||
if !eqStrings(rs.lists["entry"], []string{"10.0.0.0/8", "192.168.44.0/24"}) {
|
||||
t.Fatalf("entries = %q", rs.lists["entry"])
|
||||
}
|
||||
r1 := f.rule(1)
|
||||
if !eqStrings(r1.lists["dst_ruleset"], []string{"rule-corp-nets-ip"}) {
|
||||
t.Fatalf("dst_ruleset = %q", r1.lists["dst_ruleset"])
|
||||
}
|
||||
if _, ok := r1.lists["dst_ip"]; ok {
|
||||
t.Fatal("dst_ip survived the migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A rule with no destination at all stays a catch-all — the B1 reachability
|
||||
// analysis (model.RuleReachability) keys off exactly that, so the migration must
|
||||
// not hand it a rule-set it never asked for.
|
||||
func TestMigrate1to2LeavesCatchAllAlone(t *testing.T) {
|
||||
f := newFakeUCI(legacyConfig)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
r2 := f.rule(2)
|
||||
if len(r2.lists["dst_ruleset"]) != 0 {
|
||||
t.Fatalf("catch-all gained a ruleset: %q", r2.lists["dst_ruleset"])
|
||||
}
|
||||
text, _ := f.Export("shater")
|
||||
m, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse migrated config: %v", err)
|
||||
}
|
||||
if len(m.Rules) != 3 {
|
||||
t.Fatalf("rules = %d, want 3", len(m.Rules))
|
||||
}
|
||||
if !IsCatchAll(m.Rules[2]) {
|
||||
t.Fatalf("rule %q stopped being a catch-all after the migration", m.Rules[2].Name)
|
||||
}
|
||||
for _, i := range []int{0, 1} {
|
||||
if IsCatchAll(m.Rules[i]) {
|
||||
t.Fatalf("rule %q became a catch-all — its destination was lost", m.Rules[i].Name)
|
||||
}
|
||||
}
|
||||
reach := RuleReachability(m.Rules)
|
||||
for i, v := range reach {
|
||||
if v.Unreachable {
|
||||
t.Fatalf("rule %d (%q) reported unreachable after the migration: %s", i, v.Name, v.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Running the migration twice must not duplicate rule-sets or references. The
|
||||
// second run goes through migrate1to2 directly, because migrateWith is gated by
|
||||
// schema_version and would (correctly) do nothing at all.
|
||||
func TestMigrate1to2IsIdempotent(t *testing.T) {
|
||||
f := newFakeUCI(legacyConfig)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
first, _ := f.Export("shater")
|
||||
|
||||
if err := migrate1to2(f); err != nil {
|
||||
t.Fatalf("second migrate1to2: %v", err)
|
||||
}
|
||||
second, _ := f.Export("shater")
|
||||
if first != second {
|
||||
t.Fatalf("re-running the migration changed the config:\n--- first\n%s\n--- second\n%s", first, second)
|
||||
}
|
||||
|
||||
// And a full migrateWith re-run (schema already at CurrentSchemaVersion) is a
|
||||
// no-op too.
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("third migrate: %v", err)
|
||||
}
|
||||
third, _ := f.Export("shater")
|
||||
if third != second {
|
||||
t.Fatalf("re-running migrateWith changed the config:\n%s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted run — the rule-set was created and referenced, but the legacy
|
||||
// option was not deleted yet — must reuse the rule-set rather than make a second.
|
||||
func TestMigrate1to2ResumesAnInterruptedRun(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config ruleset
|
||||
option name 'rule-half'
|
||||
option type 'domain'
|
||||
option source 'inline'
|
||||
list entry 'full:a.example'
|
||||
|
||||
config rule
|
||||
option name 'half'
|
||||
list dst_domain 'a.example'
|
||||
list dst_ruleset 'rule-half'
|
||||
option target 'direct'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if names := f.rulesetNames(); !eqStrings(names, []string{"rule-half"}) {
|
||||
t.Fatalf("rulesets = %q, want just rule-half", names)
|
||||
}
|
||||
r := f.rule(0)
|
||||
if !eqStrings(r.lists["dst_ruleset"], []string{"rule-half"}) {
|
||||
t.Fatalf("dst_ruleset = %q", r.lists["dst_ruleset"])
|
||||
}
|
||||
if _, ok := r.lists["dst_domain"]; ok {
|
||||
t.Fatal("dst_domain survived")
|
||||
}
|
||||
}
|
||||
|
||||
// A hand-written rule-set already owning the generated name must not be
|
||||
// clobbered: two `config ruleset` blocks with one name collide on the engine tag
|
||||
// and one of them is dropped.
|
||||
func TestMigrate1to2AvoidsNameCollisions(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config ruleset
|
||||
option name 'rule-ads'
|
||||
option type 'domain'
|
||||
option source 'url'
|
||||
option url 'https://example.invalid/list.txt'
|
||||
|
||||
config rule
|
||||
option name 'ads'
|
||||
list dst_domain 'ads.example'
|
||||
option target 'block'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
names := f.rulesetNames()
|
||||
if !eqStrings(names, []string{"rule-ads", "rule-ads-2"}) {
|
||||
t.Fatalf("rulesets = %q, want rule-ads + rule-ads-2", names)
|
||||
}
|
||||
if got := f.ruleset("rule-ads").opts["source"]; got != "url" {
|
||||
t.Fatalf("the hand-written list was overwritten (source = %q)", got)
|
||||
}
|
||||
if !eqStrings(f.rule(0).lists["dst_ruleset"], []string{"rule-ads-2"}) {
|
||||
t.Fatalf("dst_ruleset = %q", f.rule(0).lists["dst_ruleset"])
|
||||
}
|
||||
}
|
||||
|
||||
// A hand-written rule-set the rule ALREADY references must not swallow the legacy
|
||||
// entries. `taken[want] && rule references want` alone reads an operator's own
|
||||
// `rule-ads` list as our own half-finished work from an interrupted run, and the
|
||||
// legacy `ads.example` was then dropped with nothing said. The entries have to
|
||||
// actually BE in that rule-set for it to count as ours.
|
||||
func TestMigrate1to2DoesNotFoldIntoAReferencedHandWrittenRuleset(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config ruleset
|
||||
option name 'rule-ads'
|
||||
option type 'domain'
|
||||
option source 'url'
|
||||
option url 'https://example.invalid/list.txt'
|
||||
|
||||
config rule
|
||||
option name 'ads'
|
||||
list dst_domain 'ads.example'
|
||||
list dst_ruleset 'rule-ads'
|
||||
option target 'block'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
// The operator's list is untouched...
|
||||
if got := f.ruleset("rule-ads").opts["source"]; got != "url" {
|
||||
t.Fatalf("the hand-written list was overwritten (source = %q)", got)
|
||||
}
|
||||
if got := f.ruleset("rule-ads").lists["entry"]; len(got) != 0 {
|
||||
t.Fatalf("entries were injected into the hand-written list: %q", got)
|
||||
}
|
||||
// ...and the legacy entry landed in a new list of its own.
|
||||
rs := f.ruleset("rule-ads-2")
|
||||
if rs == nil {
|
||||
t.Fatalf("legacy entry was dropped; rulesets = %q", f.rulesetNames())
|
||||
}
|
||||
if !eqStrings(rs.lists["entry"], []string{"full:ads.example"}) {
|
||||
t.Fatalf("entries = %q", rs.lists["entry"])
|
||||
}
|
||||
// Both references stand: rule_set is ORed, so the operator's list keeps
|
||||
// matching everything it matched before.
|
||||
if !eqStrings(f.rule(0).lists["dst_ruleset"], []string{"rule-ads", "rule-ads-2"}) {
|
||||
t.Fatalf("dst_ruleset = %q", f.rule(0).lists["dst_ruleset"])
|
||||
}
|
||||
if _, ok := f.rule(0).lists["dst_domain"]; ok {
|
||||
t.Fatal("dst_domain survived a completed migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy list whose values are all blank produces no rule-set — so the option
|
||||
// must NOT be deleted. Deleting it left the rule with zero matchers, which is the
|
||||
// spelling of a catch-all: a rule that previously matched nothing would have
|
||||
// become the router's default route. Keeping it leaves the rule visibly
|
||||
// unmigrated, which the parser then holds disabled.
|
||||
func TestMigrate1to2KeepsALegacyListThatMigratesToNothing(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config rule
|
||||
option name 'blank'
|
||||
list dst_domain ' '
|
||||
option target 'direct'
|
||||
|
||||
config rule
|
||||
option name 'default'
|
||||
option target 'block'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if names := f.rulesetNames(); len(names) != 0 {
|
||||
t.Fatalf("an empty rule-set was created: %q", names)
|
||||
}
|
||||
if got := f.rule(0).lists["dst_domain"]; !eqStrings(got, []string{" "}) {
|
||||
t.Fatalf("dst_domain = %q, want it left in place", got)
|
||||
}
|
||||
|
||||
text, _ := f.Export("shater")
|
||||
m, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if IsCatchAll(m.Rules[0]) {
|
||||
t.Fatal("a rule with an unmigrated dst_domain became a catch-all")
|
||||
}
|
||||
if m.Rules[0].Enabled {
|
||||
t.Fatal("a rule with an unmigrated dst_domain stayed enabled")
|
||||
}
|
||||
// And it does not take the default route away from the rule that owns it.
|
||||
if !IsCatchAll(m.Rules[1]) || !m.Rules[1].Enabled {
|
||||
t.Fatal("the real catch-all was disturbed")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNMIGRATED config (the migration never ran, or could not commit) must not
|
||||
// silently reroute the whole router. This is the failure the parser guard exists
|
||||
// for: `list dst_domain 'bank.ru'` + `option target 'direct'` used to parse as a
|
||||
// rule with no matchers at all, i.e. a catch-all, and generate points route Final
|
||||
// at the LAST catch-all — so one uncommitted `uci commit` sent every packet out
|
||||
// the plain WAN with the router's real address.
|
||||
func TestUnmigratedRuleIsNeverACatchAll(t *testing.T) {
|
||||
const v1 = `package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config rule
|
||||
option name 'bank'
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list dst_domain 'bank.ru'
|
||||
option target 'direct'
|
||||
|
||||
config rule
|
||||
option name 'ips'
|
||||
option enabled '1'
|
||||
option order '20'
|
||||
list dst_ip '10.0.0.0/8'
|
||||
option target 'group:corp'
|
||||
|
||||
config rule
|
||||
option name 'default'
|
||||
option enabled '1'
|
||||
option order '100'
|
||||
option target 'group:auto'
|
||||
`
|
||||
m, err := ParseUCIExport(v1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(m.Rules) != 3 {
|
||||
t.Fatalf("rules = %d, want 3", len(m.Rules))
|
||||
}
|
||||
for _, i := range []int{0, 1} {
|
||||
r := m.Rules[i]
|
||||
if len(r.LegacyDst) == 0 {
|
||||
t.Fatalf("rule %q: the live legacy option went unnoticed", r.Name)
|
||||
}
|
||||
if IsCatchAll(r) {
|
||||
t.Fatalf("rule %q became a catch-all — it would take over route Final", r.Name)
|
||||
}
|
||||
if r.Enabled {
|
||||
t.Fatalf("rule %q stayed enabled with an unreadable destination", r.Name)
|
||||
}
|
||||
}
|
||||
if m.Rules[0].LegacyDst[0] != "dst_domain=bank.ru" || m.Rules[1].LegacyDst[0] != "dst_ip=10.0.0.0/8" {
|
||||
t.Fatalf("LegacyDst = %q / %q", m.Rules[0].LegacyDst, m.Rules[1].LegacyDst)
|
||||
}
|
||||
// The real default is untouched, and it is the only one.
|
||||
if !IsCatchAll(m.Rules[2]) || !m.Rules[2].Enabled {
|
||||
t.Fatal("the genuine catch-all was disturbed")
|
||||
}
|
||||
|
||||
// The operator is told, through the ordinary config-warning channel.
|
||||
warns := ValidateRules(m.Rules, nil)
|
||||
if len(warns) != 2 {
|
||||
t.Fatalf("warnings = %d (%v), want one per unmigrated rule", len(warns), warns)
|
||||
}
|
||||
for _, w := range warns {
|
||||
if w.Section != "rule" || !strings.Contains(w.Message, "shaterd migrate") {
|
||||
t.Fatalf("warning does not point at the fix: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile may not hand such a rule its Enabled bit back either.
|
||||
prof := &Profile{Name: "home", EnableRules: []string{"bank"}}
|
||||
got, pwarns := ApplyProfileRuleOverrides(m.Rules, prof)
|
||||
if got[0].Enabled {
|
||||
t.Fatal("a profile re-enabled an unmigrated rule")
|
||||
}
|
||||
if len(pwarns) != 1 {
|
||||
t.Fatalf("profile warnings = %v, want one refusal", pwarns)
|
||||
}
|
||||
|
||||
// And after the migration the same config is fully live again.
|
||||
f := newFakeUCI(v1)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
text, _ := f.Export("shater")
|
||||
mm, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse migrated: %v", err)
|
||||
}
|
||||
for i, r := range mm.Rules {
|
||||
if len(r.LegacyDst) != 0 {
|
||||
t.Fatalf("rule %d still flagged unmigrated: %q", i, r.LegacyDst)
|
||||
}
|
||||
if !r.Enabled {
|
||||
t.Fatalf("rule %d stayed disabled after a successful migration", i)
|
||||
}
|
||||
}
|
||||
if len(ValidateRules(mm.Rules, nil)) != 0 {
|
||||
t.Fatalf("a migrated config still warns: %v", ValidateRules(mm.Rules, nil))
|
||||
}
|
||||
}
|
||||
|
||||
// failingUCI fails one named write, so the abort path can be observed.
|
||||
type failingUCI struct {
|
||||
*fakeUCI
|
||||
failAddList bool
|
||||
failDelete bool
|
||||
}
|
||||
|
||||
var errFake = errors.New("uci: no space left on device")
|
||||
|
||||
func (f *failingUCI) AddList(k, v string) error {
|
||||
if f.failAddList {
|
||||
return errFake
|
||||
}
|
||||
return f.fakeUCI.AddList(k, v)
|
||||
}
|
||||
|
||||
func (f *failingUCI) Delete(k string) error {
|
||||
if f.failDelete && strings.Contains(k, "dst_") {
|
||||
return errFake
|
||||
}
|
||||
return f.fakeUCI.Delete(k)
|
||||
}
|
||||
|
||||
// A failed write must abort the migration AND drop the staged half-migration.
|
||||
// Without the revert the partial rewrite sits in /tmp/.uci until the next
|
||||
// `uci commit shater` from any process (the panel saving one setting) flushes a
|
||||
// config that is half v1 and half v2 onto the disk.
|
||||
func TestMigrate1to2RevertsOnWriteFailure(t *testing.T) {
|
||||
f := &failingUCI{fakeUCI: newFakeUCI(legacyConfig), failAddList: true}
|
||||
err := migrateWith(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected the failed write to abort the migration")
|
||||
}
|
||||
if f.reverts == 0 {
|
||||
t.Fatal("the staged half-migration was left behind (no revert)")
|
||||
}
|
||||
if f.commits != 0 {
|
||||
t.Fatalf("committed %d times despite the failure", f.commits)
|
||||
}
|
||||
if v, _ := f.Get("shater.globals.schema_version"); v != "1" {
|
||||
t.Fatalf("schema_version = %q — a failed migration must not claim v2", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete that fails must abort too. Swallowing it left the legacy option in the
|
||||
// config forever: migrateWith bumps schema_version to 2 on return, and the step
|
||||
// that would have removed it never runs again.
|
||||
func TestMigrate1to2FailsLoudlyWhenALegacyOptionCannotBeDeleted(t *testing.T) {
|
||||
f := &failingUCI{fakeUCI: newFakeUCI(legacyConfig), failDelete: true}
|
||||
if err := migrateWith(f); err == nil {
|
||||
t.Fatal("a failed delete was swallowed")
|
||||
}
|
||||
if f.reverts == 0 {
|
||||
t.Fatal("no revert after the failed delete")
|
||||
}
|
||||
if v, _ := f.Get("shater.globals.schema_version"); v == "2" {
|
||||
t.Fatal("schema_version reached v2 with a legacy option still in the config")
|
||||
}
|
||||
}
|
||||
|
||||
// A rule carrying BOTH lists gets both rule-sets, and keeps every entry.
|
||||
func TestMigrate1to2SplitsMixedRuleIntoTwoRulesets(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config rule
|
||||
option name 'mixed'
|
||||
list dst_domain 'regexp:^ads\.'
|
||||
list dst_ip '203.0.113.0/24'
|
||||
option target 'block'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dom := f.ruleset("rule-mixed")
|
||||
ip := f.ruleset("rule-mixed-ip")
|
||||
if dom == nil || ip == nil {
|
||||
t.Fatalf("want rule-mixed + rule-mixed-ip, got %q", f.rulesetNames())
|
||||
}
|
||||
// `regexp:` crosses over untouched (generate.peelDomainRegexes reads it).
|
||||
if !eqStrings(dom.lists["entry"], []string{`regexp:^ads\.`}) {
|
||||
t.Fatalf("domain entries = %q", dom.lists["entry"])
|
||||
}
|
||||
if !eqStrings(ip.lists["entry"], []string{"203.0.113.0/24"}) {
|
||||
t.Fatalf("ip entries = %q", ip.lists["entry"])
|
||||
}
|
||||
if !eqStrings(f.rule(0).lists["dst_ruleset"], []string{"rule-mixed", "rule-mixed-ip"}) {
|
||||
t.Fatalf("dst_ruleset = %q", f.rule(0).lists["dst_ruleset"])
|
||||
}
|
||||
}
|
||||
|
||||
// An inert `geosite:` matcher is copied verbatim rather than promoted to a
|
||||
// source=geosite list: it matched nothing before the upgrade (the engine's
|
||||
// route-rule geosite field is gone) and must not start routing traffic because of
|
||||
// one. The text is kept so the operator can see and convert it.
|
||||
func TestMigrate1to2KeepsGeoMarkersInert(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '1'
|
||||
|
||||
config rule
|
||||
option name 'geo'
|
||||
list dst_domain 'geosite:youtube'
|
||||
list dst_ip 'geoip:ru'
|
||||
option target 'direct'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if got := f.ruleset("rule-geo").lists["entry"]; !eqStrings(got, []string{"geosite:youtube"}) {
|
||||
t.Fatalf("domain entries = %q", got)
|
||||
}
|
||||
if got := f.ruleset("rule-geo-ip").lists["entry"]; !eqStrings(got, []string{"geoip:ru"}) {
|
||||
t.Fatalf("ip entries = %q", got)
|
||||
}
|
||||
for _, s := range f.secs {
|
||||
if s.typ == "ruleset" && s.opts["source"] != "inline" {
|
||||
t.Fatalf("ruleset %q got source %q — a geo marker was promoted", s.opts["name"], s.opts["source"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh install has no config to export; the migration must succeed silently
|
||||
// rather than refuse to boot.
|
||||
func TestMigrate1to2NoConfig(t *testing.T) {
|
||||
f := &fakeUCI{missing: true}
|
||||
if err := migrate1to2(f); err != nil {
|
||||
t.Fatalf("migrate on an absent package: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesetBaseName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"ru-direct", "rule-ru-direct"},
|
||||
{"corp nets!", "rule-corp-nets"},
|
||||
{" spaced name ", "rule-spaced-name"},
|
||||
{"Ünïcode", "rule-n-code"}, // non-ASCII is not tag-safe; dropped, leaving a separator
|
||||
{"", "rule-7"},
|
||||
{"!!!", "rule-7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rulesetBaseName(c.in, 7); got != c.want {
|
||||
t.Errorf("rulesetBaseName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDomainEntry(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"example.com", "full:example.com"}, // bare: exact in a rule, suffix in a list
|
||||
{"full:example.com", "full:example.com"},
|
||||
{"suffix:example.com", "suffix:example.com"},
|
||||
{"keyword:ads", "keyword:ads"},
|
||||
{`regexp:^a\.b$`, `regexp:^a\.b$`},
|
||||
{".example.com", ".example.com"},
|
||||
{"geosite:youtube", "geosite:youtube"},
|
||||
{" spaced.example ", "full:spaced.example"},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := migrateDomainEntry(c.in); got != c.want {
|
||||
t.Errorf("migrateDomainEntry(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -633,19 +633,52 @@ type Ruleset struct {
|
||||
}
|
||||
|
||||
// Rule is a `config rule` (ordered, first-match).
|
||||
//
|
||||
// DESTINATION IS ALWAYS A RULE-SET. A rule names WHERE traffic is going only
|
||||
// through DstRuleset — there are no inline domain or IP lists on a rule any
|
||||
// more (`dst_domain` / `dst_ip` were removed in schema v2; migrate1to2 folds
|
||||
// every existing one into a generated `config ruleset` and rewrites the rule to
|
||||
// point at it). One destination mechanism means one set of matcher semantics to
|
||||
// learn, one place a list is edited, and a list that is compiled once into a
|
||||
// .srs and shared by every rule that references it instead of being re-parsed
|
||||
// per rule. Src (the CLIENT side), DstPort and Proto are unaffected: they are
|
||||
// not lists of destinations and have no rule-set form.
|
||||
type Rule struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Order int
|
||||
Src []string
|
||||
DstDomain []string
|
||||
DstRuleset []string
|
||||
DstIP []string
|
||||
DstPort string
|
||||
Proto string
|
||||
Target string // chain:|group:|node:|direct|block
|
||||
Egress string
|
||||
|
||||
// LegacyDst is the UNMIGRATED-RULE TRIPWIRE, and it is a safety device, not a
|
||||
// data field. It is non-empty exactly when the config STILL carries a
|
||||
// schema-v1 `dst_domain`/`dst_ip` on this rule — i.e. migrate1to2 never ran,
|
||||
// or ran and could not commit (a full /overlay is the documented way that
|
||||
// happens). Each element is the raw `<option>=<value>` text, purely so the
|
||||
// warning can quote what it found.
|
||||
//
|
||||
// WHY IT EXISTS. Nothing re-runs the migration on the paths that matter: the
|
||||
// daemon's `run`, the SIGHUP reconcile and the panel's config write all load
|
||||
// UCI directly. The parser no longer reads the two removed options, so an
|
||||
// unmigrated `list dst_domain 'bank.ru'` + `option target 'direct'` parsed as
|
||||
// a rule with NO matchers at all — a catch-all, which generate turns into
|
||||
// route `Final`, which the LAST such rule wins. One un-committed migration
|
||||
// therefore sent the WHOLE router's traffic out the plain WAN, silently.
|
||||
//
|
||||
// WHAT IT DOES. ParseUCIExport forces Enabled=false on such a rule (generate
|
||||
// and the reachability analysis both skip disabled rules), IsCatchAll reports
|
||||
// false for it whatever its matchers say (so it can never become the default
|
||||
// route even if something re-enables it), ApplyProfileRuleOverrides refuses to
|
||||
// enable it, and ValidateRules reports it through the normal apply-warning
|
||||
// channel. It is never rendered back to UCI: render.go emits neither legacy
|
||||
// option, so a panel write drains them out — with `enabled '0'` recorded, so
|
||||
// the rule stays inert until an operator looks at it.
|
||||
LegacyDst []string
|
||||
|
||||
// Kill is the per-rule policy for when the target cannot resolve at generate
|
||||
// time (dead group, chain that would not assemble, missing egress/node). The
|
||||
// rule is ALWAYS still emitted — its traffic never falls through to the
|
||||
|
||||
@@ -114,6 +114,22 @@ func ApplyProfileRuleOverrides(rules []Rule, prof *Profile) ([]Rule, []Warning)
|
||||
continue
|
||||
}
|
||||
for _, i := range targets {
|
||||
// A profile may switch a rule OFF freely, but it may not switch an
|
||||
// UNMIGRATED one on: its destination matcher is unreadable (see
|
||||
// Rule.LegacyDst), so enabling it would put a rule into force with
|
||||
// fewer conditions than the operator wrote — in the worst case none
|
||||
// at all, i.e. the router's default route.
|
||||
if enabled && len(out[i].LegacyDst) > 0 {
|
||||
warns = append(warns, Warning{
|
||||
Section: "profile",
|
||||
Name: prof.Name,
|
||||
Message: fmt.Sprintf("cannot enable rule %q: it still carries the "+
|
||||
"removed dst_domain/dst_ip options, so its destination cannot be "+
|
||||
"read and it stays disabled until the config is migrated "+
|
||||
"(run `shaterd migrate`)", n),
|
||||
})
|
||||
continue
|
||||
}
|
||||
out[i].Enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,31 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsCatchAll reports whether a rule carries NO matcher of any kind (src, dst
|
||||
// domain/ip/ruleset, port, proto). Such a rule is the default egress: generate
|
||||
// points route `Final` at it rather than emitting a match-all rule.
|
||||
// IsCatchAll reports whether a rule carries NO matcher of any kind (src,
|
||||
// dst_ruleset, port, proto). Such a rule is the default egress: generate points
|
||||
// route `Final` at it rather than emitting a match-all rule.
|
||||
//
|
||||
// The destination side is now exactly one field — a rule names where traffic is
|
||||
// going through DstRuleset alone (schema v2; see model.Rule). A rule that was
|
||||
// catch-all before the migration is still catch-all after it, and a rule that
|
||||
// carried `dst_domain`/`dst_ip` is not, because the migration gives it a
|
||||
// DstRuleset in their place.
|
||||
//
|
||||
// AN UNMIGRATED RULE IS NEVER A CATCH-ALL. A rule that still carries a
|
||||
// schema-v1 `dst_domain`/`dst_ip` (Rule.LegacyDst) has a destination the parser
|
||||
// cannot read, so its lack of matchers here means "unreadable", not "everything".
|
||||
// Calling it a catch-all is what turned an uncommitted migration into route
|
||||
// `Final` for the whole router. ParseUCIExport already holds such a rule
|
||||
// disabled; this is the second lock, and it is the one that holds if anything
|
||||
// ever hands the rule back its Enabled bit.
|
||||
//
|
||||
// generate.isCatchAll and the panel's isCatchAll() are the same predicate; this
|
||||
// is the one the Go side shares.
|
||||
func IsCatchAll(r Rule) bool {
|
||||
if len(r.LegacyDst) > 0 {
|
||||
return false
|
||||
}
|
||||
return len(r.Src) == 0 &&
|
||||
len(r.DstDomain) == 0 &&
|
||||
len(r.DstIP) == 0 &&
|
||||
len(r.DstRuleset) == 0 &&
|
||||
strings.TrimSpace(r.DstPort) == "" &&
|
||||
strings.TrimSpace(r.Proto) == ""
|
||||
|
||||
@@ -216,8 +216,8 @@ func TestReachabilityUnsortedInputIsJudgedByOrder(t *testing.T) {
|
||||
func TestReachabilityMatcherKindsAreNotCatchAll(t *testing.T) {
|
||||
conditional := []Rule{
|
||||
{Name: "by-src", Enabled: true, Order: 10, Target: "direct", Src: []string{"192.168.1.0/24"}},
|
||||
{Name: "by-domain", Enabled: true, Order: 11, Target: "direct", DstDomain: []string{"example.com"}},
|
||||
{Name: "by-ip", Enabled: true, Order: 12, Target: "direct", DstIP: []string{"1.1.1.1/32"}},
|
||||
// The destination side is one field now (schema v2): a domain list and an
|
||||
// address list are both `config ruleset`s a rule points dst_ruleset at.
|
||||
{Name: "by-ruleset", Enabled: true, Order: 13, Target: "direct", DstRuleset: []string{"ads"}},
|
||||
{Name: "by-port", Enabled: true, Order: 14, Target: "direct", DstPort: "443"},
|
||||
{Name: "by-proto", Enabled: true, Order: 15, Target: "direct", Proto: "quic"},
|
||||
|
||||
+212
-3
@@ -9,7 +9,9 @@ package model
|
||||
// the same uciRunner seam the migrations use).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -33,6 +35,14 @@ import (
|
||||
// ParseUCIExport already carries those defaults as concrete values, so the
|
||||
// panel's read→edit→write flow round-trips exactly. See render_test.go.
|
||||
//
|
||||
// Rule.LegacyDst IS emitted (as the `dst_domain`/`dst_ip` it names), because a
|
||||
// write over an unmigrated config must not erase the operator's lists. This
|
||||
// function is PURE and therefore trusts the Model it is given — so a Model whose
|
||||
// LegacyDst did not come from the config on disk would have it written to disk.
|
||||
// writeUCIWith is what guarantees the field's provenance (withDiskLegacyDst), and
|
||||
// it is the only non-test caller; any future caller that persists the result owes
|
||||
// the same guarantee.
|
||||
//
|
||||
// Subscription-cache nodes (FromSub != "") are NOT emitted at all: they live in
|
||||
// per-subscription JSON cache files (see subcache.go) and are folded back in by
|
||||
// ReadUCI's MergeSubCaches. Only manual nodes become `config node` sections, and
|
||||
@@ -211,9 +221,12 @@ func RenderUCIExport(m *Model) string {
|
||||
w.boolOpt("enabled", r.Enabled)
|
||||
w.intOpt("order", r.Order)
|
||||
w.listOpt("src", r.Src)
|
||||
w.listOpt("dst_domain", r.DstDomain)
|
||||
// dst_domain / dst_ip are never SYNTHESISED (they are not part of schema v2)
|
||||
// — but they ARE written back when the rule still carries them, so a write
|
||||
// that is allowed to proceed over an unmigrated config cannot erase them.
|
||||
// See legacyDstOpts and guardUnmigratedConfig.
|
||||
w.legacyDstOpts(r.LegacyDst)
|
||||
w.listOpt("dst_ruleset", r.DstRuleset)
|
||||
w.listOpt("dst_ip", r.DstIP)
|
||||
w.strOpt("dst_port", r.DstPort)
|
||||
w.strOpt("proto", r.Proto)
|
||||
w.strOpt("target", r.Target)
|
||||
@@ -376,6 +389,40 @@ func (w *uciWriter) listOpt(k string, vs []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// legacyDstOpts re-emits the schema-v1 `dst_domain`/`dst_ip` a rule STILL carries
|
||||
// on disk (Rule.LegacyDst, `<option>=<value>` text produced by legacyDstEntries).
|
||||
//
|
||||
// This is preservation, not support. A write over an unmigrated config is only
|
||||
// ever allowed when it does not touch the rules at all (guardUnmigratedConfig) —
|
||||
// a subscription refresh writing quota counters, the profile watcher switching
|
||||
// the active profile. Those writers re-render the WHOLE package, so without this
|
||||
// the operator's destination lists would be erased by a cron job, which is the
|
||||
// same data loss the guard exists to prevent, just triggered from a different
|
||||
// place. Writing them back keeps the config exactly as unmigrated as it was, so
|
||||
// `shaterd migrate` can still do its job afterwards.
|
||||
//
|
||||
// Only the two known keys with a non-empty value are emitted: LegacyDst may be
|
||||
// filled by a client (the panel PUTs back the model it GETs), and this must not
|
||||
// become a way to inject arbitrary `list <anything>` lines into the config.
|
||||
func (w *uciWriter) legacyDstOpts(entries []string) {
|
||||
for _, e := range entries {
|
||||
i := strings.IndexByte(e, '=')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
k, v := e[:i], e[i+1:]
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
for _, known := range legacyDstOptions {
|
||||
if k == known {
|
||||
fmt.Fprintf(&w.b, "\tlist %s %s\n", k, renderQuote(v))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderQuote single-quotes a value and escapes embedded single quotes the uci
|
||||
// way (`'\”`), mirroring uci.go's unquote.
|
||||
//
|
||||
@@ -423,8 +470,170 @@ func WriteUCI(m *Model) error {
|
||||
return writeUCIWith(uci, m)
|
||||
}
|
||||
|
||||
// ErrUnmigratedConfig is what every config write fails with when the config ON
|
||||
// DISK still carries the schema-v1 rule destinations and the write would change
|
||||
// the rules. Callers match it with errors.Is to tell this refusal (a config the
|
||||
// operator can fix with one command) apart from a real I/O failure — the panel
|
||||
// answers 409 with the message rather than a bare 500.
|
||||
var ErrUnmigratedConfig = errors.New("config not migrated to schema v2")
|
||||
|
||||
// CheckConfigWritable reports whether persisting m would be refused, WITHOUT
|
||||
// writing anything. It is the same check WriteUCI runs, exported so a caller that
|
||||
// does several writes in one request can find out before the first of them lands:
|
||||
// the panel's PUT /api/config writes the subscription node caches first, and
|
||||
// discovering the refusal only at the UCI step would leave those caches rewritten
|
||||
// for a request that was rejected.
|
||||
func CheckConfigWritable(m *Model) error {
|
||||
disk, ok := diskRules(uci)
|
||||
return guardUnmigratedConfig(disk, ok, m)
|
||||
}
|
||||
|
||||
// diskRules returns the rules of the config CURRENTLY on disk, and whether they
|
||||
// could be read at all. ok=false covers a fresh install (no package) and an
|
||||
// export that will not parse — the two cases where there is nothing on disk to
|
||||
// protect and nothing to copy from.
|
||||
//
|
||||
// It is read ONCE per write and handed to both guardUnmigratedConfig and
|
||||
// withDiskLegacyDst: they answer two halves of the same question ("may this write
|
||||
// proceed?" / "what may it say about the legacy options?") and must not be able
|
||||
// to see different configs.
|
||||
func diskRules(u uciRunner) ([]Rule, bool) {
|
||||
text, ok := u.Export("shater")
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil, false
|
||||
}
|
||||
disk, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return disk.Rules, true
|
||||
}
|
||||
|
||||
// withDiskLegacyDst returns m with every rule's LegacyDst forced to what the DISK
|
||||
// says, so the renderer can never be told about a legacy option that is not
|
||||
// really there. m is not modified: the rules are copied when (and only when)
|
||||
// something actually differs.
|
||||
//
|
||||
// WHY. LegacyDst is a safety device, and PUT /api/config decodes the whole Model
|
||||
// out of the request body — including this field. On a healthy, migrated config
|
||||
// the guard above returns early (there is nothing to protect), and the renderer
|
||||
// would then have written a fabricated `list dst_domain 'example.com'` straight
|
||||
// into /etc/config/shater. No traffic leak — everything downstream fails closed —
|
||||
// but an authenticated client could switch off any rule it named and lock the box
|
||||
// out of saving its config until someone ran `shaterd migrate`, and the warning
|
||||
// explaining it would have blamed a migration that never had anything to do with
|
||||
// it. Taking the value from disk removes the input entirely.
|
||||
//
|
||||
// In the branch where the disk DOES carry legacy options, the guard has already
|
||||
// established reflect.DeepEqual(disk.Rules, m.Rules), so the substitution is a
|
||||
// no-op there by construction — this is purely the sanitiser for everything else.
|
||||
// When the disk is unreadable there is nothing to preserve, so every LegacyDst is
|
||||
// cleared: a fabricated one must never be the reason an option appears on disk.
|
||||
func withDiskLegacyDst(disk []Rule, ok bool, m *Model) *Model {
|
||||
if m == nil {
|
||||
return m
|
||||
}
|
||||
want := func(i int) []string {
|
||||
if !ok || i >= len(disk) {
|
||||
return nil
|
||||
}
|
||||
return disk[i].LegacyDst
|
||||
}
|
||||
differs := false
|
||||
for i := range m.Rules {
|
||||
if !reflect.DeepEqual(m.Rules[i].LegacyDst, want(i)) {
|
||||
differs = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !differs {
|
||||
return m
|
||||
}
|
||||
out := *m
|
||||
out.Rules = append([]Rule(nil), m.Rules...)
|
||||
for i := range out.Rules {
|
||||
out.Rules[i].LegacyDst = want(i)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// guardUnmigratedConfig refuses a write that would silently destroy schema-v1
|
||||
// rule destinations still present on disk.
|
||||
//
|
||||
// WHY IT READS THE DISK AND NOT THE MODEL. The Model handed to WriteUCI is not
|
||||
// trustworthy: PUT /api/config decodes one straight out of the request body, and
|
||||
// the body belongs to the client (an older panel build, curl, a script). A check
|
||||
// phrased as "refuse when m has LegacyDst" is defeated by simply omitting the
|
||||
// field — and that omission is precisely the dangerous request, because
|
||||
// RenderUCIExport would then write the rule with `enabled '1'` and NO destination
|
||||
// at all, which the next read parses as a catch-all and generate turns into route
|
||||
// `Final` for the entire router. Only the config already on disk can say whether
|
||||
// there is anything to protect, so that is what is consulted.
|
||||
//
|
||||
// WHAT IT ALLOWS. A write whose rules are IDENTICAL to the ones on disk goes
|
||||
// through, and legacyDstOpts writes the legacy options back with it. That keeps
|
||||
// the non-panel writers working on an unmigrated box — a subscription refresh
|
||||
// persisting quota counters and manual nodes (apply.refreshSubscription,
|
||||
// `shaterd sub update`) and the profile watcher switching globals.active_profile
|
||||
// — none of which has any business editing rules. They all build their Model with
|
||||
// ReadUCI, so their rules ARE the disk's, byte for byte, including the LegacyDst
|
||||
// the parser attached. Nothing about a migrated config changes: with no legacy
|
||||
// options on disk the function returns nil before comparing anything.
|
||||
//
|
||||
// An unreadable/unparseable export is treated as "nothing to protect" (ok=false
|
||||
// from diskRules). It cannot be distinguished from a fresh install here, and
|
||||
// refusing every write on a malformed file would leave the box unconfigurable
|
||||
// through its only UI.
|
||||
func guardUnmigratedConfig(disk []Rule, ok bool, m *Model) error {
|
||||
if !ok {
|
||||
return nil // fresh install / unreadable: no config to lose
|
||||
}
|
||||
var stuck []string
|
||||
for i, r := range disk {
|
||||
if len(r.LegacyDst) == 0 {
|
||||
continue
|
||||
}
|
||||
if r.Name != "" {
|
||||
stuck = append(stuck, strconv.Quote(r.Name))
|
||||
} else {
|
||||
stuck = append(stuck, "#"+strconv.Itoa(i))
|
||||
}
|
||||
}
|
||||
if len(stuck) == 0 {
|
||||
return nil // migrated (the normal case): nothing to guard
|
||||
}
|
||||
if m != nil && reflect.DeepEqual(disk, m.Rules) {
|
||||
return nil // the rules are untouched; legacyDstOpts carries them across
|
||||
}
|
||||
return fmt.Errorf("%w: rule %s still carr%s the removed dst_domain/dst_ip "+
|
||||
"options, and this config has no place to write them — saving would erase "+
|
||||
"the destination lists and leave the rule matching nothing (or, worse, "+
|
||||
"everything). The write was REFUSED and nothing on disk changed. Run "+
|
||||
"`shaterd migrate` on the router to fold each list into a rule-set, then "+
|
||||
"reload and save again.",
|
||||
ErrUnmigratedConfig, strings.Join(stuck, ", "), plural(len(stuck), "ies", "y"))
|
||||
}
|
||||
|
||||
// plural picks the suffix for a count (carries/carry).
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
|
||||
func writeUCIWith(u uciRunner, m *Model) error {
|
||||
text := RenderUCIExport(m)
|
||||
// ONE read of the disk feeds both halves of the protection below.
|
||||
disk, ok := diskRules(u)
|
||||
// Refuse BEFORE the delete+import: writeUCIWith replaces the whole package, so
|
||||
// by the time an import has run the legacy options are already gone.
|
||||
if err := guardUnmigratedConfig(disk, ok, m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render from the DISK's idea of which rules carry legacy options, never the
|
||||
// caller's — RenderUCIExport is a pure function and will faithfully emit a
|
||||
// `list dst_domain` for anything it is told about.
|
||||
text := RenderUCIExport(withDiskLegacyDst(disk, ok, m))
|
||||
var errs []string
|
||||
// Clear the staged package first so `uci import` replaces rather than merges
|
||||
// (import appends sections; without the delete a second write would duplicate
|
||||
|
||||
+210
-3
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -84,8 +85,7 @@ func richModel() *Model {
|
||||
}},
|
||||
Rules: []Rule{{
|
||||
Name: "pc", Enabled: true, Order: 10,
|
||||
Src: []string{"192.168.1.1/32"}, DstDomain: []string{"geosite:telegram"},
|
||||
DstRuleset: []string{"ads"}, DstIP: []string{"1.1.1.1/32"},
|
||||
Src: []string{"192.168.1.1/32"}, DstRuleset: []string{"ads"},
|
||||
DstPort: "443", Proto: "tcp,udp", Target: "chain:triple", Egress: "frag",
|
||||
Kill: "default", SchedEnabled: true, SchedDays: []string{"mon", "tue"},
|
||||
SchedStart: "08:00", SchedEnd: "22:00", SchedUTCOffset: 180,
|
||||
@@ -384,7 +384,7 @@ func TestRenderSkipsSubCacheNodes(t *testing.T) {
|
||||
// and COMMITs — and the imported text re-parses to the original Model.
|
||||
func TestWriteUCIReplaces(t *testing.T) {
|
||||
m := richModel()
|
||||
f := &fakeUCI{kv: map[string]string{}}
|
||||
f := newFakeUCI("")
|
||||
if err := writeUCIWith(f, m); err != nil {
|
||||
t.Fatalf("writeUCIWith: %v", err)
|
||||
}
|
||||
@@ -418,3 +418,210 @@ func TestWriteUCIReplaces(t *testing.T) {
|
||||
t.Fatalf("second write duplicated sections: nodes=%d rules=%d", len(got2.Nodes), len(got2.Rules))
|
||||
}
|
||||
}
|
||||
|
||||
// --- write-path guard: an unmigrated config may not be overwritten ------------
|
||||
|
||||
// unmigratedOnDisk is a v1 config as it sits on the router when `shaterd migrate`
|
||||
// never got to commit: two rules whose destination is still an inline list, plus
|
||||
// a subscription whose quota counters a refresh wants to update.
|
||||
const unmigratedOnDisk = `package shater
|
||||
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option schema_version '1'
|
||||
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option url 'https://example.invalid/sub'
|
||||
|
||||
config rule
|
||||
option name 'bank'
|
||||
option enabled '1'
|
||||
list dst_domain 'bank.ru'
|
||||
option target 'direct'
|
||||
|
||||
config rule
|
||||
option name 'default'
|
||||
option enabled '1'
|
||||
option target 'group:auto'
|
||||
`
|
||||
|
||||
// A write that would change the rules of an unmigrated config is REFUSED, and the
|
||||
// on-disk config is byte-identical afterwards. Without this the renderer (which
|
||||
// emits no dst_domain/dst_ip) simply dropped the operator's lists on the first
|
||||
// save from the panel.
|
||||
func TestWriteUCIRefusesToOverwriteAnUnmigratedConfig(t *testing.T) {
|
||||
f := newFakeUCI(unmigratedOnDisk)
|
||||
before, _ := f.Export("shater")
|
||||
|
||||
m, err := ParseUCIExport(before)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
m.Rules[0].Order = 42 // any rule edit at all
|
||||
|
||||
err = writeUCIWith(f, m)
|
||||
if err == nil {
|
||||
t.Fatal("the write was allowed to erase the legacy destination lists")
|
||||
}
|
||||
if !errors.Is(err, ErrUnmigratedConfig) {
|
||||
t.Fatalf("error is not ErrUnmigratedConfig: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"bank"`, "dst_domain", "shaterd migrate", "REFUSED"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("message does not mention %q: %s", want, err)
|
||||
}
|
||||
}
|
||||
if after, _ := f.Export("shater"); after != before {
|
||||
t.Fatalf("the config on disk changed despite the refusal:\n--- before\n%s\n--- after\n%s", before, after)
|
||||
}
|
||||
if f.commits != 0 || len(f.deleted) != 0 {
|
||||
t.Fatalf("the refused write still touched uci (commits=%d deleted=%v)", f.commits, f.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard reads the DISK, not the submitted model — so a body that simply omits
|
||||
// LegacyDst and sets Enabled cannot get a destination-less rule written with
|
||||
// `enabled '1'`. That rule would parse back as a catch-all and become route Final
|
||||
// for the whole router, which is the leak the parser guard closes on the read
|
||||
// side and this closes on the write side.
|
||||
func TestWriteUCIRefusesACraftedModelWithoutLegacyDst(t *testing.T) {
|
||||
f := newFakeUCI(unmigratedOnDisk)
|
||||
before, _ := f.Export("shater")
|
||||
|
||||
// Exactly what a hand-rolled PUT (or an older panel build) sends: the rule is
|
||||
// there, enabled, and the field that marks it unmigrated is gone.
|
||||
crafted := &Model{
|
||||
Globals: DefaultGlobals(),
|
||||
Rules: []Rule{
|
||||
{Name: "bank", Enabled: true, Target: "direct"},
|
||||
{Name: "default", Enabled: true, Target: "group:auto"},
|
||||
},
|
||||
}
|
||||
if err := writeUCIWith(f, crafted); !errors.Is(err, ErrUnmigratedConfig) {
|
||||
t.Fatalf("crafted body was accepted (err = %v)", err)
|
||||
}
|
||||
after, _ := f.Export("shater")
|
||||
if after != before {
|
||||
t.Fatalf("disk changed:\n--- before\n%s\n--- after\n%s", before, after)
|
||||
}
|
||||
if strings.Contains(after, "config rule\n\toption name 'bank'\n\toption enabled '1'\n\toption target") {
|
||||
t.Fatal("a destination-less enabled rule reached the disk")
|
||||
}
|
||||
// And re-reading the disk still yields the protected shape.
|
||||
m, err := ParseUCIExport(after)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if IsCatchAll(m.Rules[0]) || m.Rules[0].Enabled {
|
||||
t.Fatal("the unmigrated rule lost its protection")
|
||||
}
|
||||
}
|
||||
|
||||
// A writer that does NOT touch the rules keeps working on an unmigrated box, and
|
||||
// the legacy options survive the write. This is the subscription-refresh path
|
||||
// (apply.UpdateSubscription / `shaterd sub update`) and the profile watcher: they
|
||||
// build their model with ReadUCI, change a counter or globals.active_profile, and
|
||||
// re-render the WHOLE package — so a blanket refusal would break a cron job, and
|
||||
// a blanket allow would let that cron job erase the operator's lists.
|
||||
func TestWriteUCIPreservesLegacyDstOnANonRuleWrite(t *testing.T) {
|
||||
f := newFakeUCI(unmigratedOnDisk)
|
||||
text, _ := f.Export("shater")
|
||||
m, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
// Exactly what subscribe.StoreUserInfo does.
|
||||
m.Subscriptions[0].UserDownload = 1 << 40
|
||||
m.Subscriptions[0].UserInfoAt = 1700000000
|
||||
m.Globals.ActiveProfile = "home"
|
||||
|
||||
if err := writeUCIWith(f, m); err != nil {
|
||||
t.Fatalf("a non-rule write was refused: %v", err)
|
||||
}
|
||||
|
||||
after, _ := f.Export("shater")
|
||||
got, err := ParseUCIExport(after)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if got.Subscriptions[0].UserDownload != 1<<40 || got.Globals.ActiveProfile != "home" {
|
||||
t.Fatalf("the write did not land: %+v / %q", got.Subscriptions[0], got.Globals.ActiveProfile)
|
||||
}
|
||||
if !eqStrings(got.Rules[0].LegacyDst, []string{"dst_domain=bank.ru"}) {
|
||||
t.Fatalf("the legacy destination list was erased: %q", got.Rules[0].LegacyDst)
|
||||
}
|
||||
if got.Rules[0].Enabled || IsCatchAll(got.Rules[0]) {
|
||||
t.Fatal("the rule lost its unmigrated protection across the write")
|
||||
}
|
||||
// The config is still exactly as unmigrated as it was, so `shaterd migrate`
|
||||
// can still fold the list into a rule-set afterwards.
|
||||
if err := migrate1to2(f); err != nil {
|
||||
t.Fatalf("migrate after the write: %v", err)
|
||||
}
|
||||
if f.ruleset("rule-bank") == nil {
|
||||
t.Fatalf("migration found nothing to fold; rulesets = %q", f.rulesetNames())
|
||||
}
|
||||
}
|
||||
|
||||
// A fabricated LegacyDst in the submitted model must never reach the disk. On a
|
||||
// MIGRATED config the guard has nothing to protect and returns early, so without
|
||||
// withDiskLegacyDst the renderer happily wrote the client's `list dst_domain`
|
||||
// into /etc/config/shater — and from there every downstream lock fired on a lie:
|
||||
// the rule the client named went (and stayed) disabled, further rule writes were
|
||||
// refused, and the warning blamed a migration that had never been involved.
|
||||
func TestWriteUCIIgnoresAFabricatedLegacyDst(t *testing.T) {
|
||||
const migratedOnDisk = `package shater
|
||||
|
||||
config globals 'globals'
|
||||
option enabled '1'
|
||||
option schema_version '2'
|
||||
|
||||
config rule
|
||||
option name 'bank'
|
||||
option enabled '1'
|
||||
list dst_ruleset 'rule-bank'
|
||||
option target 'direct'
|
||||
`
|
||||
f := newFakeUCI(migratedOnDisk)
|
||||
text, _ := f.Export("shater")
|
||||
m, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(m.Rules[0].LegacyDst) != 0 {
|
||||
t.Fatal("fixture is not migrated")
|
||||
}
|
||||
// Exactly what a crafted PUT body carries.
|
||||
m.Rules[0].LegacyDst = []string{"dst_domain=example.com", "dst_ip=203.0.113.0/24"}
|
||||
|
||||
if err := writeUCIWith(f, m); err != nil {
|
||||
t.Fatalf("writeUCIWith: %v", err)
|
||||
}
|
||||
after, _ := f.Export("shater")
|
||||
for _, forbidden := range []string{"dst_domain", "dst_ip"} {
|
||||
if strings.Contains(after, forbidden) {
|
||||
t.Fatalf("a fabricated %s reached the disk:\n%s", forbidden, after)
|
||||
}
|
||||
}
|
||||
got, err := ParseUCIExport(after)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if len(got.Rules[0].LegacyDst) != 0 {
|
||||
t.Fatalf("rule came back unmigrated: %q", got.Rules[0].LegacyDst)
|
||||
}
|
||||
// Enabled is whatever was submitted — the fabrication must not have switched
|
||||
// the rule off either.
|
||||
if !got.Rules[0].Enabled {
|
||||
t.Fatal("the fabricated field disabled a healthy rule")
|
||||
}
|
||||
// And the caller's model was not mutated behind its back.
|
||||
if len(m.Rules[0].LegacyDst) != 2 {
|
||||
t.Fatalf("writeUCIWith mutated the caller's model: %q", m.Rules[0].LegacyDst)
|
||||
}
|
||||
// The config is still writable: nothing latched.
|
||||
if err := writeUCIWith(f, got); err != nil {
|
||||
t.Fatalf("the config became unwritable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ func fullModel() *Model {
|
||||
m.Nodes[0].FromSub = ""
|
||||
m.Nodes[0].Fingerprint = ""
|
||||
m.Nodes[0].Stale = false
|
||||
// Rule.LegacyDst is exempt because it is not a field with a value of its own:
|
||||
// each element is `<option>=<value>` naming one of exactly two removed options,
|
||||
// and uciWriter.legacyDstOpts deliberately drops anything else so a client
|
||||
// cannot inject arbitrary `list` lines through it. fillNonZero's generic
|
||||
// "s-legacydst-1" is precisely such an anything-else, so it cannot round-trip
|
||||
// by construction. The real round-trip (parse a legacy option -> render it back
|
||||
// unchanged) is pinned by TestWriteUCIPreservesLegacyDstOnANonRuleWrite.
|
||||
m.Rules[0].LegacyDst = nil
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
+63
-4
@@ -174,14 +174,39 @@ func ParseUCIExport(text string) (*Model, error) {
|
||||
Entries: s.list("entry"),
|
||||
})
|
||||
case "rule":
|
||||
// dst_domain / dst_ip were REMOVED in schema v2: a rule's destination is
|
||||
// a rule-set reference and nothing else, and migrate1to2 (migrate.go)
|
||||
// folds any legacy inline list into a `config ruleset` and points
|
||||
// dst_ruleset at it.
|
||||
//
|
||||
// The migration is NOT re-run on every load — it runs from the service
|
||||
// init and from uci-defaults at package install (`shaterd migrate`), and
|
||||
// from nowhere else: the daemon's `run`, the SIGHUP reconcile and the
|
||||
// panel's config write all come straight here. So a config that still
|
||||
// carries the options is a real, reachable state — an interrupted or
|
||||
// uncommittable migration (a full /overlay is the documented way that
|
||||
// happens), or a hand edit after a downgrade.
|
||||
//
|
||||
// Ignoring them there was NOT safe. A rule whose only matcher was
|
||||
// `dst_domain` parsed as a rule with NO matchers, which IS the spelling
|
||||
// of a catch-all: generate points route `Final` at it and the last such
|
||||
// rule wins, so `dst_domain bank.ru` + `target direct` quietly became
|
||||
// "send EVERYTHING out the plain WAN". legacyDstEntries detects the live
|
||||
// options; a rule that has them is held DISABLED here and is reported by
|
||||
// ValidateRules, and Rule.LegacyDst keeps IsCatchAll/profile overrides
|
||||
// from resurrecting it. See the field's doc comment in model.go.
|
||||
legacyDst := legacyDstEntries(s)
|
||||
m.Rules = append(m.Rules, Rule{
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
// An unmigrated rule is never in force. Its destination matcher is
|
||||
// unreadable, so every alternative — routing it without the matcher,
|
||||
// or treating the absence as "matches everything" — states a policy
|
||||
// the operator did not write.
|
||||
Enabled: s.optBool("enabled", true) && len(legacyDst) == 0,
|
||||
Order: parseInt(s.opt("order"), 0),
|
||||
Src: s.list("src"),
|
||||
DstDomain: s.list("dst_domain"),
|
||||
LegacyDst: legacyDst,
|
||||
DstRuleset: s.list("dst_ruleset"),
|
||||
DstIP: s.list("dst_ip"),
|
||||
DstPort: s.opt("dst_port"),
|
||||
Proto: s.opt("proto"),
|
||||
Target: s.opt("target"),
|
||||
@@ -335,6 +360,40 @@ func applyGlobals(g *Globals, s uciSection) {
|
||||
g.StatsDiskLimitMB = parseInt(s.opt("stats_disk_limit_mb"), g.StatsDiskLimitMB)
|
||||
}
|
||||
|
||||
// legacyDstOptions are the `config rule` options schema v2 removed. Their mere
|
||||
// PRESENCE on a rule proves the config was not migrated (migrate1to2 deletes them
|
||||
// as its last step per rule, and only once the replacement rule-set reference is
|
||||
// in place).
|
||||
var legacyDstOptions = []string{"dst_domain", "dst_ip"}
|
||||
|
||||
// legacyDstEntries returns the live schema-v1 destination entries of a rule
|
||||
// section as `<option>=<value>` text, or nil when there are none.
|
||||
//
|
||||
// PRESENCE, not content, is the test. A `list dst_domain ' '` is kept as an
|
||||
// entry even though it names no domain: the value is junk, but the OPTION being
|
||||
// there still means the migration did not process this rule, and dropping the
|
||||
// blank would hand the rule back its catch-all shape — the exact leak this
|
||||
// detection exists to stop. (A truly empty `list dst_domain ”` never reaches
|
||||
// here: parseSections drops empty list values, so the key is simply absent, and
|
||||
// such a rule had no destination under v1 either.)
|
||||
func legacyDstEntries(s uciSection) []string {
|
||||
var out []string
|
||||
for _, k := range legacyDstOptions {
|
||||
vals, ok := s.Lists[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
out = append(out, k+"=")
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- section accessors ---
|
||||
|
||||
func (s uciSection) opt(k string) string { return s.Options[k] }
|
||||
|
||||
@@ -59,7 +59,7 @@ config rule
|
||||
option enabled '1'
|
||||
option order '10'
|
||||
list src '192.168.11.14/32'
|
||||
list dst_domain 'geosite:telegram'
|
||||
list dst_ruleset 'ads'
|
||||
option dst_port '443'
|
||||
option proto 'tcp,udp'
|
||||
option target 'chain:triple'
|
||||
|
||||
@@ -123,6 +123,28 @@ func ValidateRules(rules []Rule, classify NetClassifier) []Warning {
|
||||
}
|
||||
var out []Warning
|
||||
for _, r := range rules {
|
||||
// Checked BEFORE the Enabled gate on purpose: the parser holds an unmigrated
|
||||
// rule disabled, so gating on Enabled would suppress the one warning that
|
||||
// explains why the rule stopped working.
|
||||
if len(r.LegacyDst) > 0 {
|
||||
out = append(out, Warning{
|
||||
Section: "rule",
|
||||
Name: r.Name,
|
||||
Message: fmt.Sprintf(
|
||||
"this rule still carries the removed schema-v1 destination options "+
|
||||
"(%s), so the config was never brought to schema v%d — either "+
|
||||
"`shaterd migrate` never ran, or it ran and could not commit (a "+
|
||||
"full /overlay is the usual cause), or the section was hand-edited. "+
|
||||
"Its destination cannot be read, so the rule is HELD "+
|
||||
"DISABLED: routing it without its destination would have applied "+
|
||||
"target %q to far more traffic than you wrote it for, and a rule "+
|
||||
"whose only matcher was one of these options would have become the "+
|
||||
"router's DEFAULT ROUTE. Run `shaterd migrate` to convert them into "+
|
||||
"a rule-set, then re-enable the rule.",
|
||||
strings.Join(r.LegacyDst, ", "), CurrentSchemaVersion,
|
||||
EffectiveRuleTarget(r)),
|
||||
})
|
||||
}
|
||||
if !r.Enabled {
|
||||
continue // a disabled rule installs nothing
|
||||
}
|
||||
|
||||
@@ -203,6 +203,22 @@ func (s *Server) handleConfigPut(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Refuse to overwrite a config that was never migrated to schema v2, BEFORE
|
||||
// touching anything. The check reads the config on disk, not this body: a
|
||||
// request that simply omits the LegacyDst field is exactly the dangerous one
|
||||
// (it would have the renderer write a rule with `enabled '1'` and no
|
||||
// destination, which the next read treats as the router's default route), so
|
||||
// trusting the body would defeat the guard. Running it here, ahead of
|
||||
// syncSubCaches, keeps a rejected request from rewriting the node caches.
|
||||
// 409, not 500: nothing is broken, the operator has one command to run.
|
||||
if err := checkWritable(&m); err != nil {
|
||||
if errors.Is(err, model.ErrUnmigratedConfig) {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "check config: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Caches first: they are plain files and the cheaper write; a UCI failure
|
||||
// after them leaves provider-owned node caches fresh and the manual config
|
||||
// untouched, which the next successful PUT converges anyway.
|
||||
@@ -211,6 +227,13 @@ func (s *Server) handleConfigPut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := writeConfig(&m); err != nil {
|
||||
// The same refusal can still surface here (WriteUCI re-checks under its own
|
||||
// read, so a migration that finished between the two calls cannot slip a
|
||||
// destructive write through). Keep the status honest.
|
||||
if errors.Is(err, model.ErrUnmigratedConfig) {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "write config: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -231,6 +254,9 @@ const maxConfigBytes = 4 << 20 // 4 MiB
|
||||
var (
|
||||
writeConfig = model.WriteUCI
|
||||
syncSubCaches = model.SyncSubCaches
|
||||
// checkWritable is the pre-flight refusal (see handleConfigPut); it reads the
|
||||
// on-disk config and never writes.
|
||||
checkWritable = model.CheckConfigWritable
|
||||
)
|
||||
|
||||
// validateModel does light structural validation — enough to reject obviously
|
||||
|
||||
@@ -3,6 +3,7 @@ package panel
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -227,3 +228,81 @@ func readAll(resp *http.Response) (string, error) {
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// A PUT over a config that was never migrated to schema v2 is REFUSED with 409
|
||||
// and a readable body, and nothing is written — not the UCI config and not the
|
||||
// subscription node caches (the guard runs ahead of both).
|
||||
//
|
||||
// The crafted body is the dangerous shape the coordinator flagged: the rule is
|
||||
// present and Enabled, and LegacyDst — the field that marks it unmigrated — is
|
||||
// simply absent. Trusting the body would have the renderer write `enabled '1'`
|
||||
// with no destination at all, which the next read parses as a catch-all and
|
||||
// generate turns into route Final for the entire router. The guard consults the
|
||||
// config on disk instead, which is why omitting the field changes nothing.
|
||||
func TestConfigPutRefusesUnmigratedConfig(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
wrote, synced := false, false
|
||||
origWrite, origSync, origCheck := writeConfig, syncSubCaches, checkWritable
|
||||
writeConfig = func(*model.Model) error { wrote = true; return nil }
|
||||
syncSubCaches = func(*model.Model) error { synced = true; return nil }
|
||||
checkWritable = func(*model.Model) error {
|
||||
return fmt.Errorf("%w: rule \"bank\" still carries the removed dst_domain/dst_ip "+
|
||||
"options ... The write was REFUSED and nothing on disk changed. Run "+
|
||||
"`shaterd migrate` on the router", model.ErrUnmigratedConfig)
|
||||
}
|
||||
defer func() { writeConfig, syncSubCaches, checkWritable = origWrite, origSync, origCheck }()
|
||||
|
||||
body := `{
|
||||
"Globals": {"Enabled": true},
|
||||
"Rules": [{"Name": "bank", "Enabled": true, "Target": "direct"}]
|
||||
}`
|
||||
resp := doPut(t, srv, cookie, body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
b, _ := readAll(resp)
|
||||
t.Fatalf("PUT over an unmigrated config: got %d, want 409 (body %s)", resp.StatusCode, b)
|
||||
}
|
||||
if wrote {
|
||||
t.Fatal("the config was written despite the refusal")
|
||||
}
|
||||
if synced {
|
||||
t.Fatal("the subscription caches were rewritten for a rejected request")
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
for _, want := range []string{"shaterd migrate", "REFUSED", "bank"} {
|
||||
if !strings.Contains(out["error"], want) {
|
||||
t.Fatalf("error body does not mention %q: %q", want, out["error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same refusal raised by WriteUCI itself (a migration that finished between
|
||||
// the pre-flight check and the write, or any caller that skipped the pre-flight)
|
||||
// must still reach the client as 409 with its text, not a bare 500.
|
||||
func TestConfigPutMapsWriteRefusalTo409(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
origWrite, origSync, origCheck := writeConfig, syncSubCaches, checkWritable
|
||||
writeConfig = func(*model.Model) error {
|
||||
return fmt.Errorf("%w: rule \"bank\" ...", model.ErrUnmigratedConfig)
|
||||
}
|
||||
syncSubCaches = func(*model.Model) error { return nil }
|
||||
checkWritable = func(*model.Model) error { return nil }
|
||||
defer func() { writeConfig, syncSubCaches, checkWritable = origWrite, origSync, origCheck }()
|
||||
|
||||
resp := doPut(t, srv, cookie, `{"Globals": {"Enabled": true}}`)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("got %d, want 409", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ func TestMalformedShareLinksAreRejectedNotPanicking(t *testing.T) {
|
||||
"ss://", "ss://@", "ss://notbase64@", "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-128-gcm:pw")),
|
||||
"ss://" + base64.RawURLEncoding.EncodeToString([]byte("rot13:pw")) + "@h:443", // unknown cipher
|
||||
"wireguard://", "wg://k@host", "awg://@host:51820",
|
||||
"hysteria2://", "hy2://", "hysteria2://@", "hysteria2://pw@", "hy2://pw@:443",
|
||||
"hysteria2://host:443", // auth-less: every real server requires auth
|
||||
"hysteria2://pw@host:0", "hysteria2://pw@host:99999", "hysteria2://pw@host:notaport",
|
||||
"hysteria2://pw@host:-", "hysteria2://pw@host:,", "hysteria2://pw@host:0-0",
|
||||
"hysteria2://pw@[2001:db8::1", "hysteria2://pw@host:443?obfs=salamander",
|
||||
"tuic://", "tuic://@", "tuic://:@host:443", "tuic://uuid@host:443",
|
||||
"tuic://x:y@host:443", "tuic://" + strings.Repeat("a", 4096) + ":pw@host:443",
|
||||
"hysteria2://" + strings.Repeat("p", 4096) + "@host:443?" + strings.Repeat("k=v&", 2000),
|
||||
}
|
||||
for _, uri := range bad {
|
||||
func() {
|
||||
|
||||
+128
-1
@@ -67,6 +67,132 @@ func parsePort(s string) (uint16, error) {
|
||||
return uint16(n), nil
|
||||
}
|
||||
|
||||
// truthyParam reports whether a share-link boolean-ish query value means "yes".
|
||||
// The specs say "1"/"0", but the same flag is written "true", "yes" and "on" by
|
||||
// different clients, and a flag read as false when the link meant true (e.g.
|
||||
// insecure= on a self-signed node) is a silent misconfiguration.
|
||||
func truthyParam(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// foldedParams flattens query params into a lookup whose keys are lower-cased
|
||||
// with '-' folded to '_'. Clients disagree on the spelling of the SAME parameter
|
||||
// (pinSHA256/pinsha256, obfs-password/obfs_password, allow_insecure/allowInsecure),
|
||||
// and a parameter missed because of its spelling is a parameter silently dropped.
|
||||
// Only the first value of a repeated key is kept.
|
||||
func foldedParams(q url.Values) map[string]string {
|
||||
out := make(map[string]string, len(q))
|
||||
for k, vs := range q {
|
||||
if len(vs) == 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ReplaceAll(strings.ToLower(k), "-", "_")
|
||||
if _, seen := out[key]; !seen {
|
||||
out[key] = vs[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// looksLikeUUID reports whether s is a UUID in one of the spellings sing-box's
|
||||
// uuid.FromString accepts: canonical 8-4-4-4-12 hex, bare 32-hex, or either of
|
||||
// those wrapped in braces / prefixed with "urn:uuid:".
|
||||
func looksLikeUUID(s string) bool {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.TrimPrefix(s, "urn:uuid:")
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, "{"), "}")
|
||||
switch len(s) {
|
||||
case 32:
|
||||
case 36:
|
||||
if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
digits := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; {
|
||||
case c >= '0' && c <= '9', c >= 'a' && c <= 'f':
|
||||
digits++
|
||||
case c == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return digits == 32
|
||||
}
|
||||
|
||||
// rewriteHopPorts folds a hysteria2 port-hopping port field ("host:443,5000-6000"
|
||||
// or "host:5000-6000") down to the first concrete port and returns the rewritten
|
||||
// URI plus the ORIGINAL spec (empty when the URI has no hop spec).
|
||||
//
|
||||
// It exists because such an authority is not a legal URI: net/url's port
|
||||
// validator accepts digits only, so url.Parse would reject the entire link and
|
||||
// the node would vanish with a parser error that names nothing the operator can
|
||||
// act on. Folding keeps the node — a hysteria2 server listening with port hopping
|
||||
// redirects the whole range to its single real listener, so any port in the set
|
||||
// reaches it — and the caller turns the returned spec into a warning.
|
||||
func rewriteHopPorts(uri string) (string, string) {
|
||||
i := strings.Index(uri, "://")
|
||||
if i < 0 {
|
||||
return uri, ""
|
||||
}
|
||||
start := i + 3
|
||||
end := len(uri)
|
||||
for j := start; j < len(uri); j++ {
|
||||
if c := uri[j]; c == '/' || c == '?' || c == '#' {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
auth := uri[start:end]
|
||||
hostStart := 0
|
||||
if at := strings.LastIndexByte(auth, '@'); at >= 0 {
|
||||
hostStart = at + 1
|
||||
}
|
||||
hp := auth[hostStart:]
|
||||
colon := -1
|
||||
if strings.HasPrefix(hp, "[") { // bracketed IPv6 literal
|
||||
if j := strings.IndexByte(hp, ']'); j >= 0 && j+1 < len(hp) && hp[j+1] == ':' {
|
||||
colon = j + 1
|
||||
}
|
||||
} else {
|
||||
colon = strings.LastIndexByte(hp, ':')
|
||||
}
|
||||
if colon < 0 {
|
||||
return uri, ""
|
||||
}
|
||||
spec := hp[colon+1:]
|
||||
if !strings.ContainsAny(spec, ",-") {
|
||||
return uri, ""
|
||||
}
|
||||
first := firstHopPort(spec)
|
||||
if first == "" {
|
||||
return uri, "" // let url.Parse report the malformed authority
|
||||
}
|
||||
return uri[:start] + auth[:hostStart] + hp[:colon+1] + first + uri[end:], spec
|
||||
}
|
||||
|
||||
// firstHopPort returns the first concrete port of a hop spec ("443,5000-6000" ->
|
||||
// "443"; "5000-6000" -> "5000"), or "" when none of the parts is a valid port.
|
||||
func firstHopPort(spec string) string {
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if d := strings.IndexByte(part, '-'); d >= 0 {
|
||||
part = part[:d]
|
||||
}
|
||||
if _, err := parsePort(part); err == nil {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// atoiOr parses an int, returning def on failure.
|
||||
func atoiOr(s string, def int) int {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
@@ -255,7 +381,8 @@ func anyMatch(rs []*regexp.Regexp, s string) bool {
|
||||
|
||||
// schemePrefixes is the set of share-link schemes ParseShareLink understands.
|
||||
var schemePrefixes = []string{
|
||||
"vless://", "vmess://", "trojan://", "ss://", "wireguard://", "wg://", "awg://",
|
||||
"vless://", "vmess://", "trojan://", "ss://", "hysteria2://", "hy2://", "tuic://",
|
||||
"wireguard://", "wg://", "awg://",
|
||||
}
|
||||
|
||||
func hasScheme(l string) bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user