Compare commits

..
Author SHA1 Message Date
omarandClaude Opus 5 1746d4d0ef fix: stop the panel and the shipped binary from lying about what works
release / apk aarch64_cortex-a53 (push) Successful in 9m13s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 7s
Four defects, all found by the owner on the live router, all of the same
family: something declared itself working while it was not.

WIREGUARD WAS DEAD IN THE SHIPPED BINARY (B17). Setting up WireGuard gave
"create WireGuard device: gVisor is not included in this build". The router
tag set carried with_wireguard and with_awg but not with_gvisor, so
sing-tun compiled its stub instead of the netstack every WireGuard device
needs. FEATURES.md marks WireGuard [MVP] and AmneziaWG "a driving
requirement", so this was a broken promise, not a trim.

The tag itself was the small half. The tag set was the ONE build
configuration nothing in the repo tested: TestAmneziaWGEndpoint passes
because tests build with the full upstream tags. So the set now lives in
one file (scripts/router-tags.sh) and two guards hold it to the feature
list -- a static check that needs no tags, no Linux and no network (so the
next such gap fails on the developer's machine), and a behavioural one that
constructs every declared protocol through box.New UNDER THE SHIPPED TAGS,
where skipping is forbidden. Removing the tag now fails with the feature
name, the missing tag, and why: "Either add the tag back, or stop declaring
the feature -- those are the only two honest options." Cost: +2.8 MB raw,
+0.6-0.7 MB packed per arch. D23; D9 corrected.

THE PANEL CALLED A DIRECT-ONLY ROUTER "PROTECTED" (B16). The headline came
from plane === 'full', which reports whether the data plane is installed --
nft table, policy routing, live engine -- and says nothing about where the
traffic goes. On a config with one `default -> direct` rule and no groups
the plane is fully installed and every packet leaves in the clear, so the
worst possible state rendered as the reassuring one.

The verdict is now computed on the daemon FROM THE GENERATED OPTIONS at the
moment they reach the engine, not from the model: buildRoute changes the
answer (a scheduled rule outside its window is never emitted, only the last
condition-less rule reaches Final, an unresolved target is rewritten by
ruleKillFallback), and re-deriving it anywhere else is a second
implementation that will drift -- model/reachability.go exists because two
already did. Four verdicts, not three: `blocked` is separate because under
a closed kill-switch with no catch-all nothing leaks, and calling that
"going out directly" is a lie in the alarm direction. Rider: Overview's
defaultTarget printed the highest-Order enabled rule as the default; a rule
becomes Final by having no conditions, whatever its Order.

"PREVENT THIS PAGE FROM CREATING ADDITIONAL DIALOGS" KILLED EVERY DELETE
(B15). Once the browser suppresses dialogs, window.confirm returns false
immediately, so all 15 confirmations across 7 pages read as "cancelled" and
silently did nothing, with no way to recover from inside the panel. Replaced
with an in-app dialog the browser cannot mute: focus trapped and parked on
Cancel, Esc and veil cancel, focus returned to the opener, crit styling for
destructive commits. useConfirm() throws if the provider is missing rather
than falling back to a quiet false -- the failure mode being fixed.

HYSTERIA2 AND TUIC NODES WERE DROPPED (B6). No share-link parser existed,
so a feed's nodes of those types vanished. The real landmine was one layer
up: ParseSubscriptionBody splits a feed by scheme prefix before parsing, so
without schemePrefixes the links were gone before any parser ran and the
fix would have looked complete. Undeliverable parameters are refused when
the node cannot work or would be less secure than the link asked (obfs,
pinSHA256, tuic v4/non-UUID) and flagged via Proxy.Warnings when it
survives -- shaterd nodes shows both. uTLS is dropped for QUIC: it cannot
produce a QUIC TLS config, and that fails at dial time, not at box.New.

Also: nodes added by hand can be named and renamed. The name is the
outbound tag, so a rename rewrites every reference in one PUT -- rule
targets, group members, chain hops, detours -- in the spelling each already
uses, and is refused outright when a group answers to the same bare name.
Subscription nodes state why they cannot be renamed instead of hiding the
control.

go build, go vet, go test ./shater/... (13 packages), panel npm run build
and npm test (13/13) all green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 20:08:58 +03:00
omarandClaude Opus 5 f86501bf77 ci!: drop the opkg lane — apk only, and fix the stale rolling release
Both routers are past opkg: mini_router runs ImmortalWrt 25.12.1 and
main_router OpenWrt 25.12.0, both with apk-tools 3.0.5, and main_router has
no `opkg` binary at all. The 24.10 lane was building and signing a feed no
device could consume.

Removed jobs `build` and `release` with the scripts only they called
(ci/build-feed.sh, ci/sdk-build.sh, ci/make-index.sh, ci/install-usign.sh)
and the usign trust anchor dist/shater-feed.pub. A committed public key is
an instruction: it invites the old install path for a feed that is no longer
produced. The key is retired, not revoked -- git history keeps it, KEY_BUILD
still holds the secret half, and a usign secret contains its own public half,
so the identity is reconstructible if a 24.10 device ever needs serving.
D7 is marked SUPERSEDED by the new D22 rather than deleted.

Separately: the rolling `apk-latest-<arch>` release was frozen at 0.2.0 from
2026-07-24 while every tag run published its versioned release correctly.
The publish loop was an either/or -- `TAG=apk-latest-<arch>` when VER=latest
(workflow_dispatch only), ELSE `TAG=apk-<ver>-<arch>` -- so a `v*` tag run
never touched the rolling pointer. Asset replacement was never the problem;
ci/gitea-release.sh already deletes before recreating. A router pinned to
the rolling URL sat on 0.2.0 while `apk update` reported success: silent
staleness, the failure mode this repo keeps having to close.

The rolling pointer is now published on EVERY run, tag runs included, and a
new assert reads the release back over the API afterwards: our three
tag-versioned packages at the built version plus the index and the key must
be present (exit 13), and no package asset at any other version may survive
(exit 14). Same class of check as sdk-build-apk.sh's package-version assert,
added for the same reason -- the previous failure mode was silent.

KEY_BUILD can now be deleted from the Gitea repo secrets; nothing references
it. Docs state plainly that mini_router is deliberately pinned to a
versioned URL and that the hand-edit per release is the price of pinning.

Known consequence: the x86_64 QEMU testbed is still OpenWrt 24.10.3 and can
no longer install our packages. Its 25.12 rebuild is in flight separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 18:46:21 +03:00
70 changed files with 4331 additions and 1098 deletions
+162 -315
View File
@@ -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
View File
@@ -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
View File
@@ -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
+31 -48
View File
@@ -10,7 +10,7 @@
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
![feeds: opkg 24.10 · apk 25.12](https://img.shields.io/badge/feeds-opkg%2024.10%20%C2%B7%20apk%2025.12-orange.svg)
![feed: apk 25.12+](https://img.shields.io/badge/feed-apk%2025.12%2B-orange.svg)
---
@@ -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
View File
@@ -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)" \
-106
View File
@@ -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
View File
@@ -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)"
-60
View File
@@ -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")"
-39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
untrusted comment: shater feed signing key
RWRaxLF3aJy44JbcxSFujtrFFEQ8lIsnTkd1K5TdjIhdlC2c0wa0fv4V
+10 -11
View File
@@ -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),
+138 -1
View File
@@ -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.
@@ -529,3 +539,130 @@ 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.
+4 -4
View File
@@ -93,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
View File
@@ -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
+1 -1
View File
@@ -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) | Фазовый план |
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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
View File
@@ -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",
+70
View File
@@ -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;
}
+36
View File
@@ -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".
+15 -1
View File
@@ -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);
}
+15 -7
View File
@@ -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}
/>
)
}
})
+162
View File
@@ -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;
}
}
+281
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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)
@@ -405,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)
@@ -416,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',
@@ -518,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
View File
@@ -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(
+3 -51
View File
@@ -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;
+13 -7
View File
@@ -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}`}
+12 -13
View File
@@ -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 (
+50
View File
@@ -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
View File
@@ -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 && (
+17 -9
View File
@@ -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'
}
+10 -4
View File
@@ -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) -------------------
+20 -10
View File
@@ -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,
@@ -366,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)
@@ -603,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 ?? []
@@ -620,7 +625,7 @@ export default function Routing() {
`ruleset ${name} deleted`,
)
},
[config, rules, rulesets, rulesetUsage, persist],
[config, rules, rulesets, rulesetUsage, persist, confirm],
)
const onToggle = useCallback(
@@ -660,14 +665,19 @@ 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).
+32 -16
View File
@@ -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
+150
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 --------------------------------------------------
+118
View File
@@ -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. =="
+78
View File
@@ -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"
+56
View File
@@ -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())
+47
View File
@@ -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)
}
}
+206
View File
@@ -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
}
+175
View File
@@ -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)
}
}
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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") }
+7
View File
@@ -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()
}
+27 -3
View File
@@ -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()})
+236
View File
@@ -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)
}
}
+252
View File
@@ -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()
}
+233
View File
@@ -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)
}
}
+8
View File
@@ -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
View File
@@ -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 {
+259 -5
View File
@@ -1,10 +1,10 @@
package parse
// Share-link parsers for vless/vmess/trojan/ss (+ wireguard/wg/awg in
// wireguard.go). Ported from v0.1 xrayctl/sharelink.go; the OUTPUT target is the
// neutral *Proxy instead of an xray map. Handles modern transports (tcp/raw, ws,
// grpc, xhttp, httpupgrade, http/h2, quic) and security none/tls/reality, plus
// the base64 quirks and the vmess base64-JSON "ps" name.
// Share-link parsers for vless/vmess/trojan/ss/hysteria2/tuic (+ wireguard/wg/awg
// in wireguard.go). Ported from v0.1 xrayctl/sharelink.go; the OUTPUT target is
// the neutral *Proxy instead of an xray map. Handles modern transports (tcp/raw,
// ws, grpc, xhttp, httpupgrade, http/h2, quic) and security none/tls/reality,
// plus the base64 quirks and the vmess base64-JSON "ps" name.
import (
"encoding/json"
@@ -27,6 +27,11 @@ func ParseShareLink(uri string) (*Proxy, error) {
return parseTrojan(uri)
case strings.HasPrefix(uri, "ss://"):
return parseSS(uri)
case strings.HasPrefix(uri, "hysteria2://"),
strings.HasPrefix(uri, "hy2://"):
return parseHysteria2(uri)
case strings.HasPrefix(uri, "tuic://"):
return parseTUIC(uri)
case strings.HasPrefix(uri, "wireguard://"),
strings.HasPrefix(uri, "wg://"),
strings.HasPrefix(uri, "awg://"):
@@ -285,3 +290,252 @@ func parseSS(uri string) (*Proxy, error) {
Password: password,
}, nil
}
// --- QUIC protocols: hysteria2 / tuic ---------------------------------------
//
// # Which spellings are supported, and why
//
// hysteria2 follows the OFFICIAL URI scheme
// (https://v2.hysteria.network/docs/developers/URI-Scheme/):
//
// hysteria2://[auth@]hostname[:port]/?key=value&…#name
//
// `hy2://` is an equally official alias of the same grammar and is accepted
// verbatim. The port is OPTIONAL and defaults to 443. The WHOLE userinfo is the
// auth string, so an auth containing a raw ':' is stitched back together exactly
// like the trojan password (a link that writes it percent-encoded arrives already
// decoded by net/url and needs no stitching). Query keys read here: sni,
// insecure, obfs, obfs-password, pinSHA256, ech, up, down, fastopen — plus the
// two incompatible ways port hopping is written in the wild: the official one
// puts the hop set in the port field itself (`host:443,5000-6000`), while
// NekoBox/v2rayN keep a single port and add `mport=`/`ports=`. Both are handled.
//
// tuic has NO official URI scheme. The de-facto form — emitted by
// v2rayN/NekoBox/NekoRay and understood by every TUIC v5 client — is:
//
// tuic://uuid:password@host:port/?key=value&…#name
//
// uuid and port are mandatory here. The TUIC v4 `token@host:port` form is
// REFUSED rather than guessed at: sing-box implements v5 only, so treating a v4
// token as a uuid would mint a node that can never authenticate. Query keys read
// here: sni, alpn, congestion_control, udp_relay_mode, allow_insecure/insecure,
// disable_sni, zero_rtt_handshake/reduce_rtt.
//
// # What happens to parameters the neutral Proxy cannot carry
//
// Neither parse.Proxy nor generate/outbound.go has a field for obfs, port
// hopping, congestion control, and friends. Swallowing them silently is the one
// thing this package must not do, so each such parameter is sorted into one of
// two buckets:
//
// - REFUSE (return an error). Used when ignoring the parameter yields a node
// that cannot work, or that works with LESS security than the link asked
// for. The error is not a black hole: generate warns per node and skips it
// (outbound.go), and `shaterd nodes` prints it verbatim as `parse_error`, so
// the operator sees the node and the reason side by side. hysteria2 `obfs`
// is the first kind — Salamander obfuscation rewrites every QUIC packet, so
// a client that does not perform it never completes a handshake. hysteria2
// `pinSHA256` is the second — the link pins one certificate (usually
// alongside insecure=1); dropping the pin would leave us accepting ANY
// certificate, which is not "slightly different", it is the MITM the pin
// exists to stop.
//
// - FLAG (Proxy.Warnings). Used when the node still connects and still carries
// the traffic, but some property of the link did not survive: performance
// tuning (congestion control, Brutal up/down), an evasion enhancement (port
// hopping, ECH), or a knob sing-box defaults differently. Refusing these
// would delete most real-world tuic nodes over a throughput hint, which is
// the opposite of honest. `shaterd nodes` reports them as `parse_warnings`.
//
// Both buckets are per-parameter and explicit: an UNRECOGNISED query key is not
// flagged, because feed operators sprinkle tracking junk into links and warning
// about it would drown the warnings that mean something.
// quicTLS builds the TLS block for a QUIC protocol from the query params.
//
// buildSecurity is reused (sni/host/alpn/insecure handling is identical and must
// not drift), but it does NOT fit as-is on two counts, both handled here:
//
// - its `network` argument would synthesise a v2ray stream transport, which is
// meaningless over QUIC — passing "" makes normalizeTransport return "tcp"
// and no Transport is built;
// - it copies `fp` into TLS.Fingerprint, which generate turns into a uTLS
// client. uTLS cannot produce a QUIC TLS config (common/tls/utls_client.go
// STDConfig returns "unsupported usage for uTLS"), and the failure surfaces
// only when the node is DIALLED, i.e. as an unexplained dead node. The
// fingerprint is therefore dropped here, loudly.
//
// It also widens the insecure flag: the official hysteria2 scheme documents
// "1"/"0", but links in the wild spell it "true"/"yes"/"on" and a silently
// ignored insecure flag turns a self-signed node into a permanently failing one.
func quicTLS(proto string, q url.Values, server string) (*TLS, []string) {
tls, _ := buildSecurity("", "tls", q, server)
if tls == nil { // unreachable: security is fixed to "tls"
tls = &TLS{Enabled: true, SNI: server}
}
var warns []string
if tls.Fingerprint != "" {
warns = append(warns, fmt.Sprintf(
"%s: fp=%s dropped — a uTLS fingerprint cannot be applied to a QUIC handshake, and keeping it would make the node fail at dial time instead of here",
proto, tls.Fingerprint))
tls.Fingerprint = ""
}
if !tls.Insecure {
tls.Insecure = truthyParam(q.Get("insecure")) ||
truthyParam(q.Get("allow_insecure")) || truthyParam(q.Get("allowInsecure"))
}
return tls, warns
}
func parseHysteria2(uri string) (*Proxy, error) {
// Port hopping in the port field ("host:443,5000-6000") is not a valid URI
// authority, so net/url would reject the whole link. Fold it to the first
// concrete port BEFORE parsing and remember the original spec for the warning.
rewritten, hopSpec := rewriteHopPorts(uri)
u, err := url.Parse(rewritten)
if err != nil {
return nil, fmt.Errorf("hysteria2: %w", err)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("hysteria2: missing host")
}
port := uint16(443) // the scheme makes the port optional; 443 is its default
if ps := u.Port(); ps != "" {
if port, err = parsePort(ps); err != nil {
return nil, fmt.Errorf("hysteria2: %w", err)
}
}
// The whole userinfo is the auth string (same rule as the trojan password).
auth := ""
if u.User != nil {
auth = u.User.Username()
if pw, ok := u.User.Password(); ok {
auth = auth + ":" + pw
}
}
if auth == "" {
// The grammar marks auth optional, but every deployed hysteria2 server
// configures an auth block, so an authless link is a truncated one.
// Accepting it would add a node that can only ever fail to authenticate.
return nil, fmt.Errorf("hysteria2: missing auth")
}
q := u.Query()
p := foldedParams(q)
if obfs := strings.ToLower(p["obfs"]); obfs != "" && obfs != "none" {
return nil, fmt.Errorf("hysteria2: obfs=%s is not supported — "+
"obfuscation rewrites every QUIC packet, so a client that cannot perform it never completes a handshake; "+
"the node is refused rather than added as one that can only fail", obfs)
}
if pin := p["pinsha256"]; pin != "" {
return nil, fmt.Errorf("hysteria2: pinSHA256 is not supported — " +
"the link pins one certificate and this build cannot honour the pin; " +
"ignoring it would mean accepting ANY certificate instead, which is exactly what the pin exists to prevent")
}
tls, warns := quicTLS("hysteria2", q, host)
if hopSpec == "" {
hopSpec = firstNonEmpty(p["mport"], p["ports"])
}
if hopSpec != "" {
warns = append(warns, fmt.Sprintf(
"hysteria2: port hopping %q is not carried into the engine — the node dials %d only. "+
"Hopping is a censorship-evasion enhancement layered on top of the real listener "+
"(the server listens on one port and redirects the range to it), so the node still works, but it is more fingerprintable",
hopSpec, port))
}
if p["ech"] != "" {
warns = append(warns, "hysteria2: ech= is not carried into the engine — "+
"the TLS ClientHello, and with it the SNI, travels in the clear")
}
if p["up"] != "" || p["down"] != "" {
warns = append(warns, fmt.Sprintf(
"hysteria2: bandwidth hints (up=%q down=%q) are not carried into the engine — "+
"the Brutal congestion controller stays off and BBR is used instead; throughput may differ from what the provider sized",
p["up"], p["down"]))
}
if truthyParam(p["fastopen"]) {
warns = append(warns, "hysteria2: fastopen is not carried into the engine — "+
"the first request waits for the tunnel to be established (latency only, no functional change)")
}
return &Proxy{
Name: fragmentName(uri),
Protocol: "hysteria2",
Server: host,
Port: port,
Password: auth,
TLS: tls,
Warnings: warns,
}, nil
}
func parseTUIC(uri string) (*Proxy, error) {
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("tuic: %w", err)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("tuic: missing host")
}
port, err := parsePort(u.Port())
if err != nil {
return nil, fmt.Errorf("tuic: %w", err)
}
if u.User == nil {
return nil, fmt.Errorf("tuic: missing uuid:password")
}
uuid := u.User.Username()
pw, hasPW := u.User.Password()
if !hasPW {
// A single userinfo component is the TUIC v4 "token" form. sing-box
// implements v5 only; passing the token off as a uuid would produce a
// node that authenticates against nothing.
return nil, fmt.Errorf("tuic: userinfo %q is not uuid:password — "+
"only the TUIC v5 form is supported (the v4 token form cannot be dialled by this engine)", uuid)
}
if !looksLikeUUID(uuid) {
// Not cosmetic: the TUIC outbound rejects a non-UUID at CONSTRUCTION,
// and that failure aborts the whole generated config — every other node
// with it. Refusing here costs one node instead.
return nil, fmt.Errorf("tuic: %q is not a uuid", uuid)
}
q := u.Query()
p := foldedParams(q)
tls, warns := quicTLS("tuic", q, host)
// sing-box's own defaults are cubic + native; only a DIFFERENT value in the
// link is a lost instruction worth reporting.
if cc := strings.ToLower(p["congestion_control"]); cc != "" && cc != "cubic" {
warns = append(warns, fmt.Sprintf(
"tuic: congestion_control=%s is not carried into the engine — the node dials with sing-box's default (cubic); "+
"this is a throughput difference, not a connectivity one", cc))
}
if m := strings.ToLower(p["udp_relay_mode"]); m != "" && m != "native" {
warns = append(warns, fmt.Sprintf(
"tuic: udp_relay_mode=%s is not carried into the engine — UDP is relayed in native mode (QUIC datagrams)", m))
}
if truthyParam(p["disable_sni"]) {
warns = append(warns, fmt.Sprintf(
"tuic: disable_sni is not carried into the engine — the SNI %q is sent in the clear, so the destination is visible to a middlebox", tls.SNI))
}
if truthyParam(p["zero_rtt_handshake"]) || truthyParam(p["reduce_rtt"]) {
warns = append(warns, "tuic: zero_rtt_handshake is not carried into the engine — "+
"a full handshake is performed (one extra round trip on connect)")
}
return &Proxy{
Name: fragmentName(uri),
Protocol: "tuic",
Server: host,
Port: port,
UUID: uuid,
Password: pw,
TLS: tls,
Warnings: warns,
}, nil
}
+324 -2
View File
@@ -2,6 +2,7 @@ package parse
import (
"encoding/base64"
"strings"
"testing"
)
@@ -157,7 +158,328 @@ func TestFingerprintStable(t *testing.T) {
}
func TestUnsupportedScheme(t *testing.T) {
if _, err := ParseShareLink("hysteria2://x@h:1"); err == nil {
t.Fatal("expected error on unsupported scheme")
// hysteria v1 and socks are genuinely not implemented (registry_quic.go
// registers hysteria2/tuic only), so they are the honest examples here.
for _, uri := range []string{"hysteria://x@h:1", "socks5://u:p@h:1080", "juicity://x@h:1"} {
if _, err := ParseShareLink(uri); err == nil {
t.Fatalf("expected error on unsupported scheme %q", uri)
}
}
}
// --- hysteria2 --------------------------------------------------------------
func TestParseHysteria2(t *testing.T) {
uri := "hysteria2://s3cret@example.com:8443/?sni=cdn.example.com&insecure=1&alpn=h3#hy2-1"
p, err := ParseShareLink(uri)
if err != nil {
t.Fatal(err)
}
if p.Protocol != "hysteria2" {
t.Fatalf("protocol = %v", p.Protocol)
}
if p.Server != "example.com" || p.Port != 8443 {
t.Fatalf("server/port = %v:%v", p.Server, p.Port)
}
if p.Password != "s3cret" {
t.Fatalf("password = %q", p.Password)
}
if p.UUID != "" || p.Method != "" || p.Transport != nil {
t.Fatalf("unexpected non-hysteria2 fields set: %+v", p)
}
if p.TLS == nil || !p.TLS.Enabled {
t.Fatalf("tls = %+v", p.TLS)
}
if p.TLS.SNI != "cdn.example.com" || !p.TLS.Insecure {
t.Fatalf("tls = %+v", p.TLS)
}
if len(p.TLS.ALPN) != 1 || p.TLS.ALPN[0] != "h3" {
t.Fatalf("alpn = %v", p.TLS.ALPN)
}
if p.TLS.Reality != nil {
t.Fatalf("reality must never be built for a QUIC link: %+v", p.TLS.Reality)
}
if p.Name != "hy2-1" {
t.Fatalf("name = %v", p.Name)
}
if len(p.Warnings) != 0 {
t.Fatalf("clean link warned: %v", p.Warnings)
}
}
// hy2:// is an official alias, the port defaults to 443, and SNI falls back to
// the server address (so generate never has to synthesise one).
func TestParseHysteria2AliasDefaultPort(t *testing.T) {
p, err := ParseShareLink("hy2://pw@h.example.com#alias")
if err != nil {
t.Fatal(err)
}
if p.Protocol != "hysteria2" || p.Port != 443 {
t.Fatalf("hy2 alias/default port: %+v", p)
}
if p.TLS == nil || p.TLS.SNI != "h.example.com" {
t.Fatalf("sni fallback = %+v", p.TLS)
}
}
// The WHOLE userinfo is the auth string: a raw ':' must not truncate it, and a
// percent-encoded one arrives already decoded.
func TestParseHysteria2AuthForms(t *testing.T) {
cases := map[string]string{
"hysteria2://user:pass@h.example.com:443#a": "user:pass",
"hysteria2://p%40ss%23w%3Ax@h.example.com:443": "p@ss#w:x",
"hysteria2://plain@h.example.com:443": "plain",
}
for uri, want := range cases {
p, err := ParseShareLink(uri)
if err != nil {
t.Fatalf("%s: %v", uri, err)
}
if p.Password != want {
t.Errorf("%s: auth = %q, want %q", uri, p.Password, want)
}
}
}
func TestParseHysteria2IPv6AndNoFragment(t *testing.T) {
p, err := ParseShareLink("hysteria2://pw@[2001:db8::1]:443?sni=v6.example.com")
if err != nil {
t.Fatal(err)
}
if p.Server != "2001:db8::1" || p.Port != 443 {
t.Fatalf("IPv6 host/port mangled: %v/%v", p.Server, p.Port)
}
if p.Name != "" {
t.Fatalf("name from a fragment-less link = %q, want empty", p.Name)
}
}
// obfs and pinSHA256 are REFUSED, not flagged: without the first the handshake
// can never complete, and ignoring the second silently downgrades the link's
// certificate pinning to "accept anything".
func TestParseHysteria2RefusesUndeliverableSecurity(t *testing.T) {
cases := map[string]string{
"hysteria2://pw@h.example.com:443?obfs=salamander&obfs-password=x": "obfs",
"hysteria2://pw@h.example.com:443?obfs=gecko": "obfs",
"hysteria2://pw@h.example.com:443?pinSHA256=aa:bb:cc": "pinSHA256",
"hysteria2://pw@h.example.com:443?pinsha256=aabbcc": "pinSHA256",
}
for uri, want := range cases {
p, err := ParseShareLink(uri)
if err == nil {
t.Errorf("%s: accepted (%+v), want refusal", uri, p)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("%s: error %q does not name %q", uri, err, want)
}
}
// obfs=none is the explicit "no obfuscation" spelling and must pass.
if _, err := ParseShareLink("hysteria2://pw@h.example.com:443?obfs=none"); err != nil {
t.Errorf("obfs=none refused: %v", err)
}
}
// Port hopping in either spelling keeps the node (the base port is the server's
// real listener) but must leave a warning behind.
func TestParseHysteria2PortHopping(t *testing.T) {
cases := []struct {
uri string
port uint16
}{
{"hysteria2://pw@h.example.com:443,5000-6000/?sni=h.example.com#hop", 443},
{"hysteria2://pw@h.example.com:5000-6000#hop", 5000},
{"hysteria2://pw@h.example.com:443?mport=10000-20000#hop", 443},
{"hysteria2://pw@h.example.com:443?ports=10000-20000#hop", 443},
{"hysteria2://pw@[2001:db8::1]:443,5000-6000#hop", 443},
}
for _, c := range cases {
p, err := ParseShareLink(c.uri)
if err != nil {
t.Errorf("%s: %v", c.uri, err)
continue
}
if p.Port != c.port {
t.Errorf("%s: port = %d, want %d", c.uri, p.Port, c.port)
}
if !warnedAbout(p, "port hopping") {
t.Errorf("%s: port hopping dropped silently (warnings: %v)", c.uri, p.Warnings)
}
}
}
// A uTLS fingerprint cannot be used for a QUIC handshake; keeping it would make
// the node fail at dial time with an unrelated-looking message.
func TestParseHysteria2DropsUTLSFingerprint(t *testing.T) {
p, err := ParseShareLink("hysteria2://pw@h.example.com:443?fp=chrome#fp")
if err != nil {
t.Fatal(err)
}
if p.TLS.Fingerprint != "" {
t.Fatalf("fingerprint kept for QUIC: %q", p.TLS.Fingerprint)
}
if !warnedAbout(p, "fp=chrome") {
t.Fatalf("dropped fingerprint without a warning: %v", p.Warnings)
}
}
func TestParseHysteria2OtherLossesWarn(t *testing.T) {
p, err := ParseShareLink("hysteria2://pw@h.example.com:443?up=100&down=500&fastopen=1&ech=AEX+DQ")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"bandwidth hints", "fastopen", "ech"} {
if !warnedAbout(p, want) {
t.Errorf("no warning mentioning %q: %v", want, p.Warnings)
}
}
}
// insecure= is spelled several ways in the wild; reading it as false would turn
// a working self-signed node into a permanently failing one.
func TestParseHysteria2InsecureSpellings(t *testing.T) {
for _, q := range []string{"insecure=1", "insecure=true", "insecure=yes", "allow_insecure=true", "allowInsecure=1"} {
p, err := ParseShareLink("hysteria2://pw@h.example.com:443?" + q)
if err != nil {
t.Fatalf("%s: %v", q, err)
}
if !p.TLS.Insecure {
t.Errorf("%s: insecure not honoured", q)
}
}
p, _ := ParseShareLink("hysteria2://pw@h.example.com:443?insecure=0")
if p.TLS.Insecure {
t.Error("insecure=0 read as true")
}
}
// --- tuic -------------------------------------------------------------------
func TestParseTUIC(t *testing.T) {
uri := "tuic://22222222-2222-2222-2222-222222222222:s3cret@example.com:443/?sni=example.com&alpn=h3&congestion_control=cubic&udp_relay_mode=native#tuic-1"
p, err := ParseShareLink(uri)
if err != nil {
t.Fatal(err)
}
if p.Protocol != "tuic" {
t.Fatalf("protocol = %v", p.Protocol)
}
if p.Server != "example.com" || p.Port != 443 {
t.Fatalf("server/port = %v:%v", p.Server, p.Port)
}
if p.UUID != "22222222-2222-2222-2222-222222222222" {
t.Fatalf("uuid = %q", p.UUID)
}
if p.Password != "s3cret" {
t.Fatalf("password = %q", p.Password)
}
if p.TLS == nil || p.TLS.SNI != "example.com" || p.TLS.Insecure {
t.Fatalf("tls = %+v", p.TLS)
}
if len(p.TLS.ALPN) != 1 || p.TLS.ALPN[0] != "h3" {
t.Fatalf("alpn = %v", p.TLS.ALPN)
}
if p.Transport != nil {
t.Fatalf("a QUIC link must not build a stream transport: %+v", p.Transport)
}
if p.Name != "tuic-1" {
t.Fatalf("name = %v", p.Name)
}
// congestion_control/udp_relay_mode equal to sing-box's own defaults are not
// a loss, so they must not produce noise.
if len(p.Warnings) != 0 {
t.Fatalf("default-valued knobs warned: %v", p.Warnings)
}
}
func TestParseTUICPercentEncodedPasswordAndIPv6(t *testing.T) {
p, err := ParseShareLink("tuic://33333333-3333-3333-3333-333333333333:p%40ss%3Aword@[2001:db8::2]:8443?allow_insecure=1")
if err != nil {
t.Fatal(err)
}
if p.Server != "2001:db8::2" || p.Port != 8443 {
t.Fatalf("IPv6 host/port mangled: %v/%v", p.Server, p.Port)
}
if p.Password != "p@ss:word" {
t.Fatalf("password = %q", p.Password)
}
if !p.TLS.Insecure {
t.Fatalf("allow_insecure not honoured: %+v", p.TLS)
}
if p.Name != "" {
t.Fatalf("name from a fragment-less link = %q, want empty", p.Name)
}
}
func TestParseTUICWarnsOnUndeliverableKnobs(t *testing.T) {
uri := "tuic://44444444-4444-4444-4444-444444444444:pw@h.example.com:443?congestion_control=bbr&udp_relay_mode=quic&disable_sni=1&zero_rtt_handshake=1&fp=chrome"
p, err := ParseShareLink(uri)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"congestion_control=bbr", "udp_relay_mode=quic", "disable_sni", "zero_rtt_handshake", "fp=chrome"} {
if !warnedAbout(p, want) {
t.Errorf("no warning mentioning %q: %v", want, p.Warnings)
}
}
if p.TLS.Fingerprint != "" {
t.Errorf("fingerprint kept for QUIC: %q", p.TLS.Fingerprint)
}
}
func TestParseTUICRejects(t *testing.T) {
cases := map[string]string{
// v4 token form: sing-box speaks v5 only.
"tuic://sometoken@h.example.com:443": "uuid:password",
"tuic://not-a-uuid:pw@h.example.com:443": "not a uuid",
"tuic://44444444-4444-4444-4444-444444444444:pw@": "host",
"tuic://44444444-4444-4444-4444-444444444444:pw@h.example.com": "port",
"tuic://44444444-4444-4444-4444-444444444444:pw@h.example.com:notaport": "port",
"tuic://44444444-4444-4444-4444-444444444444:pw@h.example.com:0": "port",
"tuic://44444444-4444-4444-4444-444444444444:pw@h.example.com:99999": "port",
"tuic://h.example.com:443": "uuid",
}
for uri, want := range cases {
p, err := ParseShareLink(uri)
if err == nil {
t.Errorf("%s: accepted (%+v), want error", uri, p)
continue
}
if !strings.Contains(err.Error(), want) {
t.Errorf("%s: error %q does not mention %q", uri, err, want)
}
}
// The bare 32-hex uuid spelling is legal (sing-box's uuid.FromString takes it).
if _, err := ParseShareLink("tuic://44444444444444444444444444444444:pw@h.example.com:443"); err != nil {
t.Errorf("bare 32-hex uuid refused: %v", err)
}
}
// A subscription body is split by scheme prefix before anything is parsed, so a
// missing prefix drops the node one layer EARLIER than ParseShareLink — silently.
func TestSubscriptionBodyKeepsQUICLinks(t *testing.T) {
body := "hysteria2://pw@a.example.com:443#hy2\n" +
"hy2://pw@b.example.com:443#hy2-alias\n" +
"tuic://55555555-5555-5555-5555-555555555555:pw@c.example.com:443#tuic\n"
links := ParseSubscriptionBody([]byte(body))
if len(links) != 3 {
t.Fatalf("ParseSubscriptionBody kept %d of 3 QUIC links: %v", len(links), links)
}
ps, err := ParseSubBody([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(ps) != 3 {
t.Fatalf("ParseSubBody produced %d of 3 proxies: %v", len(ps), names(ps))
}
}
// warnedAbout reports whether any Proxy warning mentions substr.
func warnedAbout(p *Proxy, substr string) bool {
for _, w := range p.Warnings {
if strings.Contains(w, substr) {
return true
}
}
return false
}
+18
View File
@@ -43,6 +43,24 @@ type Proxy struct {
// wireguard / AmneziaWG (nil unless Protocol == "wireguard")
WG *WGConfig
// Warnings records, in human-readable form, everything the share-link asked
// for that this Proxy CANNOT carry to the generate stage — hysteria2 port
// hopping, tuic congestion control, a uTLS fingerprint on a QUIC link, and so
// on. It is empty for a link that survived its translation intact.
//
// It exists because the alternative was to swallow those parameters: the node
// would then connect, but not the way the link describes it, and nothing
// anywhere would say so. A parameter whose loss makes the node UNUSABLE or
// LESS SECURE is not put here — that is an error from ParseShareLink, so the
// node is skipped with a reason instead. This field is only for losses the
// node survives.
//
// Consumers: `shaterd nodes` prints it as `parse_warnings`. It is deliberately
// NOT emitted per-apply by the generate stage: a 300-node subscription would
// repeat the same lines on every apply and train the operator to ignore the
// warning channel that also carries kill-switch and chain failures.
Warnings []string
// Passthrough for the generate stage; the parser leaves these at zero. In the
// v0.2 model, per-node multiplexing and sockopt/mark live on model.Node and are
// combined with this Proxy at generate time — these fields exist so the contract