Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0ea55054 | ||
|
|
625942834b | ||
|
|
4bf4ad8aa2 | ||
|
|
be1cdbfc63 | ||
|
|
bbb493ea91 | ||
|
|
4869d62e02 | ||
|
|
efb2177f43 | ||
|
|
815011dfb0 |
@@ -11,13 +11,12 @@
|
||||
# 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 mini + BPI-R4,
|
||||
# mediatek/filogic), both on 25.12 with apk-tools 3.
|
||||
# Only shaterd + byedpi are arch-specific; shater-core + luci-app-shater are
|
||||
# Only shaterd is arch-specific; shater-core + luci-app-shater are
|
||||
# PKGARCH=all, so one build of each covers every device — but the RELEASES
|
||||
# are still per-arch (see the release-apk job for why).
|
||||
#
|
||||
@@ -59,8 +58,8 @@
|
||||
# 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).
|
||||
# — `git describe` needs tags and ancestry. Every package this repo ships is
|
||||
# versioned from the tag; there is no longer an exception to remember.
|
||||
|
||||
# CACHING (T3 — fast CI)
|
||||
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
|
||||
@@ -453,7 +452,7 @@ jobs:
|
||||
echo "[release-apk] arch=$arch built version=$want"
|
||||
|
||||
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
|
||||
Packages: shaterd + byedpi (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
|
||||
This build: \`$want\`.
|
||||
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
|
||||
|
||||
@@ -462,7 +461,6 @@ jobs:
|
||||
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
|
||||
@@ -470,7 +468,7 @@ jobs:
|
||||
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
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
A bare \`apk upgrade\` reconciles EVERY installed package against every
|
||||
configured repo and can downgrade unrelated system packages; naming them
|
||||
upgrades only those (apk-tools 3: \"If list of packages is provided, only
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
|
||||
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
|
||||
`apk upgrade shaterd shater-core luci-app-shater byedpi`.
|
||||
`apk upgrade shaterd shater-core luci-app-shater`.
|
||||
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
|
||||
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
|
||||
иначе.
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ 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`
|
||||
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
|
||||
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
|
||||
pin a build; that file then has to be edited by hand for every upgrade.
|
||||
|
||||
@@ -109,7 +109,7 @@ sufficient: it does not see the kernel, procd or nftables seams.
|
||||
|------|------|
|
||||
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
|
||||
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
|
||||
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
|
||||
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater` |
|
||||
| `docs-shater/` | Product documentation |
|
||||
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
|
||||
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
|
||||
|
||||
@@ -139,8 +139,8 @@ BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/
|
||||
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
|
||||
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
|
||||
|
||||
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`
|
||||
(+ опциональный `byedpi`). `shaterd` подтягивается автоматически как зависимость.
|
||||
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
|
||||
`shaterd` подтягивается автоматически как зависимость.
|
||||
|
||||
### Фид apk
|
||||
|
||||
@@ -158,7 +158,6 @@ echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/a
|
||||
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
|
||||
apk update
|
||||
apk add luci-app-shater # -> shater-core -> shaterd
|
||||
apk add byedpi # опционально: ByeDPI desync-egress
|
||||
```
|
||||
|
||||
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
|
||||
@@ -168,14 +167,14 @@ apk add byedpi # опционально: ByeDPI desync-egress
|
||||
|
||||
```sh
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
# эквивалент, дополнительно закрепляющий пакеты в world:
|
||||
# apk add -u shaterd shater-core luci-app-shater byedpi
|
||||
# apk add -u shaterd shater-core luci-app-shater
|
||||
```
|
||||
|
||||
Документация apk-tools 3 про `apk upgrade`: *«If list of packages is provided,
|
||||
only those packages are upgraded along with needed dependencies»*. Проверить
|
||||
установленные версии: `apk list -I shaterd shater-core luci-app-shater byedpi`.
|
||||
установленные версии: `apk list -I shaterd shater-core luci-app-shater`.
|
||||
|
||||
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
|
||||
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
|
||||
@@ -282,7 +281,7 @@ bash scripts/run-tests.sh --no-race # без -race, для локального
|
||||
|------|---------|
|
||||
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
|
||||
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
|
||||
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater`, `byedpi` |
|
||||
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
|
||||
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
|
||||
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
|
||||
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
|
||||
|
||||
@@ -110,7 +110,12 @@ docker run --rm --volumes-from "$(hostname)" \
|
||||
# --- 2) sanity: the per-arch apk repo dir must be complete -------------------
|
||||
[ -s "$OUT/packages.adb" ] || { echo "[apk-feed] ERROR: $OUT/packages.adb missing/empty" >&2; exit 4; }
|
||||
apks=$(find "$OUT" -maxdepth 1 -name '*.apk' | wc -l)
|
||||
[ "$apks" -ge 4 ] || { echo "[apk-feed] ERROR: expected >=4 .apk in $OUT, found $apks" >&2; exit 5; }
|
||||
# Three, since D29 removed byedpi: shaterd, shater-core, luci-app-shater. The
|
||||
# count lives in TWO scripts — sdk-build-apk.sh asserts what it collected out of
|
||||
# bin/, this one asserts what reached the feed dir. v0.2.22 shipped with only the
|
||||
# first one updated and the aarch64 lane died here on `found 3`, so if the set of
|
||||
# packages ever changes again, change it in both.
|
||||
[ "$apks" -ge 3 ] || { echo "[apk-feed] ERROR: expected >=3 .apk in $OUT, found $apks" >&2; exit 5; }
|
||||
if [ -n "${KEY_APK:-}" ] && [ ! -s "$OUT/shater-apk.pem" ]; then
|
||||
echo "[apk-feed] ERROR: signed feed but shater-apk.pem missing from $OUT" >&2; exit 6
|
||||
fi
|
||||
|
||||
+14
-13
@@ -30,7 +30,7 @@ echo "[apk-sdk] sdk=$SDK_URL"
|
||||
# Package version derived from the git tag by ci/version.sh (bug B4). Forwarded
|
||||
# to the unprivileged build user on the `su` line at the bottom of this file;
|
||||
# openwrt/{shaterd,shater-core,luci-app-shater}/Makefile pick it up from the
|
||||
# environment. byedpi keeps upstream ByeDPI's own version (see its Makefile).
|
||||
# environment. All three are versioned from the tag — there is no exception.
|
||||
echo "[apk-sdk] package version: ${SHATER_PKG_VERSION:-<unset -> Makefile fallback>}-r${SHATER_PKG_RELEASE:-?}"
|
||||
test -f "$REPO/openwrt/shaterd/Makefile" || {
|
||||
echo "[apk-sdk] ERROR: feed not mounted ($REPO/openwrt/shaterd/Makefile missing)"; ls -la "$REPO" || true; exit 9; }
|
||||
@@ -135,7 +135,7 @@ if ! ./scripts/feeds update -a; then
|
||||
./scripts/feeds update -a
|
||||
fi
|
||||
echo "[apk-sdk] feeds install (prefer shater feed)"
|
||||
./scripts/feeds install -p shater shaterd shater-core byedpi luci-app-shater
|
||||
./scripts/feeds install -p shater shaterd shater-core luci-app-shater
|
||||
|
||||
# --- strip the SDK's generated per-package `default m` blocks ----------------
|
||||
# Run 60 settled the question that runs 58 and 59 left open. Writing an explicit
|
||||
@@ -232,7 +232,7 @@ if [ -s .config.sdk ]; then
|
||||
.config.sdk | tee -a .config | sed 's/^/[apk-sdk] /' || true
|
||||
fi
|
||||
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
echo "CONFIG_PACKAGE_$p=m" >> .config
|
||||
done
|
||||
# Route source downloads through OpenWrt's fast CDN mirror FIRST. Some upstreams
|
||||
@@ -336,15 +336,15 @@ fi
|
||||
echo "[apk-sdk] cache settings after defconfig:"
|
||||
grep -E '^CONFIG_(LOCALMIRROR|DOWNLOAD_FOLDER)=' .config | sed 's/^/[apk-sdk] /' || true
|
||||
echo "[apk-sdk] our packages after defconfig:"
|
||||
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|byedpi|luci-app-shater)=' .config \
|
||||
grep -E '^CONFIG_PACKAGE_(shaterd|shater-core|luci-app-shater)=' .config \
|
||||
| sed 's/^/[apk-sdk] /' || true
|
||||
|
||||
# Each of our 4 must have SURVIVED defconfig. If kconfig dropped one, it is
|
||||
# Each of our 3 must have SURVIVED defconfig. If kconfig dropped one, it is
|
||||
# because a symbol it `select`s (a DEPENDS entry) does not exist in the installed
|
||||
# feeds — with the old append-everything .config that was masked by the SDK
|
||||
# pre-selecting half the distro. `make package/<p>/compile` would then die with a
|
||||
# cryptic "No rule to make target", far from the real cause.
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
grep -q "^CONFIG_PACKAGE_$p=m" .config || {
|
||||
echo "[apk-sdk] ERROR: $p is NOT selected after defconfig."
|
||||
echo " kconfig dropped it -> one of its DEPENDS is missing from the"
|
||||
@@ -368,7 +368,7 @@ done
|
||||
grep -m5 '^CONFIG_PACKAGE_kmod.*=m' .config | sed 's/^/ /' || true
|
||||
exit 11; }
|
||||
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
echo "[apk-sdk] === build $p ==="
|
||||
make "package/$p/compile" V=s -j"$(nproc)"
|
||||
done
|
||||
@@ -384,24 +384,25 @@ anyapk=$(find bin -type f -name '*.apk' | wc -l)
|
||||
[ "$anyapk" -gt 0 ] || {
|
||||
echo "[apk-sdk] ERROR: no .apk produced under bin/ (wrong/older SDK? found $(find bin -type f -name '*.ipk' | wc -l) .ipk)";
|
||||
find bin -maxdepth 4 -type d || true; exit 6; }
|
||||
# Collect ONLY our 4 packages' .apk (apk filenames carry NO arch:
|
||||
# Collect ONLY our 3 packages' .apk (apk filenames carry NO arch:
|
||||
# `<name>-<ver>-r<rel>.apk`). NOT a blanket `*.apk` copy — the SDK bin/ can hold
|
||||
# prebuilt base/kmod .apk that would bloat the index and be signed under our key.
|
||||
found=0
|
||||
for p in shaterd shater-core byedpi luci-app-shater; do
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
for a in $(find bin -type f -name "${p}-*.apk"); do
|
||||
cp -f "$a" "$OUT/"; found=$((found+1))
|
||||
done
|
||||
done
|
||||
[ "$found" -ge 4 ] || { echo "[apk-sdk] ERROR: expected >=4 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
|
||||
[ "$found" -ge 3 ] || { echo "[apk-sdk] ERROR: expected >=3 of OUR .apk, collected $found"; echo "[apk-sdk] (all .apk under bin/:)"; find bin -type f -name '*.apk' | head -20; exit 6; }
|
||||
echo "[apk-sdk] collected $found of our .apk"
|
||||
|
||||
# --- assert the tag-derived version actually reached the packages -------------
|
||||
# B4's failure mode is a wrong-but-plausible version shipping silently, so the
|
||||
# env -> make hand-off is verified, not trusted: each of our three tag-versioned
|
||||
# packages must be named `<name>-<ver>-r<rel>.apk`. byedpi is excluded on purpose
|
||||
# (it carries upstream ByeDPI's own version). This runs BEFORE `apk mkndx`, so a
|
||||
# stale version can never even reach the index.
|
||||
# packages must be named `<name>-<ver>-r<rel>.apk`. Every package this repo ships
|
||||
# is tag-versioned, so the check covers all of them with no exception to
|
||||
# remember. This runs BEFORE `apk mkndx`, so a stale version can never even
|
||||
# reach the index.
|
||||
if [ -n "${SHATER_PKG_VERSION:-}" ] && [ -n "${SHATER_PKG_RELEASE:-}" ]; then
|
||||
want="${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE}"
|
||||
for p in shaterd shater-core luci-app-shater; do
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@
|
||||
# a dispatch build of the tagged commit itself identical to the release build of
|
||||
# that same commit — which is the truth: same tree, same binary.
|
||||
#
|
||||
# `byedpi` is deliberately NOT versioned from our tag — see openwrt/byedpi/Makefile.
|
||||
# Every package this repo ships is versioned from the tag. There used to be one
|
||||
# exception (an external tool carrying its upstream's own version); it is gone
|
||||
# with the package, and nothing here has to remember it any more.
|
||||
#
|
||||
# USAGE
|
||||
# ci/version.sh # or --env: eval-able / $GITHUB_ENV-able lines
|
||||
|
||||
+227
-18
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +49,33 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
}()
|
||||
serverName := IndexTLSServerName(b)
|
||||
if serverName != nil {
|
||||
// The SNI extension carries a LIST of names; MyServerName.Length is
|
||||
// the length of the FIRST entry while MyServerName.ServerName is
|
||||
// everything left in the extension. Plan the cuts over the first
|
||||
// entry only: a second entry would otherwise be handed to the
|
||||
// public suffix list as if it were part of the name.
|
||||
name := serverName.ServerName
|
||||
if serverName.Length >= 0 && serverName.Length < len(name) {
|
||||
name = name[:serverName.Length]
|
||||
}
|
||||
// Packet fragmentation pays half a second per cut, record
|
||||
// fragmentation pays microseconds — see the budget constants.
|
||||
budget := recordCutBudget
|
||||
if c.splitPacket {
|
||||
budget = packetCutBudget
|
||||
}
|
||||
splitIndexes := cutOffsets(name, budget, rand.Intn)
|
||||
if len(splitIndexes) == 0 {
|
||||
// Nothing inside this name can be cut — it is empty or a single
|
||||
// byte, so there is no offset that leaves a non-empty piece on
|
||||
// both sides. Write the ClientHello as it stands: the loop
|
||||
// below reads b[:splitIndexes[0]] unconditionally and would
|
||||
// panic on an empty plan.
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
for i := range splitIndexes {
|
||||
splitIndexes[i] += serverName.Index
|
||||
}
|
||||
if c.splitPacket {
|
||||
if c.tcpConn != nil {
|
||||
err = c.tcpConn.SetNoDelay(true)
|
||||
@@ -55,24 +84,6 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
splits := strings.Split(serverName.ServerName, ".")
|
||||
currentIndex := serverName.Index
|
||||
if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" {
|
||||
splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
|
||||
}
|
||||
if len(splits) > 1 && splits[0] == "..." {
|
||||
currentIndex += len(splits[0]) + 1
|
||||
splits = splits[1:]
|
||||
}
|
||||
var splitIndexes []int
|
||||
for i, split := range splits {
|
||||
splitAt := rand.Intn(len(split))
|
||||
splitIndexes = append(splitIndexes, currentIndex+splitAt)
|
||||
currentIndex += len(split)
|
||||
if i != len(splits)-1 {
|
||||
currentIndex++
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
for i := 0; i <= len(splitIndexes); i++ {
|
||||
var payload []byte
|
||||
@@ -133,6 +144,204 @@ func (c *Conn) Write(b []byte) (n int, err error) {
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
// labelSpan is the half-open byte range [start, end) of one DNS label inside a
|
||||
// server name, relative to the first byte of that name.
|
||||
type labelSpan struct {
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// How many cuts Write may spend on one ClientHello. The two numbers differ
|
||||
// because the two modes cost completely different things per cut — MEASURED
|
||||
// 2026-07-27 on this tree, loopback peer, product default fallbackDelay
|
||||
// (500 ms), one ClientHello per row:
|
||||
//
|
||||
// cuts tls_fragment (*net.TCPConn) tls_fragment (proxy conn) tls_record_fragment
|
||||
// 1 502 ms 500 ms <1 ms
|
||||
// 2 1.004 s 1.001 s <1 ms
|
||||
// 4 2.008 s 2.002 s <1 ms
|
||||
// 8 4.015 s 4.003 s 539 µs
|
||||
// 21 10.540 s 10.509 s 525 µs
|
||||
//
|
||||
// So a cut in the PACKET modes costs half a second of connection setup, and it
|
||||
// costs that on BOTH branches: writeAndWaitAck sleeps the whole fallbackDelay
|
||||
// anyway whenever the ACK comes back inside 20 ms (its "under transparent
|
||||
// proxy" case), and N.UnwrapReader only reaches the *net.TCPConn when nothing
|
||||
// in the chain transforms the stream — which a proxy protocol conn always does,
|
||||
// so a proxied egress takes the flat-500 ms branch regardless of RTT. The
|
||||
// number of labels is chosen by whoever picked the hostname, so "a cut in every
|
||||
// label" made a 253-byte SNI worth ~42 s of one connection's setup.
|
||||
//
|
||||
// In tls_record_fragment nothing waits at all: the whole ClientHello leaves in
|
||||
// ONE write, split into more TLS records. 21 cuts cost 525 µs and 105 bytes of
|
||||
// record headers, and a real server (1.1.1.1) completed the handshake with the
|
||||
// ClientHello in 22 records in the same 77 ms it took with 2. That mode is
|
||||
// where "cut every label" was always affordable — and it is the mode the field
|
||||
// measurement that started this was taken in.
|
||||
//
|
||||
// recordCutBudget is therefore not a cost limit but a shape limit: real names
|
||||
// have one to three labels outside the public suffix, so 4 never binds on real
|
||||
// traffic, while a hostile 253-byte name cannot turn one ClientHello into 85
|
||||
// records that no ordinary client would ever emit.
|
||||
const (
|
||||
packetCutBudget = 1
|
||||
recordCutBudget = 4
|
||||
)
|
||||
|
||||
// cutOffsets plans where the ClientHello must be cut, in byte offsets relative
|
||||
// to the FIRST BYTE OF THE SERVER NAME, spending at most budget cuts. randIntn
|
||||
// is math/rand's Intn in production; a test hands in its own to make the plan
|
||||
// deterministic.
|
||||
//
|
||||
// A cut is only a cut if it lands STRICTLY INSIDE a label. Offset 0 of a label
|
||||
// is that label's own boundary: it leaves the label — the very string the DPI
|
||||
// box matches on — whole in the following segment. That is not theory. The old
|
||||
// code drew rand.Intn(len(label)), so a one-byte label could only ever produce
|
||||
// offset 0, and on the measured provider (blocks by name in the handshake)
|
||||
// m.youtube.com, tv.youtube.com and www.youtube.com were all blocked with the
|
||||
// cut sitting uselessly at the start of "m"/"tv"/"www", while the name itself
|
||||
// travelled intact in one segment. Hence a label shorter than two bytes carries
|
||||
// no cut at all.
|
||||
func cutOffsets(name string, budget int, randIntn func(n int) int) []int {
|
||||
var offsets []int
|
||||
for _, span := range cutCandidates(name) {
|
||||
if span.end-span.start < 2 {
|
||||
continue // no interior offset exists
|
||||
}
|
||||
offsets = append(offsets, cutInside(span, randIntn))
|
||||
if len(offsets) >= budget {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(offsets) == 0 && len(name) >= 2 {
|
||||
// No candidate label was long enough to cut on its own (a.b.co.uk).
|
||||
// Cut the name somewhere rather than hand it over in one piece: a
|
||||
// matcher looking for the whole FQDN still fails across the split, even
|
||||
// though no single label was severed.
|
||||
offsets = append(offsets, cutInside(labelSpan{start: 0, end: len(name)}, randIntn))
|
||||
}
|
||||
slices.Sort(offsets) // candidates are returned by priority, the wire wants order
|
||||
return offsets
|
||||
}
|
||||
|
||||
// cutInside draws an offset strictly inside span, from its MIDDLE THIRD.
|
||||
//
|
||||
// Every interior offset severs the label, but not equally well: a cut one byte
|
||||
// in leaves "outube" of "youtube", and a matcher keyed on a suffix or on a
|
||||
// six-byte substring still reads it. The middle leaves two short, unremarkable
|
||||
// halves. The draw stays random inside that third — a fixed point (say, exactly
|
||||
// the middle of the longest label) would be a constant a middlebox vendor can
|
||||
// special-case in one line, and the whole family of fragmentation tricks lives
|
||||
// on making reassembly the only counter.
|
||||
func cutInside(span labelSpan, randIntn func(n int) int) int {
|
||||
lo, hi := span.start+1, span.end-1 // the interior offsets, both inclusive
|
||||
if margin := (span.end - span.start - 1) / 3; margin > 0 {
|
||||
lo += margin
|
||||
hi -= margin
|
||||
}
|
||||
return lo + randIntn(hi-lo+1)
|
||||
}
|
||||
|
||||
// cutCandidates returns the labels of name that a cut may land in, MOST WORTH
|
||||
// CUTTING FIRST — which matters because the budget above is small.
|
||||
//
|
||||
// First is the registrable label: the one immediately left of the public
|
||||
// suffix. That is the label a name-based blocklist keys on ("youtube" of
|
||||
// youtube.com, www.youtube.com and studio.youtube.com alike, "ytimg" of
|
||||
// i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also breaks
|
||||
// any match on the whole FQDN, so one cut covers both matchers. It is chosen by
|
||||
// STRUCTURE, from the public suffix list — not by length, which is the trap the
|
||||
// old code fell into from the other side: in cdn-static-assets.youtube.com the
|
||||
// longest label is not the blocked one.
|
||||
//
|
||||
// The rest follow longest-first: among labels we have no structural reason to
|
||||
// rank, a long one is likelier to be a distinctive token than "www", "m" or
|
||||
// "tv". They are only reached when the budget allows more than one cut, or when
|
||||
// the registrable label is too short to cut.
|
||||
//
|
||||
// The public suffix itself is dropped because it is shared by everything under
|
||||
// it and carries none of the blocked word. WIDENING this set needs no proof,
|
||||
// NARROWING it does, so an input the public suffix list has no opinion about (a
|
||||
// trailing dot, an unmanaged TLD, a name that IS a suffix) keeps every label.
|
||||
// No branch here ends up with nothing to cut except the empty name, which has
|
||||
// nothing to cut by construction.
|
||||
func cutCandidates(name string) []labelSpan {
|
||||
spans := labelSpans(name)
|
||||
suffix := publicsuffix.List.PublicSuffix(name)
|
||||
switch {
|
||||
case len(spans) == 0:
|
||||
// name == "". Nothing to cut; Write sends the ClientHello unchanged.
|
||||
|
||||
case isIPLiteral(name):
|
||||
// An IP literal is not a name (RFC 6066 forbids it in SNI) and its dots
|
||||
// do not separate labels, so the public suffix list has nothing to say
|
||||
// about it — it returns the literal itself. Treat the whole literal as
|
||||
// one token: there is no name for a DPI box to read here, but the
|
||||
// caller asked for a fragmented handshake and gets one.
|
||||
return []labelSpan{{start: 0, end: len(name)}}
|
||||
|
||||
case suffix != "" && len(suffix) < len(name) && strings.HasSuffix(name, "."+suffix):
|
||||
// The ordinary case, and the one the old arithmetic got wrong: it
|
||||
// subtracted the number of dots in the WHOLE NAME, which — labels being
|
||||
// always one more than dots — left exactly one label, the FIRST, for
|
||||
// every name in existence. Subtract the number of labels in the SUFFIX
|
||||
// instead: "com" is one ("www.youtube.com" keeps www + youtube),
|
||||
// "co.uk" is two ("a.b.co.uk" keeps a + b).
|
||||
if keep := len(spans) - strings.Count(suffix, ".") - 1; keep > 0 {
|
||||
spans = spans[:keep]
|
||||
}
|
||||
|
||||
// Everything else — suffix == "" (a trailing dot, which the list
|
||||
// declines to parse), suffix == name (the name IS a public suffix:
|
||||
// "com", "co.uk", "localhost"), or a suffix that is somehow not a tail
|
||||
// of the name — keeps every label. Cutting inside a suffix costs a
|
||||
// segment and hides nothing that was not already hidden; NOT cutting is
|
||||
// the expensive mistake.
|
||||
}
|
||||
return byCutPriority(spans)
|
||||
}
|
||||
|
||||
// byCutPriority puts the registrable label first and orders the rest
|
||||
// longest-first. It never drops a span, so the budget — not this — decides how
|
||||
// many labels are actually cut.
|
||||
func byCutPriority(spans []labelSpan) []labelSpan {
|
||||
if len(spans) < 2 {
|
||||
return spans
|
||||
}
|
||||
out := make([]labelSpan, 0, len(spans))
|
||||
out = append(out, spans[len(spans)-1])
|
||||
rest := make([]labelSpan, len(spans)-1)
|
||||
copy(rest, spans[:len(spans)-1])
|
||||
slices.SortStableFunc(rest, func(a, b labelSpan) int {
|
||||
return (b.end - b.start) - (a.end - a.start)
|
||||
})
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// labelSpans splits name on '.' and returns the byte range of each label.
|
||||
// Empty labels (a leading, trailing or doubled dot) come back as zero-width
|
||||
// spans and are dropped by cutOffsets, which is what keeps a name like
|
||||
// ".youtube.com" away from rand.Intn(0) — that combination panicked.
|
||||
func labelSpans(name string) []labelSpan {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var spans []labelSpan
|
||||
start := 0
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if i == len(name) || name[i] == '.' {
|
||||
spans = append(spans, labelSpan{start: start, end: i})
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func isIPLiteral(name string) bool {
|
||||
_, err := netip.ParseAddr(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Conn) ReaderReplaceable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
package tf
|
||||
|
||||
// Cut planning: which label of the SNI gets a cut, where inside it, and how
|
||||
// many cuts one ClientHello is allowed to cost.
|
||||
//
|
||||
// WHY THIS FILE EXISTS (2026-07-27)
|
||||
// Conn.Write used to compute the labels to cut as
|
||||
//
|
||||
// splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
|
||||
//
|
||||
// which is identically splits[:1] for EVERY name, labels being always one
|
||||
// more than dots. Exactly one label was ever cut — the LEFTMOST — so on a
|
||||
// provider that blocks by the name in the handshake, youtube.com passed (its
|
||||
// first label IS the blocked word) while m./tv./www./music./studio.youtube.com
|
||||
// were all blocked, the cut sitting inside "m"/"tv"/"www" while "youtube"
|
||||
// travelled whole in the next segment. Measured on the router.
|
||||
//
|
||||
// Two more halves of the same defect:
|
||||
// - the offset came from rand.Intn(len(label)), whose 0 is the label's own
|
||||
// boundary and severs nothing. For a one-byte label that is the ONLY
|
||||
// value it can take;
|
||||
// - an EMPTY label reached rand.Intn(0) and panicked the process. Reachable
|
||||
// from the LAN: route/conn.go wraps the outbound with this and the
|
||||
// ClientHello it fragments is the client's. See
|
||||
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN.
|
||||
//
|
||||
// Every test below fails on the old expressions — see the mutation log.
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- deterministic draws -----------------------------------------------------
|
||||
|
||||
// minRand takes the lowest offset a label allows, maxRand the highest. Between
|
||||
// them they pin BOTH ends of the range, which is where the interesting failures
|
||||
// live: it is the ends that decide whether the label is severed or only touched.
|
||||
func minRand(int) int { return 0 }
|
||||
func maxRand(n int) int { return n - 1 }
|
||||
|
||||
func fixedRand(v int) func(int) int {
|
||||
return func(n int) int {
|
||||
if v >= n {
|
||||
return n - 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// severedLabel names the label that a cut at offset o splits in two, or says
|
||||
// why it splits none. This is the assertion vocabulary of the whole file: the
|
||||
// question is never "which number came out" but "which word did we break".
|
||||
func severedLabel(name string, o int) string {
|
||||
switch {
|
||||
case o <= 0 || o >= len(name):
|
||||
return "!outside the name"
|
||||
case name[o] == '.' || name[o-1] == '.':
|
||||
return "!a label boundary, nothing severed"
|
||||
}
|
||||
start := strings.LastIndexByte(name[:o], '.') + 1
|
||||
end := len(name)
|
||||
if i := strings.IndexByte(name[o:], '.'); i >= 0 {
|
||||
end = o + i
|
||||
}
|
||||
return name[start:end]
|
||||
}
|
||||
|
||||
func severedLabels(name string, offsets []int) []string {
|
||||
var out []string
|
||||
for _, o := range offsets {
|
||||
out = append(out, severedLabel(name, o))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- which label is cut ------------------------------------------------------
|
||||
|
||||
func TestCutOffsetsCutTheRegistrableLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
packet []string // labels severed with the packet budget (1 cut)
|
||||
record []string // ... and with the record budget (4 cuts), in wire order
|
||||
why string
|
||||
}{
|
||||
{name: "youtube.com", packet: []string{"youtube"}, record: []string{"youtube"}},
|
||||
{name: "www.youtube.com", packet: []string{"youtube"}, record: []string{"www", "youtube"},
|
||||
why: "THE regression: the old code cut www and shipped youtube whole"},
|
||||
{name: "m.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "a one-byte label has no interior offset and carries no cut"},
|
||||
{name: "tv.youtube.com", packet: []string{"youtube"}, record: []string{"tv", "youtube"}},
|
||||
{name: "music.youtube.com", packet: []string{"youtube"}, record: []string{"music", "youtube"}},
|
||||
{name: "studio.youtube.com", packet: []string{"youtube"}, record: []string{"studio", "youtube"}},
|
||||
{name: "cdn-static-assets.youtube.com", packet: []string{"youtube"}, record: []string{"cdn-static-assets", "youtube"},
|
||||
why: "the LONGEST label is not the blocked one — structure decides, not length"},
|
||||
{name: "foo.bar.baz.youtube.com", packet: []string{"youtube"}, record: []string{"foo", "bar", "baz", "youtube"},
|
||||
why: "four candidates, four cuts, and the budget stops there"},
|
||||
{name: "a.b.c.d.e.youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "five one-byte labels: the budget is never even reached"},
|
||||
{name: "i9.ytimg.com", packet: []string{"ytimg"}, record: []string{"i9", "ytimg"}},
|
||||
{name: "example.co.uk", packet: []string{"example"}, record: []string{"example"},
|
||||
why: "co.uk is TWO labels of public suffix"},
|
||||
{name: "a.b.example.co.uk", packet: []string{"example"}, record: []string{"example"}},
|
||||
{name: "example.com.br", packet: []string{"example"}, record: []string{"example"}},
|
||||
{name: "site.pp.ru", packet: []string{"site"}, record: []string{"site"},
|
||||
why: "pp.ru is a private two-label suffix"},
|
||||
{name: "localhost", packet: []string{"localhost"}, record: []string{"localhost"},
|
||||
why: "unmanaged TLD: the list returns the whole name, so cut it"},
|
||||
{name: "com", packet: []string{"com"}, record: []string{"com"}},
|
||||
{name: "co.uk", packet: []string{"uk"}, record: []string{"co", "uk"},
|
||||
why: "the name IS the suffix: keep every label rather than cut nothing"},
|
||||
{name: ".youtube.com", packet: []string{"youtube"}, record: []string{"youtube"},
|
||||
why: "leading dot: the empty label is skipped, NOT fed to rand.Intn(0)"},
|
||||
{name: "youtube.com.", packet: []string{"youtube"}, record: []string{"youtube", "com"},
|
||||
why: "trailing dot: the list declines to parse it, so every label stays a candidate"},
|
||||
{name: "WWW.YouTube.COM", packet: []string{"YouTube"}, record: []string{"WWW", "YouTube"}},
|
||||
{name: "ab", packet: []string{"ab"}, record: []string{"ab"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, draw := range []struct {
|
||||
label string
|
||||
fn func(int) int
|
||||
}{{"lowest", minRand}, {"highest", maxRand}} {
|
||||
got := severedLabels(tc.name, cutOffsets(tc.name, packetCutBudget, draw.fn))
|
||||
require.Equal(t, tc.packet, got, "%s draw, packet budget: %s", draw.label, tc.why)
|
||||
got = severedLabels(tc.name, cutOffsets(tc.name, recordCutBudget, draw.fn))
|
||||
require.Equal(t, tc.record, got, "%s draw, record budget: %s", draw.label, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsSeverTheBlockedLabel is the field measurement turned into an
|
||||
// instrument. On the measured provider these names differ only in the label in
|
||||
// front of "youtube", and five of the six were blocked. What has to hold — for
|
||||
// every draw and both budgets, not for most of them — is that the byte range of
|
||||
// the blocked word straddles a cut.
|
||||
func TestCutOffsetsSeverTheBlockedLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, blocked string }{
|
||||
{"youtube.com", "youtube"},
|
||||
{"m.youtube.com", "youtube"},
|
||||
{"tv.youtube.com", "youtube"},
|
||||
{"www.youtube.com", "youtube"},
|
||||
{"music.youtube.com", "youtube"},
|
||||
{"studio.youtube.com", "youtube"},
|
||||
{"cdn-static-assets.youtube.com", "youtube"},
|
||||
{"i9.ytimg.com", "ytimg"},
|
||||
{"a.b.example.co.uk", "example"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
start := strings.Index(tc.name, tc.blocked)
|
||||
require.GreaterOrEqual(t, start, 0)
|
||||
end := start + len(tc.blocked)
|
||||
// Every draw the label can take, not a sample: the range is small
|
||||
// enough to enumerate, so there is no "it passed 1000 times" here.
|
||||
for draw := 0; draw < len(tc.name); draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(tc.name, budget, fixedRand(draw))
|
||||
severed := false
|
||||
for _, o := range offsets {
|
||||
if o > start && o < end {
|
||||
severed = true
|
||||
}
|
||||
}
|
||||
require.True(t, severed,
|
||||
"draw %d, budget %d: %q got cuts at %v (%v), none inside %q [%d,%d)",
|
||||
draw, budget, tc.name, offsets, severedLabels(tc.name, offsets), tc.blocked, start, end)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsStayInTheMiddleThird: every interior offset severs the label,
|
||||
// but not equally well — one byte in leaves "outube" of "youtube", which a
|
||||
// matcher keyed on a substring still reads. Both halves must keep at least
|
||||
// (width-1)/3 + 1 bytes.
|
||||
func TestCutOffsetsStayInTheMiddleThird(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{
|
||||
"youtube.com", "www.youtube.com", "cdn-static-assets.youtube.com",
|
||||
"music.youtube.com", "example.co.uk", "ab.example.com", "localhost",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for draw := 0; draw < 64; draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
for _, o := range cutOffsets(name, budget, fixedRand(draw)) {
|
||||
label := severedLabel(name, o)
|
||||
require.NotContains(t, label, "!", "draw %d: cut at %d in %q severed nothing", draw, o, name)
|
||||
start := strings.Index(name, label)
|
||||
width := len(label)
|
||||
margin := (width-1)/3 + 1
|
||||
require.GreaterOrEqual(t, o-start, margin,
|
||||
"draw %d: cut at %d leaves only %d byte(s) of %q on the left", draw, o, o-start, label)
|
||||
require.GreaterOrEqual(t, start+width-o, margin,
|
||||
"draw %d: cut at %d leaves only %d byte(s) of %q on the right", draw, o, start+width-o, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsRespectTheBudget: the budget is what bounds a hostile name's
|
||||
// cost — a measured 500 ms of connection setup per cut in the packet modes.
|
||||
func TestCutOffsetsRespectTheBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
var long strings.Builder
|
||||
for i := 0; i < 40; i++ {
|
||||
long.WriteString("lb.")
|
||||
}
|
||||
long.WriteString("example.com") // 40 cuttable labels plus the registrable one
|
||||
for _, budget := range []int{1, 2, 3, 4} {
|
||||
require.Len(t, cutOffsets(long.String(), budget, rand.Intn), budget, "budget %d", budget)
|
||||
}
|
||||
require.Len(t, cutOffsets(long.String(), packetCutBudget, rand.Intn), 1,
|
||||
"a 253-byte SNI must not be able to buy more than one 500 ms wait")
|
||||
require.Len(t, cutOffsets(long.String(), recordCutBudget, rand.Intn), 4,
|
||||
"nor more than five records")
|
||||
}
|
||||
|
||||
// TestCutOffsetsFallBackWhenNoLabelCanBeCut covers the names where NO candidate
|
||||
// label has an interior offset. Severing a label is impossible there, so what
|
||||
// is checked is that a cut still happens and still lands inside the buffer: a
|
||||
// matcher keyed on the whole FQDN fails across it.
|
||||
func TestCutOffsetsFallBackWhenNoLabelCanBeCut(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"a.b.co.uk", "x.pp.ru", "a.b.c.d", "1.2.3.4", "::1", "x.com"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for draw := 0; draw < len(name)+4; draw++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(name, budget, fixedRand(draw))
|
||||
require.NotEmpty(t, offsets, "%q went out in one piece", name)
|
||||
require.Greater(t, offsets[0], 0)
|
||||
require.Less(t, offsets[len(offsets)-1], len(name))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutOffsetsAreOrderedAndDistinct: the write loop slices b between
|
||||
// consecutive offsets, so anything out of order or repeated is an empty or
|
||||
// negative segment on the wire. Candidates come back in PRIORITY order, which
|
||||
// is not wire order — this is the test that the sort is not forgotten.
|
||||
func TestCutOffsetsAreOrderedAndDistinct(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{
|
||||
"www.youtube.com", "foo.bar.baz.youtube.com", "cdn-static-assets.youtube.com",
|
||||
"a.bb.ccc.dddd.example.com", "youtube.com.", ".youtube.com", "co.uk",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
offsets := cutOffsets(name, recordCutBudget, rand.Intn)
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
require.Greater(t, o, prev, "%q: offsets %v are not strictly increasing", name, offsets)
|
||||
prev = o
|
||||
}
|
||||
require.Less(t, prev, len(name))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- the arithmetic must not panic on anything ------------------------------
|
||||
|
||||
// adversarialNames is the closed list of shapes that reach the arithmetic from
|
||||
// outside: empty and one-byte names, every position a dot can take, names that
|
||||
// are nothing but dots, names at the 253-byte limit, and bytes that are not
|
||||
// ASCII at all. The unit test, the fuzz seed corpus and the end-to-end test all
|
||||
// draw from it, so all three see the same inputs.
|
||||
func adversarialNames() []string {
|
||||
return []string{
|
||||
"", "a", ".", "..", "...", "....",
|
||||
".com", "com.", ".com.", ".youtube.com", "youtube.com.", ".youtube.com.",
|
||||
"a..b.example.com", "..youtube..com..", "-.-.-.-", "-", "--",
|
||||
"xn--p1ai", "test.xn--p1ai", "xn--", ".xn--p1ai.",
|
||||
"\xff\xfe.example.com", "\x00\x00.com", "пример.рф", "\xff",
|
||||
strings.Repeat("a", 253),
|
||||
strings.Repeat("ab.", 84) + "a", // 253 bytes, 85 labels
|
||||
strings.Repeat(".", 253),
|
||||
strings.Repeat("a.", 126) + "a",
|
||||
"1.2.3.4", "::1", "::ffff:1.2.3.4", "fe80::1%eth0", "0.0.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutOffsetsSurviveEveryAdversarialName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range adversarialNames() {
|
||||
t.Run(strings.ToValidUTF8(name, "?"), func(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
for _, budget := range []int{packetCutBudget, recordCutBudget} {
|
||||
offsets := cutOffsets(name, budget, rand.Intn) // must not panic
|
||||
require.LessOrEqual(t, len(offsets), budget)
|
||||
if len(name) >= 2 {
|
||||
require.NotEmpty(t, offsets, "%q is long enough to cut and was not cut", name)
|
||||
} else {
|
||||
require.Empty(t, offsets, "%q has no offset that leaves bytes on both sides", name)
|
||||
}
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
require.Greater(t, o, prev)
|
||||
require.Less(t, o, len(name))
|
||||
prev = o
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzCutOffsets is the open half of the audit above: the closed list says what
|
||||
// we thought of, this says whether anything else reaches rand.Intn with a
|
||||
// non-positive argument or produces an offset the write loop cannot slice at.
|
||||
// Under plain `go test` it runs the seed corpus, which is that closed list.
|
||||
func FuzzCutOffsets(f *testing.F) {
|
||||
for _, name := range adversarialNames() {
|
||||
f.Add(name, packetCutBudget)
|
||||
f.Add(name, recordCutBudget)
|
||||
}
|
||||
for _, name := range []string{"www.youtube.com", "a.b.example.co.uk", "localhost"} {
|
||||
f.Add(name, 1)
|
||||
f.Add(name, 4)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, name string, budget int) {
|
||||
if budget < 0 {
|
||||
budget = -budget
|
||||
}
|
||||
budget = budget%recordCutBudget + 1 // 1..4, never zero or negative
|
||||
offsets := cutOffsets(name, budget, rand.Intn)
|
||||
if len(offsets) > budget {
|
||||
t.Fatalf("%q: %d offsets for a budget of %d", name, len(offsets), budget)
|
||||
}
|
||||
if len(name) >= 2 && len(offsets) == 0 {
|
||||
t.Fatalf("%q (%d bytes) was handed over in one piece", name, len(name))
|
||||
}
|
||||
prev := 0
|
||||
for _, o := range offsets {
|
||||
if o <= prev || o >= len(name) {
|
||||
t.Fatalf("%q: offsets %v are not strictly increasing inside [1,%d)", name, offsets, len(name))
|
||||
}
|
||||
prev = o
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- end to end: what actually goes out on the wire -------------------------
|
||||
|
||||
// fakeConn records every Write. It is deliberately NOT a *net.TCPConn, which is
|
||||
// also the common production case (the outbound is usually a proxy stream whose
|
||||
// reader transforms the bytes, so N.UnwrapReader stops there), so Conn.Write
|
||||
// takes the sleep-instead-of-ACK path — hence the 1ns fallback delay the tests
|
||||
// below pass to NewConn.
|
||||
type fakeConn struct {
|
||||
writes [][]byte
|
||||
}
|
||||
|
||||
func (c *fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *fakeConn) Close() error { return nil }
|
||||
func (c *fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (c *fakeConn) Write(b []byte) (int, error) {
|
||||
c.writes = append(c.writes, append([]byte(nil), b...))
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// clientHelloFor produces a real ClientHello for serverName by letting
|
||||
// crypto/tls build one and capturing the first write.
|
||||
func clientHelloFor(t *testing.T, serverName string) []byte {
|
||||
t.Helper()
|
||||
rec := &fakeConn{}
|
||||
_ = tls.Client(rec, &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}).Handshake()
|
||||
require.NotEmpty(t, rec.writes, "crypto/tls wrote no ClientHello for %q", serverName)
|
||||
hello := rec.writes[0]
|
||||
// Control on the instrument: the parser this package ships must find the
|
||||
// name we asked for, otherwise the assertions below prove nothing.
|
||||
sni := IndexTLSServerName(hello)
|
||||
require.NotNil(t, sni, "IndexTLSServerName found no SNI in the generated ClientHello")
|
||||
require.Equal(t, serverName, sni.ServerName)
|
||||
return hello
|
||||
}
|
||||
|
||||
// buildClientHello assembles a ClientHello by hand around a server_name_list of
|
||||
// the given entries. crypto/tls will not emit a name with a leading or trailing
|
||||
// dot, an IP literal, an empty name or a second list entry — hostnameInSNI
|
||||
// rewrites or refuses all of them — and those are exactly the shapes a
|
||||
// forwarded ClientHello from a LAN client can carry.
|
||||
func buildClientHello(t *testing.T, entries ...string) []byte {
|
||||
t.Helper()
|
||||
var list []byte
|
||||
for _, e := range entries {
|
||||
list = append(list, sniNameDNSHostnameType)
|
||||
list = binary.BigEndian.AppendUint16(list, uint16(len(e)))
|
||||
list = append(list, e...)
|
||||
}
|
||||
extBody := binary.BigEndian.AppendUint16(nil, uint16(len(list)))
|
||||
extBody = append(extBody, list...)
|
||||
|
||||
ext := binary.BigEndian.AppendUint16(nil, sniExtensionType)
|
||||
ext = binary.BigEndian.AppendUint16(ext, uint16(len(extBody)))
|
||||
ext = append(ext, extBody...)
|
||||
|
||||
extensions := binary.BigEndian.AppendUint16(nil, uint16(len(ext)))
|
||||
extensions = append(extensions, ext...)
|
||||
|
||||
body := []byte{0x03, 0x03} // client_version TLS 1.2
|
||||
body = append(body, make([]byte, 32)...) // random
|
||||
body = append(body, 0x00) // session_id length
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites
|
||||
body = append(body, 0x01, 0x00) // compression_methods
|
||||
body = append(body, extensions...)
|
||||
handshake := []byte{handshakeType, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
|
||||
handshake = append(handshake, body...)
|
||||
|
||||
record := []byte{contentType, 0x03, 0x01}
|
||||
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
|
||||
record = append(record, handshake...)
|
||||
|
||||
// Control on the instrument: this hand-built record must parse the way a
|
||||
// real one does, or the tests below are testing a straw man.
|
||||
sni := IndexTLSServerName(record)
|
||||
require.NotNil(t, sni, "hand-built ClientHello did not parse")
|
||||
require.Equal(t, len(entries[0]), sni.Length, "Length must be the FIRST entry")
|
||||
require.Equal(t, entries[0], string(record[sni.Index:sni.Index+sni.Length]))
|
||||
return record
|
||||
}
|
||||
|
||||
// patchSNI rewrites the server name inside a ClientHello in place. from and to
|
||||
// must be the same length, so every length field in the record stays valid.
|
||||
func patchSNI(t *testing.T, hello []byte, from, to string) []byte {
|
||||
t.Helper()
|
||||
require.Equal(t, len(from), len(to), "patchSNI cannot change the length")
|
||||
at := IndexTLSServerName(hello)
|
||||
require.NotNil(t, at)
|
||||
require.Equal(t, from, at.ServerName)
|
||||
out := append([]byte(nil), hello...)
|
||||
copy(out[at.Index:], to)
|
||||
sni := IndexTLSServerName(out)
|
||||
require.NotNil(t, sni)
|
||||
require.Equal(t, to, sni.ServerName)
|
||||
return out
|
||||
}
|
||||
|
||||
// segments returns, for one recorded run, the payload of every segment written
|
||||
// and the absolute offsets in hello at which the cuts fell.
|
||||
func segments(t *testing.T, hello []byte, writes [][]byte, recordFragment bool) ([][]byte, []int) {
|
||||
t.Helper()
|
||||
var payloads [][]byte
|
||||
for _, w := range writes {
|
||||
if !recordFragment {
|
||||
payloads = append(payloads, w)
|
||||
continue
|
||||
}
|
||||
// A record-fragmented write is one or more TLS records: 3 bytes of the
|
||||
// original header, a 2-byte length, then the payload.
|
||||
for len(w) > 0 {
|
||||
require.GreaterOrEqual(t, len(w), recordLayerHeaderLen, "truncated record header")
|
||||
require.Equal(t, hello[:3], w[:3], "record header is not the ClientHello's own")
|
||||
n := int(binary.BigEndian.Uint16(w[3:5]))
|
||||
require.LessOrEqual(t, recordLayerHeaderLen+n, len(w), "record length runs past the write")
|
||||
payloads = append(payloads, w[recordLayerHeaderLen:recordLayerHeaderLen+n])
|
||||
w = w[recordLayerHeaderLen+n:]
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, payloads, "Write returned without putting anything on the wire")
|
||||
// Cut offsets are the cumulative payload lengths, shifted past the record
|
||||
// header that the first fragment drops.
|
||||
offset := 0
|
||||
if recordFragment {
|
||||
offset = recordLayerHeaderLen
|
||||
}
|
||||
var cuts []int
|
||||
for _, p := range payloads[:len(payloads)-1] {
|
||||
offset += len(p)
|
||||
cuts = append(cuts, offset)
|
||||
}
|
||||
return payloads, cuts
|
||||
}
|
||||
|
||||
type writeMode struct {
|
||||
name string
|
||||
splitPacket bool
|
||||
splitRecord bool
|
||||
recordFraming bool
|
||||
segmentPerCall bool // one Write call per segment
|
||||
budget int
|
||||
}
|
||||
|
||||
var writeModes = []writeMode{
|
||||
{name: "tls_fragment", splitPacket: true, segmentPerCall: true, budget: packetCutBudget},
|
||||
{name: "tls_record_fragment", splitRecord: true, recordFraming: true, budget: recordCutBudget},
|
||||
{name: "both", splitPacket: true, splitRecord: true, recordFraming: true, segmentPerCall: true, budget: packetCutBudget},
|
||||
}
|
||||
|
||||
// TestWriteSeversTheBlockedLabelOnTheWire is the end-to-end control: not "the
|
||||
// planner returned nice numbers" but "the bytes that left the socket have the
|
||||
// blocked label straddling a segment boundary", for every mode the presets
|
||||
// expose, over many real random draws.
|
||||
func TestWriteSeversTheBlockedLabelOnTheWire(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range writeModes {
|
||||
for _, tc := range []struct{ name, blocked string }{
|
||||
{"youtube.com", "youtube"}, // the ONE name the old code got right
|
||||
{"www.youtube.com", "youtube"}, // the regression
|
||||
{"m.youtube.com", "youtube"}, // one-byte label in front
|
||||
{"music.youtube.com", "youtube"},
|
||||
{"cdn-static-assets.youtube.com", "youtube"},
|
||||
{"a.b.example.co.uk", "example"}, // two-label public suffix
|
||||
} {
|
||||
t.Run(mode.name+"/"+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := clientHelloFor(t, tc.name)
|
||||
sniAt := IndexTLSServerName(hello).Index
|
||||
start := sniAt + strings.Index(tc.name, tc.blocked)
|
||||
end := start + len(tc.blocked)
|
||||
for i := 0; i < 100; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n, "Write must report the length of the buffer it was given")
|
||||
_, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
require.NotEmpty(t, cuts, "the ClientHello went out in one piece")
|
||||
require.LessOrEqual(t, len(cuts), mode.budget, "more cuts than this mode's budget")
|
||||
severed := false
|
||||
for _, c := range cuts {
|
||||
if c > start && c < end {
|
||||
severed = true
|
||||
}
|
||||
}
|
||||
require.True(t, severed,
|
||||
"run %d: %q left with cuts at %v, none inside %q [%d,%d)",
|
||||
i, tc.name, cuts, tc.blocked, start, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteReassemblesToTheOriginalClientHello: cutting may change how the
|
||||
// bytes are packaged and nothing else. Byte-for-byte, plus the length Write
|
||||
// reports, plus the segment count implied by the plan.
|
||||
func TestWriteReassemblesToTheOriginalClientHello(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range writeModes {
|
||||
for _, serverName := range []string{
|
||||
"www.youtube.com", "youtube.com", "a.b.example.co.uk", "localhost", "a",
|
||||
"foo.bar.baz.youtube.com",
|
||||
} {
|
||||
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := clientHelloFor(t, serverName)
|
||||
for i := 0; i < 50; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n)
|
||||
|
||||
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p, "empty segment: a cut of zero length went out on the wire")
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
// The record header is re-emitted per fragment, so what
|
||||
// must survive is the handshake body.
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "run %d: the reassembled ClientHello differs from the original", i)
|
||||
if mode.segmentPerCall {
|
||||
require.Len(t, out.writes, len(cuts)+1, "one Write call per segment")
|
||||
} else {
|
||||
require.Len(t, out.writes, 1, "record fragmentation without packet fragmentation is a single write")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN is the regression for a
|
||||
// PROCESS DEATH that shipped in v0.2.21.
|
||||
//
|
||||
// route/conn.go wraps the outbound connection with this Conn and fragments the
|
||||
// ClientHello the LAN client sent, so the server name is chosen by the client,
|
||||
// not by us. An empty label made cutOffsets call rand.Intn(0) — "panic: invalid
|
||||
// argument to Intn" — and a Go panic in a connection goroutine takes the whole
|
||||
// daemon with it. Two ordinary ways to produce one:
|
||||
//
|
||||
// "youtube.com." a fully qualified name with the root dot, which curl and
|
||||
// every browser will happily send, and for which the public
|
||||
// suffix list returns "" so the trailing empty label survived;
|
||||
// ".youtube.com" a leading dot, which nothing legitimate sends but nothing
|
||||
// stops a client from writing into its own ClientHello.
|
||||
//
|
||||
// With the kill switch armed the daemon's death is not a slow connection, it is
|
||||
// a dark LAN until procd restarts it — into the same request.
|
||||
func TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ from, to, why string }{
|
||||
{from: "youtube.comx", to: "youtube.com.", why: "the FQDN root dot — a legitimate name"},
|
||||
{from: "xyoutube.com", to: ".youtube.com", why: "leading dot"},
|
||||
{from: "xyoutube.comx", to: ".youtube.com.", why: "both"},
|
||||
{from: "ax.example.com", to: "a..example.com", why: "a doubled dot mid-name"},
|
||||
{from: "xxxxxxxxxxxx", to: "............", why: "nothing but dots"},
|
||||
{from: "1x2x3x4", to: "1.2.3.4", why: "an IP literal, which RFC 6066 forbids in SNI"},
|
||||
{from: "xxx", to: "::1", why: "an IPv6 literal"},
|
||||
{from: "a", to: "a", why: "one byte: no cut exists, and the empty plan must not be indexed"},
|
||||
{from: "ab", to: "ab", why: "two bytes: exactly one interior offset"},
|
||||
{from: "\xff\xfe.example.com", to: "\xff\xfe.example.com", why: "bytes that are not ASCII"},
|
||||
} {
|
||||
t.Run(strings.ToValidUTF8(tc.to, "?"), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := patchSNI(t, clientHelloFor(t, tc.from), tc.from, tc.to)
|
||||
for _, mode := range writeModes {
|
||||
for i := 0; i < 50; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err, "%s: %s", mode.name, tc.why)
|
||||
require.Equal(t, len(hello), n, "%s: %s", mode.name, tc.why)
|
||||
payloads, _ := segments(t, hello, out.writes, mode.recordFraming)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p)
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "%s run %d: %s", mode.name, i, tc.why)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteHandlesServerNameListsCryptoTLSWillNotEmit reaches the shapes that
|
||||
// need a hand-built record: a zero-length name, a name at the 253-byte limit,
|
||||
// and a list carrying a SECOND entry — which MyServerName.ServerName includes
|
||||
// and MyServerName.Length does not, so cut planning must run on the first entry
|
||||
// alone or it feeds the public suffix list bytes that belong to no name.
|
||||
func TestWriteHandlesServerNameListsCryptoTLSWillNotEmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
entries []string
|
||||
cut bool // must the ClientHello leave in more than one piece?
|
||||
}{
|
||||
{title: "empty name", entries: []string{""}, cut: false},
|
||||
{title: "one byte", entries: []string{"a"}, cut: false},
|
||||
{title: "253 bytes, 85 labels", entries: []string{strings.Repeat("ab.", 84) + "a"}, cut: true},
|
||||
{title: "253 bytes, one label", entries: []string{strings.Repeat("a", 253)}, cut: true},
|
||||
{title: "two entries", entries: []string{"www.youtube.com", "evil.example.com"}, cut: true},
|
||||
{title: "two entries, first empty", entries: []string{"", "www.youtube.com"}, cut: false},
|
||||
{title: "trailing dot", entries: []string{"youtube.com."}, cut: true},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := buildClientHello(t, tc.entries...)
|
||||
for _, mode := range writeModes {
|
||||
for i := 0; i < 20; i++ {
|
||||
out := &fakeConn{}
|
||||
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err, mode.name)
|
||||
require.Equal(t, len(hello), n, mode.name)
|
||||
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
|
||||
require.Equal(t, tc.cut, len(cuts) > 0, "%s: expected cut=%v, got %d cut(s)", mode.name, tc.cut, len(cuts))
|
||||
require.LessOrEqual(t, len(cuts), mode.budget, mode.name)
|
||||
var joined []byte
|
||||
for _, p := range payloads {
|
||||
require.NotEmpty(t, p)
|
||||
joined = append(joined, p...)
|
||||
}
|
||||
want := hello
|
||||
if mode.recordFraming {
|
||||
want = hello[recordLayerHeaderLen:]
|
||||
}
|
||||
require.Equal(t, want, joined, "%s run %d", mode.name, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCutsTheFirstEntryOfTheServerNameList: with two entries the cut must
|
||||
// land inside "youtube" of the FIRST one. Planning over the whole remainder of
|
||||
// the extension would hand the public suffix list a string that is not a name
|
||||
// and put the cut somewhere else entirely.
|
||||
func TestWriteCutsTheFirstEntryOfTheServerNameList(t *testing.T) {
|
||||
t.Parallel()
|
||||
hello := buildClientHello(t, "www.youtube.com", "cdn-static-assets.example.com")
|
||||
sni := IndexTLSServerName(hello)
|
||||
start := sni.Index + strings.Index("www.youtube.com", "youtube")
|
||||
end := start + len("youtube")
|
||||
for i := 0; i < 200; i++ {
|
||||
out := &fakeConn{}
|
||||
_, err := NewConn(out, t.Context(), true, false, time.Nanosecond).Write(hello)
|
||||
require.NoError(t, err)
|
||||
_, cuts := segments(t, hello, out.writes, false)
|
||||
require.Len(t, cuts, 1)
|
||||
require.Greater(t, cuts[0], start, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
|
||||
require.Less(t, cuts[0], end, "run %d: cut at %d is outside youtube [%d,%d)", i, cuts[0], start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithoutSNIIsUntouched: the fast path must stay a straight pass, and
|
||||
// the second and later writes must never be re-planned.
|
||||
func TestWriteWithoutSNIIsUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := []byte("not a tls record at all")
|
||||
out := &fakeConn{}
|
||||
conn := NewConn(out, t.Context(), true, true, time.Nanosecond)
|
||||
n, err := conn.Write(payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(payload), n)
|
||||
require.Len(t, out.writes, 1)
|
||||
require.Equal(t, payload, out.writes[0])
|
||||
|
||||
hello := clientHelloFor(t, "www.youtube.com")
|
||||
n, err = conn.Write(hello)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(hello), n)
|
||||
require.Len(t, out.writes, 2, "a ClientHello after the first write must not be fragmented")
|
||||
require.Equal(t, hello, out.writes[1])
|
||||
}
|
||||
@@ -142,6 +142,12 @@ with a modest one-time decompress-into-RAM cost at start. Ship compressed; keep
|
||||
uncompressed artifact for debugging.
|
||||
|
||||
## D13 — External DPI-bypass tool = **ByeDPI** (a SOCKS egress), NOT zapret
|
||||
> **PARTLY REVERSED by [D29](#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken) (2026-07-27).** The
|
||||
> comparison below still stands and zapret is still rejected. What did not stand
|
||||
> is the premise that the native presets were too weak to carry this: they were
|
||||
> not weak, they were defective. ByeDPI, the `byedpi` egress kind and the
|
||||
> `openwrt/byedpi` package are gone. Read D29 before acting on anything here.
|
||||
|
||||
Decided 2026-07-14. We evaluated exactly two external desync tools — **zapret**
|
||||
(nfqws/tpws, NFQUEUE packet plane) vs **ByeDPI/ciadpi** (a local SOCKS5 desync
|
||||
proxy) — and picked **one**: ByeDPI. DPI-bypass stays a **per-ruleset egress
|
||||
@@ -1418,3 +1424,263 @@ all of them end up above `main` — but it is luck, not design. Moving to explic
|
||||
`pref` values touches every existing rule and needs a migration for rules already
|
||||
installed on implicit numbers; that is its own piece of work, not a rider on this
|
||||
one.
|
||||
|
||||
## D29 — ByeDPI is removed: the presets it replaced were not weak, they were broken
|
||||
Decided 2026-07-27. **This reverses the ByeDPI half of [D13](#d13--external-dpi-bypass-tool--byedpi-a-socks-egress-not-zapret).** The
|
||||
`byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness
|
||||
endpoint `GET /api/byedpi` and the panel's readiness plate are all deleted. The
|
||||
zapret half of D13 is untouched: zapret was rejected for reasons that have
|
||||
nothing to do with this and stays rejected.
|
||||
|
||||
**Why it was added.** D13 read: the native route-action presets
|
||||
(`tls_fragment` / `tls_record_fragment` / `tls_spoof`) "cover the light 'just
|
||||
fragment the ClientHello' case", and ByeDPI "is what we add for the stronger
|
||||
methods the engine lacks". That sentence was written from observed behaviour —
|
||||
the native presets were tried against a real ISP and did not get through — and
|
||||
the conclusion drawn from it was that the METHOD was too weak.
|
||||
|
||||
**Why it is removed.** The method was never tried. `common/tlsfragment/conn.go`
|
||||
chose the split point by dropping a number of labels equal to the number of DOTS
|
||||
in the whole name — and a name always has one more label than it has dots. So
|
||||
the cut always landed inside the **first** label. For `www.youtube.com` it split
|
||||
`www` and handed `youtube` to the wire in one intact piece, which is precisely
|
||||
the word the DPI matches on. Measured against the live ISP: of six blocked
|
||||
names, exactly one got through — `youtube.com`, the one whose first label IS the
|
||||
blocked word. That is not a weak desync, it is a desync aimed at the wrong three
|
||||
characters, and every conclusion drawn from its failure rate was a conclusion
|
||||
about our own defect.
|
||||
|
||||
The fix is one expression. With it, the built-in presets do the job that the
|
||||
external process was brought in to do, and the external process is 100 KB of
|
||||
binary, a second procd service, a second UCI file, a port that agreed with our
|
||||
egress by hand-written comment only, a readiness prober, a five-state service
|
||||
model, a per-egress port cross-check and a panel plate — all to work around a
|
||||
bug in fifteen lines of our own code.
|
||||
|
||||
**So this is not "ByeDPI turned out to be bad".** It is a good tool. It turned
|
||||
out not to be needed, and the reason we thought it was needed was ours.
|
||||
|
||||
**What happens to a config that still says `type 'byedpi'`.** Nothing is
|
||||
migrated and nothing is rewritten. The kind stays unbuildable, which means it
|
||||
stays **fail-closed**: no outbound, no mark, no `ip rule`, no routing table, so
|
||||
everything bound to that egress is blocked rather than released onto the plain
|
||||
WAN. The one thing that changes is what the operator is TOLD. `byedpi` is
|
||||
recorded in `model.RetiredEgressTypes` — a closed, positive table read by both
|
||||
`ValidateEgresses` and the generator — and draws a sentence naming the removal,
|
||||
the replacement (`direct`/`interface` with `dpi 'record'`), the honest caveat
|
||||
that which preset defeats a given ISP is not something we can promise, and how
|
||||
to take the dead package off the router.
|
||||
|
||||
A migration rewriting `byedpi` → `direct` was considered and rejected. It is the
|
||||
only rewrite that leaves the egress routing at all, and it would silently
|
||||
convert a blocked egress into a live plain-WAN path with the router's real
|
||||
address — the exact leak class this codebase refuses everywhere else, performed
|
||||
by an upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore
|
||||
**not** bumped either: no stored field changes meaning, nothing is migrated, and
|
||||
a bump would only make configs written by this build unreadable to an older
|
||||
daemon (Migrate refuses a newer schema) for no gain.
|
||||
|
||||
**The package is not uninstalled by this change.** Dropping it from the feed does
|
||||
not remove it from a router it is already on. `apk del byedpi` does, it is safe,
|
||||
and it is written down in `INSTALL.md` beside the update command — which loses
|
||||
its fourth name: `apk upgrade shaterd shater-core luci-app-shater`.
|
||||
|
||||
**When ByeDPI would win again.** If a fixed `tls_fragment`/`tls_record_fragment`
|
||||
still fails against an ISP that fake/disorder/oob/autottl defeats, the argument
|
||||
in D13 for choosing ByeDPI over zapret is still the right argument and this
|
||||
decision is the one to revisit — with a measurement of the FIXED presets first,
|
||||
which is the step that was skipped last time.
|
||||
|
||||
## D30 — `fetch_via` has THREE states, and the third one had to be bought with a schema bump
|
||||
Decided 2026-07-28. Adds `fetch_detour` to `config globals` and `config profile`,
|
||||
adds `resolver_default` / `resolver_fallback` to `config profile`, and turns a
|
||||
subscription's `fetch_via` from *the* statement about where that feed is fetched
|
||||
into an **override** of the general one. `CurrentSchemaVersion` goes 2 → 3.
|
||||
|
||||
**What forced it, measured on the production router (BPI-R3, SIM uplink).** The
|
||||
operator's mobile carrier refuses TCP/443 to `9.9.9.9` and `1.1.1.1` while
|
||||
carrying everything else — verified with a positive control (`ya.ru:443` and
|
||||
`77.88.8.8:53` connect; the two resolvers are refused). The resolvers configured
|
||||
in `globals` go out **direct**, not through the tunnel (`dnsDetour` returns `""`,
|
||||
which is `dialer.NewDefault`, an ordinary system socket), so on that uplink DNS
|
||||
resolved nothing: the vless server names did not resolve, the hop in front of
|
||||
`awgout` never came up, and the whole `swan-bypass-wg-subs` chain died with it.
|
||||
The ethernet uplink has the opposite problem — it can reach those resolvers
|
||||
perfectly well and there was no reason to give them up. One pair of global
|
||||
scalars cannot be right for both uplinks, and the object that already knows which
|
||||
uplink is live is the profile.
|
||||
|
||||
**Inheritance is PER FIELD.** A profile that sets only `resolver_fallback` keeps
|
||||
globals' `resolver_default`. The alternative — "a profile that names any resolver
|
||||
owns all of them" — silently drags a field the operator never wrote into the
|
||||
override, which is the same defect class as an open `default:`.
|
||||
|
||||
**The empty string is "not set", and that is not a convention we invented.** UCI
|
||||
draws exactly this line already: `option` present vs absent. The renderer's
|
||||
`strOpt` omits an empty value, so a model field of `""` round-trips as an absent
|
||||
option with no new machinery, and the accepted vocabulary of each option is a
|
||||
closed positive set that `""` is not a member of. A `*string` would have changed
|
||||
the JSON the panel exchanges over `/api/config` and added a nil-deref class to a
|
||||
stdlib-only leaf package; a companion `FetchViaSet bool` would have allowed the
|
||||
contradictory state *(not set, "proxy")* that nothing could reject.
|
||||
|
||||
**WHAT THIS BREAKS, stated plainly.** Until now `ReadUCI` parsed an absent
|
||||
`option fetch_via` as the literal string `direct`
|
||||
(`s.optOr("fetch_via", "direct")`). So "the operator chose a clear-text fetch"
|
||||
and "the operator never touched this row" were **the same value** in the model,
|
||||
and they now have to land in different places. Every subscription with a blank
|
||||
`fetch_via` changes meaning: it used to mean *direct*, it now means *inherit the
|
||||
general `fetch_detour`*.
|
||||
|
||||
**So the reinterpretation is performed ONCE, by a migration, in the open.**
|
||||
`migrate2to3` writes `fetch_via='direct'` into every subscription whose value is
|
||||
absent, blank, or not one of `proxy`/`direct`. After it runs, every existing feed
|
||||
fetches exactly where it fetched before, the change is visible in
|
||||
`/etc/config/shater` to `uci show`, and the pre-migration file is sitting beside
|
||||
it (`migrateWith` takes `config.pre-v2.bak` before any step runs). Simply dropping
|
||||
the parser default without the migration would have been one line and would have
|
||||
been *worse*: the reinterpretation would then happen continuously and invisibly,
|
||||
at every read, and the first operator to set
|
||||
`globals.fetch_detour='group:sim-bypass'` would find every subscription that had
|
||||
never mentioned `fetch_via` silently moved into a tunnel.
|
||||
|
||||
**An unrecognised value is normalised, not preserved.** `fetch_via='yes'` meant
|
||||
`direct` under v2 — every reader in the tree asks `EqualFold(v, "proxy")` and
|
||||
treats everything else as direct — so writing `direct` records what the config
|
||||
already did rather than changing it. Each such rewrite is announced through
|
||||
`migrateNotef`; none is silent. At runtime the same value would now be
|
||||
`FetchViaUnknown`, which `ClassifyFetchVia` refuses to fold into a real mode:
|
||||
there is no safe silent answer, because choosing `direct` discloses the feed URL
|
||||
and this router's real address to the provider and the ISP (the exact thing
|
||||
`fetch_via=proxy` is set to prevent) and choosing the general detour puts a feed
|
||||
through a tunnel nobody asked for. `ValidateSubscriptions` names it instead.
|
||||
|
||||
**WHY THE SCHEMA VERSION IS BUMPED, when the last two changes deliberately did
|
||||
not bump it.** Both precedents turn on the same test and both passed it:
|
||||
`RetiredEgressTypes` (D29) — "no stored field changes meaning, nothing is
|
||||
migrated, and a bump would only make configs written by this build unreadable to
|
||||
an older daemon for no gain" — and `Device.Blocklists` — "purely additive, no
|
||||
existing field is reinterpreted". This change fails that test on both counts: the
|
||||
**absence** of `fetch_via` changes meaning, and there **is** something to migrate.
|
||||
The bump is the mechanism that guarantees the rewrite happens before any reader
|
||||
can act on the new meaning; without it a v2 config is simply handed to a v3
|
||||
parser, and there is no second chance to tell the two cases apart afterwards. The
|
||||
cost is the usual one — a config written by this build is refused by an older
|
||||
daemon — and it is the correct signal, because an older daemon does not
|
||||
understand `globals.fetch_detour` at all and would quietly fetch everything
|
||||
direct while the panel showed otherwise.
|
||||
|
||||
**The panel now has to write `fetch_via` explicitly.** `panel/src/subEdit.ts`
|
||||
wrote an explicitly-chosen "Direct" out as *no option at all*, which was
|
||||
indistinguishable from "unset" and is now a different setting. Until it emits the
|
||||
value, a panel save silently converts an explicit direct fetch back into
|
||||
"inherit" — and that is a real change of destination the moment a general
|
||||
`fetch_detour` exists.
|
||||
|
||||
## D31 — The subscription cache is the DEFAULT source; the newest copy wins, not the persistent one
|
||||
Decided 2026-07-28. Two findings about `shater/model/subcache.go`, one of them a
|
||||
live defect.
|
||||
|
||||
**"Cache first, refresh later" is not a fallback — it is the only path.** The
|
||||
daemon's start sequence is `Migrate()` → `ReadUCI()` → apply, and `ReadUCI` is
|
||||
`ParseUCIExport` + `MergeSubCaches`. **No fetch happens on that path at all**;
|
||||
the refresh is a separate cron job (`/etc/init.d/shater-cron`). So a boot with no
|
||||
network is the ordinary case and the cache is what the node inventory *is*, not
|
||||
what it degrades to. That was already true and is now pinned by a test that boots
|
||||
a config plus cache files with no network and asserts the whole inventory —
|
||||
including each node's hand-set `Enabled` flag, because a node parked by hand that
|
||||
comes back enabled after a reboot puts traffic on it again.
|
||||
|
||||
**A failed fetch cannot cost the inventory, and that is layered.**
|
||||
`subscribe.Fetch` returns an error for a non-2xx status, an empty body and a body
|
||||
read that ends early (a connection torn mid-stream);
|
||||
`subscribe.UpdateSubscription` refuses a body that yields zero nodes and leaves
|
||||
the model untouched; and every caller reaches `SaveSubCache` only after both have
|
||||
succeeded. `SaveSubCache` itself writes temp-file-then-rename, so a write that
|
||||
fails at any step leaves the previous file exactly as it was. A refresh that
|
||||
cannot be persisted therefore costs a **stale** inventory, never an empty one.
|
||||
|
||||
**THE DEFECT: the stale copy was shadowing the fresh one.** `SaveSubCache`
|
||||
degrades to tmpfs precisely when the persistent home *refused* the write — a full
|
||||
or read-only `/overlay`, which is a routine OpenWrt state. At that instant the
|
||||
persistent copy is by definition the stale one. But the load rule was "the
|
||||
earlier (higher-priority) dir wins", so `sub update` fetched successfully,
|
||||
reported "N nodes cached", wrote them to `/tmp/shater-subs` — and every reader
|
||||
went on serving the **old** set out of `/etc/shater/subs`, with nothing anywhere
|
||||
saying the refresh had not taken effect. An instrument that reads identically in
|
||||
the working and the broken case.
|
||||
|
||||
**THE FIRST FIX WAS WRONG, AND IT IS WORTH RECORDING WHY.** The rule became "the
|
||||
file with the newer modification time wins". It passed on the Windows dev host 5
|
||||
runs out of 5 and **failed on Linux 5 runs out of 5** — that is, it did not fix
|
||||
the defect at all on the only platform that matters, and the green run was taken
|
||||
on the wrong one. Two independent reasons, either fatal on its own:
|
||||
|
||||
- **Resolution.** Two writes inside one operation fall in the same kernel timer
|
||||
tick and receive *bit-identical* timestamps. Measured in the CI container: the
|
||||
persistent and the tmpfs copy came out at the same `UnixNano` **to the digit**,
|
||||
so `fresh.After(stale)` was `false` and the first-visited (persistent, stale)
|
||||
copy won. This is not a rare tie — for two small consecutive writes it is the
|
||||
normal outcome.
|
||||
- **No RTC.** This router boots with its clock at the epoch until NTP syncs, over
|
||||
the very SIM uplink that drops. A cache written before a sync carries a 1970
|
||||
stamp and loses to an older file written after a previous sync, so mtime
|
||||
ordering can *invert* across a reboot. The codebase already refuses to act on
|
||||
an unsynced clock elsewhere (`alert/expiry.go`); the cache must not depend on
|
||||
one either.
|
||||
|
||||
**The rule is now "the highest generation wins," and the generation is carried
|
||||
inside the file** (`"seq"` in `subs/*.json`). Every write takes the highest
|
||||
generation found in any candidate directory and adds one, so "this supersedes
|
||||
that" is a fact about the data, not about the filesystem. A tie — which now means
|
||||
only "both files predate the counter", i.e. both are 0 — keeps the persistent
|
||||
copy, exactly the behaviour those files had before. A reboot needs no special
|
||||
case: tmpfs is empty then, so the persistent copy is the only candidate and the
|
||||
cold-start-on-cache guarantee is unchanged.
|
||||
|
||||
**The cache format version is NOT bumped.** A new field is transparent in both
|
||||
directions — `decodeSubCache` uses a plain `json.Unmarshal`, so an older daemon
|
||||
ignores `seq` and a newer one reads `0` — whereas bumping it would make every
|
||||
cache file already on the router unreadable, which is precisely the cold-start
|
||||
wipe this file exists to prevent.
|
||||
|
||||
**One case now costs a write that used to be skipped**, deliberately: when the
|
||||
node set is unchanged *but the copy in force is the tmpfs one*, the persistent
|
||||
home is rewritten anyway with a higher generation, so authority moves back to the
|
||||
home that survives a reboot. Skipping it is how a router that filled its overlay
|
||||
once would stay permanently one reboot away from serving a stale set.
|
||||
|
||||
**A superseded copy is deleted best-effort only.** The generation counter has
|
||||
already decided the outcome, so a cleanup that fails costs a few KB and nothing
|
||||
else — which is the property mtime did not have, where a failed cleanup was
|
||||
indistinguishable from a fresh write.
|
||||
|
||||
**`writeFileAtomic` became a seam** (`var`, defaulting to the real
|
||||
implementation), and `subCacheDirPersistent`/`subCacheDirFallback` became `var`s,
|
||||
for the reason `migrate.go` already gives for `statBackup`/`writeBackupFile`: the
|
||||
degraded two-directory state is exactly what `SHATER_SUBS_DIR` cannot produce (it
|
||||
collapses the chain to one directory) and what a temp directory cannot produce on
|
||||
demand. Each test built on that seam carries its own positive control — the same
|
||||
instrument, unbroken, is shown to give the other answer — because "no damage
|
||||
found" by an instrument that could not have seen damage is not a result.
|
||||
|
||||
**`TestAuthorityIgnoresTheClock` is the guard that makes the mtime mistake
|
||||
unrepeatable.** It hands the *stale* copy every advantage a clock could give it —
|
||||
mtime in the future for the stale file, the epoch for the fresh one — asserts
|
||||
first that the inversion really is in place, and then requires the fresh set to
|
||||
be returned anyway. Any implementation that consults the clock, coarse or fine,
|
||||
strict or not, fails it on every platform. **Every run of this package is now
|
||||
taken in Linux under Docker**; a Windows-only green run is not evidence for
|
||||
anything here, and that is the concrete lesson this entry paid for.
|
||||
|
||||
**Known and deliberately NOT changed: `SyncSubCaches` writes what it is given.**
|
||||
It makes the cache files equal to the `FromSub` nodes carried in the model,
|
||||
*including* emptying one whose nodes are all gone — which is what makes "delete
|
||||
the last node" stick in the panel. The consequence is that a caller which builds
|
||||
a model without `MergeSubCaches` (`ParseUCIExport` alone carries no `FromSub`
|
||||
nodes) and hands it to `SyncSubCaches` empties every cache the router has,
|
||||
silently and totally. The panel does not do this — it PUTs back the merged model
|
||||
it GETs — but nothing in the package enforces it. It is pinned by a test that
|
||||
states the hazard rather than fixed by a heuristic, because the only available
|
||||
heuristic ("nobody would delete every node of every subscription at once") is a
|
||||
guess about intent.
|
||||
|
||||
+38
-18
@@ -83,14 +83,13 @@ probe case in `shater/generate/shipped_tags_linux_test.go`.
|
||||
|
||||
## 2. Packages
|
||||
|
||||
Four OpenWrt packages live under `openwrt/`:
|
||||
Three OpenWrt packages live under `openwrt/`:
|
||||
|
||||
| Package | Arch | What it ships |
|
||||
|--------------------|-----------|---------------|
|
||||
| `shaterd` | per-arch | **Prebuilt** static `shaterd` binary → `/usr/bin/shaterd` (this is the ship artifact from step 1). |
|
||||
| `shater-core` | all | procd init (supervises `shaterd run`), the boot armor (§4), cron, hotplug, sysctl, inert default UCI. `DEPENDS:=+shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle`. |
|
||||
| `luci-app-shater` | all | Thin LuCI launcher: mini dashboard + token-handoff "Open panel" button. `DEPENDS:=+shater-core +rpcd`. |
|
||||
| `byedpi` | per-arch | *Optional* ByeDPI (`ciadpi`) local desync SOCKS proxy for a `type='byedpi'` egress. |
|
||||
|
||||
### Why `shaterd` is a prebuilt-binary package
|
||||
|
||||
@@ -138,11 +137,9 @@ builds. `ci/sdk-build-apk.sh` then **asserts** the produced `.apk` really carrie
|
||||
it, so a lost variable fails the build instead of shipping a stale version. The
|
||||
release job asserts the same version again on the published rolling repo (§5.1).
|
||||
|
||||
`byedpi` is deliberately excluded — `PKG_VERSION:=0.17.3` is *upstream ByeDPI's*
|
||||
version, which is what `PKG_HASH` pins and what tells you which ByeDPI is
|
||||
installed. Stamping our tag on it would also be a downgrade: every comparator
|
||||
reads `0.2.7 < 0.17.3` (component-wise, `2 < 17`). Bump its `PKG_RELEASE` by hand
|
||||
when our packaging of it changes.
|
||||
All three are versioned this way. There used to be a fourth package carrying its
|
||||
upstream's own version and therefore exempt from the assertion above; it is gone
|
||||
(D29), and with it the exception nobody could be expected to remember.
|
||||
|
||||
## 3. Install on a router
|
||||
|
||||
@@ -160,7 +157,6 @@ apk filenames carry no architecture, so make sure you copied the `.apk` built fo
|
||||
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
|
||||
```
|
||||
|
||||
From the repo instead (§5 sets it up once), deps pull the rest in:
|
||||
@@ -346,7 +342,6 @@ echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/a
|
||||
# 3) refresh + install (shaterd pulled in as a dependency).
|
||||
apk update
|
||||
apk add luci-app-shater # -> shater-core -> shaterd
|
||||
apk add byedpi # optional: ByeDPI desync egress
|
||||
```
|
||||
|
||||
### 5.3 Updating
|
||||
@@ -358,7 +353,7 @@ unrelated system packages. Always name ours:
|
||||
|
||||
```sh
|
||||
apk update
|
||||
apk upgrade shaterd shater-core luci-app-shater byedpi
|
||||
apk upgrade shaterd shater-core luci-app-shater
|
||||
```
|
||||
|
||||
apk-tools 3 documents exactly this behaviour for `apk upgrade`: *"When no
|
||||
@@ -368,16 +363,44 @@ dependencies."* The equivalent form, which additionally re-pins the packages in
|
||||
`world`, is:
|
||||
|
||||
```sh
|
||||
apk add -u shaterd shater-core luci-app-shater byedpi # -u = --upgrade
|
||||
apk add -u shaterd shater-core luci-app-shater # -u = --upgrade
|
||||
```
|
||||
|
||||
Drop `byedpi` from either list if you never installed it. Check what you are on
|
||||
with `apk list -I shaterd shater-core luci-app-shater byedpi` — the version reads
|
||||
Check what you are on
|
||||
with `apk list -I shaterd shater-core luci-app-shater` — the version reads
|
||||
`0.2.7-r1` (§2.1: `PKG_VERSION-rPKG_RELEASE`, derived from the git tag by CI, so
|
||||
every build really is a new version; before that fix v0.2.2…v0.2.6 all published
|
||||
as `0.2.0-r3` and `apk update` offered nothing). Rolling vs pinned repo URL —
|
||||
§5.1.
|
||||
|
||||
#### If this router still has `byedpi` installed
|
||||
|
||||
Older releases shipped a fourth, optional package — `byedpi` (the `ciadpi` local
|
||||
desync proxy) — behind an egress of `type='byedpi'`. Both are **removed from the
|
||||
product** ([D29](DECISIONS.md#d29--byedpi-is-removed-the-presets-it-replaced-were-not-weak-they-were-broken)):
|
||||
the desync it provided is done by the engine's own `dpi` presets, which were
|
||||
failing for a defect of ours that is fixed.
|
||||
|
||||
Dropping it from the feed does **not** take it off a router it is already on —
|
||||
nothing here uninstalls anything. Remove it by hand:
|
||||
|
||||
```sh
|
||||
apk del byedpi
|
||||
```
|
||||
|
||||
That is safe. No shater package depends on it, nothing in `shaterd` looks for the
|
||||
`ciadpi` binary any more, and it takes `/etc/init.d/byedpi`, `/usr/bin/ciadpi`
|
||||
and (unless you edited it) `/etc/config/byedpi` with it. Leaving it installed is
|
||||
also harmless — it is then simply a service nothing routes to.
|
||||
|
||||
If an egress in `/etc/config/shater` still says `option type 'byedpi'`, it is
|
||||
**blocked, not leaking**: the daemon builds no outbound for the kind, so every
|
||||
node, group and rule bound to that egress stops rather than going out over the
|
||||
plain WAN. The panel and the apply warnings name it and say what to change it to
|
||||
— `direct` (or `interface`) with `dpi 'record'`. Nothing is migrated for you on
|
||||
purpose: the only automatic rewrite that would keep the egress routing is the one
|
||||
that would silently put that traffic on the plain WAN.
|
||||
|
||||
### 5.4 Downgrading — going back to an older build
|
||||
|
||||
Per-version releases live forever (`apk-vX.Y.Z-<arch>`, §5.1), so the way back is
|
||||
@@ -399,7 +422,7 @@ box to hold the config it has rather than accept edits.
|
||||
|
||||
```sh
|
||||
# 0) note where you are, and keep it.
|
||||
apk list -I shaterd shater-core luci-app-shater byedpi
|
||||
apk list -I shaterd shater-core luci-app-shater
|
||||
|
||||
# 1) repoint the repo file at the PINNED per-version release you want.
|
||||
echo "https://git.qomar.pw/omar/shater/releases/download/apk-v0.2.9-$(cat /etc/apk/arch)/packages.adb" \
|
||||
@@ -413,9 +436,6 @@ apk list shaterd shater-core luci-app-shater
|
||||
apk add shaterd=0.2.9-r1 shater-core=0.2.9-r1 luci-app-shater=0.2.9-r1
|
||||
```
|
||||
|
||||
`byedpi` is not tag-versioned (it ships as `0.17.3-r1`, deliberately excluded
|
||||
from `ci/version.sh`), so leave it out unless you really mean to move it.
|
||||
|
||||
Going back up afterwards is two commands, not one — the `=` form leaves a
|
||||
**pinned constraint** in `/etc/apk/world` (`shaterd=0.2.9-r1`), and a pin outranks
|
||||
an upgrade:
|
||||
@@ -462,7 +482,7 @@ The mtk-vendor channel (base: `SuperKali/immortalwrt-mt798x-rebase`, branch
|
||||
**`aarch64_cortex-a53`**, and its images even point their distfeeds at vanilla
|
||||
`downloads.immortalwrt.org/releases/25.12-SNAPSHOT` — so packages built with the
|
||||
vanilla ImmortalWrt 25.12 filogic SDK install cleanly; no SuperKali-special SDK
|
||||
is needed. We ship **no kmods** (shaterd is a static Go binary, byedpi plain C),
|
||||
is needed. We ship **no kmods** (shaterd is a static Go binary),
|
||||
so the vendor 6.6 kernel is irrelevant to our packages.
|
||||
|
||||
What was actually checked in the BananaWRT mtk-vendor `config.buildinfo` is
|
||||
|
||||
@@ -322,9 +322,10 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
|
||||
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, egress — the last binds EVERY member's dialer to that egress (a node's own `egress` is more specific and wins).
|
||||
**No `probe_url`/`probe_interval`**: the per-group overrides were deleted; probing is configured once, in `globals` (D20). Old configs carrying them still parse — the options are ignored and drain out on the next render.
|
||||
- `config chain`: name, list hop (`group:<n>` \| `node:<n>`, L1..Ln, Ln = exit).
|
||||
- `config egress`: name, type, interface, port, dpi.
|
||||
type is `interface` \| `direct` \| `byedpi`; `tunnel` is an accepted ALIAS of `interface` and an empty value means `direct` — both folded to the canonical spelling once, at the config boundary (`Model.NormalizeEgressTypes`, called by `ReadUCI`), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by `ValidateEgresses`, every binding to it fail-closed).
|
||||
`interface` names the device and is meaningful for the `interface` type only; `port` is byedpi-only (`0` = "unset", the generator substitutes 1080 — it is NOT defaulted at parse, or every direct egress would gain a port it never asked for); `dpi` is the native DPI-bypass preset (D13).
|
||||
- `config egress`: name, type, interface, dpi.
|
||||
type is `interface` \| `direct`; `tunnel` is an accepted ALIAS of `interface` and an empty value means `direct` — both folded to the canonical spelling once, at the config boundary (`Model.NormalizeEgressTypes`, called by `ReadUCI`), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by `ValidateEgresses`, every binding to it fail-closed). A type this product REMOVED is a third case with the same fail-closed behaviour and a different sentence — `model.RetiredEgressTypes` is the closed table both `ValidateEgresses` and the generator read it from, so an operator whose config was correct for an older build is told what happened rather than that their value is a typo (D29).
|
||||
`interface` names the device and is meaningful for the `interface` type only; `dpi` is the native DPI-bypass preset — `off`|`fragment`|`record`|`spoof` (D13).
|
||||
**No `port`**: no surviving egress kind dials anything, so the option is not parsed and drains out on the next render. It belonged to the removed SOCKS-hop kind (D29).
|
||||
**No `target`**: v0.1's `proxy`/`block` egress kinds are gone — where traffic goes is a rule's `target`, what device it leaves by is an egress.
|
||||
- `config ruleset`: name, type(`domain`|`ipcidr`, default `domain`), source(`inline`|`file`|`url`|`geosite`|`geoip`, default `inline`), url, path, format, update_interval, list category, list entry.
|
||||
`format` names the ENGINE rule-set format and has exactly two real values, `binary` (a compiled `.srs`) and `source` (a sing-box rule-set `.json`); empty — and the v0.1 leftover `plain`, and `auto` — mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip.
|
||||
|
||||
+15
-19
@@ -49,25 +49,21 @@ build new logic in the `shater/`, `panel/`, `openwrt/` overlay.
|
||||
the gate: fail-closed forward drop (4f618140), engine apply-swap close-first
|
||||
fallback (9b6b9406), DNS hijack-dns per D14 (86194ce6).
|
||||
|
||||
## Phase 2b — DPI-bypass egress = ByeDPI (D13) ✅ DONE
|
||||
- The one external desync tool is **ByeDPI (ciadpi)** — chosen over zapret because
|
||||
it *is* a SOCKS egress (fits shater's "routing picks the egress" model with zero
|
||||
packet-plane conflict); zapret is explicitly rejected (see D13).
|
||||
- Add egress `dpi` value `byedpi`: a supervised local `ciadpi` SOCKS5 instance +
|
||||
a `socks` outbound pointed at it. New `openwrt/` procd package + musl-static
|
||||
cross-build of ciadpi (~100 KB); `model` reserves the egress kind, `generate`
|
||||
wires the `socks` outbound.
|
||||
- QUIC gap closed by routing (drop `udp/443` for desync-domains → TCP+TLS
|
||||
fallback), not by adopting a packet plane.
|
||||
- The **`byedpi` package** (`openwrt/byedpi/`, SEPARATE & optional) provides the
|
||||
`ciadpi` process behind a `type='byedpi'` egress: it cross-compiles ciadpi via
|
||||
the SDK toolchain and ships a procd init that supervises one `ciadpi` SOCKS5
|
||||
desync instance per enabled `config instance` in `/etc/config/byedpi`
|
||||
(127.0.0.1:`<port>`). Install it only when you want a byedpi egress; a
|
||||
`type='byedpi'` egress with no matching `ciadpi` listener simply has nothing to
|
||||
dial. `shater-core` does NOT depend on it (opt-in).
|
||||
- **Gate:** a DPI-blocked domain (that plain `fragment` can't crack) loads via the
|
||||
`byedpi` egress on the VM, direct (no tunnel), kill-switch still honest.
|
||||
## Phase 2b — DPI-bypass egress ✅ DONE, then REVERSED (D29, 2026-07-27)
|
||||
- Shipped as **ByeDPI (ciadpi)**: a supervised local SOCKS5 desync process in its
|
||||
own optional `openwrt/byedpi/` package, reached through a `type='byedpi'`
|
||||
egress. Chosen over zapret because it *is* an egress and needed no second
|
||||
packet plane (D13); zapret stays rejected.
|
||||
- **Removed in full on 2026-07-27** — package, egress kind, readiness endpoint and
|
||||
panel plate. It was adopted because the engine's own `tls_fragment` /
|
||||
`tls_record_fragment` presets did not get through; the cause was a defect in
|
||||
our fragmentation (the split always landed inside the first label of the name),
|
||||
not a limit of the method. With that fixed the built-in presets carry this, and
|
||||
the external process is weight without a job. Full argument: **D29**.
|
||||
- What survives from this phase: the native `dpi` presets `fragment` / `record` /
|
||||
`spoof` on a `direct` or `interface` egress, which is what the feature is now.
|
||||
- A config still naming the removed kind is fail-closed and told so by name — see
|
||||
`model.RetiredEgressTypes`.
|
||||
|
||||
## Phase 3 — Admin panel MVP + thin LuCI launcher ✅ DONE (2026-07-15)
|
||||
- `panel/`: embedded web server on its own port + session store; token-mint ubus
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
#
|
||||
# byedpi — ByeDPI (ciadpi), a tiny portable-C SOCKS5/HTTP desync proxy.
|
||||
#
|
||||
# This is the process behind a Shater egress of `type='byedpi'`: shaterd's
|
||||
# `generate` emits a SOCKS5 outbound `egress-<name>` -> 127.0.0.1:<port>, and a
|
||||
# `ciadpi` instance supervised by this package listens on that port, applies
|
||||
# TCP/TLS desync to the connections passing through it, and goes DIRECT to the
|
||||
# target (no tunnel). Kept as a SEPARATE, optional package: a byedpi egress is
|
||||
# opt-in — install this only when you want the external desync engine.
|
||||
#
|
||||
# Compiled C (musl, per target) => NOT PKGARCH:=all. The OpenWrt SDK toolchain
|
||||
# cross-compiles ciadpi via its own plain Makefile.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=byedpi
|
||||
|
||||
# DELIBERATELY NOT auto-versioned from our git tag (unlike shaterd/shater-core/
|
||||
# luci-app-shater, which take SHATER_PKG_VERSION/SHATER_PKG_RELEASE from
|
||||
# ci/version.sh). PKG_VERSION here is THIRD-PARTY UPSTREAM's version — it is what
|
||||
# PKG_SOURCE_URL/PKG_HASH pin, and what tells an operator which ByeDPI is
|
||||
# actually installed. Stamping our tag on it would be both a lie and a
|
||||
# regression: our tags are 0.2.x, and the version comparator (apk-tools 3,
|
||||
# verified) reads 0.2.7 < 0.17.3 — component-wise numerically, 2 < 17
|
||||
# — so the "new" package would be a DOWNGRADE and routers would refuse it.
|
||||
# Bump PKG_RELEASE BY HAND when *our packaging* of it changes (init script, uci
|
||||
# defaults, build flags); bump PKG_VERSION+PKG_HASH when upstream releases.
|
||||
PKG_VERSION:=0.17.3
|
||||
PKG_RELEASE:=1
|
||||
|
||||
# Pinned upstream release tag v0.17.3 (commit
|
||||
# 7efde1b1296eaaa187b70e951894dde17527489c). codeload emits a stable tarball
|
||||
# per tag; PKG_HASH is the sha256 of that tarball (build fails on mismatch).
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://codeload.github.com/hufrea/byedpi/tar.gz/refs/tags/v$(PKG_VERSION)?
|
||||
PKG_HASH:=0a9cb8585554c68c3e2be88c33c9bf6f99f8e8c7f54b362285adab99e262566c
|
||||
|
||||
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_LICENSE_FILES:=LICENSE
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/byedpi
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=ByeDPI (ciadpi) local SOCKS5/HTTP desync proxy
|
||||
URL:=https://github.com/hufrea/byedpi
|
||||
# Pure C against musl; every target has a C toolchain, so no arch-depends.
|
||||
# No runtime library deps beyond libc (static-ish tiny binary).
|
||||
DEPENDS:=
|
||||
endef
|
||||
|
||||
define Package/byedpi/description
|
||||
ByeDPI is a small local SOCKS5/HTTP proxy that applies TCP/TLS desynchronization
|
||||
(split, disorder, fake packets, TLS-record splitting) to the connections passing
|
||||
through it and then connects DIRECTLY to the destination — no upstream tunnel.
|
||||
Its binary is `ciadpi`. In the Shater stack it is the process behind an egress of
|
||||
`type='byedpi'`: shaterd routes selected traffic to a local SOCKS5 outbound
|
||||
pointed at ciadpi's 127.0.0.1:<port>. Multi-instance, driven by /etc/config/byedpi.
|
||||
endef
|
||||
|
||||
# ciadpi's upstream Makefile appends its own required flags with `CFLAGS +=`.
|
||||
# A CFLAGS set on the make command line CLOBBERS that `+=` (GNU make: a
|
||||
# command-line assignment overrides the makefile's append), so we must re-supply
|
||||
# ciadpi's own needed flags (-I. -std=c99 and its warning set) alongside
|
||||
# $(TARGET_CFLAGS). CPPFLAGS (-D_DEFAULT_SOURCE) is left untouched by not
|
||||
# overriding it. The default target `all` builds the `ciadpi` binary; its link
|
||||
# rule is `$(CC) -o ciadpi $(OBJ) $(LDFLAGS)`, so $(TARGET_LDFLAGS) reaches the
|
||||
# link. Kernel headers (linux/netfilter_ipv4.h) come from the SDK sysroot.
|
||||
define Build/Compile
|
||||
+$(MAKE) -C $(PKG_BUILD_DIR) \
|
||||
CC="$(TARGET_CC)" \
|
||||
CFLAGS="$(TARGET_CFLAGS) -I. -std=c99 -Wall -Wno-unused -Wextra -Wno-unused-parameter" \
|
||||
LDFLAGS="$(TARGET_LDFLAGS)" \
|
||||
all
|
||||
endef
|
||||
|
||||
define Package/byedpi/install
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
$(INSTALL_BIN) $(PKG_BUILD_DIR)/ciadpi $(1)/usr/bin/ciadpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/etc/init.d/byedpi $(1)/etc/init.d/byedpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/etc/config/byedpi $(1)/etc/config/byedpi
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_BIN) ./files/etc/uci-defaults/40_byedpi $(1)/etc/uci-defaults/40_byedpi
|
||||
endef
|
||||
|
||||
# /etc/config/byedpi is user-editable desired state -> preserve on upgrade.
|
||||
define Package/byedpi/conffiles
|
||||
/etc/config/byedpi
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,byedpi))
|
||||
@@ -1,33 +0,0 @@
|
||||
#
|
||||
# ByeDPI (ciadpi) desync SOCKS proxies (/etc/config/byedpi).
|
||||
#
|
||||
# Each `config instance` is one supervised `ciadpi` process bound to
|
||||
# 127.0.0.1:<port>. Point a Shater egress at it:
|
||||
#
|
||||
# config egress 'bd'
|
||||
# option name 'bd'
|
||||
# option type 'byedpi'
|
||||
# option port '1080' # must match an enabled instance's `port`
|
||||
#
|
||||
# then a rule with `option target 'egress:bd'` routes selected traffic through
|
||||
# ciadpi, which desyncs it and connects DIRECTLY to the target (no tunnel).
|
||||
#
|
||||
# This shipped default is INERT (enabled='0'): installing the package opens no
|
||||
# listener. Set enabled='1' and apply to bring the proxy up.
|
||||
#
|
||||
# This file is a conffile — your edits survive package upgrades.
|
||||
#
|
||||
|
||||
config instance 'default'
|
||||
option enabled '0'
|
||||
option port '1080'
|
||||
# Desync preset (documented ByeDPI example — general RKN/YouTube-busting):
|
||||
# --disorder 1 : split the first segment at offset 1 and send the two
|
||||
# parts in REVERSE order (TCP desync; defeats naive
|
||||
# stream reassembly in the DPI).
|
||||
# --auto=torst : auto mode — if the connection is reset (TCP RST), retry
|
||||
# with the desync params instead of failing.
|
||||
# --tlsrec 1+s : re-frame the TLS record boundary at the SNI offset +1,
|
||||
# so the ClientHello SNI is split across TLS records and
|
||||
# SNI-based DPI can't match the hostname.
|
||||
option args '--disorder 1 --auto=torst --tlsrec 1+s'
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# /etc/init.d/byedpi — procd supervisor for ByeDPI (ciadpi) desync SOCKS proxies.
|
||||
#
|
||||
# One supervised `ciadpi` process per ENABLED `config instance` in
|
||||
# /etc/config/byedpi. Each instance is a local SOCKS5 desync proxy bound to
|
||||
# 127.0.0.1:<port>; a Shater egress of type='byedpi' with the matching `port`
|
||||
# routes traffic to it (shaterd emits a SOCKS5 outbound to that port). ciadpi
|
||||
# desyncs the connection and goes DIRECT to the target — no tunnel.
|
||||
#
|
||||
# Design:
|
||||
# * INERT by default: the shipped instance has enabled='0', and config_foreach
|
||||
# starts nothing unless an instance is explicitly enabled. Installing this
|
||||
# package can never, by itself, open a listener or affect connectivity.
|
||||
# * FOREGROUND: ciadpi stays in the foreground unless -D/--daemon is given (we
|
||||
# never pass it), so procd supervises the real process. respawn on crash.
|
||||
# * Instances are named after their UCI section, so `reload` diffs per-section
|
||||
# and restarts only the instances whose config actually changed.
|
||||
# * busybox ash only — no bashisms.
|
||||
|
||||
USE_PROCD=1
|
||||
START=90 # before shater (START=99): the SOCKS egress should be up
|
||||
STOP=11 # after shater (STOP=10) — higher STOP runs LATER on shutdown,
|
||||
# so the desync proxy outlives the data plane it serves.
|
||||
|
||||
PROG=/usr/bin/ciadpi
|
||||
CONF=byedpi
|
||||
|
||||
# Validate one `instance` section. Datatypes per openwrt-uci:
|
||||
# enabled : bool (default 0 — inert)
|
||||
# port : port (default 1080 — matches shater's byedpi egress default)
|
||||
# args : free-form desync flag string (passed verbatim to ciadpi)
|
||||
validate_instance_section() {
|
||||
uci_load_validate "$CONF" instance "$1" "$2" \
|
||||
'enabled:bool:0' \
|
||||
'port:port:1080' \
|
||||
'args:string:'
|
||||
}
|
||||
|
||||
start_instance() {
|
||||
# $1 = section name, $2 = validation return code
|
||||
local cfg="$1"
|
||||
[ "$2" = 0 ] || { echo "byedpi: validation failed for '$cfg'"; return 1; }
|
||||
[ "$enabled" -eq 1 ] || return 0
|
||||
|
||||
# Never claim to run without the binary (half-removed/failed upgrade must
|
||||
# degrade to "off", not to a phantom respawn loop).
|
||||
[ -x "$PROG" ] || { echo "byedpi: $PROG missing, skipping '$cfg'"; return 1; }
|
||||
|
||||
procd_open_instance "$cfg"
|
||||
# Bind loopback only: this proxy is reachable solely by the local engine.
|
||||
procd_set_param command "$PROG" -i 127.0.0.1 -p "$port"
|
||||
# Desync flag list. Unquoted on purpose: word-split $args into separate argv
|
||||
# tokens (e.g. "--disorder 1 --auto=torst --tlsrec 1+s" -> 5 arguments).
|
||||
# shellcheck disable=SC2086
|
||||
[ -n "$args" ] && procd_append_param command $args
|
||||
|
||||
procd_set_param respawn
|
||||
# Restart this instance when its config changes (checksum-watched).
|
||||
procd_set_param file /etc/config/$CONF
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
start_service() {
|
||||
config_load "$CONF"
|
||||
config_foreach validate_instance_section instance start_instance
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
start_service
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
# Reload (not full restart) when /etc/config/byedpi changes via a
|
||||
# config.change event (LuCI Save&Apply / `reload_config`).
|
||||
procd_add_reload_trigger "$CONF"
|
||||
procd_add_validation validate_instance_section
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# /etc/uci-defaults/40_byedpi
|
||||
#
|
||||
# Idempotent first-boot setup for the byedpi package. Runs once on first boot
|
||||
# (and via the default postinst on a live opkg/apk install); must exit 0 so it
|
||||
# is cleared and not retried. Enabling the init is safe on a fresh box: the init
|
||||
# and the shipped config are INERT (the 'default' instance has enabled='0'), so
|
||||
# nothing listens until an instance is explicitly enabled.
|
||||
|
||||
[ -x /etc/init.d/byedpi ] && /etc/init.d/byedpi enable
|
||||
|
||||
exit 0
|
||||
@@ -186,8 +186,9 @@ config inbound
|
||||
# record - tls_record_fragment (alternative; mutually exclusive w/ fragment)
|
||||
# spoof - tls_spoof (inject a decoy ClientHello; needs root NET_RAW/NET_ADMIN)
|
||||
# Point a rule's target at it for DPI-blocked-but-not-IP-blocked domains — direct
|
||||
# and fragmented, no exit node, no extra binary. (The stronger external `byedpi`
|
||||
# preset is Phase-2b.)
|
||||
# and fragmented, no exit node, no extra binary. These three are the WHOLE set;
|
||||
# the external desync egress that once stood beside them is removed (D29), and a
|
||||
# `type 'byedpi'` egress left over from an older build is blocked, not routed.
|
||||
#config egress
|
||||
# option name 'frag'
|
||||
# option type 'direct'
|
||||
|
||||
+170
-182
@@ -261,167 +261,6 @@ export interface Status {
|
||||
// Absent on older daemons ⇒ show nothing rather than guessing.
|
||||
started_unix?: number
|
||||
uptime_seconds?: number
|
||||
/**
|
||||
* Is the `ciadpi` binary (optional `byedpi` package) present on the router?
|
||||
*
|
||||
* IT IS NOT A READINESS SIGNAL AND MUST NOT GATE ANYTHING. The two questions
|
||||
* come apart on the SHIPPED configuration, not on an exotic one: the packaged
|
||||
* /etc/config/byedpi is inert (`option enabled '0'`), so a freshly installed
|
||||
* package answers `true` here and serves no port at all. Gating the egress
|
||||
* type on this flag is exactly how an operator ended up with an egress on
|
||||
* 127.0.0.1:1080, a green apply, and nobody listening.
|
||||
*
|
||||
* The readiness answer is {@link ByeDPIReport} from GET /api/byedpi —
|
||||
* `state === 'listening'` and nothing else. This field survives because "is
|
||||
* the package there" is still a real question with a real answer (it is the
|
||||
* same value as that report's `binary`), and because it separates "installed
|
||||
* but off" from "not installed" for copy purposes. It costs a PATH lookup and
|
||||
* no socket, which is the only kind of work this endpoint may do.
|
||||
*/
|
||||
byedpi_installed?: boolean
|
||||
// THERE IS NO READINESS REPORT ON THIS RESPONSE, deliberately. It used to
|
||||
// carry one, served from a cache the daemon refreshed in the background — and
|
||||
// the panel polls this endpoint every 5 s from every open tab, hidden ones
|
||||
// included, while reading the field NOWHERE. Measured on the shipped config
|
||||
// with 16 enabled instances behind a black hole, one probe cost 6.4 s; the
|
||||
// steady cost was 12 connects a minute per tab for a value nothing consumed.
|
||||
// The daemon dropped the field and the whole cache with it (shater/panel/
|
||||
// api.go statusResponse). Ask GET /api/byedpi — it really connects, and only
|
||||
// when a human asks.
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT THE BYEDPI SERVICE IS DOING — a CLOSED list of five, ordered the way it
|
||||
* degrades. The panel switches on it exhaustively (see byedpiReady.ts, which is
|
||||
* the only place allowed to decide it) and a value outside the five reads as
|
||||
* `unknown`.
|
||||
*
|
||||
* unknown — the check COULD NOT BE COMPLETED: the config file was
|
||||
* unreadable, or a connect neither succeeded nor was refused.
|
||||
* It says nothing about the service. Never a soft yes, and
|
||||
* never a refusal either — it is its own state and has to look
|
||||
* like one.
|
||||
* not_installed — no `ciadpi` binary on the router.
|
||||
* disabled — the binary is there and /etc/config/byedpi enables no
|
||||
* instance. This is the state a FRESH INSTALL is in on
|
||||
* purpose; a configuration fact, not a fault.
|
||||
* not_listening — an instance is enabled and the connect was REFUSED: the
|
||||
* service is configured to run and is not running.
|
||||
* listening — an instance is enabled and something accepts TCP on its
|
||||
* port. The ONLY state that may unlock the byedpi egress type.
|
||||
*
|
||||
* The listener check is a TCP CONNECT and nothing else. It proves a socket is
|
||||
* accepting; it does NOT prove ciadpi is the process behind it and does NOT
|
||||
* prove it speaks SOCKS5. Every sentence the daemon emits says so — do not
|
||||
* strengthen them in the UI.
|
||||
*/
|
||||
export type ByeDPIState = 'unknown' | 'not_installed' | 'disabled' | 'not_listening' | 'listening'
|
||||
|
||||
/**
|
||||
* What a connect to ONE instance's port found. `unknown` is NEVER folded into
|
||||
* `no`: only a clean refusal shows the port to be empty, and a timeout or a
|
||||
* permission error is a failure of the instrument.
|
||||
*/
|
||||
export type ByeDPIPortAnswer = 'yes' | 'no' | 'unknown'
|
||||
|
||||
/** One ENABLED `config instance` of /etc/config/byedpi, plus what a connect to
|
||||
* its port found. Disabled instances are omitted — they name a port nothing
|
||||
* will ever bind. `port` already carries the init script's 1080 default, so it
|
||||
* is the port the service would really use, not merely what is written down. */
|
||||
export interface ByeDPIInstance {
|
||||
section: string // UCI section name ('default'), or '@instance[N]' when anonymous
|
||||
port: number
|
||||
listening: string // ByeDPIPortAnswer — closed; read it through byedpiPortAnswer()
|
||||
}
|
||||
|
||||
/**
|
||||
* One configured byedpi EGRESS reconciled against the running listeners — a
|
||||
* CLOSED list of six.
|
||||
*
|
||||
* ok — an enabled instance serves this egress's port and
|
||||
* something is accepting there.
|
||||
* port_mismatch — byedpi IS running, on a DIFFERENT port than this egress
|
||||
* dials. The failure this whole endpoint exists for: the
|
||||
* two packages agreed only in a comment. The daemon's
|
||||
* sentence names BOTH numbers — show it verbatim.
|
||||
* not_listening — an enabled instance names this port and nothing accepts.
|
||||
* service_disabled — no instance is enabled at all.
|
||||
* not_installed — the package is not installed.
|
||||
* unknown — the service state could not be determined, so neither can
|
||||
* this egress's. Unchecked, not working, not broken.
|
||||
*/
|
||||
export type ByeDPIEgressState =
|
||||
| 'ok'
|
||||
| 'port_mismatch'
|
||||
| 'not_listening'
|
||||
| 'service_disabled'
|
||||
| 'not_installed'
|
||||
| 'unknown'
|
||||
|
||||
/** One byedpi egress's cross-check. `port` is the port that will ACTUALLY be
|
||||
* dialled (the generator's 1080 default already applied), so it can be printed
|
||||
* next to the instance's port without the panel re-deriving either. `detail` is
|
||||
* one sentence, safe to show verbatim. */
|
||||
export interface ByeDPIEgressCheck {
|
||||
name: string
|
||||
port: number
|
||||
state: string // ByeDPIEgressState — closed; read it through byedpiEgressState()
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/byedpi — the ONLY endpoint that reports a listener, and it connects
|
||||
* every time it is asked. Nothing else in the API carries this report.
|
||||
*
|
||||
* Three INDEPENDENT facts, kept apart because they fail apart: is the binary
|
||||
* there, does the conffile enable an instance and on which port, and does
|
||||
* anything accept a TCP connection on that port. `state` is the verdict over all
|
||||
* three; `binary` / `config_read` / `instances` are the evidence behind it, so a
|
||||
* person can see WHY rather than being handed a word.
|
||||
*
|
||||
* `egresses` is empty when the daemon could not read the model: it still answers
|
||||
* 200 with the measured service facts and a `detail` saying the cross-check was
|
||||
* skipped. So an empty list means "not cross-checked", never "no byedpi
|
||||
* egresses" — see byedpiEgressFor().
|
||||
*/
|
||||
export interface ByeDPIReport {
|
||||
state: string // ByeDPIState — closed; read it through byedpiState()
|
||||
/** One sentence describing `state`, written by the daemon to be shown
|
||||
* verbatim. It never claims more than was checked. */
|
||||
detail: string
|
||||
/** Is `ciadpi` on the box — the OLD check, demoted to one input among three. */
|
||||
binary: boolean
|
||||
/** Could /etc/config/byedpi be read? `false` with state `unknown` is the
|
||||
* honest "could not look"; `false` with `not_installed` just means there was
|
||||
* no point looking. */
|
||||
config_read: boolean
|
||||
instances: ByeDPIInstance[]
|
||||
egresses: ByeDPIEgressCheck[]
|
||||
/**
|
||||
* HOW LONG THE PROBE BEHIND THIS BODY TOOK, in whole seconds — measured from
|
||||
* the first connect it started.
|
||||
*
|
||||
* Every sentence in `detail` is present tense ("something is accepting TCP on
|
||||
* 127.0.0.1:1080"), and this report is what unlocks the byedpi egress type. A
|
||||
* probe that spent six seconds in dial timeouts is describing the service as
|
||||
* it was six seconds ago, and says so instead of claiming to be instantaneous.
|
||||
*
|
||||
* 0 IS THE NORMAL ANSWER, not a sentinel: a loopback connect finishes in
|
||||
* microseconds and this endpoint measures on the request a human issued.
|
||||
*
|
||||
* This daemon never sends a negative value — every body it returns is a
|
||||
* measurement it just took. It could once, when GET /api/status served a
|
||||
* cached copy and used a negative age for "this is not a measurement at all";
|
||||
* that endpoint carries no report any more. {@link byedpiAge} still keeps the
|
||||
* negative case apart from a real reading, so an older daemon cannot make the
|
||||
* absence of a measurement read as the freshest possible one — but nothing may
|
||||
* start using negative for anything else.
|
||||
*
|
||||
* Absent on daemons older than the field: the reading is real and its age is
|
||||
* simply not reported. That is a third case again, and {@link byedpiAge} —
|
||||
* the only place the panel is allowed to decide this — keeps all three apart.
|
||||
*/
|
||||
age_seconds?: number
|
||||
}
|
||||
|
||||
/** POST /api/apply|confirm|rollback — mirrors the control socket result. */
|
||||
@@ -1018,13 +857,27 @@ export interface Globals {
|
||||
FwmarkBase: number
|
||||
TableBase: number
|
||||
ConfirmTimeout: number
|
||||
/**
|
||||
* The `config resolver` every DNS query falls to when no DNS rule matches.
|
||||
*
|
||||
* STORED, NOT NECESSARILY EFFECTIVE: the active profile may override it
|
||||
* (Profile.ResolverDefault). Read the live answer through
|
||||
* {@link getEffectiveConfig} — a page that renders this string as "the
|
||||
* resolver in use" is the defect that endpoint exists to fix.
|
||||
*/
|
||||
ResolverDefault: string
|
||||
/** The resolver tried when the default fails. Same profile caveat as
|
||||
* ResolverDefault (Profile.ResolverFallback). */
|
||||
ResolverFallback: string
|
||||
/**
|
||||
* `config resolver` used to resolve the SERVER DOMAINS of proxy configs
|
||||
* (vless/awg endpoints) — sing-box route.default_domain_resolver, a bootstrap
|
||||
* resolver that is always direct. "" ⇒ not set (model.Globals.EndpointResolver,
|
||||
* UCI `endpoint_resolver`).
|
||||
*
|
||||
* Same profile caveat as ResolverDefault, and this is the field it was caught
|
||||
* on: the DNS page printed `local` from here while the router, under the
|
||||
* active `mobile-uplink` profile, was resolving endpoints through `yandex`.
|
||||
*/
|
||||
EndpointResolver: string
|
||||
ProbeURL: string
|
||||
@@ -1094,6 +947,33 @@ export interface Globals {
|
||||
* is in sole charge.
|
||||
*/
|
||||
UntunnelableEgress?: string
|
||||
/**
|
||||
* The route the daemon FETCHES LIST DATA over — every blocklist, allowlist,
|
||||
* rule-set and geoip/geosite `.srs` (model.Globals.FetchDetour, UCI
|
||||
* `fetch_detour`). Same target grammar the routing rules use, so the same
|
||||
* picker reads it: `direct` | `block` | `node:<n>` | `group:<n>` |
|
||||
* `egress:<n>` | `chain:<n>`.
|
||||
*
|
||||
* "" ⇒ `direct`, which is what every build before this field did with no way
|
||||
* to say otherwise (a `filterFetchDetour = tagDirect` constant in
|
||||
* generate/dnsfilter.go). It stops being a detail on an uplink that blocks the
|
||||
* hosts the lists live on: a list that cannot be fetched is not applied, and
|
||||
* the engine reports it BY NAME ("…was omitted and matches NOTHING until it
|
||||
* loads") rather than failing the apply — so the router keeps running with a
|
||||
* filter that filters nothing.
|
||||
*
|
||||
* Two things the panel must not conflate with it. The active profile can
|
||||
* override it (Profile.FetchDetour), so this string is the STORED value, not
|
||||
* necessarily the effective one — read {@link getEffectiveConfig}. And
|
||||
* {@link Subscription.FetchDetour} is a different, narrower setting: a
|
||||
* subscription that names its own fetch route keeps it.
|
||||
*
|
||||
* CIRCULARITY IS POSSIBLE AND IS NOT DETECTED HERE: pointing this at a group
|
||||
* or chain whose own routing depends on a list fetched through it is a knot
|
||||
* the daemon has to untie, not the picker. The panel offers the targets; it
|
||||
* does not promise they will work.
|
||||
*/
|
||||
FetchDetour?: string
|
||||
/**
|
||||
* Where a `source=geosite` / `source=geoip` list (a {@link Ruleset}, a
|
||||
* {@link Blocklist} or an {@link Allowlist}) fetches its data from
|
||||
@@ -1435,8 +1315,42 @@ export interface Profile {
|
||||
// either key fails the whole write with 400.
|
||||
EnableRules?: string[] | null
|
||||
DisableRules?: string[] | null
|
||||
/**
|
||||
* Per-profile override of {@link Globals.ResolverDefault} — the resolver
|
||||
* unmatched queries fall to while this profile is active
|
||||
* (model.Profile.ResolverDefault, UCI `resolver_default`).
|
||||
*
|
||||
* "" ⇒ NO OVERRIDE, and inheritance is PER FIELD: a profile may set only the
|
||||
* fallback and keep the global default. So "empty" and "empty on purpose" are
|
||||
* the same thing here, and the editor offers no way to force a global's value
|
||||
* — leaving the override off already does that.
|
||||
*
|
||||
* Why it exists: a mobile uplink that refuses :443 to 9.9.9.9 and 1.1.1.1
|
||||
* (measured on this router) kills DoH outright, and with it the resolution of
|
||||
* every proxy server domain. The profile that matches that uplink switches the
|
||||
* resolvers to ones the carrier does route.
|
||||
*/
|
||||
ResolverDefault?: string
|
||||
/** Per-profile override of {@link Globals.ResolverFallback}. Same per-field,
|
||||
* ""-means-inherit contract as {@link ResolverDefault}
|
||||
* (model.Profile.ResolverFallback, UCI `resolver_fallback`). */
|
||||
ResolverFallback?: string
|
||||
/** Per-profile override of the endpoint resolver (keyed by active WAN: SIM→yandex, WiFi→DoH). "" ⇒ no override (model.Profile.EndpointResolver, UCI `endpoint_resolver`). */
|
||||
EndpointResolver?: string
|
||||
/**
|
||||
* Per-profile override of {@link Globals.FetchDetour} — where list data is
|
||||
* fetched from while this profile is active (model.Profile.FetchDetour, UCI
|
||||
* `fetch_detour`). Same target grammar, same ""-means-inherit contract.
|
||||
*
|
||||
* Note the asymmetry with the resolvers: for a detour `""` and `direct` are
|
||||
* NOT the same input. `""` inherits whatever globals says; an explicit
|
||||
* `direct` forces the plain WAN even when globals routes fetches through a
|
||||
* tunnel. The panel must keep those two apart, and the daemon does it for
|
||||
* them: {@link getEffectiveConfig} compares an unset detour and an explicit
|
||||
* `direct` as ONE route, so a profile spelling out `direct` over empty globals
|
||||
* correctly reports nothing overridden.
|
||||
*/
|
||||
FetchDetour?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1502,20 +1416,25 @@ export interface Inbound {
|
||||
/**
|
||||
* A `config egress` — a named way OUT of the router (model.go Egress).
|
||||
*
|
||||
* Exactly THREE types produce an outbound, and a type outside them emits nothing,
|
||||
* Exactly TWO types produce an outbound, and a type outside them emits nothing,
|
||||
* which is fail-CLOSED: every binding to it is blocked rather than quietly sent
|
||||
* over the plain WAN.
|
||||
*
|
||||
* interface — a direct outbound bound to that device + the egress routing mark.
|
||||
* direct — a plain direct outbound; its purpose is to carry a native DPI
|
||||
* preset (see DPI) on the rules routed to it.
|
||||
* byedpi — a SOCKS5 outbound to the local ciadpi desync proxy on
|
||||
* 127.0.0.1:Port.
|
||||
*
|
||||
* `proxy` and `block` were removed: neither ever emitted an outbound, so every
|
||||
* reference to them dangled and that traffic left over the plain WAN with the real
|
||||
* address. Route to a group/node/chain for the former, and to the `block` TARGET
|
||||
* for the latter.
|
||||
*
|
||||
* A third type was RETIRED — it worked, and then the defect it was compensating
|
||||
* for got fixed, so it became weight. That spelling still arrives here from an
|
||||
* un-migrated /etc/config/shater: it emits no outbound, so it is fail-closed like
|
||||
* any other unbuilt type, and the editor names it by hand instead of calling it
|
||||
* unrecognised. The type and its sentence live in ONE place, egressEdit.ts
|
||||
* RETIRED_EGRESS_TYPES; do not copy either here.
|
||||
*/
|
||||
/*
|
||||
* THERE IS DELIBERATELY NO `Target`. It was declared here as "legacy field of the
|
||||
@@ -1529,12 +1448,19 @@ export interface Inbound {
|
||||
* rejected the WHOLE write with `json: unknown field "Target"` (verified against
|
||||
* the running daemon), losing an unrelated edit somewhere else on the page.
|
||||
*/
|
||||
/*
|
||||
* THERE IS DELIBERATELY NO `Port` EITHER, for the same reason. It was the local
|
||||
* desync proxy's listen port, and no other type ever dialled anything; the Go
|
||||
* model dropped the field along with the type. Leaving it typed here would keep a
|
||||
* field the editor could still write, and PUT /api/config decodes with
|
||||
* DisallowUnknownFields — the daemon would reject the WHOLE write with
|
||||
* `json: unknown field "Port"`, losing whatever else was being saved with it.
|
||||
*/
|
||||
export interface Egress {
|
||||
Name: string
|
||||
Type: string // interface|direct|byedpi
|
||||
Type: string // interface|direct
|
||||
Interface?: string // type=interface: the UCI interface name
|
||||
Port?: number // type=byedpi ONLY: the local ciadpi listen port (default 1080)
|
||||
DPI?: string // type=interface|direct: off|fragment|record|spoof (byedpi desyncs itself)
|
||||
DPI?: string // type=interface|direct: off|fragment|record|spoof
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
@@ -2144,6 +2070,83 @@ export function getRulesReachability(): Promise<RulesReachability> {
|
||||
return MOCK ? mock().getRulesReachability() : req<RulesReachability>('api/rules/reachability')
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile-overridable scalar, resolved by the DAEMON.
|
||||
*
|
||||
* The redundancy between `Profile` and `Overridden` is deliberate and pinned by
|
||||
* a server-side test: `Profile !== ''` is exactly `Overridden`. So a caller that
|
||||
* renders on either one behaves identically, and the permanent noise line this
|
||||
* shape exists to prevent cannot come back through a careless reading.
|
||||
*/
|
||||
export interface EffectiveOverride {
|
||||
/** What the engine takes. `''` is a legitimate answer, not an error. */
|
||||
Value: string
|
||||
/**
|
||||
* The value in `config globals` that this verdict was computed against —
|
||||
* i.e. what was ON DISK when the daemon answered.
|
||||
*
|
||||
* Not the same thing as the value in the input on screen: the page holds an
|
||||
* optimistic copy and may carry an edit that has not been PUT yet. Comparing
|
||||
* the two is the only way a row can notice it is showing a verdict about a
|
||||
* different configuration than the one it is drawing.
|
||||
*/
|
||||
Stored: string
|
||||
/** The active profile responsible for the DIFFERENCE, else `''`. Bare name —
|
||||
* no `profile:` prefix to strip. */
|
||||
Profile: string
|
||||
/** True only when `Value` differs from `Stored`. The whole condition for
|
||||
* saying anything at all. */
|
||||
Overridden: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/config/effective — the four profile-overridable scalars as the ENGINE
|
||||
* reads them.
|
||||
*
|
||||
* WHY A SEPARATE REQUEST AND NOT A FIELD ON /api/config: the panel PUTs that
|
||||
* body back verbatim and `handleConfigPut` decodes with `DisallowUnknownFields`,
|
||||
* so a derived field there answers 400 — the daemon's author proved it by
|
||||
* mutation, planting a harmless derived key and getting
|
||||
* `decode model: json: unknown field "ActiveProfileName"`. The same reasoning is
|
||||
* already written down for /api/rules/reachability.
|
||||
*
|
||||
* WHY THE PANEL DOES NOT COMPUTE THIS: it used to (panel/src/effective.ts), and
|
||||
* that was a second copy of "which profile is active" and "whose value wins",
|
||||
* written in another language and shipped on its own schedule. Two
|
||||
* implementations of one rule drift, and the drift IS the defect this answers.
|
||||
*
|
||||
* CONSEQUENCE WORTH KNOWING: the verdict is computed from the config ON DISK, so
|
||||
* typing a new value into a globals field does NOT move it. The line keeps
|
||||
* naming the profile's value until the edit is saved — which is correct, because
|
||||
* until then the engine is still using the profile's value. Do not "fix" that
|
||||
* with a client-side recompute; that is the drift coming back.
|
||||
*/
|
||||
export interface EffectiveConfig {
|
||||
/**
|
||||
* The profile whose overrides apply, or `''` when none does. Reported
|
||||
* INDEPENDENTLY of whether it overrode any of the four: "a profile is active
|
||||
* and changed none of these" and "no profile is active" are different facts.
|
||||
*/
|
||||
ActiveProfile: string
|
||||
ResolverDefault: EffectiveOverride
|
||||
ResolverFallback: EffectiveOverride
|
||||
EndpointResolver: EffectiveOverride
|
||||
FetchDetour: EffectiveOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/config/effective — what the engine takes for the four scalars a
|
||||
* profile can override.
|
||||
*
|
||||
* Rejects as an ApiError on a daemon that predates the endpoint (404). Callers
|
||||
* must treat that as "no reading", never as "nothing is overridden": the whole
|
||||
* point is that a stored value and an effective one can differ, and a panel that
|
||||
* answers a question it could not measure is the defect, louder.
|
||||
*/
|
||||
export function getEffectiveConfig(): Promise<EffectiveConfig> {
|
||||
return MOCK ? mock().getEffectiveConfig() : req<EffectiveConfig>('api/config/effective')
|
||||
}
|
||||
|
||||
/** GET /api/ruleset/status — remote rule-set / blocklist freshness + rule counts. */
|
||||
export function getRulesetStatus(): Promise<RulesetStatus[]> {
|
||||
return MOCK ? mock().getRulesetStatus() : req<RulesetStatus[]>('api/ruleset/status')
|
||||
@@ -2218,21 +2221,6 @@ export function getRulesetCategories(source: string): Promise<RulesetCategories>
|
||||
: req<RulesetCategories>(`api/ruleset/categories?source=${encodeURIComponent(source)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/byedpi — the byedpi readiness report PLUS the per-egress port
|
||||
* cross-check (see {@link ByeDPIReport}). Other methods answer 405.
|
||||
*
|
||||
* Call it from the screen that shows egresses, and only when a human asks: it
|
||||
* OPENS SOCKETS (one connect per enabled instance, up to 16) and reads the whole
|
||||
* model. That is why it is not on the 5-second status poll — see
|
||||
* {@link Status.byedpi_installed}.
|
||||
* A rejection here is NOT "byedpi is broken" — it is "the readiness could not be
|
||||
* read", which is the `unknown` state and must be drawn as one.
|
||||
*/
|
||||
export function getByeDPI(): Promise<ByeDPIReport> {
|
||||
return MOCK ? mock().getByeDPI() : req<ByeDPIReport>('api/byedpi')
|
||||
}
|
||||
|
||||
/** GET /api/devices — discovered LAN clients merged with per-device config. */
|
||||
export function getDevices(): Promise<DiscoveredDevice[]> {
|
||||
return MOCK ? mock().getDevices() : req<DiscoveredDevice[]>('api/devices')
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// HOW OLD IS THE BYEDPI READING, and is that visible on screen.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// WHAT THIS PROTECTS. GET /api/status stopped probing: the connects behind the
|
||||
// byedpi report cost up to 6.4 s in the pathological case, measured, and the
|
||||
// panel polls that endpoint up to 27 times a minute. So the daemon serves a
|
||||
// cached report and stamps it with `age_seconds`.
|
||||
//
|
||||
// Every sentence in that report is present tense ("something is accepting TCP on
|
||||
// 127.0.0.1:1080"), and the report is what unlocks the byedpi egress type. A
|
||||
// panel that ignores the stamp draws an eight-second-old reading exactly like a
|
||||
// fresh one. The daemon refuses to quote anything older than 20 s, so such a
|
||||
// panel fails SAFE — but by accident, and an accident is not a design.
|
||||
//
|
||||
// Three facts, and each has its control:
|
||||
//
|
||||
// 1. A FRESH READING AND AN AGED ONE ARE DIFFERENT WORDS. Written so that
|
||||
// rendering them identically fails.
|
||||
// 2. NEGATIVE IS NOT AN AGE. Zero is a real and common answer (a loopback
|
||||
// connect finishes in microseconds), so the "nothing was measured" sentinel
|
||||
// had to be negative — and reading it as 0, or as "0 s ago", would make the
|
||||
// absence of a measurement the freshest possible reading.
|
||||
// 3. THE COLD FIRST POLL READS AS "not measured yet", NOT AS A FAILED CHECK.
|
||||
// It is the normal state of a daemon that started ten seconds ago.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { ByeDPIReport } from './api.ts'
|
||||
import {
|
||||
byedpiAge,
|
||||
byedpiAgeFresh,
|
||||
byedpiAgeHint,
|
||||
byedpiAgeLabel,
|
||||
byedpiLabel,
|
||||
byedpiNotYetMeasured,
|
||||
byedpiReady,
|
||||
byedpiStateLabel,
|
||||
byedpiTone,
|
||||
} from './byedpiReady.ts'
|
||||
|
||||
const rep = (over: Partial<ByeDPIReport>): ByeDPIReport => ({
|
||||
state: 'listening',
|
||||
detail: 'something is accepting TCP on 127.0.0.1:1080',
|
||||
binary: true,
|
||||
config_read: true,
|
||||
instances: [],
|
||||
egresses: [],
|
||||
age_seconds: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** What the daemon's byedpiNotMeasured() puts on the wire: an unknown that is
|
||||
* not a measurement at all. */
|
||||
const coldReport = rep({
|
||||
state: 'unknown',
|
||||
age_seconds: -1,
|
||||
config_read: false,
|
||||
detail:
|
||||
'the byedpi listener check has not finished its first run on this daemon yet, so nothing is known about the service. One is being taken now; this is an unknown, not a verdict.',
|
||||
})
|
||||
|
||||
// --- 1. fresh and aged are not the same thing, and not drawn the same ---------
|
||||
|
||||
test('a reading taken now and one taken seconds ago are DIFFERENT on screen', () => {
|
||||
const now = rep({ age_seconds: 0 })
|
||||
const old = rep({ age_seconds: 8 })
|
||||
|
||||
assert.equal(byedpiAgeFresh(byedpiAge(now)), true, '0 s is the freshest a reading gets')
|
||||
assert.equal(
|
||||
byedpiAgeFresh(byedpiAge(old)),
|
||||
false,
|
||||
'a reading the daemon itself calls 8 seconds old must not be drawn as live',
|
||||
)
|
||||
assert.notEqual(
|
||||
byedpiAgeLabel(byedpiAge(now)),
|
||||
byedpiAgeLabel(byedpiAge(old)),
|
||||
'same words for both ages is the defect: the operator cannot tell "now" from "a while ago"',
|
||||
)
|
||||
assert.match(byedpiAgeLabel(byedpiAge(now)), /just now/)
|
||||
assert.match(byedpiAgeLabel(byedpiAge(old)), /8 s ago/)
|
||||
})
|
||||
|
||||
test('CONTROL: the same helpers do produce the positive, live reading', () => {
|
||||
// Without this, "they differ" would also be satisfied by a function that can
|
||||
// never say a reading is fresh at all — the mirror of the bug, and just as bad
|
||||
// on a page whose Re-check button measures on the spot.
|
||||
const now = byedpiAge(rep({ age_seconds: 0 }))
|
||||
assert.deepEqual(now, { kind: 'measured', seconds: 0 })
|
||||
assert.equal(byedpiAgeFresh(now), true)
|
||||
assert.match(byedpiAgeHint(now), /just now/)
|
||||
})
|
||||
|
||||
test('one second is not "now" — the daemon counts in whole seconds', () => {
|
||||
// The daemon's background refresh runs at 3 s, so anything it bothered to call
|
||||
// 1 s old has genuinely aged past the instant.
|
||||
assert.equal(byedpiAgeFresh(byedpiAge(rep({ age_seconds: 1 }))), false)
|
||||
assert.equal(byedpiAgeLabel(byedpiAge(rep({ age_seconds: 1 }))), 'measured 1 s ago')
|
||||
})
|
||||
|
||||
test('a minute-old reading is spelled in minutes, and still never as "now"', () => {
|
||||
assert.equal(byedpiAgeLabel(byedpiAge(rep({ age_seconds: 60 }))), 'measured 1 min ago')
|
||||
assert.equal(byedpiAgeLabel(byedpiAge(rep({ age_seconds: 95 }))), 'measured 1 min 35 s ago')
|
||||
assert.equal(byedpiAgeFresh(byedpiAge(rep({ age_seconds: 95 }))), false)
|
||||
})
|
||||
|
||||
// --- 2. negative is not an age -------------------------------------------------
|
||||
|
||||
test('a NEGATIVE age is "no measurement", never the freshest one', () => {
|
||||
const age = byedpiAge(coldReport)
|
||||
assert.deepEqual(age, { kind: 'none' })
|
||||
assert.equal(
|
||||
byedpiAgeFresh(age),
|
||||
false,
|
||||
'this is the whole reason the sentinel is negative and not zero',
|
||||
)
|
||||
assert.notEqual(
|
||||
byedpiAgeLabel(age),
|
||||
byedpiAgeLabel(byedpiAge(rep({ age_seconds: 0 }))),
|
||||
'"nothing was measured" and "measured just now" must not share a sentence',
|
||||
)
|
||||
})
|
||||
|
||||
test('a report with no age field is a THIRD case: real reading, unknown age', () => {
|
||||
// A daemon older than the field. Claiming "not measured" would deny a real
|
||||
// reading; claiming "just now" would invent one.
|
||||
const unstamped = rep({ age_seconds: undefined })
|
||||
assert.deepEqual(byedpiAge(unstamped), { kind: 'unstamped' })
|
||||
assert.equal(byedpiAgeFresh(byedpiAge(unstamped)), false)
|
||||
assert.match(byedpiAgeLabel(byedpiAge(unstamped)), /age not reported/)
|
||||
// …and it is not the same sentence as either of the other two.
|
||||
const words = new Set([
|
||||
byedpiAgeLabel(byedpiAge(unstamped)),
|
||||
byedpiAgeLabel(byedpiAge(coldReport)),
|
||||
byedpiAgeLabel(byedpiAge(rep({ age_seconds: 0 }))),
|
||||
])
|
||||
assert.equal(words.size, 3, 'three different facts, three different sentences')
|
||||
})
|
||||
|
||||
test('a garbled age claims nothing rather than being read as a number', () => {
|
||||
for (const raw of [NaN, Infinity, 'soon' as unknown as number]) {
|
||||
const age = byedpiAge(rep({ age_seconds: raw }))
|
||||
assert.equal(age.kind, 'unstamped', String(raw))
|
||||
assert.equal(byedpiAgeFresh(age), false, String(raw))
|
||||
}
|
||||
})
|
||||
|
||||
// --- 3. the cold first poll ----------------------------------------------------
|
||||
|
||||
test('the first poll after the daemon starts reads as "not measured yet"', () => {
|
||||
assert.equal(byedpiNotYetMeasured(coldReport), true)
|
||||
assert.equal(
|
||||
byedpiStateLabel(coldReport),
|
||||
'not measured yet',
|
||||
'a check nobody has taken is not a check that "could not be determined"',
|
||||
)
|
||||
assert.notEqual(
|
||||
byedpiStateLabel(coldReport),
|
||||
byedpiLabel('unknown'),
|
||||
'the two causes of `unknown` call for different words: one is a fresh boot, the other a broken instrument',
|
||||
)
|
||||
})
|
||||
|
||||
test('…but it is the same unlit lamp, and it still locks the control', () => {
|
||||
// The word changes. The tone must not: a cold cache has established nothing,
|
||||
// exactly like a failed check, and neither may open a control or accuse the
|
||||
// service of being down.
|
||||
assert.equal(byedpiTone('unknown'), 'unknown', 'never crit, never good')
|
||||
assert.equal(byedpiReady(coldReport), false, 'only `listening` unlocks the byedpi egress type')
|
||||
})
|
||||
|
||||
test('an unknown that DID run keeps the old words — the two are told apart', () => {
|
||||
// The control for the case above: byedpiStateLabel must not simply rename every
|
||||
// unknown.
|
||||
const measuredUnknown = rep({
|
||||
state: 'unknown',
|
||||
age_seconds: 0,
|
||||
config_read: false,
|
||||
detail: '…could not be read (permission denied)…',
|
||||
})
|
||||
assert.equal(byedpiNotYetMeasured(measuredUnknown), false)
|
||||
assert.equal(byedpiStateLabel(measuredUnknown), 'not determined')
|
||||
assert.notEqual(byedpiStateLabel(measuredUnknown), byedpiStateLabel(coldReport))
|
||||
})
|
||||
|
||||
test('a listening report is still labelled by its state, whatever its age', () => {
|
||||
// The control that byedpiStateLabel did not become "an age renderer".
|
||||
assert.equal(byedpiStateLabel(rep({ age_seconds: 0 })), 'listening')
|
||||
assert.equal(byedpiStateLabel(rep({ age_seconds: 19 })), 'listening')
|
||||
assert.equal(byedpiReady(rep({ age_seconds: 19 })), true)
|
||||
})
|
||||
|
||||
test('no report at all is not a measurement either', () => {
|
||||
assert.deepEqual(byedpiAge(null), { kind: 'none' })
|
||||
assert.deepEqual(byedpiAge(undefined), { kind: 'none' })
|
||||
assert.equal(byedpiAgeFresh(byedpiAge(null)), false)
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
// The byedpi gate — what may unlock the egress type, and what "unknown" costs.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// WHAT THESE PROTECT, in the order they matter:
|
||||
//
|
||||
// 1. THE GATE HAS TO SWING BOTH WAYS. A gate that never opens and a gate that
|
||||
// never closes both pass a test that only checks one direction, and the
|
||||
// defect being fixed here was exactly a gate that always opened. So
|
||||
// `listening` is shown to unlock AND each of the other four is shown to
|
||||
// lock, by name.
|
||||
// 2. UNKNOWN IS NEITHER. It must not read as working (that is the old bug with
|
||||
// a new word) and it must not read as a refusal (that is a fault nobody
|
||||
// found). It gets its own tone, and the test fails if it ever shares one
|
||||
// with `listening` or with `not_listening`.
|
||||
// 3. A STRANGER VALUE LANDS SOMEWHERE SAFE. An unrecognised state — a typo, a
|
||||
// newer daemon — must become `unknown`, not whichever arm is last.
|
||||
// 4. PORT MISMATCH IS LOUD. It is the one verdict where every other signal
|
||||
// says healthy, so it may not be graded below a refusal.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { ByeDPIReport, ByeDPIState } from './api.ts'
|
||||
import {
|
||||
BYEDPI_EGRESS_STATES,
|
||||
BYEDPI_STATES,
|
||||
byedpiDetail,
|
||||
byedpiEgressFor,
|
||||
byedpiEgressLabel,
|
||||
byedpiEgressState,
|
||||
byedpiEgressTone,
|
||||
byedpiLabel,
|
||||
byedpiReady,
|
||||
byedpiState,
|
||||
byedpiTone,
|
||||
reportState,
|
||||
} from './byedpiReady.ts'
|
||||
|
||||
const rep = (state: string, over: Partial<ByeDPIReport> = {}): ByeDPIReport => ({
|
||||
state,
|
||||
detail: 'whatever the daemon said',
|
||||
binary: state !== 'not_installed',
|
||||
config_read: state !== 'not_installed' && state !== 'unknown',
|
||||
instances: [],
|
||||
egresses: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
// --- 1. the gate, in BOTH directions ----------------------------------------
|
||||
|
||||
test('a live listener — and nothing else — unlocks the byedpi egress type', () => {
|
||||
// The positive half of the control: without it, a gate stuck shut would pass.
|
||||
assert.equal(byedpiReady(rep('listening')), true, 'listening must unlock the type')
|
||||
|
||||
// The negative half, one named state at a time.
|
||||
for (const state of ['unknown', 'not_installed', 'disabled', 'not_listening'] as const) {
|
||||
assert.equal(byedpiReady(rep(state)), false, `${state} must NOT unlock the type`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the installed BINARY is not the gate — that is the whole defect', () => {
|
||||
// `disabled` is a router with ciadpi on disk and the shipped inert config: the
|
||||
// exact state that used to unlock the type and produce an egress nobody was
|
||||
// listening on.
|
||||
const fresh = rep('disabled', { binary: true, config_read: true })
|
||||
assert.equal(fresh.binary, true)
|
||||
assert.equal(byedpiReady(fresh), false, 'binary present must not be enough')
|
||||
})
|
||||
|
||||
test('no report at all is unknown, and unknown does not unlock', () => {
|
||||
assert.equal(reportState(null), 'unknown')
|
||||
assert.equal(reportState(undefined), 'unknown')
|
||||
assert.equal(byedpiReady(null), false)
|
||||
assert.equal(byedpiReady(undefined), false)
|
||||
})
|
||||
|
||||
// --- 2. unknown is its own state, drawn as neither ---------------------------
|
||||
|
||||
test('unknown shares a tone with neither the working state nor the broken one', () => {
|
||||
const unknown = byedpiTone('unknown')
|
||||
assert.notEqual(unknown, byedpiTone('listening'), 'unknown must not read as working')
|
||||
assert.notEqual(unknown, byedpiTone('not_listening'), 'unknown must not read as a refusal')
|
||||
assert.equal(unknown, 'unknown')
|
||||
})
|
||||
|
||||
test('every state has a tone and a label, and the tones say the right thing', () => {
|
||||
const want: Record<ByeDPIState, string> = {
|
||||
listening: 'good',
|
||||
not_listening: 'crit',
|
||||
disabled: 'warn',
|
||||
not_installed: 'warn',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
for (const s of BYEDPI_STATES) {
|
||||
assert.equal(byedpiTone(s), want[s], `tone of ${s}`)
|
||||
assert.ok(byedpiLabel(s).length > 0, `${s} needs a label`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the label never calls an unfinished check a state of the service', () => {
|
||||
// "not determined" is a statement about the CHECK. "off", "down" or "unknown
|
||||
// state" would each be a statement about byedpi, which is precisely what was
|
||||
// not established.
|
||||
assert.equal(byedpiLabel('unknown'), 'not determined')
|
||||
})
|
||||
|
||||
// --- 3. a stranger value lands on unknown ------------------------------------
|
||||
|
||||
test('a state outside the closed five reads as unknown, never as listening', () => {
|
||||
for (const raw of ['', ' ', 'LISTENING', 'running', 'ok', 'yes', 'listening ']) {
|
||||
const got = byedpiState(raw)
|
||||
assert.equal(got, 'unknown', `${JSON.stringify(raw)} must be unknown`)
|
||||
assert.equal(byedpiReady(rep(raw)), false, `${JSON.stringify(raw)} must not unlock`)
|
||||
}
|
||||
// …and the one spelling that IS the contract still works, so the check above
|
||||
// is not passing because everything fails.
|
||||
assert.equal(byedpiState('listening'), 'listening')
|
||||
})
|
||||
|
||||
test('the daemon detail is shown verbatim; only its absence is filled in', () => {
|
||||
assert.equal(byedpiDetail(rep('listening', { detail: 'something is accepting TCP' })), 'something is accepting TCP')
|
||||
// No report at all: the sentence must be about the CHECK, not about byedpi.
|
||||
assert.match(byedpiDetail(null), /has not answered/)
|
||||
assert.match(byedpiDetail(null), /not a verdict/)
|
||||
})
|
||||
|
||||
// --- 4. the per-egress cross-check -------------------------------------------
|
||||
|
||||
test('port_mismatch is graded as loud as a dead service, not as a note', () => {
|
||||
assert.equal(byedpiEgressTone('port_mismatch'), 'crit')
|
||||
assert.equal(byedpiEgressTone('not_listening'), 'crit')
|
||||
assert.equal(byedpiEgressTone('ok'), 'good')
|
||||
assert.equal(byedpiEgressTone('service_disabled'), 'warn')
|
||||
assert.equal(byedpiEgressTone('not_installed'), 'warn')
|
||||
assert.equal(byedpiEgressTone('unknown'), 'unknown')
|
||||
})
|
||||
|
||||
test('every egress verdict has a label, and unknown is not dressed as a verdict', () => {
|
||||
for (const s of BYEDPI_EGRESS_STATES) assert.ok(byedpiEgressLabel(s).length > 0, s)
|
||||
assert.equal(byedpiEgressLabel('unknown'), 'not determined')
|
||||
assert.notEqual(byedpiEgressTone('unknown'), byedpiEgressTone('ok'))
|
||||
assert.notEqual(byedpiEgressTone('unknown'), byedpiEgressTone('not_listening'))
|
||||
})
|
||||
|
||||
test('an egress verdict outside the closed six is unknown, not ok', () => {
|
||||
for (const raw of ['', 'fine', 'OK', 'listening']) {
|
||||
assert.equal(byedpiEgressState(raw), 'unknown', JSON.stringify(raw))
|
||||
}
|
||||
assert.equal(byedpiEgressState('ok'), 'ok')
|
||||
})
|
||||
|
||||
test('a missing cross-check row is "not checked", never an implied pass', () => {
|
||||
const withRow = rep('listening', {
|
||||
egresses: [{ name: 'ciadpi', port: 1080, state: 'ok', detail: 'accepting' }],
|
||||
})
|
||||
assert.equal(byedpiEgressFor(withRow, 'ciadpi')?.state, 'ok')
|
||||
// The status copy always carries an empty list — the caller must be able to
|
||||
// tell that apart from "this egress passed".
|
||||
assert.equal(byedpiEgressFor(withRow, 'other'), undefined)
|
||||
assert.equal(byedpiEgressFor(rep('listening'), 'ciadpi'), undefined)
|
||||
assert.equal(byedpiEgressFor(null, 'ciadpi'), undefined)
|
||||
})
|
||||
@@ -1,422 +0,0 @@
|
||||
// ByeDPI readiness, as the panel is allowed to read it.
|
||||
//
|
||||
// THE DEFECT THIS MODULE EXISTS FOR. The panel used to unlock the `byedpi`
|
||||
// egress type on `status.byedpi_installed`, which is LookPath("ciadpi") plus an
|
||||
// os.Stat. That answers "is the package installed"; the operator is asking "will
|
||||
// traffic sent to this egress go anywhere". The two come apart on the SHIPPED
|
||||
// configuration — the packaged /etc/config/byedpi is inert (`enabled '0'`) — so
|
||||
// installing the package unlocked the type, the egress was created on
|
||||
// 127.0.0.1:1080, the apply came back green, and nobody was listening.
|
||||
//
|
||||
// So the gate is now one thing and one thing only: state === 'listening'. Every
|
||||
// other state, INCLUDING `unknown`, keeps the type locked, because the whole
|
||||
// point of `unknown` is that nothing was established and a control that opens on
|
||||
// nothing established is the old bug wearing a new word.
|
||||
//
|
||||
// AND `unknown` IS NOT A REFUSAL EITHER. It has its own tone (`unknown`), never
|
||||
// `crit`, never `good`: "the check could not be completed" and "the service is
|
||||
// down" are different facts with different fixes, and a screen that paints them
|
||||
// the same has thrown away the distinction the daemon went to the trouble of
|
||||
// reporting.
|
||||
|
||||
import type { ByeDPIEgressCheck, ByeDPIEgressState, ByeDPIReport, ByeDPIState } from './api'
|
||||
|
||||
/**
|
||||
* The five service states, in the order they degrade. A CLOSED, POSITIVE list:
|
||||
* membership is tested against it rather than against a `default:` branch, so a
|
||||
* value this build has never heard of lands on `unknown` instead of on whichever
|
||||
* arm happens to be last.
|
||||
*/
|
||||
export const BYEDPI_STATES: readonly ByeDPIState[] = [
|
||||
'unknown',
|
||||
'not_installed',
|
||||
'disabled',
|
||||
'not_listening',
|
||||
'listening',
|
||||
]
|
||||
|
||||
/**
|
||||
* Normalise the wire value into the closed set. Anything unrecognised — a typo,
|
||||
* a future daemon's sixth state, a missing field — is `unknown`.
|
||||
*
|
||||
* `unknown` is the safe landing place in BOTH directions here, which is rare and
|
||||
* is why it can be the catch-all: it neither unlocks the control (only
|
||||
* `listening` does) nor accuses the service of being down.
|
||||
*
|
||||
* MATCHED EXACTLY, with no trimming or case folding. This value is a Go constant
|
||||
* (panel/byedpi.go), not an operator-typed UCI option like `kill_switch` — so
|
||||
* `"listening "` is not a spelling of the contract, it is a sign that something
|
||||
* between here and there is mangling the payload, and a control that unlocks on
|
||||
* a mangled payload is the defect this file exists for.
|
||||
*/
|
||||
export function byedpiState(raw: string | null | undefined): ByeDPIState {
|
||||
return (BYEDPI_STATES as readonly string[]).includes(raw ?? '')
|
||||
? (raw as ByeDPIState)
|
||||
: 'unknown'
|
||||
}
|
||||
|
||||
/** The report's state, with a missing report (never fetched, or the fetch
|
||||
* failed) reading as `unknown` — because that is precisely what it is. */
|
||||
export function reportState(rep: ByeDPIReport | null | undefined): ByeDPIState {
|
||||
return byedpiState(rep?.state)
|
||||
}
|
||||
|
||||
/**
|
||||
* MAY A NEW BYEDPI EGRESS BE CREATED? Exactly one state says yes.
|
||||
*
|
||||
* Keep this the only expression of the rule. It was three lines of `=== false`
|
||||
* in a page component last time, and that is how it came to mean "the file is
|
||||
* on disk".
|
||||
*/
|
||||
export function byedpiReady(rep: ByeDPIReport | null | undefined): boolean {
|
||||
return reportState(rep) === 'listening'
|
||||
}
|
||||
|
||||
/**
|
||||
* How a state is drawn. Deliberately FOUR tones, not three: `unknown` is its
|
||||
* own, and collapsing it into `crit` (or into `good`) is the lie this module
|
||||
* exists to prevent.
|
||||
*
|
||||
* good — listening
|
||||
* crit — not_listening: configured to run, refused the connection
|
||||
* warn — disabled / not_installed: nothing is wrong, nothing is running
|
||||
* either, and the fix is a deliberate operator action
|
||||
* unknown — unknown: no reading. An unlit lamp, never a red one.
|
||||
*/
|
||||
export type ByeDPITone = 'good' | 'warn' | 'crit' | 'unknown'
|
||||
|
||||
export function byedpiTone(state: ByeDPIState): ByeDPITone {
|
||||
switch (state) {
|
||||
case 'listening':
|
||||
return 'good'
|
||||
case 'not_listening':
|
||||
return 'crit'
|
||||
case 'disabled':
|
||||
case 'not_installed':
|
||||
return 'warn'
|
||||
case 'unknown':
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** The short word on the badge. Lower case, the daemon's own vocabulary. */
|
||||
export function byedpiLabel(state: ByeDPIState): string {
|
||||
switch (state) {
|
||||
case 'listening':
|
||||
return 'listening'
|
||||
case 'not_listening':
|
||||
return 'not listening'
|
||||
case 'disabled':
|
||||
return 'service disabled'
|
||||
case 'not_installed':
|
||||
return 'not installed'
|
||||
case 'unknown':
|
||||
return 'not determined'
|
||||
}
|
||||
}
|
||||
|
||||
// ---- how old the reading is -------------------------------------------------
|
||||
//
|
||||
// WHY AN AGE AT ALL, now that there is only one endpoint and it always connects.
|
||||
// GET /api/byedpi probes on the request a human issued, so the usual answer is
|
||||
// 0 — but the probe itself can be slow: up to sixteen enabled instances, each
|
||||
// dialled with a timeout, is 6.4 s measured in the state this whole report was
|
||||
// written to name honestly (a port that neither accepts nor refuses). Every
|
||||
// sentence the daemon writes is present tense, and this report is what unlocks
|
||||
// the byedpi egress type, so a verdict assembled over six seconds says so
|
||||
// instead of reading like an instant.
|
||||
//
|
||||
// (GET /api/status carried a cached copy of this report once, refreshed off the
|
||||
// request path, and the age existed to keep that copy from passing for a
|
||||
// reading. The panel polled it every five seconds and read the field NOWHERE, so
|
||||
// the field, the cache and the background refresh are all gone. What is left is
|
||||
// the probe's own duration.)
|
||||
|
||||
/**
|
||||
* WHAT THE AGE FIELD IS SAYING — three cases, kept apart because they are three
|
||||
* different facts and one of them is not about the service at all.
|
||||
*
|
||||
* measured — a real reading, and the probe behind it took `seconds`. Zero is
|
||||
* the COMMON case, not a sentinel: a loopback connect finishes in
|
||||
* microseconds and GET /api/byedpi measures on the spot.
|
||||
* none — there is NO reading here. In practice that is a report the panel
|
||||
* never got: the fetch failed, or none has been issued yet, and
|
||||
* {@link byedpiAge} is handed null. The negative-age arm below is
|
||||
* the same case arriving as a body — this daemon never sends one,
|
||||
* an older one could, and folding it into a measurement would make
|
||||
* "nobody looked" read as the freshest possible reading.
|
||||
* unstamped — a report from a daemon older than the field. The reading is
|
||||
* real; its age is simply not reported. Never claim it is fresh,
|
||||
* and never claim nothing was measured either.
|
||||
*/
|
||||
export type ByeDPIAge =
|
||||
| { kind: 'measured'; seconds: number }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'unstamped' }
|
||||
|
||||
/**
|
||||
* Read the age off a report. A CLOSED, POSITIVE classification: only a finite
|
||||
* number ≥ 0 is a measurement, and everything else lands on a case that claims
|
||||
* nothing — a missing report, a missing field, a negative value, or a NaN that
|
||||
* some proxy put there.
|
||||
*
|
||||
* The negative arm is kept although this daemon cannot produce one: it costs a
|
||||
* line, and the alternative — letting an unexpected number through as an age —
|
||||
* is the direction that turns "not measured" into "measured just now".
|
||||
*/
|
||||
export function byedpiAge(rep: ByeDPIReport | null | undefined): ByeDPIAge {
|
||||
if (!rep) return { kind: 'none' }
|
||||
const raw = rep.age_seconds
|
||||
if (raw === undefined || raw === null) return { kind: 'unstamped' }
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return { kind: 'unstamped' }
|
||||
if (raw < 0) return { kind: 'none' }
|
||||
return { kind: 'measured', seconds: Math.floor(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* IS THIS READING FROM THIS INSTANT? True for exactly one case: a measurement
|
||||
* whose age rounded down to whole seconds is zero, which is what a healthy
|
||||
* on-the-spot re-check reports.
|
||||
*
|
||||
* One second is not "now". A loopback connect finishes in microseconds, so a
|
||||
* probe the daemon bothered to call 1 s old spent that second in dial timeouts —
|
||||
* it is a slow reading, and the panel must not draw it as live.
|
||||
*/
|
||||
export function byedpiAgeFresh(age: ByeDPIAge): boolean {
|
||||
return age.kind === 'measured' && age.seconds === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The words next to the state. Each case reads as what it is, and no case is
|
||||
* allowed to be silent: an absent stamp beside a green lamp is precisely the
|
||||
* "assume now" this exists to stop.
|
||||
*/
|
||||
export function byedpiAgeLabel(age: ByeDPIAge): string {
|
||||
switch (age.kind) {
|
||||
case 'none':
|
||||
return 'not measured'
|
||||
case 'unstamped':
|
||||
return 'measured, age not reported'
|
||||
case 'measured': {
|
||||
if (age.seconds === 0) return 'measured just now'
|
||||
if (age.seconds < 60) return `measured ${age.seconds} s ago`
|
||||
const m = Math.floor(age.seconds / 60)
|
||||
const s = age.seconds % 60
|
||||
return s === 0 ? `measured ${m} min ago` : `measured ${m} min ${s} s ago`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The longer sentence behind the stamp — a tooltip, not a headline. It says
|
||||
* WHERE the number comes from, because "measured 6 s ago" on a page whose
|
||||
* Re-check button measures on the spot is otherwise a puzzle.
|
||||
*/
|
||||
export function byedpiAgeHint(age: ByeDPIAge): string {
|
||||
switch (age.kind) {
|
||||
case 'none':
|
||||
return 'There is no reading here, so nothing below is a measurement of the service. The listener check runs when this page asks for it, and the page has no answer yet — either it is still waiting or the request failed. Press Re-check to ask again.'
|
||||
case 'unstamped':
|
||||
return 'This daemon does not report how long its listener check took, so the state below cannot be dated. Press Re-check to take one now.'
|
||||
case 'measured':
|
||||
return age.seconds === 0
|
||||
? 'The connects behind this state were made just now.'
|
||||
: `The connects behind this state started ${age.seconds} seconds ago — the probe spent that long in dial timeouts — so every sentence below describes the service as it was then. Press Re-check to measure now.`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IS THIS AN UNKNOWN NOBODY HAS LOOKED AT YET? State `unknown` with no
|
||||
* measurement behind it — in practice a report the page never received: the
|
||||
* first render before the fetch resolves, or a fetch that failed.
|
||||
*
|
||||
* It is a different sentence from the other `unknown`: "no check has answered"
|
||||
* is about the panel and is fixed by asking again, while "the check ran and
|
||||
* could not establish anything" is a router with an unreadable conffile or a
|
||||
* wedged loopback. Same lamp — unlit, never red — different next action.
|
||||
*
|
||||
* A MISSING report is the main way in, and correctly: the page asked and got
|
||||
* nothing back, so nothing was measured as far as it knows. The sentence beside
|
||||
* it comes from {@link byedpiDetail}, which says which of the two happened.
|
||||
*/
|
||||
export function byedpiNotYetMeasured(rep: ByeDPIReport | null | undefined): boolean {
|
||||
return reportState(rep) === 'unknown' && byedpiAge(rep).kind === 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge word for a whole REPORT, rather than for a bare state.
|
||||
*
|
||||
* Identical to {@link byedpiLabel} except on the one state that has two causes:
|
||||
* an `unknown` with no measurement behind it says "not measured yet", because
|
||||
* "not determined" reads as an instrument that looked and failed, and the
|
||||
* commonest way to reach this is a page whose first fetch has not landed. Both
|
||||
* keep the same unlit lamp — this changes the word, never the tone.
|
||||
*/
|
||||
export function byedpiStateLabel(rep: ByeDPIReport | null | undefined): string {
|
||||
return byedpiNotYetMeasured(rep) ? 'not measured yet' : byedpiLabel(reportState(rep))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence to show. The daemon writes one per state, phrased to be shown
|
||||
* verbatim and phrased NOT to overclaim (a connect is not a SOCKS5 handshake),
|
||||
* so the panel prefers it and never rewrites it.
|
||||
*
|
||||
* The fallbacks below are for the one case the daemon cannot cover: there is no
|
||||
* daemon answer at all. They say that, rather than describing byedpi.
|
||||
*/
|
||||
export function byedpiDetail(rep: ByeDPIReport | null | undefined): string {
|
||||
const d = (rep?.detail ?? '').trim()
|
||||
if (d) return d
|
||||
if (!rep) {
|
||||
return 'The byedpi readiness check has not answered, so it is not known whether anything is listening. This is an unknown, not a verdict about the service.'
|
||||
}
|
||||
return 'The daemon reported no detail for this state, so nothing more is known than the state itself.'
|
||||
}
|
||||
|
||||
// ---- why a NEW byedpi egress cannot be created right now ---------------------
|
||||
//
|
||||
// The egress editor greys the `byedpi` type out in every state but `listening`.
|
||||
// A control that is simply greyed reads as a bug, so each refusal names the
|
||||
// state AND the next action — and none of them may name the WRONG next action,
|
||||
// which is the whole subject of `disabled` below.
|
||||
|
||||
/**
|
||||
* The states whose word has exactly one meaning, so a lookup can answer them.
|
||||
*
|
||||
* `disabled` IS DELIBERATELY NOT IN HERE — see {@link byedpiRefusal}. The type
|
||||
* excludes it rather than leaving a string nobody reads, so re-adding a fixed
|
||||
* sentence for it does not compile.
|
||||
*
|
||||
* `listening` has an entry only so the record is total over what is left; the
|
||||
* control is not locked in that state, so the empty string is never shown.
|
||||
*/
|
||||
const BYEDPI_REFUSAL: Record<Exclude<ByeDPIState, 'disabled'>, string> = {
|
||||
listening: '',
|
||||
not_installed:
|
||||
'The byedpi package is not installed, so nothing can listen. Install it (apk add byedpi), then re-check.',
|
||||
not_listening:
|
||||
'An instance is enabled but the connection to its port was refused, so the service is not running. Start it (/etc/init.d/byedpi restart), then re-check.',
|
||||
unknown:
|
||||
'The ByeDPI readiness check could not be completed, so it is not known whether anything is listening. Re-check above; a new egress stays unavailable until it is, because an egress no one is listening on blocks everything routed to it.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The consequence of `disabled`, and ONLY the consequence.
|
||||
*
|
||||
* The diagnosis and the fix come from the daemon's own sentence, which is why
|
||||
* this one says neither. Writing "enable an instance" here is what made the old
|
||||
* fixed sentence wrong: it is the right advice for one of the two situations
|
||||
* `disabled` covers and the wrong advice for the other.
|
||||
*/
|
||||
const BYEDPI_DISABLED_TAIL =
|
||||
'Until something is listening there, a new byedpi egress stays unavailable: one nothing is on blocks every rule routed to it. Re-check above once you have changed it.'
|
||||
|
||||
/**
|
||||
* The state `unknown` with nothing measured behind it — the page's first render,
|
||||
* or a fetch that failed. Different from the `unknown` above, which is a check
|
||||
* that RAN and established nothing: telling somebody their check "could not be
|
||||
* completed" when none has come back yet sends them looking for a router fault
|
||||
* that does not exist.
|
||||
*/
|
||||
const BYEDPI_REFUSAL_COLD =
|
||||
'No ByeDPI listener check has answered yet — this page takes one when it opens, and Re-check takes another. Nothing is known about the service until then, so a new egress stays unavailable: one no listener is on blocks everything routed to it. Re-check above to measure now.'
|
||||
|
||||
/**
|
||||
* WHY THE TYPE IS LOCKED, in one sentence the operator can act on.
|
||||
*
|
||||
* `disabled` COVERS TWO SITUATIONS AND THEY NEED OPPOSITE SENTENCES:
|
||||
*
|
||||
* - no instance is enabled at all. That is how the package SHIPS — the
|
||||
* packaged section carries `enabled '0'` — and nothing is wrong;
|
||||
* - an instance IS written and looks enabled, and /etc/init.d/byedpi refuses
|
||||
* it. Measured on the 25.12.1 testbed against the init script's own
|
||||
* validate_data spec: `option port 'auto'`, `option port '99999'`,
|
||||
* `option enabled ' 1'` (leading space) and `option enabled 'TRUE'` all
|
||||
* start nothing while reading as enabled to a human.
|
||||
*
|
||||
* The panel cannot tell them apart on its own — the daemon keeps its `problems`
|
||||
* list off the wire on purpose, so `detail` is the only carrier — and it must
|
||||
* not try. So `disabled` is answered with the DAEMON'S sentence, which names the
|
||||
* section, the option and the value in the second case, plus a fixed tail that
|
||||
* says only what is true of both. The sentence this replaced said "that is how
|
||||
* the package ships" about a section the operator had written themselves.
|
||||
*/
|
||||
export function byedpiRefusal(rep: ByeDPIReport | null | undefined): string {
|
||||
if (byedpiNotYetMeasured(rep)) return BYEDPI_REFUSAL_COLD
|
||||
const state = reportState(rep)
|
||||
if (state === 'disabled') return `${byedpiDetail(rep)} ${BYEDPI_DISABLED_TAIL}`
|
||||
return BYEDPI_REFUSAL[state]
|
||||
}
|
||||
|
||||
// ---- per-egress cross-check -------------------------------------------------
|
||||
|
||||
/** The six per-egress verdicts. Same closed-list discipline as the service
|
||||
* states, and the same landing place for a stranger value. */
|
||||
export const BYEDPI_EGRESS_STATES: readonly ByeDPIEgressState[] = [
|
||||
'ok',
|
||||
'port_mismatch',
|
||||
'not_listening',
|
||||
'service_disabled',
|
||||
'not_installed',
|
||||
'unknown',
|
||||
]
|
||||
|
||||
export function byedpiEgressState(raw: string | null | undefined): ByeDPIEgressState {
|
||||
return (BYEDPI_EGRESS_STATES as readonly string[]).includes(raw ?? '')
|
||||
? (raw as ByeDPIEgressState)
|
||||
: 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* `port_mismatch` is CRIT, not warn. It is the one state where everything looks
|
||||
* healthy from every other angle — the package is installed, the service is
|
||||
* running, the apply was green — and the egress still dials a port nobody is on.
|
||||
* Loud is the point.
|
||||
*/
|
||||
export function byedpiEgressTone(state: ByeDPIEgressState): ByeDPITone {
|
||||
switch (state) {
|
||||
case 'ok':
|
||||
return 'good'
|
||||
case 'port_mismatch':
|
||||
case 'not_listening':
|
||||
return 'crit'
|
||||
case 'service_disabled':
|
||||
case 'not_installed':
|
||||
return 'warn'
|
||||
case 'unknown':
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function byedpiEgressLabel(state: ByeDPIEgressState): string {
|
||||
switch (state) {
|
||||
case 'ok':
|
||||
return 'listening'
|
||||
case 'port_mismatch':
|
||||
return 'port mismatch'
|
||||
case 'not_listening':
|
||||
return 'not listening'
|
||||
case 'service_disabled':
|
||||
return 'service disabled'
|
||||
case 'not_installed':
|
||||
return 'not installed'
|
||||
case 'unknown':
|
||||
return 'not determined'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cross-check row for one egress by name.
|
||||
*
|
||||
* A MISS IS NOT AN "OK". The rows come only from GET /api/byedpi, and a model
|
||||
* the daemon could not read produces an empty list while still answering 200
|
||||
* with the service facts. So an absent row means "not cross-checked", and the
|
||||
* caller renders the SERVICE state instead of implying this egress passed
|
||||
* something.
|
||||
*/
|
||||
export function byedpiEgressFor(
|
||||
rep: ByeDPIReport | null | undefined,
|
||||
name: string,
|
||||
): ByeDPIEgressCheck | undefined {
|
||||
return (rep?.egresses ?? []).find((e) => e.name === name)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// WHY THE BYEDPI EGRESS TYPE IS LOCKED — and whether the sentence is true.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// WHAT THIS PROTECTS
|
||||
//
|
||||
// 1. `disabled` IS TWO SITUATIONS WEARING ONE WORD.
|
||||
//
|
||||
// - no instance is enabled at all — how the package SHIPS (the packaged
|
||||
// section carries `enabled '0'`). Nothing is wrong;
|
||||
// - an instance IS written and reads as enabled to a human, and
|
||||
// /etc/init.d/byedpi refuses it. Measured on the 25.12.1 testbed against
|
||||
// the init script's own validate_data spec: `option port 'auto'`,
|
||||
// `option port '99999'`, `option enabled ' 1'` (leading space) and
|
||||
// `option enabled 'TRUE'` each start nothing.
|
||||
//
|
||||
// The panel cannot separate them itself: the daemon keeps its `problems` list
|
||||
// off the wire (shater/panel/byedpi.go — the field is unexported and
|
||||
// unserialised on purpose), so `detail` is the only carrier. The old fixed
|
||||
// sentence said "that is how the package ships" about a section the operator
|
||||
// had typed themselves, and sent them to read the packaging instead of their
|
||||
// own typo.
|
||||
//
|
||||
// 2. THE INSTRUMENT HAS TO SWING BOTH WAYS. A refusal that always cries "your
|
||||
// typo" is exactly as useless as one that always says "that is how it ships",
|
||||
// and both pass a test that only looks at one report. So the factory case has
|
||||
// its own assertions: it must still read as the factory state, and it must
|
||||
// NOT carry the section-level accusation.
|
||||
//
|
||||
// 3. GET /api/status CARRIES NO READINESS REPORT. The daemon dropped the field
|
||||
// and the cache behind it — the panel polls that endpoint every 5 s from
|
||||
// every open tab and read the field nowhere, at 12 connects a minute per tab.
|
||||
// A fixture that still served it would show a browser something the router
|
||||
// does not send, so the fixture is asserted against, not just the type.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import type { ByeDPIReport, ByeDPIState } from './api.ts'
|
||||
import { byedpiRefusal } from './byedpiReady.ts'
|
||||
|
||||
const rep = (state: string, detail: string, over: Partial<ByeDPIReport> = {}): ByeDPIReport => ({
|
||||
state,
|
||||
detail,
|
||||
binary: state !== 'not_installed',
|
||||
config_read: state !== 'not_installed' && state !== 'unknown',
|
||||
instances: [],
|
||||
egresses: [],
|
||||
age_seconds: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
// The daemon's two `disabled` sentences, verbatim from byedpiProbe().
|
||||
const SHIPPED = rep(
|
||||
'disabled',
|
||||
"the ciadpi binary is installed but /etc/config/byedpi has no enabled instance, so nothing is listening. That is the shipped default (the packaged instance has enabled='0'): set enabled='1' on an instance and restart /etc/init.d/byedpi.",
|
||||
)
|
||||
|
||||
const REJECTED = rep(
|
||||
'disabled',
|
||||
'the ciadpi binary is installed, but /etc/config/byedpi holds no instance /etc/init.d/byedpi would start on a port this check can dial, so nothing is listening. section \'default\' sets port "auto", which is not a plain port number in 1-65535. /etc/init.d/byedpi validates that option as `port:port:1080`; this check does not guess what such a value binds and does NOT fall back to the 1080 default for it, so the section is not counted as a listener and nothing was dialled for it.',
|
||||
)
|
||||
|
||||
// --- 1. the two meanings of `disabled` are not drawn the same -----------------
|
||||
|
||||
test('a rejected section and the shipped inert config do not get the same refusal', () => {
|
||||
const shipped = byedpiRefusal(SHIPPED)
|
||||
const rejected = byedpiRefusal(REJECTED)
|
||||
|
||||
assert.notEqual(
|
||||
rejected,
|
||||
shipped,
|
||||
'both `disabled` reports produced one sentence — the operator who wrote the broken section is being told it is the factory state',
|
||||
)
|
||||
})
|
||||
|
||||
test("the rejected section is named — section, option and value, from the daemon", () => {
|
||||
const rejected = byedpiRefusal(REJECTED)
|
||||
|
||||
// The three things that make it actionable. Without them the sentence is a
|
||||
// mood, and the operator has no way to find the section that stopped it.
|
||||
assert.match(rejected, /section 'default'/, 'the section must be named')
|
||||
assert.match(rejected, /port "auto"/, 'the option and its value must be quoted')
|
||||
assert.match(rejected, /would start/, "the init script's refusal must be stated")
|
||||
|
||||
// …and it must NOT tell that operator this is how the package ships.
|
||||
assert.doesNotMatch(
|
||||
rejected,
|
||||
/shipped default|how the package ships|enabled '0'/,
|
||||
'a section the operator wrote is not the factory state',
|
||||
)
|
||||
})
|
||||
|
||||
// --- 2. the control: the factory state still reads as the factory state -------
|
||||
|
||||
test('the shipped inert config still reads as shipped, not as somebody’s typo', () => {
|
||||
const shipped = byedpiRefusal(SHIPPED)
|
||||
|
||||
// The positive half. Without this the "they differ" test above would pass on a
|
||||
// refusal that accuses every disabled router of a typo.
|
||||
assert.match(shipped, /shipped default/, 'the factory state must still be named as such')
|
||||
assert.match(shipped, /set enabled='1'/, "the daemon's own next action must survive")
|
||||
|
||||
// The negative half: no section-level accusation where no section is at fault.
|
||||
assert.doesNotMatch(
|
||||
shipped,
|
||||
/section '|would start|not a plain port number/,
|
||||
'nothing was rejected here — naming a section would send the operator hunting a typo that does not exist',
|
||||
)
|
||||
})
|
||||
|
||||
test('the daemon sentence is carried verbatim, not paraphrased', () => {
|
||||
// The panel may add to it; it may not rewrite it. A paraphrase is a second
|
||||
// copy of a diagnosis, free to drift from the one the daemon measured.
|
||||
for (const r of [SHIPPED, REJECTED]) {
|
||||
assert.ok(
|
||||
byedpiRefusal(r).startsWith(r.detail),
|
||||
'the refusal must open with the daemon’s own sentence',
|
||||
)
|
||||
assert.ok(byedpiRefusal(r).length > r.detail.length, 'the consequence must be appended')
|
||||
}
|
||||
// The appended half is the SAME in both, and says only what is true of both:
|
||||
// no diagnosis, no fix — those come from the daemon.
|
||||
const tail = (r: ByeDPIReport) => byedpiRefusal(r).slice(r.detail.length)
|
||||
assert.equal(tail(SHIPPED), tail(REJECTED))
|
||||
assert.match(tail(SHIPPED), /stays unavailable/)
|
||||
assert.doesNotMatch(
|
||||
tail(SHIPPED),
|
||||
/enable|install|start it/i,
|
||||
'the tail must not prescribe a fix — it cannot know which of the two situations it is in',
|
||||
)
|
||||
})
|
||||
|
||||
// --- 3. every locked state still has a sentence -------------------------------
|
||||
|
||||
test('each locked state refuses with a reason; the unlocked one shows nothing', () => {
|
||||
const locked: ByeDPIState[] = ['not_installed', 'disabled', 'not_listening', 'unknown']
|
||||
for (const s of locked) {
|
||||
const msg = byedpiRefusal(rep(s, 'the daemon said something about ' + s))
|
||||
assert.ok(msg.length > 0, `${s} must explain itself`)
|
||||
}
|
||||
// `listening` is not locked, so its entry is never shown — and must not be a
|
||||
// sentence somebody could accidentally render.
|
||||
assert.equal(byedpiRefusal(rep('listening', 'something is accepting TCP on 127.0.0.1:1080')), '')
|
||||
})
|
||||
|
||||
test('a disabled report with no detail still says something honest', () => {
|
||||
// The daemon always writes one, but a truncated proxy response must not
|
||||
// produce a bare tail — nor may the panel fill the gap with the claim it just
|
||||
// stopped making.
|
||||
const bare = byedpiRefusal(rep('disabled', ''))
|
||||
assert.ok(bare.length > 0)
|
||||
assert.doesNotMatch(bare, /shipped default|how the package ships/)
|
||||
assert.match(bare, /no detail/)
|
||||
})
|
||||
|
||||
// --- 4. no answer at all is not a verdict about the router --------------------
|
||||
|
||||
test('no report yet is about the PANEL, not about a daemon filling a cache', () => {
|
||||
const cold = byedpiRefusal(null)
|
||||
assert.match(cold, /has answered yet|Re-check/)
|
||||
// The daemon takes no check on its own any more: there is no background probe
|
||||
// and no cache to fill. Saying otherwise sends somebody off to wait.
|
||||
assert.doesNotMatch(
|
||||
cold,
|
||||
/shortly after it starts|cache|background/i,
|
||||
'the daemon probes only when asked — nothing is filling in behind the page',
|
||||
)
|
||||
// It must not be confused with a check that RAN and failed.
|
||||
assert.notEqual(cold, byedpiRefusal(rep('unknown', 'the config could not be read')))
|
||||
})
|
||||
|
||||
// --- 5. the status response carries no readiness report -----------------------
|
||||
//
|
||||
// Read as SOURCE rather than imported and called: mock.ts imports its neighbours
|
||||
// without file extensions (vite resolves those, node does not), so it cannot be
|
||||
// loaded here. Same approach as dnsOutcome.test.ts. The instrument is a regex,
|
||||
// so each assertion below is paired with a control showing the same regex does
|
||||
// find the thing when it is there.
|
||||
|
||||
const src = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8')
|
||||
|
||||
const API = src('./api.ts')
|
||||
const MOCK = src('./mock.ts')
|
||||
|
||||
test('the Status type declares no readiness report', () => {
|
||||
assert.doesNotMatch(
|
||||
API,
|
||||
/^\s*byedpi\?:/m,
|
||||
'GET /api/status carries no report — the daemon dropped the field with the cache behind it (shater/panel/api.go statusResponse)',
|
||||
)
|
||||
// CONTROL: the same shape of declaration IS found when it exists, so the
|
||||
// assertion above is not passing because the pattern never matches anything.
|
||||
assert.match(API, /^\s*byedpi_installed\?:/m, 'byedpi_installed must remain on the status')
|
||||
})
|
||||
|
||||
test('the mock status serves exactly what the daemon serves', () => {
|
||||
// A fixture that keeps the field shows a browser something the router does not
|
||||
// send, which is the same lie as the panel reading one that is not there.
|
||||
assert.doesNotMatch(
|
||||
MOCK,
|
||||
/^\s*byedpi:/m,
|
||||
'the mock status must not carry a readiness report the daemon no longer sends',
|
||||
)
|
||||
// CONTROL: the byedpi fact the status DOES carry is still in the fixture.
|
||||
assert.match(MOCK, /^\s*byedpi_installed:/m)
|
||||
})
|
||||
|
||||
test('both meanings of `disabled` are reachable in the browser fixture', () => {
|
||||
// A refusal that cannot be looked at cannot be judged. `?mock&byedpi=disabled`
|
||||
// is the shipped inert config; `?mock&byedpi=rejected` is the section the init
|
||||
// script threw out.
|
||||
assert.match(MOCK, /'rejected',/, 'the rejected-section mode must be selectable')
|
||||
assert.match(MOCK, /BYEDPI_REJECTED_PROBLEM/, "the daemon's section sentence must be in the fixture")
|
||||
assert.match(MOCK, /shipped default/, 'the factory sentence must still be reachable too')
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DetourCatalog } from '../detour'
|
||||
|
||||
/**
|
||||
* The detour picker: `Direct` plus every group / chain / interface-egress / node
|
||||
* the Model currently defines.
|
||||
*
|
||||
* One <select>, shared by every page that pins traffic to a route — a resolver's
|
||||
* DNS path, an alert's delivery, a subscription's fetch, the list-fetch route.
|
||||
* It was three byte-identical copies (DNS.tsx, Nodes.tsx, Alerts.tsx) before the
|
||||
* fourth consumer arrived; the option labels are the words the operator learns
|
||||
* the vocabulary from, so they have to be the SAME words on every page.
|
||||
*
|
||||
* `className` and `directLabel` are the only things a page varies. `directLabel`
|
||||
* matters: under a proxy-fetch `Direct` is not "no preference", it is the plain
|
||||
* WAN with the router's real address, and the page that means that says so.
|
||||
*
|
||||
* A stored value the catalog no longer knows is kept as a trailing
|
||||
* `<option>… (missing)</option>` rather than silently reselecting the first
|
||||
* entry — a picker that quietly rewrites a stale setting to `direct` would turn
|
||||
* a visible misconfiguration into an invisible leak.
|
||||
*/
|
||||
export interface DetourSelectProps {
|
||||
/** Canonical value — run the stored string through `canonDetour` first. */
|
||||
value: string
|
||||
catalog: DetourCatalog
|
||||
/** `detourValues(catalog)` — what counts as still-resolvable. */
|
||||
valid: Set<string>
|
||||
disabled?: boolean
|
||||
/** A save/apply is in flight; folded into `disabled`. */
|
||||
busy?: boolean
|
||||
ariaLabel: string
|
||||
onChange: (v: string) => void
|
||||
className?: string
|
||||
directLabel?: string
|
||||
/**
|
||||
* Turns the picker into an OVERRIDE picker: adds a leading `<option value="">`
|
||||
* with this label, meaning "not set here — inherit". Only for fields where
|
||||
* empty and `direct` are different inputs (a profile override: `''` inherits
|
||||
* whatever globals says, `direct` forces the plain WAN over a globals setting
|
||||
* that tunnels). Leave it off and `''` is not a selectable state.
|
||||
*/
|
||||
inheritLabel?: string
|
||||
}
|
||||
|
||||
export function DetourSelect({
|
||||
value,
|
||||
catalog,
|
||||
valid,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
className = 'fp-input',
|
||||
directLabel = 'Direct (no proxy)',
|
||||
inheritLabel,
|
||||
}: DetourSelectProps) {
|
||||
const missing = value !== '' && value !== 'direct' && !valid.has(value)
|
||||
return (
|
||||
<select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={busy || disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{inheritLabel !== undefined && <option value="">{inheritLabel}</option>}
|
||||
<option value="direct">{directLabel}</option>
|
||||
{catalog.groups.length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{catalog.groups.map((g) => (
|
||||
<option key={g} value={`group:${g}`}>
|
||||
Group {g} (balancer)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.chains.length > 0 && (
|
||||
<optgroup label="Chains">
|
||||
{catalog.chains.map((c) => (
|
||||
<option key={c} value={`chain:${c}`}>
|
||||
Chain {c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.egresses.length > 0 && (
|
||||
<optgroup label="Interfaces / egresses">
|
||||
{catalog.egresses.map((e) => (
|
||||
<option key={e.name} value={`egress:${e.name}`}>
|
||||
Interface/egress {e.name}
|
||||
{e.type ? ` (${e.type})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.nodes.length > 0 && (
|
||||
<optgroup label="Nodes">
|
||||
{catalog.nodes.map((n) => (
|
||||
<option key={n} value={`node:${n}`}>
|
||||
Node {n}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{missing && <option value={value}>{value} (missing)</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export { Button } from './Button'
|
||||
export type { ButtonProps } from './Button'
|
||||
export { Select } from './Select'
|
||||
export type { SelectProps, SelectOption } from './Select'
|
||||
export { DetourSelect } from './DetourSelect'
|
||||
export type { DetourSelectProps } from './DetourSelect'
|
||||
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog'
|
||||
export type { ConfirmDialogProps, ConfirmOptions, ConfirmTone } from './ConfirmDialog'
|
||||
export { Clock } from './Clock'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// The detour vocabulary, in one place.
|
||||
//
|
||||
// A "detour" is the panel's word for a model TARGET STRING — `direct`,
|
||||
// `node:<n>`, `group:<n>`, `egress:<n>`, `chain:<n>` — the same grammar
|
||||
// generate.resolveTarget parses for rule targets, device targets, DNS-resolver
|
||||
// detours, alert delivery, subscription fetches and (now) list fetches. One
|
||||
// grammar, so one reader.
|
||||
//
|
||||
// These four helpers used to be COPIED into DNS.tsx, Nodes.tsx and Alerts.tsx,
|
||||
// character for character. The copy in Alerts.tsx carried the standing
|
||||
// instruction that made this file: "If a third consumer ever appears, promote
|
||||
// them then." The third and fourth appeared (Globals.FetchDetour on the DNS
|
||||
// page, Profile.FetchDetour on the Profiles page), so they are promoted. The
|
||||
// duplication was never harmless: `canonDetour` decides whether a stored value
|
||||
// is drawn as live or as `(missing)`, and three copies free to drift are three
|
||||
// chances for one page to call a working route broken.
|
||||
//
|
||||
// This module is deliberately PURE (no JSX, no imports from ./api): it is what
|
||||
// `node --test` can execute directly. The <select> that renders these options
|
||||
// lives in components/DetourSelect.tsx.
|
||||
|
||||
/** The live targets a detour can point at, harvested from the Model. */
|
||||
export interface DetourCatalog {
|
||||
groups: string[]
|
||||
chains: string[]
|
||||
egresses: { name: string; type: string }[]
|
||||
nodes: string[]
|
||||
}
|
||||
|
||||
/** An empty catalog — every picker still offers `direct`. */
|
||||
export const EMPTY_CATALOG: DetourCatalog = { groups: [], chains: [], egresses: [], nodes: [] }
|
||||
|
||||
/**
|
||||
* Normalise a stored detour to a picker option value. Empty/`direct` ⇒
|
||||
* `direct`; already-prefixed values (`group:`/`chain:`/`egress:`/`node:`) pass
|
||||
* through; a bare legacy name is resolved against the catalog so a still-valid
|
||||
* setup isn't mislabelled; anything unresolved is kept verbatim (shown stale).
|
||||
*/
|
||||
export function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
|
||||
const d = (raw ?? '').trim()
|
||||
if (!d || d.toLowerCase() === 'direct') return 'direct'
|
||||
if (/^(node|group|chain|egress):/i.test(d)) return d
|
||||
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
|
||||
if (cat.groups.includes(d)) return `group:${d}`
|
||||
if (cat.chains.includes(d)) return `chain:${d}`
|
||||
if (cat.nodes.includes(d)) return `node:${d}`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Every valid option value for a catalog, including `direct`. */
|
||||
export function detourValues(cat: DetourCatalog): Set<string> {
|
||||
const s = new Set<string>(['direct'])
|
||||
for (const g of cat.groups) s.add(`group:${g}`)
|
||||
for (const c of cat.chains) s.add(`chain:${c}`)
|
||||
for (const e of cat.egresses) s.add(`egress:${e.name}`)
|
||||
for (const n of cat.nodes) s.add(`node:${n}`)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Describe a canonical detour value for a row readout. */
|
||||
export function describeDetour(
|
||||
canon: string,
|
||||
cat: DetourCatalog,
|
||||
valid: Set<string>,
|
||||
): { direct: boolean; prefix: string; name: string; missing: boolean } {
|
||||
if (canon === 'direct') return { direct: true, prefix: '', name: '', missing: false }
|
||||
const i = canon.indexOf(':')
|
||||
const kind = i === -1 ? '' : canon.slice(0, i)
|
||||
const name = i === -1 ? canon : canon.slice(i + 1)
|
||||
const missing = !valid.has(canon)
|
||||
let prefix = 'via'
|
||||
if (kind === 'group') prefix = 'via group'
|
||||
else if (kind === 'chain') prefix = 'via chain'
|
||||
else if (kind === 'node') prefix = 'via node'
|
||||
else if (kind === 'egress') {
|
||||
const eg = cat.egresses.find((e) => e.name === name)
|
||||
prefix = eg?.type === 'interface' ? 'via interface' : 'via egress'
|
||||
}
|
||||
return { direct: false, prefix, name, missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* One human-readable phrase for a canonical detour — "direct", "via group auto".
|
||||
* Used where there is room for a sentence but not for a whole readout row.
|
||||
*/
|
||||
export function detourLabel(canon: string, cat: DetourCatalog, valid: Set<string>): string {
|
||||
const d = describeDetour(canon, cat, valid)
|
||||
if (d.direct) return 'direct'
|
||||
return `${d.prefix} ${d.name}${d.missing ? ' (missing)' : ''}`
|
||||
}
|
||||
|
||||
/** Harvest a catalog from the Model slices, in the shape every page passes. */
|
||||
export function catalogOf(m: {
|
||||
Groups?: { Name: string }[] | null
|
||||
Chains?: { Name: string }[] | null
|
||||
Egresses?: { Name: string; Type: string }[] | null
|
||||
Nodes?: { Name: string }[] | null
|
||||
}): DetourCatalog {
|
||||
const arr = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
|
||||
return {
|
||||
groups: arr(m.Groups).map((g) => g.Name),
|
||||
chains: arr(m.Chains).map((c) => c.Name),
|
||||
egresses: arr(m.Egresses).map((e) => ({ name: e.Name, type: e.Type })),
|
||||
nodes: arr(m.Nodes).map((n) => n.Name),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Reading the daemon's effective-config answer.
|
||||
//
|
||||
// Run with `npm test`. Plain module, no React, no DOM.
|
||||
//
|
||||
// This file replaces effective.test.ts, which tested a RULE the panel no longer
|
||||
// owns — which profile is active, whose value wins. That rule is
|
||||
// model.ResolveActiveProfile + model.Override, answered by
|
||||
// GET /api/config/effective, and pinned by Go tests next to the code that
|
||||
// implements it. What is left to test here is the reading, and it has its own
|
||||
// ways to be wrong.
|
||||
//
|
||||
// WHAT THESE PROTECT, in the order they matter:
|
||||
//
|
||||
// 1. NO READING IS NOT "NOTHING IS OVERRIDDEN". A daemon that predates the
|
||||
// endpoint 404s, and a request can fail. Both are easy to write as `null ⇒
|
||||
// draw nothing`, which is indistinguishable on screen from "the stored
|
||||
// value is what runs" — the original defect, restored, in the exact
|
||||
// situation where the panel has least right to an opinion. `unknown` is a
|
||||
// third state and stays one.
|
||||
// 2. `Overridden` IS THE WHOLE CONDITION. Rendering whenever a profile is
|
||||
// merely active brings back the permanent "in effect now: <same thing>"
|
||||
// line under every field. The server pins `Profile !== '' ⟺ Overridden`
|
||||
// precisely so both readings behave alike; this checks the reading the
|
||||
// panel actually uses.
|
||||
// 3. THE VERDICT KNOWS WHICH STORED VALUE IT JUDGED. `Stored` is what was on
|
||||
// disk when the daemon answered. When the input on screen has moved on, the
|
||||
// row would otherwise pair a fresh field with an old verdict and let it
|
||||
// read as one statement.
|
||||
// 4. …BUT NOT WHILE THE ANSWER IS IN FLIGHT. Every save moves the on-screen
|
||||
// value first and the verdict a moment later, so an unsuppressed stale mark
|
||||
// would fire on every single edit — and a warning that appears routinely is
|
||||
// one nobody reads on the day it means something.
|
||||
// 5. CONTROL: A REAL OVERRIDE IS STILL REPORTED, with the production values
|
||||
// that started all of this. A reader that answered `none` to everything
|
||||
// would satisfy 1–4 read carelessly.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import type { EffectiveConfig, EffectiveOverride } from './api.ts'
|
||||
import { activeProfileName, effectiveLine } from './effectiveView.ts'
|
||||
|
||||
const ov = (o: Partial<EffectiveOverride> = {}): EffectiveOverride => ({
|
||||
Value: '',
|
||||
Stored: '',
|
||||
Profile: '',
|
||||
Overridden: false,
|
||||
...o,
|
||||
})
|
||||
|
||||
/** The production shape from the bug report: globals say `local`, the engine
|
||||
* uses `yandex` because `mobile-uplink` is pinned. */
|
||||
const PROD: EffectiveConfig = {
|
||||
ActiveProfile: 'mobile-uplink',
|
||||
ResolverDefault: ov({ Value: 'yandex', Stored: 'quad9', Profile: 'mobile-uplink', Overridden: true }),
|
||||
ResolverFallback: ov({ Value: 'cloudflare', Stored: 'cloudflare' }),
|
||||
EndpointResolver: ov({ Value: 'yandex', Stored: 'local', Profile: 'mobile-uplink', Overridden: true }),
|
||||
FetchDetour: ov({ Value: 'group:sim-bypass', Stored: 'direct', Profile: 'mobile-uplink', Overridden: true }),
|
||||
}
|
||||
|
||||
// ---- 1. no reading is its own answer ---------------------------------------
|
||||
|
||||
test('with no reading every field reports unknown, never "not overridden"', () => {
|
||||
for (const f of ['ResolverDefault', 'ResolverFallback', 'EndpointResolver', 'FetchDetour'] as const) {
|
||||
assert.deepEqual(effectiveLine(null, f, 'quad9'), { kind: 'unknown' }, f)
|
||||
}
|
||||
assert.equal(activeProfileName(null), null, 'and "which profile" is unknown too, not ""')
|
||||
})
|
||||
|
||||
test('a response missing a field is unknown for that field, not silence', () => {
|
||||
// A daemon that grew the endpoint before it grew the fourth scalar.
|
||||
const partial = { ActiveProfile: 'mobile-uplink', ResolverDefault: PROD.ResolverDefault } as unknown as EffectiveConfig
|
||||
assert.deepEqual(effectiveLine(partial, 'FetchDetour', 'direct'), { kind: 'unknown' })
|
||||
assert.equal(effectiveLine(partial, 'ResolverDefault', 'quad9').kind, 'in-effect')
|
||||
})
|
||||
|
||||
// ---- 2. Overridden is the whole condition -----------------------------------
|
||||
|
||||
test('an active profile that changed nothing produces no lines at all', () => {
|
||||
const quiet: EffectiveConfig = {
|
||||
ActiveProfile: 'evening-direct',
|
||||
ResolverDefault: ov({ Value: 'quad9', Stored: 'quad9' }),
|
||||
ResolverFallback: ov({ Value: 'cloudflare', Stored: 'cloudflare' }),
|
||||
EndpointResolver: ov({ Value: '', Stored: '' }),
|
||||
FetchDetour: ov({ Value: 'direct', Stored: 'direct' }),
|
||||
}
|
||||
for (const f of ['ResolverDefault', 'ResolverFallback', 'EndpointResolver', 'FetchDetour'] as const) {
|
||||
assert.deepEqual(effectiveLine(quiet, f, quiet[f].Stored), { kind: 'none' }, f)
|
||||
}
|
||||
// …and the profile is still NAMED. "Active and changed nothing" and "none
|
||||
// active" are different facts and the page says which.
|
||||
assert.equal(activeProfileName(quiet), 'evening-direct')
|
||||
})
|
||||
|
||||
test('no active profile is "" — an answer, distinct from no reading', () => {
|
||||
const none: EffectiveConfig = { ...PROD, ActiveProfile: '' }
|
||||
assert.equal(activeProfileName(none), '')
|
||||
assert.notEqual(activeProfileName(none), activeProfileName(null))
|
||||
})
|
||||
|
||||
// ---- 3. the verdict names the stored value it judged ------------------------
|
||||
|
||||
test('a field edited past the judged value is marked, not silently paired', () => {
|
||||
// The daemon judged `local`; the operator has since typed `router-local` and
|
||||
// not saved. The verdict is still true about disk, and says which disk.
|
||||
const line = effectiveLine(PROD, 'EndpointResolver', 'router-local')
|
||||
assert.deepEqual(line, {
|
||||
kind: 'in-effect',
|
||||
value: 'yandex',
|
||||
profile: 'mobile-uplink',
|
||||
stale: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('whitespace is not a difference — trimming matches the daemon', () => {
|
||||
const line = effectiveLine(PROD, 'EndpointResolver', ' local ')
|
||||
assert.equal(line.kind === 'in-effect' && line.stale, false)
|
||||
})
|
||||
|
||||
// ---- 4. …but not while the answer is in flight ------------------------------
|
||||
|
||||
test('the stale mark is suppressed while a refetch is pending', () => {
|
||||
const line = effectiveLine(PROD, 'EndpointResolver', 'router-local', true)
|
||||
assert.deepEqual(line, {
|
||||
kind: 'in-effect',
|
||||
value: 'yandex',
|
||||
profile: 'mobile-uplink',
|
||||
stale: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 5. control: a real override is still reported --------------------------
|
||||
|
||||
test('control — the defect that started this still reports, with both names', () => {
|
||||
assert.deepEqual(effectiveLine(PROD, 'EndpointResolver', 'local'), {
|
||||
kind: 'in-effect',
|
||||
value: 'yandex',
|
||||
profile: 'mobile-uplink',
|
||||
stale: false,
|
||||
})
|
||||
assert.deepEqual(effectiveLine(PROD, 'FetchDetour', 'direct'), {
|
||||
kind: 'in-effect',
|
||||
value: 'group:sim-bypass',
|
||||
profile: 'mobile-uplink',
|
||||
stale: false,
|
||||
})
|
||||
// The one field the profile left alone stays quiet in the same response.
|
||||
assert.deepEqual(effectiveLine(PROD, 'ResolverFallback', 'cloudflare'), { kind: 'none' })
|
||||
})
|
||||
|
||||
test('an effective value of "" is a value, and is reported as one', () => {
|
||||
// globals name a resolver, the profile clears it back to the engine default.
|
||||
const cleared: EffectiveConfig = {
|
||||
...PROD,
|
||||
EndpointResolver: ov({ Value: '', Stored: 'local', Profile: 'mobile-uplink', Overridden: true }),
|
||||
}
|
||||
const line = effectiveLine(cleared, 'EndpointResolver', 'local')
|
||||
assert.equal(line.kind, 'in-effect')
|
||||
assert.equal(line.kind === 'in-effect' && line.value, '')
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// How a page READS the daemon's effective-config answer. No rule, just a reading.
|
||||
//
|
||||
// The rule itself — which profile is active, whose value wins — used to live in
|
||||
// this directory (panel/src/effective.ts) as a second implementation in a second
|
||||
// language on a second release schedule. It is gone: GET /api/config/effective
|
||||
// answers it now, from model.ResolveActiveProfile, the same call generate and
|
||||
// netplane make. What is left here is the part that is genuinely the client's:
|
||||
// deciding what to DRAW from an answer that may not have arrived.
|
||||
//
|
||||
// The one thing this module exists to stop is the panel answering a question it
|
||||
// could not measure. A daemon that predates the endpoint 404s; a request can
|
||||
// fail. Neither is "nothing is overridden" — the whole premise is that stored
|
||||
// and effective can differ, so with no reading the honest output is a stated
|
||||
// absence, not a confident silence.
|
||||
|
||||
import type { EffectiveConfig, EffectiveOverride } from './api'
|
||||
|
||||
/** The four fields the endpoint resolves, spelled as it spells them. */
|
||||
export type EffectiveField =
|
||||
| 'ResolverDefault'
|
||||
| 'ResolverFallback'
|
||||
| 'EndpointResolver'
|
||||
| 'FetchDetour'
|
||||
|
||||
/**
|
||||
* What to render under one globals field. A CLOSED set — the caller switches on
|
||||
* `kind` and there is no fall-through.
|
||||
*
|
||||
* none — nothing to say: the value in the field is the value in force.
|
||||
* unknown — no reading was obtained. The field may or may not be overridden
|
||||
* and this panel cannot tell; say so rather than implying `none`.
|
||||
* in-effect — a profile makes the engine use something else. `stale` marks the
|
||||
* case where the verdict was computed against a DIFFERENT stored
|
||||
* value than the one on screen, so the row is not quietly pairing
|
||||
* a fresh input with an old verdict.
|
||||
*/
|
||||
export type EffectiveLine =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'in-effect'; value: string; profile: string; stale: boolean }
|
||||
|
||||
/**
|
||||
* Decide the line for one field.
|
||||
*
|
||||
* `eff` is null when no reading was obtained (never fetched, or the request
|
||||
* failed). `onScreenStored` is the value the input is currently showing, which
|
||||
* is the panel's optimistic copy and can legitimately run ahead of disk.
|
||||
*
|
||||
* `pending` suppresses the stale mark while a refetch is in flight. Every save
|
||||
* moves the on-screen value first and the verdict a moment later, so without it
|
||||
* the mark would flash on every keystroke-and-save — and a warning that appears
|
||||
* routinely is one nobody reads on the day it means something.
|
||||
*/
|
||||
export function effectiveLine(
|
||||
eff: EffectiveConfig | null,
|
||||
field: EffectiveField,
|
||||
onScreenStored: string | undefined,
|
||||
pending = false,
|
||||
): EffectiveLine {
|
||||
if (!eff) return { kind: 'unknown' }
|
||||
const ov: EffectiveOverride | undefined = eff[field]
|
||||
// A daemon that answers with the endpoint but not this field is the same
|
||||
// situation as no answer: unmeasured, not unoverridden.
|
||||
if (!ov) return { kind: 'unknown' }
|
||||
if (!ov.Overridden) return { kind: 'none' }
|
||||
return {
|
||||
kind: 'in-effect',
|
||||
value: ov.Value,
|
||||
profile: ov.Profile,
|
||||
stale: !pending && (onScreenStored ?? '').trim() !== ov.Stored,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile, or `''` when none is — reported even when it overrode
|
||||
* nothing, because the endpoint reports it that way and the two facts differ.
|
||||
* `null` (no reading) is a third answer and stays distinguishable.
|
||||
*/
|
||||
export function activeProfileName(eff: EffectiveConfig | null): string | null {
|
||||
return eff ? eff.ActiveProfile : null
|
||||
}
|
||||
+102
-19
@@ -4,9 +4,11 @@ import type { Egress } from './api.ts'
|
||||
import {
|
||||
DPI_TYPES,
|
||||
EGRESS_TYPES,
|
||||
RETIRED_EGRESS_TYPES,
|
||||
UNKNOWN_EGRESS_TYPE_HINT,
|
||||
isKnownEgressType,
|
||||
nextEgress,
|
||||
retiredEgressType,
|
||||
} from './egressEdit.ts'
|
||||
|
||||
// The egress editor's save merge. The defect these pin: the submit handler
|
||||
@@ -26,7 +28,6 @@ test('an unknown type keeps the fields the editor never showed', () => {
|
||||
name: 'vpn-renamed',
|
||||
type: 'wireguard',
|
||||
iface: 'wg0',
|
||||
port: '',
|
||||
dpi: 'off',
|
||||
})
|
||||
assert.equal(out.Name, 'vpn-renamed')
|
||||
@@ -43,15 +44,15 @@ test('an unknown type with a blank form state still keeps what was stored', () =
|
||||
// The stricter version: the editor's `iface` state is seeded from `initial`,
|
||||
// so a test that passes the same value back could pass on a broken merge too.
|
||||
// Blank the form and the stored value must still survive.
|
||||
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0', Port: 9050 }
|
||||
const out = nextEgress(initial, { name: 'vpn', type: 'wireguard', iface: '', port: '', dpi: '' })
|
||||
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0', DPI: 'spoof' }
|
||||
const out = nextEgress(initial, { name: 'vpn', type: 'wireguard', iface: '', dpi: '' })
|
||||
assert.equal(out.Interface, 'wg0')
|
||||
assert.equal(out.Port, 9050)
|
||||
assert.equal(out.DPI, 'spoof')
|
||||
})
|
||||
|
||||
test('the inputs are not mutated — the caller keeps a usable `initial`', () => {
|
||||
const initial: Egress = { Name: 'vpn', Type: 'wireguard', Interface: 'wg0' }
|
||||
nextEgress(initial, { name: 'other', type: 'interface', iface: 'wan2', port: '', dpi: 'off' })
|
||||
nextEgress(initial, { name: 'other', type: 'interface', iface: 'wan2', dpi: 'off' })
|
||||
assert.deepEqual(initial, { Name: 'vpn', Type: 'wireguard', Interface: 'wg0' })
|
||||
})
|
||||
|
||||
@@ -60,42 +61,35 @@ test('a known type still clears the fields it does not use', () => {
|
||||
// settings from the previous type must go, or the config keeps a value the new
|
||||
// type ignores and the panel shows a setting that does nothing.
|
||||
const initial: Egress = { Name: 'e', Type: 'interface', Interface: 'wan2', DPI: 'fragment' }
|
||||
const out = nextEgress(initial, { name: 'e', type: 'byedpi', iface: 'wan2', port: '1081', dpi: 'fragment' })
|
||||
assert.equal(out.Type, 'byedpi')
|
||||
assert.equal(out.Interface, undefined, 'a byedpi egress has no interface')
|
||||
assert.equal(out.Port, 1081)
|
||||
assert.equal(out.DPI, undefined, 'byedpi desyncs itself; the native preset is not applied')
|
||||
const out = nextEgress(initial, { name: 'e', type: 'direct', iface: 'wan2', dpi: 'record' })
|
||||
assert.equal(out.Type, 'direct')
|
||||
assert.equal(out.Interface, undefined, 'a direct egress binds no interface')
|
||||
assert.equal(out.DPI, 'record', 'but it does carry the native preset')
|
||||
})
|
||||
|
||||
test('an interface egress carries its interface and DPI, and no port', () => {
|
||||
test('an interface egress carries its interface and its DPI preset', () => {
|
||||
const out = nextEgress(undefined, {
|
||||
name: ' wan-direct ',
|
||||
type: 'interface',
|
||||
iface: ' wan2 ',
|
||||
port: '1080',
|
||||
dpi: 'record',
|
||||
})
|
||||
assert.equal(out.Name, 'wan-direct', 'the name is trimmed')
|
||||
assert.equal(out.Interface, 'wan2', 'the interface is trimmed')
|
||||
assert.equal(out.Port, undefined, 'only a byedpi egress dials a port')
|
||||
assert.equal(out.DPI, 'record')
|
||||
})
|
||||
|
||||
test('a byedpi egress with no port falls back to the ciadpi default', () => {
|
||||
const out = nextEgress(undefined, { name: 'b', type: 'byedpi', iface: '', port: ' ', dpi: 'off' })
|
||||
assert.equal(out.Port, 1080)
|
||||
})
|
||||
|
||||
test('the type list is the closed set the daemon builds outbounds for', () => {
|
||||
// model.KnownEgressTypes. `tunnel` must NOT be here: the daemon folds it to
|
||||
// `interface` on read (model.NormalizeEgressTypes), so the panel receives the
|
||||
// canonical spelling and a second entry would put the split back into the UI.
|
||||
assert.deepEqual(
|
||||
EGRESS_TYPES.map((t) => t.id),
|
||||
['interface', 'direct', 'byedpi'],
|
||||
['interface', 'direct'],
|
||||
)
|
||||
assert.equal(isKnownEgressType('tunnel'), false)
|
||||
assert.equal(isKnownEgressType('interface'), true)
|
||||
assert.equal(isKnownEgressType('direct'), true)
|
||||
assert.deepEqual([...DPI_TYPES].sort(), ['direct', 'interface'])
|
||||
})
|
||||
|
||||
@@ -114,3 +108,92 @@ test('the unknown-type hint describes what actually happens, both halves of it',
|
||||
'the superseded sentence claimed the engine was the only half involved',
|
||||
)
|
||||
})
|
||||
|
||||
// ---- the RETIRED type -------------------------------------------------------
|
||||
//
|
||||
// `byedpi` was a working egress kind. It handed traffic to a separate ciadpi
|
||||
// process because the engine's own TLS fragmentation was not getting through
|
||||
// DPI; the cause turned out to be a defect in that fragmentation — the cut
|
||||
// always landed inside the FIRST label of the name, so the blocked word
|
||||
// travelled intact — and with that fixed the external process was weight. The
|
||||
// type is gone from the Go model.
|
||||
//
|
||||
// What these pin is the SECOND half of removing it. Routers still carry
|
||||
// `option type 'byedpi'` in /etc/config/shater, and the cheap thing to do is let
|
||||
// the type fall into the generic unknown branch. That branch tells an operator
|
||||
// the router "does not recognise this type", which reads as a typo — so they go
|
||||
// looking for a misspelling that is not there, while every rule bound to that
|
||||
// egress is blocked right now.
|
||||
|
||||
test('a retired type is named as removed, not as unrecognised', () => {
|
||||
const hint = retiredEgressType('byedpi')
|
||||
assert.ok(hint, 'a stored byedpi egress must get a sentence of its own')
|
||||
// (a) removed from the product — and explicitly NOT a misspelling, because
|
||||
// that is the wrong hunt to send somebody on.
|
||||
assert.match(hint, /REMOVED from this product/)
|
||||
assert.match(hint, /not a misspelling/)
|
||||
// (b) what is happening RIGHT NOW: fail-closed, not a quiet WAN leak.
|
||||
assert.match(hint, /BLOCKED/)
|
||||
assert.match(hint, /fail-closed/)
|
||||
assert.match(hint, /never quietly sent out over the plain WAN/)
|
||||
// (c) the replacement, in the words of the two controls this form has.
|
||||
assert.match(hint, /Direct/)
|
||||
assert.match(hint, /Interface/)
|
||||
assert.match(hint, /record/)
|
||||
// (d) no promise about the operator's own ISP. The daemon does not make one,
|
||||
// and a caption that quietly did would be the panel contradicting it.
|
||||
assert.match(hint, /property of your ISP and is not promised here/)
|
||||
})
|
||||
|
||||
test('the retired sentence promises nothing about getting through', () => {
|
||||
// The failure mode this guards is a rewrite that "helps" by upgrading the
|
||||
// replacement instruction into a claim. `record` is a preset, not an outcome.
|
||||
const hint = retiredEgressType('byedpi') ?? ''
|
||||
for (const claim of [/will get through/i, /works with your/i, /restores/i, /fixes your/i]) {
|
||||
assert.doesNotMatch(hint, claim, `the caption must not claim an outcome: ${claim}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a retired type is spelled loosely — a hand-edited UCI file is the source', () => {
|
||||
assert.equal(retiredEgressType(' ByeDPI '), retiredEgressType('byedpi'))
|
||||
assert.equal(retiredEgressType('BYEDPI'), retiredEgressType('byedpi'))
|
||||
})
|
||||
|
||||
test('only the retired spellings match — no alias, no prototype, no live type', () => {
|
||||
// The control for the test above: a lookup that says yes to everything would
|
||||
// pass "byedpi gets a sentence" and be worthless. These must all be undefined.
|
||||
for (const live of ['interface', 'direct', 'tunnel', 'wireguard', '', ' ', 'byedpi2', 'bye dpi']) {
|
||||
assert.equal(retiredEgressType(live), undefined, `${JSON.stringify(live)} is not retired`)
|
||||
}
|
||||
// `RETIRED_EGRESS_TYPES[k]` answers these off Object.prototype; the lookup
|
||||
// must not, or a config typo becomes "[object Object]" under the Type select.
|
||||
for (const proto of ['toString', 'constructor', 'hasOwnProperty', '__proto__']) {
|
||||
assert.equal(retiredEgressType(proto), undefined, proto)
|
||||
}
|
||||
assert.deepEqual(Object.keys(RETIRED_EGRESS_TYPES), ['byedpi'])
|
||||
})
|
||||
|
||||
test('a retired type is retired — the engine list must not take it back', () => {
|
||||
// The other direction, and the one that would make the sentence above a lie:
|
||||
// if `byedpi` ever reads as a live type again, the editor renders fields for
|
||||
// it and nextEgress starts rewriting egresses the daemon builds nothing for.
|
||||
assert.equal(isKnownEgressType('byedpi'), false)
|
||||
assert.ok(!EGRESS_TYPES.some((t) => t.id === 'byedpi'), 'the type list must not offer it')
|
||||
assert.ok(!DPI_TYPES.has('byedpi'), 'and no preset is stamped on it either')
|
||||
// A type cannot be both, or the editor would show a blurb and a removal notice.
|
||||
for (const t of EGRESS_TYPES) {
|
||||
assert.equal(retiredEgressType(t.id), undefined, `${t.id} is offered AND retired`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a retired egress survives a rename with every stored field intact', () => {
|
||||
// The whole reason `if (!isKnownEgressType(type)) return base` exists, now that
|
||||
// a real router carries a type in exactly that position. Blanking the form
|
||||
// state too, so a merge that echoed the inputs back could not pass this.
|
||||
const initial: Egress = { Name: 'ciadpi-exit', Type: 'byedpi', Interface: 'wan', DPI: 'fragment' }
|
||||
const out = nextEgress(initial, { name: 'old-desync', type: 'byedpi', iface: '', dpi: 'off' })
|
||||
assert.equal(out.Name, 'old-desync')
|
||||
assert.equal(out.Type, 'byedpi', 'the type is not silently rewritten either')
|
||||
assert.equal(out.Interface, 'wan', 'renaming a retired egress must not delete its interface')
|
||||
assert.equal(out.DPI, 'fragment')
|
||||
})
|
||||
|
||||
+77
-20
@@ -12,23 +12,33 @@ import type { Egress } from './api'
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three egress kinds that produce a real way out, in the order the editor
|
||||
* The two egress kinds that produce a real way out, in the order the editor
|
||||
* offers them. This is the panel's copy of `model.KnownEgressTypes` and must
|
||||
* stay equal to it: the daemon builds no outbound for anything else, and the
|
||||
* router installs no mark, no `ip rule` and no routing table for it either, so
|
||||
* every node, group and rule bound to such an egress is blocked.
|
||||
*
|
||||
* The list is CLOSED and POSITIVE. There is no fallback entry and no "other":
|
||||
* a type that is not spelled here has no fields in this editor, and
|
||||
* {@link nextEgress} therefore refuses to rewrite it.
|
||||
*
|
||||
* `tunnel` is deliberately NOT here. It is an accepted spelling in
|
||||
* `/etc/config/shater`, but the daemon folds it to `interface` on read
|
||||
* (model.NormalizeEgressTypes), so an egress written that way arrives at this
|
||||
* panel already saying `interface` — with its Interface field rendered, its
|
||||
* blurb correct and no "(unknown)" label. Adding a fourth entry here would put
|
||||
* blurb correct and no "(unknown)" label. Adding a third entry here would put
|
||||
* the second spelling back into a UI that has to agree with two backend halves.
|
||||
*
|
||||
* `proxy` and `block` were removed: neither ever created an outbound, so
|
||||
* everything bound to them fell through to the plain WAN with the real address.
|
||||
* Send traffic through a proxy by routing it at a group/node/chain, and drop it
|
||||
* with the `block` target on a rule.
|
||||
*
|
||||
* A third type was RETIRED for the opposite reason — it worked, and then the
|
||||
* defect it was compensating for got fixed, so it became weight. It is not
|
||||
* dropped into the generic unknown branch on the way out; it is named, once, in
|
||||
* {@link RETIRED_EGRESS_TYPES}, which is the only place its spelling and its
|
||||
* story live.
|
||||
*/
|
||||
export const EGRESS_TYPES: ReadonlyArray<{ id: string; label: string; blurb: string }> = [
|
||||
{
|
||||
@@ -41,11 +51,6 @@ export const EGRESS_TYPES: ReadonlyArray<{ id: string; label: string; blurb: str
|
||||
label: 'Direct — straight out, with an optional DPI preset',
|
||||
blurb: 'Uses the normal route. Its point is the DPI preset below, applied to what you route here.',
|
||||
},
|
||||
{
|
||||
id: 'byedpi',
|
||||
label: 'ByeDPI — through the local ciadpi desync proxy',
|
||||
blurb: 'Hands traffic to ciadpi on 127.0.0.1, which desyncs it and goes out direct.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Lookup by id, or undefined when the stored type is not one this panel knows. */
|
||||
@@ -59,16 +64,69 @@ export function isKnownEgressType(type: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Types whose native DPI-bypass preset applies. NOT byedpi: the desync happens
|
||||
* inside the ciadpi process, and the engine's tls_* flags are never stamped on
|
||||
* top of it — so the control is hidden there rather than accepted and dropped.
|
||||
* Types whose native DPI-bypass preset applies — which is now every type this
|
||||
* panel knows. It is kept as its own set rather than folded into
|
||||
* {@link EGRESS_TYPES} because the two lists answer different questions ("what
|
||||
* may be created" vs "what carries a preset"), and they have already come apart
|
||||
* once.
|
||||
*/
|
||||
export const DPI_TYPES: ReadonlySet<string> = new Set(['interface', 'direct'])
|
||||
|
||||
/**
|
||||
* What the editor shows under the Type select when the stored type is not one of
|
||||
* the three. It has to describe what the router actually does with such an
|
||||
* egress, and what THIS FORM does to it on save — both halves were wrong before.
|
||||
* Types that WERE built and are not any more, with the sentence the editor shows
|
||||
* for each. A CLOSED, positive table: nothing lands here by accident, and a type
|
||||
* absent from it falls to {@link UNKNOWN_EGRESS_TYPE_HINT}.
|
||||
*
|
||||
* It exists so a router carrying `option type 'byedpi'` in /etc/config/shater is
|
||||
* told what happened rather than handed the generic "not recognised" — the
|
||||
* operator did not mistype anything, the kind was taken out from under them, and
|
||||
* the difference decides what they do next.
|
||||
*
|
||||
* The daemon says the same thing at length (`model.RetiredEgressTypes`); this is
|
||||
* the caption-length version and must not contradict it.
|
||||
*/
|
||||
export const RETIRED_EGRESS_TYPES: Readonly<Record<string, string>> = {
|
||||
byedpi:
|
||||
'The type “byedpi” was REMOVED from this product — this is not a misspelling, it is a kind ' +
|
||||
'that no longer exists. Nothing is built for it: no outbound, no mark, no routing rule or ' +
|
||||
'table, so every node, group and rule bound to this egress is BLOCKED right now — ' +
|
||||
'fail-closed, never quietly sent out over the plain WAN. It existed to hand traffic to a ' +
|
||||
'separate ciadpi process because the engine’s own TLS fragmentation was not getting through; ' +
|
||||
'that turned out to be a defect in the fragmentation, and it is fixed. Move this egress onto ' +
|
||||
'the built-in desync: set the type to Direct with the DPI preset “record”, or to Interface ' +
|
||||
'with the same preset plus the interface this traffic should leave through, then apply. ' +
|
||||
'Which preset gets through is a property of your ISP and is not promised here — “fragment” ' +
|
||||
'and “spoof” are the other two.',
|
||||
}
|
||||
|
||||
/**
|
||||
* The retired-type sentence for a stored type, or undefined when the type is not
|
||||
* a retired one.
|
||||
*
|
||||
* Case and surrounding space are folded, because that is what a hand-edited
|
||||
* `/etc/config/shater` produces. Nothing else is: there is deliberately no alias
|
||||
* or normalisation step in front of this lookup, so only the exact retired
|
||||
* spellings match and no live type can ever be routed into a "this was removed"
|
||||
* message.
|
||||
*
|
||||
* The own-property check is not ceremony: a bare `obj[key]` answers `toString`
|
||||
* and `constructor` out of the prototype, and a `type` comes off a config file
|
||||
* this panel does not control.
|
||||
*/
|
||||
export function retiredEgressType(type: string): string | undefined {
|
||||
const key = type.trim().toLowerCase()
|
||||
return Object.prototype.hasOwnProperty.call(RETIRED_EGRESS_TYPES, key)
|
||||
? RETIRED_EGRESS_TYPES[key]
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor shows under the Type select when the stored type is neither a
|
||||
* known one nor a RETIRED one — the retired table above answers first, because
|
||||
* "you mistyped something" and "we took this kind away" send an operator to
|
||||
* different places. It has to describe what the router actually does with such
|
||||
* an egress, and what THIS FORM does to it on save — both halves were wrong
|
||||
* before.
|
||||
*
|
||||
* It used to read: "This engine builds no outbound for that type, so everything
|
||||
* routed here is blocked. Pick one above." Two problems. It said "this engine",
|
||||
@@ -93,7 +151,6 @@ export interface EgressForm {
|
||||
name: string
|
||||
type: string
|
||||
iface: string
|
||||
port: string
|
||||
dpi: string
|
||||
}
|
||||
|
||||
@@ -103,8 +160,8 @@ export interface EgressForm {
|
||||
* # The rule, and why it is the rule
|
||||
*
|
||||
* A save may only CLEAR a field the editor was in a position to show. For the
|
||||
* three known types the editor renders every field that type uses, so clearing
|
||||
* the others is right: switching `interface` → `byedpi` must drop the stale
|
||||
* known types the editor renders every field that type uses, so clearing the
|
||||
* others is right: switching `interface` → `direct` must drop the stale
|
||||
* interface name, or the config keeps a setting the new type ignores.
|
||||
*
|
||||
* For a type this panel has no definition for, the editor renders NONE of those
|
||||
@@ -122,9 +179,10 @@ export interface EgressForm {
|
||||
* from one rename, with no message anywhere.
|
||||
*
|
||||
* The daemon no longer hands this panel a `tunnel` (it is folded to `interface`
|
||||
* on read), so that particular type is gone. The rule stays, because the next
|
||||
* type the backend gains before the panel learns it would repeat the whole
|
||||
* thing: an editor must not delete what it declines to display.
|
||||
* on read), so that particular type is gone. The rule stays, and the RETIRED type
|
||||
* is why it earns its keep today: routers still carry that spelling in their
|
||||
* config, the editor renders none of its fields, and renaming such an egress must
|
||||
* leave every stored setting on it exactly as saved.
|
||||
*
|
||||
* `initial` is never mutated — the caller keeps a usable object if the save
|
||||
* fails.
|
||||
@@ -136,7 +194,6 @@ export function nextEgress(initial: Egress | undefined, form: EgressForm): Egres
|
||||
// never delivers one and the spread above cannot produce one.
|
||||
if (!isKnownEgressType(type)) return base
|
||||
base.Interface = type === 'interface' ? form.iface.trim() : undefined
|
||||
base.Port = type === 'byedpi' ? Number(form.port.trim()) || 1080 : undefined
|
||||
base.DPI = DPI_TYPES.has(type) ? form.dpi : undefined
|
||||
return base
|
||||
}
|
||||
|
||||
+122
-284
@@ -10,7 +10,7 @@ import { killSwitchClosed } from './planeState'
|
||||
// The SAME field lists and matcher the page documents and the daemon implements —
|
||||
// so a search in `?mock` cannot quietly be more (or less) generous than the real one.
|
||||
import { connSearchFields, logSearchFields, rowMatches } from './logRoute'
|
||||
import type { ApplyResult, ByeDPIEgressCheck, ByeDPIInstance, ByeDPIReport, ByeDPIState, ChainHealth, ChainHopHealth, ConnLogEntry, DiscoveredDevice, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, Profile, QueryLogEntry, RuleReach, RulesReachability, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning, TestKind, Traffic } from './api'
|
||||
import type { ApplyResult, ChainHealth, ChainHopHealth, ConnLogEntry, DiscoveredDevice, EffectiveConfig, EffectiveOverride, GroupHealth, GroupMemberHealth, GroupsHealth, GroupTestResult, GroupTestStart, GroupTestStatus, Interface, Model, Profile, QueryLogEntry, RuleReach, RulesReachability, RulesetCategories, RulesetCheck, RulesetStatus, Stats, StatsLogPage, StatsLogQuery, Status, StatusWarning, TestKind, Traffic } from './api'
|
||||
|
||||
/** One URL knob, safe to read before `location` exists (SSR-less builds/tests). */
|
||||
function mockParam(name: string): string | null {
|
||||
@@ -53,9 +53,19 @@ const CONFIG: Model = {
|
||||
FwmarkBase: 0x2000,
|
||||
TableBase: 0x2000,
|
||||
ConfirmTimeout: 90,
|
||||
// Three resolver slots and the list-fetch route, ALL of them overridable by
|
||||
// the active profile below — which is the point of these particular values.
|
||||
// `mobile-uplink` (pinned as ActiveProfile) repoints the default, the
|
||||
// endpoint resolver and the fetch route, and says NOTHING about the
|
||||
// fallback. So `?mock` renders the DNS card in both states at once: three
|
||||
// rows carrying an "in effect now" line, one row quiet because nothing
|
||||
// overrode it. A fixture where every row is overridden would hide the bug
|
||||
// that a permanently-visible line is no better than no line at all.
|
||||
ResolverDefault: 'cloudflare-doh',
|
||||
ResolverFallback: 'router-local',
|
||||
EndpointResolver: '',
|
||||
// '' is `direct` — what every build did before the field existed.
|
||||
FetchDetour: '',
|
||||
ProbeURL: 'https://www.gstatic.com/generate_204',
|
||||
ProbeInterval: '60s',
|
||||
SchemaVersion: 2,
|
||||
@@ -157,6 +167,13 @@ const CONFIG: Model = {
|
||||
// Provider reported NOTHING (all zeros) ⇒ the honest "not reported" rendering,
|
||||
// never a 0-of-0 bar.
|
||||
{ Name: 'backup', Enabled: false, URL: 'https://sub.example.org/link', Format: 'clash', UpdateInterval: '24h', FetchVia: 'proxy', FetchDetour: 'group:auto' },
|
||||
// The three `fetch_via` states are all present in this fixture on purpose:
|
||||
// `backup` is an explicit `proxy`, `legacy` below is an explicit `direct`
|
||||
// (what the v2→v3 migration stamped on every existing subscription, so their
|
||||
// behaviour did not change under it), and `primary` above leaves the option
|
||||
// ABSENT — the new third state, where the general fetch route decides. A
|
||||
// fixture missing the absent one would let the two-state reading come back
|
||||
// unnoticed, which is exactly how it survived this long.
|
||||
// ALREADY EXPIRED, and on an unlimited/unreported plan (UserTotal 0 with real
|
||||
// traffic counters). A valid state, not an error — exercises both edge cases.
|
||||
{
|
||||
@@ -164,6 +181,9 @@ const CONFIG: Model = {
|
||||
Enabled: false,
|
||||
URL: 'https://sub.example.com/old',
|
||||
Format: 'auto',
|
||||
// Explicitly pinned in the clear — stays direct even once the general
|
||||
// fetch route sends everything else through a tunnel.
|
||||
FetchVia: 'direct',
|
||||
UserUpload: 1_073_741_824,
|
||||
UserDownload: 15_032_385_536,
|
||||
UserTotal: 0,
|
||||
@@ -221,8 +241,6 @@ const CONFIG: Model = {
|
||||
// Goes straight out the default WAN, but splits the TLS ClientHello on the way
|
||||
// — a direct egress exists to carry a native DPI preset.
|
||||
{ Name: 'tunnel', Type: 'direct', DPI: 'fragment' },
|
||||
// The local ciadpi desync proxy (the only type that dials a port).
|
||||
{ Name: 'ciadpi', Type: 'byedpi', Port: 1080 },
|
||||
],
|
||||
Rules: [
|
||||
{ Name: 'block-ads', Enabled: true, Order: 10, DstRuleset: ['ad-hosts'], Target: 'block' },
|
||||
@@ -280,6 +298,11 @@ const CONFIG: Model = {
|
||||
{ Name: 'cloudflare-doh', Type: 'doh', Address: 'https://1.1.1.1/dns-query', Detour: 'tunnel' },
|
||||
{ Name: 'router-local', Type: 'local' },
|
||||
{ Name: 'fakeip-pool', Type: 'fakeip', Pool: '198.18.0.0/15' },
|
||||
// The one a mobile profile switches to. Modelled on the measurement that
|
||||
// started this: the carrier refuses :443 to 1.1.1.1 and 9.9.9.9 but routes
|
||||
// 77.88.8.8:53 fine, so plain DNS to an address it does carry is the only
|
||||
// resolver that works on that uplink.
|
||||
{ Name: 'carrier-plain', Type: 'plain', Address: '77.88.8.8' },
|
||||
],
|
||||
// Which resolver answers what, first match wins by ascending Order. Covers all
|
||||
// three matcher shapes: domains only, source only, and both together.
|
||||
@@ -366,8 +389,23 @@ const CONFIG: Model = {
|
||||
MatchIface: ['wan1'],
|
||||
EnableRules: ['default-tunnel'],
|
||||
DisableRules: ['block-ads', 'ru-bypass', 'private-direct'],
|
||||
// The scalar overrides, chosen to exercise PER-FIELD inheritance rather
|
||||
// than to look tidy: default + endpoint + fetch route are repointed, the
|
||||
// FALLBACK IS DELIBERATELY ABSENT and keeps the globals value
|
||||
// (`router-local`). A fixture that set all four would pass a panel that
|
||||
// wrongly treats "profile has any override" as "profile overrides
|
||||
// everything" — the exact mistake this contract invites.
|
||||
ResolverDefault: 'carrier-plain',
|
||||
EndpointResolver: 'carrier-plain',
|
||||
// A route, not a resolver: the lists are pulled through the tunnel because
|
||||
// the uplink cannot be trusted to reach the hosts they live on.
|
||||
FetchDetour: 'group:stealth',
|
||||
},
|
||||
{
|
||||
// No scalar overrides at all — the control. Switch the override on the
|
||||
// Profiles page to this one in `?mock` and every "in effect now" line on
|
||||
// the DNS page must DISAPPEAR. If one survives, the page is drawing the
|
||||
// line from "a profile is active" rather than from a real difference.
|
||||
Name: 'evening-direct',
|
||||
Enabled: true,
|
||||
Priority: 10,
|
||||
@@ -457,6 +495,86 @@ const RULESET_STATUS: RulesetStatus[] = [
|
||||
{ tag: 'al-work-allow', name: 'work-allow', category: '', kind: 'allowlist', remote: true, last_updated: '', interval_seconds: 21_600, rule_count: 0 },
|
||||
]
|
||||
|
||||
/**
|
||||
* model.ResolveActiveProfile, as the daemon runs it: a pin naming an existing,
|
||||
* ENABLED profile wins outright; otherwise auto-select the highest Priority among
|
||||
* enabled profiles, ties by Name, SKIPPING any with an iface condition — those
|
||||
* need live network state, and the WAN watcher expresses its verdict as the pin.
|
||||
*
|
||||
* One copy here, used by both endpoints that depend on it. Two would be the same
|
||||
* mistake the panel just deleted from its own source tree, only in the fixtures.
|
||||
*/
|
||||
function resolveActiveProfile(): Profile | null {
|
||||
const profiles = CONFIG.Profiles ?? []
|
||||
const pinned = String(CONFIG.Globals?.ActiveProfile ?? '').trim()
|
||||
const hit = profiles.find((p) => p.Enabled && p.Name === pinned)
|
||||
if (hit) return hit
|
||||
let best: Profile | null = null
|
||||
for (const p of profiles) {
|
||||
if (!p.Enabled || (p.MatchIface ?? []).length > 0) continue
|
||||
const pp = p.Priority ?? 0
|
||||
const bp = best?.Priority ?? 0
|
||||
if (!best || pp > bp || (pp === bp && p.Name < best.Name)) best = p
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/config/effective. Mirrors shater/panel/effective.go exactly, including
|
||||
* the two things it is easy to get almost right:
|
||||
*
|
||||
* - `Overridden` is `Value !== Stored`, NOT "the profile set something". A
|
||||
* profile pinning the value globals already carries has changed nothing, and
|
||||
* must not produce a permanent "in effect now: <same>" line.
|
||||
* - the fetch detour compares through `canonFetchDetour`: unset and an explicit
|
||||
* `direct` are ONE route. Resolver names compare verbatim (the generator
|
||||
* matches them case-sensitively, so folding case here would call `Quad9` and
|
||||
* `quad9` the same setting when the engine will not).
|
||||
*/
|
||||
export async function getEffectiveConfig(): Promise<EffectiveConfig> {
|
||||
await wait(60)
|
||||
// `?mock&noeffective=1` — the daemon that predates this endpoint, or a read
|
||||
// that fails. Reachable on purpose: "no reading" must NOT look like "nothing
|
||||
// is overridden", and the only way to check that on screen is to be able to
|
||||
// produce it. Without a knob the branch ships untested and its whole point is
|
||||
// that it is indistinguishable from the bug when it is wrong.
|
||||
if (mockParam('noeffective') === '1') {
|
||||
throw new ApiErrorLike(404, 'not found')
|
||||
}
|
||||
const prof = resolveActiveProfile()
|
||||
const g = CONFIG.Globals
|
||||
const canonDetour = (v: string): string => {
|
||||
const s = (v ?? '').trim()
|
||||
return !s || s.toLowerCase() === 'direct' ? 'direct' : s
|
||||
}
|
||||
const verbatim = (v: string): string => (v ?? '').trim()
|
||||
|
||||
const one = (
|
||||
stored: string | undefined,
|
||||
override: string | undefined,
|
||||
canon: (v: string) => string,
|
||||
): EffectiveOverride => {
|
||||
const base = (stored ?? '').trim()
|
||||
const raw = (override ?? '').trim()
|
||||
const value = prof && raw !== '' ? raw : base
|
||||
const differs = prof !== null && raw !== '' && canon(value) !== canon(base)
|
||||
return {
|
||||
Value: value,
|
||||
Stored: base,
|
||||
Profile: differs && prof ? prof.Name : '',
|
||||
Overridden: differs,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ActiveProfile: prof ? prof.Name : '',
|
||||
ResolverDefault: one(g.ResolverDefault, prof?.ResolverDefault, verbatim),
|
||||
ResolverFallback: one(g.ResolverFallback, prof?.ResolverFallback, verbatim),
|
||||
EndpointResolver: one(g.EndpointResolver, prof?.EndpointResolver, verbatim),
|
||||
FetchDetour: one(g.FetchDetour, prof?.FetchDetour, canonDetour),
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/rules/reachability. Mirrors the daemon's analysis over CONFIG.Rules:
|
||||
* a rule with no conditions is the router's default, and the LAST such rule by
|
||||
* Order wins — every earlier one can never apply. It reads the live CONFIG so
|
||||
@@ -470,20 +588,7 @@ export async function getRulesReachability(): Promise<RulesReachability> {
|
||||
await wait(60)
|
||||
const rules = CONFIG.Rules ?? []
|
||||
|
||||
// A pin naming an existing, ENABLED profile wins outright. Otherwise auto-select:
|
||||
// highest Priority among enabled profiles, ties by Name, skipping any with an
|
||||
// iface condition (the WAN watcher owns those and expresses its verdict as the pin).
|
||||
const profiles = CONFIG.Profiles ?? []
|
||||
const pinned = String(CONFIG.Globals?.ActiveProfile ?? '').trim()
|
||||
let prof: Profile | null = profiles.find((p) => p.Enabled && p.Name === pinned) ?? null
|
||||
if (!prof) {
|
||||
for (const p of profiles) {
|
||||
if (!p.Enabled || (p.MatchIface ?? []).length > 0) continue
|
||||
const pp = p.Priority ?? 0
|
||||
const bp = prof?.Priority ?? 0
|
||||
if (!prof || pp > bp || (pp === bp && p.Name < prof.Name)) prof = p
|
||||
}
|
||||
}
|
||||
const prof = resolveActiveProfile()
|
||||
// Enable first, then Disable, so a name in both ends up disabled (Disable wins).
|
||||
const effective = rules.map((r) => Boolean(r.Enabled))
|
||||
if (prof) {
|
||||
@@ -904,7 +1009,6 @@ export async function getStatus(): Promise<Status> {
|
||||
warnings: [CONFIG_UNREADABLE_WARNING, ...mockWarnings(killSwitch)],
|
||||
started_unix: MOCK_STARTED_UNIX,
|
||||
uptime_seconds: Math.floor(Date.now() / 1000) - MOCK_STARTED_UNIX,
|
||||
byedpi_installed: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -928,275 +1032,9 @@ export async function getStatus(): Promise<Status> {
|
||||
// the reading ticks forward across polls exactly like the real daemon's does.
|
||||
started_unix: MOCK_STARTED_UNIX,
|
||||
uptime_seconds: Math.floor(Date.now() / 1000) - MOCK_STARTED_UNIX,
|
||||
// The old flag, and it is only ever the BINARY question — exactly as the
|
||||
// daemon now documents it. It reads `true` for `disabled`, which is the
|
||||
// whole reason it stopped being a gate: a fresh install has the file and no
|
||||
// listener.
|
||||
//
|
||||
// AND IT IS THE ONLY BYEDPI FIELD HERE. The readiness report used to ride
|
||||
// along on this response; the daemon removed it, cache and all, because the
|
||||
// panel polls this endpoint every 5 s and read the field nowhere. A fixture
|
||||
// that still served it would show a browser something the router does not
|
||||
// send — which is the same lie in the other direction.
|
||||
byedpi_installed: byedpiMode() !== 'not_installed',
|
||||
}
|
||||
}
|
||||
|
||||
// ---- byedpi readiness -------------------------------------------------------
|
||||
//
|
||||
// Five service states, and the mock has to reach all five: gating the egress
|
||||
// type on one of them is worthless unless the other four are exercisable. Pick
|
||||
// with `?mock&byedpi=<state>`; `?nobyedpi` is kept as the older spelling of
|
||||
// `not_installed`.
|
||||
//
|
||||
// Two knobs are NOT service states, and each exists because a real failure is
|
||||
// invisible from the state alone:
|
||||
//
|
||||
// mismatch — the service IS listening, on a port no egress dials. The defect
|
||||
// the cross-check exists for: two packages that agreed in a
|
||||
// comment.
|
||||
// rejected — `disabled`, but NOT the shipped inert config: a section is
|
||||
// written and looks enabled, and /etc/init.d/byedpi refuses it.
|
||||
// Measured on the 25.12.1 testbed with the init script's own
|
||||
// validate_data spec — `option port 'auto'`, `option port '99999'`,
|
||||
// `option enabled ' 1'`, `option enabled 'TRUE'` all start nothing.
|
||||
// Same state word, opposite next action, so the panel must not draw
|
||||
// the two alike (see byedpiRefusal in byedpiReady.ts).
|
||||
//
|
||||
// Two more live outside the mode list, next to `noread`, because they are not
|
||||
// bodies the daemon sends: `?mock&byedpi=noanswer` makes GET /api/byedpi FAIL,
|
||||
// which is the only way the panel now reaches "nothing has been measured".
|
||||
//
|
||||
// The sentences are the daemon's own (shater/panel/byedpi.go), including the
|
||||
// clause that keeps them honest: the check is a connection, not a SOCKS5
|
||||
// handshake.
|
||||
|
||||
type ByeDPIMode = ByeDPIState | 'mismatch' | 'rejected'
|
||||
|
||||
const BYEDPI_MODES: readonly ByeDPIMode[] = [
|
||||
'listening',
|
||||
'not_listening',
|
||||
'disabled',
|
||||
'rejected',
|
||||
'not_installed',
|
||||
'unknown',
|
||||
'mismatch',
|
||||
]
|
||||
|
||||
const BYEDPI_CONF = '/etc/config/byedpi'
|
||||
|
||||
/**
|
||||
* The daemon's own sentence for a section /etc/init.d/byedpi will not start —
|
||||
* byedpiSectionProblem.Reason in shater/panel/byedpi.go, verbatim, for the
|
||||
* `option port 'auto'` case. It is the material that separates the two meanings
|
||||
* of `disabled`, and it is the ONLY thing on the wire that does: the daemon
|
||||
* keeps its `problems` list unserialised on purpose, so `detail` is where this
|
||||
* arrives or it does not arrive at all.
|
||||
*/
|
||||
const BYEDPI_REJECTED_PROBLEM =
|
||||
'section \'default\' sets port "auto", which is not a plain port number in 1-65535. /etc/init.d/byedpi validates that option as `port:port:1080`; this check does not guess what such a value binds and does NOT fall back to the 1080 default for it, so the section is not counted as a listener and nothing was dialled for it.'
|
||||
|
||||
/** The requested mode, defaulting to a working service. An unrecognised value
|
||||
* falls back to the default rather than inventing a sixth state. */
|
||||
function byedpiMode(): ByeDPIMode {
|
||||
if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('nobyedpi')) {
|
||||
return 'not_installed'
|
||||
}
|
||||
const want = (mockParam('byedpi') ?? '').trim()
|
||||
return (BYEDPI_MODES as readonly string[]).includes(want) ? (want as ByeDPIMode) : 'listening'
|
||||
}
|
||||
|
||||
/** The port a live instance binds in the `mismatch` mode — deliberately NOT the
|
||||
* 1080 the fixture's `ciadpi` egress dials, so both numbers appear on screen. */
|
||||
const BYEDPI_MISMATCH_PORT = 1081
|
||||
|
||||
/**
|
||||
* How long the probe behind this body took, in whole seconds — the daemon's
|
||||
* `age_seconds`. GET /api/byedpi measures on the spot, so 0 is the normal
|
||||
* answer and anything above it is a probe that really did spend that long in
|
||||
* dial timeouts.
|
||||
*
|
||||
* `?mock&byedpiage=<n>` forces it, so BOTH sides of the freshness rendering are
|
||||
* reachable without waiting for anything. The value is only a display input; it
|
||||
* never changes the state.
|
||||
*
|
||||
* NEGATIVE IS ALLOWED THROUGH ON PURPOSE. This daemon never sends one — the
|
||||
* cached status copy that used to is gone — but the panel still keeps that case
|
||||
* apart from a real reading, and `?mock&byedpi=unknown&byedpiage=-1` is how that
|
||||
* guard is reached in a browser.
|
||||
*/
|
||||
function byedpiAgeSeconds(): number {
|
||||
const raw = (mockParam('byedpiage') ?? '').trim()
|
||||
if (raw === '') return 0
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : 0
|
||||
}
|
||||
|
||||
function byedpiReport(withEgresses: boolean): ByeDPIReport {
|
||||
const mode = byedpiMode()
|
||||
const rep: ByeDPIReport = {
|
||||
// `rejected` is the SAME service state as `disabled` — that is the whole
|
||||
// point of it. What differs is the sentence, which is the only place the
|
||||
// difference exists on the wire.
|
||||
state: mode === 'mismatch' ? 'listening' : mode === 'rejected' ? 'disabled' : mode,
|
||||
detail: '',
|
||||
binary: mode !== 'not_installed',
|
||||
config_read: mode !== 'not_installed' && mode !== 'unknown',
|
||||
instances: [],
|
||||
egresses: [],
|
||||
age_seconds: byedpiAgeSeconds(),
|
||||
}
|
||||
const inst = (port: number, listening: string): ByeDPIInstance[] => [
|
||||
{ section: 'default', port, listening },
|
||||
]
|
||||
switch (mode) {
|
||||
case 'not_installed':
|
||||
rep.detail =
|
||||
'the ciadpi binary is not on this router, so the byedpi package is not installed and no desync proxy can run. Install it (apk add byedpi) before pointing an egress at one.'
|
||||
break
|
||||
case 'unknown':
|
||||
rep.detail = `the ciadpi binary is installed, but ${BYEDPI_CONF} could not be read (permission denied), so it is NOT known whether any instance is enabled or on which port. This is an unknown, not a verdict.`
|
||||
break
|
||||
case 'disabled':
|
||||
rep.detail = `the ciadpi binary is installed but ${BYEDPI_CONF} has no enabled instance, so nothing is listening. That is the shipped default (the packaged instance has enabled='0'): set enabled='1' on an instance and restart /etc/init.d/byedpi.`
|
||||
break
|
||||
case 'rejected':
|
||||
// No instance is enabled AS FAR AS THE INIT SCRIPT IS CONCERNED — but one
|
||||
// is written, and the operator wrote it. The daemon says which section,
|
||||
// which option and which value; the shipped-default sentence above would
|
||||
// send that operator looking at the wrong thing entirely.
|
||||
rep.detail = `the ciadpi binary is installed, but ${BYEDPI_CONF} holds no instance /etc/init.d/byedpi would start on a port this check can dial, so nothing is listening. ${BYEDPI_REJECTED_PROBLEM}`
|
||||
break
|
||||
case 'not_listening':
|
||||
rep.instances = inst(1080, 'no')
|
||||
rep.detail =
|
||||
'an instance is enabled on 127.0.0.1:1080, but the connection was refused there — the service is configured to run and is not running. Start it: /etc/init.d/byedpi restart.'
|
||||
break
|
||||
case 'listening':
|
||||
rep.instances = inst(1080, 'yes')
|
||||
rep.detail =
|
||||
'something is accepting TCP on 127.0.0.1:1080, the port an enabled byedpi instance is configured for. The check is a connection, not a SOCKS5 handshake: it proves a listener is there, not that ciadpi is the process behind it.'
|
||||
break
|
||||
case 'mismatch':
|
||||
rep.instances = inst(BYEDPI_MISMATCH_PORT, 'yes')
|
||||
rep.detail = `something is accepting TCP on 127.0.0.1:${BYEDPI_MISMATCH_PORT}, the port an enabled byedpi instance is configured for. The check is a connection, not a SOCKS5 handshake: it proves a listener is there, not that ciadpi is the process behind it.`
|
||||
break
|
||||
}
|
||||
if (withEgresses) rep.egresses = byedpiEgressChecks(rep, mode === 'rejected')
|
||||
return rep
|
||||
}
|
||||
|
||||
/** The per-egress cross-check, mirroring panel/byedpi.go byedpiEgressVerdict —
|
||||
* service state first (an egress is never healthier than the service), then the
|
||||
* port comparison.
|
||||
*
|
||||
* `rejectedSection` is the daemon's `len(rep.problems) > 0`: the same
|
||||
* `service_disabled` verdict, a different sentence, because "no instance is
|
||||
* enabled" would read as the shipped state when in fact one is written and the
|
||||
* init script threw it out. */
|
||||
function byedpiEgressChecks(rep: ByeDPIReport, rejectedSection = false): ByeDPIEgressCheck[] {
|
||||
const byPort = new Map<number, ByeDPIInstance>()
|
||||
for (const i of rep.instances) if (!byPort.has(i.port)) byPort.set(i.port, i)
|
||||
return (CONFIG.Egresses ?? [])
|
||||
.filter((e) => e.Type === 'byedpi')
|
||||
.map((e) => {
|
||||
const port = e.Port || 1080
|
||||
const where = `127.0.0.1:${port}`
|
||||
if (rep.state === 'not_installed') {
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'not_installed',
|
||||
detail: `this egress dials ${where}, but the byedpi package is not installed, so nothing can be there. Every rule bound to this egress is fail-closed.`,
|
||||
}
|
||||
}
|
||||
if (rep.state === 'unknown') {
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'unknown',
|
||||
detail: `this egress dials ${where}, and the byedpi service state could not be determined, so it is NOT known whether that port is served. Treat this as unchecked, not as working.`,
|
||||
}
|
||||
}
|
||||
if (rep.state === 'disabled') {
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'service_disabled',
|
||||
detail: rejectedSection
|
||||
? `this egress dials ${where}, but ${BYEDPI_CONF} holds no instance /etc/init.d/byedpi would start, so nothing is listening on any port. ${BYEDPI_REJECTED_PROBLEM}`
|
||||
: `this egress dials ${where}, but no byedpi instance is enabled in ${BYEDPI_CONF}, so nothing is listening on any port.`,
|
||||
}
|
||||
}
|
||||
const inst = byPort.get(port)
|
||||
if (!inst) {
|
||||
const ports = rep.instances.map((i) => `127.0.0.1:${i.port}`).join(', ') || 'no port at all'
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'port_mismatch',
|
||||
detail: `PORT MISMATCH: this egress dials ${where}, but ${BYEDPI_CONF} enables no instance on that port — the enabled instances are on ${ports}. The two packages agree only by hand: change the egress port, or the instance's, so they match.`,
|
||||
}
|
||||
}
|
||||
if (inst.listening === 'yes') {
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'ok',
|
||||
detail: `byedpi instance '${inst.section}' is enabled on ${where} and something is accepting there. The check is a connection, not a SOCKS5 handshake.`,
|
||||
}
|
||||
}
|
||||
if (inst.listening === 'no') {
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'not_listening',
|
||||
detail: `byedpi instance '${inst.section}' is enabled on ${where}, but the connection was refused — the process is not running. Start it: /etc/init.d/byedpi restart.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: e.Name,
|
||||
port,
|
||||
state: 'unknown',
|
||||
detail: `byedpi instance '${inst.section}' is enabled on ${where}, but the connection attempt neither succeeded nor was refused, so it is NOT known whether anything is listening.`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/byedpi — the ONLY endpoint that reports a listener, with the
|
||||
* per-egress cross-check filled in.
|
||||
*
|
||||
* `?mock&byedpi=noread` reproduces the daemon's own degraded answer: a 200 that
|
||||
* still carries the measured service facts, an EMPTY egresses list, and a detail
|
||||
* saying the cross-check was not performed. Withholding the service facts
|
||||
* because the model failed to load would help nobody.
|
||||
*
|
||||
* `?mock&byedpi=noanswer` makes the request FAIL. Not a body the daemon sends —
|
||||
* the point is that it sends none, which is now the only way the panel reaches
|
||||
* "nothing has been measured": the report stays null. Without a way in, the
|
||||
* sentence the page shows for it could not be looked at.
|
||||
*
|
||||
* This endpoint MEASURES — the daemon calls probeNow here, on the request a
|
||||
* human issued — so its age is 0, "just now". `?mock&byedpiage=<n>` forces a
|
||||
* slower probe (the daemon reports the seconds it actually spent in timeouts
|
||||
* rather than claiming to have been instantaneous), which is how the aged
|
||||
* rendering is reached on this page.
|
||||
*/
|
||||
export async function getByeDPI(): Promise<ByeDPIReport> {
|
||||
await wait(80)
|
||||
if (mockParam('byedpi') === 'noanswer') {
|
||||
throw new Error('byedpi readiness: request failed')
|
||||
}
|
||||
if (mockParam('byedpi') === 'noread') {
|
||||
const rep = byedpiReport(false)
|
||||
rep.detail +=
|
||||
' (the configuration could not be read, so the per-egress port cross-check was not performed: uci show shater: exit status 1)'
|
||||
return rep
|
||||
}
|
||||
return byedpiReport(true)
|
||||
}
|
||||
|
||||
// The mock daemon "started" 2 h 31 min before the page loaded — a value with both
|
||||
// an hours and a minutes part, so the two-unit uptime rendering is visible.
|
||||
const MOCK_STARTED_UNIX = Math.floor(Date.now() / 1000) - (2 * 3600 + 31 * 60)
|
||||
|
||||
+9
-129
@@ -1,6 +1,6 @@
|
||||
import './Alerts.css'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Button, Toggle, useConfirm } from '../components'
|
||||
import { Button, DetourSelect, Toggle, useConfirm } from '../components'
|
||||
import type { Alert, Model } from '../api'
|
||||
import {
|
||||
buildAlert,
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
TYPE_SWITCH_NOTE,
|
||||
} from '../alertEdit'
|
||||
import type { AlertDraft, AlertType } from '../alertEdit'
|
||||
import type { DetourCatalog } from '../detour'
|
||||
import { canonDetour, describeDetour, detourValues } from '../detour'
|
||||
|
||||
// The Alerts section — out-of-band notifications (Telegram bot / webhook) for
|
||||
// kill-switch trips, apply failures, new devices and subscription expiry. It
|
||||
@@ -72,62 +74,10 @@ function uniqueName(base: string, taken: Set<string>): string {
|
||||
return `${seed}-${i}`
|
||||
}
|
||||
|
||||
/** The live targets an alert's delivery can be pinned to (the picker). */
|
||||
interface DetourCatalog {
|
||||
groups: string[]
|
||||
chains: string[]
|
||||
egresses: { name: string; type: string }[]
|
||||
nodes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a stored `Via` to a picker option value. Empty/`direct` ⇒
|
||||
* `direct`; already-prefixed values (`group:`/`chain:`/`egress:`/`node:`) pass
|
||||
* through; a bare legacy name is resolved against the catalog so a still-valid
|
||||
* setup isn't mislabelled; anything unresolved is kept verbatim (shown stale).
|
||||
*/
|
||||
function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
|
||||
const d = (raw ?? '').trim()
|
||||
if (!d || d.toLowerCase() === 'direct') return 'direct'
|
||||
if (/^(node|group|chain|egress):/i.test(d)) return d
|
||||
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
|
||||
if (cat.groups.includes(d)) return `group:${d}`
|
||||
if (cat.chains.includes(d)) return `chain:${d}`
|
||||
if (cat.nodes.includes(d)) return `node:${d}`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Every valid option value for a catalog, including `direct`. */
|
||||
function detourValues(cat: DetourCatalog): Set<string> {
|
||||
const s = new Set<string>(['direct'])
|
||||
for (const g of cat.groups) s.add(`group:${g}`)
|
||||
for (const c of cat.chains) s.add(`chain:${c}`)
|
||||
for (const e of cat.egresses) s.add(`egress:${e.name}`)
|
||||
for (const n of cat.nodes) s.add(`node:${n}`)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Describe a canonical detour value for the row readout. */
|
||||
function describeDetour(
|
||||
canon: string,
|
||||
cat: DetourCatalog,
|
||||
valid: Set<string>,
|
||||
): { direct: boolean; prefix: string; name: string; missing: boolean } {
|
||||
if (canon === 'direct') return { direct: true, prefix: '', name: '', missing: false }
|
||||
const i = canon.indexOf(':')
|
||||
const kind = i === -1 ? '' : canon.slice(0, i)
|
||||
const name = i === -1 ? canon : canon.slice(i + 1)
|
||||
const missing = !valid.has(canon)
|
||||
let prefix = 'via'
|
||||
if (kind === 'group') prefix = 'via group'
|
||||
else if (kind === 'chain') prefix = 'via chain'
|
||||
else if (kind === 'node') prefix = 'via node'
|
||||
else if (kind === 'egress') {
|
||||
const eg = cat.egresses.find((e) => e.name === name)
|
||||
prefix = eg?.type === 'interface' ? 'via interface' : 'via egress'
|
||||
}
|
||||
return { direct: false, prefix, name, missing }
|
||||
}
|
||||
// The detour helpers (DetourCatalog, canonDetour, detourValues,
|
||||
// describeDetour) and the picker itself now live in ../detour and
|
||||
// components/DetourSelect. The note above used to say "if a third consumer
|
||||
// ever appears, promote them then" -- it did, so they were.
|
||||
|
||||
// ---- section ----------------------------------------------------------------
|
||||
|
||||
@@ -542,6 +492,7 @@ function AlertForm({
|
||||
<label className="alr-resp">
|
||||
<span className="alr-resp-label mono">Deliver via</span>
|
||||
<DetourSelect
|
||||
className="alr-select alr-detour-select"
|
||||
value={via}
|
||||
catalog={catalog}
|
||||
valid={valid}
|
||||
@@ -671,6 +622,7 @@ function AlertRow({
|
||||
<label className="alr-detour">
|
||||
<span className="alr-detour-label mono">Deliver via</span>
|
||||
<DetourSelect
|
||||
className="alr-select alr-detour-select"
|
||||
value={canon}
|
||||
catalog={catalog}
|
||||
valid={valid}
|
||||
@@ -708,78 +660,6 @@ function AlertRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** The live delivery picker: option list built from the Model's targets. */
|
||||
function DetourSelect({
|
||||
value,
|
||||
catalog,
|
||||
valid,
|
||||
busy,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
directLabel = 'Direct (no proxy)',
|
||||
}: {
|
||||
value: string // canonical value
|
||||
catalog: DetourCatalog
|
||||
valid: Set<string>
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
ariaLabel: string
|
||||
onChange: (v: string) => void
|
||||
directLabel?: string
|
||||
}) {
|
||||
const missing = value !== 'direct' && !valid.has(value)
|
||||
return (
|
||||
<select
|
||||
className="alr-select alr-detour-select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={busy || disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<option value="direct">{directLabel}</option>
|
||||
{catalog.groups.length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{catalog.groups.map((g) => (
|
||||
<option key={g} value={`group:${g}`}>
|
||||
Group {g} (balancer)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.chains.length > 0 && (
|
||||
<optgroup label="Chains">
|
||||
{catalog.chains.map((c) => (
|
||||
<option key={c} value={`chain:${c}`}>
|
||||
Chain {c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.egresses.length > 0 && (
|
||||
<optgroup label="Interfaces / egresses">
|
||||
{catalog.egresses.map((e) => (
|
||||
<option key={e.name} value={`egress:${e.name}`}>
|
||||
Interface/egress {e.name}
|
||||
{e.type ? ` (${e.type})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.nodes.length > 0 && (
|
||||
<optgroup label="Nodes">
|
||||
{catalog.nodes.map((n) => (
|
||||
<option key={n} value={`node:${n}`}>
|
||||
Node {n}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{missing && <option value={value}>{value} (missing)</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyPlate({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="alr-empty">
|
||||
|
||||
@@ -135,12 +135,75 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
/* Which layer of config the field edits. Constant, not state-dependent: this
|
||||
control always writes `config globals`, whether or not a profile is currently
|
||||
overriding it. A label that appeared only when overridden would read as an
|
||||
alert instead of a scope. */
|
||||
.dns-scope {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--groove);
|
||||
border-radius: 2px;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: var(--track-label, 0.18em);
|
||||
color: var(--faint);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dns-readout-item dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Column so the "in effect now" line can sit under its own control rather
|
||||
than beside it — the field stays the thing you edit, the line stays a
|
||||
readout. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* ---- "in effect now": stored value ≠ value the engine takes ---- */
|
||||
.dns-ineffect {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dns-ineffect-turn {
|
||||
color: var(--groove);
|
||||
}
|
||||
.dns-ineffect-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--track-label, 0.18em);
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
/* The live value. `--led-on` is the page's existing word for "this is what is
|
||||
actually happening" (see .dns-path-name) — semantic, never the brand accent. */
|
||||
.dns-ineffect-value {
|
||||
font-weight: 600;
|
||||
color: var(--led-on);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dns-ineffect-src {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--dim);
|
||||
}
|
||||
/* The verdict was computed against a different stored value than the one in the
|
||||
field — amber, because it is a real "these two are not talking about the same
|
||||
config" and the fix is to save. Not crit: nothing is broken, it is one save
|
||||
behind. */
|
||||
.dns-ineffect-stale {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ---- known-lists quick add ---- */
|
||||
@@ -596,6 +659,11 @@
|
||||
padding: 4px 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
/* The list-fetch picker shares the readout row but carries route labels
|
||||
("Interface/egress wan (interface)"), which do not survive 12rem. */
|
||||
.dns-readout-select--wide {
|
||||
max-width: 17rem;
|
||||
}
|
||||
|
||||
/* the per-resolver DNS-path picker sits inline in the row */
|
||||
.dns-detour {
|
||||
|
||||
+222
-144
@@ -1,9 +1,10 @@
|
||||
import './DNS.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, CatSuggest, Led, SrcPicker, Toggle, useConfirm } from '../components'
|
||||
import { Button, CatSuggest, DetourSelect, Led, SrcPicker, Toggle, useConfirm } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getConfig,
|
||||
getEffectiveConfig,
|
||||
getRulesetStatus,
|
||||
putConfig,
|
||||
updateRuleset as apiUpdateRuleset,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
Blocklist,
|
||||
Complete,
|
||||
DNSRule,
|
||||
EffectiveConfig,
|
||||
Model,
|
||||
Resolver,
|
||||
RulesetStatus,
|
||||
@@ -32,6 +34,10 @@ import {
|
||||
} from '../dnsListEdit'
|
||||
import { everyLabel, relFetch } from '../format'
|
||||
import { attachedDeviceNames, dnsFilterOffNote, listRowState } from '../deviceLists'
|
||||
import type { DetourCatalog } from '../detour'
|
||||
import { canonDetour, catalogOf, describeDetour, detourValues } from '../detour'
|
||||
import { effectiveLine } from '../effectiveView'
|
||||
import type { EffectiveField, EffectiveLine } from '../effectiveView'
|
||||
|
||||
// The DNS / Blocklists page is a thin editor over the desired-state Model —
|
||||
// exactly like Nodes.tsx. Every edit rewrites the relevant slice in-place, PUTs
|
||||
@@ -129,62 +135,9 @@ const RESOLVER_TYPES: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: 'fakeip', label: 'Fake-IP' },
|
||||
]
|
||||
|
||||
/** The live targets a resolver's DNS traffic can be pinned to (the picker). */
|
||||
interface DetourCatalog {
|
||||
groups: string[]
|
||||
chains: string[]
|
||||
egresses: { name: string; type: string }[]
|
||||
nodes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a stored `Detour` to a picker option value. Empty/`direct` ⇒
|
||||
* `direct`; already-prefixed values (`group:`/`chain:`/`egress:`/`node:`) pass
|
||||
* through; a bare legacy name is resolved against the catalog so a still-valid
|
||||
* setup isn't mislabelled; anything unresolved is kept verbatim (shown stale).
|
||||
*/
|
||||
function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
|
||||
const d = (raw ?? '').trim()
|
||||
if (!d || d.toLowerCase() === 'direct') return 'direct'
|
||||
if (/^(node|group|chain|egress):/i.test(d)) return d
|
||||
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
|
||||
if (cat.groups.includes(d)) return `group:${d}`
|
||||
if (cat.chains.includes(d)) return `chain:${d}`
|
||||
if (cat.nodes.includes(d)) return `node:${d}`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Every valid option value for a catalog, including `direct`. */
|
||||
function detourValues(cat: DetourCatalog): Set<string> {
|
||||
const s = new Set<string>(['direct'])
|
||||
for (const g of cat.groups) s.add(`group:${g}`)
|
||||
for (const c of cat.chains) s.add(`chain:${c}`)
|
||||
for (const e of cat.egresses) s.add(`egress:${e.name}`)
|
||||
for (const n of cat.nodes) s.add(`node:${n}`)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Describe a canonical detour value for the row readout. */
|
||||
function describeDetour(
|
||||
canon: string,
|
||||
cat: DetourCatalog,
|
||||
valid: Set<string>,
|
||||
): { direct: boolean; prefix: string; name: string; missing: boolean } {
|
||||
if (canon === 'direct') return { direct: true, prefix: '', name: '', missing: false }
|
||||
const i = canon.indexOf(':')
|
||||
const kind = i === -1 ? '' : canon.slice(0, i)
|
||||
const name = i === -1 ? canon : canon.slice(i + 1)
|
||||
const missing = !valid.has(canon)
|
||||
let prefix = 'via'
|
||||
if (kind === 'group') prefix = 'via group'
|
||||
else if (kind === 'chain') prefix = 'via chain'
|
||||
else if (kind === 'node') prefix = 'via node'
|
||||
else if (kind === 'egress') {
|
||||
const eg = cat.egresses.find((e) => e.name === name)
|
||||
prefix = eg?.type === 'interface' ? 'via interface' : 'via egress'
|
||||
}
|
||||
return { direct: false, prefix, name, missing }
|
||||
}
|
||||
// The detour vocabulary (DetourCatalog / canonDetour / detourValues /
|
||||
// describeDetour) and the <select> that renders it now live in ../detour and
|
||||
// components/DetourSelect — one copy, shared with Nodes, Alerts and Profiles.
|
||||
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
@@ -206,6 +159,41 @@ export default function DNS() {
|
||||
void loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
// ---- what the ENGINE takes, computed by the daemon ------------------------
|
||||
//
|
||||
// GET /api/config/effective, fetched beside the config and re-fetched after
|
||||
// every PUT and every apply. It is a SEPARATE request because the panel PUTs
|
||||
// /api/config's body back verbatim and that decoder has DisallowUnknownFields,
|
||||
// so a derived field there would 400 the whole write.
|
||||
//
|
||||
// The verdict is computed from the config ON DISK. Typing into a globals field
|
||||
// therefore does not move the "in effect now" line until the edit is saved —
|
||||
// which is right: until then the engine really is still using the profile's
|
||||
// value. There is deliberately no client-side recompute to "fix" that; the
|
||||
// recompute was the bug (a second copy of the rule, free to drift), and it has
|
||||
// been deleted.
|
||||
//
|
||||
// A failed fetch is NOT read as "nothing is overridden": `effective` stays
|
||||
// null and every row reports an unknown instead. See ../effectiveView.
|
||||
const [effective, setEffective] = useState<EffectiveConfig | null>(null)
|
||||
const [effError, setEffError] = useState<string | null>(null)
|
||||
const [effPending, setEffPending] = useState(true)
|
||||
const loadEffective = useCallback(async () => {
|
||||
setEffPending(true)
|
||||
try {
|
||||
setEffective(await getEffectiveConfig())
|
||||
setEffError(null)
|
||||
} catch (e) {
|
||||
setEffective(null)
|
||||
setEffError(errText(e))
|
||||
} finally {
|
||||
setEffPending(false)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
void loadEffective()
|
||||
}, [loadEffective])
|
||||
|
||||
// ---- toast + persistent apply banner --------------------------------------
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const toastTimer = useRef<number | undefined>(undefined)
|
||||
@@ -233,6 +221,9 @@ export default function DNS() {
|
||||
await putConfig(next)
|
||||
setDirty(true)
|
||||
flash(okMsg)
|
||||
// The PUT changed what is on disk, which is what the effective verdict is
|
||||
// computed from — so the verdict is now one request old. Re-ask.
|
||||
void loadEffective()
|
||||
return true
|
||||
} catch (e) {
|
||||
setConfig(prev) // revert the optimistic edit
|
||||
@@ -242,7 +233,7 @@ export default function DNS() {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[config, flash],
|
||||
[config, flash, loadEffective],
|
||||
)
|
||||
|
||||
const applyNow = useCallback(async () => {
|
||||
@@ -255,13 +246,17 @@ export default function DNS() {
|
||||
setDirty(false)
|
||||
flash(r.changed ? 'Applied — data plane reconciled' : 'Applied — already up to date')
|
||||
void loadConfig()
|
||||
// An apply can re-pin the active profile (the WAN watcher writes
|
||||
// Globals.ActiveProfile), so every one of the four verdicts can move
|
||||
// without any field on this page changing.
|
||||
void loadEffective()
|
||||
}
|
||||
} catch (e) {
|
||||
flash(`Apply failed — ${errText(e)}`)
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [flash, loadConfig])
|
||||
}, [flash, loadConfig, loadEffective])
|
||||
|
||||
// ---- derived slices -------------------------------------------------------
|
||||
const globals = config?.Globals
|
||||
@@ -360,31 +355,48 @@ export default function DNS() {
|
||||
const alNames = useMemo(() => new Set(allowlists.map((a) => a.Name)), [allowlists])
|
||||
|
||||
// Live detour catalog — the picker's option list is built from the Model.
|
||||
const detourCatalog = useMemo<DetourCatalog>(
|
||||
() => ({
|
||||
groups: asArray(config?.Groups).map((g) => g.Name),
|
||||
chains: asArray(config?.Chains as { Name: string }[] | null | undefined).map((c) => c.Name),
|
||||
egresses: asArray(config?.Egresses).map((e) => ({ name: e.Name, type: e.Type })),
|
||||
nodes: asArray(config?.Nodes).map((n) => n.Name),
|
||||
}),
|
||||
[config],
|
||||
)
|
||||
const detourCatalog = useMemo<DetourCatalog>(() => catalogOf(config ?? {}), [config])
|
||||
const detourValid = useMemo(() => detourValues(detourCatalog), [detourCatalog])
|
||||
const resolverNames = useMemo(() => new Set(resolvers.map((r) => r.Name)), [resolvers])
|
||||
|
||||
// ---- stored here, possibly overridden by the active profile ---------------
|
||||
//
|
||||
// The three resolver slots and the list-fetch route are all Globals scalars a
|
||||
// profile may override, so the value in the field is the value SAVED, not
|
||||
// necessarily the value running. This page used to print the saved one as the
|
||||
// answer, which on the production router read `local` while the engine —
|
||||
// under the profile the WAN watcher had pinned — resolved endpoints through
|
||||
// `yandex`. Each row now carries a separate "in effect now" line whenever the
|
||||
// two differ, and stays quiet when they agree.
|
||||
//
|
||||
// The verdict comes from the DAEMON (`effective` above). It is not recomputed
|
||||
// here — that copy existed for one wave and is deleted.
|
||||
const fetchCanon = canonDetour(globals?.FetchDetour, detourCatalog)
|
||||
const lineFor = useCallback(
|
||||
(field: EffectiveField, onScreen: string | undefined): EffectiveLine =>
|
||||
effectiveLine(effective, field, onScreen, effPending),
|
||||
[effective, effPending],
|
||||
)
|
||||
|
||||
// A fake-IP resolver in the default or fallback slot silently disables failover:
|
||||
// the engine refuses to `evaluate` past a fake-IP server, so the chain never
|
||||
// forms. Name whichever slot is affected so the fix is obvious.
|
||||
//
|
||||
// Read from the EFFECTIVE values where there ARE any: a profile that swaps a
|
||||
// plain resolver in front of a fake-IP one has removed this problem, and a
|
||||
// profile that swaps a fake-IP one in has created it. With no reading, fall
|
||||
// back to the stored names — a warning about the saved config is worth more
|
||||
// than no warning, and the two agree except under an override.
|
||||
const fakeipRole = useMemo<string | null>(() => {
|
||||
const isFake = (name: string | undefined) =>
|
||||
!!name && resolvers.some((r) => r.Name === name && r.Type === 'fakeip')
|
||||
const d = isFake(globals?.ResolverDefault)
|
||||
const f = isFake(globals?.ResolverFallback)
|
||||
const d = isFake(effective?.ResolverDefault.Value ?? globals?.ResolverDefault)
|
||||
const f = isFake(effective?.ResolverFallback.Value ?? globals?.ResolverFallback)
|
||||
if (d && f) return 'default and fallback'
|
||||
if (d) return 'default'
|
||||
if (f) return 'fallback'
|
||||
return null
|
||||
}, [resolvers, globals?.ResolverDefault, globals?.ResolverFallback])
|
||||
}, [resolvers, effective, globals?.ResolverDefault, globals?.ResolverFallback])
|
||||
|
||||
const busy = saving || applying
|
||||
|
||||
@@ -711,6 +723,21 @@ export default function DNS() {
|
||||
[config, save],
|
||||
)
|
||||
|
||||
// Where list DATA is fetched from — blocklists, allowlists, rule-sets and the
|
||||
// geo .srs files. `direct` is stored as the empty string so an untouched
|
||||
// config keeps the shape it has always had.
|
||||
const setFetchDetour = useCallback(
|
||||
(v: string) => {
|
||||
if (!config) return
|
||||
const next = v === 'direct' ? '' : v
|
||||
void save(
|
||||
{ ...config, Globals: { ...config.Globals, FetchDetour: next } },
|
||||
next ? `List fetches → ${next}` : 'List fetches → direct',
|
||||
)
|
||||
},
|
||||
[config, save],
|
||||
)
|
||||
|
||||
// ---- DNS-rule mutations ---------------------------------------------------
|
||||
// Rules are held sorted by ascending Order (first match wins). A DNS rule has no
|
||||
// Name in the contract, so list POSITION is its only handle — every mutation
|
||||
@@ -802,42 +829,82 @@ export default function DNS() {
|
||||
</div>
|
||||
<dl className="dns-readout">
|
||||
<div className="dns-readout-item">
|
||||
<dt>default resolver</dt>
|
||||
<dt>
|
||||
default resolver <span className="dns-scope">globals</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<RoleSelect
|
||||
value={globals?.ResolverDefault ?? ''}
|
||||
names={resolverNames}
|
||||
busy={busy}
|
||||
disabled={!config}
|
||||
ariaLabel="Default resolver"
|
||||
ariaLabel="Default resolver (globals)"
|
||||
onChange={setResolverDefault}
|
||||
/>
|
||||
<InEffectLine
|
||||
line={lineFor('ResolverDefault', globals?.ResolverDefault)}
|
||||
what="Default resolver"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="dns-readout-item">
|
||||
<dt>fallback</dt>
|
||||
<dt>
|
||||
fallback <span className="dns-scope">globals</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<RoleSelect
|
||||
value={globals?.ResolverFallback ?? ''}
|
||||
names={resolverNames}
|
||||
busy={busy}
|
||||
disabled={!config}
|
||||
ariaLabel="Fallback resolver"
|
||||
ariaLabel="Fallback resolver (globals)"
|
||||
onChange={setResolverFallback}
|
||||
/>
|
||||
<InEffectLine
|
||||
line={lineFor('ResolverFallback', globals?.ResolverFallback)}
|
||||
what="Fallback resolver"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="dns-readout-item">
|
||||
<dt>endpoint resolver</dt>
|
||||
<dt>
|
||||
endpoint resolver <span className="dns-scope">globals</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<RoleSelect
|
||||
value={globals?.EndpointResolver ?? ''}
|
||||
names={resolverNames}
|
||||
busy={busy}
|
||||
disabled={!config}
|
||||
ariaLabel="Endpoint resolver"
|
||||
ariaLabel="Endpoint resolver (globals)"
|
||||
onChange={setEndpointResolver}
|
||||
/>
|
||||
<InEffectLine
|
||||
line={lineFor('EndpointResolver', globals?.EndpointResolver)}
|
||||
what="Endpoint resolver"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="dns-readout-item">
|
||||
<dt>
|
||||
list fetch route <span className="dns-scope">globals</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<DetourSelect
|
||||
className="dns-select dns-readout-select dns-readout-select--wide"
|
||||
value={fetchCanon}
|
||||
catalog={detourCatalog}
|
||||
valid={detourValid}
|
||||
busy={busy}
|
||||
disabled={!config}
|
||||
ariaLabel="List fetch route (globals)"
|
||||
directLabel="Direct — plain WAN"
|
||||
onChange={setFetchDetour}
|
||||
/>
|
||||
<InEffectLine
|
||||
line={lineFor('FetchDetour', globals?.FetchDetour)}
|
||||
what="List fetch route"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -847,6 +914,41 @@ export default function DNS() {
|
||||
<span className="mono"> none</span> to use the engine default; point it at a plain,
|
||||
direct resolver so it can bootstrap before any tunnel is up.
|
||||
</p>
|
||||
<p className="dns-filter-sub dns-filter-note">
|
||||
The list fetch route carries the DOWNLOADS — every blocklist, allowlist, rule-set and
|
||||
geoip/geosite file. Send them through a tunnel when the uplink blocks the hosts they
|
||||
live on; a list that can’t be downloaded isn’t applied, and the engine says so by name
|
||||
instead of failing the apply. Each subscription still has its own fetch route on the
|
||||
Nodes page.
|
||||
</p>
|
||||
{/* Three states, not two. "A profile is active and changed none of
|
||||
these" and "no profile is active" are different facts, and the
|
||||
endpoint reports ActiveProfile independently of whether it
|
||||
overrode anything — so the page can say which. The third is "we
|
||||
could not ask", which must not be drawn as either of the other
|
||||
two: the whole premise here is that stored and effective can
|
||||
differ, so a panel that goes quiet when it cannot measure is the
|
||||
original defect with better manners. */}
|
||||
{effError !== null ? (
|
||||
<p className="dns-filter-sub dns-filter-note">
|
||||
Couldn’t read the effective values — {effError}. The fields above show what is
|
||||
STORED; if a profile is overriding any of them, this panel can’t currently tell you
|
||||
which.{' '}
|
||||
<button className="linkish" onClick={() => void loadEffective()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
) : effective && effective.ActiveProfile !== '' ? (
|
||||
<p className="dns-filter-sub dns-filter-note">
|
||||
Profile <span className="mono">{effective.ActiveProfile}</span> is active. It can
|
||||
override any of the four above — where it does, the value in effect is named under
|
||||
the field.{' '}
|
||||
<a className="linkish" href="#/profiles">
|
||||
Edit its overrides
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1754,75 +1856,49 @@ function ListRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** The live DNS-path picker: option list built from the Model's targets. */
|
||||
function DetourSelect({
|
||||
value,
|
||||
catalog,
|
||||
valid,
|
||||
busy,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
directLabel = 'Direct (no proxy)',
|
||||
}: {
|
||||
value: string // canonical value
|
||||
catalog: DetourCatalog
|
||||
valid: Set<string>
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
ariaLabel: string
|
||||
onChange: (v: string) => void
|
||||
directLabel?: string
|
||||
}) {
|
||||
const missing = value !== 'direct' && !valid.has(value)
|
||||
/**
|
||||
* The one line that stops this card lying: what the engine takes RIGHT NOW,
|
||||
* when the active profile overrides the stored value in the field above.
|
||||
*
|
||||
* Rendered only on a difference. A permanent "in effect now: <same thing>"
|
||||
* under every field would be noise, and noise is how the one row that matters
|
||||
* gets skipped — which is exactly what happened before it existed, with no row
|
||||
* at all.
|
||||
*
|
||||
* Informational, not alarming: a profile overriding a global is the feature
|
||||
* working, not a fault. No crit/amber tokens here, no LED — a turnstile glyph,
|
||||
* the value, and the profile that decided it, linked to where it is edited.
|
||||
*/
|
||||
function InEffectLine({ line, what }: { line: EffectiveLine; what: string }) {
|
||||
// `unknown` draws nothing HERE: the card carries one retry line for the whole
|
||||
// group rather than four copies of the same failure under four fields.
|
||||
if (line.kind !== 'in-effect') return null
|
||||
const shown = line.value || 'none'
|
||||
return (
|
||||
<select
|
||||
className="dns-select dns-detour-select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={busy || disabled}
|
||||
aria-label={ariaLabel}
|
||||
<p
|
||||
className="dns-ineffect"
|
||||
title={
|
||||
line.stale
|
||||
? `${what} in effect now: ${shown} (profile ${line.profile}). This verdict was computed from the SAVED config, which no longer matches the value in the field — save and apply to bring them back together.`
|
||||
: `${what} in effect now: ${shown} (profile ${line.profile})`
|
||||
}
|
||||
>
|
||||
<option value="direct">{directLabel}</option>
|
||||
{catalog.groups.length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{catalog.groups.map((g) => (
|
||||
<option key={g} value={`group:${g}`}>
|
||||
Group {g} (balancer)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.chains.length > 0 && (
|
||||
<optgroup label="Chains">
|
||||
{catalog.chains.map((c) => (
|
||||
<option key={c} value={`chain:${c}`}>
|
||||
Chain {c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.egresses.length > 0 && (
|
||||
<optgroup label="Interfaces / egresses">
|
||||
{catalog.egresses.map((e) => (
|
||||
<option key={e.name} value={`egress:${e.name}`}>
|
||||
Interface/egress {e.name}
|
||||
{e.type ? ` (${e.type})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.nodes.length > 0 && (
|
||||
<optgroup label="Nodes">
|
||||
{catalog.nodes.map((n) => (
|
||||
<option key={n} value={`node:${n}`}>
|
||||
Node {n}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{missing && <option value={value}>{value} (missing)</option>}
|
||||
</select>
|
||||
<span className="dns-ineffect-turn" aria-hidden="true">
|
||||
↳
|
||||
</span>
|
||||
<span className="dns-ineffect-label mono">in effect now</span>
|
||||
<span className="dns-ineffect-value mono">{shown}</span>
|
||||
<span className="dns-ineffect-src">
|
||||
profile{' '}
|
||||
<a className="linkish" href="#/profiles">
|
||||
{line.profile}
|
||||
</a>
|
||||
</span>
|
||||
{/* The daemon reports which stored value it judged. When that is not the
|
||||
value in the input above, the row would otherwise pair a fresh field
|
||||
with an old verdict and let it read as one statement. */}
|
||||
{line.stale && <span className="dns-ineffect-stale">· judged the saved config</span>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1959,6 +2035,7 @@ function ResolverFields({
|
||||
<label className="dns-resp">
|
||||
<span className="dns-resp-label mono">DNS path</span>
|
||||
<DetourSelect
|
||||
className="dns-select dns-detour-select"
|
||||
value={draft.detour}
|
||||
catalog={catalog}
|
||||
valid={valid}
|
||||
@@ -2246,6 +2323,7 @@ function ResolverRow({
|
||||
<label className="dns-detour">
|
||||
<span className="dns-detour-label mono">DNS path</span>
|
||||
<DetourSelect
|
||||
className="dns-select dns-detour-select"
|
||||
value={canon}
|
||||
catalog={catalog}
|
||||
valid={valid}
|
||||
|
||||
+110
-119
@@ -2,10 +2,11 @@ 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, useConfirm } from '../components'
|
||||
import { Button, DetourSelect, Led, Toggle, useConfirm } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getConfig,
|
||||
getEffectiveConfig,
|
||||
getGroupsTest,
|
||||
getStatus,
|
||||
postGroupsTest,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
ApiError,
|
||||
} from '../api'
|
||||
import type {
|
||||
EffectiveOverride,
|
||||
GroupTestResult,
|
||||
Model,
|
||||
Node as NodeCfg,
|
||||
@@ -35,10 +37,13 @@ import {
|
||||
} from '../testResult'
|
||||
import type { NodeTestRefusal, RowOrigin } from '../testResult'
|
||||
import { fetchBadge, loudest, otherFindings } from '../subFetch'
|
||||
import type { DetourCatalog } from '../detour'
|
||||
import { canonDetour, catalogOf, detourValues } from '../detour'
|
||||
import type { HeaderRow, SubForm } from '../subEdit'
|
||||
import {
|
||||
SUB_FORMATS,
|
||||
SUB_PROTOS,
|
||||
classifyFetchVia,
|
||||
nextSubscription,
|
||||
parseExpireAlert,
|
||||
proxyFetchGoesDirect,
|
||||
@@ -465,106 +470,9 @@ interface NodeGroupData {
|
||||
// A named refusal the operator can read is a state worth offering; a hidden
|
||||
// capability is not.
|
||||
|
||||
/** The live targets a sub's proxy fetch can be pinned to (the picker options). */
|
||||
interface DetourCatalog {
|
||||
groups: string[]
|
||||
chains: string[]
|
||||
egresses: { name: string; type: string }[]
|
||||
nodes: string[]
|
||||
}
|
||||
|
||||
/** Normalise a stored `FetchDetour` to a picker option value (empty ⇒ `direct`). */
|
||||
function canonDetour(raw: string | undefined, cat: DetourCatalog): string {
|
||||
const d = (raw ?? '').trim()
|
||||
if (!d || d.toLowerCase() === 'direct') return 'direct'
|
||||
if (/^(node|group|chain|egress):/i.test(d)) return d
|
||||
if (cat.egresses.some((e) => e.name === d)) return `egress:${d}`
|
||||
if (cat.groups.includes(d)) return `group:${d}`
|
||||
if (cat.chains.includes(d)) return `chain:${d}`
|
||||
if (cat.nodes.includes(d)) return `node:${d}`
|
||||
return d
|
||||
}
|
||||
|
||||
/** Every valid option value for a catalog, including `direct`. */
|
||||
function detourValues(cat: DetourCatalog): Set<string> {
|
||||
const s = new Set<string>(['direct'])
|
||||
for (const g of cat.groups) s.add(`group:${g}`)
|
||||
for (const c of cat.chains) s.add(`chain:${c}`)
|
||||
for (const e of cat.egresses) s.add(`egress:${e.name}`)
|
||||
for (const n of cat.nodes) s.add(`node:${n}`)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Detour picker: Direct + groups / chains / interfaces-egresses / nodes. */
|
||||
function DetourSelect({
|
||||
value,
|
||||
catalog,
|
||||
valid,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string // canonical value
|
||||
catalog: DetourCatalog
|
||||
valid: Set<string>
|
||||
disabled: boolean
|
||||
ariaLabel: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const missing = value !== 'direct' && !valid.has(value)
|
||||
return (
|
||||
<select
|
||||
className="fp-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* Under FetchVia=proxy this option is not "no preference" — it is the
|
||||
plain WAN, with the real address. The label says so rather than leaving
|
||||
the word `Direct` to be read as a default. */}
|
||||
<option value="direct">Direct — plain WAN, real address</option>
|
||||
{catalog.groups.length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{catalog.groups.map((g) => (
|
||||
<option key={g} value={`group:${g}`}>
|
||||
Group {g} (balancer)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.chains.length > 0 && (
|
||||
<optgroup label="Chains">
|
||||
{catalog.chains.map((c) => (
|
||||
<option key={c} value={`chain:${c}`}>
|
||||
Chain {c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.egresses.length > 0 && (
|
||||
<optgroup label="Interfaces / egresses">
|
||||
{catalog.egresses.map((e) => (
|
||||
<option key={e.name} value={`egress:${e.name}`}>
|
||||
Interface/egress {e.name}
|
||||
{e.type ? ` (${e.type})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{catalog.nodes.length > 0 && (
|
||||
<optgroup label="Nodes">
|
||||
{catalog.nodes.map((n) => (
|
||||
<option key={n} value={`node:${n}`}>
|
||||
Node {n}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{missing && <option value={value}>{value} (missing)</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
// The four helpers this file used to keep locally (DetourCatalog, canonDetour,
|
||||
// detourValues and the <select> itself) now live in ../detour and
|
||||
// components/DetourSelect, shared with DNS, Alerts and Profiles.
|
||||
|
||||
// ---- page ------------------------------------------------------------------
|
||||
|
||||
@@ -734,6 +642,26 @@ export default function Nodes() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
// What a subscription with NO `fetch_via` inherits — GET /api/config/effective,
|
||||
// the DAEMON's answer. Schema v3 made the absent option mean "the general fetch
|
||||
// route decides" rather than `direct`, and that route can itself be overridden
|
||||
// by the active profile, so the picker's third state has to NAME a value this
|
||||
// page is in no position to compute. `null` until it arrives, or if it cannot
|
||||
// be read: the option then says it does not know rather than naming a route
|
||||
// that might be the wrong one.
|
||||
const [generalFetch, setGeneralFetch] = useState<EffectiveOverride | null>(null)
|
||||
const loadEffective = useCallback(async () => {
|
||||
try {
|
||||
setGeneralFetch((await getEffectiveConfig()).FetchDetour)
|
||||
} catch {
|
||||
// Older daemon, or the read failed. The picker says it does not know.
|
||||
setGeneralFetch(null)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
void loadEffective()
|
||||
}, [loadEffective])
|
||||
|
||||
/**
|
||||
* Optimistically apply `next` to the UI, PUT the whole Model, and mark the
|
||||
* config dirty so the Apply banner shows. Reverts on failure.
|
||||
@@ -747,6 +675,9 @@ export default function Nodes() {
|
||||
await putConfig(next)
|
||||
setDirty(true)
|
||||
flash(okMsg)
|
||||
// The general fetch route lives in globals and is judged from disk, so a
|
||||
// PUT can move what an unset `fetch_via` inherits. Re-ask.
|
||||
void loadEffective()
|
||||
return true
|
||||
} catch (e) {
|
||||
setConfig(prev) // revert the optimistic edit
|
||||
@@ -756,7 +687,7 @@ export default function Nodes() {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[config, flash],
|
||||
[config, flash, loadEffective],
|
||||
)
|
||||
|
||||
const applyNow = useCallback(async () => {
|
||||
@@ -769,6 +700,9 @@ export default function Nodes() {
|
||||
setDirty(false)
|
||||
flash(r.changed ? 'Applied — data plane reconciled' : 'Applied — already up to date')
|
||||
void loadConfig()
|
||||
// An apply can re-pin the active profile, which moves the general fetch
|
||||
// route without any field on this page changing.
|
||||
void loadEffective()
|
||||
}
|
||||
} catch (e) {
|
||||
flash(`Apply failed — ${errText(e)}`)
|
||||
@@ -778,7 +712,7 @@ export default function Nodes() {
|
||||
// ones the operator just fixed.
|
||||
void loadFindings()
|
||||
}
|
||||
}, [flash, loadConfig, loadFindings])
|
||||
}, [flash, loadConfig, loadFindings, loadEffective])
|
||||
|
||||
// ---- node mutations -------------------------------------------------------
|
||||
const nodes = useMemo(() => asArray(config?.Nodes), [config])
|
||||
@@ -786,17 +720,21 @@ export default function Nodes() {
|
||||
const subsOn = subs.filter((s) => s.Enabled).length
|
||||
|
||||
// Live detour catalog for the proxy-fetch picker — built from the Model.
|
||||
const detourCatalog = useMemo<DetourCatalog>(
|
||||
() => ({
|
||||
groups: asArray(config?.Groups).map((g) => g.Name),
|
||||
chains: asArray(config?.Chains).map((c) => c.Name),
|
||||
egresses: asArray(config?.Egresses).map((e) => ({ name: e.Name, type: e.Type })),
|
||||
nodes: nodes.map((n) => n.Name),
|
||||
}),
|
||||
[config, nodes],
|
||||
)
|
||||
const detourCatalog = useMemo<DetourCatalog>(() => catalogOf(config ?? {}), [config])
|
||||
const detourValid = useMemo(() => detourValues(detourCatalog), [detourCatalog])
|
||||
|
||||
// What a subscription with NO `fetch_via` inherits. Schema v3 made the absent
|
||||
// option mean "the general fetch route decides" rather than `direct`, so the
|
||||
// picker's third state has to NAME the value it defers to — an option reading
|
||||
// "not set" over a route that quietly moves the feed into a tunnel is the same
|
||||
// silence this whole change is undoing.
|
||||
//
|
||||
// It is the DAEMON's answer (GET /api/config/effective), not a local
|
||||
// recomputation, because the general route can be overridden by the active
|
||||
// profile and this page must not grow a second copy of that rule. `null` until
|
||||
// it arrives, or if it cannot be read — the option then names no route rather
|
||||
// than naming the wrong one.
|
||||
|
||||
// Egress names for the per-node "dial via egress" picker. Empty ⇒ the control is
|
||||
// hidden entirely (nothing to pick from).
|
||||
const egressNames = useMemo(() => asArray(config?.Egresses).map((e) => e.Name), [config])
|
||||
@@ -1513,6 +1451,7 @@ export default function Nodes() {
|
||||
busy={busy}
|
||||
catalog={detourCatalog}
|
||||
valid={detourValid}
|
||||
generalFetch={generalFetch}
|
||||
findings={subFindings.get(s.Name) ?? EMPTY_FINDINGS}
|
||||
onToggle={(on) => toggleSub(i, on)}
|
||||
onDelete={() => removeSub(i)}
|
||||
@@ -2120,6 +2059,7 @@ function SubRow({
|
||||
busy,
|
||||
catalog,
|
||||
valid,
|
||||
generalFetch,
|
||||
findings,
|
||||
onToggle,
|
||||
onDelete,
|
||||
@@ -2131,6 +2071,8 @@ function SubRow({
|
||||
busy: boolean
|
||||
catalog: DetourCatalog
|
||||
valid: Set<string>
|
||||
/** What an unset `fetch_via` defers to — passed down to the options editor. */
|
||||
generalFetch: EffectiveOverride | null
|
||||
/** What the last apply said about THIS subscription; empty when it said nothing. */
|
||||
findings: StatusWarning[]
|
||||
onToggle: (on: boolean) => void
|
||||
@@ -2252,6 +2194,7 @@ function SubRow({
|
||||
busy={busy}
|
||||
catalog={catalog}
|
||||
valid={valid}
|
||||
generalFetch={generalFetch}
|
||||
onEdit={onEdit}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
@@ -2369,10 +2312,30 @@ function SubAccount({ sub }: { sub: Subscription }) {
|
||||
// which survive a restart precisely so they are readable when a refetch is
|
||||
// impossible — cannot be cleared by a form that has no control for them.
|
||||
|
||||
const FETCH_VIA: { value: string; label: string }[] = [
|
||||
{ value: 'direct', label: 'Direct' },
|
||||
{ value: 'proxy', label: 'Proxy (through the tunnel)' },
|
||||
]
|
||||
// `fetch_via` has THREE states in schema v3, and the third one is spelled by the
|
||||
// option being absent. It is listed first because it is what a new subscription
|
||||
// gets, and it is the only one whose meaning lives somewhere else — so it names
|
||||
// that somewhere else instead of saying "default".
|
||||
//
|
||||
// `Direct` is deliberately NOT worded as "no proxy". It is a decision that
|
||||
// outranks the general route: a feed marked Direct stays in the clear on the day
|
||||
// the operator sends every other fetch through a tunnel.
|
||||
//
|
||||
// `general` is the daemon's reading, or null when there is none. With no reading
|
||||
// the option says so instead of guessing a route: naming the wrong one is worse
|
||||
// than naming none, and the two are indistinguishable to whoever reads it.
|
||||
function fetchViaOptions(general: EffectiveOverride | null): { value: string; label: string }[] {
|
||||
let inherits = 'route unknown — couldn’t read the general setting'
|
||||
if (general) {
|
||||
const route = general.Value || 'direct'
|
||||
inherits = general.Profile ? `${route}, from profile ${general.Profile}` : route
|
||||
}
|
||||
return [
|
||||
{ value: '', label: `Not set — use the general fetch route (${inherits})` },
|
||||
{ value: 'direct', label: 'Direct — always in the clear, whatever the general route is' },
|
||||
{ value: 'proxy', label: 'Proxy — through this subscription’s own detour' },
|
||||
]
|
||||
}
|
||||
|
||||
function SubOptions({
|
||||
id,
|
||||
@@ -2380,6 +2343,7 @@ function SubOptions({
|
||||
busy,
|
||||
catalog,
|
||||
valid,
|
||||
generalFetch,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
@@ -2388,6 +2352,8 @@ function SubOptions({
|
||||
busy: boolean
|
||||
catalog: DetourCatalog
|
||||
valid: Set<string>
|
||||
/** What an unset `fetch_via` defers to — named in the picker's first option. */
|
||||
generalFetch: EffectiveOverride | null
|
||||
onEdit: (patch: Subscription) => Promise<boolean>
|
||||
onUpdate: (name: string) => Promise<void>
|
||||
}) {
|
||||
@@ -2424,7 +2390,9 @@ function SubOptions({
|
||||
}
|
||||
}, [onUpdate, sub.Name])
|
||||
|
||||
const proxy = draft.FetchVia === 'proxy'
|
||||
const viaMode = classifyFetchVia(draft.FetchVia)
|
||||
const proxy = viaMode === 'proxy'
|
||||
const viaOptions = useMemo(() => fetchViaOptions(generalFetch), [generalFetch])
|
||||
// Read from the DRAFT, not the saved sub: the warning has to appear the moment
|
||||
// the picker lands on Direct, not one save later.
|
||||
const leaksDirect = proxyFetchGoesDirect({
|
||||
@@ -2480,17 +2448,36 @@ function SubOptions({
|
||||
onChange={(e) => set('UpdateInterval', e.target.value)}
|
||||
/>
|
||||
</OptField>
|
||||
<OptField label="Fetch via" hint="Proxy = pull through the tunnel for a blocked host">
|
||||
<OptField
|
||||
label="Fetch via"
|
||||
wide
|
||||
hint={
|
||||
viaMode === 'inherit'
|
||||
? 'Not set: this feed follows the general fetch route, so it moves when that setting moves. Pick Direct to pin it in the clear, or Proxy to give it a route of its own.'
|
||||
: viaMode === 'direct'
|
||||
? 'Pinned in the clear. It stays direct even if the general fetch route is later sent through a tunnel.'
|
||||
: viaMode === 'proxy'
|
||||
? 'Pulled through the route chosen below — use it for a feed host the uplink blocks.'
|
||||
: 'This value is not one the daemon accepts, so it refuses to guess a route rather than picking one for you. Choose Not set, Direct or Proxy.'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className="fp-input"
|
||||
value={draft.FetchVia}
|
||||
onChange={(e) => set('FetchVia', e.target.value)}
|
||||
aria-label={`Fetch via for ${sub.Name}`}
|
||||
>
|
||||
{FETCH_VIA.map((o) => (
|
||||
{viaOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
{/* An unrecognised stored value is shown, not silently replaced by
|
||||
whichever option happens to be first — that swap is a route
|
||||
decision, and it is not the picker's to make. */}
|
||||
{viaMode === 'unknown' && (
|
||||
<option value={draft.FetchVia}>{draft.FetchVia} (not a valid value)</option>
|
||||
)}
|
||||
</select>
|
||||
</OptField>
|
||||
{proxy && (
|
||||
@@ -2504,6 +2491,10 @@ function SubOptions({
|
||||
valid={valid}
|
||||
disabled={disabled}
|
||||
ariaLabel={`Proxy-fetch detour for ${sub.Name}`}
|
||||
/* Under FetchVia=proxy this option is not "no preference" — it is
|
||||
the plain WAN, with the real address. The label says so rather
|
||||
than leaving the word `Direct` to be read as a default. */
|
||||
directLabel="Direct — plain WAN, real address"
|
||||
onChange={(v) => set('FetchDetour', v)}
|
||||
/>
|
||||
</OptField>
|
||||
|
||||
@@ -331,6 +331,19 @@
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* A scalar override (resolver slot / list-fetch route). Deliberately NOT the
|
||||
on/off pair's green and red: it flips nothing on, it repoints a value, and
|
||||
borrowing their semantics would make a routine profile setting read as a
|
||||
verdict. */
|
||||
.pf-chip--soft {
|
||||
color: var(--dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pf-chip--soft .mono {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- row actions ---- */
|
||||
.pf-row-actions {
|
||||
|
||||
+182
-24
@@ -1,8 +1,10 @@
|
||||
import './Profiles.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Led, Toggle, useConfirm } from '../components'
|
||||
import { Button, DetourSelect, Led, Toggle, useConfirm } from '../components'
|
||||
import { apply as apiApply, getConfig, getInterfaces, putConfig, ApiError } from '../api'
|
||||
import type { Interface, Model, Profile } from '../api'
|
||||
import type { DetourCatalog } from '../detour'
|
||||
import { canonDetour, catalogOf, detourValues } from '../detour'
|
||||
|
||||
// The Profiles page is a thin editor over the desired-state Model — the same
|
||||
// save→apply split as Settings / DNS / Routing. Every edit rewrites a slice in
|
||||
@@ -24,6 +26,60 @@ const asArray = <T,>(a: T[] | null | undefined): T[] => (a ? a : [])
|
||||
const ruleSummary = (names: string[], max = 2): string =>
|
||||
names.length <= max ? names.join(', ') : `${names.slice(0, max).join(', ')} +${names.length - max} more`
|
||||
|
||||
// ---- the scalar overrides --------------------------------------------------
|
||||
//
|
||||
// Four Globals scalars a profile can repoint while it is active. They share one
|
||||
// contract, so they are described once and rendered from the description:
|
||||
//
|
||||
// "" means NO OVERRIDE, and inheritance is PER FIELD — a profile may set only
|
||||
// the fallback resolver and keep the global default. There is deliberately no
|
||||
// control for "override with the globals value": leaving the override off
|
||||
// already does exactly that, and a second way to say it would be a second
|
||||
// thing to keep in step.
|
||||
//
|
||||
// The detour one is NOT interchangeable with the resolvers despite sharing the
|
||||
// contract: for a route, `""` and `direct` are different inputs — `""` inherits,
|
||||
// an explicit `direct` forces the plain WAN over a globals setting that tunnels.
|
||||
// That is why its picker carries a separate "No override" option instead of
|
||||
// letting `direct` stand in for one.
|
||||
|
||||
/** The Profile fields that override a Globals scalar of the same name. */
|
||||
type ScalarField = 'ResolverDefault' | 'ResolverFallback' | 'EndpointResolver' | 'FetchDetour'
|
||||
|
||||
const SCALAR_OVERRIDES: ReadonlyArray<{ field: ScalarField; chip: string }> = [
|
||||
{ field: 'ResolverDefault', chip: 'default dns' },
|
||||
{ field: 'ResolverFallback', chip: 'fallback dns' },
|
||||
{ field: 'EndpointResolver', chip: 'endpoint dns' },
|
||||
{ field: 'FetchDetour', chip: 'list fetch' },
|
||||
]
|
||||
|
||||
/** The three resolver slots, in the order the DNS page lists them. */
|
||||
const RESOLVER_OVERRIDES: ReadonlyArray<{
|
||||
field: Extract<ScalarField, 'ResolverDefault' | 'ResolverFallback' | 'EndpointResolver'>
|
||||
label: string
|
||||
toast: string
|
||||
hint: string
|
||||
}> = [
|
||||
{
|
||||
field: 'ResolverDefault',
|
||||
label: 'Default resolver',
|
||||
toast: 'Default resolver',
|
||||
hint: 'Where queries go when no DNS rule matches. Point it at a resolver the active uplink can actually reach — a carrier that refuses :443 to the public DoH addresses takes the whole DNS path down with it.',
|
||||
},
|
||||
{
|
||||
field: 'ResolverFallback',
|
||||
label: 'Fallback resolver',
|
||||
toast: 'Fallback resolver',
|
||||
hint: 'Tried when the default fails. Overriding this alone is fine — the default keeps its globals value.',
|
||||
},
|
||||
{
|
||||
field: 'EndpointResolver',
|
||||
label: 'Endpoint resolver',
|
||||
toast: 'Endpoint resolver',
|
||||
hint: 'Looks up the server domains in your proxy configs while this profile is active — e.g. a plain resolver on SIM, DoH on Wi-Fi. It resolves before any tunnel is up, so it has to work on the bare uplink.',
|
||||
},
|
||||
]
|
||||
|
||||
// Pull Name strings out of a model slice (Rules / Resolvers) for the pickers.
|
||||
type Named = { Name?: unknown }
|
||||
function namesOf(v: unknown): string[] {
|
||||
@@ -130,6 +186,22 @@ export default function Profiles() {
|
||||
const profiles = useMemo<Profile[]>(() => asArray(config?.Profiles), [config])
|
||||
const ruleNames = useMemo(() => namesOf(config?.Rules), [config])
|
||||
const resolverNames = useMemo(() => namesOf(config?.Resolvers), [config])
|
||||
// The routes a profile's list-fetch override can point at — the same catalog
|
||||
// and the same picker every other page uses, so the vocabulary is one
|
||||
// vocabulary (see ../detour).
|
||||
const detourCatalog = useMemo<DetourCatalog>(() => catalogOf(config ?? {}), [config])
|
||||
const detourValid = useMemo(() => detourValues(detourCatalog), [detourCatalog])
|
||||
// What the profile overrides are measured against. Shown beside each override
|
||||
// so "No override" is a statement about a known value, not a blank.
|
||||
const globalFallbacks = useMemo(
|
||||
() => ({
|
||||
ResolverDefault: config?.Globals?.ResolverDefault ?? '',
|
||||
ResolverFallback: config?.Globals?.ResolverFallback ?? '',
|
||||
EndpointResolver: config?.Globals?.EndpointResolver ?? '',
|
||||
FetchDetour: canonDetour(config?.Globals?.FetchDetour, detourCatalog),
|
||||
}),
|
||||
[config, detourCatalog],
|
||||
)
|
||||
|
||||
const profileNames = useMemo(() => new Set(profiles.map((p) => p.Name)), [profiles])
|
||||
const active = globals?.ActiveProfile ?? ''
|
||||
@@ -325,6 +397,9 @@ export default function Profiles() {
|
||||
ruleNames={ruleNames}
|
||||
interfaces={interfaces}
|
||||
resolverNames={resolverNames}
|
||||
detourCatalog={detourCatalog}
|
||||
detourValid={detourValid}
|
||||
globalFallbacks={globalFallbacks}
|
||||
taken={profileNames}
|
||||
onPatch={(patch, msg) => patchProfile(p.Name, patch, msg)}
|
||||
onRename={(nn) => onRename(p.Name, nn)}
|
||||
@@ -550,6 +625,12 @@ function AddProfileForm({
|
||||
|
||||
// ---- one profile: summary row + expandable editor --------------------------
|
||||
|
||||
/** The globals each override is measured against, keyed by the field it overrides. */
|
||||
type GlobalFallbacks = Record<
|
||||
'ResolverDefault' | 'ResolverFallback' | 'EndpointResolver' | 'FetchDetour',
|
||||
string
|
||||
>
|
||||
|
||||
function ProfileRow({
|
||||
profile,
|
||||
isActive,
|
||||
@@ -560,6 +641,9 @@ function ProfileRow({
|
||||
ruleNames,
|
||||
interfaces,
|
||||
resolverNames,
|
||||
detourCatalog,
|
||||
detourValid,
|
||||
globalFallbacks,
|
||||
taken,
|
||||
onPatch,
|
||||
onRename,
|
||||
@@ -574,6 +658,9 @@ function ProfileRow({
|
||||
ruleNames: string[]
|
||||
interfaces: Interface[]
|
||||
resolverNames: string[]
|
||||
detourCatalog: DetourCatalog
|
||||
detourValid: Set<string>
|
||||
globalFallbacks: GlobalFallbacks
|
||||
taken: Set<string>
|
||||
onPatch: (patch: Partial<Profile>, msg: string) => void
|
||||
onRename: (newName: string) => void
|
||||
@@ -582,6 +669,14 @@ function ProfileRow({
|
||||
const iface = asArray(profile.MatchIface)
|
||||
const enableRules = asArray(profile.EnableRules)
|
||||
const disableRules = asArray(profile.DisableRules)
|
||||
// Scalar overrides belong on the COLLAPSED row too. A profile that silently
|
||||
// repoints the resolvers is the whole reason the DNS page had to grow an "in
|
||||
// effect now" line; a row that only listed rule flips let the operator scroll
|
||||
// past the profile actually doing it.
|
||||
const scalars = SCALAR_OVERRIDES.map((s) => ({
|
||||
label: s.chip,
|
||||
value: (profile[s.field] ?? '').trim(),
|
||||
})).filter((s) => s.value !== '')
|
||||
|
||||
return (
|
||||
<li className={profile.Enabled ? 'pf-row' : 'pf-row off'}>
|
||||
@@ -640,6 +735,16 @@ function ProfileRow({
|
||||
rules off: {ruleSummary(disableRules)}
|
||||
</span>
|
||||
)}
|
||||
{scalars.map((s) => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="pf-chip pf-chip--soft"
|
||||
title={`While active, ${s.label} is ${s.value} instead of the globals value`}
|
||||
>
|
||||
<b>{s.label}</b>
|
||||
<span className="mono">{s.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-row-actions">
|
||||
@@ -671,6 +776,9 @@ function ProfileRow({
|
||||
ruleNames={ruleNames}
|
||||
interfaces={interfaces}
|
||||
resolverNames={resolverNames}
|
||||
detourCatalog={detourCatalog}
|
||||
detourValid={detourValid}
|
||||
globalFallbacks={globalFallbacks}
|
||||
taken={taken}
|
||||
onPatch={onPatch}
|
||||
onRename={onRename}
|
||||
@@ -688,6 +796,9 @@ function ProfileEditor({
|
||||
ruleNames,
|
||||
interfaces,
|
||||
resolverNames,
|
||||
detourCatalog,
|
||||
detourValid,
|
||||
globalFallbacks,
|
||||
taken,
|
||||
onPatch,
|
||||
onRename,
|
||||
@@ -697,6 +808,9 @@ function ProfileEditor({
|
||||
ruleNames: string[]
|
||||
interfaces: Interface[]
|
||||
resolverNames: string[]
|
||||
detourCatalog: DetourCatalog
|
||||
detourValid: Set<string>
|
||||
globalFallbacks: GlobalFallbacks
|
||||
taken: Set<string>
|
||||
onPatch: (patch: Partial<Profile>, msg: string) => void
|
||||
onRename: (newName: string) => void
|
||||
@@ -791,36 +905,80 @@ function ProfileEditor({
|
||||
<fieldset className="pf-eblock">
|
||||
<legend className="pf-elegend">Overrides while active</legend>
|
||||
|
||||
{RESOLVER_OVERRIDES.map((o) => {
|
||||
const value = (profile[o.field] ?? '').trim()
|
||||
const global = globalFallbacks[o.field]
|
||||
return (
|
||||
<div className="pf-erow" key={o.field}>
|
||||
<span className="pf-elabel">{o.label}</span>
|
||||
<div className="pf-erow-ctl">
|
||||
<select
|
||||
className="pf-select"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
onPatch(
|
||||
{ [o.field]: e.target.value } as Partial<Profile>,
|
||||
e.target.value
|
||||
? `${o.toast} → ${e.target.value}`
|
||||
: `${o.toast} override cleared`,
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
aria-label={`${o.label} override`}
|
||||
>
|
||||
{/* Names the value it falls back to, so "no override" is a
|
||||
statement about a known resolver rather than a blank. */}
|
||||
<option value="">
|
||||
No override{global ? ` — globals: ${global}` : ' — globals: none'}
|
||||
</option>
|
||||
{resolverNames.map((rn) => (
|
||||
<option key={rn} value={rn}>
|
||||
{rn}
|
||||
</option>
|
||||
))}
|
||||
{value !== '' && !resolverNames.includes(value) && (
|
||||
<option value={value}>{value} (missing)</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="pf-ehint">{o.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="pf-erow">
|
||||
<span className="pf-elabel">Endpoint resolver</span>
|
||||
<span className="pf-elabel">List fetch route</span>
|
||||
<div className="pf-erow-ctl">
|
||||
<select
|
||||
<DetourSelect
|
||||
className="pf-select"
|
||||
value={profile.EndpointResolver ?? ''}
|
||||
onChange={(e) =>
|
||||
/* Canonicalise only a value that is THERE. canonDetour maps '' to
|
||||
'direct', so running it unconditionally would turn "this profile
|
||||
says nothing about fetches" into "this profile forces the plain
|
||||
WAN" — and then display that invention as the effective route. */
|
||||
value={
|
||||
(profile.FetchDetour ?? '').trim()
|
||||
? canonDetour(profile.FetchDetour, detourCatalog)
|
||||
: ''
|
||||
}
|
||||
catalog={detourCatalog}
|
||||
valid={detourValid}
|
||||
busy={busy}
|
||||
ariaLabel="List fetch route override"
|
||||
inheritLabel={`No override — globals: ${globalFallbacks.FetchDetour}`}
|
||||
directLabel="Direct — plain WAN"
|
||||
onChange={(v) =>
|
||||
onPatch(
|
||||
{ EndpointResolver: e.target.value },
|
||||
e.target.value
|
||||
? `Endpoint resolver → ${e.target.value}`
|
||||
: 'Endpoint resolver override cleared',
|
||||
{ FetchDetour: v },
|
||||
v ? `List fetches → ${v}` : 'List fetch override cleared',
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
aria-label="Endpoint resolver"
|
||||
>
|
||||
<option value="">No override</option>
|
||||
{resolverNames.map((rn) => (
|
||||
<option key={rn} value={rn}>
|
||||
{rn}
|
||||
</option>
|
||||
))}
|
||||
{profile.EndpointResolver && !resolverNames.includes(profile.EndpointResolver) && (
|
||||
<option value={profile.EndpointResolver}>{profile.EndpointResolver} (missing)</option>
|
||||
)}
|
||||
</select>
|
||||
/>
|
||||
<p className="pf-ehint">
|
||||
Overrides which resolver looks up your proxy server domains while this profile is active
|
||||
— e.g. a plain resolver on SIM, DoH on Wi-Fi.
|
||||
Where blocklists, allowlists, rule-sets and geo files are downloaded from while this
|
||||
profile is active. Pick a route the uplink can reach — a list that can’t be
|
||||
downloaded isn’t applied and matches nothing until it loads.{' '}
|
||||
<b>Direct is not the same as no override</b>: it forces the plain WAN even when
|
||||
globals sends fetches through a tunnel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-124
@@ -174,29 +174,17 @@
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--groove));
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Semantics carry the colour: amber = degraded, not on fire (same recipe as the
|
||||
dev-badge--warn / dns-badge--warn variants). */
|
||||
.tg-badge--warn {
|
||||
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
|
||||
color: var(--amber);
|
||||
}
|
||||
.tg-badge--good {
|
||||
border-color: color-mix(in srgb, var(--led-on) 55%, var(--groove));
|
||||
color: var(--led-on);
|
||||
}
|
||||
/* Semantics carry the colour: crit = broken. The --warn / --good / --unknown
|
||||
variants that stood beside it went out with the readiness plate that was their
|
||||
only caller — an unused rule is a rule nobody is keeping true, and the recipe
|
||||
is not lost: dev-badge--warn / dns-badge--warn are the same one, still live.
|
||||
The dashed --unknown treatment is worth re-deriving rather than re-copying if a
|
||||
third state ever comes back here; painting a not-yet-taken reading amber makes
|
||||
it indistinguishable from a degraded one. */
|
||||
.tg-badge--crit {
|
||||
border-color: color-mix(in srgb, var(--crit) 55%, var(--groove));
|
||||
color: var(--crit);
|
||||
}
|
||||
/* NOT DETERMINED gets its own look, and it is a look nobody reads as a verdict:
|
||||
the plainest border on the page and a dashed underline that says "no reading
|
||||
here". Painting it amber would make an unfinished check indistinguishable from
|
||||
a degraded service — the exact confusion the daemon's five-state answer is
|
||||
there to remove. */
|
||||
.tg-badge--unknown {
|
||||
border-style: dashed;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* ---- chain signal path (the signature) ---- */
|
||||
.tg-path {
|
||||
@@ -565,16 +553,6 @@
|
||||
.tg-fhint--warn {
|
||||
color: var(--amber);
|
||||
}
|
||||
/* When the hint quotes a readiness report, it says how old that report is. The
|
||||
sentence is a verdict; this is the timestamp on it. Kept quieter than the
|
||||
sentence so it qualifies rather than competes — and it inherits the amber of a
|
||||
warning hint deliberately, since the age belongs to the same statement. */
|
||||
.tg-fhint-age {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* editor footer */
|
||||
.tg-ed-foot {
|
||||
display: flex;
|
||||
@@ -765,101 +743,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- ByeDPI readiness ----
|
||||
* A small instrument plate over the egress list: state, the ports behind it, and
|
||||
* the one sentence the daemon wrote for that state. The left rule carries the
|
||||
* tone so the state is legible before a word is read — and `unknown` gets the
|
||||
* plain groove, because an incomplete check is not a degraded service. */
|
||||
.tg-byedpi {
|
||||
margin: 12px 0 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--groove);
|
||||
border-left-width: 3px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--sink) 45%, transparent);
|
||||
}
|
||||
.tg-byedpi--good {
|
||||
border-left-color: color-mix(in srgb, var(--led-on) 60%, var(--groove));
|
||||
}
|
||||
.tg-byedpi--warn {
|
||||
border-left-color: color-mix(in srgb, var(--amber) 60%, var(--groove));
|
||||
}
|
||||
.tg-byedpi--crit {
|
||||
border-left-color: color-mix(in srgb, var(--crit) 60%, var(--groove));
|
||||
}
|
||||
.tg-byedpi--unknown {
|
||||
border-left-style: dashed;
|
||||
}
|
||||
.tg-byedpi-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tg-byedpi-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
}
|
||||
.tg-byedpi-ports {
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
}
|
||||
/* HOW OLD THE READING IS. Every sentence in this report is present tense and the
|
||||
probe behind it can take seconds, so the stamp is never omitted — an absent age
|
||||
beside a lit lamp is exactly the "assume now" it exists to stop.
|
||||
Fresh is quiet: it agrees with what the plate already implies. */
|
||||
.tg-byedpi-age {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
cursor: help;
|
||||
}
|
||||
/* Aged is NOT quiet. A boxed, brighter chip, because this is the state in which
|
||||
every present-tense sentence below is a claim about a moment that has passed —
|
||||
and the control this report unlocks is still being decided on it. Deliberately
|
||||
not amber or crit: an old reading is not a fault, it is a qualifier. */
|
||||
.tg-byedpi-age--aged {
|
||||
padding: 1px 6px;
|
||||
border: 1px dashed color-mix(in srgb, var(--dim) 55%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.tg-byedpi-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.tg-byedpi-detail {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--dim);
|
||||
max-width: 78ch;
|
||||
}
|
||||
|
||||
/* One egress's cross-check, on its own row under the address it dials. Shares
|
||||
the .tg-test flexbox so a long sentence wraps in its own column. */
|
||||
.tg-egcheck {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tg-egcheck--crit .tg-test-msg {
|
||||
color: var(--crit);
|
||||
}
|
||||
.tg-egcheck--warn .tg-test-msg {
|
||||
color: var(--amber);
|
||||
}
|
||||
.tg-egcheck--unknown .tg-test-msg {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--faint);
|
||||
}
|
||||
.tg-egcheck--good .tg-test-msg {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* ---- per-group membership health ----
|
||||
* The signature of this page's group section: a band that is only as long as
|
||||
* what has actually been MEASURED. Green = answering, red = probed and failed,
|
||||
|
||||
+39
-278
@@ -4,7 +4,6 @@ import { Button, Led, Toggle, useConfirm } from '../components'
|
||||
import type { LedVariant } from '../components'
|
||||
import {
|
||||
apply as apiApply,
|
||||
getByeDPI,
|
||||
getConfig,
|
||||
getGroupsHealth,
|
||||
getGroupsTest,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from '../api'
|
||||
import type {
|
||||
Model,
|
||||
ByeDPIReport,
|
||||
Group,
|
||||
GroupHealth,
|
||||
GroupMemberHealth,
|
||||
@@ -36,24 +34,8 @@ import {
|
||||
UNKNOWN_EGRESS_TYPE_HINT,
|
||||
egressTypeInfo,
|
||||
nextEgress,
|
||||
retiredEgressType,
|
||||
} from '../egressEdit'
|
||||
import {
|
||||
byedpiAge,
|
||||
byedpiAgeFresh,
|
||||
byedpiAgeHint,
|
||||
byedpiAgeLabel,
|
||||
byedpiDetail,
|
||||
byedpiEgressFor,
|
||||
byedpiEgressLabel,
|
||||
byedpiEgressState,
|
||||
byedpiEgressTone,
|
||||
byedpiReady,
|
||||
byedpiRefusal,
|
||||
byedpiStateLabel,
|
||||
byedpiTone,
|
||||
reportState,
|
||||
} from '../byedpiReady'
|
||||
import type { ByeDPITone } from '../byedpiReady'
|
||||
import { blockedHop, originStamp, readingTone, rowOrigin } from '../testResult'
|
||||
import type { RowOrigin } from '../testResult'
|
||||
import { targetResultFor } from '../targetResult'
|
||||
@@ -495,43 +477,6 @@ export default function Targets() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- byedpi readiness ------------------------------------------------------
|
||||
//
|
||||
// IS THERE A LISTENER, not is there a file. This used to read
|
||||
// `status.byedpi_installed`, which is LookPath("ciadpi"): it answers "is the
|
||||
// package installed" while the operator is asking "will traffic sent to this
|
||||
// egress go anywhere". They come apart on the SHIPPED config — the packaged
|
||||
// /etc/config/byedpi is inert — so installing the package unlocked the type,
|
||||
// the egress was created on 127.0.0.1:1080, the apply was green, and nobody
|
||||
// was listening.
|
||||
//
|
||||
// GET /api/byedpi answers the real question and also cross-checks each byedpi
|
||||
// egress's PORT against the enabled instances, which is the other half of the
|
||||
// same defect: the two packages coordinated that number in a comment.
|
||||
//
|
||||
// A failed read leaves the report null, which reads as `unknown` — and unknown
|
||||
// keeps the type locked, because "the check could not be completed" is not a
|
||||
// reason to open a control. It is not a refusal either: the hint says so and
|
||||
// offers Re-check, so nobody is stranded on a dropped request.
|
||||
const [byedpi, setByedpi] = useState<ByeDPIReport | null>(null)
|
||||
const [byedpiChecking, setByedpiChecking] = useState(false)
|
||||
const loadByedpi = useCallback(async () => {
|
||||
setByedpiChecking(true)
|
||||
try {
|
||||
setByedpi(await getByeDPI())
|
||||
} catch {
|
||||
setByedpi(null) // no reading — see reportState(): that is `unknown`
|
||||
} finally {
|
||||
setByedpiChecking(false)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
void loadByedpi()
|
||||
}, [loadByedpi])
|
||||
// The gate itself lives in the editor (byedpiReady on this same report), so
|
||||
// the page hands down the evidence rather than a boolean somebody would have
|
||||
// to trust.
|
||||
|
||||
// ---- toast + persistent apply banner --------------------------------------
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const toastTimer = useRef<number | undefined>(undefined)
|
||||
@@ -769,9 +714,6 @@ export default function Targets() {
|
||||
const groupNames = useMemo(() => new Set(groups.map((g) => g.Name)), [groups])
|
||||
const chainNames = useMemo(() => new Set(chains.map((c) => c.Name)), [chains])
|
||||
const egressNames = useMemo(() => new Set(egresses.map((e) => e.Name)), [egresses])
|
||||
/** Does anything on this router actually dial the local desync proxy? */
|
||||
const hasByedpiEgress = useMemo(() => egresses.some((e) => e.Type === 'byedpi'), [egresses])
|
||||
|
||||
// Hop picker options for chains: every group and node in the config.
|
||||
const hopOptions = useMemo<Opt[]>(
|
||||
() => [
|
||||
@@ -1189,26 +1131,15 @@ export default function Targets() {
|
||||
</Button>
|
||||
</header>
|
||||
<p className="tg-sec-note">
|
||||
An egress is a raw <strong>exit</strong> — a WAN interface or tunnel, the normal route with
|
||||
a DPI preset on it, or the local ByeDPI desync proxy. Rules, nodes and groups point at
|
||||
An egress is a raw <strong>exit</strong> — a WAN interface or tunnel, or the normal route
|
||||
with a DPI preset on it. Rules, nodes and groups point at
|
||||
egress:<code><name></code>. To send traffic through a proxy, route it at a group, node
|
||||
or chain instead; to drop it, give the rule the <code>block</code> target.
|
||||
</p>
|
||||
|
||||
<ByeDPIPlate
|
||||
report={byedpi}
|
||||
checking={byedpiChecking}
|
||||
onRecheck={() => void loadByedpi()}
|
||||
// Only where it is someone's business: a router with no byedpi egress
|
||||
// and no editor open is not asked to care about a package it does not
|
||||
// use. Opening the Add editor is the moment it becomes relevant.
|
||||
show={hasByedpiEgress || egressEd?.mode === 'add'}
|
||||
/>
|
||||
|
||||
{egressEd?.mode === 'add' && (
|
||||
<EgressEditor
|
||||
interfaces={interfaces}
|
||||
byedpi={byedpi}
|
||||
taken={egressNames}
|
||||
busy={busy}
|
||||
onCancel={() => setEgressEd(null)}
|
||||
@@ -1235,7 +1166,6 @@ export default function Targets() {
|
||||
<EgressEditor
|
||||
initial={e}
|
||||
interfaces={interfaces}
|
||||
byedpi={byedpi}
|
||||
taken={without(egressNames, e.Name)}
|
||||
busy={busy}
|
||||
onCancel={() => setEgressEd(null)}
|
||||
@@ -1250,7 +1180,6 @@ export default function Targets() {
|
||||
<EgressRow
|
||||
key={e.Name}
|
||||
egress={e}
|
||||
byedpi={byedpi}
|
||||
busy={busy}
|
||||
onEdit={() => setEgressEd({ mode: 'edit', name: e.Name })}
|
||||
onDelete={() => removeEgress(e.Name)}
|
||||
@@ -2826,142 +2755,32 @@ function ChainEditor({
|
||||
|
||||
// ---- EGRESS row + editor ---------------------------------------------------
|
||||
|
||||
// Why a NEW byedpi egress cannot be created right now: byedpiRefusal, in
|
||||
// byedpiReady.ts beside the gate it explains. It lives there because `disabled`
|
||||
// has two meanings — the shipped inert config, and a section the init script
|
||||
// threw out — and only the daemon's own sentence tells them apart, so the choice
|
||||
// is a readiness decision rather than page copy.
|
||||
|
||||
/**
|
||||
* The age of the report a hint is quoting, appended to that hint.
|
||||
*
|
||||
* WITHHELD when there is no measurement at all, and that is not an omission: the
|
||||
* sentence it would follow already opens with "no listener check has been taken
|
||||
* yet". Printing "(not measured)" after it is the same point twice in two
|
||||
* voices, which is where wording drifts apart — and the whole reason to show an
|
||||
* age is that a sentence does not otherwise carry one.
|
||||
*/
|
||||
function AgeSuffix({ report }: { report: ByeDPIReport | null }) {
|
||||
const age = byedpiAge(report)
|
||||
if (age.kind === 'none') return null
|
||||
return <span className="tg-fhint-age">({byedpiAgeLabel(age)})</span>
|
||||
}
|
||||
|
||||
/** The Led variant each readiness tone is drawn with. `unknown` is an UNLIT
|
||||
* socket, never a red one: "the check could not be completed" and "the service
|
||||
* is down" are different facts with different fixes. */
|
||||
const TONE_LED: Record<ByeDPITone, LedVariant> = {
|
||||
good: 'on',
|
||||
warn: 'amber',
|
||||
crit: 'crit',
|
||||
unknown: 'off',
|
||||
}
|
||||
|
||||
/**
|
||||
* The byedpi readiness plate above the egress list.
|
||||
*
|
||||
* It shows the SERVICE state — the three facts behind it (binary, conffile,
|
||||
* listener) are what makes the state readable instead of a word to be trusted —
|
||||
* and it is the only place the "Re-check" control lives, because a state that
|
||||
* can be unknown needs a way out of unknown that is not a page reload.
|
||||
*
|
||||
* AND IT SAYS HOW OLD THE READING IS. Every sentence the daemon writes here is
|
||||
* present tense, and GET /api/byedpi is the one endpoint that connects — up to
|
||||
* sixteen instances, each with a dial timeout, which is 6.4 s measured in the
|
||||
* pathological case. So "measured just now" is the normal answer and a slow
|
||||
* probe must not pass for it; nor must a page that has no answer at all.
|
||||
*/
|
||||
function ByeDPIPlate({
|
||||
report,
|
||||
checking,
|
||||
show,
|
||||
onRecheck,
|
||||
}: {
|
||||
report: ByeDPIReport | null
|
||||
checking: boolean
|
||||
show: boolean
|
||||
onRecheck: () => void
|
||||
}) {
|
||||
if (!show) return null
|
||||
const state = reportState(report)
|
||||
const tone = byedpiTone(state)
|
||||
const instances = report?.instances ?? []
|
||||
const age = byedpiAge(report)
|
||||
const fresh = byedpiAgeFresh(age)
|
||||
return (
|
||||
<div className={`tg-byedpi tg-byedpi--${tone}`} role="status" aria-label="ByeDPI readiness">
|
||||
<div className="tg-byedpi-hd">
|
||||
<Led variant={TONE_LED[tone]} />
|
||||
<span className="tg-byedpi-title">ByeDPI</span>
|
||||
<span className={`tg-badge tg-badge--${tone}`}>{byedpiStateLabel(report)}</span>
|
||||
{/* The stamp is never omitted: an absent age beside a green lamp is
|
||||
exactly the "assume now" this exists to stop. Fresh and aged are
|
||||
deliberately different words in a differently-styled chip — a
|
||||
timestamp that looks the same either way says nothing. */}
|
||||
<span
|
||||
className={`tg-byedpi-age${fresh ? '' : ' tg-byedpi-age--aged'}`}
|
||||
title={byedpiAgeHint(age)}
|
||||
>
|
||||
{byedpiAgeLabel(age)}
|
||||
</span>
|
||||
{instances.length > 0 && (
|
||||
<span className="tg-byedpi-ports mono">
|
||||
{instances
|
||||
.map((i) => `127.0.0.1:${i.port} (${i.listening === 'yes' ? 'accepting' : i.listening === 'no' ? 'refused' : 'not determined'})`)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<Button className="tg-act tg-byedpi-btn" onClick={onRecheck} disabled={checking}>
|
||||
{checking ? 'Checking…' : 'Re-check'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="tg-byedpi-detail">{byedpiDetail(report)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EgressRow({
|
||||
egress,
|
||||
byedpi,
|
||||
busy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
egress: Egress
|
||||
/** The readiness report, or null when it could not be read (⇒ `unknown`). An
|
||||
* existing byedpi egress is still SHOWN whatever it says — saved config never
|
||||
* silently disappears — it just carries the state it is really in. */
|
||||
byedpi: ByeDPIReport | null
|
||||
busy: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const isByedpi = egress.Type === 'byedpi'
|
||||
// The daemon's own cross-check for THIS egress. Absent means "not
|
||||
// cross-checked" (the status copy carries none, and an unreadable model
|
||||
// produces none) — never "this one passed", so the row falls back to the
|
||||
// service state rather than going quiet.
|
||||
const check = isByedpi ? byedpiEgressFor(byedpi, egress.Name) : undefined
|
||||
const egState = check ? byedpiEgressState(check.state) : undefined
|
||||
const egTone = egState ? byedpiEgressTone(egState) : undefined
|
||||
const detail = useMemo(() => {
|
||||
switch (egress.Type) {
|
||||
case 'interface':
|
||||
return egress.Interface ? `iface ${egress.Interface}` : 'no interface set'
|
||||
case 'byedpi':
|
||||
return `127.0.0.1:${check?.port ?? egress.Port ?? 1080}`
|
||||
case 'direct':
|
||||
return 'straight to WAN'
|
||||
default:
|
||||
// No outbound is built for a type outside the three, so everything bound
|
||||
// No outbound is built for a type outside the two, so everything bound
|
||||
// to it is blocked. Say so on the row rather than printing a bare word.
|
||||
// A RETIRED type lands here too, and the editor is where it gets its own
|
||||
// sentence — the row states the consequence, which is the same one.
|
||||
return `${egress.Type || 'no type'} — nothing routed here can leave`
|
||||
}
|
||||
}, [egress, check])
|
||||
}, [egress])
|
||||
const dpi = DPI_TYPES.has(egress.Type) && egress.DPI && egress.DPI !== 'off' ? egress.DPI : ''
|
||||
// No row of its own: without the cross-check the service state is all there
|
||||
// is, and saying it here would repeat the plate directly above.
|
||||
const serviceOnly = isByedpi && !check
|
||||
|
||||
return (
|
||||
<li className="tg-row">
|
||||
@@ -2970,28 +2789,10 @@ function EgressRow({
|
||||
<span className="tg-row-name">{egress.Name}</span>
|
||||
<span className="tg-badge tg-badge--accent">{EGRESS_TYPE_LABEL[egress.Type] ?? egress.Type}</span>
|
||||
{dpi && <span className="tg-badge">dpi: {dpi}</span>}
|
||||
{egState && egTone && (
|
||||
<span className={`tg-badge tg-badge--${egTone}`}>{byedpiEgressLabel(egState)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tg-row-l2 mono">
|
||||
<span className="tg-row-detail">{detail}</span>
|
||||
</div>
|
||||
{check && egTone && (
|
||||
<div className={`tg-test tg-egcheck tg-egcheck--${egTone}`} role="status">
|
||||
<Led variant={TONE_LED[egTone]} />
|
||||
<span className="tg-test-msg">{check.detail}</span>
|
||||
</div>
|
||||
)}
|
||||
{serviceOnly && (
|
||||
<div className="tg-test tg-egcheck tg-egcheck--unknown" role="status">
|
||||
<Led variant="off" />
|
||||
<span className="tg-test-msg">
|
||||
This egress was not cross-checked against the running byedpi instances, so it is not
|
||||
known whether its port is served. See the ByeDPI state above.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RowActions
|
||||
onEdit={onEdit}
|
||||
@@ -3007,7 +2808,6 @@ function EgressRow({
|
||||
function EgressEditor({
|
||||
initial,
|
||||
interfaces,
|
||||
byedpi,
|
||||
taken,
|
||||
busy,
|
||||
onCancel,
|
||||
@@ -3015,17 +2815,6 @@ function EgressEditor({
|
||||
}: {
|
||||
initial?: Egress
|
||||
interfaces: Interface[]
|
||||
/**
|
||||
* The readiness report. A NEW byedpi egress may only be created when it says
|
||||
* `listening` — a live listener is the only state in which traffic sent there
|
||||
* goes anywhere, and every other state (including `unknown`, which is "the
|
||||
* check could not be completed") would create an egress whose every bound rule
|
||||
* is fail-closed while the apply comes back green.
|
||||
*
|
||||
* An egress that is ALREADY byedpi keeps the option selectable whatever the
|
||||
* state, so opening and re-saving a stored config never rewrites it.
|
||||
*/
|
||||
byedpi: ByeDPIReport | null
|
||||
taken: Set<string>
|
||||
busy: boolean
|
||||
onCancel: () => void
|
||||
@@ -3034,16 +2823,14 @@ function EgressEditor({
|
||||
const [name, setName] = useState(initial?.Name ?? '')
|
||||
const [type, setType] = useState(initial?.Type || 'interface')
|
||||
const [iface, setIface] = useState(initial?.Interface ?? '')
|
||||
const [port, setPort] = useState(initial?.Port != null ? String(initial.Port) : '')
|
||||
const [dpi, setDpi] = useState(initial?.DPI || 'off')
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const typeInfo = egressTypeInfo(type)
|
||||
// This egress was byedpi when the editor opened — its own type stays legal
|
||||
// whatever the service is doing, so saved config can always round-trip.
|
||||
const wasByedpi = initial?.Type === 'byedpi'
|
||||
const byedpiState = reportState(byedpi)
|
||||
const byedpiOK = byedpiReady(byedpi)
|
||||
const byedpiLocked = !byedpiOK && !wasByedpi
|
||||
// A type this product used to build and does not any more. Answered BEFORE the
|
||||
// generic unknown hint: the operator did not mistype anything, so telling them
|
||||
// the router "does not recognise" it would send them looking for a typo that is
|
||||
// not there. Undefined for every live type and for a genuine unknown.
|
||||
const retired = retiredEgressType(type)
|
||||
|
||||
const submit = async () => {
|
||||
const nm = name.trim()
|
||||
@@ -3051,15 +2838,12 @@ function EgressEditor({
|
||||
if (taken.has(nm)) return setErr(`An egress named “${nm}” already exists.`)
|
||||
if (type === 'interface' && !iface.trim())
|
||||
return setErr('Enter the UCI interface name (e.g. wan, wg0).')
|
||||
// Belt to the disabled option's braces: a stale select state must not save
|
||||
// an egress whose port nothing is on.
|
||||
if (type === 'byedpi' && byedpiLocked) return setErr(byedpiRefusal(byedpi))
|
||||
setErr(null)
|
||||
// Carry the fields the chosen type uses and clear the rest — but ONLY for a
|
||||
// type this editor renders those fields for. An unknown type keeps every
|
||||
// stored setting untouched, because this form showed the operator none of
|
||||
// them and must not delete what it declined to display. See nextEgress.
|
||||
await onSave(nextEgress(initial, { name: nm, type, iface, port, dpi }))
|
||||
// type this editor renders those fields for. An unknown or retired type keeps
|
||||
// every stored setting untouched, because this form showed the operator none
|
||||
// of them and must not delete what it declined to display. See nextEgress.
|
||||
await onSave(nextEgress(initial, { name: nm, type, iface, dpi }))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -3098,37 +2882,31 @@ function EgressEditor({
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{EGRESS_TYPES.map((t) => {
|
||||
const locked = t.id === 'byedpi' && byedpiLocked
|
||||
return (
|
||||
<option key={t.id} value={t.id} disabled={locked}>
|
||||
{locked ? `${t.label} (${byedpiStateLabel(byedpi)})` : t.label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
{!typeInfo && <option value={type}>{type || '—'} (unknown)</option>}
|
||||
{EGRESS_TYPES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
{/* The stored type, kept selectable so a config this editor cannot
|
||||
build is never silently rewritten by opening it. Its parenthesis
|
||||
says WHICH kind of stranger it is: a type that was taken out of
|
||||
the product reads "removed", not "unknown". */}
|
||||
{!typeInfo && (
|
||||
<option value={type}>
|
||||
{type || '—'} ({retired ? 'removed' : 'unknown'})
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="tg-fhint">{typeInfo?.blurb ?? UNKNOWN_EGRESS_TYPE_HINT}</p>
|
||||
{/* Withheld, with the reason and a way out — not silently absent. The
|
||||
`unknown` branch is deliberately not amber: nothing established
|
||||
that anything is wrong, and nothing established that it is fine
|
||||
either. */}
|
||||
{byedpiLocked && (
|
||||
<p
|
||||
className={`tg-fhint${byedpiState === 'unknown' ? '' : ' tg-fhint--warn'}`}
|
||||
role="note"
|
||||
>
|
||||
{byedpiRefusal(byedpi)} <AgeSuffix report={byedpi} />
|
||||
</p>
|
||||
)}
|
||||
{!byedpiOK && wasByedpi && type === 'byedpi' && (
|
||||
<p
|
||||
className={`tg-fhint${byedpiState === 'unknown' ? '' : ' tg-fhint--warn'}`}
|
||||
role="alert"
|
||||
>
|
||||
{byedpiDetail(byedpi)} This egress is kept exactly as saved.{' '}
|
||||
<AgeSuffix report={byedpi} />
|
||||
{/* A retired type answers FIRST: it is a different fact from an
|
||||
unrecognised one, and it sends the operator somewhere else. */}
|
||||
{typeInfo ? (
|
||||
<p className="tg-fhint">{typeInfo.blurb}</p>
|
||||
) : retired ? (
|
||||
<p className="tg-fhint tg-fhint--warn" role="alert">
|
||||
{retired}
|
||||
</p>
|
||||
) : (
|
||||
<p className="tg-fhint">{UNKNOWN_EGRESS_TYPE_HINT}</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
@@ -3172,23 +2950,6 @@ function EgressEditor({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Only a byedpi egress dials anywhere, so only it has a port. */}
|
||||
{type === 'byedpi' && (
|
||||
<label className="tg-field">
|
||||
<span className="tg-flabel">ciadpi port</span>
|
||||
<input
|
||||
className="tg-input"
|
||||
value={port}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
inputMode="numeric"
|
||||
placeholder="1080"
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{DPI_TYPES.has(type) && (
|
||||
<label className="tg-field">
|
||||
<span className="tg-flabel">DPI preset</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
SUB_FORMATS,
|
||||
SUB_PROTOS,
|
||||
classifyFetchVia,
|
||||
formatExpireAlert,
|
||||
joinFilterList,
|
||||
nextSubscription,
|
||||
@@ -254,6 +255,122 @@ test('a saved proxy sub with the picker on Direct trips the predicate', () => {
|
||||
assert.equal(proxyFetchGoesDirect(out), true)
|
||||
})
|
||||
|
||||
// ---- `fetch_via` has THREE states, and one of them is spelled by absence -----
|
||||
//
|
||||
// WHAT THESE PROTECT, in the order they matter:
|
||||
//
|
||||
// 1. AN EXPLICIT `direct` MUST SURVIVE A SAVE. The merge wrote
|
||||
// `proxy ? 'proxy' : undefined`, so a user's deliberate "always in the
|
||||
// clear" was stored as an ABSENT option — which in schema v3 means
|
||||
// "inherit the general fetch route". Nothing failed, nothing was logged,
|
||||
// and the two behave identically right up until a general fetch route is
|
||||
// set, at which point every such feed silently moves into a tunnel. This
|
||||
// is the regression, so it is the first test.
|
||||
// 2. THE INHERIT STATE MUST BE REACHABLE IN BOTH DIRECTIONS. `subToForm`
|
||||
// collapsed absent into `'direct'` on load, so the state could not be seen;
|
||||
// the merge could not write it either. A round-trip is the only test that
|
||||
// catches a state that is lost on one leg and re-invented on the other.
|
||||
// 3. UNKNOWN IS NOT A MODE, AND NOT AN ERASURE. The Go side refuses to guess
|
||||
// (SubscriptionFetchDetour returns ok=false). The panel must neither guess
|
||||
// on its behalf nor quietly normalise the value away — the stored typo is
|
||||
// the evidence, and the value it used to be read as was `direct`: a fetch
|
||||
// that goes out in the clear and SUCCEEDS, so the outcome reveals nothing.
|
||||
// 4. CONTROL: `proxy` STILL WORKS, WITH ITS DETOUR. A rule that turns every
|
||||
// state into "not set" would satisfy 1–3 read carelessly.
|
||||
|
||||
/** Every state, both spellings of the absent one, through load → save → load. */
|
||||
test('all three fetch_via states survive a full round trip', () => {
|
||||
const cases: { stored: string | undefined; form: string; saved: string | undefined }[] = [
|
||||
{ stored: undefined, form: '', saved: undefined }, // absent ⇒ inherit
|
||||
{ stored: '', form: '', saved: undefined }, // empty ⇒ inherit
|
||||
{ stored: 'direct', form: 'direct', saved: 'direct' }, // explicit, and KEPT
|
||||
{ stored: 'proxy', form: 'proxy', saved: 'proxy' },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const s = stored({ FetchVia: c.stored, FetchDetour: c.stored === 'proxy' ? 'group:auto' : undefined })
|
||||
const form = subToForm(s, c.stored === 'proxy' ? 'group:auto' : 'direct')
|
||||
assert.equal(form.FetchVia, c.form, `load ${JSON.stringify(c.stored)}`)
|
||||
|
||||
const out = nextSubscription(s, form, undefined)
|
||||
assert.equal(out.FetchVia, c.saved, `save ${JSON.stringify(c.stored)}`)
|
||||
|
||||
// …and back again: the second load must land on the same form value, or the
|
||||
// state is being re-invented on one of the two legs.
|
||||
const again = subToForm(out, c.stored === 'proxy' ? 'group:auto' : 'direct')
|
||||
assert.equal(again.FetchVia, c.form, `reload ${JSON.stringify(c.stored)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an explicit Direct is written out — absence means inherit now, not direct', () => {
|
||||
const s = stored({ FetchVia: 'direct' })
|
||||
const out = nextSubscription(s, subToForm(s, 'direct'), undefined)
|
||||
assert.equal(out.FetchVia, 'direct')
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(out, 'FetchVia') && out.FetchVia !== undefined,
|
||||
'the key must be PRESENT: an omitted fetch_via is the inherit state, so dropping it hands this feed to whatever the general fetch route becomes',
|
||||
)
|
||||
// The shape that actually reaches the daemon, since PUT sends JSON.
|
||||
assert.equal(JSON.parse(JSON.stringify(out)).FetchVia, 'direct')
|
||||
})
|
||||
|
||||
test('choosing Not set clears the option entirely — that is how inherit is written', () => {
|
||||
const s = stored({ FetchVia: 'direct' })
|
||||
const out = nextSubscription(s, { ...subToForm(s, 'direct'), FetchVia: '' }, undefined)
|
||||
assert.equal(out.FetchVia, undefined)
|
||||
assert.equal('FetchVia' in JSON.parse(JSON.stringify(out)), false, 'inherit is the ABSENT option')
|
||||
})
|
||||
|
||||
test('switching a subscription from inherit to Direct is a real, saved change', () => {
|
||||
// The user path the defect hid: open a feed that follows the general route,
|
||||
// pin it in the clear, save. Before the fix the save was a no-op.
|
||||
const s = stored({ FetchVia: undefined })
|
||||
const form = subToForm(s, 'direct')
|
||||
assert.equal(form.FetchVia, '', 'it must load as inherit, not as direct')
|
||||
const out = nextSubscription(s, { ...form, FetchVia: 'direct' }, undefined)
|
||||
assert.equal(out.FetchVia, 'direct')
|
||||
assert.notEqual(out.FetchVia, s.FetchVia, 'the save has to change something')
|
||||
})
|
||||
|
||||
test('classifyFetchVia is closed, positive, and case/space-insensitive', () => {
|
||||
const table: Record<string, ReturnType<typeof classifyFetchVia>> = {
|
||||
'': 'inherit',
|
||||
' ': 'inherit',
|
||||
direct: 'direct',
|
||||
DIRECT: 'direct',
|
||||
' Direct ': 'direct',
|
||||
proxy: 'proxy',
|
||||
PROXY: 'proxy',
|
||||
' Proxy ': 'proxy',
|
||||
}
|
||||
for (const [raw, want] of Object.entries(table)) {
|
||||
assert.equal(classifyFetchVia(raw), want, JSON.stringify(raw))
|
||||
}
|
||||
assert.equal(classifyFetchVia(undefined), 'inherit')
|
||||
// Everything else is a named defect, never a working mode — above all never
|
||||
// `direct`, which is what it silently became before.
|
||||
for (const bad of ['tunnel', 'prox', 'yes', 'true', 'group:auto', 'inherit']) {
|
||||
assert.equal(classifyFetchVia(bad), 'unknown', bad)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unrecognised fetch_via is neither guessed nor erased', () => {
|
||||
const s = stored({ FetchVia: 'tunnel' })
|
||||
const form = subToForm(s, 'direct')
|
||||
assert.equal(form.FetchVia, 'tunnel', 'shown as it is stored, so it can be seen and fixed')
|
||||
const out = nextSubscription(s, form, undefined)
|
||||
assert.equal(out.FetchVia, 'tunnel', 'rewriting it would pick a route nobody chose')
|
||||
// And it must not be read as any of the three real modes.
|
||||
assert.equal(proxyFetchGoesDirect(out), false)
|
||||
})
|
||||
|
||||
test('control — proxy still round-trips and keeps its own detour', () => {
|
||||
const s = stored({ FetchVia: 'proxy', FetchDetour: 'group:auto' })
|
||||
const out = nextSubscription(s, subToForm(s, 'group:auto'), undefined)
|
||||
assert.equal(out.FetchVia, 'proxy')
|
||||
assert.equal(out.FetchDetour, 'group:auto')
|
||||
assert.equal(proxyFetchGoesDirect(out), false)
|
||||
})
|
||||
|
||||
// ---- list + header parsing ----------------------------------------------------
|
||||
|
||||
test('filter lists split on commas and newlines, trimmed and deduped', () => {
|
||||
|
||||
+111
-4
@@ -61,6 +61,99 @@ export const joinFilterList = (a: string[] | null | undefined): string => (a ? a
|
||||
|
||||
export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
// ---- `fetch_via`: three states, and one of them has no spelling -------------
|
||||
//
|
||||
// Schema v3 changed what an ABSENT `fetch_via` means, and the change is invisible
|
||||
// from the outside: it used to mean `direct`, and now it means INHERIT — the
|
||||
// general `fetch_detour` (globals, or the active profile's override) decides.
|
||||
//
|
||||
// The panel got that wrong in both directions and neither one raised anything:
|
||||
//
|
||||
// READ `FetchVia: s.FetchVia === 'proxy' ? 'proxy' : 'direct'` collapsed the
|
||||
// inherit state into `direct` on load, so it could not be seen.
|
||||
// WRITE `FetchVia: proxy ? 'proxy' : undefined` wrote a user's explicit
|
||||
// `Direct` as an ABSENT option, so it could not be kept either. Every
|
||||
// save of every non-proxy subscription silently converted "always in the
|
||||
// clear" into "follow the general setting" — and the two behave
|
||||
// identically right up until somebody sets a general fetch route, at
|
||||
// which point those feeds move into a tunnel with no message anywhere.
|
||||
//
|
||||
// So the form value is the raw stored vocabulary, not a boolean in disguise:
|
||||
// `''` (inherit), `'direct'`, `'proxy'`. Mirrors model.ClassifyFetchVia.
|
||||
|
||||
/** What a raw `fetch_via` means. Mirrors model.FetchViaMode — CLOSED and
|
||||
* POSITIVE: an unrecognised value gets its own member instead of being folded
|
||||
* into one of the real ones. */
|
||||
export type FetchViaMode = 'inherit' | 'direct' | 'proxy' | 'unknown'
|
||||
|
||||
/** The two spellings the option accepts. Absent is the third state and has no
|
||||
* spelling — that is why this list has two entries and the picker has three. */
|
||||
export const FETCH_VIA_VALUES = ['direct', 'proxy'] as const
|
||||
|
||||
/**
|
||||
* Classify a raw `fetch_via`, exactly as model.ClassifyFetchVia does: case- and
|
||||
* space-insensitive, with everything unrecognised landing on `unknown` rather
|
||||
* than on a working mode.
|
||||
*
|
||||
* `unknown` is not pedantry. The value it used to be silently treated as was
|
||||
* `direct` — a fetch that goes out over the plain WAN with this router's real
|
||||
* address and SUCCEEDS, so nothing about the outcome reveals the typo. The Go
|
||||
* side refuses to guess (SubscriptionFetchDetour returns ok=false); the panel
|
||||
* must not guess on its behalf either.
|
||||
*/
|
||||
export function classifyFetchVia(v: string | undefined): FetchViaMode {
|
||||
switch ((v ?? '').trim().toLowerCase()) {
|
||||
case '':
|
||||
return 'inherit'
|
||||
case 'direct':
|
||||
return 'direct'
|
||||
case 'proxy':
|
||||
return 'proxy'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value the picker binds to: the canonical spelling for the three real
|
||||
* modes, and an unrecognised value kept VERBATIM so the editor can show it as
|
||||
* stale instead of quietly rewriting somebody's config into a mode they did not
|
||||
* choose.
|
||||
*/
|
||||
export function fetchViaFormValue(raw: string | undefined): string {
|
||||
switch (classifyFetchVia(raw)) {
|
||||
case 'inherit':
|
||||
return ''
|
||||
case 'direct':
|
||||
return 'direct'
|
||||
case 'proxy':
|
||||
return 'proxy'
|
||||
case 'unknown':
|
||||
return (raw ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to STORE for a form choice. `undefined` writes no option at all,
|
||||
* which is the only way to express inherit — and, critically, is NOT what an
|
||||
* explicit `direct` writes any more.
|
||||
*/
|
||||
export function fetchViaStored(formValue: string): string | undefined {
|
||||
switch (classifyFetchVia(formValue)) {
|
||||
case 'inherit':
|
||||
return undefined
|
||||
case 'direct':
|
||||
return 'direct'
|
||||
case 'proxy':
|
||||
return 'proxy'
|
||||
case 'unknown':
|
||||
// Preserved, not normalised. The daemon names it as a defect
|
||||
// (ValidateSubscriptions); rewriting it here would erase the evidence and
|
||||
// pick a route on the operator's behalf.
|
||||
return formValue.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this subscription SAYS it fetches through the tunnel and does not.
|
||||
*
|
||||
@@ -80,12 +173,18 @@ export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string
|
||||
*
|
||||
* `direct` spelled out explicitly counts the same as empty: it resolves to the
|
||||
* same tag, and picking it deliberately does not make the address any more hidden.
|
||||
* That is about the DETOUR. It is not true of `fetch_via`, where empty and
|
||||
* `direct` became different states in schema v3 — see the note below.
|
||||
*
|
||||
* Only `fetch_via=proxy` is judged here. `direct` is an honest choice and
|
||||
* inherit expresses no opinion, so neither can be "saying proxy and not meaning
|
||||
* it", which is the single thing this predicate detects.
|
||||
*/
|
||||
export function proxyFetchGoesDirect(s: {
|
||||
FetchVia?: string
|
||||
FetchDetour?: string
|
||||
}): boolean {
|
||||
if ((s.FetchVia ?? '').trim().toLowerCase() !== 'proxy') return false
|
||||
if (classifyFetchVia(s.FetchVia) !== 'proxy') return false
|
||||
const d = (s.FetchDetour ?? '').trim()
|
||||
return d === '' || d.toLowerCase() === 'direct'
|
||||
}
|
||||
@@ -141,6 +240,11 @@ export interface SubForm {
|
||||
Name: string
|
||||
URL: string
|
||||
UpdateInterval: string
|
||||
/**
|
||||
* `''` (inherit — the general fetch route decides) | `'direct'` | `'proxy'`,
|
||||
* or an unrecognised stored value carried through verbatim. NOT a boolean:
|
||||
* see the `fetch_via` note above for what treating it as one cost.
|
||||
*/
|
||||
FetchVia: string
|
||||
/** Canonical: `direct` | `group:x` | `node:x` | `egress:x` | `chain:x`. */
|
||||
FetchDetour: string
|
||||
@@ -167,7 +271,7 @@ export function subToForm(s: Subscription, canonDetour: string): SubForm {
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
UpdateInterval: s.UpdateInterval ?? '',
|
||||
FetchVia: s.FetchVia === 'proxy' ? 'proxy' : 'direct',
|
||||
FetchVia: fetchViaFormValue(s.FetchVia),
|
||||
FetchDetour: canonDetour,
|
||||
UA: s.UA ?? '',
|
||||
HWID: s.HWID ?? '',
|
||||
@@ -215,7 +319,7 @@ export function nextSubscription(
|
||||
f: SubForm,
|
||||
expireAlertDays: number | undefined,
|
||||
): Subscription {
|
||||
const proxy = f.FetchVia === 'proxy'
|
||||
const proxy = classifyFetchVia(f.FetchVia) === 'proxy'
|
||||
const headers = f.Headers.filter((h) => h.key.trim()).map(
|
||||
(h) => `${h.key.trim()}: ${h.value.trim()}`,
|
||||
)
|
||||
@@ -227,7 +331,10 @@ export function nextSubscription(
|
||||
Name: f.Name.trim() || base.Name,
|
||||
URL: f.URL.trim() || base.URL,
|
||||
UpdateInterval: str(f.UpdateInterval),
|
||||
FetchVia: proxy ? 'proxy' : undefined,
|
||||
// Three states, and only ONE of them writes nothing. An explicit `direct`
|
||||
// must be written out: absence means inherit now, so collapsing the two
|
||||
// would hand this feed to whatever the general fetch route becomes later.
|
||||
FetchVia: fetchViaStored(f.FetchVia),
|
||||
// The detour only rides along when fetching via proxy and it isn't plain Direct.
|
||||
FetchDetour: proxy && f.FetchDetour !== 'direct' ? f.FetchDetour : undefined,
|
||||
UA: str(f.UA),
|
||||
|
||||
+11
-2
@@ -66,7 +66,7 @@ import type { StatusWarning, Subscription } from './api'
|
||||
// Explicit extension: this module is exercised by `npm test` under node's own
|
||||
// loader, which does not do bundler-style extension guessing. Same reason as
|
||||
// intercept.ts's import of planeState.
|
||||
import { proxyFetchGoesDirect } from './subEdit.ts'
|
||||
import { classifyFetchVia, proxyFetchGoesDirect } from './subEdit.ts'
|
||||
|
||||
/**
|
||||
* What the row should say about the fetch route. A CLOSED set — the caller
|
||||
@@ -211,7 +211,16 @@ export function fetchBadge(sub: Subscription, findings: StatusWarning[]): FetchB
|
||||
}
|
||||
}
|
||||
|
||||
if ((sub.FetchVia ?? '') !== 'proxy') return null
|
||||
// Read through the shared classifier, not a bare `!== 'proxy'`. The daemon
|
||||
// compares case- and space-insensitively (model.ClassifyFetchVia), so a stored
|
||||
// `Proxy` is proxy to the engine; a strict compare here would have drawn no
|
||||
// badge at all over a configuration that can leak.
|
||||
//
|
||||
// Both other real states pass through silently AND HONESTLY: an explicit
|
||||
// `direct` claims nothing about a tunnel, and `inherit` claims nothing either
|
||||
// — its route is the general fetch route, which this row does not own and the
|
||||
// options editor names in full.
|
||||
if (classifyFetchVia(sub.FetchVia) !== 'proxy') return null
|
||||
|
||||
if (proxyFetchGoesDirect(sub)) {
|
||||
// No URL is the only quiet answer, and it is quiet because the fetch is
|
||||
|
||||
@@ -147,7 +147,6 @@ CANARY_FILES=(
|
||||
/etc/shater/boot.nft
|
||||
/etc/config/shater
|
||||
/etc/config/network
|
||||
/etc/config/byedpi
|
||||
/etc/config/firewall
|
||||
/etc/config/dhcp
|
||||
/var/run/shaterd.pid
|
||||
@@ -156,7 +155,6 @@ CANARY_FILES=(
|
||||
/var/run/shater.active
|
||||
/var/log/shaterd.log
|
||||
/var/lock/shater.lock
|
||||
/usr/bin/ciadpi
|
||||
/tmp/dhcp.leases
|
||||
/tmp/shater-stats.db
|
||||
/tmp/shater-cache.db
|
||||
|
||||
+19
-3
@@ -721,9 +721,25 @@ else
|
||||
priv_bad=1
|
||||
fi
|
||||
done <<<"$priv_expect"
|
||||
if [ "$priv_rc" -ne 0 ] && [ "$priv_bad" -eq 0 ]; then
|
||||
echo " FAILED [privileged]: go test exited $priv_rc with every named test accounted for —" >&2
|
||||
echo " a build or package-level failure, see the log above." >&2
|
||||
# The runner's own exit status is reported WHATEVER the per-name verdicts say.
|
||||
# It used to be reported only when every name was accounted for, on the
|
||||
# reasoning that a named failure already explains the status — but the case
|
||||
# that actually happens is the opposite one: the run dies at package level, the
|
||||
# loop above prints MISSING for every name because none of them produced a
|
||||
# verdict, and the one line naming the cause was the one suppressed. A reader
|
||||
# then goes looking for three vanished tests instead of at the build error.
|
||||
# Same verdict either way (priv_bad, hence FAILED, is set in both branches) —
|
||||
# what changes is whether the log says why.
|
||||
if [ "$priv_rc" -ne 0 ]; then
|
||||
if [ "$priv_bad" -eq 0 ]; then
|
||||
echo " FAILED [privileged]: go test exited $priv_rc with every named test accounted for —" >&2
|
||||
echo " a build or package-level failure, see the log above." >&2
|
||||
else
|
||||
echo " FAILED [privileged]: go test exited $priv_rc as well — the verdicts above are" >&2
|
||||
echo " the symptom. A run that dies at package level names no test, so" >&2
|
||||
echo " MISSING lines are what that looks like from here; the cause is in" >&2
|
||||
echo " the log above, not in the tests they name." >&2
|
||||
fi
|
||||
priv_bad=1
|
||||
fi
|
||||
if [ "$priv_bad" -ne 0 ]; then
|
||||
|
||||
+67
-6
@@ -413,6 +413,44 @@ func (a *Applier) HTTPClient(via string) (*http.Client, error) {
|
||||
return a.eng.HTTPClient(via)
|
||||
}
|
||||
|
||||
// subFetchNeedsEngine reports whether a RESOLVED subscription fetch has to be
|
||||
// dialled through the running box, or may go straight out of this process
|
||||
// (subscribe.Fetch treats a nil client as a plain direct client).
|
||||
//
|
||||
// The switch is closed and positive over model.FetchViaMode, and the three live
|
||||
// arms are three different decisions rather than one test on the resolved string:
|
||||
//
|
||||
// - `direct` is the operator saying "in the clear", explicitly. No engine, and
|
||||
// no dependency on one — which is what makes it the documented escape from the
|
||||
// first-boot deadlock (see subBootstrapDeadlockMessage).
|
||||
// - `proxy` goes through the engine ALWAYS, empty detour included. That is not an
|
||||
// oversight being preserved: an empty detour resolves to the box's own `direct`
|
||||
// outbound, apply/warnings.go grades that critical by name, and quietly moving
|
||||
// it onto a process-local client instead would change which of the two the
|
||||
// operator is being warned about while leaving the disclosure identical.
|
||||
// - ABSENT inherits the general detour, so it needs the engine exactly when that
|
||||
// detour names something other than `direct`. This arm is the whole behaviour
|
||||
// change of schema v3, and the migration wrote an explicit `direct` onto every
|
||||
// existing subscription so no deployed config reaches it by accident.
|
||||
//
|
||||
// FetchViaUnknown cannot arrive: SubscriptionFetchDetour refuses it and the caller
|
||||
// returns before asking. It is listed anyway so a value that is not a decision can
|
||||
// never be answered as if it were one.
|
||||
func subFetchNeedsEngine(via model.FetchViaMode, detour string) bool {
|
||||
switch via {
|
||||
case model.FetchViaDirect:
|
||||
return false
|
||||
case model.FetchViaProxy:
|
||||
return true
|
||||
case model.FetchViaInherit:
|
||||
d := strings.TrimSpace(detour)
|
||||
return d != "" && !strings.EqualFold(d, model.TargetDirect)
|
||||
case model.FetchViaUnknown:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chainViaPrefix is the `via`/detour selector that names a multi-hop chain.
|
||||
const chainViaPrefix = "chain:"
|
||||
|
||||
@@ -660,7 +698,11 @@ func (a *Applier) runningTags() map[string]bool {
|
||||
// outbound named by its FetchDetour (group:/node:/egress:/chain:/direct); a detour
|
||||
// that cannot be resolved FAILS the update rather than falling back — a silent
|
||||
// direct fetch would put the feed, and the owner's real address, on the plain WAN.
|
||||
// With FetchVia!="proxy" the fetch is DIRECT, as configured.
|
||||
// With FetchVia=="direct" the fetch is in the clear, as configured. With FetchVia
|
||||
// ABSENT the GENERAL detour applies (globals.fetch_detour, or the active WAN
|
||||
// profile's override) — see subFetchNeedsEngine for the whole table, and
|
||||
// model.SubscriptionFetchDetour for the resolution the three consumers share.
|
||||
// Any OTHER value of FetchVia is refused by name rather than demoted to direct.
|
||||
// Zero-node safety is inherited from subscribe.UpdateSubscription
|
||||
// (a bad body leaves the cache untouched); nothing is written on a failed parse.
|
||||
// Returns the node count now cached.
|
||||
@@ -733,13 +775,32 @@ func (a *Applier) UpdateSubscription(name string) (added int, err error) {
|
||||
"an explicit request was made for it by name. The scheduled refresh does not touch it.")
|
||||
}
|
||||
|
||||
// Resolve the fetch client: through the tunnel when fetch_via=proxy, else direct
|
||||
// (subscribe.Fetch treats a nil client as a plain direct client).
|
||||
// Resolve the fetch client. THREE states now, not two, and the third is the
|
||||
// point: `fetch_via` ABSENT no longer means `direct`, it means "use the general
|
||||
// fetch detour" (globals.fetch_detour, or the active WAN profile's override) —
|
||||
// which is the setting that exists so a router behind an operator whitelist can
|
||||
// reach its provider at all. model.SubscriptionFetchDetour is the ONE resolver
|
||||
// for that table, shared with generate and the panel, so the three cannot drift
|
||||
// into three different answers again.
|
||||
//
|
||||
// A FOURTH state is what the two-way test used to swallow. `EqualFold(FetchVia,
|
||||
// "proxy")` sent every unrecognised value — a typo, a word from another product
|
||||
// — down the else branch and out over the plain WAN, successfully, with the feed
|
||||
// URL and this router's real address on it and nothing anywhere to see. ok=false
|
||||
// is that state named; there is no safe side to pick for it, so this refuses.
|
||||
var client *http.Client
|
||||
if strings.EqualFold(strings.TrimSpace(sub.FetchVia), "proxy") {
|
||||
client, err = a.HTTPClient(sub.FetchDetour)
|
||||
prof, _ := model.ResolveActiveProfile(m) // the warnings are the apply path's to report
|
||||
detour, viaOK := model.SubscriptionFetchDetour(*sub, m.Globals, prof)
|
||||
if !viaOK {
|
||||
return 0, fmt.Errorf("subscription %q: fetch_via %q is not a value this option has — "+
|
||||
"the accepted values are %s, and leaving it out means \"use the general fetch_detour\". "+
|
||||
"REFUSING to fetch rather than guess: the guess this used to make was a fetch in the clear",
|
||||
name, sub.FetchVia, strings.Join(model.FetchViaNames, "/"))
|
||||
}
|
||||
if subFetchNeedsEngine(model.ClassifyFetchVia(sub.FetchVia), detour.Value) {
|
||||
client, err = a.HTTPClient(detour.Value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("subscription %q detour %q: %w", name, sub.FetchDetour, err)
|
||||
return 0, fmt.Errorf("subscription %q detour %q (from %s): %w", name, detour.Value, detour.From, err)
|
||||
}
|
||||
// engine.HTTPClient builds a FRESH http.Transport per call, and its dialer
|
||||
// closes over the resolved outbound. Dropping the client on the floor leaves
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
package apply
|
||||
|
||||
// Three gradings this file owns, and one plumbing job that had never been wired.
|
||||
//
|
||||
// 1. FETCH-DETOUR-NOT-APPLIED — the operator aimed every list/geo/rule-set
|
||||
// download at a tunnel, the name resolves to nothing, and the downloads are
|
||||
// going out over the plain WAN instead. Critical, and driven from the REAL
|
||||
// producer so a reword in generate fails here rather than going quiet.
|
||||
// 2. RULESET-FROM-CACHE — the source could not be checked and the engine's cache
|
||||
// holds a copy it can load, so the list IS applied and IS matching. Degraded,
|
||||
// NOT critical. Behind a whitelisting SIM operator this is the STEADY state,
|
||||
// so grading it critical means a permanently red banner over working lists.
|
||||
// 3. model.ValidateProfiles — written, tested, and called by nothing. A check
|
||||
// nobody runs reads exactly like a check that passes.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/generate"
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
|
||||
// unfetchableSRSURL is a rule-set URL that fails at http.NewRequest, before any
|
||||
// socket is opened. That is deliberate: the tests below need generate to ATTEMPT a
|
||||
// remote rule-set (which is what makes it resolve the fetch detour) without the
|
||||
// suite doing real network I/O, and without a three-second timeout in the middle
|
||||
// of a unit test. The ".srs" suffix is load-bearing — ruleSetURLIsEngineNative
|
||||
// decides remote-vs-locally-compiled by extension alone.
|
||||
const unfetchableSRSURL = "http://%zz/blocklist.srs"
|
||||
|
||||
// fetchDetourModel is a router-shaped configuration with one remote blocklist, so
|
||||
// the fetch detour has something to be stamped on, plus the node and group a
|
||||
// working detour would name.
|
||||
func fetchDetourModel(globalsDetour string, profiles ...model.Profile) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.KillSwitch = "closed"
|
||||
g.Untunnelable = netplane.UntunnelableBlock
|
||||
g.DNSFilter = true
|
||||
g.ResolverDefault = "up"
|
||||
g.FetchDetour = globalsDetour
|
||||
if len(profiles) > 0 {
|
||||
g.ActiveProfile = profiles[0].Name
|
||||
}
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Profiles: profiles,
|
||||
Inbounds: []model.Inbound{{
|
||||
Name: "lan", Enabled: true, Type: "tproxy", Network: "lan",
|
||||
TproxyPort: 12345, TCP: true, UDP: true,
|
||||
}},
|
||||
Resolvers: []model.Resolver{{Name: "up", Type: "doh", Address: "https://dns.quad9.net/dns-query"}},
|
||||
Nodes: []model.Node{{
|
||||
Name: "tokyo", Enabled: true,
|
||||
URI: "vless://11111111-1111-1111-1111-111111111111@example.com:443?security=tls&sni=example.com#tokyo",
|
||||
}},
|
||||
Groups: []model.Group{{Name: "auto", Source: "manual", Strategy: "single", Nodes: []string{"tokyo"}}},
|
||||
Blocklists: []model.Blocklist{{
|
||||
Name: "apple", Enabled: true, Source: "url", URL: unfetchableSRSURL, UpdateInterval: "24h",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// findingsMentioning returns the PUBLISHED findings whose text contains sub. It
|
||||
// never searches by tag: the tag is what is under test and must not also be the
|
||||
// search key, or a renamed tag would simply return nothing and pass.
|
||||
func findingsMentioning(t *testing.T, m *model.Model, sub string) []Warning {
|
||||
t.Helper()
|
||||
_, warns, err := generate.GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("generate.GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if len(warns) == 0 {
|
||||
t.Fatalf("the producer emitted NO warnings at all — the fixture is what failed, not the grading")
|
||||
}
|
||||
var out []Warning
|
||||
for _, w := range gatherWarnings(m, warns, nil, nil) {
|
||||
if strings.Contains(w.Message, sub) {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFetchDetourNotAppliedIsCriticalFromTheRealProducer is the coupling, not a
|
||||
// fixture: it configures a fetch detour that names nothing, runs the real
|
||||
// generator, and asks the real classifier what the real text grades as. The only
|
||||
// way it passes is if the tag generate stamps is the tag notAppliedTags knows.
|
||||
//
|
||||
// MUTATION THAT MUST KILL IT: rename fetchDetourNotAppliedTag in
|
||||
// generate/dnsfilter.go, or drop tagFetchDetourNotApplied from notAppliedTags.
|
||||
func TestFetchDetourNotAppliedIsCriticalFromTheRealProducer(t *testing.T) {
|
||||
got := findingsMentioning(t, fetchDetourModel("group:ghost"), "group:ghost")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want exactly 1 finding naming the bad detour, got %d: %+v", len(got), got)
|
||||
}
|
||||
w := got[0]
|
||||
if w.Severity != SeverityCritical {
|
||||
t.Fatalf("graded %q, want %q. Every list, geo and rule-set download is leaving over the plain "+
|
||||
"WAN with the router's real address while the configuration says otherwise — if the panel's "+
|
||||
"banner is dark for that, it is dark for the whole class.\nmessage:\n%s",
|
||||
w.Severity, SeverityCritical, w.Message)
|
||||
}
|
||||
if w.Section != "globals" || w.Name != "fetch_detour" {
|
||||
t.Errorf("a globals-level fault must be attributed to globals; section=%q name=%q", w.Section, w.Name)
|
||||
}
|
||||
if !strings.Contains(w.Message, tagFetchDetourNotApplied) {
|
||||
t.Errorf("the tag must survive into the published message — it is the `logread` grep handle:\n%s", w.Message)
|
||||
}
|
||||
|
||||
// CONTROL 1: the same model with a detour that RESOLVES must produce nothing.
|
||||
// Without it, a classifier that graded every generate warning critical would
|
||||
// pass everything above.
|
||||
for _, w := range findingsMentioning(t, fetchDetourModel("group:auto"), "fetch_detour") {
|
||||
if strings.Contains(w.Message, tagFetchDetourNotApplied) {
|
||||
t.Errorf("a detour that resolves must produce no not-applied finding:\n%s", w.Message)
|
||||
}
|
||||
}
|
||||
// CONTROL 2: and so must NOT CONFIGURING the option at all.
|
||||
for _, w := range findingsMentioning(t, fetchDetourModel(""), "fetch_detour") {
|
||||
if strings.Contains(w.Message, tagFetchDetourNotApplied) {
|
||||
t.Errorf("an unset optional override is not a fault:\n%s", w.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourFromAProfileIsBadgedOnThatProfile: the fault is attributed to
|
||||
// whoever wrote the value. A profile override only misbehaves on its own uplink,
|
||||
// so an operator who cannot see WHICH profile carries the bad name has nothing to
|
||||
// act on — and the panel would badge the wrong page.
|
||||
func TestFetchDetourFromAProfileIsBadgedOnThatProfile(t *testing.T) {
|
||||
m := fetchDetourModel("direct", model.Profile{
|
||||
Name: "mobile-uplink", Enabled: true, FetchDetour: "group:sim-bypass",
|
||||
})
|
||||
got := findingsMentioning(t, m, "group:sim-bypass")
|
||||
var tagged []Warning
|
||||
for _, w := range got {
|
||||
if strings.Contains(w.Message, tagFetchDetourNotApplied) {
|
||||
tagged = append(tagged, w)
|
||||
}
|
||||
}
|
||||
if len(tagged) != 1 {
|
||||
t.Fatalf("want exactly 1 tagged finding, got %d: %+v", len(tagged), got)
|
||||
}
|
||||
if tagged[0].Severity != SeverityCritical {
|
||||
t.Errorf("severity = %q, want critical", tagged[0].Severity)
|
||||
}
|
||||
if tagged[0].Section != "profile" || tagged[0].Name != "mobile-uplink" {
|
||||
t.Errorf("section=%q name=%q, want profile/mobile-uplink so the panel badges the profile that "+
|
||||
"carries the value", tagged[0].Section, tagged[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4b: a stale list is not a missing list ----------------------------------
|
||||
|
||||
// The two texts, VERBATIM from generate/ruleset.go remoteRuleSetAs. They are typed
|
||||
// out here because apply cannot stub generate's reachability probe or seed
|
||||
// sing-box's cache DB from outside the package, so the producer-driven form is not
|
||||
// available for this pair. The producer half of the binding lives in
|
||||
// generate/fetchdetour_lists_test.go
|
||||
// (TestFetchDetourCachedListAlsoSaysAppliedFromTheCache), which drives the REAL
|
||||
// cold-start branch and asserts the phrase this file grades on still appears in
|
||||
// it — with the no-cache control beside it. Reword the branch and that test fails
|
||||
// by name.
|
||||
const (
|
||||
rulesetFromCacheText = `RULESET-FROM-CACHE: blocklist "apple" could not be checked against its source ` +
|
||||
`(its source "https://x/geosite-apple.srs" is unreachable right now), but the cache holds a copy of ` +
|
||||
`rule-set "bl-apple-apple" that the engine can load (fetched 2026-07-26T09:00:00Z), so it is ` +
|
||||
`APPLIED FROM THE CACHE — this is what lets a reboot with no working WAN still come up with its lists. ` +
|
||||
`That copy will not change until the source is reachable again, so the list is as old as the timestamp says.`
|
||||
|
||||
rulesetNotAppliedText = `RULESET-NOT-APPLIED: blocklist "apple" is configured but NOT ACTIVE: its source ` +
|
||||
`"https://x/geosite-apple.srs" is unreachable right now, so rule-set "bl-apple-apple" was omitted and ` +
|
||||
`matches NOTHING until it loads (a blocklist blocks nothing; a routing rule is skipped). ` +
|
||||
`There is no usable copy in the cache either, so there is nothing to fall back to.`
|
||||
)
|
||||
|
||||
// TestCachedRulesetIsDegradedAndOmittedRulesetIsCritical is the WHOLE instrument
|
||||
// in one test, because a one-sided assertion here is worthless.
|
||||
//
|
||||
// The classifier had exactly one behaviour for both texts — section "blocklist" is
|
||||
// in protectionSections, so anything about it graded critical — and the panel
|
||||
// painted a list that IS loaded and IS matching in the same red as a list that is
|
||||
// absent. On the router this ships to, the cached case is not a blip: the SIM
|
||||
// operator's whitelist makes the source permanently unreachable, so that red was
|
||||
// going to be lit on every apply, forever, until nobody read the next one.
|
||||
//
|
||||
// The POSITIVE CONTROL is the second half: the same instrument, over the sibling
|
||||
// text produced by the sibling branch of the same function, must still say
|
||||
// critical. Without it, "the marker demoted it" is indistinguishable from "this
|
||||
// classifier cannot produce a critical for a blocklist at all" — which is the
|
||||
// failure mode a marker list invites, and the one this project has already paid
|
||||
// for once.
|
||||
func TestCachedRulesetIsDegradedAndOmittedRulesetIsCritical(t *testing.T) {
|
||||
cached := oneGenerateFinding(t, rulesetFromCacheText)
|
||||
if cached.Severity != SeverityWarning {
|
||||
t.Errorf("a list served FROM THE CACHE graded %q, want %q — it is applied and it is matching; the "+
|
||||
"copy is stale, which is a degradation, not a protection gap. A permanent red here is how the "+
|
||||
"next real red stops being read.", cached.Severity, SeverityWarning)
|
||||
}
|
||||
|
||||
omitted := oneGenerateFinding(t, rulesetNotAppliedText)
|
||||
if omitted.Severity != SeverityCritical {
|
||||
t.Fatalf("POSITIVE CONTROL FAILED: a list that was OMITTED graded %q, want %q. The demotion above "+
|
||||
"proves nothing if this instrument can no longer produce a critical for a blocklist at all.",
|
||||
omitted.Severity, SeverityCritical)
|
||||
}
|
||||
|
||||
// Both must still be attributed to the list, or the panel cannot tell the
|
||||
// operator WHICH list it is talking about in either state.
|
||||
for _, w := range []Warning{cached, omitted} {
|
||||
if w.Section != "blocklist" || w.Name != "apple" {
|
||||
t.Errorf("section=%q name=%q, want blocklist/apple:\n%s", w.Section, w.Name, w.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oneGenerateFinding publishes a single generate-channel text and returns the one
|
||||
// finding it becomes.
|
||||
func oneGenerateFinding(t *testing.T, text string) Warning {
|
||||
t.Helper()
|
||||
got := collectWarnings(blockModel(), []string{text}, nil, nil)
|
||||
var out []Warning
|
||||
for _, w := range got {
|
||||
if w.Section == "blocklist" {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("want exactly 1 blocklist finding, got %d: %+v", len(out), got)
|
||||
}
|
||||
return out[0]
|
||||
}
|
||||
|
||||
// --- 4a: the validator nobody called -----------------------------------------
|
||||
|
||||
// TestValidateProfilesReachesTheStatus: model.ValidateProfiles existed, was
|
||||
// tested, and was referenced by nothing but its own test — model.(*Model).Validate
|
||||
// does not call it, so every per-profile override went unchecked on every path
|
||||
// that matters. This pins that its findings now reach the published set, with the
|
||||
// Section/Name the panel needs to badge the profile.
|
||||
//
|
||||
// A profile override is the setting whose failure is invisible by construction: it
|
||||
// only applies while that profile is active, so a typo in the SIM profile's
|
||||
// resolver is not observable at all until the ethernet cable comes out — at which
|
||||
// point DNS stops and the config that broke it looks exactly like the config that
|
||||
// worked.
|
||||
func TestValidateProfilesReachesTheStatus(t *testing.T) {
|
||||
m := blockModel()
|
||||
m.Resolvers = []model.Resolver{{Name: "quad9", Type: "tls", Address: "9.9.9.9"}}
|
||||
m.Profiles = []model.Profile{{
|
||||
Name: "mobile-uplink", Enabled: true, ResolverDefault: "yandex-typo",
|
||||
}}
|
||||
|
||||
var found []Warning
|
||||
for _, w := range collectWarnings(m, nil, nil, nil) {
|
||||
if w.Section == "profile" {
|
||||
found = append(found, w)
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("want exactly 1 profile finding, got %d: %+v", len(found), found)
|
||||
}
|
||||
for _, want := range []string{"resolver_default", "yandex-typo", "config resolver"} {
|
||||
if !strings.Contains(found[0].Message, want) {
|
||||
t.Errorf("the finding must contain %q:\n%s", want, found[0].Message)
|
||||
}
|
||||
}
|
||||
if found[0].Name != "mobile-uplink" {
|
||||
t.Errorf("Name = %q, want the profile's own name", found[0].Name)
|
||||
}
|
||||
|
||||
// CONTROL: the SAME model with the resolver actually defined must be silent. A
|
||||
// validator that fired unconditionally would pass everything above.
|
||||
ok := blockModel()
|
||||
ok.Resolvers = []model.Resolver{{Name: "yandex-typo", Type: "udp", Address: "77.88.8.8"}}
|
||||
ok.Profiles = m.Profiles
|
||||
for _, w := range collectWarnings(ok, nil, nil, nil) {
|
||||
if w.Section == "profile" {
|
||||
t.Errorf("a profile whose override names a resolver that exists must be silent:\n%s", w.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PROFILE-OVERRIDE-NOT-APPLIED: graded by the tag, not by one word --------
|
||||
|
||||
// TestProfileOverrideNotAppliedIsGradedStructurally.
|
||||
//
|
||||
// generate/dns.go emits this when the active WAN profile names a resolver that is
|
||||
// not usable: the override is dead and the globals value resolves instead. It
|
||||
// ALREADY graded critical before the tag was added — by accident. The producer's
|
||||
// sentence contains the word "inert", criticalMarkers contains "inert", and that is
|
||||
// the whole of why. The word is there because it is the right word; nobody was
|
||||
// aiming at the classifier, and one rewording would have dropped a real DNS fault a
|
||||
// full severity with every test in the tree still green.
|
||||
//
|
||||
// So the assertions are in three steps, and the middle one is the actual claim:
|
||||
//
|
||||
// 1. the REAL producer's REAL text grades critical (and is attributed);
|
||||
// 2. the same text with the word "inert" gone STILL grades critical — that is what
|
||||
// "structural" means, and it is false of the old classifier;
|
||||
// 3. POSITIVE CONTROL: strip the TAG as well and the grading drops. Without this,
|
||||
// step 2 would be satisfied by a classifier that had simply started returning
|
||||
// critical for everything.
|
||||
func TestProfileOverrideNotAppliedIsGradedStructurally(t *testing.T) {
|
||||
m := fetchDetourModel("", model.Profile{
|
||||
Name: "mobile-uplink", Enabled: true, ResolverDefault: "yandex-typo",
|
||||
})
|
||||
real := producedWarningWithTag(t, m, tagProfileOverrideNotApplied)
|
||||
|
||||
first := oneProfileFinding(t, real)
|
||||
if first.Severity != SeverityCritical {
|
||||
t.Fatalf("the real producer text graded %q, want %q:\n%s", first.Severity, SeverityCritical, real)
|
||||
}
|
||||
if first.Name != "mobile-uplink" {
|
||||
t.Errorf("Name = %q, want the profile that carries the bad override", first.Name)
|
||||
}
|
||||
|
||||
// (2) THE CLAIM. Nothing about the grading may depend on that one word.
|
||||
noInert := strings.ReplaceAll(real, "inert", "not in force")
|
||||
if noInert == real {
|
||||
t.Fatalf("fixture: the producer text no longer contains \"inert\", so step 2 tests nothing. "+
|
||||
"Re-read it and pick the word this step is supposed to neutralise:\n%s", real)
|
||||
}
|
||||
if got := oneProfileFinding(t, noInert).Severity; got != SeverityCritical {
|
||||
t.Fatalf("with the word \"inert\" reworded the grading fell to %q. The severity is still riding on "+
|
||||
"a single word in someone else's prose, which is what %q was added to stop.",
|
||||
got, tagProfileOverrideNotApplied)
|
||||
}
|
||||
|
||||
// (3) POSITIVE CONTROL. Remove the tag too and the instrument must say something
|
||||
// ELSE, or steps 1 and 2 prove only that it cannot say anything but critical.
|
||||
noTag := strings.TrimPrefix(noInert, tagProfileOverrideNotApplied+": ")
|
||||
if noTag == noInert {
|
||||
t.Fatalf("fixture: the producer text does not start with %q, so the control cannot strip it:\n%s",
|
||||
tagProfileOverrideNotApplied, noInert)
|
||||
}
|
||||
if got := oneProfileFinding(t, noTag).Severity; got == SeverityCritical {
|
||||
t.Fatalf("CONTROL FAILED: an untagged, unmarked profile note also graded critical, so this " +
|
||||
"classifier cannot distinguish anything and steps 1-2 measured nothing.")
|
||||
}
|
||||
}
|
||||
|
||||
// producedWarningWithTag runs the real generator and returns the one warning
|
||||
// carrying tag, failing if there is not exactly one.
|
||||
func producedWarningWithTag(t *testing.T, m *model.Model, tag string) string {
|
||||
t.Helper()
|
||||
_, warns, err := generate.GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("generate.GenerateWithWarnings: %v", err)
|
||||
}
|
||||
var hits []string
|
||||
for _, w := range warns {
|
||||
if strings.HasPrefix(w, tag+":") {
|
||||
hits = append(hits, w)
|
||||
}
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want exactly 1 %s warning from the real producer, got %d. The fixture is what failed, "+
|
||||
"not the grading:\n %s", tag, len(hits), strings.Join(warns, "\n "))
|
||||
}
|
||||
return hits[0]
|
||||
}
|
||||
|
||||
// oneProfileFinding publishes one generate-channel text and returns the single
|
||||
// finding filed under section "profile".
|
||||
func oneProfileFinding(t *testing.T, text string) Warning {
|
||||
t.Helper()
|
||||
var out []Warning
|
||||
for _, w := range collectWarnings(blockModel(), []string{text}, nil, nil) {
|
||||
if w.Section == "profile" {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("want exactly 1 profile finding, got %d, for:\n%s", len(out), text)
|
||||
}
|
||||
return out[0]
|
||||
}
|
||||
|
||||
// --- the three-state fetch_via, as the fetch itself reads it -----------------
|
||||
|
||||
// TestSubFetchNeedsEngine pins the table apply.UpdateSubscription decides on.
|
||||
//
|
||||
// The row that matters is the LAST group: `fetch_via` ABSENT no longer means
|
||||
// direct. It inherits the general detour, so the same subscription needs the
|
||||
// engine or does not depending on a setting written somewhere else entirely —
|
||||
// which is exactly the state the old `EqualFold(FetchVia, "proxy")` could not
|
||||
// express and answered `direct` for.
|
||||
func TestSubFetchNeedsEngine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
via model.FetchViaMode
|
||||
detour string
|
||||
want bool
|
||||
}{
|
||||
{"explicit direct never needs the engine", model.FetchViaDirect, "", false},
|
||||
{"explicit direct, stale detour beside it, still no engine", model.FetchViaDirect, "group:auto", false},
|
||||
// Unchanged on purpose: an empty detour under `proxy` resolves to the box's
|
||||
// own direct outbound, and warnings.go grades that critical by name. Moving
|
||||
// it onto a process-local client would change which finding the operator
|
||||
// gets while leaving the disclosure identical.
|
||||
{"proxy with a detour", model.FetchViaProxy, "group:auto", true},
|
||||
{"proxy with NO detour still goes through the box", model.FetchViaProxy, "", true},
|
||||
{"absent + no general detour = plain fetch", model.FetchViaInherit, "", false},
|
||||
{"absent + general detour 'direct' = plain fetch", model.FetchViaInherit, "direct", false},
|
||||
{"absent + general detour 'DIRECT' = plain fetch", model.FetchViaInherit, " DIRECT ", false},
|
||||
{"absent + general detour naming a group = through the box", model.FetchViaInherit, "group:sim-bypass", true},
|
||||
{"absent + general detour naming a chain = through the box", model.FetchViaInherit, "chain:swan", true},
|
||||
// Never reached (SubscriptionFetchDetour refuses first), and listed so a
|
||||
// value that is not a decision can never be answered as if it were one.
|
||||
{"unknown is not answered as proxy", model.FetchViaUnknown, "group:auto", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subFetchNeedsEngine(tc.via, tc.detour); got != tc.want {
|
||||
t.Errorf("%s: subFetchNeedsEngine(%v, %q) = %v, want %v", tc.name, tc.via, tc.detour, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInheritedDetourThatNamesNothingIsReported closes the gap the three-state
|
||||
// contract opened on this side.
|
||||
//
|
||||
// Before, a subscription with no `fetch_via` was fetched in the clear, always, and
|
||||
// could not fail for a reason written somewhere else. Now the general detour
|
||||
// applies to it — so a typo in `globals.fetch_detour` stops every such
|
||||
// subscription refreshing, and nothing else would say so: generate's
|
||||
// FETCH-DETOUR-NOT-APPLIED needs a remote rule-set to stamp the detour on, and a
|
||||
// router with none has no producer for this at all.
|
||||
//
|
||||
// It is a `warning`, not a critical: HTTPClient refuses the unknown name, so the
|
||||
// fetch FAILS rather than falling back to the clear. Nothing is disclosed; a node
|
||||
// list goes stale.
|
||||
func TestInheritedDetourThatNamesNothingIsReported(t *testing.T) {
|
||||
m := blockModel()
|
||||
m.Globals.FetchDetour = "group:ghost"
|
||||
// A manual node, so this is NOT also the first-boot deadlock. The two findings
|
||||
// are different faults with different fixes, and a fixture that triggers both
|
||||
// would let either one satisfy the assertions below.
|
||||
m.Nodes = []model.Node{{Name: "tokyo", Enabled: true, URI: "ss://tokyo"}}
|
||||
m.Subscriptions = []model.Subscription{{
|
||||
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=t",
|
||||
}}
|
||||
got := subscriptionFindings(t, m)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want exactly 1 subscription finding, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Severity != SeverityWarning {
|
||||
t.Errorf("severity = %q, want %q — a refused fetch discloses nothing", got[0].Severity, SeverityWarning)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"sets no `fetch_via`", // why this row is affected at all
|
||||
"`globals.fetch_detour`", // and WHERE the operator must go to fix it
|
||||
`no group named "ghost"`, // what is actually wrong
|
||||
"every subscription", // the blast radius: it is not this row's fault
|
||||
} {
|
||||
if !strings.Contains(got[0].Message, want) {
|
||||
t.Errorf("the message must contain %q:\n%s", want, got[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL 1: a general detour that RESOLVES must be silent.
|
||||
ok := blockModel()
|
||||
ok.Globals.FetchDetour = "node:tokyo"
|
||||
ok.Nodes = []model.Node{{Name: "tokyo", Enabled: true, URI: "ss://tokyo"}}
|
||||
ok.Subscriptions = m.Subscriptions
|
||||
if got := subscriptionFindings(t, ok); len(got) != 0 {
|
||||
t.Errorf("a general detour that names a real node must be silent: %+v", got)
|
||||
}
|
||||
// CONTROL 2: and so must NO general detour, which is what "not set" has always
|
||||
// meant and must keep meaning.
|
||||
plain := blockModel()
|
||||
plain.Nodes = m.Nodes
|
||||
plain.Subscriptions = m.Subscriptions
|
||||
if got := subscriptionFindings(t, plain); len(got) != 0 {
|
||||
t.Errorf("with no general detour the fetch is plain, exactly as before: %+v", got)
|
||||
}
|
||||
// CONTROL 3: an EXPLICIT `fetch_via=direct` opts out of the general setting, so
|
||||
// the bad globals value must not be reported against it. This is the whole point
|
||||
// of the migration having written `direct` onto every existing subscription.
|
||||
explicit := blockModel()
|
||||
explicit.Globals.FetchDetour = "group:ghost"
|
||||
// The node again: this control is about ONE thing, and a nodeless model would
|
||||
// also arm the deadlock detector, so a regression there could fail it for a
|
||||
// reason that has nothing to do with what it asserts.
|
||||
explicit.Nodes = m.Nodes
|
||||
explicit.Subscriptions = []model.Subscription{{
|
||||
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=t", FetchVia: "direct",
|
||||
}}
|
||||
if got := subscriptionFindings(t, explicit); len(got) != 0 {
|
||||
t.Errorf("an explicit fetch_via=direct ignores the general detour and must not be badged: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// subscriptionFindings is what the panel receives for section "subscription".
|
||||
func subscriptionFindings(t *testing.T, m *model.Model) []Warning {
|
||||
t.Helper()
|
||||
var out []Warning
|
||||
for _, w := range collectWarnings(m, nil, nil, nil) {
|
||||
if w.Section == subscriptionSection {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBootstrapDeadlockSeesAnInheritedDetour: the deadlock detector asks "is there
|
||||
// any subscription that could be fetched with NO outbound?", and it used to answer
|
||||
// that from `fetch_via` alone. Once an absent `fetch_via` began inheriting
|
||||
// globals.fetch_detour, that answer became wrong in the expensive direction — the
|
||||
// detector would declare the deadlock broken by a subscription that is itself part
|
||||
// of it, and go silent on a router whose LAN is dark and whose panel looks healthy.
|
||||
func TestBootstrapDeadlockSeesAnInheritedDetour(t *testing.T) {
|
||||
stuck := blockModel()
|
||||
stuck.Globals.FetchDetour = "group:auto"
|
||||
stuck.Subscriptions = []model.Subscription{{
|
||||
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=t",
|
||||
// No FetchVia at all: the general detour applies.
|
||||
}}
|
||||
var got []Warning
|
||||
for _, w := range collectWarnings(stuck, nil, nil, nil) {
|
||||
if strings.Contains(w.Message, "DEADLOCK") {
|
||||
got = append(got, w)
|
||||
}
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want the deadlock named exactly once, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Severity != SeverityCritical || got[0].Name != "qomar" {
|
||||
t.Errorf("severity=%q name=%q, want critical/qomar", got[0].Severity, got[0].Name)
|
||||
}
|
||||
|
||||
// CONTROL: the same subscription with NO general detour can bootstrap in the
|
||||
// clear, so there is no deadlock and the detector must stay silent. Without
|
||||
// this, a detector that fired on every proxy-less config would pass above.
|
||||
free := blockModel()
|
||||
free.Subscriptions = stuck.Subscriptions
|
||||
for _, w := range collectWarnings(free, nil, nil, nil) {
|
||||
if strings.Contains(w.Message, "DEADLOCK") {
|
||||
t.Errorf("a subscription that fetches in the clear breaks the deadlock; it must not be reported:\n%s",
|
||||
w.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package apply
|
||||
|
||||
// THE PRIVATE-DESTINATION CHANNEL CARRIES TWO DIFFERENT ANSWERS.
|
||||
//
|
||||
// netplane's private-destination mechanism (netplane/coverage.go
|
||||
// privateRoutedPlan) produces two shapes of news, and the netplane channel grades
|
||||
// everything critical by default:
|
||||
//
|
||||
// a REFUSAL a rule names a private subnet and this plan will NOT route it —
|
||||
// because it collides with one of the router's own networks, or
|
||||
// because the router could not be enumerated at all. The rule looks
|
||||
// configured and does nothing. Critical, and the default is right.
|
||||
// a DISCLOSURE
|
||||
// an address list this plan never reads MIGHT contain private
|
||||
// destinations. It is unconditional by construction — "warn on
|
||||
// suspicion" would mean reading the list, which is precisely what
|
||||
// that stage cannot do — so it fires for rule-sets that are very
|
||||
// probably fine. Nothing is leaking and nothing is cut off; grading
|
||||
// it critical would spend the alarm banner on a maybe, every apply.
|
||||
//
|
||||
// WHY THIS TEST DRIVES THE REAL RENDERER. The discriminator is a substring of
|
||||
// netplane's prose, and this package already carries the scar of markers that
|
||||
// matched no living text. Nothing here is hand-typed prose: both cases build a
|
||||
// model, call netplane.RenderNftWithWarnings, and grade whatever comes back, so a
|
||||
// reworded producer fails this test BY NAME instead of silently reverting the
|
||||
// severity to critical.
|
||||
//
|
||||
// THE CONTROL IS THE SPLIT. An instrument that answered "warning" to everything
|
||||
// would pass a test that only checked the disclosure, and the wholesale-critical
|
||||
// code would pass a test that only checked the refusal. Only asserting that the
|
||||
// SAME instrument gives DIFFERENT answers to the two real texts distinguishes the
|
||||
// grading from either failure.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
|
||||
// privSevModel is one LAN plus whatever destination rule-set the case needs.
|
||||
// There is no ubus on a build machine, so netplane cannot enumerate this
|
||||
// router's networks — which is exactly the state the REFUSAL case needs, and is
|
||||
// irrelevant to the DISCLOSURE case.
|
||||
func privSevModel(sets []model.Ruleset) *model.Model {
|
||||
return &model.Model{
|
||||
Globals: model.Globals{
|
||||
Enabled: true, KillSwitch: "closed", IPv6: false,
|
||||
FwmarkBase: 0x2000, TableBase: 0x2000,
|
||||
},
|
||||
Inbounds: []model.Inbound{
|
||||
{Name: "lan", Enabled: true, Type: "tproxy", Network: "lan", TproxyPort: 12345, TCP: true, UDP: true},
|
||||
},
|
||||
Rulesets: sets,
|
||||
Rules: []model.Rule{
|
||||
{Name: "lab", Enabled: true, Order: 10, DstRuleset: []string{"lab"}, Target: "node:awghome"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// privSevFinding renders m through the REAL netplane renderer, pushes the result
|
||||
// through the real gatherWarnings, and returns the one finding whose message
|
||||
// contains phrase.
|
||||
func privSevFinding(t *testing.T, m *model.Model, phrase string) Warning {
|
||||
t.Helper()
|
||||
_, netWarns, err := netplane.RenderNftWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderNftWithWarnings: %v", err)
|
||||
}
|
||||
var hits []Warning
|
||||
for _, w := range gatherWarnings(m, nil, netWarns, nil) {
|
||||
if strings.Contains(w.Message, phrase) {
|
||||
hits = append(hits, w)
|
||||
}
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want exactly one finding containing %q, got %d; netplane said: %v", phrase, len(hits), netWarns)
|
||||
}
|
||||
return hits[0]
|
||||
}
|
||||
|
||||
func TestPrivateDestinationRefusalIsCriticalDisclosureIsNot(t *testing.T) {
|
||||
// REFUSAL: an explicitly named private subnet this plan will not route,
|
||||
// because it cannot prove the subnet is not one of the router's own.
|
||||
refusal := privSevFinding(t,
|
||||
privSevModel([]model.Ruleset{{
|
||||
Name: "lab", Type: "ipcidr", Source: "inline", Entries: []string{"10.10.10.0/24"},
|
||||
}}),
|
||||
"network inventory could not be read")
|
||||
if refusal.Severity != SeverityCritical {
|
||||
t.Fatalf("a rule that names a private subnet and does NOT route it graded %q, want %q: %s",
|
||||
refusal.Severity, SeverityCritical, refusal.Message)
|
||||
}
|
||||
if refusal.Section != "rule" || refusal.Name != "lab" {
|
||||
t.Fatalf("refusal is not attributed to the rule it is about: section=%q name=%q",
|
||||
refusal.Section, refusal.Name)
|
||||
}
|
||||
|
||||
// DISCLOSURE: a list this plan cannot read. Same channel, same instrument.
|
||||
disclosure := privSevFinding(t,
|
||||
privSevModel([]model.Ruleset{{
|
||||
Name: "lab", Type: "ipcidr", Source: "url", URL: "https://example.invalid/lab.srs",
|
||||
}}),
|
||||
"address list whose contents this data plane never sees")
|
||||
if disclosure.Severity != SeverityWarning {
|
||||
t.Fatalf("a blind-spot disclosure graded %q, want %q — a red that means \"probably fine\" is how "+
|
||||
"the next red stops being read: %s", disclosure.Severity, SeverityWarning, disclosure.Message)
|
||||
}
|
||||
if disclosure.Section != "ruleset" || disclosure.Name != "lab" {
|
||||
t.Fatalf("disclosure is not attributed to the rule-set it is about: section=%q name=%q",
|
||||
disclosure.Section, disclosure.Name)
|
||||
}
|
||||
|
||||
// The split itself. Two texts, one instrument, two answers.
|
||||
if refusal.Severity == disclosure.Severity {
|
||||
t.Fatalf("the netplane channel gave both private-destination texts the same grade (%q), so this "+
|
||||
"test would pass against an instrument that grades nothing", refusal.Severity)
|
||||
}
|
||||
}
|
||||
+196
-29
@@ -114,6 +114,26 @@ const (
|
||||
tagRulesetNotApplied = "RULESET-NOT-APPLIED" // generate/ruleset.go:821,997,1242
|
||||
tagDNSFilterNotApplied = "DNS-FILTER-NOT-APPLIED" // generate/dnsfilter.go:132
|
||||
tagDeviceFilterNotApplied = "DEVICE-FILTER-NOT-APPLIED" // generate/devices.go deviceFilterNotAppliedTag
|
||||
// generate/dnsfilter.go fetchDetourNotAppliedTag. The operator pointed every
|
||||
// list/geo/rule-set download at a tunnel and the download is going out over the
|
||||
// plain WAN instead, because the name resolves to nothing this config builds.
|
||||
// Two protections are off at once — the disclosure fetch_detour prevents, and,
|
||||
// on an uplink that refuses those sources, the lists themselves. It carries an
|
||||
// entity prefix (`profile "x"` / `globals "fetch_detour"`), but neither section
|
||||
// is in protectionSections, so without the tag it would publish as a plain
|
||||
// warning.
|
||||
tagFetchDetourNotApplied = "FETCH-DETOUR-NOT-APPLIED"
|
||||
// generate/dns.go profileOverrideNotAppliedTag. The active WAN profile named a
|
||||
// resolver that is not usable, so its DNS override is INERT and the globals
|
||||
// value is what resolves while that profile is live.
|
||||
//
|
||||
// It already graded critical before this entry existed — but by ACCIDENT: the
|
||||
// producer's sentence happens to contain the word "inert", which is in
|
||||
// criticalMarkers. That word is there because it is the right word, not because
|
||||
// anyone was aiming at the classifier, and one rewording would have dropped the
|
||||
// finding a whole severity with every test still green. The tag makes the
|
||||
// grading STRUCTURAL, which is the same reason the three above exist.
|
||||
tagProfileOverrideNotApplied = "PROFILE-OVERRIDE-NOT-APPLIED"
|
||||
)
|
||||
|
||||
var notAppliedTags = []string{
|
||||
@@ -131,6 +151,8 @@ var notAppliedTags = []string{
|
||||
// wash that distinction out — a device that is simply off the network right now
|
||||
// is not a protection gap.
|
||||
tagDeviceFilterNotApplied,
|
||||
tagFetchDetourNotApplied,
|
||||
tagProfileOverrideNotApplied,
|
||||
}
|
||||
|
||||
// criticalMarkers are substrings that identify a warning as "protection you
|
||||
@@ -217,8 +239,26 @@ var protectionSections = map[string]bool{
|
||||
// compiled earlier and keeps blocking, which is a degradation, not a gap.
|
||||
var degradedProtectionMarkers = []string{
|
||||
"continuing with the copy compiled earlier", // generate/ruleset.go:819 — stale but blocking
|
||||
"is IGNORED", // generate/ruleset.go:445,472 — a redundant field, the list loads
|
||||
"bad update_interval", // generate/ruleset.go:862,1010 — falls back to the default interval
|
||||
// generate/ruleset.go remoteRuleSetAs, the RULESET-FROM-CACHE branch: the source
|
||||
// could not be checked, and sing-box's own cache holds a copy this build decodes,
|
||||
// so the rule-set IS HANDED TO THE ENGINE and IS matching. That is the sibling of
|
||||
// the marker above — the local-compile path says "the copy compiled earlier", the
|
||||
// engine-cache path says this — and it was graded critical purely because its
|
||||
// section is "blocklist"/"ruleset".
|
||||
//
|
||||
// This one is not a rounding error. Behind the SIM uplink the source is
|
||||
// PERMANENTLY unreachable, so this warning is the STEADY STATE: the panel's alarm
|
||||
// banner would be red on every apply forever, over lists that are working. That is
|
||||
// how the next red — RULESET-NOT-APPLIED, the one where the list really is absent —
|
||||
// stops being read. The two must not look the same, and by this file's own
|
||||
// definition they are not: "protection you configured is not in effect" is false
|
||||
// here and true there.
|
||||
//
|
||||
// The copy is STALE, and the message says how stale (it carries the fetch
|
||||
// timestamp), which is exactly the "degraded, not absent" this list is for.
|
||||
"APPLIED FROM THE CACHE", // generate/ruleset.go remoteRuleSetAs — stale but matching
|
||||
"is IGNORED", // generate/ruleset.go:445,472 — a redundant field, the list loads
|
||||
"bad update_interval", // generate/ruleset.go:862,1010 — falls back to the default interval
|
||||
}
|
||||
|
||||
// infoMarkers identify operational notes that are not protection gaps.
|
||||
@@ -272,11 +312,29 @@ var infoMarkers = []string{"cache:"}
|
||||
// file its trustworthiness once already.
|
||||
|
||||
// netplaneDeliberateMarkers are the producer's OWN statement that the state it is
|
||||
// describing may be what the operator asked for. Quoted from
|
||||
// netplane/coverage.go oneProtocolWarning's closed branch, which is the only
|
||||
// place it appears.
|
||||
// describing may be what the operator asked for — or, for the second entry, that
|
||||
// it may not be a state at all. Both are quoted verbatim from netplane:
|
||||
//
|
||||
// - oneProtocolWarning's closed branch (netplane/coverage.go), the original;
|
||||
// - opaqueAddressListWarning (netplane/coverage.go), the disclosure that an
|
||||
// address list this plan cannot read MIGHT contain private destinations the
|
||||
// private-destination bypass will swallow. It is unconditional by design —
|
||||
// "warn on suspicion" would mean reading the list, which is exactly what
|
||||
// that stage cannot do — so it fires for a rule-set that is very probably
|
||||
// fine. It describes a BLIND SPOT, not a leak and not an outage: nothing the
|
||||
// operator configured as protection is missing, and if the list holds only
|
||||
// public addresses there is nothing to do at all. Grading that critical
|
||||
// would spend the panel's alarm banner on a maybe, every apply, which is how
|
||||
// the next critical stops being read.
|
||||
//
|
||||
// It stays a `warning` rather than `info` for the same reason the first entry
|
||||
// does: the panel's attentionFindings keeps critical and warning and DROPS info,
|
||||
// so an info here would never reach the operator, and the state it describes —
|
||||
// a rule that names a private destination and silently never fires — is one they
|
||||
// have to be able to find out about.
|
||||
var netplaneDeliberateMarkers = []string{
|
||||
"If you meant it, nothing to do.",
|
||||
"If it contains only public addresses, nothing to do.",
|
||||
}
|
||||
|
||||
// netplaneNeverDowngrade is the guard on the guard: text that says traffic is
|
||||
@@ -417,6 +475,36 @@ func gatherWarnings(m *model.Model, generateWarnings, netplaneWarnings []string,
|
||||
// for the same reason as the two above — no producer emits it, because from
|
||||
// every producer's point of view the configuration is fine.
|
||||
out = append(out, subscriptionBootstrapWarnings(m)...)
|
||||
// Per-profile overrides that name a resolver or a detour target this
|
||||
// configuration does not have.
|
||||
//
|
||||
// THIS CALL IS THE POINT. model.ValidateProfiles was written, tested and then
|
||||
// referenced by nothing but its own test — model.(*Model).Validate does not call
|
||||
// it, so every profile override went unchecked on every path. That is the defect
|
||||
// class this project has already paid for once (CI running two tests out of 116
|
||||
// files): a check nobody runs reads exactly like a check that passes.
|
||||
//
|
||||
// It is called HERE rather than folded into model.Validate because Validate is
|
||||
// another tree's file this pass; the two are equivalent from the panel's side,
|
||||
// since configWarnings and this slice land in the same published set with the
|
||||
// same Section/Name shape. If model.Validate ever adopts it, delete this and the
|
||||
// findings keep arriving — but do not delete this before that.
|
||||
//
|
||||
// Severity: whatever classify() makes of the text, same as any other config
|
||||
// warning — a profile override that cannot resolve is a degradation on ONE
|
||||
// uplink, not a protection that is off right now. The fetch_detour half is the
|
||||
// exception and it is generate's to report, at critical, when that profile is
|
||||
// the ACTIVE one (FETCH-DETOUR-NOT-APPLIED).
|
||||
if m != nil {
|
||||
for _, cw := range model.ValidateProfiles(m.Profiles, m.Resolvers) {
|
||||
out = append(out, Warning{
|
||||
Severity: SeverityWarning,
|
||||
Section: cw.Section,
|
||||
Name: cw.Name,
|
||||
Message: cw.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, t := range generateWarnings {
|
||||
// Parse the entity prefix FIRST, so severity can be decided from the section
|
||||
// (a warning about a blocklist is a protection gap however it is worded).
|
||||
@@ -926,6 +1014,7 @@ func subscriptionFetchWarnings(m *model.Model) []Warning {
|
||||
return nil
|
||||
}
|
||||
var out []Warning
|
||||
prof, _ := model.ResolveActiveProfile(m)
|
||||
for _, s := range m.Subscriptions {
|
||||
// No URL: subscribe.fetchWithHeader refuses before it builds a request, so
|
||||
// this one cannot disclose anything by any path. Enabled is deliberately NOT
|
||||
@@ -933,10 +1022,46 @@ func subscriptionFetchWarnings(m *model.Model) []Warning {
|
||||
if strings.TrimSpace(s.URL) == "" {
|
||||
continue
|
||||
}
|
||||
// Byte-for-byte the test in Applier.UpdateSubscription and in
|
||||
// cmd/shaterd's subFetchViaProxy. If this one ever drifts looser than
|
||||
// those, it reports a leak that is not happening; tighter, and it goes
|
||||
// quiet on one that is.
|
||||
// `fetch_via` ABSENT is its own case now, and it is NOT the leak above. The
|
||||
// operator claimed nothing about this feed; the GENERAL detour applies, and
|
||||
// if that names something real the fetch travels through it exactly as asked.
|
||||
// What can still go wrong is the name, and that failure is silent from every
|
||||
// other angle — generate's FETCH-DETOUR-NOT-APPLIED only fires when the
|
||||
// configuration also has a remote rule-set to stamp the detour on, and a
|
||||
// router with no rule-sets at all would have had its subscriptions stop
|
||||
// refreshing with nothing anywhere saying why.
|
||||
//
|
||||
// It is a `warning`, not a critical, for the same reason the unresolvable
|
||||
// case below is: nothing is disclosed. UpdateSubscription hands the name to
|
||||
// HTTPClient, which refuses it, and the refresh fails loudly while the cached
|
||||
// nodes keep working.
|
||||
if model.ClassifyFetchVia(s.FetchVia) == model.FetchViaInherit {
|
||||
ov := model.EffectiveFetchDetour(m.Globals, prof)
|
||||
general := strings.TrimSpace(ov.Value)
|
||||
if general == "" || strings.EqualFold(general, model.TargetDirect) {
|
||||
continue // a plain fetch, which is what "not set" has always meant
|
||||
}
|
||||
if msg, bad := subFetchDetourFinding(m, subFetchInheritedLead(ov), general, s.Enabled); bad {
|
||||
out = append(out, Warning{
|
||||
Severity: SeverityWarning,
|
||||
Section: subscriptionSection,
|
||||
Name: s.Name,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Byte-for-byte the `proxy` arm of subFetchNeedsEngine — the copy here in
|
||||
// apply, and its twin in cmd/shaterd (both pinned identical by
|
||||
// cmd/shaterd/subfetchparity_test.go, which compares them as source). If
|
||||
// this one ever drifts looser than those, it reports a leak that is not
|
||||
// happening; tighter, and it goes quiet on one that is.
|
||||
//
|
||||
// The name to look for used to be cmd/shaterd's subFetchViaProxy. Schema v3
|
||||
// gave fetch_via a third state and that two-way predicate could not express
|
||||
// it; it is now subFetchRouteOf + subFetchNeedsEngine. The INHERIT case is
|
||||
// handled by the branch above, which continues, so reaching here means
|
||||
// fetch_via was written out — and only `proxy` is a leak worth reporting.
|
||||
if !strings.EqualFold(strings.TrimSpace(s.FetchVia), "proxy") {
|
||||
continue
|
||||
}
|
||||
@@ -950,7 +1075,7 @@ func subscriptionFetchWarnings(m *model.Model) []Warning {
|
||||
})
|
||||
continue
|
||||
}
|
||||
if msg, bad := subFetchDetourFinding(m, detour, s.Enabled); bad {
|
||||
if msg, bad := subFetchDetourFinding(m, subFetchProxyLead, detour, s.Enabled); bad {
|
||||
out = append(out, Warning{
|
||||
Severity: SeverityWarning,
|
||||
Section: subscriptionSection,
|
||||
@@ -962,6 +1087,29 @@ func subscriptionFetchWarnings(m *model.Model) []Warning {
|
||||
return out
|
||||
}
|
||||
|
||||
// The opening clause of an unresolvable-detour finding: WHICH setting holds the
|
||||
// name that does not resolve. It is a parameter and not a constant because the
|
||||
// same four messages now serve two different settings, and a message that names
|
||||
// the wrong one is worse than no message: an operator told "`fetch_via` is
|
||||
// \"proxy\" and the detour is …" about a subscription whose `fetch_via` is EMPTY
|
||||
// goes looking for a field that is not there, concludes the warning is wrong, and
|
||||
// stops reading the list. The alternative — a second copy of the four texts — is
|
||||
// how two wordings drift into two different claims about one behaviour.
|
||||
const subFetchProxyLead = "`fetch_via` is \"proxy\" and the detour"
|
||||
|
||||
// subFetchInheritedLead is the same clause for a subscription that expressed no
|
||||
// opinion and got the general detour. It names WHERE the value lives, because the
|
||||
// subscription's own row — the row the panel badges — is the one place it is not.
|
||||
func subFetchInheritedLead(ov model.Override) string {
|
||||
where := "`globals.fetch_detour`"
|
||||
if name, ok := strings.CutPrefix(ov.From, "profile:"); ok {
|
||||
where = "the active profile " + strconv.Quote(name) + "'s `fetch_detour`"
|
||||
}
|
||||
return "This subscription sets no `fetch_via`, so it uses the general fetch detour from " + where +
|
||||
" — which applies to every subscription that does not override it, so the fault is there and not " +
|
||||
"on this row. That detour"
|
||||
}
|
||||
|
||||
// subscriptionBootstrapWarnings names THE FIRST-BOOT DEADLOCK.
|
||||
//
|
||||
// # The state
|
||||
@@ -1022,17 +1170,35 @@ func subscriptionBootstrapWarnings(m *model.Model) []Warning {
|
||||
}
|
||||
}
|
||||
// Half 2: is there any subscription that could be fetched with no outbound?
|
||||
//
|
||||
// The question is about the RESOLVED detour, not about `fetch_via` alone. This
|
||||
// loop used to answer "not proxy => can bootstrap", which stopped being true the
|
||||
// moment an absent `fetch_via` began inheriting globals.fetch_detour: with
|
||||
// `globals.fetch_detour='group:sim-bypass'` such a subscription needs the engine
|
||||
// exactly like an explicit `proxy` one, and the old test would have declared the
|
||||
// deadlock broken by a subscription that is itself part of it — the detector
|
||||
// going silent on the state it exists to name.
|
||||
//
|
||||
// prof is the ACTIVE profile, because that is what apply.UpdateSubscription will
|
||||
// resolve against when it actually runs the fetch. A profile that is not active
|
||||
// cannot rescue or cause this.
|
||||
prof, _ := model.ResolveActiveProfile(m)
|
||||
var stuck []string
|
||||
anyScheduled := false
|
||||
for _, s := range m.Subscriptions {
|
||||
if strings.TrimSpace(s.URL) == "" {
|
||||
continue // cannot fetch at all; not this fault
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(s.FetchVia), "proxy") {
|
||||
return nil // a direct fetch needs no engine — it can bootstrap the rest
|
||||
resolved, ok := model.SubscriptionFetchDetour(s, m.Globals, prof)
|
||||
if !ok {
|
||||
// `fetch_via` is a value that is not a decision. UpdateSubscription
|
||||
// refuses it outright, so this subscription bootstraps nothing — but the
|
||||
// fault is the unreadable value, which model.ValidateSubscriptions names
|
||||
// on its own channel. Not stuck, not a rescue: skipped.
|
||||
continue
|
||||
}
|
||||
detour := strings.TrimSpace(s.FetchDetour)
|
||||
if detour == "" || strings.EqualFold(detour, "direct") {
|
||||
detour := strings.TrimSpace(resolved.Value)
|
||||
if detour == "" || strings.EqualFold(detour, model.TargetDirect) {
|
||||
return nil // resolves to the direct outbound: it leaks, but it is not stuck
|
||||
}
|
||||
stuck = append(stuck, s.Name)
|
||||
@@ -1074,7 +1240,8 @@ func subBootstrapDeadlockMessage(stuck []string, scheduled bool) string {
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"DEADLOCK: this router cannot fetch %s and cannot fix that by itself. Every subscription it has is "+
|
||||
"pulled THROUGH the tunnel (`fetch_via=proxy` with a detour), the tunnel is built out of nodes that "+
|
||||
"pulled THROUGH the tunnel (`fetch_via=proxy` with a detour, or no `fetch_via` at all and a "+
|
||||
"`globals.fetch_detour` that names one), the tunnel is built out of nodes that "+
|
||||
"only a subscription fetch can supply, and there are none left — no manual node, no egress, and no "+
|
||||
"cached node on disk (/etc/shater/subs; a wipe or a reflash that did not keep it is the usual "+
|
||||
"cause). So the fetch waits for the tunnel and the tunnel waits for the fetch. %s If your LAN is "+
|
||||
@@ -1157,18 +1324,18 @@ func subFetchDirectMessage(detour string, enabled bool) string {
|
||||
// The prefix list is positive and closed for the same reason the one in
|
||||
// chainOutboundTag is: an unlisted spelling must fall to the bare-name branch,
|
||||
// which is what engine.ViaToTag does with it, rather than into a silent "fine".
|
||||
func subFetchDetourFinding(m *model.Model, detour string, enabled bool) (string, bool) {
|
||||
func subFetchDetourFinding(m *model.Model, lead, detour string, enabled bool) (string, bool) {
|
||||
if rest, ok := cutPrefixFold(detour, "group:"); ok {
|
||||
return subFetchMissing(detour, "group", strings.TrimSpace(rest), hasGroup(m, rest), enabled)
|
||||
return subFetchMissing(lead, detour, "group", strings.TrimSpace(rest), hasGroup(m, rest), enabled)
|
||||
}
|
||||
if rest, ok := cutPrefixFold(detour, "node:"); ok {
|
||||
return subFetchMissing(detour, "node", strings.TrimSpace(rest), hasNode(m, rest), enabled)
|
||||
return subFetchMissing(lead, detour, "node", strings.TrimSpace(rest), hasNode(m, rest), enabled)
|
||||
}
|
||||
if rest, ok := cutPrefixFold(detour, "egress:"); ok {
|
||||
return subFetchMissing(detour, "egress", strings.TrimSpace(rest), hasEgress(m, rest), enabled)
|
||||
return subFetchMissing(lead, detour, "egress", strings.TrimSpace(rest), hasEgress(m, rest), enabled)
|
||||
}
|
||||
if rest, ok := cutPrefixFold(detour, chainViaPrefix); ok {
|
||||
return subFetchMissing(detour, "chain", strings.TrimSpace(rest), hasChain(m, rest), enabled)
|
||||
return subFetchMissing(lead, detour, "chain", strings.TrimSpace(rest), hasChain(m, rest), enabled)
|
||||
}
|
||||
|
||||
// Bare name. engine.ViaToTag passes it through verbatim, so it is looked up as
|
||||
@@ -1182,15 +1349,15 @@ func subFetchDetourFinding(m *model.Model, detour string, enabled bool) (string,
|
||||
// egress-<name> (netplane.EgressOutboundTag) and a chain's hops are tagged
|
||||
// chain-<name>-h1..hN, so neither is reachable by its bare name.
|
||||
if hasEgress(m, detour) {
|
||||
return subFetchWrongSpelling(detour, "egress", "", enabled), true
|
||||
return subFetchWrongSpelling(lead, detour, "egress", "", enabled), true
|
||||
}
|
||||
if hasChain(m, detour) {
|
||||
return subFetchWrongSpelling(detour, "chain",
|
||||
return subFetchWrongSpelling(lead, detour, "chain",
|
||||
" A chain is only built when something else in the configuration routes to it — a fetch "+
|
||||
"detour does not count — so if the refusal survives the rename, that is what it is "+
|
||||
"reporting.", enabled), true
|
||||
}
|
||||
return subFetchMissing(detour, "node or group", detour, false, enabled)
|
||||
return subFetchMissing(lead, detour, "node or group", detour, false, enabled)
|
||||
}
|
||||
|
||||
// subFetchRefusedWhat names WHAT the unresolvable detour actually breaks, which
|
||||
@@ -1209,29 +1376,29 @@ func subFetchRefusedWhat(enabled bool) string {
|
||||
// subFetchMissing renders the "names nothing" finding, or nothing at all when the
|
||||
// name was found. present is the caller's lookup result, threaded through here so
|
||||
// every branch above reports its miss in one voice.
|
||||
func subFetchMissing(detour, kind, name string, present, enabled bool) (string, bool) {
|
||||
func subFetchMissing(lead, detour, kind, name string, present, enabled bool) (string, bool) {
|
||||
if present {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"`fetch_via` is \"proxy\" and the detour is %q, but this configuration defines no %s named %q. "+
|
||||
"%s is %q, but this configuration defines no %s named %q. "+
|
||||
"Nothing is disclosed by this: the fetch is REFUSED rather than quietly retried over the plain "+
|
||||
"WAN, so what actually happens is that %s and its "+
|
||||
"node list goes stale, while the nodes already cached keep working. Correct the name, or restore "+
|
||||
"the %s it used to point at.", detour, kind, name, subFetchRefusedWhat(enabled), kind), true
|
||||
"the %s it used to point at.", lead, detour, kind, name, subFetchRefusedWhat(enabled), kind), true
|
||||
}
|
||||
|
||||
// subFetchWrongSpelling renders the finding for a bare name that DOES exist in
|
||||
// the configuration but not as the kind a bare detour is looked up as. tail is an
|
||||
// optional extra sentence for a kind that has a caveat of its own.
|
||||
func subFetchWrongSpelling(detour, kind, tail string, enabled bool) string {
|
||||
func subFetchWrongSpelling(lead, detour, kind, tail string, enabled bool) string {
|
||||
return fmt.Sprintf(
|
||||
"`fetch_via` is \"proxy\" and the detour is the bare name %q. A bare detour is looked up as a node "+
|
||||
"%s is the bare name %q. A bare detour is looked up as a node "+
|
||||
"or group tag, and this configuration has no node and no group by that name — it has the %s %q, "+
|
||||
"whose outbound carries a different tag, so the lookup misses. Write %q instead.%s Until then the "+
|
||||
"fetch is REFUSED rather than quietly sent over the plain WAN, so nothing is disclosed; what fails "+
|
||||
"is %s, and its node list goes stale.",
|
||||
detour, kind, detour, kind+":"+detour, tail, subFetchRefusedWhat(enabled))
|
||||
lead, detour, kind, detour, kind+":"+detour, tail, subFetchRefusedWhat(enabled))
|
||||
}
|
||||
|
||||
func hasGroup(m *model.Model, name string) bool {
|
||||
|
||||
@@ -71,7 +71,7 @@ import (
|
||||
// diagPackages is the closed list of packages this product ships. `apk list -I`
|
||||
// prints every package on the router (several hundred lines); only ours are
|
||||
// relevant, and a closed list keeps the bundle a bundle.
|
||||
var diagPackages = []string{"shaterd", "shater-core", "luci-app-shater", "byedpi"}
|
||||
var diagPackages = []string{"shaterd", "shater-core", "luci-app-shater"}
|
||||
|
||||
// diagCommandTimeout bounds each external command. `nft` and `ip` are local and
|
||||
// answer instantly; a wedged one must not turn the one channel that works into
|
||||
@@ -460,7 +460,15 @@ var diagSafeUCI = map[string]map[string]bool{
|
||||
"active_profile", "geo_provider", "geosite_url", "geoip_url", "geosite_index_url",
|
||||
"geoip_index_url", "panel_port", "dns_filter", "dns_intercept", "block_doh", "group_health",
|
||||
"untunnelable", "l3_tunnel", "untunnelable_egress", "stats_backend", "stats_ring_size",
|
||||
"stats_timeline_minutes", "stats_max_domains", "stats_retention_disabled", "stats_disk_limit_mb"),
|
||||
"stats_timeline_minutes", "stats_max_domains", "stats_retention_disabled", "stats_disk_limit_mb",
|
||||
// fetch_detour names an outbound (`direct`, `group:x`, `node:x`, `egress:x`,
|
||||
// `chain:x`) — topology, not a credential, and the same vocabulary as
|
||||
// rule.target and resolver.detour which are already here. It is also the
|
||||
// single option that explains a router whose lists and subscriptions do not
|
||||
// download at all, which is the fault this bundle is most often collected
|
||||
// for: masking it left the reader looking at a healthy `direct` config and
|
||||
// no reason for the failures beside it.
|
||||
"fetch_detour"),
|
||||
"inbound": set("name", "enabled", "type", "network", "tproxy_port", "listen", "port", "auth",
|
||||
"target_addr", "target_port", "target_network", "tcp", "udp"),
|
||||
"subscription": set("name", "enabled", "update_interval", "fetch_via", "fetch_detour", "format",
|
||||
@@ -476,8 +484,16 @@ var diagSafeUCI = map[string]map[string]bool{
|
||||
"rule": set("name", "enabled", "order", "src", "dst_domain", "dst_ip", "dst_ruleset", "dst_port",
|
||||
"proto", "target", "egress", "kill", "sched_enabled", "sched_day", "sched_start", "sched_end",
|
||||
"sched_utc_offset"),
|
||||
// The three DNS/fetch overrides a profile carries are NAMES of `config resolver`
|
||||
// sections and of an outbound — the same class as globals' own
|
||||
// resolver_default/resolver_fallback/fetch_detour a few lines up, which have
|
||||
// always been printed. Masking them was not a policy choice, it was the list
|
||||
// predating the fields: a bundle from the SIM uplink showed `option
|
||||
// endpoint_resolver ***` and nothing at all for the other three, so the one
|
||||
// section that explains why DNS and the list downloads behave differently on
|
||||
// that uplink arrived unreadable.
|
||||
"profile": set("name", "enabled", "priority", "match_iface", "enable_rule", "disable_rule",
|
||||
"endpoint_resolver"),
|
||||
"endpoint_resolver", "resolver_default", "resolver_fallback", "fetch_detour"),
|
||||
"resolver": set("name", "type", "detour", "pool"), // address: see diagMaskAddress
|
||||
"dns_rule": set("order", "match_domain", "match_src", "resolver"),
|
||||
"blocklist": set("name", "enabled", "source", "url", "path", "category", "entry", "response", "update_interval"),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// The four options that explain a router whose downloads do not work, and which
|
||||
// the diagnostic bundle used to mask.
|
||||
//
|
||||
// `shaterd diag` is collected for exactly one kind of question: "why is this
|
||||
// router not doing what its config says?" On the SIM uplink the answer is usually
|
||||
// the fetch detour and the profile's DNS overrides — and the bundle printed
|
||||
// `option endpoint_resolver ***` and nothing at all for the other three, because
|
||||
// diagSafeUCI predated the fields. The reader was left looking at what appeared to
|
||||
// be a plain `direct` configuration with unexplained failures beside it.
|
||||
//
|
||||
// None of the four is a credential. They name an outbound and two `config
|
||||
// resolver` sections — the same class as rule.target and resolver.detour, which
|
||||
// have always been printed.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// TestDiagKeepsTheFetchAndProfileOverrides asserts the four values survive the
|
||||
// mask, WITH the control that the same document still masks what it must.
|
||||
//
|
||||
// The control is not decoration here: this test would pass identically against a
|
||||
// maskUCIExport that had stopped masking anything at all, which is the one
|
||||
// regression that turns a shareable bundle into a leak.
|
||||
func TestDiagKeepsTheFetchAndProfileOverrides(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.Globals{FetchDetour: "group:sim-bypass"},
|
||||
Profiles: []model.Profile{{
|
||||
Name: "mobile-uplink",
|
||||
Enabled: true,
|
||||
ResolverDefault: "yandex",
|
||||
ResolverFallback: "yandex-sec",
|
||||
EndpointResolver: "yandex",
|
||||
FetchDetour: "chain:swan-bypass",
|
||||
}},
|
||||
// The control's carrier: a subscription URL is a credential (it carries the
|
||||
// account token) and must NOT survive.
|
||||
Subscriptions: []model.Subscription{{
|
||||
Name: "qomar", Enabled: true, URL: "https://provider.example/sub?token=zzsentinel-token",
|
||||
}},
|
||||
}
|
||||
|
||||
raw := model.RenderUCIExport(m)
|
||||
// Fixture guard: if the renderer did not emit these options at all, every
|
||||
// assertion below would be vacuously true.
|
||||
for _, want := range []string{
|
||||
"option fetch_detour 'group:sim-bypass'",
|
||||
"option resolver_default 'yandex'",
|
||||
"option resolver_fallback 'yandex-sec'",
|
||||
"option fetch_detour 'chain:swan-bypass'",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Fatalf("fixture: the renderer emitted no %q; the mask cannot be measured\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
masked, secrets := maskUCIExport(raw)
|
||||
|
||||
for _, want := range []struct{ what, line string }{
|
||||
{"the general fetch detour", "option fetch_detour 'group:sim-bypass'"},
|
||||
{"the profile's default resolver", "option resolver_default 'yandex'"},
|
||||
{"the profile's fallback resolver", "option resolver_fallback 'yandex-sec'"},
|
||||
{"the profile's endpoint resolver", "option endpoint_resolver 'yandex'"},
|
||||
{"the profile's fetch detour", "option fetch_detour 'chain:swan-bypass'"},
|
||||
} {
|
||||
if !strings.Contains(masked, want.line) {
|
||||
t.Errorf("%s was masked out of the bundle. It names an outbound or a `config resolver`, not a "+
|
||||
"credential, and it is the setting a broken uplink is diagnosed from.\nwanted: %s\ngot:\n%s",
|
||||
want.what, want.line, masked)
|
||||
}
|
||||
}
|
||||
|
||||
// THE CONTROL. Same document, same call: the subscription token is gone, and
|
||||
// was collected for the document-wide scrub.
|
||||
if strings.Contains(masked, "zzsentinel-token") {
|
||||
t.Fatalf("CONTROL FAILED: the subscription token survived the mask, so this instrument is not "+
|
||||
"masking anything and the assertions above prove nothing:\n%s", masked)
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
t.Fatal("CONTROL FAILED: no refused values were collected, so the log and nft sections of the " +
|
||||
"bundle would not be scrubbed either")
|
||||
}
|
||||
}
|
||||
+136
-25
@@ -888,13 +888,18 @@ var (
|
||||
// runSubUpdate refreshes every target subscription and returns the process exit
|
||||
// code. Each subscription travels the path it is CONFIGURED for:
|
||||
//
|
||||
// - fetch_via != proxy — fetched DIRECT, in this process, exactly as before.
|
||||
// This is the common case and the one that must not regress: a direct
|
||||
// subscription keeps working with no daemon at all (cold start, first boot).
|
||||
// - fetched DIRECT, in this process, exactly as before, when nothing asks for an
|
||||
// engine outbound: an explicit `fetch_via=direct`, or no `fetch_via` at all
|
||||
// while the general fetch detour is unset/`direct`. This is the common case and
|
||||
// the one that must not regress: such a subscription keeps working with no
|
||||
// daemon at all (cold start, first boot).
|
||||
//
|
||||
// - fetch_via == proxy — DELEGATED to the running daemon over the control
|
||||
// socket (`sub update <name>`), which resolves fetch_detour against the
|
||||
// engine it owns and does its own cache/UCI write and reconcile. This
|
||||
// - DELEGATED to the running daemon over the control socket (`sub update
|
||||
// <name>`) when the bytes must come through an engine outbound: an explicit
|
||||
// `fetch_via=proxy`, or no `fetch_via` while globals.fetch_detour (or the
|
||||
// active WAN profile's override) names something other than `direct`. The
|
||||
// daemon resolves the detour against the engine it owns and does its own
|
||||
// cache/UCI write and reconcile — see subFetchRouteOf for the whole table. This
|
||||
// process cannot do it: only ONE process may own the engine, so a CLI verb
|
||||
// has no outbound to dial through — that is why this used to warn and fetch
|
||||
// DIRECT, which put the feed URL and the owner's real address on the plain
|
||||
@@ -921,12 +926,30 @@ func runSubUpdate(m *model.Model, targets []*model.Subscription, logger log.Cont
|
||||
var updated int
|
||||
var failed bool
|
||||
|
||||
// Pass 1 — the DIRECT subscriptions, in this process.
|
||||
// The active WAN profile is resolved ONCE for the whole run: it is what decides
|
||||
// the general fetch detour for every subscription that does not override it, and
|
||||
// re-resolving it per subscription could hand two of them different answers if
|
||||
// the model were ever mutated mid-run. The warnings are the apply path's to
|
||||
// report — this verb must not duplicate them into the CLI's output.
|
||||
var globals model.Globals
|
||||
var prof *model.Profile
|
||||
if m != nil {
|
||||
globals = m.Globals
|
||||
prof, _ = model.ResolveActiveProfile(m)
|
||||
}
|
||||
|
||||
// Pass 1 — the subscriptions this process may fetch itself, in the clear.
|
||||
var viaDaemon []int
|
||||
for i, s := range targets {
|
||||
if subFetchViaProxy(s) {
|
||||
switch subFetchRouteOf(s, globals, prof) {
|
||||
case subFetchDaemon:
|
||||
viaDaemon = append(viaDaemon, i)
|
||||
continue
|
||||
case subFetchRefused:
|
||||
logger.Error("sub ", s.Name, ": ", errFetchViaUnknown(s.FetchVia))
|
||||
failed = true
|
||||
continue
|
||||
case subFetchHere:
|
||||
}
|
||||
body, ferr := subscribe.Fetch(*s, nil)
|
||||
if ferr != nil {
|
||||
@@ -975,7 +998,7 @@ func runSubUpdate(m *model.Model, targets []*model.Subscription, logger log.Cont
|
||||
s := targets[i]
|
||||
added, derr := subUpdateViaDaemon(s.Name)
|
||||
if derr != nil {
|
||||
logger.Error("sub ", s.Name, " (fetch_via=proxy): ", derr)
|
||||
logger.Error("sub ", s.Name, " (fetched through the engine): ", derr)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
@@ -993,26 +1016,114 @@ func runSubUpdate(m *model.Model, targets []*model.Subscription, logger log.Cont
|
||||
return 0
|
||||
}
|
||||
|
||||
// subFetchViaProxy reports whether a subscription is configured to be fetched
|
||||
// THROUGH the tunnel.
|
||||
// subFetchRoute is where ONE subscription's refresh has to happen. Three values,
|
||||
// because `fetch_via` has three states and a fourth non-state:
|
||||
//
|
||||
// It is a deliberate byte-for-byte copy of the test inside
|
||||
// apply.(*Applier).UpdateSubscription (shater/apply/apply.go), and the two MUST
|
||||
// stay identical. This predicate decides whether the fetch is delegated; the one
|
||||
// in the daemon decides whether the delegated fetch uses a detour. If they ever
|
||||
// disagree in the direction "CLI says direct, daemon would have said proxy", the
|
||||
// CLI fetches on the plain WAN — the exact defect this function exists to close.
|
||||
func subFetchViaProxy(s *model.Subscription) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.FetchVia), "proxy")
|
||||
// subFetchHere fetch in this process, in the clear. No daemon needed — the
|
||||
// cold-start / first-boot path must keep working with no engine.
|
||||
// subFetchDaemon delegate over the control socket: the bytes have to come
|
||||
// through an engine outbound, and only the daemon owns the engine.
|
||||
// subFetchRefused `fetch_via` is not a value this option has. Not a decision, so
|
||||
// no side is picked; the run reports it and exits non-zero.
|
||||
type subFetchRoute int
|
||||
|
||||
const (
|
||||
subFetchHere subFetchRoute = iota
|
||||
subFetchDaemon
|
||||
subFetchRefused
|
||||
)
|
||||
|
||||
// subFetchRouteOf decides which way one subscription's fetch travels, and is the
|
||||
// CLI half of a two-half contract: this side chooses whether to delegate, and
|
||||
// apply.(*Applier).UpdateSubscription then chooses whether the delegated fetch
|
||||
// uses a detour. If they disagree in the direction "CLI says here, daemon would
|
||||
// have said engine", the CLI puts the feed URL and this router's real address on
|
||||
// the plain WAN — the exact defect this function exists to close.
|
||||
//
|
||||
// It used to be `EqualFold(FetchVia, "proxy")`, and under schema v2 that was the
|
||||
// whole truth: an absent `fetch_via` meant a direct fetch, full stop. Under v3 it
|
||||
// means "the GENERAL fetch detour applies" (globals.fetch_detour, or the active WAN
|
||||
// profile's override), and that setting exists precisely because on the uplink this
|
||||
// ships to a direct fetch is the one that does not arrive. So the old two-way test
|
||||
// answered "here, in the clear" for exactly the configuration the operator set up
|
||||
// to avoid it, while the daemon — reached through the panel's Refresh button on the
|
||||
// same subscription — pulled it through the tunnel. Two paths, two answers, one of
|
||||
// them a silent disclosure.
|
||||
//
|
||||
// model.SubscriptionFetchDetour is the ONE resolver of that table, shared with
|
||||
// apply and the panel, and its ok=false is the fourth state: an unrecognised
|
||||
// `fetch_via` has no safe silent answer (direct discloses, the general detour
|
||||
// tunnels a feed nobody asked to tunnel), so it is refused by name here exactly as
|
||||
// the daemon refuses it.
|
||||
func subFetchRouteOf(s *model.Subscription, g model.Globals, prof *model.Profile) subFetchRoute {
|
||||
detour, ok := model.SubscriptionFetchDetour(*s, g, prof)
|
||||
if !ok {
|
||||
return subFetchRefused
|
||||
}
|
||||
if subFetchNeedsEngine(model.ClassifyFetchVia(s.FetchVia), detour.Value) {
|
||||
return subFetchDaemon
|
||||
}
|
||||
return subFetchHere
|
||||
}
|
||||
|
||||
// errNoDaemonForProxyFetch is the refusal a fetch_via=proxy subscription gets
|
||||
// when no daemon is running. It is an ERROR, never a fallback: the caller's exit
|
||||
// code is what stops /etc/init.d/shater-cron from stamping the item as freshly
|
||||
// updated, so the next tick retries instead of waiting out the full interval.
|
||||
// subFetchNeedsEngine is a deliberate copy of apply.subFetchNeedsEngine
|
||||
// (shater/apply/apply.go), and the two MUST stay identical — see subFetchRouteOf
|
||||
// for what a disagreement costs. It is duplicated rather than imported because the
|
||||
// daemon-side one is unexported and cmd/shaterd must not reach into apply's
|
||||
// internals for one predicate; TestSubFetchNeedsEngineTable pins the same table
|
||||
// apply/fetchdetour_severity_test.go pins on the other side, so a change to one
|
||||
// that is not made to the other fails a test by name rather than leaking quietly.
|
||||
//
|
||||
// - `direct` is the operator saying "in the clear", explicitly: no engine, and no
|
||||
// dependency on one, which is what makes it the escape from the first-boot
|
||||
// deadlock.
|
||||
// - `proxy` needs the engine ALWAYS, empty detour included — an empty detour
|
||||
// resolves to the box's own `direct` outbound and apply/warnings.go grades that
|
||||
// critical by name; quietly fetching it here instead would change which of the
|
||||
// two the operator is warned about and leave the disclosure identical.
|
||||
// - ABSENT inherits the general detour, so it needs the engine exactly when that
|
||||
// detour names something other than `direct`.
|
||||
//
|
||||
// FetchViaUnknown cannot arrive (subFetchRouteOf refuses it first) and is listed so
|
||||
// a value that is not a decision can never be answered as if it were one.
|
||||
func subFetchNeedsEngine(via model.FetchViaMode, detour string) bool {
|
||||
switch via {
|
||||
case model.FetchViaDirect:
|
||||
return false
|
||||
case model.FetchViaProxy:
|
||||
return true
|
||||
case model.FetchViaInherit:
|
||||
d := strings.TrimSpace(detour)
|
||||
return d != "" && !strings.EqualFold(d, model.TargetDirect)
|
||||
case model.FetchViaUnknown:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errFetchViaUnknown is the refusal an unrecognised `fetch_via` gets, worded to
|
||||
// match the daemon's (apply.(*Applier).UpdateSubscription) so an operator who hits
|
||||
// it through the panel and through the CLI reads the same sentence.
|
||||
func errFetchViaUnknown(via string) error {
|
||||
return fmt.Errorf("fetch_via %q is not a value this option has — the accepted values are %s, "+
|
||||
"and leaving it out means \"use the general fetch_detour\". REFUSING to fetch rather than "+
|
||||
"guess: the guess this used to make was a fetch in the clear",
|
||||
via, strings.Join(model.FetchViaNames, "/"))
|
||||
}
|
||||
|
||||
// errNoDaemonForProxyFetch is the refusal a subscription whose feed must travel
|
||||
// through an engine outbound gets when no daemon is running. Two configurations
|
||||
// reach it — an explicit `fetch_via=proxy`, and no `fetch_via` at all while the
|
||||
// general fetch detour names something other than `direct` — so the text names
|
||||
// both rather than only the one that existed under schema v2.
|
||||
//
|
||||
// It is an ERROR, never a fallback: the caller's exit code is what stops
|
||||
// /etc/init.d/shater-cron from stamping the item as freshly updated, so the next
|
||||
// tick retries instead of waiting out the full interval.
|
||||
var errNoDaemonForProxyFetch = errors.New(
|
||||
"fetch_via=proxy, but no daemon is running and only the daemon owns the engine this " +
|
||||
"fetch must travel through — REFUSING to fetch direct, which would put the feed URL " +
|
||||
"this subscription's feed has to travel through an engine outbound (fetch_via=proxy, or no " +
|
||||
"fetch_via with a non-direct fetch_detour in force), but no daemon is running and only the " +
|
||||
"daemon owns that engine — REFUSING to fetch direct, which would put the feed URL " +
|
||||
"and this router's real address on the plain WAN. The cached nodes are untouched; " +
|
||||
"start the daemon (/etc/init.d/shater start) and this retries on the next cron tick")
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// THE THREE PLACES THAT DECIDE WHERE A SUBSCRIPTION FETCH TRAVELS.
|
||||
//
|
||||
// `fetch_via` is read by three independent readers, and the cost of a disagreement
|
||||
// is stated in the source itself (shater/apply/warnings.go): "If this one ever
|
||||
// drifts looser than those, it reports a leak that is not happening; tighter, and
|
||||
// it goes quiet on one that is." The CLI's copy is worse than either — a CLI that
|
||||
// decides "here, in the clear" for a subscription the daemon would have tunnelled
|
||||
// puts the feed URL and this router's real address on the plain WAN on every cron
|
||||
// tick, and the only trace is a syslog line an operator can switch off.
|
||||
//
|
||||
// The three:
|
||||
//
|
||||
// 1. shater/apply/apply.go — subFetchNeedsEngine, the daemon's decision;
|
||||
// 2. shater/cmd/shaterd/main.go — subFetchNeedsEngine, this package's copy;
|
||||
// 3. shater/generate/wgdedup.go — subscriptionDetourSeeds, which decides whether
|
||||
// the fetch keeps an outbound alive. It now asks model.SubscriptionFetchDetour
|
||||
// directly instead of carrying a fourth copy of the table, so there is nothing
|
||||
// there to drift.
|
||||
//
|
||||
// (1) and (2) cannot share code: apply's is unexported, and cmd/shaterd is package
|
||||
// main, so neither can import the other's predicate. A COMMENT saying they must
|
||||
// match already existed, in all three files, and did not stop (1) and (2) from
|
||||
// coming apart the moment `fetch_via` gained its third state. So this compares the
|
||||
// two functions AS SOURCE: same signature, same body, comments and formatting
|
||||
// ignored. Change one and not the other and this fails by name, with both bodies
|
||||
// printed, instead of the difference shipping as a silent disclosure.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// daemonSideSource is the daemon's copy, relative to this package's directory.
|
||||
// `go test` runs with the package directory as the working directory, so this is
|
||||
// stable; a wrong path fails loudly below rather than skipping.
|
||||
var daemonSideSource = filepath.Join("..", "..", "apply", "apply.go")
|
||||
|
||||
// funcSource returns the signature and body of a top-level (non-method) function,
|
||||
// printed from the AST. Parsing WITHOUT parser.ParseComments is what makes the
|
||||
// comparison about code: the two copies document themselves differently on purpose
|
||||
// (one talks about the daemon, the other about the CLI) and that must not be a
|
||||
// failure, while a changed condition must be.
|
||||
func funcSource(t *testing.T, path, name string) (sig, body string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("cannot read %s: %v — this tripwire compares two source files and "+
|
||||
"cannot do its job without both; fix the path rather than skipping it", path, err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
for _, decl := range f.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Recv != nil || fn.Name == nil || fn.Name.Name != name {
|
||||
continue
|
||||
}
|
||||
var sigBuf, bodyBuf bytes.Buffer
|
||||
if err := printer.Fprint(&sigBuf, fset, fn.Type); err != nil {
|
||||
t.Fatalf("print signature of %s in %s: %v", name, path, err)
|
||||
}
|
||||
if err := printer.Fprint(&bodyBuf, fset, fn.Body); err != nil {
|
||||
t.Fatalf("print body of %s in %s: %v", name, path, err)
|
||||
}
|
||||
return sigBuf.String(), bodyBuf.String()
|
||||
}
|
||||
t.Fatalf("no top-level func %q in %s — the two halves of this contract are found "+
|
||||
"BY NAME, so renaming one without the other must fail here", name, path)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// TestSubFetchNeedsEngineMatchesTheDaemon is the drift tripwire. It is the reason
|
||||
// the CLI copy is allowed to exist at all.
|
||||
func TestSubFetchNeedsEngineMatchesTheDaemon(t *testing.T) {
|
||||
const name = "subFetchNeedsEngine"
|
||||
ourSig, ourBody := funcSource(t, "main.go", name)
|
||||
theirSig, theirBody := funcSource(t, daemonSideSource, name)
|
||||
|
||||
if ourSig != theirSig {
|
||||
t.Fatalf("%s has different signatures:\n cmd/shaterd: func%s\n apply: func%s\n"+
|
||||
"The two decide the two halves of one fetch and must take the same inputs.",
|
||||
name, ourSig, theirSig)
|
||||
}
|
||||
if ourBody != theirBody {
|
||||
t.Fatalf("%s has DRIFTED between the CLI and the daemon.\n\n"+
|
||||
"cmd/shaterd/main.go:\n%s\n\napply/apply.go:\n%s\n\n"+
|
||||
"A CLI that answers \"fetch here, in the clear\" where the daemon would have "+
|
||||
"answered \"through the engine\" puts the feed URL and this router's real address "+
|
||||
"on the plain WAN on every cron tick. Make the two identical; if the daemon's rule "+
|
||||
"genuinely changed, copy it here rather than approximating it.",
|
||||
name, ourBody, theirBody)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -313,9 +313,15 @@ func TestSubUpdateProxyWithoutDaemonRefusesInsteadOfFetchingDirect(t *testing.T)
|
||||
// there being no daemon: the code chose the direct path.
|
||||
//
|
||||
// Both spellings are covered: the explicit "direct" and the empty value
|
||||
// model/uci.go defaults to.
|
||||
// model/uci.go defaults to. The empty one is only direct here because nothing sets
|
||||
// a general fetch detour in this fixture — under schema v3 that is a fact about
|
||||
// globals, not about the subscription; see TestSubUpdateInheritedDetourIsDelegated.
|
||||
//
|
||||
// A value that is NEITHER used to land here too ("whatever-the-panel-writes"). It
|
||||
// no longer does: an unrecognised fetch_via is refused rather than demoted to a
|
||||
// fetch in the clear — see TestSubUpdateUnknownFetchViaIsRefused.
|
||||
func TestSubUpdateDirectStillFetchesDirect(t *testing.T) {
|
||||
for _, via := range []string{"direct", "", "DIRECT", "whatever-the-panel-writes"} {
|
||||
for _, via := range []string{"direct", "", "DIRECT"} {
|
||||
t.Run("fetch_via="+strconv.Quote(via), func(t *testing.T) {
|
||||
origin := newDirectOrigin(t)
|
||||
d := startFakeDaemon(t, true, `{"added":7}`)
|
||||
@@ -408,25 +414,225 @@ func TestSubUpdateOldDaemonIsNotADirectFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- the predicate ----------------------------------------------------------
|
||||
// --- case 6: the general fetch detour, inherited ----------------------------
|
||||
|
||||
// TestSubFetchViaProxyTokens pins the token set. It must accept exactly what
|
||||
// apply.(*Applier).UpdateSubscription accepts — the two predicates decide the two
|
||||
// halves of the same fetch, and a disagreement in the "CLI says direct" direction
|
||||
// is a leak.
|
||||
func TestSubFetchViaProxyTokens(t *testing.T) {
|
||||
proxy := []string{"proxy", "PROXY", "Proxy", " proxy ", "\tproxy\n"}
|
||||
direct := []string{"", "direct", "DIRECT", " ", "proxied", "proxy2", "via-proxy", "tunnel"}
|
||||
for _, v := range proxy {
|
||||
if !subFetchViaProxy(&model.Subscription{FetchVia: v}) {
|
||||
t.Errorf("subFetchViaProxy(%q) = false, want true — this value would be "+
|
||||
"fetched DIRECT here while the daemon would tunnel it", v)
|
||||
// TestSubUpdateInheritedDetourIsDelegated is the schema-v3 defect, measured.
|
||||
//
|
||||
// The subscription says NOTHING about how it is fetched. Under v2 that meant
|
||||
// "direct" and the CLI fetched it here; under v3 it means "whatever
|
||||
// globals.fetch_detour says", and globals says `group:sim-bypass`. The daemon —
|
||||
// the panel's Refresh button on this very row — pulls it through the engine. The
|
||||
// CLI must do the same, or the same subscription travels two different ways
|
||||
// depending on who asked for it, and one of those ways is the plain WAN.
|
||||
//
|
||||
// The origin is live and would serve the feed instantly; origin=0 is therefore the
|
||||
// CLI choosing not to dial it, not an endpoint being unavailable. The paired
|
||||
// positive control is TestSubUpdateDirectStillFetchesDirect, which reads origin=1
|
||||
// on the same instrument.
|
||||
func TestSubUpdateInheritedDetourIsDelegated(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
globals string
|
||||
profile *model.Profile
|
||||
}{
|
||||
{name: "from globals", globals: "group:sim-bypass"},
|
||||
{name: "from globals, node form", globals: "node:awgout"},
|
||||
{
|
||||
// A profile override must be honoured too: on the router the WAN watcher
|
||||
// pins `mobile-uplink`, and its fetch_detour is the ONLY reason the feed
|
||||
// is reachable at all. Reading globals alone would answer `direct`.
|
||||
name: "profile overrides a direct globals",
|
||||
globals: "direct",
|
||||
profile: &model.Profile{Name: "mobile-uplink", Enabled: true, FetchDetour: "group:sim-bypass"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
origin := newDirectOrigin(t)
|
||||
d := startFakeDaemon(t, true, `{"added":7}`)
|
||||
saves, writes := stubPersistence(t)
|
||||
|
||||
m := &model.Model{
|
||||
Globals: model.Globals{FetchDetour: tc.globals},
|
||||
Subscriptions: []model.Subscription{{Name: "qomar", Enabled: true, URL: origin.url()}},
|
||||
}
|
||||
if tc.profile != nil {
|
||||
m.Globals.ActiveProfile = tc.profile.Name
|
||||
m.Profiles = []model.Profile{*tc.profile}
|
||||
}
|
||||
|
||||
if code := runOne(m); code != 0 {
|
||||
t.Fatalf("exit = %d, want 0 (the daemon answered {\"added\":7})", code)
|
||||
}
|
||||
if got := origin.count(); got != 0 {
|
||||
inForce, _ := model.SubscriptionFetchDetour(m.Subscriptions[0], m.Globals, tc.profile)
|
||||
t.Errorf("the origin was hit %d time(s) DIRECTLY from this process — the general "+
|
||||
"fetch detour in force is %q (from %s), so this feed must not go out on the plain WAN",
|
||||
got, inForce.Value, inForce.From)
|
||||
}
|
||||
want := ctlSubUpdatePrefix + "qomar"
|
||||
if got := d.seen(); len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("control socket saw %q, want [%q] — the fetch was not delegated", got, want)
|
||||
}
|
||||
if len(*saves) != 0 || *writes != 0 {
|
||||
t.Errorf("the CLI wrote cache=%v uci=%d for a DELEGATED subscription", *saves, *writes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubUpdateInheritedDirectStaysHere is the other half of the same table, and
|
||||
// the no-regression case that matters most: with the general detour unset or
|
||||
// `direct`, a subscription with no `fetch_via` must still be fetched by this
|
||||
// process, with no daemon required. Getting this wrong would make first boot —
|
||||
// where nothing owns an engine yet — unable to fetch anything at all.
|
||||
func TestSubUpdateInheritedDirectStaysHere(t *testing.T) {
|
||||
for _, detour := range []string{"", "direct", " DIRECT "} {
|
||||
t.Run(strconv.Quote(detour), func(t *testing.T) {
|
||||
origin := newDirectOrigin(t)
|
||||
d := startFakeDaemon(t, true, `{"added":7}`)
|
||||
stubPersistence(t)
|
||||
|
||||
m := &model.Model{
|
||||
Globals: model.Globals{FetchDetour: detour},
|
||||
Subscriptions: []model.Subscription{{Name: "plainsub", Enabled: true, URL: origin.url()}},
|
||||
}
|
||||
if code := runOne(m); code != 0 {
|
||||
t.Fatalf("exit = %d, want 0", code)
|
||||
}
|
||||
if got := origin.count(); got != 1 {
|
||||
t.Errorf("origin hits = %d, want 1 — a direct general detour must not start "+
|
||||
"demanding a running daemon", got)
|
||||
}
|
||||
if got := d.seen(); len(got) != 0 {
|
||||
t.Errorf("control socket saw %q; nothing here needs the engine", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubUpdateExplicitDirectBeatsTheGeneralDetour: `fetch_via=direct` is the
|
||||
// operator saying "in the clear, whatever globals says". It is the documented
|
||||
// escape from the first-boot deadlock (the feed for the nodes the detour is built
|
||||
// from), so a general detour must NOT capture it.
|
||||
func TestSubUpdateExplicitDirectBeatsTheGeneralDetour(t *testing.T) {
|
||||
origin := newDirectOrigin(t)
|
||||
d := startFakeDaemon(t, true, `{"added":7}`)
|
||||
stubPersistence(t)
|
||||
|
||||
m := &model.Model{
|
||||
Globals: model.Globals{FetchDetour: "group:sim-bypass"},
|
||||
Subscriptions: []model.Subscription{{
|
||||
Name: "bootstrap", Enabled: true, URL: origin.url(), FetchVia: "direct",
|
||||
}},
|
||||
}
|
||||
if code := runOne(m); code != 0 {
|
||||
t.Fatalf("exit = %d, want 0", code)
|
||||
}
|
||||
if got := origin.count(); got != 1 {
|
||||
t.Errorf("origin hits = %d, want 1 — an explicit fetch_via=direct must stay direct", got)
|
||||
}
|
||||
if got := d.seen(); len(got) != 0 {
|
||||
t.Errorf("control socket saw %q: fetch_via=direct must never need a daemon", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- case 7: a fetch_via that is not a value ---------------------------------
|
||||
|
||||
// TestSubUpdateUnknownFetchViaIsRefused. A typo used to be swallowed by
|
||||
// `EqualFold(FetchVia,"proxy")` and fetched in the clear, successfully, with
|
||||
// nothing to see. apply.(*Applier).UpdateSubscription refuses it by name; so must
|
||||
// this. Both endpoints are live, so neither zero is an artifact.
|
||||
func TestSubUpdateUnknownFetchViaIsRefused(t *testing.T) {
|
||||
for _, via := range []string{"proxied", "proxy2", "via-proxy", "tunnel", "whatever-the-panel-writes"} {
|
||||
t.Run(via, func(t *testing.T) {
|
||||
origin := newDirectOrigin(t)
|
||||
d := startFakeDaemon(t, true, `{"added":7}`)
|
||||
saves, writes := stubPersistence(t)
|
||||
|
||||
m := &model.Model{Subscriptions: []model.Subscription{{
|
||||
Name: "typo", Enabled: true, URL: origin.url(), FetchVia: via,
|
||||
}}}
|
||||
|
||||
if code := runOne(m); code == 0 {
|
||||
t.Fatalf("exit = 0 for fetch_via=%q — cron would stamp this as freshly updated", via)
|
||||
}
|
||||
if got := origin.count(); got != 0 {
|
||||
t.Errorf("origin hits = %d: an unrecognised fetch_via was demoted to a fetch "+
|
||||
"in the clear, which is the defect", got)
|
||||
}
|
||||
if got := d.seen(); len(got) != 0 {
|
||||
t.Errorf("control socket saw %q: a value that is not a decision must not be "+
|
||||
"turned into one by tunnelling it either", got)
|
||||
}
|
||||
if len(*saves) != 0 || *writes != 0 {
|
||||
t.Errorf("a refused update wrote cache=%v uci=%d", *saves, *writes)
|
||||
}
|
||||
msg := errFetchViaUnknown(via).Error()
|
||||
for _, want := range []string{"REFUSING", "direct", "proxy"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("refusal message does not contain %q: %s", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- the predicates ---------------------------------------------------------
|
||||
|
||||
// TestSubFetchNeedsEngineTable pins the CLI's copy of the daemon's predicate
|
||||
// against the SAME table apply/fetchdetour_severity_test.go's TestSubFetchNeedsEngine
|
||||
// pins on the other side. The two functions are duplicated (apply's is unexported
|
||||
// and cmd/shaterd must not reach into it), so this is the tripwire: change one and
|
||||
// not the other, and one of the two tables goes red by name instead of the CLI
|
||||
// quietly fetching what the daemon would have tunnelled.
|
||||
func TestSubFetchNeedsEngineTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
via model.FetchViaMode
|
||||
detour string
|
||||
want bool
|
||||
}{
|
||||
{"explicit direct never needs the engine", model.FetchViaDirect, "", false},
|
||||
{"explicit direct, stale detour beside it, still no engine", model.FetchViaDirect, "group:auto", false},
|
||||
{"proxy with a detour", model.FetchViaProxy, "group:auto", true},
|
||||
{"proxy with NO detour still goes through the box", model.FetchViaProxy, "", true},
|
||||
{"absent + no general detour = plain fetch", model.FetchViaInherit, "", false},
|
||||
{"absent + general detour 'direct' = plain fetch", model.FetchViaInherit, "direct", false},
|
||||
{"absent + general detour 'DIRECT' = plain fetch", model.FetchViaInherit, " DIRECT ", false},
|
||||
{"absent + general detour naming a group = through the box", model.FetchViaInherit, "group:sim-bypass", true},
|
||||
{"absent + general detour naming a chain = through the box", model.FetchViaInherit, "chain:swan", true},
|
||||
{"unknown is not answered as proxy", model.FetchViaUnknown, "group:auto", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subFetchNeedsEngine(tc.via, tc.detour); got != tc.want {
|
||||
t.Errorf("%s: subFetchNeedsEngine(%v, %q) = %v, want %v", tc.name, tc.via, tc.detour, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, v := range direct {
|
||||
if subFetchViaProxy(&model.Subscription{FetchVia: v}) {
|
||||
t.Errorf("subFetchViaProxy(%q) = true, want false — this would demand a "+
|
||||
"running daemon for a subscription that never needed one", v)
|
||||
}
|
||||
|
||||
// TestSubFetchRouteOfTokens pins the whole three-way decision, including the
|
||||
// spellings `fetch_via` accepts. The `proxy` row is what the old two-state
|
||||
// subFetchViaProxy got right; the `inherit` rows are what it could not express;
|
||||
// the last row is the state it silently answered `direct` for.
|
||||
func TestSubFetchRouteOfTokens(t *testing.T) {
|
||||
g := model.Globals{FetchDetour: "group:sim-bypass"}
|
||||
cases := []struct {
|
||||
via string
|
||||
want subFetchRoute
|
||||
}{
|
||||
{"proxy", subFetchDaemon}, {"PROXY", subFetchDaemon}, {"Proxy", subFetchDaemon},
|
||||
{" proxy ", subFetchDaemon}, {"\tproxy\n", subFetchDaemon},
|
||||
{"direct", subFetchHere}, {"DIRECT", subFetchHere}, {" direct ", subFetchHere},
|
||||
// Absent: the general detour decides, and here it names a group.
|
||||
{"", subFetchDaemon}, {" ", subFetchDaemon},
|
||||
{"proxied", subFetchRefused}, {"proxy2", subFetchRefused},
|
||||
{"via-proxy", subFetchRefused}, {"tunnel", subFetchRefused},
|
||||
}
|
||||
names := map[subFetchRoute]string{subFetchHere: "here", subFetchDaemon: "daemon", subFetchRefused: "refused"}
|
||||
for _, tc := range cases {
|
||||
s := &model.Subscription{Name: "s", FetchVia: tc.via, FetchDetour: "group:auto"}
|
||||
if got := subFetchRouteOf(s, g, nil); got != tc.want {
|
||||
t.Errorf("subFetchRouteOf(fetch_via=%q) = %s, want %s", tc.via, names[got], names[tc.want])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,22 @@ func obsFixture() option.Options {
|
||||
}
|
||||
}
|
||||
|
||||
// obsSelectorFixture is the same two nodes behind a used SELECTOR group. A
|
||||
// selector has no checker of its own, so both members are the OBSERVATORY's to
|
||||
// dial — which obsFixture's urltest group's members are not (they are
|
||||
// SelfChecked, TestPlanHandsURLTestMembersToTheirGroup). Any test that must
|
||||
// observe the observatory actually dialling needs this shape, not that one.
|
||||
func obsSelectorFixture() option.Options {
|
||||
return option.Options{
|
||||
Outbounds: []option.Outbound{
|
||||
fixNode("n1", ""),
|
||||
fixNode("n2", ""),
|
||||
fixGroup(C.TypeSelector, "sel", "n1", "n2"),
|
||||
},
|
||||
Route: fixRoute("sel"),
|
||||
}
|
||||
}
|
||||
|
||||
func obsState(e *Engine) (plan []ProbeJob, cursor int, enabled bool) {
|
||||
e.obsMu.Lock()
|
||||
defer e.obsMu.Unlock()
|
||||
@@ -60,6 +76,19 @@ func TestConfigureObservatoryLifecycle(t *testing.T) {
|
||||
|
||||
cfg := ObservatoryConfig{Enabled: true, Options: obsFixture(), ProbeURL: "u", ProbeInterval: time.Minute}
|
||||
e.ConfigureObservatory(cfg)
|
||||
// An enabled configure must have armed the ticker — asserted HERE because the
|
||||
// next line takes that goroutine out of the test's way, and an assertion
|
||||
// removed by a fixture is an assertion lost. Everything below is about what
|
||||
// ConfigureObservatory writes, which only reads deterministically with no
|
||||
// second writer; see detachObservatoryLoop.
|
||||
e.obsMu.Lock()
|
||||
armed := e.obs.stop != nil && e.obs.done != nil && e.obs.nudge != nil
|
||||
e.obsMu.Unlock()
|
||||
if !armed {
|
||||
t.Fatal("an enabled configure did not start the observatory loop")
|
||||
}
|
||||
detachObservatoryLoop(e)
|
||||
|
||||
plan, _, enabled := obsState(e)
|
||||
if !enabled || len(plan) != 2 {
|
||||
t.Fatalf("after enable: enabled=%v plan=%d jobs, want true/2 (n1, n2)", enabled, len(plan))
|
||||
@@ -105,6 +134,10 @@ func TestObservatoryTickStoppedEngine(t *testing.T) {
|
||||
e := New()
|
||||
defer e.StopObservatory()
|
||||
e.ConfigureObservatory(ObservatoryConfig{Enabled: true, Options: obsFixture(), ProbeInterval: time.Minute})
|
||||
// The loop's own first pass would have advanced the cursor for us, and the
|
||||
// assertion below would then hold whether or not the hand-driven tick did
|
||||
// anything at all. Quiesce so the tick under test is the only one there is.
|
||||
quiesceObservatoryLoop(e)
|
||||
|
||||
e.observatoryTickOnce()
|
||||
if _, cursor, _ := obsState(e); cursor == 0 {
|
||||
@@ -122,6 +155,11 @@ func TestObservatoryTicksDuringManualRun(t *testing.T) {
|
||||
e := New()
|
||||
defer e.StopObservatory()
|
||||
e.ConfigureObservatory(ObservatoryConfig{Enabled: true, Options: obsFixture(), ProbeInterval: time.Minute})
|
||||
// Same reason as above, and it bites harder here: if the loop's first pass
|
||||
// were still in flight the hand-driven tick would hit the busy guard and do
|
||||
// NOTHING, while the cursor it reads would already be non-zero — the test
|
||||
// would pass for the exact behaviour it exists to forbid.
|
||||
quiesceObservatoryLoop(e)
|
||||
|
||||
e.groupTestRunning.Store(true)
|
||||
e.observatoryTickOnce()
|
||||
@@ -156,6 +194,12 @@ func TestRefreshObservatoryForcesOnePass(t *testing.T) {
|
||||
}
|
||||
|
||||
e.ConfigureObservatory(ObservatoryConfig{Enabled: true, Options: obsFixture(), ProbeInterval: time.Minute})
|
||||
// The flag lifecycle below is driven by hand, one tick at a time, so the loop
|
||||
// must not be running a pass of its own alongside it — it would consume the
|
||||
// force flag, move the cursor, or (holding the busy guard) turn the tick under
|
||||
// test into a no-op. That collision IS a real production sequence, and it has
|
||||
// its own test: TestRefreshDuringInFlightTickRunsAFullForcedPass.
|
||||
detachObservatoryLoop(e)
|
||||
|
||||
// Fresh observations on every planned tag: the polite gate would skip them
|
||||
// all, which is exactly what a forced pass must NOT do.
|
||||
@@ -195,6 +239,197 @@ func TestRefreshObservatoryForcesOnePass(t *testing.T) {
|
||||
// TestGroups depends on.
|
||||
}
|
||||
|
||||
// A refresh raised while a tick is ALREADY IN FLIGHT must still produce a full
|
||||
// forced pass. This is the LIVE-loop half of the contract the test above pins by
|
||||
// hand, and it is the sequence the panel actually generates: a human presses
|
||||
// "Test" whenever they like, which is very often mid-tick.
|
||||
//
|
||||
// The collision is real. observatoryTickOnce holds the busy guard for the whole
|
||||
// duration of a batch, so a tick started while one is running would be a silent
|
||||
// no-op — the request must survive being made at exactly that moment.
|
||||
//
|
||||
// TWO independent wakeups carry it across, and this was measured rather than
|
||||
// read off the code: the buffered nudge RefreshObservatory sends (parked in the
|
||||
// channel while the loop is inside its tick, picked up the instant that tick
|
||||
// returns), and the tick's own deferred re-nudge. Disabling EITHER one leaves
|
||||
// this test green; disabling BOTH makes it fail with "force is still raised" and
|
||||
// 2 attempts instead of 4. So the assertion below is on the observable contract,
|
||||
// not on a mechanism — which is the right level for it, but it does mean neither
|
||||
// send is individually pinned here. The defer has its own subject in
|
||||
// TestForcedPassChainsItsBatchesWithoutWaitingForTheTick.
|
||||
//
|
||||
// This is also the sequence the flaky version of the tests above kept stumbling
|
||||
// into without ever asserting anything about it: it was the accident, never the
|
||||
// subject. Here it is the subject, and it is deterministic by construction — the
|
||||
// stub probe holds pass 1 open until the test has raised the flag, so "in
|
||||
// flight" is a fact rather than a hope.
|
||||
func TestRefreshDuringInFlightTickRunsAFullForcedPass(t *testing.T) {
|
||||
e := New()
|
||||
t.Cleanup(e.StopObservatory)
|
||||
|
||||
var mu sync.Mutex
|
||||
var attempts []string
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var startOnce, releaseOnce sync.Once
|
||||
// Registered AFTER StopObservatory so it runs BEFORE it (cleanups are LIFO):
|
||||
// any t.Fatal below would otherwise leave a probe parked in the stub, and
|
||||
// StopObservatory waits for the tick holding it — a failing test would hang
|
||||
// instead of reporting.
|
||||
releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
t.Cleanup(releaseAll)
|
||||
hist := e.URLTestHistory()
|
||||
|
||||
// Installed BEFORE the observatory is configured, because the pass this test
|
||||
// parks is the loop's own immediate first one.
|
||||
e.setProbeFn(func(j ProbeJob) {
|
||||
startOnce.Do(func() { close(started) })
|
||||
<-release
|
||||
mu.Lock()
|
||||
attempts = append(attempts, j.Dial)
|
||||
mu.Unlock()
|
||||
// A real probe leaves a FRESH observation behind, and that is what makes
|
||||
// the second pass proof of anything: the polite freshness gate would skip
|
||||
// every job on it, so a second round of attempts can only come from force.
|
||||
for _, tag := range j.Store {
|
||||
hist.StoreURLTestHistory(tag, &adapter.URLTestHistory{LastOK: time.Now(), Delay: 10})
|
||||
}
|
||||
})
|
||||
e.ConfigureObservatory(ObservatoryConfig{
|
||||
Enabled: true, Options: obsSelectorFixture(), ProbeURL: "u", ProbeInterval: time.Minute,
|
||||
})
|
||||
// Checked before anything waits on the stub: a plan the observatory does not
|
||||
// dial would park nothing, and the wait below would hang instead of failing.
|
||||
plan, _, _ := obsState(e)
|
||||
if len(plan) != 2 {
|
||||
t.Fatalf("plan = %d jobs, want 2 (n1, n2)", len(plan))
|
||||
}
|
||||
for _, j := range plan {
|
||||
if j.SelfChecked {
|
||||
t.Fatalf("%s is self-checked; this test needs jobs the observatory itself dials", j.Dial)
|
||||
}
|
||||
}
|
||||
|
||||
// The loop's first pass parks inside the stub, holding the busy guard.
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the observatory's first pass never reached the probe stub")
|
||||
}
|
||||
e.RefreshObservatory()
|
||||
e.obsMu.Lock()
|
||||
force := e.obs.force
|
||||
e.obsMu.Unlock()
|
||||
if !force {
|
||||
t.Fatal("precondition: the refresh did not raise the force flag")
|
||||
}
|
||||
releaseAll()
|
||||
|
||||
// The forced pass must arrive on its own — nothing below ticks anything.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var n int
|
||||
for {
|
||||
mu.Lock()
|
||||
n = len(attempts)
|
||||
mu.Unlock()
|
||||
e.obsMu.Lock()
|
||||
force = e.obs.force
|
||||
e.obsMu.Unlock()
|
||||
if (n >= 4 && !force) || !time.Now().Before(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if force {
|
||||
t.Error("force is still raised: a refresh made during a busy tick was never carried over to a pass")
|
||||
}
|
||||
if n != 4 {
|
||||
t.Fatalf("%d probe attempts, want 4 — the loop's own pass over the 2-job plan, then the FORCED pass the refresh asked for. "+
|
||||
"2 means the refresh was dropped by the busy guard and never re-nudged; more means the flag outlived its one walk", n)
|
||||
}
|
||||
}
|
||||
|
||||
// obsWideSelectorFixture is n nodes behind one used selector — a plan LONGER
|
||||
// than one batch (observatoryBatch), which is the ordinary shape on a
|
||||
// subscription-sized config and the only one where a forced pass needs more than
|
||||
// a single tick to finish.
|
||||
func obsWideSelectorFixture(n int) option.Options {
|
||||
var obs []option.Outbound
|
||||
var members []string
|
||||
for i := 0; i < n; i++ {
|
||||
tag := fmt.Sprintf("w%02d", i)
|
||||
obs = append(obs, fixNode(tag, ""))
|
||||
members = append(members, tag)
|
||||
}
|
||||
obs = append(obs, fixGroup(C.TypeSelector, "wide", members...))
|
||||
return option.Options{Outbounds: obs, Route: fixRoute("wide")}
|
||||
}
|
||||
|
||||
// A forced pass that does not fit in one batch must CHAIN its batches instead of
|
||||
// taking one per 10s tick — the deferred re-nudge in observatoryTickOnce.
|
||||
//
|
||||
// This is the claim that comment makes, and until now nothing held it. It is not
|
||||
// cosmetic: a manual run polls the board and gives up after 120s, so on a
|
||||
// subscription-sized plan (hundreds of measurements, a dozen batches) a pass at
|
||||
// one batch per tick would report "not reached" about nodes nobody got round to
|
||||
// dialling.
|
||||
//
|
||||
// THREE batches, not two, and the third one is the whole point. Two would prove
|
||||
// nothing: the loop's own unconditional first tick runs one batch, and
|
||||
// RefreshObservatory's wakeup is still sitting unconsumed in the buffered nudge
|
||||
// channel to pay for a second — so a two-batch plan finishes even with the
|
||||
// chaining removed. Measured that way round before this constant was raised.
|
||||
func TestForcedPassChainsItsBatchesWithoutWaitingForTheTick(t *testing.T) {
|
||||
const nodes = 2*observatoryBatch + 6 // three batches, the last a short one
|
||||
|
||||
e := New()
|
||||
t.Cleanup(e.StopObservatory)
|
||||
var mu sync.Mutex
|
||||
dialled := map[string]bool{}
|
||||
hist := e.URLTestHistory()
|
||||
e.setProbeFn(func(j ProbeJob) {
|
||||
mu.Lock()
|
||||
dialled[j.Dial] = true
|
||||
mu.Unlock()
|
||||
for _, tag := range j.Store {
|
||||
hist.StoreURLTestHistory(tag, &adapter.URLTestHistory{LastOK: time.Now(), Delay: 10})
|
||||
}
|
||||
})
|
||||
e.ConfigureObservatory(ObservatoryConfig{
|
||||
Enabled: true, Options: obsWideSelectorFixture(nodes), ProbeURL: "u", ProbeInterval: time.Minute,
|
||||
})
|
||||
if plan, _, _ := obsState(e); len(plan) != nodes {
|
||||
t.Fatalf("plan = %d jobs, want %d", len(plan), nodes)
|
||||
}
|
||||
e.RefreshObservatory()
|
||||
|
||||
// Half of observatoryTick (10s), which is the only number that matters here:
|
||||
// generous enough that a loaded runner cannot miss three chained batches of
|
||||
// instant stub probes, and still strictly less than the tick a batch would
|
||||
// otherwise wait for. Both directions measured — see the mutation note above.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var got int
|
||||
for {
|
||||
mu.Lock()
|
||||
got = len(dialled)
|
||||
mu.Unlock()
|
||||
if got >= nodes || !time.Now().Before(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if got != nodes {
|
||||
t.Fatalf("%d of %d planned targets dialled within 5s; a forced pass must chain its batches, "+
|
||||
"not spend one 10s tick each — a manual run times out at 120s", got, nodes)
|
||||
}
|
||||
e.obsMu.Lock()
|
||||
force := e.obs.force
|
||||
e.obsMu.Unlock()
|
||||
if force {
|
||||
t.Error("force is still raised after the whole plan was walked; it must be one-shot")
|
||||
}
|
||||
}
|
||||
|
||||
// The freshness gate: a job whose every covered tag has an observation younger
|
||||
// than the global probe interval is skipped — that set is exactly what an ACTIVE
|
||||
// group is measuring itself, and the observatory must not duplicate or suppress
|
||||
@@ -385,25 +620,58 @@ func newChainProberOf(t *testing.T, opts option.Options, dead func(string) bool)
|
||||
return e, rec
|
||||
}
|
||||
|
||||
// quiesceObservatoryLoop stops the background ticker while leaving the plan, the
|
||||
// hop index and the used-set installed, and rewinds the cursor to the top.
|
||||
// detachObservatoryLoop takes the observatory's ticker goroutine out of the way
|
||||
// and KEEPS it out, leaving the plan, the hop index, the used-set and the CURSOR
|
||||
// exactly as ConfigureObservatory left them.
|
||||
//
|
||||
// The tests below drive observatoryTickOnce by hand and assert on an exact list
|
||||
// of dial attempts, which a ticker running its own passes in parallel would make
|
||||
// nondeterministic. StopObservatory cannot be used for this: it also throws the
|
||||
// plan away, which is the very thing under test. ConfigureObservatory still built
|
||||
// everything — only the goroutine is taken out.
|
||||
func quiesceObservatoryLoop(e *Engine) {
|
||||
// # Why every hand-driven observatory test needs this
|
||||
//
|
||||
// ConfigureObservatory starts the loop, and the loop's first tick fires
|
||||
// IMMEDIATELY — by contract, so an applied config gets its first verdicts in
|
||||
// seconds. That tick advances the cursor and consumes the force flag, and a
|
||||
// plan change additionally nudges the loop into a pass on purpose. Those are
|
||||
// precisely the fields the lifecycle tests read, so reading them straight after
|
||||
// a Configure is reading a value another goroutine is entitled to rewrite in the
|
||||
// same instant.
|
||||
//
|
||||
// That is the whole of the 2026-07-28 release-blocking flake: four assertions,
|
||||
// two tests, one cause. Each failed on its own engine's loop — no cross-test
|
||||
// state, no leaked goroutine — at roughly 1 run in 200 alone and far more often
|
||||
// under a loaded `-shuffle=on` package run, which is why CI saw it and a quiet
|
||||
// laptop did not.
|
||||
//
|
||||
// # Why a PLACEHOLDER stop channel rather than nil
|
||||
//
|
||||
// ConfigureObservatory starts a loop only when e.obs.stop is nil, so leaving a
|
||||
// non-nil one behind means the reconfigures these tests make run their whole
|
||||
// state machine — plan rebuild, cursor policy, nudge — with no goroutine racing
|
||||
// the reader. e.obs.nudge is left nil and every send to it is a select with a
|
||||
// default, so a nudge is dropped instead of blocking. StopObservatory and a
|
||||
// later disable close the placeholder exactly once, as they would the real one.
|
||||
func detachObservatoryLoop(e *Engine) {
|
||||
e.obsMu.Lock()
|
||||
stop, done := e.obs.stop, e.obs.done
|
||||
e.obs.stop, e.obs.done, e.obs.nudge = nil, nil, nil
|
||||
e.obs.stop, e.obs.done, e.obs.nudge = make(chan struct{}), nil, nil
|
||||
e.obsMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
e.obsMu.Unlock()
|
||||
if done != nil {
|
||||
<-done // the loop's own first tick may still be in flight
|
||||
}
|
||||
}
|
||||
|
||||
// quiesceObservatoryLoop detaches the loop as above and additionally rewinds the
|
||||
// cursor to the top of the plan.
|
||||
//
|
||||
// The tests below drive observatoryTickOnce by hand and assert on an exact list
|
||||
// of dial attempts, which a ticker running its own passes in parallel would make
|
||||
// nondeterministic — and which the loop's own first pass would have half-walked
|
||||
// before the test started. StopObservatory cannot be used for this: it also
|
||||
// throws the plan away, which is the very thing under test. ConfigureObservatory
|
||||
// still built everything — only the goroutine is taken out.
|
||||
func quiesceObservatoryLoop(e *Engine) {
|
||||
detachObservatoryLoop(e)
|
||||
e.obsMu.Lock()
|
||||
e.obs.cursor = 0
|
||||
e.obsMu.Unlock()
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// D13 ByeDPI (ciadpi) egress: a `byedpi` egress must emit a SOCKS5 outbound to
|
||||
// 127.0.0.1:<port> tagged egress-<name>, and a rule targeting egress:<name> must
|
||||
// route to that tag. These are pure option-struct assertions (no box.New), so
|
||||
// they build on every platform; the box.New/engine validation runs in the linux
|
||||
// suite (TestByedpiEgressValidates).
|
||||
package generate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// outboundByTag returns the outbound with the given tag (nil if absent). Named
|
||||
// distinctly from the linux suite's findOutbound so both compile together.
|
||||
func outboundByTag(opts option.Options, tag string) *option.Outbound {
|
||||
for i := range opts.Outbounds {
|
||||
if opts.Outbounds[i].Tag == tag {
|
||||
return &opts.Outbounds[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func byedpiModel(port int) *model.Model {
|
||||
return &model.Model{
|
||||
Globals: plainGlobals(),
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: port}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "desync", Enabled: true, Order: 10, Src: []string{"192.168.1.0/24"}, Target: "egress:bd"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// assertByedpiSocks checks the egress-bd outbound is a SOCKS5 outbound to
|
||||
// 127.0.0.1:wantPort with the loop-guard mark, and that a route rule targets it.
|
||||
func assertByedpiSocks(t *testing.T, opts option.Options, wantPort uint16) {
|
||||
t.Helper()
|
||||
ob := outboundByTag(opts, "egress-bd")
|
||||
if ob == nil {
|
||||
t.Fatalf("byedpi egress outbound 'egress-bd' not emitted; outbounds=%+v", opts.Outbounds)
|
||||
}
|
||||
if ob.Type != C.TypeSOCKS {
|
||||
t.Fatalf("egress-bd type = %q, want %q", ob.Type, C.TypeSOCKS)
|
||||
}
|
||||
so, ok := ob.Options.(*option.SOCKSOutboundOptions)
|
||||
if !ok {
|
||||
t.Fatalf("egress-bd options type = %T, want *option.SOCKSOutboundOptions", ob.Options)
|
||||
}
|
||||
if so.Server != "127.0.0.1" {
|
||||
t.Fatalf("egress-bd server = %q, want 127.0.0.1", so.Server)
|
||||
}
|
||||
if so.ServerPort != wantPort {
|
||||
t.Fatalf("egress-bd server_port = %d, want %d", so.ServerPort, wantPort)
|
||||
}
|
||||
if so.Version != "5" {
|
||||
t.Fatalf("egress-bd socks version = %q, want \"5\"", so.Version)
|
||||
}
|
||||
if so.DialerOptions.RoutingMark != LoopMark {
|
||||
t.Fatalf("egress-bd RoutingMark = %d, want loop-guard %d", so.DialerOptions.RoutingMark, LoopMark)
|
||||
}
|
||||
if routeActionFor(opts, "egress-bd") == nil {
|
||||
t.Fatalf("expected a route rule with action route->egress-bd; route=%+v", opts.Route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByedpiEgressSocksOutbound(t *testing.T) {
|
||||
opts, warns, err := GenerateWithWarnings(byedpiModel(1080))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
assertByedpiSocks(t, opts, 1080)
|
||||
}
|
||||
|
||||
func TestByedpiEgressDefaultPort(t *testing.T) {
|
||||
// Port:0 must fall back to 1080.
|
||||
opts, warns, err := GenerateWithWarnings(byedpiModel(0))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
assertByedpiSocks(t, opts, 1080)
|
||||
}
|
||||
|
||||
func TestByedpiEgressCustomPort(t *testing.T) {
|
||||
opts, _, err := GenerateWithWarnings(byedpiModel(1055))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
assertByedpiSocks(t, opts, 1055)
|
||||
}
|
||||
@@ -99,6 +99,7 @@ func (b *builder) cacheFilePath() string {
|
||||
// to install a firmware upgrade at all.
|
||||
if _, statErr := os.Stat(cacheFilePersistent); statErr == nil {
|
||||
_ = os.Remove(cacheFilePersistent)
|
||||
forgetCachedRuleSets(cacheFilePersistent)
|
||||
}
|
||||
b.warnf("cache: only %d MiB free on %s (need %d MiB), using tmpfs %s instead — remote rule-sets will be re-downloaded after every reboot, so free some space", avail>>20, cacheDirPersistent, cacheMinFreeSpace>>20, cacheFileFallback)
|
||||
return cacheFileFallback
|
||||
@@ -106,3 +107,38 @@ func (b *builder) cacheFilePath() string {
|
||||
|
||||
return cacheFilePersistent
|
||||
}
|
||||
|
||||
// engineCacheDBPath names the cache DB the ENGINE will read at start, or "" when
|
||||
// there will be nothing there worth reading. It is the READ-SIDE TWIN of
|
||||
// cacheFilePath: same states, same order, but no pruning, no deleting and no
|
||||
// warnings, because it runs while the rule-sets are being built — BEFORE
|
||||
// cacheFilePath itself runs (generate.go emits Experimental.CacheFile last).
|
||||
//
|
||||
// The cold-start check (coldstart_cache.go) needs this and cannot use
|
||||
// cacheFilePath: calling that twice per generate would warn twice and prune twice.
|
||||
// The two must agree, so the standard for a presence-check applies — it has to
|
||||
// cover everything its acting twin does — and cache_test.go pins the four states
|
||||
// against each other.
|
||||
//
|
||||
// The one state where the twins deliberately DIFFER is the oversized DB. There
|
||||
// cacheFilePath deletes it and keeps the persistent path; anything read out of that
|
||||
// DB is about to be thrown away, so the honest answer for a reader is "there is no
|
||||
// cache", not the path. (If the delete then fails, the DB does survive and we lose a
|
||||
// proof we could have had — that degrades to the pre-existing behaviour, never past
|
||||
// it.)
|
||||
func engineCacheDBPath() string {
|
||||
fi, err := os.Stat(cacheDirPersistent)
|
||||
if err != nil || !fi.IsDir() {
|
||||
// cacheFilePath returns the tmpfs path here. That file may well exist and be
|
||||
// populated — an engine restart without a reboot keeps it — so it is worth
|
||||
// reading; it is only a REBOOT that empties it.
|
||||
return cacheFileFallback
|
||||
}
|
||||
if st, err := os.Stat(cacheFilePersistent); err == nil && st.Size() > cacheMaxBytes {
|
||||
return ""
|
||||
}
|
||||
if avail, ok := fsAvailBytes(cacheDirPersistent); ok && avail < cacheMinFreeSpace {
|
||||
return cacheFileFallback
|
||||
}
|
||||
return cacheFilePersistent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package generate
|
||||
|
||||
// R5, part two — COLD START: a remote rule-set that is already IN THE CACHE must
|
||||
// not be dropped just because its source is unreachable.
|
||||
//
|
||||
// # The hole this closes, measured
|
||||
//
|
||||
// The R5 preflight (ruleset.go) asks one question — "can this URL be fetched right
|
||||
// now?" — and omits the list when the answer is no. Its own comment records the
|
||||
// limitation and names the fix:
|
||||
//
|
||||
// "a brief source outage at generate time temporarily disables the list even
|
||||
// though a perfectly good cached copy exists ... The cache-seeding fix above
|
||||
// removes this limitation entirely and should replace the probe when the
|
||||
// engine/apply owner can take it."
|
||||
//
|
||||
// "Brief" was the assumption, and it is false on the router this was written for.
|
||||
// Behind a SIM uplink whose operator passes a whitelist, raw.githubusercontent.com
|
||||
// is not briefly unreachable, it is PERMANENTLY unreachable, and the result was a
|
||||
// permanent
|
||||
//
|
||||
// blocklist "apple": RULESET-NOT-APPLIED: ... its source ... is unreachable
|
||||
// right now, so rule-set "bl-apple-apple" was omitted and matches NOTHING
|
||||
// DNS-FILTER-NOT-APPLIED: ... NOT ONE of them could be built right now
|
||||
//
|
||||
// on a router whose cache holds a perfectly good copy of every one of those lists.
|
||||
// Nothing was filtered, forever, and no reconcile was ever going to fix it.
|
||||
//
|
||||
// # What is checked, and why it is proof rather than a bet
|
||||
//
|
||||
// route/rule.RemoteRuleSet.StartContext (rule_set_remote.go:87-103) does exactly
|
||||
// this before it considers fetching:
|
||||
//
|
||||
// savedSet := cacheFile.LoadRuleSet(tag) // bucket "rule_set", key = TAG
|
||||
// err := loadBytes(savedSet.Content) // srs.Read / json + Upgrade
|
||||
// if err == nil { lastUpdated = savedSet.LastUpdated }
|
||||
// if lastUpdated.IsZero() { fetch(...) } // <- the only fatal branch
|
||||
//
|
||||
// So the fetch — the branch that fails router.Start and takes the LAN down with it
|
||||
// — is skipped iff the cache holds an entry for THIS TAG whose content this build
|
||||
// can decode and whose LastUpdated is non-zero. cachedRuleSetUsable evaluates that
|
||||
// same predicate on the same bytes with the same reader. It is not a heuristic
|
||||
// about the cache being "probably warm": a positive answer means the engine has
|
||||
// already been shown the path it will take.
|
||||
//
|
||||
// Two properties make the answer sturdier than it looks:
|
||||
//
|
||||
// - The cache is only ever WRITTEN after a successful load. fetch() calls
|
||||
// loadBytes first and SaveRuleSet only once it returns nil
|
||||
// (rule_set_remote.go:289-309), so every entry in there was, at the moment it
|
||||
// was stored, fully constructible by this binary — matchers included. Re-reading
|
||||
// it here re-verifies the two things that can change underneath that: on-disk
|
||||
// corruption, and an .srs format version a DOWNGRADED build can no longer read.
|
||||
// - The key is the TAG from the config being generated. Rename a list and the
|
||||
// lookup misses, which is the truthful answer: the engine would miss too.
|
||||
//
|
||||
// # Why NOT seed the cache instead, as the R5 block proposed
|
||||
//
|
||||
// Writing an empty entry to make StartContext skip the fetch was the plan on
|
||||
// record. It is rejected here, and not for effort:
|
||||
//
|
||||
// - It needs a WRITE lock on a bbolt file the engine holds open for its whole
|
||||
// life, so it can only happen in the gap between closing the old box and
|
||||
// starting the new one — engine/apply code, not this package.
|
||||
// - An EMPTY seeded entry is a lie in the dangerous direction. lastUpdated would
|
||||
// be non-zero with no rules behind it, so the list would be ACTIVE-and-empty
|
||||
// rather than absent, and an empty allowlist or an empty ip_cidr routing set is
|
||||
// the "matches everything" collapse (D1/D2) this package spends most of its
|
||||
// warnings preventing.
|
||||
//
|
||||
// Reading is enough, and reading needs no lock the engine will fight over.
|
||||
//
|
||||
// # The lock, and the memo that makes one read last
|
||||
//
|
||||
// bbolt takes flock() per open file description, so while the engine is up this
|
||||
// read-only open is REFUSED — including from inside shaterd, which holds the
|
||||
// exclusive lock on another fd. That is not a problem, it is the shape of the
|
||||
// scenario: the generate that matters is the FIRST one after boot, which runs
|
||||
// before any box exists (apply.go: generate -> engine.Apply -> box.New), and there
|
||||
// the DB is unlocked.
|
||||
//
|
||||
// Later reconciles find it locked, so a positive proof is remembered for the
|
||||
// process lifetime and re-checked only against the DB file still existing. That is
|
||||
// sound rather than convenient: sing-box replaces a cached rule-set only with
|
||||
// another one that just loaded, and never deletes one. The single way an entry can
|
||||
// disappear under us is cacheFilePath discarding the whole DB, which happens in
|
||||
// this package and calls forgetCachedRuleSets when it does.
|
||||
//
|
||||
// # Degradation is unchanged where it has to be
|
||||
//
|
||||
// No cache entry, or one that will not decode, still omits the list with the
|
||||
// original RULESET-NOT-APPLIED warning. An omitted blocklist blocks nothing; an
|
||||
// omitted routing rule-set loses its matcher and the rule is skipped. Empty still
|
||||
// means "matches nothing", never "matches everything", and an unusable list still
|
||||
// cannot stop the engine from starting.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/bbolt"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/srs"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json"
|
||||
)
|
||||
|
||||
const (
|
||||
// cachedRuleSetBucket is the bbolt bucket experimental/cachefile keeps remote
|
||||
// rule-sets in (its bucketRuleSet, unexported). A copied constant is a drift
|
||||
// hazard, so the test that matters seeds the DB through sing-box's OWN
|
||||
// CacheFile.SaveRuleSet and reads it back through this file: if the bucket name
|
||||
// or the SavedBinary encoding ever moves, that test fails rather than this
|
||||
// check quietly answering "not cached" forever.
|
||||
cachedRuleSetBucket = "rule_set"
|
||||
|
||||
// coldStartOpenTimeout bounds the flock wait. Tiny on purpose: while the engine
|
||||
// runs, this open is EXPECTED to fail, and it must fail without stalling an
|
||||
// apply. (cachefile's own open uses 1s x 10 retries — that is a startup cost it
|
||||
// can afford and a reconcile cannot.)
|
||||
coldStartOpenTimeout = 50 * time.Millisecond
|
||||
|
||||
// coldStartLockedBackoff suppresses further open attempts after one failed:
|
||||
// when the DB is locked it is locked for every tag, and a config with a dozen
|
||||
// lists would otherwise pay the timeout a dozen times per reconcile.
|
||||
coldStartLockedBackoff = 30 * time.Second
|
||||
|
||||
// coldStartMissTTL is how long "this tag is not in the cache" is remembered.
|
||||
// Short, because the engine fills the entry in as soon as one fetch succeeds
|
||||
// and the next reconcile should see it. Matches ruleSetProbeTTL's role.
|
||||
coldStartMissTTL = 90 * time.Second
|
||||
)
|
||||
|
||||
// errCacheUnavailable marks "the DB itself could not be opened" — locked, absent
|
||||
// or unreadable — as opposed to "opened fine, this tag is not in it". Only the
|
||||
// former arms the backoff.
|
||||
var errCacheUnavailable = errors.New("cache database is not readable right now")
|
||||
|
||||
// cachedRuleSet is the evidence that a remote rule-set can start with no network.
|
||||
type cachedRuleSet struct {
|
||||
// lastUpdated is when the engine last fetched this copy. Non-zero by
|
||||
// construction (a zero value is what makes StartContext fetch), and reported to
|
||||
// the operator so "serving from cache" carries an age rather than a shrug.
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
// cachedRuleSetReader loads one saved rule-set out of a cache DB. A package var so
|
||||
// the decision logic can be driven without a real bbolt file; the bbolt-backed
|
||||
// implementation is exercised separately against a DB written by sing-box itself.
|
||||
var cachedRuleSetReader = boltCachedRuleSetReader
|
||||
|
||||
// boltCachedRuleSetReader opens dbPath read-only and returns the saved entry for
|
||||
// tag. Read-only means bbolt takes a SHARED flock, which also means the open
|
||||
// succeeds only when no process holds the exclusive one — so a successful open is
|
||||
// itself the guarantee that nothing is writing while we read.
|
||||
func boltCachedRuleSetReader(dbPath, tag string) (saved *adapter.SavedBinary, err error) {
|
||||
db, err := bbolt.Open(dbPath, 0o600, &bbolt.Options{ReadOnly: true, Timeout: coldStartOpenTimeout})
|
||||
if err != nil {
|
||||
return nil, errors.Join(errCacheUnavailable, err)
|
||||
}
|
||||
defer db.Close()
|
||||
// A corrupt page makes bbolt panic rather than error (cachefile.view wraps its
|
||||
// own reads the same way). A damaged cache must degrade to "not cached", not
|
||||
// take the daemon down.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
saved, err = nil, errors.New("cache database is corrupt")
|
||||
}
|
||||
}()
|
||||
err = db.View(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(cachedRuleSetBucket))
|
||||
if bucket == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
raw := bucket.Get([]byte(tag))
|
||||
if len(raw) == 0 {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
var out adapter.SavedBinary
|
||||
if err := out.UnmarshalBinary(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
saved = &out
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
type coldStartProof struct {
|
||||
set *cachedRuleSet // nil => looked, found nothing usable
|
||||
when time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
coldStartMu sync.Mutex
|
||||
coldStartMemo = map[string]coldStartProof{}
|
||||
coldStartBlocked time.Time // no open attempts until this instant
|
||||
)
|
||||
|
||||
// cachedRuleSetUsable reports whether sing-box's cache already holds a copy of
|
||||
// rule-set `tag` that its own StartContext would accept in `format`, i.e. whether
|
||||
// handing the engine this REMOTE rule-set is safe with the source unreachable.
|
||||
func cachedRuleSetUsable(tag, format string) (cachedRuleSet, bool) {
|
||||
dbPath := engineCacheDBPath()
|
||||
if dbPath == "" {
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
key := dbPath + "\x00" + tag
|
||||
|
||||
coldStartMu.Lock()
|
||||
if p, ok := coldStartMemo[key]; ok {
|
||||
if p.set != nil {
|
||||
coldStartMu.Unlock()
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
return *p.set, true
|
||||
}
|
||||
// The DB it was proven from is gone. Nothing else in this package can
|
||||
// have removed it silently, but a person with a shell can.
|
||||
forgetCachedRuleSets(dbPath)
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
if time.Since(p.when) < coldStartMissTTL {
|
||||
coldStartMu.Unlock()
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
}
|
||||
if time.Now().Before(coldStartBlocked) {
|
||||
coldStartMu.Unlock()
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
coldStartMu.Unlock()
|
||||
|
||||
saved, err := cachedRuleSetReader(dbPath, tag)
|
||||
if err != nil {
|
||||
if errors.Is(err, errCacheUnavailable) {
|
||||
// Locked (the engine is up) or absent. Do not memoise per tag: the
|
||||
// answer is about the DB, not about this list.
|
||||
coldStartMu.Lock()
|
||||
coldStartBlocked = time.Now().Add(coldStartLockedBackoff)
|
||||
coldStartMu.Unlock()
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
return rememberColdStart(key, nil)
|
||||
}
|
||||
if !cachedCopyLoadable(saved, format) {
|
||||
return rememberColdStart(key, nil)
|
||||
}
|
||||
return rememberColdStart(key, &cachedRuleSet{lastUpdated: saved.LastUpdated})
|
||||
}
|
||||
|
||||
func rememberColdStart(key string, set *cachedRuleSet) (cachedRuleSet, bool) {
|
||||
coldStartMu.Lock()
|
||||
coldStartMemo[key] = coldStartProof{set: set, when: time.Now()}
|
||||
coldStartMu.Unlock()
|
||||
if set == nil {
|
||||
return cachedRuleSet{}, false
|
||||
}
|
||||
return *set, true
|
||||
}
|
||||
|
||||
// forgetCachedRuleSets drops every proof read out of dbPath. Called wherever this
|
||||
// package discards a cache DB (cache.go), because a proof outlives the read that
|
||||
// produced it and must not outlive the file.
|
||||
func forgetCachedRuleSets(dbPath string) {
|
||||
coldStartMu.Lock()
|
||||
for k := range coldStartMemo {
|
||||
if strings.HasPrefix(k, dbPath+"\x00") {
|
||||
delete(coldStartMemo, k)
|
||||
}
|
||||
}
|
||||
coldStartMu.Unlock()
|
||||
}
|
||||
|
||||
// resetColdStartCache clears the memo and the backoff (tests).
|
||||
func resetColdStartCache() {
|
||||
coldStartMu.Lock()
|
||||
coldStartMemo = map[string]coldStartProof{}
|
||||
coldStartBlocked = time.Time{}
|
||||
coldStartMu.Unlock()
|
||||
}
|
||||
|
||||
// cachedCopyLoadable decides whether RemoteRuleSet.loadBytes would accept these
|
||||
// bytes, using loadBytes' own readers on loadBytes' own branches.
|
||||
//
|
||||
// LastUpdated is checked first and is not a formality: it is the ONLY thing
|
||||
// StartContext consults to decide whether to fetch, so a stored copy with a zero
|
||||
// timestamp is worth nothing here however well it decodes.
|
||||
//
|
||||
// The format list is positive and CLOSED. An unrecognised format falls to "not
|
||||
// usable", which omits the list — the recoverable side. loadBytes' own default
|
||||
// branch returns "unknown rule-set format", and that error at start is fatal, so
|
||||
// answering "usable" for a format neither of us knows would be the one mistake
|
||||
// that costs the LAN.
|
||||
func cachedCopyLoadable(saved *adapter.SavedBinary, format string) bool {
|
||||
if saved == nil || saved.LastUpdated.IsZero() {
|
||||
return false
|
||||
}
|
||||
var (
|
||||
compat option.PlainRuleSetCompat
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case C.RuleSetFormatBinary:
|
||||
compat, err = srs.Read(bytes.NewReader(saved.Content), false)
|
||||
case C.RuleSetFormatSource:
|
||||
compat, err = json.UnmarshalExtended[option.PlainRuleSetCompat](saved.Content)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Upgrade is the second half of loadBytes' decode and has its own failure mode
|
||||
// (an unknown compat version), so skipping it would leave a fatal case unseen.
|
||||
if _, err := compat.Upgrade(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/srs"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/experimental/cachefile"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// The scenario every test in this file is about, measured on the production
|
||||
// router: the SIM operator passes a whitelist, raw.githubusercontent.com is not on
|
||||
// it, and the R5 preflight therefore refuses every geosite/geoip list FOREVER —
|
||||
// while the cache on /etc/shater holds a good copy of each one. The requirement is
|
||||
// the owner's, verbatim: the lists must come up from disk after a reboot with no
|
||||
// working WAN, and only update later.
|
||||
|
||||
const coldStartAppleURL = "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs"
|
||||
|
||||
// coldStartCompiledSRS builds a real compiled rule-set — the same srs.Write path
|
||||
// compileDomainList uses — so the tests decode genuine bytes rather than a fake
|
||||
// that only this package would accept.
|
||||
func coldStartCompiledSRS(t *testing.T, domains ...string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
set := option.PlainRuleSet{Rules: []option.HeadlessRule{{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultHeadlessRule{
|
||||
DomainSuffix: badoption.Listable[string](domains),
|
||||
},
|
||||
}}}
|
||||
if err := srs.Write(&buf, set, C.RuleSetVersionCurrent); err != nil {
|
||||
t.Fatalf("compile a rule-set for the fixture: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// coldStartHasRuleSet reports whether the generated config carries a rule-set with
|
||||
// this tag. dnsfilter_test.go has the same helper, but that file is //go:build
|
||||
// linux and these tests are not — the cache decision is plain file I/O, so they
|
||||
// must also run on a developer's machine where the gate does not.
|
||||
func coldStartHasRuleSet(opts option.Options, tag string) bool {
|
||||
if opts.Route == nil {
|
||||
return false
|
||||
}
|
||||
for _, rs := range opts.Route.RuleSet {
|
||||
if rs.Tag == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withColdStartCache gives this test a private /etc/shater, clears the proof memo
|
||||
// on both sides, and returns the DB path engineCacheDBPath will choose. The real
|
||||
// overlay is never read or written from a test (see shater/testguard).
|
||||
func withColdStartCache(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "shater")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("create the private cache dir: %v", err)
|
||||
}
|
||||
persistent, _ := withCachePaths(t, dir)
|
||||
resetColdStartCache()
|
||||
t.Cleanup(resetColdStartCache)
|
||||
if got := engineCacheDBPath(); got != persistent {
|
||||
t.Fatalf("the fixture did not take: engineCacheDBPath()=%q, want %q "+
|
||||
"(free space on %s may be below the %d-byte floor)", got, persistent, dir, cacheMinFreeSpace)
|
||||
}
|
||||
return persistent
|
||||
}
|
||||
|
||||
// seedCacheDB writes one saved rule-set through sing-box's OWN cache writer.
|
||||
//
|
||||
// Using the real writer is the point, not convenience. cachedRuleSetBucket and the
|
||||
// SavedBinary framing are COPIES of things cachefile keeps unexported, and a copied
|
||||
// constant drifts silently: the reader would start answering "not cached" for
|
||||
// everything and every test that hand-rolled the layout would keep passing. Written
|
||||
// this way, a rename over there fails here.
|
||||
func seedCacheDB(t *testing.T, dbPath, tag string, content []byte, lastUpdated time.Time) {
|
||||
t.Helper()
|
||||
cf := cachefile.New(context.Background(), logger.NOP(), option.CacheFileOptions{Enabled: true, Path: dbPath})
|
||||
if err := cf.Start(adapter.StartStateInitialize); err != nil {
|
||||
t.Fatalf("open the cache db for seeding: %v", err)
|
||||
}
|
||||
if err := cf.SaveRuleSet(tag, &adapter.SavedBinary{Content: content, LastUpdated: lastUpdated}); err != nil {
|
||||
cf.Close()
|
||||
t.Fatalf("save rule-set %q: %v", tag, err)
|
||||
}
|
||||
if err := cf.Close(); err != nil {
|
||||
t.Fatalf("close the cache db (it must not stay locked): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColdStartAppliesCachedRuleSetWhenSourceIsDown is the headline case: cache
|
||||
// present, network dead, list APPLIED.
|
||||
//
|
||||
// It also pins the two halves of that claim that could each be faked on their own —
|
||||
// the set really is handed to the engine (ok, remote type, the URL intact), and the
|
||||
// operator is told it came from the cache with an age, rather than being told
|
||||
// nothing or being told it is fresh.
|
||||
func TestColdStartAppliesCachedRuleSetWhenSourceIsDown(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
fetched := time.Now().Add(-36 * time.Hour).Truncate(time.Second)
|
||||
seedCacheDB(t, db, "bl-apple-apple", coldStartCompiledSRS(t, "apple.com", "icloud.com"), fetched)
|
||||
|
||||
withRuleSetProbe(t, func(string) bool { return false }) // the SIM blocks it
|
||||
|
||||
b := newBuilder(&model.Model{})
|
||||
set, ok := b.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`)
|
||||
if !ok {
|
||||
t.Fatalf("a list with a loadable copy in the cache must still be applied when its source is down; warnings: %v", b.warnings)
|
||||
}
|
||||
if set.Type != C.RuleSetTypeRemote || set.RemoteOptions.URL != coldStartAppleURL {
|
||||
t.Fatalf("the engine must still own fetch/update/status for this list, got %+v", set)
|
||||
}
|
||||
if set.Format != C.RuleSetFormatBinary {
|
||||
t.Fatalf("format %q: the cached copy was decoded as binary, so the engine must be told binary", set.Format)
|
||||
}
|
||||
if !warnsHaveSub(b.warnings, "RULESET-FROM-CACHE") {
|
||||
t.Fatalf("applying a stale cached copy silently is a lie about freshness; warnings: %v", b.warnings)
|
||||
}
|
||||
if !warnsHaveSub(b.warnings, fetched.UTC().Format(time.RFC3339)) {
|
||||
t.Fatalf("the warning must carry the age of the copy being served; warnings: %v", b.warnings)
|
||||
}
|
||||
if warnsHaveSub(b.warnings, "RULESET-NOT-APPLIED") {
|
||||
t.Fatalf("the list WAS applied, so the not-applied warning is a false alarm; warnings: %v", b.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColdStartOmitsRuleSetWithNoCache is the pair, and the invariant that must
|
||||
// survive the fix: with no cached copy the list is still OMITTED — never handed to
|
||||
// the engine on a hope — with the original, greppable warning. An omitted blocklist
|
||||
// blocks nothing; a list handed over unfetchable and uncached aborts box.Start and
|
||||
// takes the whole LAN down.
|
||||
func TestColdStartOmitsRuleSetWithNoCache(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
if _, err := os.Stat(db); err == nil {
|
||||
t.Fatalf("fixture: %s must not exist for the empty-cache case", db)
|
||||
}
|
||||
|
||||
withRuleSetProbe(t, func(string) bool { return false })
|
||||
|
||||
b := newBuilder(&model.Model{})
|
||||
set, ok := b.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`)
|
||||
if ok {
|
||||
t.Fatalf("with no cached copy the list must be omitted, got %+v", set)
|
||||
}
|
||||
if !warnsHaveSub(b.warnings, "RULESET-NOT-APPLIED") {
|
||||
t.Fatalf("the omission must keep its greppable prefix; warnings: %v", b.warnings)
|
||||
}
|
||||
for _, want := range []string{"matches NOTHING", "no usable copy in the cache"} {
|
||||
if !warnsHaveSub(b.warnings, want) {
|
||||
t.Fatalf("warning must still say %q; warnings: %v", want, b.warnings)
|
||||
}
|
||||
}
|
||||
if warnsHaveSub(b.warnings, "RULESET-FROM-CACHE") {
|
||||
t.Fatalf("nothing was served from the cache; warnings: %v", b.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColdStartRefusesUnusableCachedCopies covers the three ways a cache entry is
|
||||
// present but worthless. Each one, if accepted, ends the same way: StartContext
|
||||
// fails to load it, lastUpdated stays zero, it fetches, the fetch fails, and
|
||||
// box.Start returns an error with the LAN fail-closed behind it. So each must
|
||||
// degrade to the omission above, not to an optimistic yes.
|
||||
func TestColdStartRefusesUnusableCachedCopies(t *testing.T) {
|
||||
good := coldStartCompiledSRS(t, "apple.com")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content []byte
|
||||
lastUpdated time.Time
|
||||
why string
|
||||
}{
|
||||
{
|
||||
name: "corrupt content", content: []byte("not a rule-set at all"), lastUpdated: time.Now(),
|
||||
why: "srs.Read refuses it, so loadBytes refuses it and the engine refetches — fatally",
|
||||
},
|
||||
{
|
||||
name: "truncated content", content: good[:len(good)/2], lastUpdated: time.Now(),
|
||||
why: "a half-written .srs decodes its header and then fails in the zlib stream",
|
||||
},
|
||||
{
|
||||
name: "never updated", content: good, lastUpdated: time.Time{},
|
||||
why: "StartContext consults ONLY lastUpdated to decide whether to fetch; zero means it fetches",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
seedCacheDB(t, db, "bl-apple-apple", tc.content, tc.lastUpdated)
|
||||
withRuleSetProbe(t, func(string) bool { return false })
|
||||
|
||||
b := newBuilder(&model.Model{})
|
||||
if _, ok := b.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`); ok {
|
||||
t.Fatalf("this cached copy must NOT be accepted: %s", tc.why)
|
||||
}
|
||||
if !warnsHaveSub(b.warnings, "RULESET-NOT-APPLIED") {
|
||||
t.Fatalf("expected the omission warning, got %v", b.warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColdStartDNSFilterComesUpOffline is the router's symptom, end to end at the
|
||||
// generate level: dns_filter on, one geosite blocklist, source unreachable. Before
|
||||
// the fix this produced "RULESET-NOT-APPLIED ... matches NOTHING" followed by
|
||||
// "DNS-FILTER-NOT-APPLIED ... NOT ONE of them could be built", i.e. no filtering at
|
||||
// all on a router whose cache held the list. The control is in the same test: with
|
||||
// the cache emptied, both warnings must come back — otherwise this only proves that
|
||||
// the filter is built, not that the CACHE is what built it.
|
||||
func TestColdStartDNSFilterComesUpOffline(t *testing.T) {
|
||||
appleModel := func() *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.DNSFilter = true
|
||||
g.ResolverDefault = "cf"
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Inbounds: []model.Inbound{
|
||||
{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12380, TCP: true, UDP: true},
|
||||
},
|
||||
Resolvers: []model.Resolver{
|
||||
{Name: "cf", Type: "doh", Address: "https://1.1.1.1/dns-query", Detour: "direct"},
|
||||
},
|
||||
Blocklists: []model.Blocklist{
|
||||
{Name: "apple", Enabled: true, Source: "geosite", Categories: []string{"apple"}, Response: "nxdomain"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("cache warm", func(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
seedCacheDB(t, db, "bl-apple-apple", coldStartCompiledSRS(t, "apple.com"), time.Now().Add(-time.Hour))
|
||||
withRuleSetProbe(t, func(string) bool { return false })
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(appleModel())
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !coldStartHasRuleSet(opts, "bl-apple-apple") {
|
||||
t.Fatalf("the blocklist must be in the config, built from the cache; warnings: %v", warns)
|
||||
}
|
||||
if warnsHaveSub(warns, "DNS-FILTER-NOT-APPLIED") {
|
||||
t.Fatalf("the DNS filter WAS built, so this warning is false; warnings: %v", warns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("control: cache cold", func(t *testing.T) {
|
||||
withColdStartCache(t) // the DB is not created at all
|
||||
withRuleSetProbe(t, func(string) bool { return false })
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(appleModel())
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if coldStartHasRuleSet(opts, "bl-apple-apple") {
|
||||
t.Fatalf("with nothing in the cache and no network the list must NOT be emitted")
|
||||
}
|
||||
if !warnsHaveSub(warns, "DNS-FILTER-NOT-APPLIED") {
|
||||
t.Fatalf("control: the offline-and-uncached router must still say the filter is not applied; warnings: %v", warns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestColdStartReaderMatchesTheEngineWriter is the drift pin for the two things
|
||||
// this package copies out of experimental/cachefile: the bucket name and the
|
||||
// SavedBinary framing. It is deliberately a direct reader test rather than an
|
||||
// assertion about behaviour, so a rename over there reports itself here instead of
|
||||
// silently turning every cold start back into an omission.
|
||||
func TestColdStartReaderMatchesTheEngineWriter(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
content := coldStartCompiledSRS(t, "apple.com")
|
||||
when := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
seedCacheDB(t, db, "bl-apple-apple", content, when)
|
||||
|
||||
saved, err := boltCachedRuleSetReader(db, "bl-apple-apple")
|
||||
if err != nil {
|
||||
t.Fatalf("reading back what sing-box's own writer wrote failed: %v — "+
|
||||
"cachedRuleSetBucket (%q) or adapter.SavedBinary has moved", err, cachedRuleSetBucket)
|
||||
}
|
||||
if !bytes.Equal(saved.Content, content) {
|
||||
t.Fatalf("content round-trip differs: %d bytes back, %d written", len(saved.Content), len(content))
|
||||
}
|
||||
if !saved.LastUpdated.Equal(when) {
|
||||
t.Fatalf("LastUpdated round-trip: got %s, want %s", saved.LastUpdated, when)
|
||||
}
|
||||
|
||||
if _, err := boltCachedRuleSetReader(db, "bl-nobody-asked-for-this"); err == nil {
|
||||
t.Fatal("a tag that was never cached must not report a hit")
|
||||
}
|
||||
|
||||
// A DB that is not there at all is "unavailable", not "this tag is missing" —
|
||||
// the difference arms the backoff instead of memoising a per-tag miss.
|
||||
missing := filepath.Join(t.TempDir(), "no-such-cache.db")
|
||||
if _, err := boltCachedRuleSetReader(missing, "bl-apple-apple"); err == nil {
|
||||
t.Fatal("opening a non-existent DB must fail")
|
||||
} else if !strings.Contains(err.Error(), "not readable right now") {
|
||||
t.Fatalf("a missing DB must be reported as unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineCacheDBPathTwinsCacheFilePath holds the read-side twin to the standard
|
||||
// this repo learned the hard way: a presence check must cover everything its acting
|
||||
// twin does, or the fast path becomes a trap. engineCacheDBPath decides which DB to
|
||||
// READ, cacheFilePath decides which DB the engine WRITES, and they must not be able
|
||||
// to disagree about which file that is.
|
||||
func TestEngineCacheDBPathTwinsCacheFilePath(t *testing.T) {
|
||||
t.Run("dir present: both choose the persistent DB", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "shater")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
persistent, _ := withCachePaths(t, dir)
|
||||
if got := newBuilder(&model.Model{Globals: model.DefaultGlobals()}).cacheFilePath(); got != persistent {
|
||||
t.Skipf("this machine's free space puts the writer on tmpfs (%s); the twin has nothing to compare", got)
|
||||
}
|
||||
if got := engineCacheDBPath(); got != persistent {
|
||||
t.Fatalf("reader chose %q, writer chose %q — the cold-start check would consult a file the engine never reads", got, persistent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dir missing: both choose tmpfs", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "absent")
|
||||
_, fallback := withCachePaths(t, dir)
|
||||
if got := newBuilder(&model.Model{Globals: model.DefaultGlobals()}).cacheFilePath(); got != fallback {
|
||||
t.Fatalf("writer: want %q, got %q", fallback, got)
|
||||
}
|
||||
if got := engineCacheDBPath(); got != fallback {
|
||||
t.Fatalf("reader: want %q, got %q", fallback, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized DB: the reader reports no cache", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "shater")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
persistent, _ := withCachePaths(t, dir)
|
||||
if err := os.WriteFile(persistent, make([]byte, cacheMaxBytes+1), 0o600); err != nil {
|
||||
t.Fatalf("seed an oversized db: %v", err)
|
||||
}
|
||||
// cacheFilePath is about to DELETE this DB, so anything read out of it is
|
||||
// about to stop existing. "" is the honest answer; the persistent path is not.
|
||||
if got := engineCacheDBPath(); got != "" {
|
||||
t.Fatalf("reader returned %q for a DB the writer is about to discard", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestColdStartProofSurvivesTheEngineTakingTheLock is the reason a proof is
|
||||
// remembered at all. The generate that matters runs before any box exists; every
|
||||
// reconcile after it finds the DB flocked by the engine and cannot read a thing. If
|
||||
// the proof did not outlive that first read, the list would be applied once and
|
||||
// dropped again a minute later — which is the original defect wearing a hat.
|
||||
func TestColdStartProofSurvivesTheEngineTakingTheLock(t *testing.T) {
|
||||
db := withColdStartCache(t)
|
||||
seedCacheDB(t, db, "bl-apple-apple", coldStartCompiledSRS(t, "apple.com"), time.Now().Add(-time.Hour))
|
||||
withRuleSetProbe(t, func(string) bool { return false })
|
||||
|
||||
b := newBuilder(&model.Model{})
|
||||
if _, ok := b.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`); !ok {
|
||||
t.Fatalf("cold start must apply the cached list; warnings: %v", b.warnings)
|
||||
}
|
||||
|
||||
// Now the engine holds it: every further read fails, exactly as on a running
|
||||
// router. The stub stands in for the flock, whose behaviour is the OS's and not
|
||||
// something a unit test can arrange portably.
|
||||
prev := cachedRuleSetReader
|
||||
cachedRuleSetReader = func(string, string) (*adapter.SavedBinary, error) {
|
||||
return nil, errCacheUnavailable
|
||||
}
|
||||
t.Cleanup(func() { cachedRuleSetReader = prev })
|
||||
|
||||
b2 := newBuilder(&model.Model{})
|
||||
if _, ok := b2.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`); !ok {
|
||||
t.Fatalf("the proof must survive the engine locking the DB; warnings: %v", b2.warnings)
|
||||
}
|
||||
|
||||
// ...but it must NOT survive the DB being removed. A proof that outlives the
|
||||
// file it was read from is the fail-open half of this feature, and cache.go
|
||||
// removes that file on its own in two branches.
|
||||
if err := os.Remove(db); err != nil {
|
||||
t.Fatalf("remove the db: %v", err)
|
||||
}
|
||||
b3 := newBuilder(&model.Model{})
|
||||
if _, ok := b3.remoteRuleSet("bl-apple-apple", coldStartAppleURL, "24h", `blocklist "apple"`); ok {
|
||||
t.Fatal("with the cache DB deleted there is nothing to start from, so the list must be omitted again")
|
||||
}
|
||||
}
|
||||
+168
-13
@@ -75,7 +75,20 @@ func (b *builder) buildDNS() *option.DNSOptions {
|
||||
// skipped resolver here makes the engine reject the config outright — the
|
||||
// fail-closed outcome every other path in this package avoids. servers is in
|
||||
// model order, so servers[0] is the first resolver that survived.
|
||||
final := b.m.Globals.ResolverDefault
|
||||
//
|
||||
// The name itself comes from resolverNameInForce, not straight out of globals:
|
||||
// the active WAN profile may override `resolver_default`, and a profile that
|
||||
// names a resolver this configuration cannot use rolls back to the globals value
|
||||
// (loudly) rather than taking DNS down on the one uplink where nobody is looking.
|
||||
final, _ := b.resolverNameInForce(profileResolverField{
|
||||
field: "resolver_default",
|
||||
globalsValue: b.m.Globals.ResolverDefault,
|
||||
unusableWhy: "no such resolver, or it was skipped for a bad address or type",
|
||||
// No "…, and the next warning names it": when BOTH sides are empty the caller
|
||||
// stays silent (it only warns for a non-empty `final`), so pointing at a
|
||||
// warning that may not exist would be a promise about output nobody checked.
|
||||
noGlobalsConsequence: "the first resolver that did build becomes the default instead",
|
||||
}, b.effectiveResolverDefault(), serverTags)
|
||||
if !serverTags[final] {
|
||||
if strings.TrimSpace(final) != "" {
|
||||
b.warnf("resolver_default %q was not built (unknown, or skipped for a bad address/type); falling back to resolver %q as the default.", final, servers[0].Tag)
|
||||
@@ -218,6 +231,130 @@ func (b *builder) buildDNS() *option.DNSOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// profileOverrideNotAppliedTag is the operator's grep handle in `logread` for a
|
||||
// profile override that named something this configuration does not have and is
|
||||
// therefore NOT the value in force. Same shape and same purpose as the
|
||||
// RULESET-NOT-APPLIED / DNS-FILTER-NOT-APPLIED / DEVICE-FILTER-NOT-APPLIED tags
|
||||
// already emitted by ruleset.go, dnsfilter.go and devices.go.
|
||||
//
|
||||
// It is also how apply/warnings.go can grade the finding structurally: that file
|
||||
// returns SeverityCritical outright for a tag in its notAppliedTags list. This tag
|
||||
// is NOT in that list yet — apply is another tree — so today the grade comes from
|
||||
// the criticalMarkers entry "inert", which the messages below say because it is
|
||||
// literally what happened to the override, not to satisfy a classifier.
|
||||
const profileOverrideNotAppliedTag = "PROFILE-OVERRIDE-NOT-APPLIED"
|
||||
|
||||
// profileResolverField describes ONE of the resolver-name scalars a WAN profile
|
||||
// may override, for resolverNameInForce. Every field here is something the two
|
||||
// callers say differently, which is why none of them is baked into the helper:
|
||||
//
|
||||
// unusableWhy what "unusable" can mean at this call site. buildDNS asks
|
||||
// about BUILT servers (a resolver can exist and still have
|
||||
// been skipped for a bad address); endpointResolver runs
|
||||
// before anything is built and asks only about existence.
|
||||
// noGlobalsConsequence what happens when globals has nothing usable either. The
|
||||
// callers degrade differently — the default falls to the
|
||||
// first resolver that built, the fallback disappears
|
||||
// outright, the endpoint resolver is left unset — and one
|
||||
// shared sentence could only be vague about the single thing
|
||||
// the operator needs to know.
|
||||
type profileResolverField struct {
|
||||
field string
|
||||
globalsValue string
|
||||
unusableWhy string
|
||||
noGlobalsConsequence string
|
||||
}
|
||||
|
||||
// resolverNameInForce turns "what model says is in force" into "what this
|
||||
// generator can actually use", applying the rollback rule for a profile override
|
||||
// that names a resolver this configuration does not have.
|
||||
//
|
||||
// # The rule
|
||||
//
|
||||
// The profile override is DROPPED and the globals value takes effect. Not the
|
||||
// other way round, and not a refusal to apply: a profile applies to exactly one
|
||||
// uplink, so a typo in the SIM profile's `resolver_default` would otherwise take
|
||||
// name resolution down on the SIM uplink — which is the uplink you are on when the
|
||||
// ethernet is out, i.e. the one where you can least afford it and can least easily
|
||||
// look. Refusing the whole config over it would be worse still: with the kill
|
||||
// switch closed, "refuse to start" means the whole LAN is offline over a name that
|
||||
// is wrong on ONE uplink.
|
||||
//
|
||||
// # Why it is never silent
|
||||
//
|
||||
// A profile override is invisible by construction — it only applies while that
|
||||
// profile is active — so nothing about the running router reveals the typo until
|
||||
// the uplink changes, at which point the config that broke DNS looks exactly like
|
||||
// the config that worked. The warning therefore names all four things the operator
|
||||
// needs: the profile, the field, the value that was rejected, and what is in force
|
||||
// instead. It is tagged (see profileOverrideNotAppliedTag) so it is greppable and
|
||||
// so apply can grade it critical.
|
||||
//
|
||||
// # Return
|
||||
//
|
||||
// name is what the caller must use. fromProfile reports whether that name is STILL
|
||||
// the profile's, so a caller that attributes the value in its own diagnostics
|
||||
// (endpointResolver's `src`) keeps saying "active profile" only while it is true.
|
||||
//
|
||||
// known is the set of names this call site can use — built DNS server tags for
|
||||
// buildDNS, declared `config resolver` names for endpointResolver. Membership is
|
||||
// tested with the caller's own spelling of the name, so the verdict here and the
|
||||
// lookup that follows it cannot disagree.
|
||||
func (b *builder) resolverNameInForce(f profileResolverField, ov model.Override, known map[string]bool) (name string, fromProfile bool) {
|
||||
v := strings.TrimSpace(ov.Value)
|
||||
|
||||
// Two states, both named. Positive and closed: model.Override.From is either
|
||||
// FromGlobals or "profile:<name>", and there is no third thing to sweep into a
|
||||
// default. When the value came from globals there is nothing to roll back TO —
|
||||
// globals IS the fallback — and the caller reports an unusable one itself.
|
||||
prof := b.activeProfile
|
||||
if prof == nil || ov.From == model.FromGlobals {
|
||||
return v, false
|
||||
}
|
||||
if v == "" || known[v] {
|
||||
return v, true // the profile set nothing, or set something usable
|
||||
}
|
||||
|
||||
g := strings.TrimSpace(f.globalsValue)
|
||||
switch {
|
||||
case known[g]:
|
||||
b.warnf("%s: profile %q: %s %q is not usable here (%s), so the override is inert and the globals value %q "+
|
||||
"is what resolves while this profile is active. The configuration was still applied: a profile covers "+
|
||||
"ONE uplink, and refusing to start over a name that is wrong on one uplink would take the router down "+
|
||||
"on every other one — with the kill switch closed that is the whole LAN. Fix the name in profile %q, "+
|
||||
"or add the `config resolver` it means.",
|
||||
profileOverrideNotAppliedTag, prof.Name, f.field, v, f.unusableWhy, g, prof.Name)
|
||||
case g == "":
|
||||
b.warnf("%s: profile %q: %s %q is not usable here (%s), so the override is inert — and globals sets no %s "+
|
||||
"either, so %s. The configuration was still applied: a profile covers ONE uplink, and refusing to "+
|
||||
"start over a name that is wrong on one uplink would take the router down on every other one — with "+
|
||||
"the kill switch closed that is the whole LAN. Fix the name in profile %q, or add the `config "+
|
||||
"resolver` it means.",
|
||||
profileOverrideNotAppliedTag, prof.Name, f.field, v, f.unusableWhy, f.field, f.noGlobalsConsequence, prof.Name)
|
||||
default:
|
||||
b.warnf("%s: profile %q: %s %q is not usable here (%s), so the override is inert — and the globals value "+
|
||||
"%q it falls back to is not usable either, so %s. The configuration was still applied: a profile "+
|
||||
"covers ONE uplink, and refusing to start over a name that is wrong on one uplink would take the "+
|
||||
"router down on every other one — with the kill switch closed that is the whole LAN. Fix the name in "+
|
||||
"profile %q, or add the `config resolver` it means.",
|
||||
profileOverrideNotAppliedTag, prof.Name, f.field, v, f.unusableWhy, g, f.noGlobalsConsequence, prof.Name)
|
||||
}
|
||||
return g, false
|
||||
}
|
||||
|
||||
// declaredResolverNames is the set of `config resolver` names the model declares,
|
||||
// keyed by the SAME spelling endpointResolver's own lookup compares against (the
|
||||
// raw Name, untrimmed) — so "this resolver exists" means one thing in both places.
|
||||
// It is deliberately NOT the set of BUILT servers: it is consulted during
|
||||
// buildRoute, before any DNS server has been built.
|
||||
func (b *builder) declaredResolverNames() map[string]bool {
|
||||
names := make(map[string]bool, len(b.m.Resolvers))
|
||||
for i := range b.m.Resolvers {
|
||||
names[b.m.Resolvers[i].Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// endpointResolverTagPrefix names the synthetic bootstrap-direct DNS server the
|
||||
// endpoint resolver is cloned into, so its tag never collides with the user's own
|
||||
// `config resolver` server tag it was cloned from.
|
||||
@@ -232,7 +369,11 @@ const endpointResolverTagPrefix = "shater-endpoint-dns-"
|
||||
// # Tag source priority
|
||||
//
|
||||
// The active WAN profile's EndpointResolver override wins over Globals.EndpointResolver.
|
||||
// Both name an existing `config resolver` by name.
|
||||
// Both name an existing `config resolver` by name. That priority is NOT decided here:
|
||||
// it comes from model.EffectiveEndpointResolver, the one place the rule lives, shared
|
||||
// with apply and with the panel that has to print what is in force. A profile override
|
||||
// naming a resolver this configuration does not have rolls back to the globals value
|
||||
// with a loud, tagged warning — see resolverNameInForce.
|
||||
//
|
||||
// # Bootstrap-direct
|
||||
//
|
||||
@@ -257,16 +398,23 @@ func (b *builder) endpointResolver() string {
|
||||
}
|
||||
b.endpointResolverComputed = true
|
||||
|
||||
// Profile overrides live on the builder only after applyProfiles; it is
|
||||
// idempotent, so calling it here makes endpointResolver safe to invoke from either
|
||||
// buildRoute or buildDNS regardless of order.
|
||||
b.applyProfiles()
|
||||
|
||||
name := b.profileEndpointResolver
|
||||
src := "active profile"
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.m.Globals.EndpointResolver)
|
||||
src = "globals.endpoint_resolver"
|
||||
// effectiveEndpointResolver runs applyProfiles for us, and that is idempotent, so
|
||||
// endpointResolver is safe to invoke from either buildRoute or buildDNS regardless
|
||||
// of order.
|
||||
//
|
||||
// The `known` set here is the DECLARED resolvers, not the built DNS servers: this
|
||||
// runs during buildRoute, before a single server exists. That is also the exact
|
||||
// predicate the res lookup below uses, so the rollback and the lookup can never
|
||||
// disagree about what "there is such a resolver" means.
|
||||
name, fromProfile := b.resolverNameInForce(profileResolverField{
|
||||
field: "endpoint_resolver",
|
||||
globalsValue: b.m.Globals.EndpointResolver,
|
||||
unusableWhy: "no such resolver",
|
||||
noGlobalsConsequence: "proxy server domains are resolved by the engine's built-in resolver and route.default_domain_resolver is left unset",
|
||||
}, b.effectiveEndpointResolver(), b.declaredResolverNames())
|
||||
src := "globals.endpoint_resolver"
|
||||
if fromProfile {
|
||||
src = "active profile"
|
||||
}
|
||||
if name == "" {
|
||||
return "" // feature off — no default_domain_resolver, engine uses its built-in
|
||||
@@ -512,7 +660,14 @@ func (b *builder) warnBootstrapInTheClear(res model.Resolver) {
|
||||
// Returns nil (and the config is byte-identical to one without the feature) when
|
||||
// no fallback is configured, when it is unusable, or when it would be a no-op.
|
||||
func (b *builder) resolverFallbackRules(final string, serverTags map[string]bool) []option.DNSRule {
|
||||
fb := strings.TrimSpace(b.m.Globals.ResolverFallback)
|
||||
// Same door as resolver_default: the active profile may override the fallback on
|
||||
// its own, and an unusable override rolls back to globals with a loud warning.
|
||||
fb, _ := b.resolverNameInForce(profileResolverField{
|
||||
field: "resolver_fallback",
|
||||
globalsValue: b.m.Globals.ResolverFallback,
|
||||
unusableWhy: "no such resolver, or it was skipped for a bad address or type",
|
||||
noGlobalsConsequence: "there is no DNS failover at all — when the default resolver stops answering, queries simply fail",
|
||||
}, b.effectiveResolverFallback(), serverTags)
|
||||
if fb == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// filterAnswerTTL is the TTL stamped on a "zero"-response predefined answer
|
||||
@@ -47,12 +49,124 @@ const wildcardOwner = "*."
|
||||
// (url-source) filter rule-set when the model does not specify one.
|
||||
const defaultRuleSetUpdateInterval = "24h"
|
||||
|
||||
// filterFetchDetour is the outbound a remote filter rule-set fetches through.
|
||||
// "direct" (always present as a baseline outbound) is chosen so a list fetch
|
||||
// cannot blackhole when the proxy is down or the kill-switch Final is "block";
|
||||
// it also avoids a chicken-and-egg where the list needed to route the proxy is
|
||||
// itself fetched via the proxy. (A future task may make this configurable.)
|
||||
const filterFetchDetour = tagDirect
|
||||
// fetchDetourNotAppliedTag is the SCREAMING-KEBAB prefix on the one warning this
|
||||
// file emits about the fetch detour. It exists for the same reason
|
||||
// RULESET-NOT-APPLIED does: apply/warnings.go grades severity off the TAG rather
|
||||
// than off prose (see its notAppliedTags), and this condition has to be critical —
|
||||
// the operator configured every list/geo download to travel through a tunnel and
|
||||
// it is travelling over the plain WAN instead. Rename it here and
|
||||
// apply/fetchdetour_severity_test.go fails by name.
|
||||
const fetchDetourNotAppliedTag = "FETCH-DETOUR-NOT-APPLIED"
|
||||
|
||||
// filterFetchDetour is the outbound a remote filter/routing rule-set is fetched
|
||||
// through — the engine's `http_client{detour}` on every option.RemoteRuleSet this
|
||||
// package emits (ruleset.go remoteRuleSetAs).
|
||||
//
|
||||
// It used to be the constant `tagDirect`, on the reasoning that a list fetch must
|
||||
// not blackhole when the proxy is down and that a list needed to route the proxy
|
||||
// must not be fetched through the proxy. Both halves of that are still true and
|
||||
// neither is what the router actually runs into: on the SIM uplink this ships to,
|
||||
// the operator passes a whitelist, so `direct` is not the safe path — it is the
|
||||
// path where raw.githubusercontent.com is refused FOREVER and the `apple`
|
||||
// blocklist never loads at all. The value is therefore configurable
|
||||
// (globals.fetch_detour, overridable per WAN profile), and the old constant is the
|
||||
// behaviour you get by leaving it unset.
|
||||
//
|
||||
// # What is resolved here, and what is NOT
|
||||
//
|
||||
// model.EffectiveFetchDetour answers "which value is in force" (profile over
|
||||
// globals, per field). This function answers the half model cannot: whether the
|
||||
// named node/group/egress/chain is something THIS generate pass actually emits.
|
||||
// The list of accepted shapes is closed and positive; anything that does not
|
||||
// resolve falls back to `direct` and says so at critical, because a silent
|
||||
// substitution here is indistinguishable from the setting working.
|
||||
//
|
||||
// # The limitation this does NOT remove, stated rather than implied
|
||||
//
|
||||
// The R5 preflight (ruleset.go remoteRuleSetUsable) probes the source with a plain
|
||||
// DIRECT http.Client, because generate has no way to dial through the running box.
|
||||
// With a non-direct detour that probe no longer measures the path the engine will
|
||||
// take, in both directions:
|
||||
//
|
||||
// - probe reachable + detour broken at start => RemoteRuleSet.StartContext still
|
||||
// fails fatally, unless the cold-start cache already holds the list
|
||||
// (coldstart_cache.go, which is what covers the steady state);
|
||||
// - probe unreachable + detour would have worked + nothing cached => the list is
|
||||
// still omitted, so a list that has NEVER been fetched cannot bootstrap over
|
||||
// the detour alone.
|
||||
//
|
||||
// Making the preflight detour-aware means dialling through the engine from inside
|
||||
// generate, which is engine/apply territory; it is deliberately not attempted
|
||||
// here, and it is not claimed to have been.
|
||||
func (b *builder) filterFetchDetour() string {
|
||||
// applyProfiles resolves (and reports) the active profile once, and buildRoute
|
||||
// calls it before anything materialises a rule-set. Asking again is cheap; the
|
||||
// duplicate warnings it re-emits are not, so they are dropped.
|
||||
var prof *model.Profile
|
||||
b.withoutNewWarnings(func() { prof = b.resolveActiveProfile() })
|
||||
|
||||
ov := model.EffectiveFetchDetour(b.m.Globals, prof)
|
||||
want := strings.TrimSpace(ov.Value)
|
||||
if want == "" {
|
||||
// Not set anywhere. `direct` is the documented default, not a fallback, so
|
||||
// there is nothing to report.
|
||||
return tagDirect
|
||||
}
|
||||
if strings.EqualFold(want, tagBlock) {
|
||||
b.warnFetchDetourNotApplied(ov, want,
|
||||
"`block` is not a route: it is the outbound that discards whatever it is handed, so every "+
|
||||
"list, geo and rule-set download aimed through it would fail — and a remote rule-set whose "+
|
||||
"first fetch fails aborts engine start, which with a closed kill switch takes the LAN down. "+
|
||||
"It is refused here rather than obeyed")
|
||||
return tagDirect
|
||||
}
|
||||
tag, ok := b.resolveTarget(want)
|
||||
if !ok {
|
||||
b.warnFetchDetourNotApplied(ov, want,
|
||||
"nothing this configuration builds answers to that name — no enabled node, no group with "+
|
||||
"usable members (an empty group is not emitted at all, so it cannot be named), no interface "+
|
||||
"egress and no chain that assembles")
|
||||
return tagDirect
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
// warnFetchDetourNotApplied states, once per generate pass, that the configured
|
||||
// fetch detour is not the one in force and what is running instead.
|
||||
//
|
||||
// The entity clause (`profile "x": ` / `globals "fetch_detour": `) is not
|
||||
// decoration: apply/warnings.go recovers Section and Name from exactly that shape
|
||||
// (taggedEntityRe), which is what lets the panel badge the profile that carries
|
||||
// the bad value instead of printing a paragraph under "generate".
|
||||
func (b *builder) warnFetchDetourNotApplied(ov model.Override, want, why string) {
|
||||
entity := `globals "fetch_detour"`
|
||||
if name, ok := strings.CutPrefix(ov.From, "profile:"); ok {
|
||||
entity = fmt.Sprintf("profile %q", name)
|
||||
}
|
||||
b.warnOnce("%s: %s: fetch_detour %q is NOT in force — %s. Every blocklist, allowlist, ruleset, "+
|
||||
"geoip and geosite download is going out %q instead: over the plain WAN, with this router's real "+
|
||||
"address, which is the disclosure fetch_detour is set to prevent — and on an uplink that refuses "+
|
||||
"those sources they will not load at all. Correct the name, or remove the option if %q is what "+
|
||||
"you meant.",
|
||||
fetchDetourNotAppliedTag, entity, want, why, tagDirect, tagDirect)
|
||||
}
|
||||
|
||||
// warnOnce is warnf that never repeats an identical sentence.
|
||||
//
|
||||
// filterFetchDetour is consulted once per remote rule-set — a dozen times on a
|
||||
// real config — and one mistyped detour must produce one critical, not a dozen. It
|
||||
// deliberately deduplicates on the FINISHED text rather than on a flag, because
|
||||
// there is nowhere to keep a flag: builder's fields are owned elsewhere this pass,
|
||||
// and a per-call memo would have to live there.
|
||||
func (b *builder) warnOnce(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
for _, w := range b.warnings {
|
||||
if w == msg {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.warnings = append(b.warnings, msg)
|
||||
}
|
||||
|
||||
// dnsFilterActive reports whether the D15 filter is switched on AND at least one
|
||||
// blocklist/allowlist is enabled — used to decide whether to warn when the DNS
|
||||
|
||||
@@ -532,6 +532,15 @@ func TestDNSFilterHostileInputStillValidates(t *testing.T) {
|
||||
// missing. Verified off-VM too: with the remote set forced into the config the same
|
||||
// scenario fails with "initialize rule-set[0]: initial rule-set: rs-...".
|
||||
func TestOfflineBootStartsEngine(t *testing.T) {
|
||||
// A COLD cache is this test's premise — "power came back" means the router has
|
||||
// not fetched anything yet. It has to be arranged explicitly now that generate
|
||||
// consults sing-box's cache before omitting an unreachable list
|
||||
// (coldstart_cache.go): TestMain gives the whole binary ONE private cache DB, and
|
||||
// an earlier test in this file starts a real engine that caches a rule-set under
|
||||
// this very tag (bl-remote-ads), which the cold-start check would then serve from
|
||||
// — correctly, but that is the OTHER scenario. Without this line the assertions
|
||||
// below depend on which tests ran first.
|
||||
withColdStartCache(t)
|
||||
withRuleSetProbe(t, unreachableProbe)
|
||||
|
||||
g := model.DefaultGlobals()
|
||||
|
||||
@@ -309,7 +309,7 @@ func TestFailoverWarnsOnceAcrossChainCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Egress.Type / Egress.Port ----------------------------------------------
|
||||
// --- Egress.Type ------------------------------------------------------------
|
||||
|
||||
func egressModel(egType string) *model.Model {
|
||||
return &model.Model{
|
||||
@@ -318,7 +318,7 @@ func egressModel(egType string) *model.Model {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressTypeEmitsOutbound: the three implemented kinds produce a real
|
||||
// TestEgressTypeEmitsOutbound: the two implemented kinds produce a real
|
||||
// outbound under the netplane's egress tag. "" is the documented synonym of
|
||||
// "direct".
|
||||
func TestEgressTypeEmitsOutbound(t *testing.T) {
|
||||
@@ -329,7 +329,6 @@ func TestEgressTypeEmitsOutbound(t *testing.T) {
|
||||
{"interface", C.TypeDirect},
|
||||
{"direct", C.TypeDirect},
|
||||
{"", C.TypeDirect}, // synonym of direct
|
||||
{"byedpi", C.TypeSOCKS},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run("type="+tc.egType, func(t *testing.T) {
|
||||
@@ -371,55 +370,12 @@ func TestEgressTypeUnknownWarns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestByedpiEgressDPIWarnsIgnored: the panel offers a DPI preset on a byedpi
|
||||
// egress, but the native tls_* flags are never applied to one.
|
||||
func TestByedpiEgressDPIWarnsIgnored(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: plainGlobals(),
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080, DPI: "fragment"}},
|
||||
}
|
||||
_, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if got := warnMatching(warns, `egress "bd"`, "byedpi", "ignores dpi"); len(got) != 1 {
|
||||
t.Fatalf("want exactly 1 byedpi-dpi warning, got: %v", warns)
|
||||
}
|
||||
for _, d := range []string{"", "off"} {
|
||||
m2 := &model.Model{
|
||||
Globals: plainGlobals(),
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", DPI: d}},
|
||||
}
|
||||
if _, w, _ := GenerateWithWarnings(m2); len(w) != 0 {
|
||||
t.Fatalf("dpi=%q: unexpected warnings %v", d, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressPortIgnoredOnNonByedpi (T4): Port only means anything for byedpi.
|
||||
func TestEgressPortIgnoredOnNonByedpi(t *testing.T) {
|
||||
for _, egType := range []string{"interface", "direct", ""} {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Egresses: []model.Egress{{Name: "e1", Type: egType, Interface: "eth1", Port: 9050}},
|
||||
}
|
||||
_, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if got := warnMatching(warns, `egress "e1"`, "port 9050 is ignored"); len(got) != 1 {
|
||||
t.Fatalf("type=%q: want exactly 1 ignored-port warning, got: %v", egType, warns)
|
||||
}
|
||||
}
|
||||
// byedpi uses it, so no warning.
|
||||
m := &model.Model{
|
||||
Globals: plainGlobals(),
|
||||
Egresses: []model.Egress{{Name: "e1", Type: "byedpi", Port: 9050}},
|
||||
}
|
||||
if _, warns, _ := GenerateWithWarnings(m); len(warns) != 0 {
|
||||
t.Fatalf("byedpi must not warn about its own port: %v", warns)
|
||||
}
|
||||
}
|
||||
// The retired-egress-type behaviour that used to be pinned around here — a
|
||||
// `byedpi` egress emitting a SOCKS outbound, its DPI preset being reported as
|
||||
// ignored, and `port` being meaningful on that one kind — is gone with the kind
|
||||
// itself. What replaced it is stricter and lives in retired_egress_test.go: the
|
||||
// type emits NOTHING, and it is reported by its own name rather than as an
|
||||
// unrecognised value.
|
||||
|
||||
// --- FAIL-CLOSED egress bindings (T6) ---------------------------------------
|
||||
//
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package generate
|
||||
|
||||
// The configurable fetch detour (globals.fetch_detour, overridable per WAN
|
||||
// profile) as the engine actually receives it: the `http_client{detour}` of every
|
||||
// remote rule-set this package emits.
|
||||
//
|
||||
// WHY THESE TESTS ARE SHAPED AROUND CONTROLS. The value under test is a single
|
||||
// string on a nested option struct, and "it says direct" is the answer for BOTH
|
||||
// "the operator configured direct" and "the whole feature is dead". Every case
|
||||
// below therefore comes with its opposite: a configured detour that must ARRIVE,
|
||||
// beside an unusable one that must roll back AND be named. A test that only
|
||||
// checked the rollback would pass over a filterFetchDetour that returned tagDirect
|
||||
// unconditionally — which is exactly the constant this change removes.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// fetchDetourSRSURL ends in .srs on purpose: ruleSetURLIsEngineNative decides
|
||||
// remote-vs-locally-compiled by URL EXTENSION alone, and only the REMOTE path
|
||||
// carries an http_client{detour} for the engine to dial through. Trim the suffix
|
||||
// and the list becomes a text list generate downloads itself, which is a different
|
||||
// fetch path with a different (and, today, un-detourable) client.
|
||||
const fetchDetourSRSURL = "https://lists.example/ads.srs"
|
||||
|
||||
// listFetchDetourModel is one enabled node, one group over it, and one remote
|
||||
// blocklist — the smallest configuration in which a detour has something real to
|
||||
// name and something real to be stamped on.
|
||||
//
|
||||
// The group is `manual` over the node rather than subscription-backed so the
|
||||
// fixture needs no sub cache; buildGroups emits the tag either way, which is all
|
||||
// resolveTarget consults.
|
||||
func listFetchDetourModel(globalsDetour string, profiles ...model.Profile) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.DNSFilter = true
|
||||
g.ResolverDefault = "cf"
|
||||
g.FetchDetour = globalsDetour
|
||||
if len(profiles) > 0 {
|
||||
// Pin the profile explicitly. Auto-select skips iface-conditioned profiles
|
||||
// (shaterd's WAN watcher owns that), so a fixture that relied on it would be
|
||||
// testing the selector rather than the override.
|
||||
g.ActiveProfile = profiles[0].Name
|
||||
}
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Profiles: profiles,
|
||||
Resolvers: []model.Resolver{{Name: "cf", Type: "udp", Address: "1.1.1.1", Detour: "direct"}},
|
||||
Nodes: []model.Node{{
|
||||
Name: "tokyo", Enabled: true,
|
||||
URI: "vless://11111111-1111-1111-1111-111111111111@example.com:443?security=tls&sni=example.com#tokyo",
|
||||
}},
|
||||
Groups: []model.Group{{Name: "auto", Source: "manual", Strategy: "single", Nodes: []string{"tokyo"}}},
|
||||
Blocklists: []model.Blocklist{
|
||||
{Name: "ads", Enabled: true, Source: "url", URL: fetchDetourSRSURL, UpdateInterval: "24h"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// remoteDetourOf returns the detour stamped on the emitted remote rule-set, or
|
||||
// fails: an absent rule-set would make every assertion below vacuously true.
|
||||
func remoteDetourOf(t *testing.T, opts option.Options, tag string) string {
|
||||
t.Helper()
|
||||
if opts.Route == nil {
|
||||
t.Fatalf("no route emitted at all")
|
||||
}
|
||||
for _, rs := range opts.Route.RuleSet {
|
||||
if rs.Tag != tag {
|
||||
continue
|
||||
}
|
||||
if rs.Type != C.RuleSetTypeRemote {
|
||||
t.Fatalf("rule-set %q type = %q, want remote — only the remote path carries a fetch detour", tag, rs.Type)
|
||||
}
|
||||
hc := rs.RemoteOptions.HTTPClient
|
||||
if hc == nil {
|
||||
t.Fatalf("rule-set %q has no http_client at all, so it dials with the engine's default", tag)
|
||||
}
|
||||
return hc.Detour
|
||||
}
|
||||
t.Fatalf("rule-set %q was not emitted; rule-sets=%+v", tag, opts.Route.RuleSet)
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateFetchDetour runs the real generator with the probe stubbed reachable —
|
||||
// the tests here are about which OUTBOUND the download is aimed at, not about
|
||||
// whether the source answers.
|
||||
func generateFetchDetour(t *testing.T, m *model.Model) (option.Options, []string) {
|
||||
t.Helper()
|
||||
withRuleSetProbe(t, func(string) bool { return true })
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
return opts, warns
|
||||
}
|
||||
|
||||
// fetchDetourWarnings returns just the FETCH-DETOUR-NOT-APPLIED lines.
|
||||
func fetchDetourWarnings(warns []string) []string {
|
||||
var out []string
|
||||
for _, w := range warns {
|
||||
if strings.HasPrefix(w, fetchDetourNotAppliedTag+":") {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFetchDetourUnsetIsDirect is the BASELINE CONTROL, and it is the one that
|
||||
// says the change is backwards compatible: with nothing configured the emitted
|
||||
// detour is byte-identical to the constant this replaced.
|
||||
func TestFetchDetourUnsetIsDirect(t *testing.T) {
|
||||
opts, warns := generateFetchDetour(t, listFetchDetourModel(""))
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != tagDirect {
|
||||
t.Fatalf("detour = %q, want %q — an unconfigured fetch_detour must behave exactly as the old constant did", got, tagDirect)
|
||||
}
|
||||
if got := fetchDetourWarnings(warns); len(got) != 0 {
|
||||
t.Fatalf("not configuring an OPTIONAL override is not a fault; warnings: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourFromGlobalsReachesTheEngine is the positive case the whole change
|
||||
// exists for: on the SIM uplink the operator points every list download at a group
|
||||
// of nodes the operator's whitelist does pass, and the engine has to be TOLD.
|
||||
func TestFetchDetourFromGlobalsReachesTheEngine(t *testing.T) {
|
||||
opts, warns := generateFetchDetour(t, listFetchDetourModel("group:auto"))
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != "auto" {
|
||||
t.Fatalf("detour = %q, want %q — the configured group must be the outbound the engine downloads through; warnings=%v",
|
||||
got, "auto", warns)
|
||||
}
|
||||
if got := fetchDetourWarnings(warns); len(got) != 0 {
|
||||
t.Fatalf("a detour that resolves must be silent; warnings: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourProfileOverridesGlobals: the field-level inheritance, end to end.
|
||||
// Globals says `direct` (the ethernet answer) and the ACTIVE profile says the SIM
|
||||
// group — and the profile wins, which is the entire reason the option is
|
||||
// per-profile rather than global.
|
||||
func TestFetchDetourProfileOverridesGlobals(t *testing.T) {
|
||||
m := listFetchDetourModel(tagDirect, model.Profile{
|
||||
Name: "mobile-uplink", Enabled: true, FetchDetour: "node:tokyo",
|
||||
})
|
||||
opts, warns := generateFetchDetour(t, m)
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != "tokyo" {
|
||||
t.Fatalf("detour = %q, want %q — the active profile's override must beat globals; warnings=%v",
|
||||
got, "tokyo", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourProfileWithoutOverrideInheritsGlobals is the CONTROL for the test
|
||||
// above: the same active profile, not setting the field, must not blank the
|
||||
// globals value. Inheritance is per FIELD, and a profile that overrides something
|
||||
// else entirely must leave this one alone.
|
||||
func TestFetchDetourProfileWithoutOverrideInheritsGlobals(t *testing.T) {
|
||||
m := listFetchDetourModel("group:auto", model.Profile{
|
||||
Name: "mobile-uplink", Enabled: true, EndpointResolver: "cf",
|
||||
})
|
||||
opts, warns := generateFetchDetour(t, m)
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != "auto" {
|
||||
t.Fatalf("detour = %q, want %q — a profile that sets no fetch_detour inherits globals'; warnings=%v",
|
||||
got, "auto", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourUnresolvableRollsBackToDirectAndSaysSo is the owner's rule: an
|
||||
// unusable value falls back to `direct` and the substitution is ANNOUNCED, with
|
||||
// the profile, the field's value and what is in force instead all named.
|
||||
//
|
||||
// The three assertions are independent on purpose. A rollback with no warning is
|
||||
// the silent substitution this project bans; a warning with no rollback would mean
|
||||
// the engine downloads through nothing; and a warning that does not name the
|
||||
// profile is unactionable, because a profile only misbehaves on its own uplink.
|
||||
func TestFetchDetourUnresolvableRollsBackToDirectAndSaysSo(t *testing.T) {
|
||||
m := listFetchDetourModel(tagDirect, model.Profile{
|
||||
Name: "mobile-uplink", Enabled: true, FetchDetour: "group:sim-bypass",
|
||||
})
|
||||
opts, warns := generateFetchDetour(t, m)
|
||||
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != tagDirect {
|
||||
t.Fatalf("detour = %q, want %q — an unresolvable detour must roll back to the outbound that always exists", got, tagDirect)
|
||||
}
|
||||
hits := fetchDetourWarnings(warns)
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want exactly 1 %s warning, got %d: %v", fetchDetourNotAppliedTag, len(hits), hits)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`profile "mobile-uplink"`, // WHOSE value it was
|
||||
`"group:sim-bypass"`, // the value that was refused
|
||||
`"direct"`, // what is in force instead
|
||||
"real", // and what that costs
|
||||
} {
|
||||
if !strings.Contains(hits[0], want) {
|
||||
t.Fatalf("the warning must contain %q:\n %s", want, hits[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourFromGlobalsIsAttributedToGlobals: the same fault written in
|
||||
// globals must not be blamed on a profile. The entity prefix is what
|
||||
// apply/warnings.go parses into Section+Name, so a wrong one sends the panel's
|
||||
// badge to the wrong page.
|
||||
func TestFetchDetourFromGlobalsIsAttributedToGlobals(t *testing.T) {
|
||||
_, warns := generateFetchDetour(t, listFetchDetourModel("group:ghost"))
|
||||
hits := fetchDetourWarnings(warns)
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want exactly 1 %s warning, got %d: %v", fetchDetourNotAppliedTag, len(hits), hits)
|
||||
}
|
||||
if !strings.Contains(hits[0], `globals "fetch_detour"`) {
|
||||
t.Fatalf("a globals-level fault must be attributed to globals:\n %s", hits[0])
|
||||
}
|
||||
if strings.Contains(hits[0], "profile ") {
|
||||
t.Fatalf("no profile set this value; the warning must not name one:\n %s", hits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourBlockIsRefused: `block` passes model's SHAPE check (it is one of
|
||||
// the two bare words) and resolveTarget resolves it happily to the block outbound
|
||||
// — so nothing upstream stops it, and a rule-set aimed at it can never be
|
||||
// downloaded. Worse than useless: a remote rule-set whose first fetch fails aborts
|
||||
// engine start, so obeying this value would take the LAN down on a closed kill
|
||||
// switch. It is refused HERE, by name.
|
||||
func TestFetchDetourBlockIsRefused(t *testing.T) {
|
||||
opts, warns := generateFetchDetour(t, listFetchDetourModel(tagBlock))
|
||||
if got := remoteDetourOf(t, opts, "bl-ads"); got != tagDirect {
|
||||
t.Fatalf("detour = %q, want %q — `block` discards the download and must not be obeyed", got, tagDirect)
|
||||
}
|
||||
hits := fetchDetourWarnings(warns)
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want exactly 1 %s warning, got %d: %v", fetchDetourNotAppliedTag, len(hits), hits)
|
||||
}
|
||||
if !strings.Contains(hits[0], "`block` is not a route") {
|
||||
t.Fatalf("the warning must say why block is refused rather than reading as a missing name:\n %s", hits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourWarnsOncePerPass: filterFetchDetour is consulted once per remote
|
||||
// rule-set. Four lists sharing one mistyped detour is ONE fault, and four copies
|
||||
// of the same critical in the panel's banner is how the banner stops being read.
|
||||
func TestFetchDetourWarnsOncePerPass(t *testing.T) {
|
||||
m := listFetchDetourModel("group:ghost")
|
||||
for _, name := range []string{"trackers", "malware", "porn"} {
|
||||
m.Blocklists = append(m.Blocklists, model.Blocklist{
|
||||
Name: name, Enabled: true, Source: "url",
|
||||
URL: "https://lists.example/" + name + ".srs", UpdateInterval: "24h",
|
||||
})
|
||||
}
|
||||
opts, warns := generateFetchDetour(t, m)
|
||||
// The control: all four lists really were emitted, so the single warning is not
|
||||
// single because three lists silently vanished.
|
||||
for _, tag := range []string{"bl-ads", "bl-trackers", "bl-malware", "bl-porn"} {
|
||||
if got := remoteDetourOf(t, opts, tag); got != tagDirect {
|
||||
t.Fatalf("%s detour = %q, want %q", tag, got, tagDirect)
|
||||
}
|
||||
}
|
||||
if hits := fetchDetourWarnings(warns); len(hits) != 1 {
|
||||
t.Fatalf("one bad value must produce one warning, got %d: %v", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourReachesRoutingRuleSetsToo: the DNS-filter lists and the routing
|
||||
// `config ruleset`s are two independent callers of the same builder
|
||||
// (buildFilterRuleSetRaw and buildRoutingRuleSets), and the whole point of putting
|
||||
// the resolution in remoteRuleSetAs is that neither can be wired up while the
|
||||
// other is forgotten. A geoip routing ruleset is the second caller, and on this
|
||||
// router it is the one carrying the country lists.
|
||||
func TestFetchDetourReachesRoutingRuleSetsToo(t *testing.T) {
|
||||
m := listFetchDetourModel("group:auto")
|
||||
m.Rulesets = []model.Ruleset{{Name: "ru", Source: "geoip", Categories: []string{"ru"}}}
|
||||
m.Rules = []model.Rule{{
|
||||
Name: "ru-direct", Enabled: true, DstRuleset: []string{"ru"}, Target: tagDirect,
|
||||
}}
|
||||
opts, warns := generateFetchDetour(t, m)
|
||||
if got := remoteDetourOf(t, opts, "rs-ru-ru"); got != "auto" {
|
||||
t.Fatalf("routing rule-set detour = %q, want %q — the routing path must not keep the old constant; warnings=%v",
|
||||
got, "auto", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourCachedListAlsoSaysAppliedFromTheCache binds the marker
|
||||
// apply/warnings.go grades on to the text this package actually emits.
|
||||
//
|
||||
// It is the producer half of the 4b fix: apply's degradedProtectionMarkers now
|
||||
// contains "APPLIED FROM THE CACHE", and a marker with no producer is fiction —
|
||||
// this project has already been bitten by five such markers matching no living
|
||||
// text. Reword the cold-start branch in ruleset.go and this fails by name, before
|
||||
// the panel goes quietly back to painting a working list red.
|
||||
func TestFetchDetourCachedListAlsoSaysAppliedFromTheCache(t *testing.T) {
|
||||
withCachePaths(t, t.TempDir())
|
||||
resetColdStartCache()
|
||||
prev := cachedRuleSetReader
|
||||
cachedRuleSetReader = func(_, _ string) (*adapter.SavedBinary, error) {
|
||||
return &adapter.SavedBinary{
|
||||
Content: compiledSRS(t, []string{"ads.example"}),
|
||||
LastUpdated: time.Now().Add(-36 * time.Hour).Truncate(time.Second),
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cachedRuleSetReader = prev
|
||||
resetColdStartCache()
|
||||
})
|
||||
withRuleSetProbe(t, func(string) bool { return false }) // the SIM blocks the source
|
||||
|
||||
b := newBuilder(&model.Model{})
|
||||
if _, ok := b.remoteRuleSet("bl-ads", fetchDetourSRSURL, "24h", `blocklist "ads"`); !ok {
|
||||
t.Fatalf("a list with a loadable cached copy must still be applied; warnings: %v", b.warnings)
|
||||
}
|
||||
if !warnsHaveSub(b.warnings, "APPLIED FROM THE CACHE") {
|
||||
t.Fatalf("apply/warnings.go grades severity on this exact phrase; warnings: %v", b.warnings)
|
||||
}
|
||||
// The control: the same instrument, no cached copy, must produce the OTHER text
|
||||
// — otherwise "the marker is present" would be true of every outcome.
|
||||
cachedRuleSetReader = func(_, _ string) (*adapter.SavedBinary, error) { return nil, os.ErrNotExist }
|
||||
resetColdStartCache()
|
||||
b2 := newBuilder(&model.Model{})
|
||||
if _, ok := b2.remoteRuleSet("bl-ads", fetchDetourSRSURL, "24h", `blocklist "ads"`); ok {
|
||||
t.Fatalf("with no cached copy the list must be omitted; warnings: %v", b2.warnings)
|
||||
}
|
||||
if warnsHaveSub(b2.warnings, "APPLIED FROM THE CACHE") {
|
||||
t.Fatalf("nothing was served from the cache; the phrase must not appear: %v", b2.warnings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build linux
|
||||
|
||||
// The RUNTIME half of fetchdetour_wgdedup_test.go.
|
||||
//
|
||||
// The portable half asks whether the tag a rule-set downloads through still exists
|
||||
// in the emitted option.Options. That is the right question, but it is asked of a
|
||||
// struct. This one asks the SAME question of a real box, with the runtime's own
|
||||
// resolver: box.New builds the OutboundManager, and dialer.InitializeDetour runs
|
||||
// exactly the lookup dialer.DetourDialer.init performs on the first download —
|
||||
// `outbound detour not found: <tag>` is its error, verbatim the one that fails
|
||||
// RemoteRuleSet.StartContext, fails router.Start (FastFail), fails box.Start, and
|
||||
// with the kill switch closed leaves the LAN with no way out.
|
||||
//
|
||||
// Why box.New and not box.Start: starting this config would make the engine
|
||||
// actually FETCH the geosite list through a WireGuard peer at 203.0.113.10, which
|
||||
// no test host can reach — so Start would fail identically whether the tag resolves
|
||||
// or not, and an instrument that reports failure in both states measures nothing.
|
||||
// The detour lookup is resolved against the outbound manager and is complete after
|
||||
// New; the fetch that follows it is a different question, already covered by
|
||||
// TestDNSFilterRemoteBlocklistHTTPClient over a local origin.
|
||||
//
|
||||
// Linux-only for the reason the whole *_linux_test.go half of this package is: the
|
||||
// loop-guard routing_mark that generate emits is validated by box.New only here.
|
||||
|
||||
package generate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
box "github.com/sagernet/sing-box"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/experimental/deprecated"
|
||||
"github.com/sagernet/sing-box/shater/registry"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
// TestFetchDetourRuleSetDownloadResolvesInTheBox drives the production shape —
|
||||
// fetch_detour naming a WireGuard node that is also a chain hop — through a real
|
||||
// box.New and then resolves every remote rule-set's download detour the way the
|
||||
// engine will.
|
||||
//
|
||||
// The POSITIVE CONTROL is inside the same test: before asking about the real
|
||||
// detour, the identical instrument is pointed at a tag that certainly does not
|
||||
// exist, and it must report the miss. Without that, "no dangling detour found"
|
||||
// would be indistinguishable from a resolver that cannot see a miss at all.
|
||||
func TestFetchDetourRuleSetDownloadResolvesInTheBox(t *testing.T) {
|
||||
for _, egress := range []string{"", "w1"} {
|
||||
name := "chain shares the node's uplink"
|
||||
if egress != "" {
|
||||
name = "chain enters over its own egress"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
opts, warns, err := GenerateWithWarnings(fetchDetourWGModel("node:wg1", egress))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
detours := remoteRuleSetDetours(t, opts)
|
||||
if egress == "" {
|
||||
// The merge case: the download must still be dialled through the
|
||||
// WireGuard device, not quietly demoted to `direct`. Asserted here as
|
||||
// well as portably, because this is the run that proves the tag the
|
||||
// engine resolves and the tag the config carries are the same tag.
|
||||
wg := wgEndpointTags(opts)
|
||||
for rsTag, detour := range detours {
|
||||
if !wg[detour] {
|
||||
t.Fatalf("rule-set %q downloads through %q, not through the WireGuard device fetch_detour named (wireguard endpoints: %v)", rsTag, detour, wg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var mgr deprecated.Manager = &recordingDeprecated{}
|
||||
ctx = service.ContextWith(ctx, mgr)
|
||||
ctx = registry.Context(ctx)
|
||||
b, err := box.New(box.Options{Context: ctx, Options: withoutL3Ingress(t, opts)})
|
||||
if err != nil {
|
||||
t.Fatalf("box.New: %v\nwarnings: %v", err, warns)
|
||||
}
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
om := service.FromContext[adapter.OutboundManager](ctx)
|
||||
if om == nil {
|
||||
t.Fatalf("no outbound manager in the box context — the instrument below cannot answer anything")
|
||||
}
|
||||
resolve := func(tag string) error {
|
||||
return dialer.InitializeDetour(dialer.NewDetour(om, tag, true))
|
||||
}
|
||||
|
||||
// Positive control: the same resolver, the same box, a tag nobody emitted.
|
||||
const ghost = "no-such-outbound-tag"
|
||||
if err := resolve(ghost); err == nil {
|
||||
t.Fatalf("the detour resolver accepted %q, a tag no outbound carries — it cannot detect a miss, so the assertions below would pass no matter what", ghost)
|
||||
}
|
||||
|
||||
for rsTag, detour := range detours {
|
||||
if err := resolve(detour); err != nil {
|
||||
t.Fatalf("rule-set %q downloads through detour %q and the RUNNING box cannot resolve it: %v\n"+
|
||||
"That error is returned from RemoteRuleSet.StartContext on the first fetch; the router starts "+
|
||||
"rule-sets with FastFail, so it is box.Start failing — and with the kill switch closed that is "+
|
||||
"the whole LAN offline.\noutbounds=%v endpoints=%v\nwarnings=%v",
|
||||
rsTag, detour, err, outboundTags(opts), endpointTags(opts), warns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package generate
|
||||
|
||||
// THE FETCH DETOUR IS A REFERENCE, AND THE DEDUPLICATOR HAS TO SEE IT.
|
||||
//
|
||||
// # The defect
|
||||
//
|
||||
// wgdedup.go decides which WireGuard endpoints are still referenced by walking
|
||||
// option.Options from every tag traffic can enter the graph at. Its rule-set arm
|
||||
// read exactly one field:
|
||||
//
|
||||
// rt.RuleSet[i].RemoteOptions.DownloadDetour
|
||||
//
|
||||
// which is the DEPRECATED spelling, and the one this generator NEVER writes:
|
||||
// ruleset.go remoteRuleSetAs emits `http_client{detour}` and leaves DownloadDetour
|
||||
// empty (dnsfilter_test.go asserts exactly that, and warns if a deprecation notice
|
||||
// ever appears). So the walk saw no rule-set download reference at all.
|
||||
//
|
||||
// That was harmless while the value was the hard-wired constant `direct` — a seed
|
||||
// naming `direct` cannot keep anything alive that this pass would otherwise delete.
|
||||
// It stopped being harmless when the detour became configurable
|
||||
// (globals.fetch_detour, overridable per WAN profile), because `fetch_detour=node:
|
||||
// <wg>` makes that node's BASE endpoint load-bearing: it is the outbound every
|
||||
// blocklist, ruleset, geoip and geosite download dials through.
|
||||
//
|
||||
// # The consequence, which is what these tests measure
|
||||
//
|
||||
// Unseen, the base endpoint looks unreferenced. If the same node is also a chain
|
||||
// hop — the ordinary shape, and the exact shape the production router runs — the
|
||||
// pass has two endpoints for one private key, keeps the chain copy and deletes the
|
||||
// base one. remapTags then rewrites every reference it KNOWS about; the rule-set's
|
||||
// http_client detour is not one of them, so it is left naming a tag that no longer
|
||||
// exists in the config.
|
||||
//
|
||||
// At runtime that is not a cosmetic dangle. RemoteRuleSet.StartContext builds its
|
||||
// transport, and dialer.DetourDialer.init resolves the tag against the running
|
||||
// box's OutboundManager: a miss is `outbound detour not found: <tag>`, the fetch
|
||||
// fails, StartContext returns an error, the router starts rule-sets with FastFail
|
||||
// — so box.Start fails, and with the kill switch closed a failed start is the whole
|
||||
// LAN with no way out. See the R5 block in ruleset.go for the same failure arriving
|
||||
// from the other direction.
|
||||
//
|
||||
// # Why the assertion is "the config is still connected", not "the seed is there"
|
||||
//
|
||||
// Asserting that the tag lands in the seed set would pass on a build that seeds it
|
||||
// and then forgets to REMAP it, which is the same outage. So the test asks the only
|
||||
// question that matters: after the pass, does the tag the rule-set dials through
|
||||
// still exist among the outbounds/endpoints the engine will be given. The Linux
|
||||
// half (fetchdetour_wgdedup_linux_test.go) asks the running box the same question
|
||||
// with the runtime's own resolver.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// fetchDetourWGModel is the production shape in miniature: one AmneziaWG-style
|
||||
// WireGuard node that is BOTH a chain hop and the fetch detour every remote list
|
||||
// downloads through, plus a geosite ruleset to be that download.
|
||||
//
|
||||
// entryEgress != "" gives the chain its own uplink, which makes the chain copy of
|
||||
// the node a materially DIFFERENT device from the base one (different dialer) —
|
||||
// the branch where the pass has to pick a loser rather than merge.
|
||||
func fetchDetourWGModel(fetchDetour, entryEgress string) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.FetchDetour = fetchDetour
|
||||
hops := []string{"node:wg1", "node:ss1"}
|
||||
m := &model.Model{
|
||||
Globals: g,
|
||||
Nodes: []model.Node{
|
||||
{Name: "wg1", Enabled: true, URI: wgDedupURI(wgDedupKey(41), wgDedupKey(51), "203.0.113.10", 51820)},
|
||||
{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.2:8388#ss1"},
|
||||
},
|
||||
Rulesets: []model.Ruleset{
|
||||
{Name: "yt", Source: "geosite", Categories: []string{"youtube"}},
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "via-chain", Enabled: true, Order: 10, DstPort: "443", Target: "chain:c"},
|
||||
{Name: "yt-direct", Enabled: true, Order: 20, DstRuleset: []string{"yt"}, Target: "direct"},
|
||||
},
|
||||
}
|
||||
if entryEgress != "" {
|
||||
m.Egresses = []model.Egress{{Name: entryEgress, Type: "interface", Interface: "eth1"}}
|
||||
hops = append([]string{"egress:" + entryEgress}, hops...)
|
||||
}
|
||||
m.Chains = []model.Chain{{Name: "c", Hops: hops}}
|
||||
return m
|
||||
}
|
||||
|
||||
// emittedTags is every outbound/endpoint tag the engine will be handed — i.e. every
|
||||
// tag a Detour is allowed to name. It is the option-layer stand-in for the lookup
|
||||
// dialer.DetourDialer.init performs against the running box's OutboundManager.
|
||||
func emittedTags(opts option.Options) map[string]bool {
|
||||
tags := make(map[string]bool, len(opts.Outbounds)+len(opts.Endpoints))
|
||||
for i := range opts.Outbounds {
|
||||
tags[opts.Outbounds[i].Tag] = true
|
||||
}
|
||||
for i := range opts.Endpoints {
|
||||
tags[opts.Endpoints[i].Tag] = true
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// remoteRuleSetDetours maps each emitted remote rule-set tag to the outbound tag it
|
||||
// downloads through, reading the modern http_client{detour} the generator writes.
|
||||
func remoteRuleSetDetours(t *testing.T, opts option.Options) map[string]string {
|
||||
t.Helper()
|
||||
if opts.Route == nil {
|
||||
t.Fatalf("no route options emitted")
|
||||
}
|
||||
out := map[string]string{}
|
||||
for i := range opts.Route.RuleSet {
|
||||
rs := opts.Route.RuleSet[i]
|
||||
if rs.Type != C.RuleSetTypeRemote {
|
||||
continue
|
||||
}
|
||||
if rs.RemoteOptions.HTTPClient == nil {
|
||||
t.Fatalf("remote rule-set %q carries no http_client at all; this test is measuring the wrong field", rs.Tag)
|
||||
}
|
||||
out[rs.Tag] = rs.RemoteOptions.HTTPClient.Detour
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("no remote rule-set was emitted, so nothing here is being measured; rule-sets=%+v", opts.Route.RuleSet)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wgEndpointTags is the set of emitted tags that really are WireGuard devices —
|
||||
// what "the download travels through the tunnel the operator named" means when
|
||||
// checked rather than assumed.
|
||||
func wgEndpointTags(opts option.Options) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for i := range opts.Endpoints {
|
||||
if _, ok := wgDeviceKey(opts.Endpoints[i]); ok {
|
||||
out[opts.Endpoints[i].Tag] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFetchDetourWGNodeStaysConnectedAfterDedup is the defect, measured as its
|
||||
// consequence.
|
||||
//
|
||||
// The configuration is the production one: `fetch_detour=node:wg1` where wg1 is
|
||||
// ALSO a chain hop. The chain here has no entry egress, so the hop copy and the
|
||||
// base endpoint build the SAME device — one device written down twice, which the
|
||||
// pass merges. Nothing about this config is wrong, and after the pass it must
|
||||
// still be TRUE, in both of the ways it can stop being true:
|
||||
//
|
||||
// 1. the download detour must name a tag that EXISTS. Deleting the copy it named
|
||||
// without repointing it is the dangling reference that fails box.Start.
|
||||
// 2. that tag must still be the WIREGUARD DEVICE. Repointing it at `direct`
|
||||
// would leave a perfectly valid config in which every list, geo and ruleset
|
||||
// download goes out on the plain WAN with this router's real address — which
|
||||
// is the whole thing fetch_detour exists to prevent, and the failure is
|
||||
// invisible because the config still starts and the lists still load.
|
||||
//
|
||||
// Both are asserted because the two possible half-fixes fail exactly one each: a
|
||||
// seed with no rewrite dangles (1), a rewrite with no seed silently degrades (2).
|
||||
func TestFetchDetourWGNodeStaysConnectedAfterDedup(t *testing.T) {
|
||||
opts, warns, err := GenerateWithWarnings(fetchDetourWGModel("node:wg1", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
tags := emittedTags(opts)
|
||||
wg := wgEndpointTags(opts)
|
||||
for rsTag, detour := range remoteRuleSetDetours(t, opts) {
|
||||
if !tags[detour] {
|
||||
t.Fatalf("rule-set %q downloads through detour %q, and NO outbound or endpoint of that name is emitted.\n"+
|
||||
"This is the outage: dialer.DetourDialer.init answers \"outbound detour not found: %s\", "+
|
||||
"RemoteRuleSet.StartContext returns that error, the router starts rule-sets with FastFail, "+
|
||||
"so box.Start fails — and with the kill switch closed the whole LAN is dark.\n"+
|
||||
"emitted outbounds=%v endpoints=%v\nwarnings=%v",
|
||||
rsTag, detour, detour, outboundTags(opts), endpointTags(opts), warns)
|
||||
}
|
||||
if !wg[detour] {
|
||||
t.Fatalf("rule-set %q downloads through %q, which is not the WireGuard device fetch_detour=node:wg1 named "+
|
||||
"(wireguard endpoints emitted: %v).\n"+
|
||||
"The config is still valid and the engine will still start — and every blocklist, ruleset, geoip and "+
|
||||
"geosite download now leaves on the plain WAN with this router's real address, which is the disclosure "+
|
||||
"the option was set to prevent.\nwarnings=%v",
|
||||
rsTag, detour, wg, warns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourWGNodeKeepsExactlyOneDevice guards the other direction. Seeing the
|
||||
// reference must not resurrect the duplicate it was blind to: one private key still
|
||||
// has to leave exactly ONE device behind, or the merge that keeps the config
|
||||
// connected has bought it at the price of the eviction loop wgdedup.go exists to
|
||||
// prevent (two devices, one key, neither passing traffic).
|
||||
func TestFetchDetourWGNodeKeepsExactlyOneDevice(t *testing.T) {
|
||||
opts, _, err := GenerateWithWarnings(fetchDetourWGModel("node:wg1", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
keys := map[string]int{}
|
||||
for i := range opts.Endpoints {
|
||||
if key, ok := wgDeviceKey(opts.Endpoints[i]); ok {
|
||||
keys[key]++
|
||||
}
|
||||
}
|
||||
for _, n := range keys {
|
||||
if n > 1 {
|
||||
t.Fatalf("one private key materialised %d times (endpoints=%v): two devices per key evict each other at the peer and NEITHER tunnel passes traffic", n, endpointTags(opts))
|
||||
}
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("want exactly one wireguard device, got %d (endpoints=%v)", len(keys), endpointTags(opts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourDirectNeedsNoSeed is the control that keeps the fix from being
|
||||
// vacuous: with fetch_detour left at its default the download detour is `direct`,
|
||||
// which names nothing this pass could preserve, and the base endpoint of a
|
||||
// chain-only node must STILL be deleted as the duplicate it is. A seed that kept
|
||||
// everything alive would pass the test above and quietly restore the two-devices
|
||||
// bug for every config that does not use the option.
|
||||
func TestFetchDetourDirectNeedsNoSeed(t *testing.T) {
|
||||
opts, _, err := GenerateWithWarnings(fetchDetourWGModel("", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if got := endpointTags(opts); len(got) != 1 || got[0] != "chain-c-h1" {
|
||||
t.Fatalf("endpoints = %v, want exactly [chain-c-h1]: with no fetch detour configured the base endpoint is genuinely unreferenced", got)
|
||||
}
|
||||
for rsTag, detour := range remoteRuleSetDetours(t, opts) {
|
||||
if detour != tagDirect {
|
||||
t.Fatalf("rule-set %q downloads through %q, want %q when nothing is configured", rsTag, detour, tagDirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourDownloadIsNeverPointedAtBlock covers the one case where `block` —
|
||||
// this pass's fail-closed answer everywhere else — is the answer that takes the LAN
|
||||
// down rather than protects it.
|
||||
//
|
||||
// When the copy the fetch detour resolved to loses the device conflict, remapTags
|
||||
// would point the rule-set's download at `block`. A download through `block` cannot
|
||||
// succeed, an uncached remote rule-set whose first fetch fails aborts engine start,
|
||||
// and a failed start with a closed kill switch is the whole LAN offline. So the
|
||||
// download degrades to `direct` — the path the R5 preflight already measured as
|
||||
// reachable, which is why it is safe to promise a start — and says so at critical,
|
||||
// because it IS the disclosure fetch_detour was set to prevent.
|
||||
func TestFetchDetourDownloadIsNeverPointedAtBlock(t *testing.T) {
|
||||
// Two chains over two different egresses: the base endpoint (what
|
||||
// fetch_detour=node:wg1 resolves to) is a third materialisation and loses.
|
||||
m := fetchDetourWGModel("node:wg1", "w1")
|
||||
m.Egresses = append(m.Egresses, model.Egress{Name: "w2", Type: "interface", Interface: "eth2"})
|
||||
m.Chains = append(m.Chains, model.Chain{Name: "d", Hops: []string{"egress:w2", "node:wg1"}})
|
||||
m.Rules = append(m.Rules, model.Rule{Name: "via-d", Enabled: true, Order: 30, DstPort: "8443", Target: "chain:d"})
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
tags := emittedTags(opts)
|
||||
for rsTag, detour := range remoteRuleSetDetours(t, opts) {
|
||||
if detour == tagBlock {
|
||||
t.Fatalf("rule-set %q downloads through %q: that fetch cannot succeed, and an uncached remote rule-set whose first fetch fails aborts engine start — the LAN goes dark to protect a list", rsTag, tagBlock)
|
||||
}
|
||||
if !tags[detour] {
|
||||
t.Fatalf("rule-set %q downloads through %q, which no outbound or endpoint answers to", rsTag, detour)
|
||||
}
|
||||
}
|
||||
// And it must not be silent about having done it.
|
||||
if got := warningsContaining(warns, fetchDetourNotAppliedTag); len(got) == 0 {
|
||||
t.Fatalf("the downgrade to %q was silent; an operator whose downloads now carry this router's real address has to be told. warnings=%v",
|
||||
tagDirect, warns)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the third reader: a subscription that says nothing ----------------------
|
||||
|
||||
// TestSubscriptionWithNoFetchViaPinsTheGeneralDetour covers the OTHER old
|
||||
// two-state reader in wgdedup.go, subscriptionDetourSeeds.
|
||||
//
|
||||
// A subscription with no `fetch_via` at all is pulled through the GENERAL fetch
|
||||
// detour, and apply.(*Applier).UpdateSubscription resolves that string against the
|
||||
// RUNNING box — a reference this pass can neither see in option.Options nor
|
||||
// rewrite. So the tag has to be seeded AND pinned, or the base endpoint is merged
|
||||
// into the node's chain copy and the next refresh fails with "unknown outbound
|
||||
// tag".
|
||||
//
|
||||
// The fixture deliberately emits NO remote rule-set, so the rule-set download seed
|
||||
// cannot cover for a missing subscription seed: `wg1` survives here only because
|
||||
// the subscription itself is counted.
|
||||
func TestSubscriptionWithNoFetchViaPinsTheGeneralDetour(t *testing.T) {
|
||||
m := fetchDetourModel(true, "")
|
||||
m.Globals.FetchDetour = "node:wg1"
|
||||
m.Subscriptions = []model.Subscription{{
|
||||
// No FetchVia: the whole point. Under schema v2 this meant "direct"; under
|
||||
// v3 it means "whatever globals.fetch_detour says", which here is wg1.
|
||||
Name: "sub0", Enabled: true, URL: "https://example.net/sub",
|
||||
}}
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if got := endpointTags(opts); len(got) != 1 || got[0] != "wg1" {
|
||||
t.Fatalf("endpoints = %v, want exactly [wg1].\n"+
|
||||
"The subscription carries no fetch_via, so globals.fetch_detour=node:wg1 is what it is "+
|
||||
"fetched through, and apply resolves that name against the running box. Merging it away "+
|
||||
"leaves the tag unresolvable and every refresh of this subscription fails with "+
|
||||
"\"unknown outbound tag\".\nwarnings=%v", got, warns)
|
||||
}
|
||||
// The chain must still work — it detours through the survivor.
|
||||
if d := anyDetour(t, opts, "chain-c-h2"); d != "wg1" {
|
||||
t.Fatalf("chain-c-h2 detour = %q, want wg1 (the merged survivor)", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscriptionExplicitDirectIsStillNotAReference is the control that stops the
|
||||
// fix above from being "seed everything". An explicit `fetch_via=direct` beats the
|
||||
// general detour — it is the documented escape from the first-boot deadlock — so
|
||||
// such a subscription dials no outbound and must keep nothing alive, even with
|
||||
// globals.fetch_detour naming the node.
|
||||
//
|
||||
// The unrecognised value rides along: apply REFUSES to fetch it at all, so it is
|
||||
// not a reference either, and treating it as one would preserve an endpoint on the
|
||||
// strength of a typo.
|
||||
func TestSubscriptionExplicitDirectIsStillNotAReference(t *testing.T) {
|
||||
for _, via := range []string{"direct", "DIRECT", "proxied"} {
|
||||
t.Run(via, func(t *testing.T) {
|
||||
m := fetchDetourModel(true, "")
|
||||
m.Globals.FetchDetour = "node:wg1"
|
||||
m.Subscriptions = []model.Subscription{{
|
||||
Name: "sub0", Enabled: true, URL: "https://example.net/sub", FetchVia: via,
|
||||
}}
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if got := endpointTags(opts); len(got) != 1 || got[0] != "chain-c-h1" {
|
||||
t.Fatalf("endpoints = %v, want exactly [chain-c-h1]: fetch_via=%q dials no engine outbound, so it must not keep the base endpoint alive", got, via)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptionSeedsReadsTheFieldTheGeneratorWrites is the narrow unit tripwire under
|
||||
// the behavioural tests above. It states the fact that made the defect possible —
|
||||
// the generator writes http_client{detour} and never download_detour — so a future
|
||||
// change that moves the value back to the deprecated field fails HERE, with a
|
||||
// message that says which field to look at, instead of failing as a dead LAN.
|
||||
func TestOptionSeedsReadsTheFieldTheGeneratorWrites(t *testing.T) {
|
||||
opts := &option.Options{Route: &option.RouteOptions{
|
||||
RuleSet: []option.RuleSet{{
|
||||
Type: C.RuleSetTypeRemote,
|
||||
Tag: "rs-x",
|
||||
RemoteOptions: option.RemoteRuleSet{
|
||||
URL: "https://example.net/x.srs",
|
||||
HTTPClient: &option.HTTPClientOptions{DialerOptions: option.DialerOptions{Detour: "wg1"}},
|
||||
},
|
||||
}},
|
||||
}}
|
||||
var found bool
|
||||
for _, seed := range optionSeeds(opts) {
|
||||
if seed.tag == "wg1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("optionSeeds did not report the http_client{detour} of a remote rule-set; seeds=%v", optionSeeds(opts))
|
||||
}
|
||||
|
||||
replace := map[string]string{"wg1": "chain-c-h1"}
|
||||
remapTags(opts, replace)
|
||||
if got := opts.Route.RuleSet[0].RemoteOptions.HTTPClient.Detour; got != "chain-c-h1" {
|
||||
t.Fatalf("remapTags left the http_client detour at %q; a reference the walk counts must be a reference the rewrite can repoint, or the merge leaves it dangling", got)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// plainGlobals is model.DefaultGlobals for a fixture whose subject is neither the
|
||||
// DNS plane nor the L3 ingress: egress binding, chain flattening, rule-set
|
||||
// compilation, byedpi wiring. It turns OFF the two router-wide planes that are
|
||||
// compilation. It turns OFF the two router-wide planes that are
|
||||
// seeded ON, so such a fixture can keep asserting "this config produces no
|
||||
// diagnostics at all" and have that assertion still mean what it says.
|
||||
//
|
||||
|
||||
@@ -200,9 +200,20 @@ type builder struct {
|
||||
// Profiles. applyProfiles fills these once, guarded by effectiveComputed;
|
||||
// buildRoute consumes them. For a model with no profiles effectiveRules is an
|
||||
// exact copy of b.m.Rules (identical generate output).
|
||||
effectiveRules []model.Rule // active-profile enable/disable applied
|
||||
profileEndpointResolver string // active profile's EndpointResolver override ("" => none)
|
||||
effectiveComputed bool // applyProfiles has run
|
||||
effectiveRules []model.Rule // active-profile enable/disable applied
|
||||
// activeProfile is the WAN profile in force, or nil when none is.
|
||||
//
|
||||
// It is kept WHOLE. This field used to be `profileEndpointResolver string` — one
|
||||
// scalar copied out of the profile, with the "profile beats globals" decision
|
||||
// then re-made by hand at the point of use. That shape does not survive a second
|
||||
// override, let alone a third: each consumer would carry its own copy of the
|
||||
// inheritance rule, and copies of a rule are free to disagree. They did — on the
|
||||
// production router the panel drew `endpoint_resolver = local` from globals while
|
||||
// the engine, under the active profile, was resolving through `yandex`. So the
|
||||
// profile is held as-is and every override is resolved by model.Effective*, the
|
||||
// one place that rule now lives (shared with apply and the panel).
|
||||
activeProfile *model.Profile
|
||||
effectiveComputed bool // applyProfiles has run
|
||||
|
||||
// Endpoint resolver (route.default_domain_resolver): the bootstrap-direct DNS
|
||||
// server used ONLY to resolve proxy outbounds' server DOMAINS. Computed once by
|
||||
|
||||
@@ -437,15 +437,20 @@ func TestEgressDPISpoofValidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- D13: ByeDPI (ciadpi) SOCKS egress validates via box.New. ----------------
|
||||
// A byedpi egress must produce a SOCKS5 outbound to 127.0.0.1:<port> plus a
|
||||
// route rule to it, and box.New must accept the socks outbound + loop-guard mark.
|
||||
// --- A retired egress kind still builds a VALID engine config. ---------------
|
||||
// The kind that used to stand here (`byedpi`, a SOCKS5 hop into a separate
|
||||
// ciadpi process) is retired. This is the box.New half of its removal: a config
|
||||
// that still names it must not merely fail to build the outbound, it must
|
||||
// produce an engine configuration the engine ACCEPTS — otherwise removing the
|
||||
// kind would turn one stale egress into a daemon that will not start, and a
|
||||
// daemon that will not start blackholes the entire LAN behind the fail-closed
|
||||
// data plane. The rule bound to it resolves to the block outbound.
|
||||
|
||||
func TestByedpiEgressValidates(t *testing.T) {
|
||||
func TestRetiredEgressKindStillBuildsAValidConfig(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12367, TCP: true, UDP: true}},
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}},
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi"}},
|
||||
Rulesets: []model.Ruleset{inlineDomainSet("blocked", "blocked.example")},
|
||||
Rules: []model.Rule{
|
||||
{Name: "desync", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "egress:bd"},
|
||||
@@ -455,25 +460,14 @@ func TestByedpiEgressValidates(t *testing.T) {
|
||||
if !changed {
|
||||
t.Fatalf("expected changed==true (warnings: %v)", warns)
|
||||
}
|
||||
ob := findOutbound(opts, "egress-bd")
|
||||
if ob == nil {
|
||||
t.Fatalf("byedpi egress outbound 'egress-bd' not emitted")
|
||||
if ob := findOutbound(opts, "egress-bd"); ob != nil {
|
||||
t.Fatalf("a retired egress kind must emit NO outbound, got type %q — anything else is the kind still working under a new name", ob.Type)
|
||||
}
|
||||
if ob.Type != C.TypeSOCKS {
|
||||
t.Fatalf("egress-bd type = %q, want %q", ob.Type, C.TypeSOCKS)
|
||||
if hasRouteToOutbound(opts, "egress-bd") {
|
||||
t.Fatalf("a rule must not route to an outbound that does not exist")
|
||||
}
|
||||
so, ok := ob.Options.(*option.SOCKSOutboundOptions)
|
||||
if !ok {
|
||||
t.Fatalf("egress-bd options type = %T, want *option.SOCKSOutboundOptions", ob.Options)
|
||||
}
|
||||
if so.Server != "127.0.0.1" || so.ServerPort != 1080 || so.Version != "5" {
|
||||
t.Fatalf("egress-bd socks = %s:%d v%q, want 127.0.0.1:1080 v\"5\"", so.Server, so.ServerPort, so.Version)
|
||||
}
|
||||
if so.DialerOptions.RoutingMark != LoopMark {
|
||||
t.Fatalf("egress-bd RoutingMark = %d, want loop-guard %d", so.DialerOptions.RoutingMark, LoopMark)
|
||||
}
|
||||
if !hasRouteToOutbound(opts, "egress-bd") {
|
||||
t.Fatalf("expected a route rule with action route->egress-bd")
|
||||
if !hasRouteToOutbound(opts, tagBlock) {
|
||||
t.Fatalf("the rule bound to the retired egress must resolve to %q (fail-closed), so its traffic stops instead of leaving over the plain WAN", tagBlock)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
@@ -266,27 +265,36 @@ func TestGroupEgressWarningIsAttributable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupEgressByedpi: a byedpi egress (a local SOCKS desync proxy) is a perfectly
|
||||
// good group egress — the members dial their servers through ciadpi.
|
||||
func TestGroupEgressByedpi(t *testing.T) {
|
||||
// TestGroupEgressRetiredKindIsFailClosed: a group bound to an egress whose kind
|
||||
// has been RETIRED must have its members blocked, not quietly released onto the
|
||||
// plain WAN. This is the group half of the same guarantee the node and chain
|
||||
// bindings make — losing a member is visible and safe, leaking one is neither —
|
||||
// and it is the case a removal is most likely to break, because the binding
|
||||
// itself is still perfectly well-formed: the egress exists, it is spelled
|
||||
// correctly, and only its KIND stopped existing.
|
||||
func TestGroupEgressRetiredKindIsFailClosed(t *testing.T) {
|
||||
m := twoGroupsOneSubModel()
|
||||
m.Egresses = []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}}
|
||||
m.Egresses = []model.Egress{{Name: "bd", Type: "byedpi"}}
|
||||
m.Groups[0].Egress = "bd"
|
||||
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
if eg := obByTag(opts, netplane.EgressOutboundTag("bd")); eg != nil {
|
||||
t.Fatalf("a retired egress kind must emit no outbound, got %q", eg.Type)
|
||||
}
|
||||
want := netplane.EgressOutboundTag("bd")
|
||||
if eg := obByTag(opts, want); eg == nil || eg.Type != C.TypeSOCKS {
|
||||
t.Fatalf("byedpi egress must be emitted as a SOCKS outbound")
|
||||
// The operator hears about it twice, and both sentences are owed: once for the
|
||||
// egress that builds nothing, once for the group whose binding is now broken.
|
||||
// Matched on the whole clause, not on the bare word "removed": the group's own
|
||||
// broken-binding warning also names this egress AND says "or it was removed",
|
||||
// so a looser match counts two warnings and passes for the wrong reason.
|
||||
if got := warnMatching(warns, `egress "bd"`, "was REMOVED from this product"); len(got) != 1 {
|
||||
t.Fatalf("want exactly 1 retired-kind warning naming the egress, got: %v", warns)
|
||||
}
|
||||
for _, mt := range groupMemberTags(t, opts, "tunneled") {
|
||||
if d := anyDetour(t, opts, mt); d != want {
|
||||
t.Fatalf("copy %q detour = %q, want the byedpi egress %q", mt, d, want)
|
||||
if d := anyDetour(t, opts, mt); d != tagBlock {
|
||||
t.Fatalf("member copy %q detour = %q, want %q — a group bound to a retired egress must be BLOCKED, not dialled over the plain WAN with the router's real address", mt, d, tagBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// The egress half of the L3 ICMP story, live-engine part: not the SHAPE of the
|
||||
// config (l3_egress_test.go pins that portably) but what the running engine
|
||||
// BELIEVES about the two egress kinds. The belief is the whole feature:
|
||||
// BELIEVES about the interface egress. The belief is the whole feature:
|
||||
//
|
||||
// - An interface egress must come up as an adapter.FlowOutbound whose
|
||||
// PreMatchFlow answers Flow for ICMP. That answer exists only if
|
||||
@@ -13,11 +13,12 @@
|
||||
// wrapping the bound dialer, say), the generated JSON stays byte-identical,
|
||||
// every codegen test stays green, and ping through every interface egress
|
||||
// silently degrades from "leaves via the second WAN" to "dropped".
|
||||
// - A byedpi egress must NOT look ICMP-capable: route.preMatchFlow
|
||||
// (l3-honest-drop) drops an ICMP flow whose outbound either lacks icmp in
|
||||
// Network() or is not a FlowOutbound, and SOCKS satisfies both refusals. If
|
||||
// it ever stops refusing, the drop stops happening — and the TUN stack's
|
||||
// alternative is forging the echo reply itself.
|
||||
//
|
||||
// There was a second half: a `byedpi` egress had to look ICMP-INCAPABLE, so that
|
||||
// route.preMatchFlow's l3-honest-drop dropped the ping instead of letting the TUN
|
||||
// stack forge the echo reply. That kind is retired and emits no outbound at all,
|
||||
// which is a strictly stronger refusal — nothing is left for the engine to have
|
||||
// an opinion about. l3_egress_test.go pins the emptiness portably.
|
||||
//
|
||||
// Gating mirrors l3_integration_linux_test.go, whose comment carries the full
|
||||
// argument: the model opts into l3_tunnel, so Start opens /dev/net/tun and
|
||||
@@ -40,9 +41,9 @@ import (
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
|
||||
// l3EgressICMPModel is the smallest l3_tunnel=1 config that carries both
|
||||
// egress kinds: the mandatory tproxy divert plane, a resolver, one interface
|
||||
// egress and one byedpi egress, each with a rule routing into it.
|
||||
// l3EgressICMPModel is the smallest l3_tunnel=1 config that exercises the
|
||||
// interface egress: the mandatory tproxy divert plane, a resolver, one interface
|
||||
// egress and a rule routing into it.
|
||||
//
|
||||
// The interface egress binds to `lo` — deliberately: BindInterface must name a
|
||||
// device that EXISTS on the runner (netplane.IfaceDevice passes an
|
||||
@@ -70,17 +71,15 @@ func l3EgressICMPModel() *model.Model {
|
||||
},
|
||||
Egresses: []model.Egress{
|
||||
{Name: "lo", Type: "interface", Interface: "lo"},
|
||||
{Name: "bd", Type: "byedpi", Port: 1080},
|
||||
},
|
||||
Rules: []model.Rule{
|
||||
{Name: "ping-lo", Enabled: true, Order: 10, Src: []string{"192.168.88.0/24"}, Target: "egress:lo"},
|
||||
{Name: "desync", Enabled: true, Order: 20, Src: []string{"192.168.89.0/24"}, Target: "egress:bd"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationL3EgressICMPIsAFlow proves the live engine's verdict on ICMP
|
||||
// through each egress kind, in failure-mode order:
|
||||
// through an interface egress, in failure-mode order:
|
||||
//
|
||||
// 1. the interface egress outbound is an adapter.FlowOutbound advertising
|
||||
// icmp — the two static gates route.preMatchFlow checks before it even
|
||||
@@ -88,10 +87,7 @@ func l3EgressICMPModel() *model.Model {
|
||||
// 2. its PreMatchFlow(icmp) answers Flow — the dynamic gate, true only when
|
||||
// the ping.Port was really constructed despite BindInterface+RoutingMark
|
||||
// on the dialer. THE assertion of this file: its failure mode is a ping
|
||||
// that silently turns into a drop with not one generated byte changed;
|
||||
// 3. the byedpi egress outbound fails at least one of the same static gates,
|
||||
// which is precisely what makes l3-honest-drop DROP a ping routed at it
|
||||
// instead of the TUN stack forging the echo reply.
|
||||
// that silently turns into a drop with not one generated byte changed.
|
||||
func TestIntegrationL3EgressICMPIsAFlow(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skipf("needs root to open and configure a TUN device (euid=%d) — run as root with CAP_NET_ADMIN and /dev/net/tun, e.g. on the OpenWrt VM or via `docker run --cap-add NET_ADMIN --device /dev/net/tun`", os.Geteuid())
|
||||
@@ -144,18 +140,4 @@ func TestIntegrationL3EgressICMPIsAFlow(t *testing.T) {
|
||||
if got := flow.PreMatchFlow(N.NetworkICMP, netip.MustParseAddr("203.0.113.9")); got != adapter.PreMatchFlow {
|
||||
t.Fatalf("PreMatchFlow(icmp) = %v, want adapter.PreMatchFlow — the direct outbound started WITHOUT its ping.Port, i.e. dialer.NewWithOptions no longer yields a *dialer.DefaultDialer once BindInterface+RoutingMark are set (protocol/direct only builds icmpPort behind that cast); ping through every interface egress then silently turns into a drop with not one generated byte changed, so only this live check can catch it", got)
|
||||
}
|
||||
|
||||
// 3. The byedpi egress: at least one static gate must refuse it. Both
|
||||
// refusing is today's reality (SOCKS advertises no icmp and is no
|
||||
// FlowOutbound); the regression is BOTH passing, because then
|
||||
// l3-honest-drop stops dropping and the TUN stack answers the echo itself
|
||||
// — a forged reply from a desync hop that never saw the packet.
|
||||
bdTag := netplane.EgressOutboundTag("bd")
|
||||
bdOb, ok := om.Outbound(bdTag)
|
||||
if !ok {
|
||||
t.Fatalf("running box has no outbound %q — every rule bound to this egress resolved into nothing, so its domains lost the desync entirely", bdTag)
|
||||
}
|
||||
if _, isFlow := bdOb.(adapter.FlowOutbound); isFlow && slices.Contains(bdOb.Network(), N.NetworkICMP) {
|
||||
t.Fatalf("outbound %q (%T, networks %v) passes both of route.preMatchFlow's static gates for ICMP — l3-honest-drop then no longer drops a ping routed at the byedpi egress, and the TUN stack forges the echo reply locally: the operator reads a working ping off a SOCKS hop that cannot carry the packet", bdTag, bdOb, bdOb.Network())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,34 +90,36 @@ func TestEgressInterfaceOutboundCarriesICMP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressByeDPIOutboundCannotCarryICMP pins the OTHER side of the line: a
|
||||
// byedpi egress is a SOCKS5 hop into the local ciadpi desync proxy, and SOCKS
|
||||
// does not (and cannot) implement tun.Port, so route.preMatchFlow's
|
||||
// l3-honest-drop block DROPS ICMP routed at it. That drop is the feature: the
|
||||
// only alternative the TUN stack offers is answering the echo ITSELF
|
||||
// (stack_gvisor_icmp.go), i.e. a forged reply from a path that never saw the
|
||||
// packet. Pinning the SOCKS type here pins the reason the drop happens.
|
||||
func TestEgressByeDPIOutboundCannotCarryICMP(t *testing.T) {
|
||||
// TestEgressRetiredKindEmitsNoOutbound pins the OTHER side of the line. The kind
|
||||
// that used to stand here was `byedpi`, a SOCKS5 hop into a separate ciadpi
|
||||
// process, and the test asserted the SOCKS type because SOCKS cannot implement
|
||||
// tun.Port — which is what made route.preMatchFlow's l3-honest-drop block DROP
|
||||
// an ICMP packet routed at it rather than let the TUN stack forge the echo reply
|
||||
// itself (stack_gvisor_icmp.go).
|
||||
//
|
||||
// With the kind retired, the guarantee gets STRONGER rather than weaker: no
|
||||
// outbound is emitted at all, so a rule routed here resolves to the block
|
||||
// outbound and the packet stops — for TCP, UDP and ICMP alike. What must be
|
||||
// pinned is that the removal did not accidentally leave the egress emitting
|
||||
// SOMETHING an L3 path would accept, which is the one way this could have turned
|
||||
// a drop into a forged reply.
|
||||
func TestEgressRetiredKindEmitsNoOutbound(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.DefaultGlobals(),
|
||||
Inbounds: []model.Inbound{
|
||||
{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12345, TCP: true, UDP: true},
|
||||
},
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}},
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi"}},
|
||||
Rules: []model.Rule{
|
||||
{Name: "desync", Enabled: true, Order: 10, Src: []string{"192.168.3.0/24"}, Target: "egress:bd"},
|
||||
},
|
||||
}
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: unexpected error: %v", err)
|
||||
}
|
||||
tag := netplane.EgressOutboundTag("bd")
|
||||
ob := l3EgressOutbound(opts, tag)
|
||||
if ob == nil {
|
||||
t.Fatalf("no outbound %q was emitted (warnings: %v) — every rule bound to this egress then resolves through the fail-closed path and the desync stops covering its domains", tag, warns)
|
||||
}
|
||||
if ob.Type != C.TypeSOCKS {
|
||||
t.Fatalf("byedpi egress outbound type = %q, want %q — the type is load-bearing twice over: only a SOCKS hop actually reaches the local ciadpi process (anything else skips the desync entirely), and its inability to implement tun.Port is exactly what makes the l3-honest-drop block DROP a ping routed here instead of the TUN stack forging an echo reply from a path that never carried the packet", ob.Type, C.TypeSOCKS)
|
||||
if ob := l3EgressOutbound(opts, tag); ob != nil {
|
||||
t.Fatalf("outbound %q was emitted with type %q for a RETIRED egress kind — the kind is supposed to build nothing, and anything it does build is a path the l3 verdict has to reason about all over again", tag, ob.Type)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-61
@@ -38,7 +38,7 @@ func (b *builder) buildOutboundsAndEndpoints() ([]option.Outbound, []option.Endp
|
||||
},
|
||||
}
|
||||
|
||||
// Egress outbounds. THREE kinds get a real outbound tag ("egress-<name>",
|
||||
// Egress outbounds. TWO kinds get a real outbound tag ("egress-<name>",
|
||||
// the netplane contract):
|
||||
// - interface: a direct outbound BOUND to the egress device and stamped
|
||||
// with the deterministic egress mark so engine-originated traffic through
|
||||
@@ -51,29 +51,22 @@ func (b *builder) buildOutboundsAndEndpoints() ([]option.Outbound, []option.Endp
|
||||
// routed to it goes DIRECT but with tls_fragment/record/spoof applied on
|
||||
// the rule's route action (see buildRoute / applyDPI). "" is a SYNONYM of
|
||||
// "direct" so an egress section written without a type still works.
|
||||
// - byedpi: a SOCKS5 outbound to 127.0.0.1:<port> — the local ciadpi desync
|
||||
// proxy (D13). The ciadpi process itself ships in a separate package; here
|
||||
// we only emit the outbound so a rule can target it. Loop-guard mark only.
|
||||
//
|
||||
// Egress.Port is meaningful for `byedpi` ONLY. The model comment calls it
|
||||
// "also usable by future proxy egresses", but there is no other type that dials
|
||||
// anything: interface/direct bind or mark a DIRECT outbound and have no
|
||||
// destination to take a port, and proxy/block emit nothing at all. A port set
|
||||
// on any other type is therefore dead config and is reported as such.
|
||||
// NEITHER of them dials anything, which is why an egress has no port. The one
|
||||
// kind that did — `byedpi`, a SOCKS5 hop into a separate ciadpi process — is
|
||||
// RETIRED (model.RetiredEgressTypes), so `option port` is no longer parsed and
|
||||
// Egress carries no Port field.
|
||||
//
|
||||
// Any other type (including the `proxy` and `block` kinds this generator once
|
||||
// reserved) emits NO outbound and is reported. Emitting nothing is safe now
|
||||
// only because every REFERENCE to a non-existent egress is fail-closed — see
|
||||
// egressDetourOrBlock. Before that, an unimplemented type was the start of a
|
||||
// leak: the section existed, the panel listed it, and every binding to it was
|
||||
// silently dropped, sending that traffic out over the plain default WAN.
|
||||
// Any other type (including the retired `byedpi` and the `proxy`/`block` kinds
|
||||
// this generator once reserved) emits NO outbound and is reported. Emitting
|
||||
// nothing is safe now only because every REFERENCE to a non-existent egress is
|
||||
// fail-closed — see egressDetourOrBlock. Before that, an unimplemented type was
|
||||
// the start of a leak: the section existed, the panel listed it, and every
|
||||
// binding to it was silently dropped, sending that traffic out over the plain
|
||||
// default WAN.
|
||||
for idx, eg := range b.m.Egresses {
|
||||
tag := netplane.EgressOutboundTag(eg.Name)
|
||||
egType := strings.ToLower(strings.TrimSpace(eg.Type))
|
||||
if eg.Port != 0 && egType != "byedpi" {
|
||||
b.warnf("egress %q: port %d is ignored — only a byedpi egress dials a port (the local ciadpi listener). A %s egress has no destination to apply it to, so this setting does nothing",
|
||||
eg.Name, eg.Port, orDefaultEgressType(egType))
|
||||
}
|
||||
switch egType {
|
||||
case "interface":
|
||||
// ONE resolution with the data plane, by calling the data plane's own.
|
||||
@@ -128,40 +121,19 @@ func (b *builder) buildOutboundsAndEndpoints() ([]option.Outbound, []option.Endp
|
||||
},
|
||||
})
|
||||
b.egressTags[tag] = true
|
||||
case "byedpi":
|
||||
// ByeDPI (ciadpi, D13) is a local SOCKS5 desync proxy: a rule sends
|
||||
// selected domains to it and it desyncs + goes DIRECT (no tunnel).
|
||||
// Model it as a SOCKS outbound to 127.0.0.1:<port>. The loop-guard
|
||||
// mark keeps ciadpi's OWN upstream connections from being re-diverted
|
||||
// by the tproxy plane (same protection the direct egress uses). Native
|
||||
// tls_* DPI flags don't apply — the desync happens inside ciadpi — so
|
||||
// recordEgressDPI is intentionally NOT called for byedpi.
|
||||
port := eg.Port
|
||||
if port == 0 {
|
||||
port = 1080
|
||||
}
|
||||
outbounds = append(outbounds, option.Outbound{
|
||||
Type: C.TypeSOCKS,
|
||||
Tag: tag,
|
||||
Options: &option.SOCKSOutboundOptions{
|
||||
DialerOptions: loopDialer(),
|
||||
ServerOptions: option.ServerOptions{
|
||||
Server: "127.0.0.1",
|
||||
ServerPort: uint16(port),
|
||||
},
|
||||
Version: "5",
|
||||
},
|
||||
})
|
||||
b.egressTags[tag] = true
|
||||
// A DPI preset on a byedpi egress is ignored on purpose (the desync
|
||||
// happens inside ciadpi), but the panel offers the control on this type,
|
||||
// so silence would read as "applied". Say it instead.
|
||||
if d := strings.ToLower(strings.TrimSpace(eg.DPI)); d != "" && d != "off" {
|
||||
b.warnf("egress %q: type=byedpi ignores dpi=%s — the desync is done by the ciadpi process itself, and the native tls_* route-action flags are not applied on top of it. Configure the desync in ciadpi's own options (or use a direct/interface egress if you want the native preset)", eg.Name, d)
|
||||
}
|
||||
continue // byedpi carries no native tls_* DPI preset
|
||||
default:
|
||||
b.warnf("egress %q: unknown type %q — no outbound is emitted, so every binding to this egress is FAIL-CLOSED (its traffic is blocked, never sent over the default WAN) and a rule targeting it falls back to its kill policy. Supported types: interface, direct, byedpi", eg.Name, eg.Type)
|
||||
// A RETIRED kind is named by what happened to it, not called a typo.
|
||||
// The router's behaviour is identical either way — no outbound, so every
|
||||
// binding fails closed — and the sentence is the whole difference: the
|
||||
// value was correct when the operator wrote it, and telling them it is
|
||||
// "unknown" sends them looking for a spelling mistake that is not there.
|
||||
// model.RetiredEgressTypes is the single copy of that sentence; it is not
|
||||
// restated here, because two copies drift.
|
||||
if msg, retired := model.RetiredEgressType(egType); retired {
|
||||
b.warnf("egress %q: %s", eg.Name, msg)
|
||||
continue
|
||||
}
|
||||
b.warnf("egress %q: unknown type %q — no outbound is emitted, so every binding to this egress is FAIL-CLOSED (its traffic is blocked, never sent over the default WAN) and a rule targeting it falls back to its kill policy. Supported types: %s", eg.Name, eg.Type, strings.Join(model.KnownEgressTypes, ", "))
|
||||
continue
|
||||
}
|
||||
b.recordEgressDPI(eg.Name, eg.DPI)
|
||||
@@ -211,15 +183,6 @@ func (b *builder) buildOutboundsAndEndpoints() ([]option.Outbound, []option.Endp
|
||||
return outbounds, endpoints
|
||||
}
|
||||
|
||||
// orDefaultEgressType names an egress type for a diagnostic, spelling out the
|
||||
// empty value as the "direct" it is a synonym of rather than printing "".
|
||||
func orDefaultEgressType(egType string) string {
|
||||
if egType == "" {
|
||||
return "direct"
|
||||
}
|
||||
return egType
|
||||
}
|
||||
|
||||
// egressDetourOrBlock resolves an EXPLICIT egress binding (Node.Egress,
|
||||
// Group.Egress, a chain's entry egress hop) to the outbound tag its owner must
|
||||
// dial through. It is the single resolution point for all of them, so the
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
@@ -37,14 +35,52 @@ func (b *builder) applyProfiles() {
|
||||
for _, w := range owarns {
|
||||
b.warnf("%s", w.Error())
|
||||
}
|
||||
// Per-profile endpoint-resolver override (by the active WAN profile). Consumed
|
||||
// by endpointResolver() with priority OVER Globals.EndpointResolver.
|
||||
b.profileEndpointResolver = strings.TrimSpace(prof.EndpointResolver)
|
||||
// The active profile is kept whole; the DNS scalars it may override are
|
||||
// resolved on demand through the accessors below, never copied out one field
|
||||
// at a time. See builder.activeProfile (generate.go) for what the copies cost.
|
||||
b.activeProfile = prof
|
||||
}
|
||||
|
||||
b.effectiveRules = eff
|
||||
}
|
||||
|
||||
// The three DNS scalars a WAN profile may override are answered HERE and nowhere
|
||||
// else in this package, each by a one-line delegation to model.
|
||||
//
|
||||
// That is the whole point of them. The inheritance rule is one sentence — a
|
||||
// non-blank profile value wins, per field, otherwise globals — and it is written
|
||||
// once, in model/fetchdetour.go, because the two other consumers (apply, which
|
||||
// builds the fetch client, and the panel, which has to PRINT what is in force)
|
||||
// must give the same answer. They did not: the panel showed the operator
|
||||
// `endpoint_resolver = local` out of globals while the engine, under the active
|
||||
// profile, was resolving through `yandex`. A second copy of the rule inside
|
||||
// generate would put the generator back in that same position.
|
||||
//
|
||||
// Each accessor calls applyProfiles first. That is idempotent, so they are safe
|
||||
// to call from buildRoute or buildDNS in either order, and safe to call twice.
|
||||
|
||||
// effectiveResolverDefault is the resolver consulted FIRST — globals', or the
|
||||
// active profile's override of it.
|
||||
func (b *builder) effectiveResolverDefault() model.Override {
|
||||
b.applyProfiles()
|
||||
return model.EffectiveResolverDefault(b.m.Globals, b.activeProfile)
|
||||
}
|
||||
|
||||
// effectiveResolverFallback is the resolver the failover chain retries against.
|
||||
// Independent of effectiveResolverDefault by construction: inheritance is per
|
||||
// field, so a profile that overrides only the fallback keeps globals' default.
|
||||
func (b *builder) effectiveResolverFallback() model.Override {
|
||||
b.applyProfiles()
|
||||
return model.EffectiveResolverFallback(b.m.Globals, b.activeProfile)
|
||||
}
|
||||
|
||||
// effectiveEndpointResolver is the bootstrap resolver for proxy SERVER DOMAINS
|
||||
// (route.default_domain_resolver).
|
||||
func (b *builder) effectiveEndpointResolver() model.Override {
|
||||
b.applyProfiles()
|
||||
return model.EffectiveEndpointResolver(b.m.Globals, b.activeProfile)
|
||||
}
|
||||
|
||||
// resolveActiveProfile delegates to model.ResolveActiveProfile — the SINGLE
|
||||
// source of truth for "which profile is active", shared with the netplane
|
||||
// divert plan (nftPlanRules) so the engine's route rules and the nft
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// A WAN profile may override THREE DNS scalars — resolver_default,
|
||||
// resolver_fallback and endpoint_resolver — and this file is about what the
|
||||
// GENERATED config does with them.
|
||||
//
|
||||
// Why it needs its own instrument at all: a profile override is invisible by
|
||||
// construction. It only applies while that profile is active, so the SIM
|
||||
// profile's `resolver_default` is unobservable until the ethernet cable comes
|
||||
// out — at which point DNS stops and the config that broke it looks exactly like
|
||||
// the config that worked. That is not hypothetical; it is what was measured on
|
||||
// the production router on 2026-07-27, where the panel showed
|
||||
// `endpoint_resolver = local` from globals while the engine, under the active
|
||||
// profile, was resolving through `yandex`.
|
||||
//
|
||||
// Every test here drives the REAL generator end to end and reads the emitted
|
||||
// option.Options. None of them asserts on model.Effective* directly — that is
|
||||
// model's own test's job, and asserting it here would prove only that the
|
||||
// generator can call a function, not that its output changed.
|
||||
|
||||
// overrideTestResolvers is the resolver set every model in this file uses.
|
||||
//
|
||||
// THE FIRST ENTRY IS A SENTINEL AND IS NEVER NAMED BY ANY TEST HERE. buildDNS has
|
||||
// a last-resort path — when `final` names no server that was built, it falls
|
||||
// through to servers[0], "the first resolver that survived", which has nothing to
|
||||
// do with what anybody configured. While the first entry was also the value under
|
||||
// test, that path produced the SAME answer as a correct resolution and the
|
||||
// assertions could not tell them apart: a mutation that dropped the profile
|
||||
// rollback entirely passed two of these tests. With a sentinel in front, any
|
||||
// arrival at servers[0] shows up by name.
|
||||
func overrideTestResolvers() []model.Resolver {
|
||||
return []model.Resolver{
|
||||
{Name: "servers0-sentinel", Type: "udp", Address: "192.0.2.1", Detour: "direct"},
|
||||
{Name: "quad9", Type: "udp", Address: "9.9.9.9", Detour: "direct"},
|
||||
{Name: "cloudflare", Type: "udp", Address: "1.1.1.1", Detour: "direct"},
|
||||
{Name: "yandex", Type: "udp", Address: "77.88.8.8", Detour: "direct"},
|
||||
{Name: "yandex-sec", Type: "udp", Address: "77.88.8.88", Detour: "direct"},
|
||||
}
|
||||
}
|
||||
|
||||
// mobileProfileModel is the shape these tests are about: the resolver set above,
|
||||
// one profile pinned active, and whatever overrides the caller sets on it.
|
||||
func mobileProfileModel(prof model.Profile, g model.Globals) *model.Model {
|
||||
g.ActiveProfile = prof.Name
|
||||
prof.Enabled = true
|
||||
return &model.Model{
|
||||
Globals: g,
|
||||
Resolvers: overrideTestResolvers(),
|
||||
Profiles: []model.Profile{prof},
|
||||
}
|
||||
}
|
||||
|
||||
// emittedDNSFinal returns the emitted dns.final, failing when there is no DNS plane.
|
||||
func emittedDNSFinal(t *testing.T, opts option.Options) string {
|
||||
t.Helper()
|
||||
if opts.DNS == nil {
|
||||
t.Fatalf("no DNS plane was emitted at all")
|
||||
}
|
||||
return opts.DNS.Final
|
||||
}
|
||||
|
||||
// emittedFailoverServers returns the servers named by the three-rule failover chain
|
||||
// resolverFallbackRules emits, in order ([1] primary, [2] fallback). An empty
|
||||
// slice means no failover chain was emitted.
|
||||
func emittedFailoverServers(opts option.Options) []string {
|
||||
if opts.DNS == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, r := range opts.DNS.Rules {
|
||||
if r.Type != C.RuleTypeDefault {
|
||||
continue
|
||||
}
|
||||
if r.DefaultOptions.Action != C.RuleActionTypeEvaluate {
|
||||
continue
|
||||
}
|
||||
out = append(out, r.DefaultOptions.RouteOptions.Server)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emittedDomainResolver returns route.default_domain_resolver's server tag, or ""
|
||||
// when the field is unset. For diagnostics only — a test that WANTS the field set
|
||||
// asserts on it directly, so that an unset one fails as "" rather than nil-panics.
|
||||
func emittedDomainResolver(opts option.Options) string {
|
||||
if opts.Route == nil || opts.Route.DefaultDomainResolver == nil {
|
||||
return ""
|
||||
}
|
||||
return opts.Route.DefaultDomainResolver.Server
|
||||
}
|
||||
|
||||
// configJSON renders a config for a failure message. option.Options carries typed
|
||||
// pointers whose %+v is a list of addresses, which is exactly no help when the
|
||||
// question is "what moved?".
|
||||
func configJSON(t *testing.T, opts option.Options) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return "<unmarshalable: " + err.Error() + ">"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestProfileResolverDefaultWins: the active profile's resolver_default is the
|
||||
// engine's dns.final, not globals'. This is the whole point of the feature — on
|
||||
// the SIM uplink quad9/cloudflare are unreachable (the operator's ISP refuses
|
||||
// :443 to both, measured), so the profile has to be able to move the default.
|
||||
func TestProfileResolverDefaultWins(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverDefault: "yandex"},
|
||||
model.Globals{ResolverDefault: "quad9", ResolverFallback: "cloudflare"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got := emittedDNSFinal(t, opts); got != "yandex" {
|
||||
t.Fatalf("dns.final = %q, want the active profile's override %q (globals says %q); warns=%v",
|
||||
got, "yandex", "quad9", warns)
|
||||
}
|
||||
// Per-FIELD inheritance: the profile said nothing about the fallback, so
|
||||
// globals' fallback is still the one in the failover chain.
|
||||
if got, want := emittedFailoverServers(opts), []string{"yandex", "cloudflare"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("failover chain = %v, want %v — the fallback is inherited from globals per field", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileResolverFallbackWinsAlone: a profile that overrides ONLY the
|
||||
// fallback keeps globals' default. Inheritance is per field; a profile is not
|
||||
// an all-or-nothing block.
|
||||
func TestProfileResolverFallbackWinsAlone(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverFallback: "yandex-sec"},
|
||||
model.Globals{ResolverDefault: "quad9", ResolverFallback: "cloudflare"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got := emittedDNSFinal(t, opts); got != "quad9" {
|
||||
t.Fatalf("dns.final = %q, want globals' %q — the profile overrode only the fallback; warns=%v",
|
||||
got, "quad9", warns)
|
||||
}
|
||||
if got, want := emittedFailoverServers(opts), []string{"quad9", "yandex-sec"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("failover chain = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileEndpointResolverWinsOverGlobals is the third scalar, asserted on the
|
||||
// generated config rather than on the builder field it used to be copied into.
|
||||
// (TestEndpointResolverProfileOverridesGlobals in endpoint_resolver_test.go
|
||||
// already covers this; it is repeated here so that this file, which owns the
|
||||
// rollback rule, also owns the positive control for it — a rollback test that
|
||||
// passes because the override never worked would prove nothing.)
|
||||
func TestProfileEndpointResolverWinsOverGlobals(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", EndpointResolver: "yandex"},
|
||||
model.Globals{ResolverDefault: "quad9", EndpointResolver: "cloudflare"},
|
||||
)
|
||||
opts, _, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if opts.Route == nil || opts.Route.DefaultDomainResolver == nil {
|
||||
t.Fatalf("route.default_domain_resolver must be set; route=%+v", opts.Route)
|
||||
}
|
||||
if got, want := opts.Route.DefaultDomainResolver.Server, endpointServerTag("yandex"); got != want {
|
||||
t.Fatalf("default_domain_resolver.server = %q, want the profile's %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRollbackWarning checks the one message shape the owner asked for: it must
|
||||
// name the PROFILE, the FIELD, the REJECTED value and WHAT IS IN FORCE INSTEAD,
|
||||
// and it must say that applying was not stopped. Anything less and the operator
|
||||
// gets a fault they cannot act on for the one setting they cannot observe.
|
||||
func assertRollbackWarning(t *testing.T, warns []string, profile, field, bad, inForce string) {
|
||||
t.Helper()
|
||||
var hit string
|
||||
for _, w := range warns {
|
||||
if strings.Contains(w, profileOverrideNotAppliedTag) && strings.Contains(w, field) {
|
||||
hit = w
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit == "" {
|
||||
t.Fatalf("no %s warning for %s; got %v", profileOverrideNotAppliedTag, field, warns)
|
||||
}
|
||||
// Printed, not just matched. These sentences are the entire user interface for a
|
||||
// fault the operator cannot otherwise observe, and a substring assertion says
|
||||
// nothing about whether the whole thing reads as English. `go test -v` puts them
|
||||
// in front of a human; the gate's green-run filter drops t.Log lines, so this
|
||||
// costs nothing there.
|
||||
t.Logf("operator-facing text:\n %s", hit)
|
||||
for _, want := range []string{
|
||||
`profile "` + profile + `"`, // whose override it was
|
||||
field, // which field
|
||||
`"` + bad + `"`, // the value that was rejected
|
||||
inForce, // what is in force instead
|
||||
"inert", // the override does nothing (and apply grades this critical)
|
||||
"still applied", // and it did not stop the config
|
||||
} {
|
||||
if !strings.Contains(hit, want) {
|
||||
t.Fatalf("the rollback warning must contain %q; got:\n %s", want, hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileResolverDefaultUnknownRollsBackToGlobals is the owner's decision,
|
||||
// verbatim: a profile that names a resolver this configuration does not have does
|
||||
// NOT get to take DNS down on its own uplink. The globals value takes effect, and
|
||||
// the substitution is announced.
|
||||
//
|
||||
// The distinction this asserts is a real fork in the code, not a formality: the
|
||||
// weaker outcome is to leave the unusable name in place, let buildDNS notice no
|
||||
// server was built for it and fall through to servers[0]. That produces a working
|
||||
// default too — just not the operator's one. overrideTestResolvers' sentinel is what makes
|
||||
// the two visibly different.
|
||||
func TestProfileResolverDefaultUnknownRollsBackToGlobals(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverDefault: "yandex-typo"},
|
||||
model.Globals{ResolverDefault: "quad9"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got := emittedDNSFinal(t, opts); got != "quad9" {
|
||||
t.Fatalf("dns.final = %q, want the globals value %q — an unusable profile override rolls back to what "+
|
||||
"globals configured, it does not fall through to whichever resolver happened to build first (%q); warns=%v",
|
||||
got, "quad9", "servers0-sentinel", warns)
|
||||
}
|
||||
assertRollbackWarning(t, warns, "mobile-uplink", "resolver_default", "yandex-typo", `"quad9"`)
|
||||
}
|
||||
|
||||
// TestProfileResolverFallbackUnknownRollsBackToGlobals: same rule, second field.
|
||||
// The failover chain must be the one globals configured, not silently absent.
|
||||
func TestProfileResolverFallbackUnknownRollsBackToGlobals(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverFallback: "yandex-sec-typo"},
|
||||
model.Globals{ResolverDefault: "quad9", ResolverFallback: "cloudflare"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got, want := emittedFailoverServers(opts), []string{"quad9", "cloudflare"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("failover chain = %v, want %v — the globals fallback must survive an unusable profile override; warns=%v",
|
||||
got, want, warns)
|
||||
}
|
||||
assertRollbackWarning(t, warns, "mobile-uplink", "resolver_fallback", "yandex-sec-typo", `"cloudflare"`)
|
||||
}
|
||||
|
||||
// TestProfileEndpointResolverUnknownRollsBackToGlobals: third field. The
|
||||
// bootstrap-direct clone must be built from GLOBALS' resolver, not left unset —
|
||||
// an unset default_domain_resolver on a two-transport plane sends node hostnames
|
||||
// down the client DNS rule chain, which is the bootstrap loop dns.go's
|
||||
// implicitBootstrapResolver exists to prevent.
|
||||
func TestProfileEndpointResolverUnknownRollsBackToGlobals(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", EndpointResolver: "ghost"},
|
||||
model.Globals{ResolverDefault: "quad9", EndpointResolver: "cloudflare"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if opts.Route == nil || opts.Route.DefaultDomainResolver == nil {
|
||||
t.Fatalf("default_domain_resolver must be set from the globals value; route=%+v, warns=%v", opts.Route, warns)
|
||||
}
|
||||
if got, want := opts.Route.DefaultDomainResolver.Server, endpointServerTag("cloudflare"); got != want {
|
||||
t.Fatalf("default_domain_resolver.server = %q, want the globals value's clone %q", got, want)
|
||||
}
|
||||
assertRollbackWarning(t, warns, "mobile-uplink", "endpoint_resolver", "ghost", `"cloudflare"`)
|
||||
}
|
||||
|
||||
// TestProfileResolverUnknownAndGlobalsUnsetSaysSo is the third branch of the
|
||||
// rollback: globals has nothing to roll back TO. The message must then carry the
|
||||
// CONSEQUENCE, because there is no substitute value to name — and the consequence
|
||||
// differs per field, which is why the helper takes it from the caller.
|
||||
func TestProfileResolverUnknownAndGlobalsUnsetSaysSo(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverFallback: "yandex-sec-typo"},
|
||||
model.Globals{ResolverDefault: "quad9"}, // no globals fallback at all
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got := emittedFailoverServers(opts); len(got) != 0 {
|
||||
t.Fatalf("no failover chain may be emitted when neither side names a usable fallback, got %v", got)
|
||||
}
|
||||
assertRollbackWarning(t, warns, "mobile-uplink", "resolver_fallback", "yandex-sec-typo",
|
||||
"there is no DNS failover at all")
|
||||
}
|
||||
|
||||
// TestProfileAndGlobalsBothUnusableNamesBoth is the last of the three rollback
|
||||
// branches: the globals value the override falls back to is broken too. The
|
||||
// message must not pretend the substitution worked — it names BOTH bad values and
|
||||
// the consequence, because "the globals value takes effect" would be false here
|
||||
// and a warning that is false about the recovery is worse than no warning.
|
||||
func TestProfileAndGlobalsBothUnusableNamesBoth(t *testing.T) {
|
||||
m := mobileProfileModel(
|
||||
model.Profile{Name: "mobile-uplink", ResolverDefault: "yandex-typo"},
|
||||
model.Globals{ResolverDefault: "quad9-typo"},
|
||||
)
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
// Nothing usable on either side, so buildDNS's own last resort applies — and it
|
||||
// is REPORTED by the pre-existing warning, not silently taken.
|
||||
if got := emittedDNSFinal(t, opts); got != "servers0-sentinel" {
|
||||
t.Fatalf("dns.final = %q, want the first resolver that built (%q)", got, "servers0-sentinel")
|
||||
}
|
||||
assertRollbackWarning(t, warns, "mobile-uplink", "resolver_default", "yandex-typo",
|
||||
"the globals value \"quad9-typo\" it falls back to is not usable either")
|
||||
if !warnsHaveSub(warns, `resolver_default "quad9-typo" was not built`) {
|
||||
t.Fatalf("the globals value must ALSO be reported as unbuilt — the rollback warning promises no more "+
|
||||
"than that it fell back; got %v", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileWithNoDNSOverridesChangesNothing is the regression guard on the half
|
||||
// of this change nobody asked for: every configuration that does NOT use a
|
||||
// profile DNS override must generate exactly what it generated before.
|
||||
//
|
||||
// The instrument is a whole-config comparison against the same model with the
|
||||
// profile removed, not a spot check on dns.final — a rewrite that resolved the
|
||||
// default correctly and quietly moved a rule, a server or the fallback chain
|
||||
// would pass a spot check. reflect.DeepEqual over option.Options is the only
|
||||
// assertion that cannot be satisfied by getting the interesting field right.
|
||||
func TestProfileWithNoDNSOverridesChangesNothing(t *testing.T) {
|
||||
g := model.DefaultGlobals()
|
||||
g.ResolverDefault = "quad9"
|
||||
g.ResolverFallback = "cloudflare"
|
||||
g.EndpointResolver = "yandex"
|
||||
|
||||
withProfile := mobileProfileModel(model.Profile{Name: "mobile-uplink"}, g)
|
||||
|
||||
bare := *withProfile
|
||||
bare.Profiles = nil
|
||||
bare.Globals.ActiveProfile = ""
|
||||
|
||||
got, gotWarns, err := GenerateWithWarnings(withProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings (with profile): %v", err)
|
||||
}
|
||||
want, wantWarns, err := GenerateWithWarnings(&bare)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings (no profile): %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
// %+v on option.Options is pointer soup and says nothing about WHAT moved, so
|
||||
// the three DNS scalars this change touches are spelled out first; the JSON
|
||||
// dump behind them is what catches a difference anywhere else.
|
||||
t.Fatalf("a profile that overrides no DNS scalar must produce an identical config.\n"+
|
||||
" dns.final : %q (with profile) vs %q (without)\n"+
|
||||
" failover chain : %v vs %v\n"+
|
||||
" default_domain_resolver : %q vs %q\n"+
|
||||
" full config with profile: %s\n"+
|
||||
" full config without : %s",
|
||||
emittedDNSFinal(t, got), emittedDNSFinal(t, want),
|
||||
emittedFailoverServers(got), emittedFailoverServers(want),
|
||||
emittedDomainResolver(got), emittedDomainResolver(want),
|
||||
configJSON(t, got), configJSON(t, want))
|
||||
}
|
||||
if !reflect.DeepEqual(gotWarns, wantWarns) {
|
||||
t.Fatalf("...and identical warnings\n with profile: %v\n without : %v", gotWarns, wantWarns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoProfileAtAllUsesGlobals is the control for every rollback test above: on
|
||||
// a model with no profiles the three scalars come from globals and nothing is
|
||||
// warned about. Without it, an implementation that ignored profiles entirely
|
||||
// (returning globals always) would pass the "unchanged behaviour" test and every
|
||||
// rollback test, and fail only the three positive ones — so this is what makes
|
||||
// those three mean something.
|
||||
func TestNoProfileAtAllUsesGlobals(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: model.Globals{ResolverDefault: "quad9", ResolverFallback: "cloudflare", EndpointResolver: "yandex"},
|
||||
Resolvers: overrideTestResolvers(), // servers[0] is the sentinel, never any of the three
|
||||
}
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateWithWarnings: %v", err)
|
||||
}
|
||||
if got := emittedDNSFinal(t, opts); got != "quad9" {
|
||||
t.Fatalf("dns.final = %q, want globals' %q", got, "quad9")
|
||||
}
|
||||
if got, want := emittedFailoverServers(opts), []string{"quad9", "cloudflare"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("failover chain = %v, want %v", got, want)
|
||||
}
|
||||
if opts.Route == nil || opts.Route.DefaultDomainResolver == nil {
|
||||
t.Fatalf("default_domain_resolver must be set from globals; route=%+v", opts.Route)
|
||||
}
|
||||
if got, want := opts.Route.DefaultDomainResolver.Server, endpointServerTag("yandex"); got != want {
|
||||
t.Fatalf("default_domain_resolver.server = %q, want %q", got, want)
|
||||
}
|
||||
for _, w := range warns {
|
||||
if strings.Contains(w, profileOverrideNotAppliedTag) {
|
||||
t.Fatalf("no profile-override warning may be emitted for a model with no profiles: %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
|
||||
// A RETIRED egress kind is the one configuration state a removal creates that
|
||||
// nobody chose. `byedpi` — an egress that handed traffic to a separate ciadpi
|
||||
// process over local SOCKS — shipped, was documented, and is configured on real
|
||||
// routers right now. Deleting the code that implemented it does not delete the
|
||||
// `option type 'byedpi'` sitting in somebody's /etc/config/shater.
|
||||
//
|
||||
// What that config must do, and what these tests pin:
|
||||
//
|
||||
// 1. it must keep FAILING CLOSED. No outbound, no device, no mark, no routing
|
||||
// table — everything bound to it stops rather than leaving over the plain
|
||||
// WAN with the router's own address. This is unchanged by the removal, and
|
||||
// it is the half that must not regress while the messages are being changed;
|
||||
// 2. it must be reported by the sentence that says the kind was REMOVED and
|
||||
// what to put in its place — not by the generic "unknown type", which sends
|
||||
// the operator hunting for a spelling mistake in a value that was correct on
|
||||
// the day they typed it;
|
||||
// 3. a type that was never supported must NOT get that sentence, because it
|
||||
// names a replacement and there is nothing to replace.
|
||||
//
|
||||
// The message lives in exactly one place, model.RetiredEgressTypes, and both the
|
||||
// validator and the generator read it from there. These tests assert the FACTS
|
||||
// the message has to carry rather than the whole string, so rewording it stays
|
||||
// possible and dropping a fact does not.
|
||||
|
||||
// retiredFacts are the load-bearing claims of the removal sentence. Each is
|
||||
// checked as a substring; together they are what makes the message actionable
|
||||
// instead of merely different.
|
||||
var retiredFacts = []struct {
|
||||
name string
|
||||
sub string
|
||||
}{
|
||||
{"names the kind", `"byedpi"`},
|
||||
{"says it was removed from the product", "was REMOVED from this product"},
|
||||
{"denies that it is a typo", "not a misspelling"},
|
||||
{"says nothing is built for it", "no outbound"},
|
||||
{"says the traffic is blocked, not leaked", "BLOCKED (fail-closed)"},
|
||||
{"names the replacement type", "'direct'"},
|
||||
{"names the replacement preset", "'record'"},
|
||||
{"refuses to promise the preset works", "is not promised here"},
|
||||
{"says how to take the dead package off the router", "apk del byedpi"},
|
||||
}
|
||||
|
||||
func assertRetiredFacts(t *testing.T, where, msg string) {
|
||||
t.Helper()
|
||||
for _, f := range retiredFacts {
|
||||
if !strings.Contains(msg, f.sub) {
|
||||
t.Errorf("%s: the removal message no longer %s (wanted %q in it).\nMessage was:\n%s",
|
||||
where, f.name, f.sub, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredEgressTypeIsReportedByTheValidator is the model half: an egress
|
||||
// carrying a retired kind draws the removal finding, and draws it INSTEAD of the
|
||||
// unrecognised-value finding.
|
||||
func TestRetiredEgressTypeIsReportedByTheValidator(t *testing.T) {
|
||||
// Spelled the way an operator's config and a hand edit would have it: the
|
||||
// canonical lower case, and a padded mixed-case variant that ReadUCI's
|
||||
// normalisation would not fold (byedpi is no alias of anything).
|
||||
for _, written := range []string{"byedpi", " ByeDPI "} {
|
||||
t.Run("type="+written, func(t *testing.T) {
|
||||
ws := model.ValidateEgresses([]model.Egress{{Name: "bd", Type: written}})
|
||||
if len(ws) != 1 {
|
||||
t.Fatalf("want exactly 1 finding for a retired egress kind, got %d: %v", len(ws), ws)
|
||||
}
|
||||
if ws[0].Section != "egress" || ws[0].Name != "bd" {
|
||||
t.Fatalf("finding = %+v, want section \"egress\" name \"bd\" — the panel groups and deep-links by these", ws[0])
|
||||
}
|
||||
if strings.Contains(ws[0].Message, "is not one of") {
|
||||
t.Fatalf("a retired kind was reported as an unrecognised value:\n%s\nThat is the message this whole change exists to replace: it tells someone whose config was correct to go looking for a typo.", ws[0].Message)
|
||||
}
|
||||
assertRetiredFacts(t, "ValidateEgresses", ws[0].Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredEgressTypeIsReportedByTheGenerator is the engine half. The
|
||||
// generator is where an apply is actually attempted, so this is the message the
|
||||
// operator sees at the moment their change is refused.
|
||||
func TestRetiredEgressTypeIsReportedByTheGenerator(t *testing.T) {
|
||||
m := &model.Model{
|
||||
Globals: plainGlobals(),
|
||||
Egresses: []model.Egress{{Name: "bd", Type: "byedpi"}},
|
||||
}
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
got := warnMatching(warns, `egress "bd"`, "was REMOVED from this product")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want exactly 1 removal warning naming the egress, got %d: %v", len(got), warns)
|
||||
}
|
||||
if len(warnMatching(warns, `egress "bd"`, "unknown type")) != 0 {
|
||||
t.Fatalf("the retired kind ALSO drew the unknown-type warning: %v", warns)
|
||||
}
|
||||
assertRetiredFacts(t, "GenerateWithWarnings", got[0])
|
||||
|
||||
// Fact 1: still fail-closed. The sentence changed; the behaviour must not
|
||||
// have. Without this the test would pass on a build that had quietly given
|
||||
// `byedpi` a direct outbound and merely printed a warning about it — which is
|
||||
// the failure mode with the worst consequence, since it puts the traffic the
|
||||
// operator was protecting onto the plain WAN under a reassuring message.
|
||||
if ob := obByTag(opts, netplane.EgressOutboundTag("bd")); ob != nil {
|
||||
t.Fatalf("a retired egress kind emitted outbound type %q — it must emit NOTHING, so every binding to it is blocked", ob.Type)
|
||||
}
|
||||
if model.EgressTypeKnown("byedpi") {
|
||||
t.Fatalf("model.EgressTypeKnown(\"byedpi\") = true — a retired kind must not be a known one; being told it was removed and having it work anyway is worse than either alone")
|
||||
}
|
||||
if model.EgressHasDevice(model.Egress{Name: "bd", Type: "byedpi", Interface: "eth1"}) {
|
||||
t.Fatalf("a retired egress kind resolved to a device — the data plane would then install a mark, an `ip rule` and a routing table for a kind the engine builds nothing for: the exact split-brain egress the canonical type set exists to prevent")
|
||||
}
|
||||
for _, known := range model.KnownEgressTypes {
|
||||
if known == "byedpi" {
|
||||
t.Fatalf("model.KnownEgressTypes still offers %q — the panel builds its type picker from this list", known)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredEgressTypeDoesNotSwallowUnknownOnes is the CONTROL for the two
|
||||
// tests above. They prove the retired kind gets its own sentence; this proves
|
||||
// the instrument can still tell the other answer apart — that the removal branch
|
||||
// did not become a catch-all quietly claiming every bad type was once supported.
|
||||
// A removal sentence on a value that never shipped names a replacement for
|
||||
// something that never existed.
|
||||
func TestRetiredEgressTypeDoesNotSwallowUnknownOnes(t *testing.T) {
|
||||
for _, written := range []string{"proxy", "block", "wireguard", "byedpi2", "bye dpi", "sorcery"} {
|
||||
t.Run("type="+written, func(t *testing.T) {
|
||||
ws := model.ValidateEgresses([]model.Egress{{Name: "u", Type: written}})
|
||||
if len(ws) != 1 {
|
||||
t.Fatalf("want exactly 1 finding, got %d: %v", len(ws), ws)
|
||||
}
|
||||
if strings.Contains(ws[0].Message, "was REMOVED from this product") {
|
||||
t.Fatalf("type %q was reported as a REMOVED kind:\n%s\nIt was never one, and the message tells the operator to replace something they never had.", written, ws[0].Message)
|
||||
}
|
||||
if !strings.Contains(ws[0].Message, "is not one of") {
|
||||
t.Fatalf("type %q drew neither the unrecognised nor the removal finding: %v", written, ws[0])
|
||||
}
|
||||
if _, retired := model.RetiredEgressType(written); retired {
|
||||
t.Fatalf("model.RetiredEgressType(%q) claims a retired kind", written)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupportedEgressTypesAreUntouched is the other half of the control: the
|
||||
// kinds that survive must keep working, silently. A removal that also broke
|
||||
// `interface` or `direct` would be caught by half the suite, but not by anything
|
||||
// that reads this file — and the point of a control is that it lives beside the
|
||||
// claim it qualifies.
|
||||
func TestSupportedEgressTypesAreUntouched(t *testing.T) {
|
||||
cases := []struct {
|
||||
written string
|
||||
device bool
|
||||
}{
|
||||
{"interface", true},
|
||||
{"tunnel", true}, // the accepted alias, folded to interface
|
||||
{"direct", false},
|
||||
{"", false}, // the documented synonym of direct
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run("type="+c.written, func(t *testing.T) {
|
||||
eg := model.Egress{Name: "e1", Type: c.written, Interface: "eth1"}
|
||||
if _, retired := model.RetiredEgressType(c.written); retired {
|
||||
t.Fatalf("a SUPPORTED type was classed as retired")
|
||||
}
|
||||
if !model.EgressTypeKnown(c.written) {
|
||||
t.Fatalf("model.EgressTypeKnown(%q) = false", c.written)
|
||||
}
|
||||
if got := model.EgressHasDevice(eg); got != c.device {
|
||||
t.Fatalf("EgressHasDevice = %v, want %v", got, c.device)
|
||||
}
|
||||
if ws := model.ValidateEgresses([]model.Egress{eg}); len(ws) != 0 {
|
||||
t.Fatalf("a correctly-written %q egress drew findings: %v", c.written, ws)
|
||||
}
|
||||
m := &model.Model{Globals: plainGlobals(), Egresses: []model.Egress{eg}}
|
||||
// Through the production boundary. `tunnel` is an ALIAS resolved once,
|
||||
// here, and nowhere else — the generator's switch deliberately knows only
|
||||
// canonical spellings, so a Model that skipped this step is not the Model
|
||||
// the daemon ever builds from. Calling it is not a workaround; leaving it
|
||||
// out would be testing a path production does not take.
|
||||
m.NormalizeEgressTypes()
|
||||
opts, warns, err := GenerateWithWarnings(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warns)
|
||||
}
|
||||
if obByTag(opts, netplane.EgressOutboundTag("e1")) == nil {
|
||||
t.Fatalf("no outbound was emitted for the supported type %q — everything bound to it would be blocked", c.written)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -522,7 +522,7 @@ func (b *builder) warnICMPRule(r model.Rule, want string) {
|
||||
}
|
||||
switch b.l3Target(want, 0) {
|
||||
case l3Drops:
|
||||
b.warnf("rule %q: proto %s is routed to %q, which cannot carry a layer-3 packet — only wireguard/AmneziaWG nodes and direct/interface egresses reach the engine's flow path; every proxy protocol (vless, vmess, trojan, shadowsocks, hysteria2, tuic, shadowtls) and the byedpi SOCKS egress cannot, because none of them can implement it. The engine DROPS a ping routed at such a target rather than let the TUN stack answer the echo itself from a path that never carried the packet, so this rule makes those pings fail instead of tunnelling them. Point it at a wireguard/AmneziaWG node, at an interface egress, or at a chain whose LAST hop is one of those", r.Name, strings.ToLower(strings.TrimSpace(r.Proto)), want)
|
||||
b.warnf("rule %q: proto %s is routed to %q, which cannot carry a layer-3 packet — only wireguard/AmneziaWG nodes and direct/interface egresses reach the engine's flow path; every proxy protocol (vless, vmess, trojan, shadowsocks, hysteria2, tuic, shadowtls) cannot, because none of them can implement it. The engine DROPS a ping routed at such a target rather than let the TUN stack answer the echo itself from a path that never carried the packet, so this rule makes those pings fail instead of tunnelling them. Point it at a wireguard/AmneziaWG node, at an interface egress, or at a chain whose LAST hop is one of those", r.Name, strings.ToLower(strings.TrimSpace(r.Proto)), want)
|
||||
case l3Partial:
|
||||
b.warnf("rule %q: proto %s is routed to %q, whose members disagree about layer 3 — the ping is carried while the group's current member is a wireguard/AmneziaWG node and dropped while it is a proxy-protocol one, and nothing in the UI says which is in force right now. Point the rule at the L3-capable node itself (or at a group holding only those) if ping must behave the same from one minute to the next", r.Name, strings.ToLower(strings.TrimSpace(r.Proto)), want)
|
||||
}
|
||||
@@ -566,8 +566,7 @@ const (
|
||||
// N.NetworkICMP among their networks, and of those exactly two are reachable from
|
||||
// a shater model: a wireguard/AmneziaWG node, and the direct outbound behind
|
||||
// `direct` or an interface/direct egress. Everything else this generator emits —
|
||||
// vless, vmess, trojan, shadowsocks, hysteria2, tuic, shadowtls and the byedpi
|
||||
// SOCKS egress — cannot.
|
||||
// vless, vmess, trojan, shadowsocks, hysteria2, tuic and shadowtls — cannot.
|
||||
//
|
||||
// # The predicate that MUST change in lockstep
|
||||
//
|
||||
@@ -671,8 +670,8 @@ func (b *builder) l3Group(name string) l3Verdict {
|
||||
}
|
||||
|
||||
// l3Egress: an interface/direct egress is a DIRECT outbound (the one proxy
|
||||
// outbound in the shipped registry that builds a ping.Port), byedpi is SOCKS, and
|
||||
// any other type emits no outbound at all — so it never reaches this diagnosis.
|
||||
// outbound in the shipped registry that builds a ping.Port), and any other type
|
||||
// emits no outbound at all — so it never reaches this diagnosis.
|
||||
func (b *builder) l3Egress(name string) l3Verdict {
|
||||
for _, eg := range b.m.Egresses {
|
||||
if eg.Name != name {
|
||||
@@ -796,9 +795,14 @@ const defaultSpoofSNI = "www.google.com"
|
||||
// recordEgressDPI normalizes an egress's D13 DPI preset and, when it is a
|
||||
// supported native value, records it under the egress's outbound tag so
|
||||
// buildRoute can stamp the matching route-action flag on rules routed to it.
|
||||
// Empty/off => no DPI; an unknown value warns and is treated as off. The
|
||||
// external "byedpi" preset is reserved for Phase-2b and intentionally NOT
|
||||
// treated as a native flag here.
|
||||
// Empty/off => no DPI; an unknown value warns and is treated as off.
|
||||
//
|
||||
// The accepted list is CLOSED and POSITIVE, and it is now the whole set: the
|
||||
// "byedpi" value, which named an external desync process and was never a native
|
||||
// flag (it warned and was treated as off), is gone with the process. It reaches
|
||||
// the default arm, which quotes it back and names what this build does accept —
|
||||
// so a config still carrying it is told what happened to it rather than silently
|
||||
// running with no desync at all.
|
||||
func (b *builder) recordEgressDPI(name, dpi string) {
|
||||
tag := netplane.EgressOutboundTag(name)
|
||||
switch d := strings.ToLower(strings.TrimSpace(dpi)); d {
|
||||
@@ -806,10 +810,8 @@ func (b *builder) recordEgressDPI(name, dpi string) {
|
||||
// no desync
|
||||
case "fragment", "record", "spoof":
|
||||
b.egressDPI[tag] = d
|
||||
case "byedpi":
|
||||
b.warnf("egress %q: dpi=byedpi is a Phase-2b external egress (D13), not a native flag; treating as off", name)
|
||||
default:
|
||||
b.warnf("egress %q: unknown dpi %q, treating as off", name, dpi)
|
||||
b.warnf("egress %q: unknown dpi %q, treating as off — NO desync is applied to anything routed here. The presets this build has are off, fragment, record and spoof", name, dpi)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,14 @@ import (
|
||||
|
||||
// icmpModel builds a model carrying one of everything the L3 verdict has to tell
|
||||
// apart: a WireGuard node (an ENDPOINT — carries layer 3), a Shadowsocks node (a
|
||||
// proxy outbound — cannot), an interface egress (a direct outbound — carries), a
|
||||
// byedpi egress (SOCKS — cannot), three groups spanning all-proxy/all-L3/mixed,
|
||||
// and two chains that differ only in which kind of node they EXIT through.
|
||||
// proxy outbound — cannot), an interface egress (a direct outbound — carries),
|
||||
// three groups spanning all-proxy/all-L3/mixed, and two chains that differ only
|
||||
// in which kind of node they EXIT through.
|
||||
//
|
||||
// There is no L4-only EGRESS here any more: the one kind that was one (`byedpi`,
|
||||
// a SOCKS hop) is retired, and every surviving kind is a direct outbound that
|
||||
// does carry layer 3. The L4 side of the verdict is therefore proved by the
|
||||
// nodes, the groups and the chains, which is where it was always decided anyway.
|
||||
func icmpModel(l3Tunnel bool, rules ...model.Rule) *model.Model {
|
||||
g := model.DefaultGlobals()
|
||||
g.DNSIntercept = false // not the subject; keeps the DNS diagnostics out
|
||||
@@ -49,7 +54,6 @@ func icmpModel(l3Tunnel bool, rules ...model.Rule) *model.Model {
|
||||
},
|
||||
Egresses: []model.Egress{
|
||||
{Name: "wan2", Type: "interface", Interface: "wan2"},
|
||||
{Name: "bd", Type: "byedpi", Port: 1080},
|
||||
},
|
||||
Groups: []model.Group{
|
||||
{Name: "proxies", Source: "manual", Nodes: []string{"ss1"}},
|
||||
@@ -254,7 +258,6 @@ func TestProtoICMPToAnL4TargetIsReported(t *testing.T) {
|
||||
for _, target := range []string{
|
||||
"node:ss1", // a proxy outbound
|
||||
"group:proxies", // a group of nothing but proxy outbounds
|
||||
"egress:bd", // byedpi: a SOCKS hop, which cannot implement tun.Port
|
||||
"chain:exit-proxy", // the rule enters at the LAST hop, and that one is ss1
|
||||
} {
|
||||
_, warns := oneICMPRule(t, true, icmpRule("ping", "icmp", target))
|
||||
@@ -303,11 +306,11 @@ func TestProtoICMPToAMixedGroupIsReported(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestProtoICMPDiagnosticsAreQuietForNonICMPRules: none of the above may fire on
|
||||
// the tcp/udp/sniffed rules that make up every existing config. A byedpi egress
|
||||
// the tcp/udp/sniffed rules that make up every existing config. An egress
|
||||
// carrying TCP is exactly right, and saying otherwise would flood the panel.
|
||||
func TestProtoICMPDiagnosticsAreQuietForNonICMPRules(t *testing.T) {
|
||||
_, warns := genICMP(t, true,
|
||||
icmpRule("a", "tcp", "egress:bd"),
|
||||
icmpRule("a", "tcp", "egress:wan2"),
|
||||
icmpRule("b", "udp", "group:proxies"),
|
||||
icmpRule("c", "tls", "node:ss1"),
|
||||
)
|
||||
|
||||
+79
-29
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
)
|
||||
|
||||
// routeRulesetTagPrefix tags a materialised routing rule-set. Distinct from the
|
||||
@@ -974,8 +975,8 @@ func compileDomainList(domains []string, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// remoteRuleSet builds a remote (.srs binary) rule-set fetched through the direct
|
||||
// outbound (filterFetchDetour). Shared by every url/geosite/geoip source across
|
||||
// remoteRuleSet builds a remote (.srs binary) rule-set fetched through the
|
||||
// configured fetch detour (filterFetchDetour). Shared by every url/geosite/geoip source across
|
||||
// the routing rule-sets AND the DNS-filter lists so the http_client{detour}, the
|
||||
// binary format and the update-interval handling stay identical in one place. url
|
||||
// must already be non-empty (the caller validates the source-specific inputs); an
|
||||
@@ -987,19 +988,55 @@ func compileDomainList(domains []string, path string) error {
|
||||
// to the engine. See the R5 block above for why such a rule-set would otherwise
|
||||
// prevent the engine from starting at all.
|
||||
func (b *builder) remoteRuleSet(tag, url, updateInterval, diag string) (option.RuleSet, bool) {
|
||||
// The format the engine will be given. Both callers that override it afterwards
|
||||
// derive it the same way (dnsfilter.go's url branch verbatim; the geo branches
|
||||
// always fetch .srs), so this reproduces their answer rather than guessing — and
|
||||
// it has to be right, because the cold-start check below decodes the CACHED copy
|
||||
// with exactly this format's reader. The one caller that can legitimately
|
||||
// disagree — a `url` ruleset with an explicit `format` — calls remoteRuleSetAs.
|
||||
format, native := ruleSetURLIsEngineNative(url)
|
||||
if !native {
|
||||
format = C.RuleSetFormatBinary
|
||||
}
|
||||
return b.remoteRuleSetAs(tag, url, updateInterval, diag, format)
|
||||
}
|
||||
|
||||
// remoteRuleSetAs is remoteRuleSet with the engine format stated outright, for the
|
||||
// one caller whose config can override what the URL extension implies.
|
||||
func (b *builder) remoteRuleSetAs(tag, url, updateInterval, diag, format string) (option.RuleSet, bool) {
|
||||
// Resolved BEFORE the preflight, not at the struct literal below, and the order
|
||||
// is the whole point: a mistyped fetch_detour must be reported even on the
|
||||
// reconciles where every list is omitted for being unreachable. Otherwise the
|
||||
// one warning that explains WHY the downloads fail would be suppressed by the
|
||||
// failure it explains — visible only once the problem had already fixed itself.
|
||||
// filterFetchDetour reports at most once per generate pass (dnsfilter.go).
|
||||
detour := b.filterFetchDetour()
|
||||
if v := remoteRuleSetUsable(url); !v.ok {
|
||||
// Requirement: an operator must be able to tell "configured but NOT APPLIED
|
||||
// right now" from "not configured at all". The omitted set produces no row in
|
||||
// GET /api/ruleset/status (that endpoint projects the engine's ACTIVE
|
||||
// rule-sets), so this warning is currently the only signal — hence the stable,
|
||||
// greppable RULESET-NOT-APPLIED prefix, and hence it names the list, the tag
|
||||
// and the reason. See the panel follow-up noted in the audit report.
|
||||
b.warnf("RULESET-NOT-APPLIED: %s is configured but NOT ACTIVE: %s, "+
|
||||
"so rule-set %q was omitted and matches NOTHING until it loads (a blocklist blocks nothing; a routing rule is skipped). "+
|
||||
"Handing an unusable list to the engine would abort engine start and take the LAN down instead. "+
|
||||
"Retried automatically on the next reconcile (~1 min) — no action needed unless this persists.",
|
||||
diag, v.reason, tag)
|
||||
return option.RuleSet{}, false
|
||||
// COLD START. Unreachable is not the same as unusable: if sing-box's cache
|
||||
// already holds a copy this build can load, StartContext will never touch the
|
||||
// network for this list, so handing it over cannot fail box.Start — the whole
|
||||
// reason the preflight exists. See coldstart_cache.go for what is proven.
|
||||
if cached, ok := cachedRuleSetUsable(tag, format); ok {
|
||||
b.warnf("RULESET-FROM-CACHE: %s could not be checked against its source (%s), "+
|
||||
"but the cache holds a copy of rule-set %q that the engine can load (fetched %s), so it is APPLIED FROM THE CACHE — "+
|
||||
"this is what lets a reboot with no working WAN still come up with its lists. "+
|
||||
"That copy will not change until the source is reachable again, so the list is as old as the timestamp says.",
|
||||
diag, v.reason, tag, cached.lastUpdated.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
// Requirement: an operator must be able to tell "configured but NOT APPLIED
|
||||
// right now" from "not configured at all". The omitted set produces no row in
|
||||
// GET /api/ruleset/status (that endpoint projects the engine's ACTIVE
|
||||
// rule-sets), so this warning is currently the only signal — hence the stable,
|
||||
// greppable RULESET-NOT-APPLIED prefix, and hence it names the list, the tag
|
||||
// and the reason. See the panel follow-up noted in the audit report.
|
||||
b.warnf("RULESET-NOT-APPLIED: %s is configured but NOT ACTIVE: %s, "+
|
||||
"so rule-set %q was omitted and matches NOTHING until it loads (a blocklist blocks nothing; a routing rule is skipped). "+
|
||||
"There is no usable copy in the cache either, so there is nothing to fall back to. "+
|
||||
"Handing an unusable list to the engine would abort engine start and take the LAN down instead. "+
|
||||
"Retried automatically on the next reconcile (~1 min) — no action needed unless this persists.",
|
||||
diag, v.reason, tag)
|
||||
return option.RuleSet{}, false
|
||||
}
|
||||
}
|
||||
iv := strings.TrimSpace(updateInterval)
|
||||
if iv == "" {
|
||||
@@ -1013,17 +1050,24 @@ func (b *builder) remoteRuleSet(tag, url, updateInterval, diag string) (option.R
|
||||
return option.RuleSet{
|
||||
Type: C.RuleSetTypeRemote,
|
||||
Tag: tag,
|
||||
// Convention: remote lists are compiled .srs (binary), fetched through the
|
||||
// always-present direct outbound (never the proxy/kill-switch) — a list
|
||||
// needed to route the proxy must not depend on the proxy.
|
||||
Format: C.RuleSetFormatBinary,
|
||||
// Convention: remote lists are compiled .srs (binary) unless the URL says
|
||||
// otherwise, fetched through the always-present direct outbound (never the
|
||||
// proxy/kill-switch) — a list needed to route the proxy must not depend on
|
||||
// the proxy. Stating the format here rather than leaving it to the caller to
|
||||
// patch afterwards is what lets the cold-start check above decode the cached
|
||||
// copy with the reader the engine will actually use.
|
||||
Format: format,
|
||||
RemoteOptions: option.RemoteRuleSet{
|
||||
URL: url,
|
||||
UpdateInterval: dur,
|
||||
// http_client{detour} — NOT the deprecated download_detour, which makes
|
||||
// box.New emit a deprecation notice.
|
||||
// box.New emit a deprecation notice. The value is the CONFIGURED fetch
|
||||
// detour (globals.fetch_detour, overridable per WAN profile), resolved and
|
||||
// validated by filterFetchDetour (dnsfilter.go); it is `direct` when
|
||||
// nothing is configured, which is what this field held unconditionally
|
||||
// before the option existed.
|
||||
HTTPClient: &option.HTTPClientOptions{
|
||||
DialerOptions: option.DialerOptions{Detour: filterFetchDetour},
|
||||
DialerOptions: option.DialerOptions{Detour: detour},
|
||||
},
|
||||
},
|
||||
}, true
|
||||
@@ -1224,13 +1268,15 @@ func (b *builder) buildRoutingRuleSetRaw(rs model.Ruleset) ([]option.RuleSet, []
|
||||
// The URL serves something the engine can parse itself, so it stays REMOTE
|
||||
// and sing-box keeps owning fetch/cache/update/status.
|
||||
b.warnRuleSetTypeIgnored(diag, "url", rs.Type)
|
||||
set, ok := b.remoteRuleSet(tag, rs.URL, rs.UpdateInterval, diag)
|
||||
// Previously hard-coded to binary, which made a .json rule-set URL fail its
|
||||
// initial fetch — and a failed initial fetch aborts engine start (R5). The
|
||||
// format is handed DOWN rather than patched onto the result, so the
|
||||
// cold-start cache check reads the cached copy with the same reader the
|
||||
// engine will (an explicit `format` here can disagree with the extension).
|
||||
set, ok := b.remoteRuleSetAs(tag, rs.URL, rs.UpdateInterval, diag, format)
|
||||
if !ok {
|
||||
return nil, nil, false // unreachable; warned, retried next reconcile
|
||||
}
|
||||
// Previously hard-coded to binary, which made a .json rule-set URL fail its
|
||||
// initial fetch — and a failed initial fetch aborts engine start (R5).
|
||||
set.Format = format
|
||||
return []option.RuleSet{set}, []string{tag}, true
|
||||
}
|
||||
// Not engine-native: a plain-text list (hosts file / one domain per line),
|
||||
@@ -1424,12 +1470,16 @@ func (b *builder) peelDomainRegexes(diag string, entries []string) (rest, regexe
|
||||
// "ipcidr" is the model's spelling; "ip_cidr" mirrors the engine's field name and
|
||||
// "ip" is the shorthand. All three are exact synonyms. Everything else (including
|
||||
// the empty default) means a DOMAIN list.
|
||||
//
|
||||
// The vocabulary itself lives in netplane, which needs the identical answer to
|
||||
// decide whether a rule-set can name a private DESTINATION the data plane would
|
||||
// otherwise bypass (netplane.privateRoutedPlan). Two packages asking the same
|
||||
// question of the same field must not each carry their own list of spellings —
|
||||
// that is how "ip" ends up accepted on one side and not the other, and the
|
||||
// symptom is a rule that silently matches nothing. This is a delegation, not a
|
||||
// copy.
|
||||
func ruleSetTypeIsIPCIDR(rsType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(rsType)) {
|
||||
case "ipcidr", "ip_cidr", "ip":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return netplane.IsIPCIDRRulesetType(rsType)
|
||||
}
|
||||
|
||||
// warnUnknownRuleSetType reports a Ruleset.Type that is neither a domain nor an
|
||||
|
||||
@@ -157,12 +157,17 @@ func indexOutbounds(opts option.Options) outboundIndex {
|
||||
}
|
||||
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.
|
||||
// A proxy that dials this very router: a local helper, which may reshape
|
||||
// the packets, but whose own connection still leaves over the plain WAN
|
||||
// with this router's address. Calling that "going through the tunnel"
|
||||
// would be a smaller version of the same lie this file exists to remove.
|
||||
//
|
||||
// This generator emits no such outbound today — the one that did, the
|
||||
// retired `byedpi` SOCKS egress, is gone with the external process. The
|
||||
// classification stays because deleting it is the unrecoverable
|
||||
// direction: the next local helper wired in here would land in the arm
|
||||
// below and be reported as a TUNNEL, which is the answer that gets
|
||||
// somebody hurt. dialsLoopback has its own direct test.
|
||||
idx.kind[ob.Tag] = kindDirect
|
||||
} else {
|
||||
idx.kind[ob.Tag] = kindProxy
|
||||
@@ -230,9 +235,10 @@ func ruleRouteOutbound(r option.Rule) (string, bool) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Only the two protocols a local helper is ever wired as are inspected — SOCKS
|
||||
// and HTTP — so the answer does not depend on which of the two such a helper
|
||||
// picks. Nothing this generator builds is one of them at present; see the call
|
||||
// site for why the test is kept anyway.
|
||||
func dialsLoopback(o any) bool {
|
||||
var server string
|
||||
switch v := o.(type) {
|
||||
|
||||
@@ -161,20 +161,47 @@ func TestTrafficEgressDefaultIsNotATunnel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
},
|
||||
// A proxy outbound that dials THIS ROUTER is a local helper: it may reshape the
|
||||
// packets, but the connection still leaves over the plain WAN with this router's
|
||||
// own address, so it must be reported DIRECT and never as a tunnel.
|
||||
//
|
||||
// The options are built by hand rather than generated, and that is the point.
|
||||
// The generator used to emit exactly one such outbound — the retired `byedpi`
|
||||
// SOCKS egress — and with it gone there is no model that produces one any more.
|
||||
// Deleting the classification along with its only producer was the tempting move
|
||||
// and the wrong one: the next local helper wired in here would fall through to
|
||||
// the proxy arm and be reported as a TUNNEL, which is the answer that gets
|
||||
// somebody hurt. So the classifier stays, and this is the instrument that keeps
|
||||
// it honest — see the guard's own comment in traffic.go.
|
||||
func TestTrafficLoopbackProxyIsNotATunnel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
server string
|
||||
want string
|
||||
}{
|
||||
{"loopback v4", "127.0.0.1", VerdictDirect},
|
||||
{"loopback v6", "::1", VerdictDirect},
|
||||
{"localhost by name", "localhost", VerdictDirect},
|
||||
// The CONTROL. Without it the test would pass on a dialsLoopback that
|
||||
// answered true for everything, which would report every proxy on the router
|
||||
// as unprotected — the same lie pointing the other way.
|
||||
{"a real remote server", "203.0.113.9", VerdictTunnel},
|
||||
}
|
||||
got := trafficOf(t, m)
|
||||
if got.Verdict != VerdictDirect {
|
||||
t.Fatalf("Verdict = %q, want %q; default=%q", got.Verdict, VerdictDirect, got.Default)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := option.Options{
|
||||
Outbounds: []option.Outbound{{
|
||||
Type: C.TypeSOCKS, Tag: "helper",
|
||||
Options: &option.SOCKSOutboundOptions{
|
||||
ServerOptions: option.ServerOptions{Server: tc.server, ServerPort: 1080},
|
||||
},
|
||||
}},
|
||||
Route: &option.RouteOptions{Final: "helper"},
|
||||
}
|
||||
if got := TrafficOf(opts); got.Verdict != tc.want {
|
||||
t.Fatalf("a SOCKS outbound dialling %q reports %q, want %q", tc.server, got.Verdict, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-4
@@ -9,6 +9,7 @@ import (
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
"github.com/sagernet/sing-box/shater/netplane"
|
||||
"github.com/sagernet/sing-box/shater/parse"
|
||||
)
|
||||
@@ -292,6 +293,7 @@ func (b *builder) dedupWireGuardEndpoints(opts *option.Options) {
|
||||
// the plain WAN with the router's real address.
|
||||
remapTags(opts, replace)
|
||||
removeRemappedEndpoints(opts, replace)
|
||||
b.unblockRuleSetDownloads(opts)
|
||||
|
||||
// Warn LAST, against the config that actually resulted. Whether losing a copy
|
||||
// takes the default route down is not decidable from the copy alone — the default
|
||||
@@ -306,6 +308,54 @@ func (b *builder) dedupWireGuardEndpoints(opts *option.Options) {
|
||||
}
|
||||
}
|
||||
|
||||
// unblockRuleSetDownloads is the ONE place `block` is the wrong fail-closed answer,
|
||||
// and it runs after the rewrite because only then is it known which downloads got
|
||||
// pointed at it.
|
||||
//
|
||||
// Everywhere else in this pass a reference whose tunnel was deleted is sent to
|
||||
// `block`: traffic that lost its tunnel must stop, not fall out onto the plain WAN.
|
||||
// A remote rule-set DOWNLOAD is not traffic. RemoteRuleSet.StartContext fetches an
|
||||
// uncached list at engine start and returns an error when the fetch fails, the
|
||||
// router starts its rule-sets with FastFail, so that error is box.Start failing —
|
||||
// and with the kill switch closed a failed start is the entire LAN offline (the R5
|
||||
// block in ruleset.go is about nothing else). Dialling a download through `block`
|
||||
// makes that failure CERTAIN.
|
||||
//
|
||||
// `direct` is not a guess at a better route, it is the one path already measured:
|
||||
// the list is only in this config because remoteRuleSetUsable fetched its header
|
||||
// over a plain DIRECT client and got an answer (or because the cold-start cache
|
||||
// already holds a loadable copy, in which case start does not fetch at all). So the
|
||||
// fallback is the path the preflight validated, which is what makes it safe to
|
||||
// promise the engine will still start.
|
||||
//
|
||||
// It is loud, because it IS a disclosure: the operator set fetch_detour precisely so
|
||||
// these downloads would not carry this router's address, and they now do.
|
||||
func (b *builder) unblockRuleSetDownloads(opts *option.Options) {
|
||||
if opts.Route == nil {
|
||||
return
|
||||
}
|
||||
for i := range opts.Route.RuleSet {
|
||||
rs := &opts.Route.RuleSet[i]
|
||||
if rs.Type != C.RuleSetTypeRemote || rs.RemoteOptions.HTTPClient == nil {
|
||||
continue
|
||||
}
|
||||
// `block` can only be here because the rewrite above put it here:
|
||||
// filterFetchDetour (dnsfilter.go) refuses a configured `block` outright.
|
||||
if !strings.EqualFold(rs.RemoteOptions.HTTPClient.Detour, tagBlock) {
|
||||
continue
|
||||
}
|
||||
rs.RemoteOptions.HTTPClient.Detour = tagDirect
|
||||
b.warnf("%s: rule-set %q was configured to download through the fetch detour, but the "+
|
||||
"outbound that detour resolved to was just removed as a duplicate WireGuard device (see the "+
|
||||
"warning above). Its download is going out %q instead — over the plain WAN, with this "+
|
||||
"router's real address, which is the disclosure fetch_detour is set to prevent. It is NOT "+
|
||||
"pointed at %q, because a remote rule-set whose first fetch fails aborts engine start, and "+
|
||||
"with a closed kill switch that takes the whole LAN down. Fix the WireGuard duplicate named "+
|
||||
"above, or point fetch_detour at a node that is not also materialised twice.",
|
||||
fetchDetourNotAppliedTag, rs.Tag, tagDirect, tagBlock)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRouteBlocked reports whether the default route — route.Final, where every
|
||||
// packet that matched no rule goes — now ends at `block`, i.e. whether the LAN has
|
||||
// lost its way out. A group counts as usable while ANY member is usable, which is
|
||||
@@ -627,19 +677,54 @@ func optionTagWeights(opts *option.Options, extraSeeds ...string) map[string]tag
|
||||
// by name and never checks it, so the panel can fetch a disabled one and the detour
|
||||
// must resolve when it does.
|
||||
//
|
||||
// # Three states, and why this asks the model instead of testing for "proxy"
|
||||
//
|
||||
// This used to be `EqualFold(FetchVia, "proxy")` — which was the whole truth while
|
||||
// `fetch_via` had two states. Under schema v3 it has three, and the third is the
|
||||
// one that produces a reference here: `fetch_via` ABSENT means the GENERAL fetch
|
||||
// detour applies (globals.fetch_detour, or the active profile's override). A
|
||||
// subscription that says nothing at all is therefore pulled through
|
||||
// `globals.fetch_detour=node:<wg>` — a live reference to that node's BASE endpoint,
|
||||
// resolved at RUNTIME against the running box, and one this pass cannot rewrite.
|
||||
// The old test answered "direct, no reference" for it, so the base endpoint looked
|
||||
// unused, was merged away into the node's chain copy, and the very next
|
||||
// subscription refresh failed with "unknown outbound tag".
|
||||
//
|
||||
// model.SubscriptionFetchDetour is asked rather than the rule re-implemented,
|
||||
// because it is the SAME function apply.(*Applier).UpdateSubscription calls to pick
|
||||
// the detour it dials. Answering the question with the runtime's own resolver is
|
||||
// what makes the seed provably the tag the runtime will look up, instead of a
|
||||
// fourth copy of a table that has already drifted once. ok=false is an
|
||||
// unrecognised `fetch_via`: apply refuses to fetch such a subscription at all, so
|
||||
// it references nothing and seeds nothing.
|
||||
//
|
||||
// Non-node forms come along for free rather than being filtered out: one mapping
|
||||
// (viaOutboundTag) covers them all, and seeding `egress-<x>` or a group tag costs
|
||||
// nothing — a tag that does not exist is ignored by the walk. Filtering would be
|
||||
// extra code that could only make the result less correct (a WG node that is a
|
||||
// member of a group used ONLY as a fetch detour would lose its endpoint).
|
||||
// member of a group used ONLY as a fetch detour would lose its endpoint). `direct`
|
||||
// is the one form dropped, and only because it can keep nothing alive: it is always
|
||||
// emitted and reaches nothing this pass could delete.
|
||||
func (b *builder) subscriptionDetourSeeds() []string {
|
||||
// The active profile decides the general detour. Resolved with warnings
|
||||
// suppressed for the same reason filterFetchDetour does it: buildRoute has
|
||||
// already reported them once and a duplicate paragraph per subscription is not
|
||||
// a diagnostic.
|
||||
var prof *model.Profile
|
||||
b.withoutNewWarnings(func() { prof = b.resolveActiveProfile() })
|
||||
|
||||
var seeds []string
|
||||
for i := range b.m.Subscriptions {
|
||||
sub := b.m.Subscriptions[i]
|
||||
if !strings.EqualFold(strings.TrimSpace(sub.FetchVia), "proxy") {
|
||||
continue // a direct fetch dials no outbound at all
|
||||
ov, ok := model.SubscriptionFetchDetour(sub, b.m.Globals, prof)
|
||||
if !ok {
|
||||
continue // not a decision; apply refuses to fetch it rather than guess
|
||||
}
|
||||
seeds = append(seeds, viaOutboundTag(sub.FetchDetour))
|
||||
tag := viaOutboundTag(ov.Value)
|
||||
if tag == tagDirect {
|
||||
continue // a direct fetch dials no configured outbound at all
|
||||
}
|
||||
seeds = append(seeds, tag)
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
@@ -734,6 +819,7 @@ func optionSeeds(opts *option.Options) []optionSeed {
|
||||
for i := range rt.RuleSet {
|
||||
if rt.RuleSet[i].Type == C.RuleSetTypeRemote {
|
||||
add(rt.RuleSet[i].RemoteOptions.DownloadDetour)
|
||||
add(remoteRuleSetHTTPDetour(rt.RuleSet[i]))
|
||||
}
|
||||
}
|
||||
if rt.GeoIP != nil {
|
||||
@@ -774,6 +860,45 @@ func walkOptionReachable(tag string, graph map[string]optionNode, reachable map[
|
||||
walkOptionReachable(node.detour, graph, reachable)
|
||||
}
|
||||
|
||||
// remoteRuleSetHTTPDetour returns the outbound tag a remote rule-set downloads
|
||||
// through under the MODERN option — `http_client{detour}` — or "" when it carries
|
||||
// none.
|
||||
//
|
||||
// It is a second reader beside RemoteOptions.DownloadDetour rather than a
|
||||
// replacement for it because the two are different fields of the same struct and
|
||||
// this pass has to see whichever one the generator wrote. It wrote the deprecated
|
||||
// one never and the modern one always: ruleset.go remoteRuleSetAs emits
|
||||
// `HTTPClient: &option.HTTPClientOptions{DialerOptions{Detour: detour}}` and leaves
|
||||
// DownloadDetour empty — dnsfilter_test.go pins exactly that. So while this file
|
||||
// read only DownloadDetour it was reading the field that is always "", i.e. it saw
|
||||
// NO rule-set download reference at all.
|
||||
//
|
||||
// That was inert for as long as the value was the hard-wired constant `direct`: a
|
||||
// seed naming `direct` reaches nothing this pass can delete. It stopped being inert
|
||||
// the moment the detour became configurable (globals.fetch_detour / the profile
|
||||
// override). `fetch_detour=node:<wg>` makes the node's BASE endpoint load-bearing;
|
||||
// unseen, it looked unreferenced, was deleted as a duplicate of the node's chain
|
||||
// copy, and the rule-set was left pointing at a tag that no longer exists.
|
||||
// box.Start then fails on the first fetch with "outbound detour not found", and
|
||||
// with the kill switch closed a failed start is the whole LAN offline. See
|
||||
// TestFetchDetourWGNodeStaysConnectedAfterDedup.
|
||||
func remoteRuleSetHTTPDetour(rs option.RuleSet) string {
|
||||
if rs.RemoteOptions.HTTPClient == nil {
|
||||
return ""
|
||||
}
|
||||
return rs.RemoteOptions.HTTPClient.Detour
|
||||
}
|
||||
|
||||
// remapRemoteRuleSetHTTPDetour is the write half of remoteRuleSetHTTPDetour and
|
||||
// must stay in step with it: a reference the seed walk counted is a reference this
|
||||
// pass has to be able to repoint, or the merge it just made leaves a dangling tag.
|
||||
func remapRemoteRuleSetHTTPDetour(rs *option.RuleSet, replace map[string]string) {
|
||||
if rs.RemoteOptions.HTTPClient == nil {
|
||||
return
|
||||
}
|
||||
remapField(&rs.RemoteOptions.HTTPClient.Detour, replace)
|
||||
}
|
||||
|
||||
// ruleActionOutbound returns the outbound tag a route rule's action sends traffic
|
||||
// to, or "" for the actions that route nowhere (reject/sniff/dns/hijack-dns/…).
|
||||
// An empty Action is the route action (see option.RuleAction.UnmarshalJSON), so it
|
||||
@@ -860,6 +985,7 @@ func remapTags(opts *option.Options, replace map[string]string) {
|
||||
for i := range rt.RuleSet {
|
||||
if rt.RuleSet[i].Type == C.RuleSetTypeRemote {
|
||||
remapField(&rt.RuleSet[i].RemoteOptions.DownloadDetour, replace)
|
||||
remapRemoteRuleSetHTTPDetour(&rt.RuleSet[i], replace)
|
||||
}
|
||||
}
|
||||
if rt.GeoIP != nil {
|
||||
|
||||
@@ -36,4 +36,3 @@ func ParseDuration(s string) (time.Duration, bool) {
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
|
||||
@@ -35,4 +35,3 @@ func TestParseDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package model
|
||||
|
||||
// The DNS/fetch overrides a profile may carry, and the one place their
|
||||
// inheritance is resolved.
|
||||
//
|
||||
// This lives in model for the same reason profile.go and schedule.go do: the two
|
||||
// packages that must agree — generate (which builds the engine's DNS servers and
|
||||
// the download_detour of every remote rule-set) and apply (which builds the
|
||||
// HTTP client a subscription fetch dials through) — cannot share a resolver any
|
||||
// other way without one importing the other. While each side derived "what is in
|
||||
// force" for itself, the panel derived a THIRD answer, and it was the wrong one:
|
||||
// on the production router it drew `endpoint_resolver = local` out of globals
|
||||
// while the engine, under the active profile, was using `yandex`.
|
||||
//
|
||||
// Everything here is SYNTACTIC. Whether a named resolver or a named detour
|
||||
// target actually exists is the generator's verdict — model is a stdlib-only
|
||||
// leaf and knows nothing about emitted tags.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FetchViaMode classifies a subscription's `fetch_via`. The set is CLOSED and
|
||||
// POSITIVE and there is no "everything else" member that behaves like one of the
|
||||
// real ones: an unrecognised value gets FetchViaUnknown, its own branch, so a
|
||||
// caller cannot silently treat a typo as a decision. That matters here more than
|
||||
// in most places, because the value it used to be silently treated as was
|
||||
// `direct` — a fetch that goes out over the plain WAN with this router's real
|
||||
// address, and succeeds, so nothing about the outcome reveals the typo.
|
||||
type FetchViaMode int
|
||||
|
||||
const (
|
||||
// FetchViaInherit is `fetch_via` ABSENT (""): this subscription expresses no
|
||||
// opinion and the general fetch detour (globals, or the active profile's
|
||||
// override) applies.
|
||||
FetchViaInherit FetchViaMode = iota
|
||||
// FetchViaDirect is an explicit `direct`: fetch this feed in the clear no
|
||||
// matter what the general setting says.
|
||||
FetchViaDirect
|
||||
// FetchViaProxy is an explicit `proxy`: fetch this feed through the
|
||||
// subscription's OWN FetchDetour.
|
||||
FetchViaProxy
|
||||
// FetchViaUnknown is anything else. It is not a mode; it is a config defect
|
||||
// that must be named (ValidateSubscriptions does) and must not be quietly
|
||||
// mapped onto one of the three above.
|
||||
FetchViaUnknown
|
||||
)
|
||||
|
||||
// FetchVia spellings. These are the WHOLE accepted vocabulary of the option.
|
||||
const (
|
||||
FetchViaValueDirect = "direct"
|
||||
FetchViaValueProxy = "proxy"
|
||||
)
|
||||
|
||||
// FetchViaNames is the closed, positive list of values `option fetch_via` accepts
|
||||
// (absent is the third state and has no spelling). Used by ValidateSubscriptions
|
||||
// to say what the valid values ARE rather than only that this one is not.
|
||||
var FetchViaNames = []string{FetchViaValueDirect, FetchViaValueProxy}
|
||||
|
||||
// ClassifyFetchVia maps a raw `fetch_via` value to its mode. Case- and
|
||||
// space-insensitive, matching the comparisons the rest of the tree already makes
|
||||
// (apply.subscriptionFetchWarnings, cmd/shaterd.subFetchViaProxy).
|
||||
func ClassifyFetchVia(v string) FetchViaMode {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "":
|
||||
return FetchViaInherit
|
||||
case FetchViaValueDirect:
|
||||
return FetchViaDirect
|
||||
case FetchViaValueProxy:
|
||||
return FetchViaProxy
|
||||
default:
|
||||
return FetchViaUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// Override is one resolved override: the value in force plus WHERE it came from.
|
||||
//
|
||||
// From exists because the panel has to print it. `Endpoint resolver (globals)`
|
||||
// showing `local` next to a line reading "in force right now: yandex (profile
|
||||
// mobile-uplink)" is the shape the owner asked for, and it needs both halves
|
||||
// from one call — a panel that recomputes the winner itself is how the three
|
||||
// answers diverged in the first place.
|
||||
type Override struct {
|
||||
// Value is what is in force. "" means nothing is set anywhere, which is a
|
||||
// legitimate answer (no resolver override, no fetch detour) and not an error.
|
||||
Value string
|
||||
// From names the section the value came from: FromGlobals, or "profile:<name>".
|
||||
// It is FromGlobals when nothing is set anywhere — globals is where an
|
||||
// unset value would be written.
|
||||
From string
|
||||
}
|
||||
|
||||
// FromGlobals is Override.From when the globals value (or the absence of any
|
||||
// value) is what is in force.
|
||||
const FromGlobals = "globals"
|
||||
|
||||
// The prefixes Override.From uses for the two sections that can supply a value.
|
||||
// Exported because a consumer that wants the NAME back out — the panel prints
|
||||
// "(profile mobile-uplink)" — would otherwise have to hard-code the separator or
|
||||
// carry the *Profile alongside, and a hard-coded ":" in three files is how the
|
||||
// two sides drift apart. Prefer the accessors below to slicing the string.
|
||||
const (
|
||||
FromProfilePrefix = "profile:"
|
||||
FromSubscriptionPrefix = "subscription:"
|
||||
)
|
||||
|
||||
// fromProfile renders Override.From for a profile-supplied value.
|
||||
func fromProfile(name string) string { return FromProfilePrefix + name }
|
||||
|
||||
// ProfileName returns the profile that supplied this value, and whether one did.
|
||||
// ok=false means the value came from globals (or from a subscription).
|
||||
func (o Override) ProfileName() (string, bool) {
|
||||
return strings.CutPrefix(o.From, FromProfilePrefix)
|
||||
}
|
||||
|
||||
// SubscriptionName returns the subscription that supplied this value, and
|
||||
// whether one did.
|
||||
func (o Override) SubscriptionName() (string, bool) {
|
||||
return strings.CutPrefix(o.From, FromSubscriptionPrefix)
|
||||
}
|
||||
|
||||
// IsFromGlobals reports whether the value in force is the globals one — i.e.
|
||||
// nothing overrode it. Named so a caller does not compare From to a literal.
|
||||
func (o Override) IsFromGlobals() bool { return o.From == FromGlobals }
|
||||
|
||||
// overrideOf is the ONE inheritance rule, applied per field: a non-blank profile
|
||||
// value wins, otherwise globals. prof may be nil (no active profile).
|
||||
//
|
||||
// Blank-not-set is deliberate and matches the renderer: strOpt omits an empty
|
||||
// value, so a profile can only ever carry "" by not having the option at all. A
|
||||
// profile therefore has no way to say "override this back to nothing", which is
|
||||
// the right trade — the alternative is an operator who cannot tell an override
|
||||
// they cleared from one they never wrote.
|
||||
func overrideOf(globalsValue, profileValue, profileName string) Override {
|
||||
if v := strings.TrimSpace(profileValue); v != "" {
|
||||
return Override{Value: v, From: fromProfile(profileName)}
|
||||
}
|
||||
return Override{Value: strings.TrimSpace(globalsValue), From: FromGlobals}
|
||||
}
|
||||
|
||||
// EffectiveResolverDefault resolves the resolver consulted FIRST: the active
|
||||
// profile's `resolver_default` when it sets one, else globals'.
|
||||
func EffectiveResolverDefault(g Globals, prof *Profile) Override {
|
||||
if prof == nil {
|
||||
return Override{Value: strings.TrimSpace(g.ResolverDefault), From: FromGlobals}
|
||||
}
|
||||
return overrideOf(g.ResolverDefault, prof.ResolverDefault, prof.Name)
|
||||
}
|
||||
|
||||
// EffectiveResolverFallback resolves the resolver consulted LAST. Independent of
|
||||
// EffectiveResolverDefault by construction: inheritance is per field, so a
|
||||
// profile that sets only the fallback keeps globals' default.
|
||||
func EffectiveResolverFallback(g Globals, prof *Profile) Override {
|
||||
if prof == nil {
|
||||
return Override{Value: strings.TrimSpace(g.ResolverFallback), From: FromGlobals}
|
||||
}
|
||||
return overrideOf(g.ResolverFallback, prof.ResolverFallback, prof.Name)
|
||||
}
|
||||
|
||||
// EffectiveEndpointResolver resolves the bootstrap resolver used for proxy SERVER
|
||||
// DOMAINS. It reads the field that already existed on both sections; it is here
|
||||
// so all three DNS scalars are answered by one mechanism instead of the profile
|
||||
// one being open-coded wherever it was needed.
|
||||
func EffectiveEndpointResolver(g Globals, prof *Profile) Override {
|
||||
if prof == nil {
|
||||
return Override{Value: strings.TrimSpace(g.EndpointResolver), From: FromGlobals}
|
||||
}
|
||||
return overrideOf(g.EndpointResolver, prof.EndpointResolver, prof.Name)
|
||||
}
|
||||
|
||||
// EffectiveFetchDetour resolves the GENERAL fetch detour — the one that applies
|
||||
// to blocklist, allowlist, ruleset, geoip and geosite downloads, and to every
|
||||
// subscription that does not override it.
|
||||
func EffectiveFetchDetour(g Globals, prof *Profile) Override {
|
||||
if prof == nil {
|
||||
return Override{Value: strings.TrimSpace(g.FetchDetour), From: FromGlobals}
|
||||
}
|
||||
return overrideOf(g.FetchDetour, prof.FetchDetour, prof.Name)
|
||||
}
|
||||
|
||||
// FromSubscription renders Override.From for a value a subscription supplied.
|
||||
func FromSubscription(name string) string { return FromSubscriptionPrefix + name }
|
||||
|
||||
// SubscriptionFetchDetour resolves where ONE subscription's feed is fetched
|
||||
// through, folding the subscription's override into the general setting:
|
||||
//
|
||||
// fetch_via absent -> the general detour (globals / active profile)
|
||||
// fetch_via=direct -> "direct", explicitly, whatever the general setting is
|
||||
// fetch_via=proxy -> this subscription's own fetch_detour
|
||||
// anything else -> FetchViaUnknown; ok=false and the caller must refuse or
|
||||
// report rather than pick a side
|
||||
//
|
||||
// ok=false is the whole reason this returns two values. There is no safe silent
|
||||
// answer for an unrecognised `fetch_via`: choosing `direct` discloses the feed URL
|
||||
// and this router's address to the provider and the ISP — the exact thing
|
||||
// `fetch_via=proxy` is set to prevent — and choosing the general detour would put
|
||||
// a feed through a tunnel the operator never asked for. Both are decisions, so
|
||||
// the model refuses to make either and hands the caller a named defect instead.
|
||||
func SubscriptionFetchDetour(s Subscription, g Globals, prof *Profile) (Override, bool) {
|
||||
switch ClassifyFetchVia(s.FetchVia) {
|
||||
case FetchViaInherit:
|
||||
return EffectiveFetchDetour(g, prof), true
|
||||
case FetchViaDirect:
|
||||
return Override{Value: TargetDirect, From: FromSubscription(s.Name)}, true
|
||||
case FetchViaProxy:
|
||||
return Override{Value: strings.TrimSpace(s.FetchDetour), From: FromSubscription(s.Name)}, true
|
||||
case FetchViaUnknown:
|
||||
return Override{}, false
|
||||
}
|
||||
// Unreachable: ClassifyFetchVia returns one of the four above, and the switch
|
||||
// covers all four by name. Present so a future member cannot fall out of a
|
||||
// function that has to return something.
|
||||
return Override{}, false
|
||||
}
|
||||
|
||||
// Fetch-detour target vocabulary. `direct` and `block` stand alone; the other
|
||||
// four are `<kind>:<name>` prefixes. This mirrors generate.resolveTarget, which
|
||||
// is the single resolution point for rule targets, device targets, DNS resolver
|
||||
// detours and egress targets — the fetch detour deliberately speaks the SAME
|
||||
// language rather than inventing a fifth one.
|
||||
const (
|
||||
TargetDirect = "direct"
|
||||
TargetBlock = "block"
|
||||
)
|
||||
|
||||
// FetchDetourKinds is the closed, positive set of `<kind>:` prefixes a fetch
|
||||
// detour may carry.
|
||||
var FetchDetourKinds = []string{"node", "group", "egress", "chain"}
|
||||
|
||||
// FetchDetourFormWarning judges the SHAPE of a fetch-detour value and returns a
|
||||
// complete operator-facing sentence when the shape is wrong, or "" when it is
|
||||
// acceptable. Existence of the named node/group/egress/chain is NOT checked here
|
||||
// — that is the generator's verdict, and model cannot see emitted tags.
|
||||
//
|
||||
// It exists because a mistyped PREFIX does not fail loudly downstream, it
|
||||
// degrades: generate.resolveTarget's last branch treats any unrecognised
|
||||
// `kind:name` as a BARE NAME and looks it up as a node, then a group. So
|
||||
// `grup:auto` does not report "no such kind"; it reports nothing at all until the
|
||||
// lookup misses, and a bare name that happens to collide with a real node would
|
||||
// have silently routed the fetch somewhere nobody chose.
|
||||
//
|
||||
// The empty value is acceptable and means "not set" — every caller of this
|
||||
// function is asking about an OPTIONAL override.
|
||||
func FetchDetourFormWarning(field, value string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(v, TargetDirect) || strings.EqualFold(v, TargetBlock) {
|
||||
return ""
|
||||
}
|
||||
kind, name := SplitTarget(v)
|
||||
if !strings.Contains(v, ":") {
|
||||
// A bare name is legal (it resolves if a NODE or GROUP carries that tag)
|
||||
// but it cannot name an egress or a chain, whose outbounds are tagged
|
||||
// `egress-X` and `chain-X-h1..hN`. Say so; do not reject it.
|
||||
return fmt.Sprintf("%s is the bare name %q. It is looked up as a NODE and then as a GROUP, "+
|
||||
"and nothing else: an egress of that name does not answer to it (its outbound is tagged "+
|
||||
"egress-%s) and neither does a chain (chain-%s-h1..hN). Write `node:%s`, `group:%s`, "+
|
||||
"`egress:%s` or `chain:%s` to say which one you mean.",
|
||||
field, v, name, name, v, v, v, v)
|
||||
}
|
||||
if !inSet(strings.ToLower(kind), FetchDetourKinds) {
|
||||
return fmt.Sprintf("%s is %q, and %q is not one of the kinds this option accepts (%s, or the "+
|
||||
"bare words %s/%s). A value with an unrecognised prefix is NOT refused downstream — it is "+
|
||||
"demoted to a bare name and looked up as a node and then a group, so it either matches "+
|
||||
"nothing at all or matches something you did not name.",
|
||||
field, v, kind, strings.Join(FetchDetourKinds, ":/")+":", TargetDirect, TargetBlock)
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Sprintf("%s is %q — the kind is there but it names nothing. Nothing is resolved and "+
|
||||
"the fetch falls back to whatever the caller does with an unresolvable detour.", field, v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package model
|
||||
|
||||
// Coverage for the profile DNS/fetch overrides and for the third state of
|
||||
// `fetch_via` — the one that did not exist before schema v3.
|
||||
//
|
||||
// The pair that matters here is (absent, "direct"). They were the SAME value in
|
||||
// the model until now, so every test below that distinguishes them is testing the
|
||||
// change itself, and every test that renders an EMPTY override is testing that
|
||||
// the distinction survives a save: an override written back as `option x ''`
|
||||
// would read as "overridden with nothing" on the next boot and blank the globals
|
||||
// value the profile was meant to leave alone.
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- the four new fields, through UCI and back ------------------------------
|
||||
|
||||
// TestNewOverridesRoundTrip: a model with ALL FOUR new options set survives
|
||||
// render -> parse byte-for-byte in the fields' own values, and each option lands
|
||||
// in the section the contract names (globals vs profile), spelled as `option`
|
||||
// and not `list`.
|
||||
func TestNewOverridesRoundTrip(t *testing.T) {
|
||||
m := &Model{
|
||||
Globals: Globals{FetchDetour: "group:sim-bypass"},
|
||||
Profiles: []Profile{{
|
||||
Name: "mobile-uplink",
|
||||
Enabled: true,
|
||||
ResolverDefault: "yandex",
|
||||
ResolverFallback: "yandex-sec",
|
||||
EndpointResolver: "yandex",
|
||||
FetchDetour: "chain:swan-bypass",
|
||||
}},
|
||||
}
|
||||
text := RenderUCIExport(m)
|
||||
|
||||
for _, want := range []string{
|
||||
"\toption fetch_detour 'group:sim-bypass'",
|
||||
"\toption resolver_default 'yandex'",
|
||||
"\toption resolver_fallback 'yandex-sec'",
|
||||
"\toption fetch_detour 'chain:swan-bypass'",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("render is missing %q — the field is in the model and not in the file, so it "+
|
||||
"cannot survive a save\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// `list` here would be silently dropped on read (uciSection keeps Options and
|
||||
// Lists in separate maps and every one of these is read with s.opt), which is
|
||||
// how a value can be in the config, visible in `uci show`, and inert.
|
||||
for _, k := range []string{"fetch_detour", "resolver_default", "resolver_fallback"} {
|
||||
if strings.Contains(text, "\tlist "+k+" ") {
|
||||
t.Fatalf("%s is rendered as a `list`; it is read as an `option` and would be dropped\n%s", k, text)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Globals.FetchDetour != "group:sim-bypass" {
|
||||
t.Fatalf("globals.fetch_detour = %q, want group:sim-bypass", got.Globals.FetchDetour)
|
||||
}
|
||||
if len(got.Profiles) != 1 {
|
||||
t.Fatalf("profiles = %+v", got.Profiles)
|
||||
}
|
||||
p := got.Profiles[0]
|
||||
if p.ResolverDefault != "yandex" || p.ResolverFallback != "yandex-sec" ||
|
||||
p.EndpointResolver != "yandex" || p.FetchDetour != "chain:swan-bypass" {
|
||||
t.Fatalf("profile overrides did not round-trip: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyOverridesLeaveNoOption is the other half, and the load-bearing one:
|
||||
// an override that is NOT set must leave no option behind. `option x ”` reads
|
||||
// back as an override whose value is the empty string, which the renderer would
|
||||
// then keep emitting — so "inherit from globals" would decay into "override with
|
||||
// nothing" on the first save, permanently.
|
||||
func TestEmptyOverridesLeaveNoOption(t *testing.T) {
|
||||
m := &Model{
|
||||
Globals: Globals{},
|
||||
Profiles: []Profile{{Name: "ethernet-uplink", Enabled: true}},
|
||||
}
|
||||
text := RenderUCIExport(m)
|
||||
for _, k := range []string{"fetch_detour", "resolver_default", "resolver_fallback", "endpoint_resolver"} {
|
||||
if strings.Contains(text, "option "+k) {
|
||||
t.Fatalf("an UNSET %s was emitted anyway; \"not set\" has become \"set to nothing\"\n%s", k, text)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Globals.FetchDetour != "" {
|
||||
t.Fatalf("globals.fetch_detour = %q, want empty", got.Globals.FetchDetour)
|
||||
}
|
||||
p := got.Profiles[0]
|
||||
if p.ResolverDefault != "" || p.ResolverFallback != "" || p.FetchDetour != "" || p.EndpointResolver != "" {
|
||||
t.Fatalf("an unset override came back non-empty: %+v", p)
|
||||
}
|
||||
// And the whole model must be identical, not merely these fields.
|
||||
if !reflect.DeepEqual(m.Profiles, got.Profiles) {
|
||||
t.Fatalf("profiles changed across render+parse:\nwant %+v\ngot %+v", m.Profiles, got.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
// --- unset vs explicit direct ------------------------------------------------
|
||||
|
||||
// TestFetchViaUnsetIsNotDirect is the change in one assertion: a subscription
|
||||
// with no `fetch_via` and a subscription with `fetch_via='direct'` must come out
|
||||
// of the parser as DIFFERENT models, and each must render back to what it was.
|
||||
//
|
||||
// Before schema v3 the parser answered "direct" to both (optOr's default), so the
|
||||
// two rows below were indistinguishable the moment the config was read — and with
|
||||
// a general fetch detour to inherit they belong in different places.
|
||||
func TestFetchViaUnsetIsNotDirect(t *testing.T) {
|
||||
const text = `package shater
|
||||
|
||||
config subscription
|
||||
option name 'inherits'
|
||||
option url 'https://p.example/a'
|
||||
|
||||
config subscription
|
||||
option name 'explicit'
|
||||
option url 'https://p.example/b'
|
||||
option fetch_via 'direct'
|
||||
`
|
||||
m, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(m.Subscriptions) != 2 {
|
||||
t.Fatalf("subs = %+v", m.Subscriptions)
|
||||
}
|
||||
if got := m.Subscriptions[0].FetchVia; got != "" {
|
||||
t.Fatalf("a subscription with NO fetch_via parsed as %q; \"not set\" and \"direct\" are "+
|
||||
"different states and the parser has merged them again", got)
|
||||
}
|
||||
if got := m.Subscriptions[1].FetchVia; got != "direct" {
|
||||
t.Fatalf("explicit fetch_via = %q, want direct", got)
|
||||
}
|
||||
if ClassifyFetchVia(m.Subscriptions[0].FetchVia) != FetchViaInherit {
|
||||
t.Fatal("an absent fetch_via must classify as FetchViaInherit")
|
||||
}
|
||||
if ClassifyFetchVia(m.Subscriptions[1].FetchVia) != FetchViaDirect {
|
||||
t.Fatal("an explicit fetch_via='direct' must classify as FetchViaDirect")
|
||||
}
|
||||
|
||||
// Both states must survive the save, which is where an over-eager renderer
|
||||
// would put the difference back.
|
||||
back, err := ParseUCIExport(RenderUCIExport(m))
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if back.Subscriptions[0].FetchVia != "" || back.Subscriptions[1].FetchVia != "direct" {
|
||||
t.Fatalf("the unset/direct distinction did not survive render+parse: %q / %q",
|
||||
back.Subscriptions[0].FetchVia, back.Subscriptions[1].FetchVia)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscriptionFetchDetourResolution pins the whole override table in one
|
||||
// place: which of (subscription, profile, globals) supplies the answer, and
|
||||
// where the answer says it came from.
|
||||
func TestSubscriptionFetchDetourResolution(t *testing.T) {
|
||||
g := Globals{FetchDetour: "group:general"}
|
||||
prof := &Profile{Name: "mobile-uplink", FetchDetour: "group:sim-bypass"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sub Subscription
|
||||
prof *Profile
|
||||
wantValue string
|
||||
wantFrom string
|
||||
wantResolv bool
|
||||
}{
|
||||
{
|
||||
name: "unset inherits globals",
|
||||
sub: Subscription{Name: "qomar"},
|
||||
wantValue: "group:general",
|
||||
wantFrom: FromGlobals,
|
||||
wantResolv: true,
|
||||
},
|
||||
{
|
||||
name: "unset inherits the ACTIVE PROFILE over globals",
|
||||
sub: Subscription{Name: "qomar"},
|
||||
prof: prof,
|
||||
wantValue: "group:sim-bypass",
|
||||
wantFrom: "profile:mobile-uplink",
|
||||
wantResolv: true,
|
||||
},
|
||||
{
|
||||
name: "explicit direct beats the general setting",
|
||||
sub: Subscription{Name: "qomar", FetchVia: "direct"},
|
||||
prof: prof,
|
||||
wantValue: "direct",
|
||||
wantFrom: "subscription:qomar",
|
||||
wantResolv: true,
|
||||
},
|
||||
{
|
||||
name: "explicit proxy uses the subscription's own detour",
|
||||
sub: Subscription{Name: "qomar", FetchVia: "proxy", FetchDetour: "node:tokyo"},
|
||||
prof: prof,
|
||||
wantValue: "node:tokyo",
|
||||
wantFrom: "subscription:qomar",
|
||||
wantResolv: true,
|
||||
},
|
||||
{
|
||||
name: "an unrecognised fetch_via resolves to NOTHING",
|
||||
sub: Subscription{Name: "qomar", FetchVia: "prxoy"},
|
||||
prof: prof,
|
||||
wantResolv: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := SubscriptionFetchDetour(tc.sub, g, tc.prof)
|
||||
if ok != tc.wantResolv {
|
||||
t.Fatalf("ok = %v, want %v (got %+v)", ok, tc.wantResolv, got)
|
||||
}
|
||||
if !tc.wantResolv {
|
||||
return
|
||||
}
|
||||
if got.Value != tc.wantValue || got.From != tc.wantFrom {
|
||||
t.Fatalf("= {%q from %q}, want {%q from %q}", got.Value, got.From, tc.wantValue, tc.wantFrom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileInheritanceIsPerField: a profile that overrides ONE of the three DNS
|
||||
// scalars must leave the other two on the globals value. Per-section inheritance
|
||||
// ("a profile that names any resolver owns all of them") would drag a field the
|
||||
// operator never wrote into the override.
|
||||
func TestProfileInheritanceIsPerField(t *testing.T) {
|
||||
g := Globals{ResolverDefault: "quad9", ResolverFallback: "cloudflare", EndpointResolver: "local"}
|
||||
prof := &Profile{Name: "mobile-uplink", ResolverFallback: "yandex-sec"}
|
||||
|
||||
if got := EffectiveResolverDefault(g, prof); got.Value != "quad9" || got.From != FromGlobals {
|
||||
t.Fatalf("resolver_default = {%q from %q}, want {quad9 from globals}: the profile set only "+
|
||||
"the FALLBACK and must not have taken the default with it", got.Value, got.From)
|
||||
}
|
||||
if got := EffectiveResolverFallback(g, prof); got.Value != "yandex-sec" || got.From != "profile:mobile-uplink" {
|
||||
t.Fatalf("resolver_fallback = {%q from %q}, want {yandex-sec from profile:mobile-uplink}", got.Value, got.From)
|
||||
}
|
||||
if got := EffectiveEndpointResolver(g, prof); got.Value != "local" || got.From != FromGlobals {
|
||||
t.Fatalf("endpoint_resolver = {%q from %q}, want {local from globals}", got.Value, got.From)
|
||||
}
|
||||
// No active profile at all: globals, and said to be globals.
|
||||
if got := EffectiveResolverFallback(g, nil); got.Value != "cloudflare" || got.From != FromGlobals {
|
||||
t.Fatalf("with no active profile: {%q from %q}, want {cloudflare from globals}", got.Value, got.From)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyFetchViaIsClosed walks the whole accepted vocabulary and pins that
|
||||
// everything outside it lands in FetchViaUnknown rather than being folded into
|
||||
// one of the three real modes.
|
||||
func TestClassifyFetchViaIsClosed(t *testing.T) {
|
||||
known := map[string]FetchViaMode{
|
||||
"": FetchViaInherit,
|
||||
" ": FetchViaInherit,
|
||||
"direct": FetchViaDirect,
|
||||
"DIRECT": FetchViaDirect,
|
||||
" proxy ": FetchViaProxy,
|
||||
"Proxy": FetchViaProxy,
|
||||
}
|
||||
for v, want := range known {
|
||||
if got := ClassifyFetchVia(v); got != want {
|
||||
t.Errorf("ClassifyFetchVia(%q) = %v, want %v", v, got, want)
|
||||
}
|
||||
}
|
||||
// The values that used to be silently read as "direct" — a plain-WAN fetch
|
||||
// with this router's real address, which SUCCEEDS, so nothing revealed them.
|
||||
for _, v := range []string{"prxoy", "yes", "1", "tunnel", "proxy!", "dir", "block"} {
|
||||
if got := ClassifyFetchVia(v); got != FetchViaUnknown {
|
||||
t.Errorf("ClassifyFetchVia(%q) = %v, want FetchViaUnknown — an unrecognised value must "+
|
||||
"not be folded into a real mode", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateSubscriptionsNamesUnknownFetchVia: the unknown branch is not just
|
||||
// classified, it is REPORTED. A branch nobody prints is a silent skip.
|
||||
func TestValidateSubscriptionsNamesUnknownFetchVia(t *testing.T) {
|
||||
got := ValidateSubscriptions([]Subscription{
|
||||
{Name: "typo", FetchVia: "prxoy"},
|
||||
{Name: "inherits"},
|
||||
{Name: "clear", FetchVia: "direct"},
|
||||
{Name: "tunnelled", FetchVia: "proxy", FetchDetour: "group:auto"},
|
||||
})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want exactly one warning (the typo), got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Name != "typo" || !strings.Contains(got[0].Message, "prxoy") {
|
||||
t.Fatalf("the warning must name the subscription AND the value: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetourFormWarning: the shape check is a CLOSED positive list of
|
||||
// prefixes. A mistyped prefix is the interesting case, because downstream it is
|
||||
// not refused — resolveTarget demotes any unrecognised `kind:name` to a bare
|
||||
// name and looks it up as a node and then a group.
|
||||
func TestFetchDetourFormWarning(t *testing.T) {
|
||||
silent := []string{"", " ", "direct", "DIRECT", "block", "node:tokyo", "group:auto",
|
||||
"egress:wg0", "chain:swan", "Group:Auto"}
|
||||
for _, v := range silent {
|
||||
if msg := FetchDetourFormWarning("fetch_detour", v); msg != "" {
|
||||
t.Errorf("FetchDetourFormWarning(%q) = %q, want silence", v, msg)
|
||||
}
|
||||
}
|
||||
loud := map[string]string{
|
||||
"grup:auto": "grup", // a mistyped prefix
|
||||
"group:": "group:", // a kind naming nothing
|
||||
"sim-bypass": "bare", // a bare name cannot reach an egress or a chain
|
||||
}
|
||||
for v, want := range loud {
|
||||
msg := FetchDetourFormWarning("fetch_detour", v)
|
||||
if msg == "" {
|
||||
t.Errorf("FetchDetourFormWarning(%q) was silent; this value does not resolve to what it looks like", v)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("FetchDetourFormWarning(%q) = %q, must mention %q", v, msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProfilesNamesAMissingResolver: a profile override naming a resolver
|
||||
// that does not exist is invisible until that profile's uplink is the live one,
|
||||
// which is exactly why it has to be reported at config time.
|
||||
func TestValidateProfilesNamesAMissingResolver(t *testing.T) {
|
||||
resolvers := []Resolver{{Name: "quad9"}, {Name: "yandex"}}
|
||||
got := ValidateProfiles([]Profile{
|
||||
{Name: "mobile-uplink", ResolverDefault: "yandex-typo", ResolverFallback: "yandex"},
|
||||
{Name: "ethernet-uplink", ResolverDefault: "quad9"},
|
||||
{Name: "no-overrides"},
|
||||
}, resolvers)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want exactly one warning, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Name != "mobile-uplink" ||
|
||||
!strings.Contains(got[0].Message, "yandex-typo") ||
|
||||
!strings.Contains(got[0].Message, "resolver_default") {
|
||||
t.Fatalf("the warning must name the profile, the FIELD and the value: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// --- the v2 -> v3 migration --------------------------------------------------
|
||||
|
||||
// v2Config is a config as this build's predecessor left it: schema 2, and three
|
||||
// subscriptions whose fetch is de-facto direct in three different spellings.
|
||||
const v2Config = `package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '2'
|
||||
|
||||
config subscription
|
||||
option name 'silent'
|
||||
option url 'https://p.example/a'
|
||||
|
||||
config subscription
|
||||
option name 'explicit'
|
||||
option url 'https://p.example/b'
|
||||
option fetch_via 'direct'
|
||||
|
||||
config subscription
|
||||
option name 'tunnelled'
|
||||
option url 'https://p.example/c'
|
||||
option fetch_via 'proxy'
|
||||
option fetch_detour 'group:auto'
|
||||
|
||||
config subscription
|
||||
option name 'junk'
|
||||
option url 'https://p.example/d'
|
||||
option fetch_via 'yes'
|
||||
`
|
||||
|
||||
// TestMigrate2to3RecordsTheExistingDirectFetch: every subscription that fetches
|
||||
// directly today says so explicitly afterwards, and the one that goes through the
|
||||
// tunnel is untouched. Nothing about where any feed is fetched changes.
|
||||
func TestMigrate2to3RecordsTheExistingDirectFetch(t *testing.T) {
|
||||
var notes []string
|
||||
old := migrateNotef
|
||||
migrateNotef = func(f string, a ...any) { notes = append(notes, f) }
|
||||
defer func() { migrateNotef = old }()
|
||||
|
||||
f := newFakeUCI(v2Config)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if v, _ := f.Get("shater.globals.schema_version"); v != strconv.Itoa(CurrentSchemaVersion) {
|
||||
t.Fatalf("schema_version = %q, want %d", v, CurrentSchemaVersion)
|
||||
}
|
||||
|
||||
want := map[int]string{0: "direct", 1: "direct", 2: "proxy", 3: "direct"}
|
||||
for idx, w := range want {
|
||||
got, ok := f.Get("shater.@subscription[" + strconv.Itoa(idx) + "].fetch_via")
|
||||
if !ok {
|
||||
t.Fatalf("subscription[%d] has no fetch_via after the migration — its absence now means "+
|
||||
"\"inherit the general detour\", which is NOT where it fetched before", idx)
|
||||
}
|
||||
if got != w {
|
||||
t.Fatalf("subscription[%d].fetch_via = %q, want %q", idx, got, w)
|
||||
}
|
||||
}
|
||||
// The junk value is rewritten, and the rewrite is announced.
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("an unrecognised fetch_via must be named when it is normalised; notes = %v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate2to3PreservesInherit: a config ALREADY at v3 must not have its
|
||||
// deliberate blanks filled in. This is the guard that keeps the migration from
|
||||
// undoing the feature every time it runs.
|
||||
func TestMigrate2to3PreservesInherit(t *testing.T) {
|
||||
f := newFakeUCI(`package shater
|
||||
|
||||
config globals 'globals'
|
||||
option schema_version '3'
|
||||
|
||||
config subscription
|
||||
option name 'inherits'
|
||||
option url 'https://p.example/a'
|
||||
`)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if v, ok := f.Get("shater.@subscription[0].fetch_via"); ok {
|
||||
t.Fatalf("a v3 config's deliberate \"inherit\" was overwritten with %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate2to3IsIdempotent: running the full chain again over its own output
|
||||
// changes nothing.
|
||||
func TestMigrate2to3IsIdempotent(t *testing.T) {
|
||||
f := newFakeUCI(v2Config)
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("first migrate: %v", err)
|
||||
}
|
||||
before, _ := f.Export("shater")
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
after, _ := f.Export("shater")
|
||||
if before != after {
|
||||
t.Fatalf("second run changed the config:\n--- before\n%s\n--- after\n%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate2to3ThenParseKeepsEveryFeedWhereItWas is the end-to-end statement of
|
||||
// the promise: take a v2 config, migrate it, read it with THIS build's parser,
|
||||
// and every subscription resolves to the same fetch path it had under the old
|
||||
// one — where "the same" is judged against the old rule (proxy, or direct).
|
||||
func TestMigrate2to3ThenParseKeepsEveryFeedWhereItWas(t *testing.T) {
|
||||
// A general detour is configured, which is what makes the test sharp: an
|
||||
// unmigrated blank would inherit THIS and move the feed into a tunnel.
|
||||
g := Globals{FetchDetour: "group:sim-bypass"}
|
||||
|
||||
pre, err := ParseUCIExport(v2Config)
|
||||
if err != nil {
|
||||
t.Fatalf("parse v2: %v", err)
|
||||
}
|
||||
// The v2 rule, verbatim: proxy => its own detour, everything else => direct.
|
||||
oldPath := map[string]string{}
|
||||
for _, s := range pre.Subscriptions {
|
||||
if strings.EqualFold(strings.TrimSpace(s.FetchVia), "proxy") {
|
||||
oldPath[s.Name] = s.FetchDetour
|
||||
} else {
|
||||
oldPath[s.Name] = "direct"
|
||||
}
|
||||
}
|
||||
|
||||
f := newFakeUCI(v2Config)
|
||||
migrateNotef = func(string, ...any) {}
|
||||
if err := migrateWith(f); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
text, _ := f.Export("shater")
|
||||
post, err := ParseUCIExport(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse v3: %v", err)
|
||||
}
|
||||
for _, s := range post.Subscriptions {
|
||||
got, ok := SubscriptionFetchDetour(s, g, nil)
|
||||
if !ok {
|
||||
t.Fatalf("subscription %q does not resolve after the migration", s.Name)
|
||||
}
|
||||
if got.Value != oldPath[s.Name] {
|
||||
t.Fatalf("subscription %q now fetches through %q, it used to fetch through %q — the "+
|
||||
"migration was supposed to preserve every existing path",
|
||||
s.Name, got.Value, oldPath[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverrideAccessors: a consumer that needs the profile name back out must not
|
||||
// have to know how From is spelled. Slicing on a hard-coded ":" in three files is
|
||||
// how the two sides of a contract drift apart.
|
||||
func TestOverrideAccessors(t *testing.T) {
|
||||
g := Globals{ResolverDefault: "quad9", FetchDetour: "direct"}
|
||||
prof := &Profile{Name: "mobile-uplink", ResolverDefault: "yandex"}
|
||||
|
||||
got := EffectiveResolverDefault(g, prof)
|
||||
name, ok := got.ProfileName()
|
||||
if !ok || name != "mobile-uplink" {
|
||||
t.Fatalf("ProfileName() = (%q, %v), want (mobile-uplink, true)", name, ok)
|
||||
}
|
||||
if got.IsFromGlobals() {
|
||||
t.Fatal("a profile-supplied value must not report IsFromGlobals")
|
||||
}
|
||||
|
||||
fromG := EffectiveResolverDefault(g, nil)
|
||||
if _, ok := fromG.ProfileName(); ok {
|
||||
t.Fatal("a globals value must not report a profile name")
|
||||
}
|
||||
if !fromG.IsFromGlobals() {
|
||||
t.Fatalf("IsFromGlobals() = false for From=%q", fromG.From)
|
||||
}
|
||||
|
||||
sub, _ := SubscriptionFetchDetour(Subscription{Name: "qomar", FetchVia: "direct"}, g, prof)
|
||||
sname, sok := sub.SubscriptionName()
|
||||
if !sok || sname != "qomar" {
|
||||
t.Fatalf("SubscriptionName() = (%q, %v), want (qomar, true)", sname, sok)
|
||||
}
|
||||
if _, ok := sub.ProfileName(); ok {
|
||||
t.Fatal("a subscription-supplied value must not report a profile name")
|
||||
}
|
||||
}
|
||||
+108
-1
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
// CurrentSchemaVersion is the schema this build understands. Bump it when adding
|
||||
// a migration step below.
|
||||
const CurrentSchemaVersion = 2
|
||||
const CurrentSchemaVersion = 3
|
||||
|
||||
// uciRunner abstracts uci get/set/delete/commit/import so migrations AND the
|
||||
// config-write path (WriteUCI) are unit-testable. Import feeds `uci export`-format
|
||||
@@ -110,6 +110,7 @@ type migration struct {
|
||||
var migrations = []migration{
|
||||
{from: 0, to: 1, apply: migrate0to1},
|
||||
{from: 1, to: 2, apply: migrate1to2},
|
||||
{from: 2, to: 3, apply: migrate2to3},
|
||||
}
|
||||
|
||||
func readSchemaVersion(u uciRunner) int {
|
||||
@@ -651,6 +652,112 @@ func migrateDomainEntry(e string) string {
|
||||
return "full:" + v
|
||||
}
|
||||
|
||||
// --- v2 -> v3: a subscription's `fetch_via` says which of THREE things it means
|
||||
//
|
||||
// WHAT CHANGED. `option fetch_detour` arrives on `config globals` (and as a
|
||||
// per-profile override) as the GENERAL fetch detour, and `fetch_via` on a
|
||||
// subscription becomes an OVERRIDE of it rather than the only statement about
|
||||
// where that feed is fetched. That gives the option a third state — absent,
|
||||
// meaning "no opinion, use the general setting" — and until now absent was not a
|
||||
// state at all: ReadUCI parsed a missing `fetch_via` as the literal string
|
||||
// "direct" (`s.optOr("fetch_via", "direct")`), so "the operator chose clear-text"
|
||||
// and "the operator never touched this row" were the same value.
|
||||
//
|
||||
// WHAT THIS STEP DOES. Every `config subscription` whose `fetch_via` is absent,
|
||||
// blank, or anything OTHER than `proxy`/`direct` gets an explicit
|
||||
// `fetch_via='direct'`. After it runs, every existing subscription fetches
|
||||
// exactly where it fetched before, and the empty value means only what it now
|
||||
// means: inherit.
|
||||
//
|
||||
// WHY A MIGRATION AND NOT A PARSER DEFAULT. Keeping the old default in ReadUCI
|
||||
// would have been one line and would have preserved behaviour just as well — but
|
||||
// it preserves it by making the new state unreachable, which is the whole feature.
|
||||
// Removing the default WITHOUT this step is worse: the reinterpretation would
|
||||
// then happen continuously and invisibly, at every read, and an operator who
|
||||
// later set `globals.fetch_detour='group:sim-bypass'` would find every
|
||||
// subscription that had never mentioned `fetch_via` silently moved into the
|
||||
// tunnel. The change of meaning is real; it happens ONCE, it is written into
|
||||
// /etc/config/shater where `uci show` can see it, and the pre-migration file is
|
||||
// kept beside it (migrateWith takes the copy before any step runs).
|
||||
//
|
||||
// AN UNRECOGNISED VALUE IS NORMALISED, NOT PRESERVED, and that is deliberate.
|
||||
// `fetch_via='yes'` meant `direct` in v2 — every reader in the tree asks
|
||||
// `EqualFold(v, "proxy")` and treats everything else as direct — so writing
|
||||
// `direct` records what the config ALREADY did rather than changing it. Leaving
|
||||
// the junk in place would have handed the same decision to the runtime, where
|
||||
// ClassifyFetchVia now refuses to make it (FetchViaUnknown), and a router whose
|
||||
// subscriptions stopped fetching after an upgrade is a worse outcome than one
|
||||
// whose config says out loud what it was doing. Each rewrite is announced through
|
||||
// migrateNotef; none is silent.
|
||||
//
|
||||
// SCHEMA VERSION IS BUMPED, unlike the two additive changes that deliberately did
|
||||
// not bump it (RetiredEgressTypes and Device.Blocklists — see their comments in
|
||||
// model.go). Both of those left every stored field meaning exactly what it meant.
|
||||
// This one does not: the ABSENCE of `fetch_via` changes meaning, and the bump is
|
||||
// what guarantees the rewrite happens before a reader can act on the new meaning.
|
||||
// Without it a v2 config would simply be read by a v3 parser, and there is no
|
||||
// second chance to tell the two cases apart afterwards.
|
||||
//
|
||||
// IDEMPOTENCE. The step only writes where the value is not already `proxy` or
|
||||
// `direct`, so a second run finds nothing to do. It needs no read-back of its own
|
||||
// writes: the export is taken once, up front, and every decision comes from it.
|
||||
func migrate2to3(u uciRunner) error {
|
||||
text, ok := u.Export("shater")
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil // no config yet (fresh install): nothing to migrate
|
||||
}
|
||||
secs, err := parseSections(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the current config: %w", err)
|
||||
}
|
||||
|
||||
// Anonymous sections are addressed positionally and the index is PER TYPE, so
|
||||
// it counts subscriptions only. This step adds no sections, so nothing shifts.
|
||||
subIdx := -1
|
||||
for _, s := range secs {
|
||||
if s.Type != "subscription" {
|
||||
continue
|
||||
}
|
||||
subIdx++
|
||||
|
||||
raw := s.opt("fetch_via")
|
||||
switch ClassifyFetchVia(raw) {
|
||||
case FetchViaProxy, FetchViaDirect:
|
||||
// Already explicit and already in the closed set: leave it alone.
|
||||
continue
|
||||
case FetchViaInherit:
|
||||
// Absent or blank — the case this whole step exists for.
|
||||
case FetchViaUnknown:
|
||||
migrateNotef("subscription %q: fetch_via %q is not a value this option has; "+
|
||||
"it has always been read as %q and is now written down as %q, so nothing about "+
|
||||
"where this feed is fetched changes",
|
||||
firstNonEmpty(s.opt("name"), s.Name), raw, FetchViaValueDirect, FetchViaValueDirect)
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("shater.@subscription[%d].fetch_via", subIdx)
|
||||
if err := u.Set(key, FetchViaValueDirect); err != nil {
|
||||
return staged(u, fmt.Errorf("record the existing direct fetch of subscription %d: %w", subIdx, err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.Commit("shater"); err != nil {
|
||||
return staged(u, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateNotef announces a migration rewrite that is not a plain copy — today,
|
||||
// an unrecognised `fetch_via` being normalised to the value it already behaved
|
||||
// as. It exists so no migration silently edits a value the operator wrote.
|
||||
//
|
||||
// Same seam and same reasoning as reportBackupProblem and subCacheLogf: model has
|
||||
// no logger dependency and could not have one (logsink imports model), and
|
||||
// os.Stderr is the daemon's log channel under procd and the terminal when shaterd
|
||||
// is run by hand. Replaced in tests.
|
||||
var migrateNotef = func(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "shater: migrate: "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// containsString reports whether list holds want (trimmed comparison).
|
||||
func containsString(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
|
||||
+151
-19
@@ -179,11 +179,28 @@ type Globals struct {
|
||||
// "" = not set. Consumed by the generator (generate/*, another agent); this is
|
||||
// only the model contract.
|
||||
EndpointResolver string
|
||||
ProbeURL string // health-probe URL (default gstatic/generate_204)
|
||||
ProbeInterval string // probe interval (e.g. 60s)
|
||||
SchemaVersion int // UCI schema revision (0 = pre-versioned legacy)
|
||||
ActiveProfile string // last profile switched to (display bookkeeping)
|
||||
PanelPort int // admin-panel HTTP port; 0 = use the built-in default (8088)
|
||||
// FetchDetour is the GENERAL fetch detour: the engine outbound every fetch
|
||||
// this product makes dials through — blocklist, allowlist, ruleset, geoip and
|
||||
// geosite downloads, and (unless the subscription overrides it, see
|
||||
// Subscription.FetchVia) subscription feeds. "" = not set.
|
||||
//
|
||||
// Accepted forms are exactly SplitTarget's, the same vocabulary a rule target
|
||||
// uses: direct | block | node:X | group:X | egress:X | chain:X, plus a bare
|
||||
// name that resolves only if a NODE or GROUP carries that tag. Whether the
|
||||
// named thing EXISTS is the generator's verdict, not this package's — model is
|
||||
// a stdlib-only leaf and knows nothing about emitted tags. What model does own
|
||||
// is the SHAPE: FetchDetourFormWarning names a prefix that is not one of the
|
||||
// four, because such a value is silently demoted to "bare name" downstream and
|
||||
// then misses.
|
||||
//
|
||||
// A per-profile override lives on Profile.FetchDetour; EffectiveFetchDetour
|
||||
// resolves the two.
|
||||
FetchDetour string
|
||||
ProbeURL string // health-probe URL (default gstatic/generate_204)
|
||||
ProbeInterval string // probe interval (e.g. 60s)
|
||||
SchemaVersion int // UCI schema revision (0 = pre-versioned legacy)
|
||||
ActiveProfile string // last profile switched to (display bookkeeping)
|
||||
PanelPort int // admin-panel HTTP port; 0 = use the built-in default (8088)
|
||||
|
||||
// Geo-data source for `source=geosite`/`source=geoip` rule-sets. Resolved by
|
||||
// generate.SetGeoProvider / GeoRuleSetURL; this package only carries the values
|
||||
@@ -524,7 +541,36 @@ type Subscription struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
UpdateInterval string
|
||||
FetchVia string // direct|proxy
|
||||
// FetchVia is this subscription's OVERRIDE of the general fetch detour
|
||||
// (Globals.FetchDetour, or the active profile's). Three states, and the third
|
||||
// one is the point:
|
||||
//
|
||||
// "proxy" this feed goes through THIS subscription's own FetchDetour below
|
||||
// "direct" this feed goes out in the clear, whatever the general setting says
|
||||
// "" NOT SET: the general setting applies (ClassifyFetchVia -> FetchViaInherit)
|
||||
//
|
||||
// "" AND "direct" USED TO BE THE SAME THING and they are not any more. Until
|
||||
// schema v3 ReadUCI parsed an absent `option fetch_via` as the literal string
|
||||
// "direct" (`s.optOr("fetch_via", "direct")`), so the model could not tell "the
|
||||
// operator chose a clear-text fetch" from "the operator never touched this
|
||||
// row" — and with a general fetch detour to inherit, those two must land in
|
||||
// different places. The distinction is carried by the EMPTY STRING rather than
|
||||
// a *string or a companion bool because UCI already draws it exactly there
|
||||
// (`option` present vs absent), strOpt already omits an empty value, and the
|
||||
// accepted vocabulary is a closed positive set that "" is not in. A pointer
|
||||
// would change the JSON the panel exchanges over /api/config and add a nil
|
||||
// class to a stdlib-only leaf; a companion bool would allow the contradictory
|
||||
// state (not-set + "proxy") that nothing could reject.
|
||||
//
|
||||
// Migrate v2->v3 (migrate2to3) writes `fetch_via='direct'` into every
|
||||
// subscription that had no value, so no existing feed changes where it goes:
|
||||
// the reinterpretation is performed ONCE, in the open, by a migration that
|
||||
// leaves its result in /etc/config/shater, instead of continuously and
|
||||
// invisibly by the parser.
|
||||
//
|
||||
// Anything OTHER than those three is FetchViaUnknown — a named branch, not a
|
||||
// silent demotion to direct. See ClassifyFetchVia.
|
||||
FetchVia string
|
||||
|
||||
// FetchDetour names the engine outbound this subscription's fetch dials
|
||||
// through when FetchVia=="proxy". It is read by apply.(*Applier).HTTPClient,
|
||||
@@ -722,7 +768,7 @@ type Chain struct {
|
||||
}
|
||||
|
||||
// Egress is a `config egress`.
|
||||
// The CANONICAL egress types. There are exactly three, and every consumer —
|
||||
// The CANONICAL egress types. There are exactly two, and every consumer —
|
||||
// the data plane (netplane.EgressDevice, the prerouting mgmt-bypass,
|
||||
// addEgressRouting) and the engine config (generate's outbound switch) — must
|
||||
// see one of these strings or nothing.
|
||||
@@ -757,10 +803,63 @@ const (
|
||||
// EgressTypeDirect dials over the main table. Its purpose is to carry a
|
||||
// native DPI-bypass preset (D13) for the rules routed to it.
|
||||
EgressTypeDirect = "direct"
|
||||
// EgressTypeByeDPI is a SOCKS5 outbound to the local ciadpi desync proxy.
|
||||
EgressTypeByeDPI = "byedpi"
|
||||
)
|
||||
|
||||
// RetiredEgressTypes maps a type this product ONCE supported to the sentence
|
||||
// that says so. A CLOSED, POSITIVE table, and it is the reason a config written
|
||||
// for an older build does not get told its egress type is a typo.
|
||||
//
|
||||
// # Why a table and not a migration
|
||||
//
|
||||
// The alternative was a schema migration rewriting `type='byedpi'` to something
|
||||
// that still routes. There is no such target. `direct` is the closest kind, and
|
||||
// substituting it would turn an egress that is currently BLOCKED (fail-closed:
|
||||
// no outbound is built, so every binding to it stops) into a live path out over
|
||||
// the plain WAN with the router's real address — the exact leak class this
|
||||
// codebase refuses everywhere else, performed silently, by an upgrade, on a
|
||||
// config the operator never touched. So nothing is rewritten: the egress stays
|
||||
// unbuildable and therefore stays fail-closed, and the only thing that changes
|
||||
// is that the operator is told WHY, precisely, instead of being told the value
|
||||
// is unrecognised.
|
||||
//
|
||||
// That also settles the schema version. CurrentSchemaVersion is not bumped: no
|
||||
// stored field changes meaning (a `byedpi` egress built nothing before this
|
||||
// change and builds nothing after it), nothing is migrated, and a bump would
|
||||
// only make configs written by this build unreadable to an older daemon —
|
||||
// Migrate refuses a schema newer than its own — for no gain.
|
||||
//
|
||||
// # Why the message and not the generic "unknown type"
|
||||
//
|
||||
// The generic finding is true (nothing is built, everything bound is blocked)
|
||||
// and useless: it sends the operator looking for a spelling mistake in a value
|
||||
// that was correct when they wrote it. A removed kind is a different event from
|
||||
// a typo and gets a different sentence.
|
||||
var RetiredEgressTypes = map[string]string{
|
||||
"byedpi": "type \"byedpi\" was REMOVED from this product — this is not a misspelling, it is a " +
|
||||
"kind that no longer exists. Nothing is built for this egress: no outbound, no mark, no " +
|
||||
"`ip rule`, no routing table. Every node, group and rule bound to it is BLOCKED (fail-closed) " +
|
||||
"rather than sent out over the plain WAN, and the `port` option on this section no longer " +
|
||||
"means anything. The kind existed to hand traffic to a separate ciadpi process because the " +
|
||||
"engine's own TLS fragmentation was not getting through DPI; the cause turned out to be a " +
|
||||
"defect in that fragmentation — it always cut inside the FIRST label of the name, so the " +
|
||||
"blocked word travelled intact — and that defect is fixed, which is why the external process " +
|
||||
"is gone. Move this egress onto the built-in desync: set `type` to 'direct' with `dpi` " +
|
||||
"'record' (or to 'interface' with the same preset, plus the `interface` this traffic should " +
|
||||
"leave through), then apply. Which preset gets through is a property of your ISP and is not " +
|
||||
"promised here — 'fragment' and 'spoof' are the other two. The now-unused package can be " +
|
||||
"taken off the router with `apk del byedpi`; removing it is safe and touches nothing else.",
|
||||
}
|
||||
|
||||
// RetiredEgressType returns the sentence for a type this product has REMOVED,
|
||||
// and ok=false for every other value — including the supported ones. It folds
|
||||
// case and whitespace the way CanonicalEgressType does, but deliberately does
|
||||
// NOT go through EgressTypeAliases: a retired kind is not an alias of anything,
|
||||
// and routing it through the alias table is how it would acquire a meaning again.
|
||||
func RetiredEgressType(t string) (string, bool) {
|
||||
msg, ok := RetiredEgressTypes[strings.ToLower(strings.TrimSpace(t))]
|
||||
return msg, ok
|
||||
}
|
||||
|
||||
// EgressTypeAliases maps every accepted NON-canonical spelling to its canonical
|
||||
// type. Closed and positive on purpose: a spelling that is not in here is not
|
||||
// "probably fine", it is unknown, and the whole point of the map is that unknown
|
||||
@@ -779,7 +878,7 @@ var EgressTypeAliases = map[string]string{
|
||||
|
||||
// KnownEgressTypes is the closed set of canonical types, in the order the panel
|
||||
// offers them.
|
||||
var KnownEgressTypes = []string{EgressTypeInterface, EgressTypeDirect, EgressTypeByeDPI}
|
||||
var KnownEgressTypes = []string{EgressTypeInterface, EgressTypeDirect}
|
||||
|
||||
// CanonicalEgressType folds one written egress type to its canonical spelling:
|
||||
// trimmed, lower-cased, and de-aliased. A type it does not recognise is returned
|
||||
@@ -794,12 +893,14 @@ func CanonicalEgressType(t string) string {
|
||||
return t
|
||||
}
|
||||
|
||||
// EgressTypeKnown reports whether this written type resolves to one of the three
|
||||
// EgressTypeKnown reports whether this written type resolves to one of the two
|
||||
// canonical types. Everything else is unknown: the generator emits no outbound
|
||||
// for it (fail-closed) and netplane installs no routing.
|
||||
// for it (fail-closed) and netplane installs no routing. A RETIRED type is not
|
||||
// known either — it must keep failing closed; RetiredEgressType only changes
|
||||
// what the operator is TOLD about it, never what the router does with it.
|
||||
func EgressTypeKnown(t string) bool {
|
||||
switch CanonicalEgressType(t) {
|
||||
case EgressTypeInterface, EgressTypeDirect, EgressTypeByeDPI:
|
||||
case EgressTypeInterface, EgressTypeDirect:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -828,7 +929,7 @@ func (m *Model) NormalizeEgressTypes() {
|
||||
|
||||
type Egress struct {
|
||||
Name string
|
||||
// Type is one of the three canonical kinds — interface|direct|byedpi (see
|
||||
// Type is one of the two canonical kinds — interface|direct (see
|
||||
// EgressTypeInterface and the block comment there). `tunnel` is an accepted
|
||||
// alias of `interface`, folded away by NormalizeEgressTypes at ReadUCI, so
|
||||
// code reading this field downstream of the config boundary sees only the
|
||||
@@ -836,10 +937,14 @@ type Egress struct {
|
||||
Type string
|
||||
Interface string
|
||||
|
||||
// Port is the ciadpi desync proxy's listen port on 127.0.0.1 (default 1080)
|
||||
// for a byedpi egress (D13). It is IGNORED by every other type — generate
|
||||
// warns when it is set on one, so it is not silently meaningful.
|
||||
Port int
|
||||
// There is no Port here any more. It existed for exactly one egress kind —
|
||||
// the retired `byedpi`, which dialled a local SOCKS proxy — and no surviving
|
||||
// kind dials anything at all: interface and direct BIND or MARK a direct
|
||||
// outbound and have no destination to take a port. Keeping the field would
|
||||
// have left a knob nothing reads, which is the defect class this model has
|
||||
// already paid for; ReadUCI simply stops parsing `option port` on an egress
|
||||
// and the option drains out of /etc/config/shater on the next render, the
|
||||
// same way the deleted per-group probe_url/probe_interval did.
|
||||
|
||||
// DPI is an optional native DPI-bypass preset (D13). On an egress that
|
||||
// resolves to a real outbound (interface/direct), the generator stamps the
|
||||
@@ -848,7 +953,8 @@ type Egress struct {
|
||||
// fragment — tls_fragment (split the ClientHello record)
|
||||
// record — tls_record_fragment (mutually exclusive with fragment)
|
||||
// spoof — tls_spoof (inject a decoy ClientHello via a raw socket)
|
||||
// The stronger external byedpi preset is reserved for Phase-2b (see D13).
|
||||
// This is the WHOLE set: the external desync egress that used to stand
|
||||
// beside it is retired (see RetiredEgressTypes).
|
||||
DPI string
|
||||
}
|
||||
|
||||
@@ -883,6 +989,32 @@ type Profile struct {
|
||||
// active WAN: SIM->yandex, WiFi->DoH). "" = no override. Consumed by the
|
||||
// generator (another agent); model contract only.
|
||||
EndpointResolver string
|
||||
|
||||
// ResolverDefault / ResolverFallback are per-profile overrides of the two
|
||||
// Globals scalars of the same name — the resolver consulted FIRST and the one
|
||||
// consulted LAST. Built to the SAME contract as EndpointResolver above: "" = no
|
||||
// override, inherit from globals.
|
||||
//
|
||||
// INHERITANCE IS PER FIELD, not per section. A profile that sets only
|
||||
// resolver_fallback keeps globals' resolver_default. The alternative — "a
|
||||
// profile that names any resolver owns all of them" — would silently drag a
|
||||
// field the operator never wrote into the override, which is the same class of
|
||||
// defect as an open `default:`.
|
||||
//
|
||||
// Why they exist: measured on the production router, an operator's SIM uplink
|
||||
// refuses TCP/443 to 9.9.9.9 and 1.1.1.1 while carrying everything else, so the
|
||||
// globals resolver pair that works on ethernet resolves NOTHING on mobile — and
|
||||
// the node server names, the hop in front of an AWG endpoint and therefore the
|
||||
// whole chain died with it. The profile that already knows which uplink is live
|
||||
// is the right place to say which resolvers that uplink can reach.
|
||||
ResolverDefault string
|
||||
ResolverFallback string
|
||||
|
||||
// FetchDetour is a per-profile override of Globals.FetchDetour — same accepted
|
||||
// forms, same "" = no override rule. Same motivation: on the SIM uplink the
|
||||
// list/geo/subscription fetches have to travel through a group of nodes that
|
||||
// operator does not block, and on ethernet they may go direct.
|
||||
FetchDetour string
|
||||
}
|
||||
|
||||
// Ruleset is a `config ruleset` (reusable domain/ip list).
|
||||
|
||||
+15
-2
@@ -30,7 +30,7 @@ import (
|
||||
// - list fields emit one `list <k> '<v>'` per element (nothing when empty).
|
||||
//
|
||||
// Consequence: a field left at the parser's non-empty/non-zero DEFAULT (e.g.
|
||||
// Inbound.Type "" which the parser fills as "tproxy", Egress.Port 0 -> 1080) is
|
||||
// Inbound.Type "" which the parser fills as "tproxy") is
|
||||
// NOT the same as that default written out — but any Model produced by
|
||||
// ParseUCIExport already carries those defaults as concrete values, so the
|
||||
// panel's read→edit→write flow round-trips exactly. See render_test.go.
|
||||
@@ -74,6 +74,10 @@ func RenderUCIExport(m *Model) string {
|
||||
w.strOpt("resolver_default", g.ResolverDefault)
|
||||
w.strOpt("resolver_fallback", g.ResolverFallback)
|
||||
w.strOpt("endpoint_resolver", g.EndpointResolver)
|
||||
// strOpt (omit-empty), NOT an always-emit variant: an empty FetchDetour means
|
||||
// "not set", and `option fetch_detour ''` would read back as "set to nothing",
|
||||
// which is a different state the moment a profile inherits from it.
|
||||
w.strOpt("fetch_detour", g.FetchDetour)
|
||||
w.strOpt("probe_url", g.ProbeURL)
|
||||
w.strOpt("probe_interval", g.ProbeInterval)
|
||||
w.intOpt("schema_version", g.SchemaVersion)
|
||||
@@ -200,7 +204,9 @@ func RenderUCIExport(m *Model) string {
|
||||
w.strOpt("name", e.Name)
|
||||
w.strOpt("type", e.Type)
|
||||
w.strOpt("interface", e.Interface)
|
||||
w.intOpt("port", e.Port)
|
||||
// No `port`: the option belonged to the retired `byedpi` kind and the model
|
||||
// no longer carries the field (see Egress). A config that still has one
|
||||
// loses it here, on the next write — which is the intent.
|
||||
w.strOpt("dpi", e.DPI)
|
||||
}
|
||||
|
||||
@@ -249,7 +255,14 @@ func RenderUCIExport(m *Model) string {
|
||||
w.listOpt("match_iface", p.MatchIface)
|
||||
w.listOpt("enable_rule", p.EnableRules)
|
||||
w.listOpt("disable_rule", p.DisableRules)
|
||||
// The four per-profile overrides. Every one of them is strOpt: an empty
|
||||
// override MUST leave no option behind, or the next read turns "inherit"
|
||||
// into "override with nothing" and the profile silently blanks the globals
|
||||
// value it was meant to leave alone.
|
||||
w.strOpt("endpoint_resolver", p.EndpointResolver)
|
||||
w.strOpt("resolver_default", p.ResolverDefault)
|
||||
w.strOpt("resolver_fallback", p.ResolverFallback)
|
||||
w.strOpt("fetch_detour", p.FetchDetour)
|
||||
}
|
||||
|
||||
for _, res := range m.Resolvers {
|
||||
|
||||
@@ -78,7 +78,7 @@ func richModel() *Model {
|
||||
Name: "triple", Hops: []string{"group:main", "node:reality-nl"},
|
||||
}},
|
||||
Egresses: []Egress{{
|
||||
Name: "frag", Type: "direct", Interface: "wg0", Port: 1080, DPI: "fragment",
|
||||
Name: "frag", Type: "direct", Interface: "wg0", DPI: "fragment",
|
||||
}},
|
||||
Rulesets: []Ruleset{{
|
||||
Name: "ads", Type: "domain", Source: "url", URL: "http://list.local/ads",
|
||||
|
||||
+223
-43
@@ -17,13 +17,18 @@ package model
|
||||
// OBJECT (not a bare array — an object carries the format version and the
|
||||
// authoritative subscription name, which a bare array cannot):
|
||||
//
|
||||
// {"version": 1, "sub": "<subscription name>", "nodes": [Node, ...]}
|
||||
// {"version": 1, "sub": "<name>", "seq": <generation>, "nodes": [Node, ...]}
|
||||
//
|
||||
// Node uses its natural Go-field JSON encoding — the same shape the panel
|
||||
// already exchanges over GET/PUT /api/config. Unknown fields are ignored on
|
||||
// read (forward compatibility); an unknown version is skipped with a warning.
|
||||
// Files are written indented so `cat` on the router stays a usable debugger.
|
||||
//
|
||||
// `seq` is the generation counter that decides which of two copies is in force.
|
||||
// It is carried INSIDE the file because the filesystem cannot answer the
|
||||
// question: see the subCacheFile doc comment for the measurement that killed the
|
||||
// modification-time version of this rule.
|
||||
//
|
||||
// # Where
|
||||
//
|
||||
// The persistent home is /etc/shater/subs (survives reboot). Like
|
||||
@@ -32,8 +37,10 @@ package model
|
||||
// warning — a reboot then costs one re-fetch, never a broken config. The
|
||||
// SHATER_SUBS_DIR env var overrides the directory outright (tests, dev hosts).
|
||||
// Writes are atomic: temp file in the target dir, then rename, so a reader
|
||||
// (daemon vs CLI) never observes a torn file. A refresh that produces the
|
||||
// byte-identical file is skipped entirely (no overlay churn).
|
||||
// (daemon vs CLI) never observes a torn file. A refresh whose node set is
|
||||
// unchanged is skipped entirely (no overlay churn) — unless the copy in force is
|
||||
// the tmpfs one, in which case the persistent home has to be rewritten to take
|
||||
// the authority back.
|
||||
//
|
||||
// # The single merge point
|
||||
//
|
||||
@@ -46,7 +53,6 @@ package model
|
||||
// /etc/config/shater on the next write.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -56,12 +62,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// subCacheDirPersistent is the preferred (survives reboot) home for the
|
||||
// per-subscription node caches; subCacheDirFallback is the tmpfs degradation
|
||||
// used when the overlay cannot take the write (same policy as generate/cache.go).
|
||||
subCacheDirPersistent = "/etc/shater/subs"
|
||||
subCacheDirFallback = "/tmp/shater-subs"
|
||||
|
||||
// subCacheDirEnv overrides the cache directory entirely (no fallback chain):
|
||||
// tests point it at a temp dir; a dev host keeps /etc clean.
|
||||
subCacheDirEnv = "SHATER_SUBS_DIR"
|
||||
@@ -71,10 +71,55 @@ const (
|
||||
subCacheVersion = 1
|
||||
)
|
||||
|
||||
// subCacheDirPersistent is the preferred (survives reboot) home for the
|
||||
// per-subscription node caches; subCacheDirFallback is the tmpfs degradation used
|
||||
// when the overlay cannot take the write (same policy as generate/cache.go).
|
||||
//
|
||||
// Variables, not constants, for the same reason migrate.go's liveConfigPath is:
|
||||
// the DEGRADED state — a persistent dir that holds a readable file and refuses
|
||||
// new ones — is a two-directory state, and SHATER_SUBS_DIR collapses the chain to
|
||||
// one directory, so it cannot produce that state at all. A test that cannot
|
||||
// produce the positive case proves nothing about the negative one.
|
||||
var (
|
||||
subCacheDirPersistent = "/etc/shater/subs"
|
||||
subCacheDirFallback = "/tmp/shater-subs"
|
||||
)
|
||||
|
||||
// subCacheFile is the on-disk JSON shape (see the package comment).
|
||||
//
|
||||
// Seq is the GENERATION COUNTER, and it is what decides which of two copies of
|
||||
// the same subscription is in force. Every write takes the highest Seq found in
|
||||
// any candidate directory and adds one, so "newer" is a fact carried inside the
|
||||
// file rather than a property of the filesystem.
|
||||
//
|
||||
// IT REPLACED MODIFICATION TIME, WHICH DOES NOT WORK HERE. Two consecutive
|
||||
// writes land in the same kernel timer tick and receive BIT-IDENTICAL
|
||||
// timestamps — measured in the Linux CI container, the persistent and the tmpfs
|
||||
// copy came out at the same UnixNano to the digit, so a strict "is newer than"
|
||||
// test was false and the STALE copy won every time. That is not a rare tie: two
|
||||
// small writes in one operation always fall in one tick, so on Linux it was the
|
||||
// normal outcome, and it is why this only ever looked correct on a Windows host.
|
||||
//
|
||||
// Worse, and independent of resolution: this router has no RTC. OpenWrt boots
|
||||
// with the clock at the epoch until NTP syncs, and the uplink this whole feature
|
||||
// exists for is a SIM that drops. So a cache written before a sync carries a
|
||||
// 1970 stamp and would lose to any older file written after a previous one —
|
||||
// mtime ordering can invert across a reboot. This codebase already refuses to
|
||||
// act on an unsynced clock elsewhere (alert/expiry.go); the cache must not
|
||||
// depend on one either.
|
||||
//
|
||||
// Seq is absent (0) in every file written before this change. That is handled
|
||||
// as a legitimate value, not a special case: a legacy pair ties at 0 and the
|
||||
// tie-break keeps the persistent copy, which is exactly the behaviour those
|
||||
// files had before. The FORMAT VERSION IS NOT BUMPED — a new field is
|
||||
// transparent in both directions (decodeSubCache uses a plain json.Unmarshal, so
|
||||
// an older daemon ignores `seq` and a newer one reads 0), and bumping it would
|
||||
// make every cache file already on the router unreadable, which is precisely the
|
||||
// cold-start wipe this file exists to prevent.
|
||||
type subCacheFile struct {
|
||||
Version int `json:"version"`
|
||||
Sub string `json:"sub"`
|
||||
Seq int `json:"seq"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
|
||||
@@ -113,29 +158,106 @@ func subCacheFileName(sub string) string {
|
||||
return b.String() + ".json"
|
||||
}
|
||||
|
||||
// marshalSubCache renders the canonical bytes for a subscription's cache file.
|
||||
// Deterministic (fixed field order, fixed indent) so "did it change" is a plain
|
||||
// byte comparison.
|
||||
func marshalSubCache(sub string, nodes []Node) ([]byte, error) {
|
||||
// marshalSubCache renders the canonical bytes for one generation of a
|
||||
// subscription's cache file. Deterministic (fixed field order, fixed indent) so
|
||||
// two renderings of the same generation are byte-comparable.
|
||||
func marshalSubCache(sub string, seq int, nodes []Node) ([]byte, error) {
|
||||
if nodes == nil {
|
||||
nodes = []Node{} // encode as [], never null
|
||||
}
|
||||
data, err := json.MarshalIndent(subCacheFile{Version: subCacheVersion, Sub: sub, Nodes: nodes}, "", "\t")
|
||||
data, err := json.MarshalIndent(
|
||||
subCacheFile{Version: subCacheVersion, Sub: sub, Seq: seq, Nodes: nodes}, "", "\t")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// subCacheCandidate is one readable copy of a subscription's cache, and the
|
||||
// directory it was found in. dirIndex is the position in subCacheDirs(), so 0 is
|
||||
// the persistent home.
|
||||
type subCacheCandidate struct {
|
||||
dirIndex int
|
||||
path string
|
||||
file *subCacheFile
|
||||
}
|
||||
|
||||
// subCacheCandidatesFor reads every readable, decodable copy of one
|
||||
// subscription's cache, in directory order.
|
||||
func subCacheCandidatesFor(sub string) []subCacheCandidate {
|
||||
name := subCacheFileName(sub)
|
||||
var out []subCacheCandidate
|
||||
for i, dir := range subCacheDirs() {
|
||||
path := filepath.Join(dir, name)
|
||||
data, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
f, derr := decodeSubCache(data)
|
||||
if derr != nil {
|
||||
subCacheLogf("%s: %v — ignoring the file (the next refresh rewrites it)", path, derr)
|
||||
continue
|
||||
}
|
||||
out = append(out, subCacheCandidate{dirIndex: i, path: path, file: f})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// authoritativeSubCache picks the copy in force: the HIGHEST generation counter
|
||||
// wins, and a tie keeps the earliest directory — i.e. the persistent home.
|
||||
//
|
||||
// The tie-break is not arbitrary. A tie means neither copy claims to supersede
|
||||
// the other, which happens in exactly two situations: both were written before
|
||||
// the counter existed (both 0), or there is only one copy. In both, the copy
|
||||
// that survives a reboot is the one to keep. Nothing else may resolve a tie,
|
||||
// and in particular not the clock — see the subCacheFile doc comment.
|
||||
func authoritativeSubCache(cands []subCacheCandidate) *subCacheCandidate {
|
||||
var best *subCacheCandidate
|
||||
for i := range cands {
|
||||
c := &cands[i]
|
||||
if best == nil || c.file.Seq > best.file.Seq {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// nextSubCacheSeq is the generation number a new write must carry: one more than
|
||||
// the highest already on disk anywhere. Monotonic without a clock, and immune to
|
||||
// a failed cleanup — a superseded copy simply keeps a lower number for ever.
|
||||
func nextSubCacheSeq(cands []subCacheCandidate) int {
|
||||
high := 0
|
||||
for i := range cands {
|
||||
if s := cands[i].file.Seq; s > high {
|
||||
high = s
|
||||
}
|
||||
}
|
||||
return high + 1
|
||||
}
|
||||
|
||||
// SaveSubCache atomically persists the node set of one subscription: temp file
|
||||
// + rename in the persistent dir, degrading to tmpfs with a warning when the
|
||||
// overlay refuses the write. Writing the byte-identical content is a no-op, so
|
||||
// a refresh that changed nothing costs no overlay churn.
|
||||
// overlay refuses the write. The new copy carries the next generation number, so
|
||||
// it outranks whatever it could not replace.
|
||||
//
|
||||
// A refresh that changes nothing costs no write at all — but only when the copy
|
||||
// in force is ALREADY the persistent one. If a tmpfs copy currently outranks it
|
||||
// (the overlay was full last time and has since recovered), the identical node
|
||||
// set is written to the persistent home anyway, with a higher generation, so the
|
||||
// authoritative copy moves back to the home that survives a reboot. That is the
|
||||
// one case where "nothing changed" must still cost a write, and skipping it is
|
||||
// how the cache would stay one reboot away from serving a stale set.
|
||||
func SaveSubCache(sub string, nodes []Node) error {
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
return fmt.Errorf("subs cache: empty subscription name")
|
||||
}
|
||||
data, err := marshalSubCache(sub, nodes)
|
||||
cands := subCacheCandidatesFor(sub)
|
||||
if best := authoritativeSubCache(cands); best != nil &&
|
||||
best.dirIndex == 0 && sameNodeSet(best.file.Nodes, nodes) {
|
||||
return nil // unchanged, and already in force from the persistent home
|
||||
}
|
||||
seq := nextSubCacheSeq(cands)
|
||||
data, err := marshalSubCache(sub, seq, nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subs cache %q: marshal: %w", sub, err)
|
||||
}
|
||||
@@ -144,9 +266,6 @@ func SaveSubCache(sub string, nodes []Node) error {
|
||||
var firstErr error
|
||||
for i, dir := range dirs {
|
||||
path := filepath.Join(dir, name)
|
||||
if existing, rerr := os.ReadFile(path); rerr == nil && bytes.Equal(existing, data) {
|
||||
return nil // unchanged — skip the write entirely
|
||||
}
|
||||
if werr := writeFileAtomic(dir, path, data); werr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = werr
|
||||
@@ -155,19 +274,64 @@ func SaveSubCache(sub string, nodes []Node) error {
|
||||
}
|
||||
if i > 0 {
|
||||
// Same degradation policy as generate/cache.go: tmpfs keeps things
|
||||
// working, but the cache is gone after a reboot (one re-fetch).
|
||||
// working, but the cache is gone after a reboot (one re-fetch). The
|
||||
// generation counter is what makes the daemon actually READ this copy
|
||||
// while it is the newest one.
|
||||
subCacheLogf("%s is not writable (%v); cached %q to tmpfs %s — the cache will not survive a reboot", dirs[0], firstErr, sub, path)
|
||||
} else {
|
||||
// The persistent home took the write, so any lower-generation copy left
|
||||
// in tmpfs is dead weight. Best-effort ONLY: the generation counter has
|
||||
// already decided the outcome, so a removal that fails costs nothing but
|
||||
// a few KB — which is why this is not allowed to fail the save.
|
||||
dropSupersededSubCaches(cands, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("subs cache %q: %w", sub, firstErr)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file + rename in the same
|
||||
// dropSupersededSubCaches removes the copies a successful write to the primary
|
||||
// home has just outranked. keep is the path just written.
|
||||
func dropSupersededSubCaches(cands []subCacheCandidate, keep string) {
|
||||
for i := range cands {
|
||||
if cands[i].path == keep {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(cands[i].path)
|
||||
}
|
||||
}
|
||||
|
||||
// sameNodeSet reports whether two node sets are equal for caching purposes,
|
||||
// treating nil and empty as the same thing (marshalSubCache encodes both as []).
|
||||
func sameNodeSet(a, b []Node) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeFileAtomic is the one write in this file, behind a seam.
|
||||
//
|
||||
// The failure that decides everything about the cache — "the filesystem took the
|
||||
// bytes for one home and refused them for the other" — is precisely the one a
|
||||
// temp directory cannot be made to produce on demand, and it is the only way to
|
||||
// prove the degraded path both works AND is read back. Same reasoning, and the
|
||||
// same shape, as migrate.go's statBackup/writeBackupFile. Nothing else may
|
||||
// replace it.
|
||||
var writeFileAtomic = writeFileAtomicOS
|
||||
|
||||
// writeFileAtomicOS writes data to path via a temp file + rename in the same
|
||||
// directory, creating the directory first. Rename is atomic on the same
|
||||
// filesystem, so a concurrent reader sees either the old file or the new one,
|
||||
// never a torn write.
|
||||
func writeFileAtomic(dir, path string, data []byte) error {
|
||||
// never a torn write — and a write that fails at any step leaves the PREVIOUS
|
||||
// cache exactly as it was, which is what makes a failed refresh cost a stale node
|
||||
// set rather than an empty one.
|
||||
func writeFileAtomicOS(dir, path string, data []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,28 +361,39 @@ func writeFileAtomic(dir, path string, data []byte) error {
|
||||
// the daemon must come up on whatever it finds, and the next refresh rewrites
|
||||
// the file anyway.
|
||||
func LoadSubCache(sub string) (nodes []Node, ok bool, err error) {
|
||||
name := subCacheFileName(sub)
|
||||
for _, dir := range subCacheDirs() {
|
||||
path := filepath.Join(dir, name)
|
||||
data, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
f, derr := decodeSubCache(data)
|
||||
if derr != nil {
|
||||
subCacheLogf("%s: %v — ignoring the file (the next refresh rewrites it)", path, derr)
|
||||
continue
|
||||
}
|
||||
return f.Nodes, true, nil
|
||||
best := authoritativeSubCache(subCacheCandidatesFor(sub))
|
||||
if best == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
return best.file.Nodes, true, nil
|
||||
}
|
||||
|
||||
// LoadAllSubCaches reads every cache file from the candidate dirs into a
|
||||
// sub-name -> nodes map. The persistent dir shadows tmpfs for the same
|
||||
// subscription. Undecodable files are reported and skipped.
|
||||
// sub-name -> nodes map. Undecodable files are reported and skipped.
|
||||
//
|
||||
// WHEN THE SAME SUBSCRIPTION IS IN BOTH DIRS, THE HIGHEST GENERATION WINS — not
|
||||
// the persistent one, and not the newer modification time. The rule used to be
|
||||
// "the earlier (higher-priority) dir wins", and it had a hole with real
|
||||
// consequences: SaveSubCache degrades to tmpfs exactly when the persistent home
|
||||
// REFUSED the write (a full or read-only overlay, which is a routine OpenWrt
|
||||
// state), so at that moment the persistent copy is by definition the stale one.
|
||||
// Under the old rule a successful `sub update` reported "N nodes cached", wrote
|
||||
// them to tmpfs, and the daemon went on serving the previous node set from /etc —
|
||||
// with nothing anywhere saying the refresh had not taken effect. That is the
|
||||
// failure this codebase keeps paying for: an instrument that reads the same in
|
||||
// the working and the broken case.
|
||||
//
|
||||
// The first attempt at a fix compared modification times, and it was wrong on
|
||||
// this platform for two independent reasons — identical timestamps within one
|
||||
// kernel tick, and a router with no RTC. See the subCacheFile doc comment; the
|
||||
// generation counter is the replacement.
|
||||
//
|
||||
// A reboot needs no special case: tmpfs is empty then, so the persistent copy is
|
||||
// the only candidate and wins by default — the cold-start-on-cache guarantee is
|
||||
// unchanged. This is the call ReadUCI makes, so it is the one the daemon boots on.
|
||||
func LoadAllSubCaches() map[string][]Node {
|
||||
out := map[string][]Node{}
|
||||
seq := map[string]int{}
|
||||
for _, dir := range subCacheDirs() {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
@@ -243,10 +418,15 @@ func LoadAllSubCaches() map[string][]Node {
|
||||
subCacheLogf("%s: missing \"sub\" field — skipped", path)
|
||||
continue
|
||||
}
|
||||
if _, dup := out[f.Sub]; dup {
|
||||
continue // earlier (higher-priority) dir wins
|
||||
// Same rule as authoritativeSubCache, and it has to STAY the same: a
|
||||
// strict > keeps the earlier directory on a tie, so a legacy pair
|
||||
// (both generation 0) resolves to the persistent copy exactly as it
|
||||
// did before the counter existed.
|
||||
if prev, dup := seq[f.Sub]; dup && f.Seq <= prev {
|
||||
continue
|
||||
}
|
||||
out[f.Sub] = f.Nodes
|
||||
seq[f.Sub] = f.Seq
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package model
|
||||
|
||||
// Which of two copies of the same subscription cache is IN FORCE.
|
||||
//
|
||||
// This is a separate file because the rule has already been got wrong once, in a
|
||||
// way that a Windows test run reported as green: the first fix compared file
|
||||
// modification times, and it failed on Linux 5 runs out of 5. The guard against
|
||||
// repeating that is TestAuthorityIgnoresTheClock, and everything here exists to
|
||||
// keep the answer a property of the DATA rather than of the filesystem.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthorityIgnoresTheClock is the regression guard for the fix that did NOT
|
||||
// work, and it is deliberately hostile: it hands the STALE copy every advantage a
|
||||
// clock could give it and requires the fresh one to win anyway.
|
||||
//
|
||||
// The measurement that condemned modification time, taken in the Linux CI
|
||||
// container, was that two writes in one operation get BIT-IDENTICAL timestamps —
|
||||
// both files came out at the same UnixNano to the digit, so a strict "is newer
|
||||
// than" was false and the first-visited (persistent, stale) copy won. That is not
|
||||
// a rare tie; two small writes always land in one kernel timer tick, so on Linux
|
||||
// it was the normal outcome.
|
||||
//
|
||||
// The second reason is independent of resolution and cannot be tuned away: this
|
||||
// router has no RTC. It boots with the clock at the epoch until NTP syncs, over
|
||||
// the very SIM uplink that drops. A cache written before a sync carries a 1970
|
||||
// stamp and would lose to an older file written after a previous sync, so the
|
||||
// ordering can invert across a reboot.
|
||||
//
|
||||
// So this test sets the stale file's mtime to the FUTURE and the fresh file's to
|
||||
// the epoch. Any implementation that consults the clock — coarse or fine, strict
|
||||
// or not — reads the stale set here and fails, on every platform.
|
||||
func TestAuthorityIgnoresTheClock(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
stale := []Node{{Name: "old-01", Enabled: true, URI: "vless://old", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failWritesTo(t, persistent, errRefused{})
|
||||
fresh := []Node{{Name: "new-01", Enabled: true, URI: "vless://new", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stalePath := filepath.Join(persistent, subCacheFileName("qomar"))
|
||||
freshPath := filepath.Join(fallback, subCacheFileName("qomar"))
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
past := time.Unix(0, 0)
|
||||
if err := os.Chtimes(stalePath, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(freshPath, past, past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// CONTROL: the timestamps really are inverted, so an implementation that read
|
||||
// them would demonstrably get the wrong answer here.
|
||||
si, err := os.Stat(stalePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Stat(freshPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !si.ModTime().After(fi.ModTime()) {
|
||||
t.Fatalf("CONTROL FAILED: the stale copy is not newer by the clock (%v vs %v), so this test "+
|
||||
"does not actually punish a clock-based rule", si.ModTime(), fi.ModTime())
|
||||
}
|
||||
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || !reflect.DeepEqual(got, fresh) {
|
||||
t.Fatalf("LoadSubCache returned %+v, want the FRESH set %+v — the copy in force is being "+
|
||||
"chosen by the filesystem clock, which on this platform is neither monotonic nor "+
|
||||
"fine-grained enough to order two writes", got, fresh)
|
||||
}
|
||||
if all := LoadAllSubCaches(); !reflect.DeepEqual(all["qomar"], fresh) {
|
||||
t.Fatalf("LoadAllSubCaches returned %+v, want the FRESH set %+v — this is the call ReadUCI "+
|
||||
"makes, so it is what the daemon boots on", all["qomar"], fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerationIsMonotonicAcrossHomes: every write claims a number higher than
|
||||
// anything on disk anywhere, so a copy that could not be replaced is outranked
|
||||
// for ever — and a cleanup that fails can never resurrect it.
|
||||
func TestGenerationIsMonotonicAcrossHomes(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
seqAt := func(dir string) int {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, subCacheFileName("qomar")))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
f, derr := decodeSubCache(data)
|
||||
if derr != nil {
|
||||
t.Fatalf("decode %s: %v", dir, derr)
|
||||
}
|
||||
return f.Seq
|
||||
}
|
||||
|
||||
if err := SaveSubCache("qomar", []Node{{Name: "a", URI: "ss://a", FromSub: "qomar"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := seqAt(persistent)
|
||||
if first < 1 {
|
||||
t.Fatalf("the first write must carry a generation of at least 1, got %d", first)
|
||||
}
|
||||
|
||||
// A degraded write must outrank the persistent copy it could not replace.
|
||||
failWritesTo(t, persistent, errRefused{})
|
||||
if err := SaveSubCache("qomar", []Node{{Name: "b", URI: "ss://b", FromSub: "qomar"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if degraded := seqAt(fallback); degraded <= first {
|
||||
t.Fatalf("the tmpfs copy carries generation %d and the persistent one %d — a copy that "+
|
||||
"supersedes another must say so with a HIGHER number, or the reader cannot tell",
|
||||
degraded, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorityReturnsToThePersistentHome: once the overlay recovers, the next
|
||||
// refresh must put the authoritative copy back where a reboot can find it — even
|
||||
// when the node set has not changed, which is exactly the case an unconditional
|
||||
// "skip if identical" would swallow. Without this the router stays permanently
|
||||
// one reboot away from serving a stale set.
|
||||
func TestAuthorityReturnsToThePersistentHome(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
if err := SaveSubCache("qomar", []Node{{Name: "old-01", URI: "vless://old", FromSub: "qomar"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The overlay is full: the refresh lands in tmpfs and takes authority.
|
||||
failWritesTo(t, persistent, errRefused{})
|
||||
fresh := []Node{{Name: "new-01", URI: "vless://new", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The overlay recovers. The node set is UNCHANGED — the provider returned the
|
||||
// same feed — so a plain "nothing changed, skip" would write nothing at all.
|
||||
writeFileAtomic = writeFileAtomicOS
|
||||
if err := SaveSubCache("qomar", fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(persistent, subCacheFileName("qomar")))
|
||||
if err != nil {
|
||||
t.Fatalf("the persistent home was never rewritten, so a reboot still serves the stale "+
|
||||
"set: %v", err)
|
||||
}
|
||||
f, derr := decodeSubCache(data)
|
||||
if derr != nil {
|
||||
t.Fatal(derr)
|
||||
}
|
||||
if !reflect.DeepEqual(f.Nodes, fresh) {
|
||||
t.Fatalf("the persistent copy holds %+v, want the fresh set %+v", f.Nodes, fresh)
|
||||
}
|
||||
|
||||
// The reboot: tmpfs is gone and the fresh set still comes up.
|
||||
if err := os.RemoveAll(fallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || !reflect.DeepEqual(got, fresh) {
|
||||
t.Fatalf("after the overlay recovered and the router rebooted, the cache serves %+v, "+
|
||||
"want %+v", got, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyPairTiesToThePersistentCopy: two cache files written before the
|
||||
// generation counter existed both read as generation 0. That tie must resolve
|
||||
// exactly the way it did before the counter — to the persistent home — so an
|
||||
// upgrade changes nothing for a router that never degraded to tmpfs, and the
|
||||
// counter's absence is a legitimate value rather than a special case.
|
||||
func TestLegacyPairTiesToThePersistentCopy(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
// Both writes are spelled out against the temp directories rather than routed
|
||||
// through a helper that takes the directory as a PARAMETER. That is not style:
|
||||
// shater/testguard reads this source and requires every mutating call's path to
|
||||
// be PROVABLY rooted in t.TempDir(), and it cannot follow a value handed to a
|
||||
// closure. A path it cannot prove is a failure, not a default — the check exists
|
||||
// because a test once deleted the router's real stats database and passed.
|
||||
if err := os.MkdirAll(persistent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(persistent, subCacheFileName("qomar")),
|
||||
[]byte(legacyCacheBody("from-etc")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(fallback, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fallback, subCacheFileName("qomar")),
|
||||
[]byte(legacyCacheBody("from-tmp")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || len(got) != 1 || got[0].Name != "from-etc" {
|
||||
t.Fatalf("a legacy pair resolved to %+v, want the persistent copy (from-etc): a file with "+
|
||||
"no generation must behave exactly as it did before the counter existed", got)
|
||||
}
|
||||
if all := LoadAllSubCaches(); len(all["qomar"]) != 1 || all["qomar"][0].Name != "from-etc" {
|
||||
t.Fatalf("LoadAllSubCaches resolved the legacy pair to %+v, want the persistent copy",
|
||||
all["qomar"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyFileIsSupersededByANewWrite: the upgrade path. A router whose cache
|
||||
// predates the counter has generation-0 files; the first refresh after the
|
||||
// upgrade must produce a copy that outranks them, in either home.
|
||||
func TestLegacyFileIsSupersededByANewWrite(t *testing.T) {
|
||||
persistent, _ := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
if err := os.MkdirAll(persistent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(persistent, subCacheFileName("qomar")),
|
||||
[]byte(legacyCacheBody("legacy")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A legacy file must still READ (no version bump, or the router boots empty).
|
||||
if got, ok, _ := LoadSubCache("qomar"); !ok || len(got) != 1 || got[0].Name != "legacy" {
|
||||
t.Fatalf("a pre-counter cache file no longer reads: ok=%v %+v — an upgrade must not empty "+
|
||||
"the node inventory", ok, got)
|
||||
}
|
||||
fresh := []Node{{Name: "new-01", URI: "vless://new", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(persistent, subCacheFileName("qomar")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, derr := decodeSubCache(data)
|
||||
if derr != nil {
|
||||
t.Fatal(derr)
|
||||
}
|
||||
if f.Seq < 1 {
|
||||
t.Fatalf("the write after a legacy file carries generation %d; it must claim a number the "+
|
||||
"legacy file cannot match", f.Seq)
|
||||
}
|
||||
if got, _, _ := LoadSubCache("qomar"); !reflect.DeepEqual(got, fresh) {
|
||||
t.Fatalf("after the first post-upgrade refresh the cache still serves %+v, want %+v", got, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyCacheBody renders a cache file as it looked BEFORE the generation counter
|
||||
// existed: no "seq" key at all, so decodeSubCache reads generation 0. It returns
|
||||
// only the BYTES — never a path — so the callers can spell their own temp
|
||||
// directory out at the call site, which is what shater/testguard requires.
|
||||
func legacyCacheBody(nodeName string) string {
|
||||
return `{"version":1,"sub":"qomar","nodes":[{"Name":"` + nodeName + `","URI":"ss://x"}]}`
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package model
|
||||
|
||||
// The subscription cache as the DEFAULT SOURCE, not as a consolation prize.
|
||||
//
|
||||
// The product boots off this cache: /etc/init.d/shater starts shaterd, which runs
|
||||
// Migrate + ReadUCI (= ParseUCIExport + MergeSubCaches) and applies the result.
|
||||
// No fetch happens on that path at all — the refresh is a separate cron job. So
|
||||
// "cache first, refresh later" is not a fallback the daemon reaches for when the
|
||||
// network fails; it is the only way the node inventory ever enters the process,
|
||||
// and a boot with no network is the ORDINARY case, not the exceptional one.
|
||||
//
|
||||
// What these tests hold down is the two halves of that promise:
|
||||
//
|
||||
// 1. a cold start with nothing but the disk produces the full node inventory;
|
||||
// 2. a write that FAILS leaves the previous inventory exactly as it was, and a
|
||||
// write that had to degrade to tmpfs is actually the one that gets read back.
|
||||
//
|
||||
// Half 2 needs the degraded two-directory state, which SHATER_SUBS_DIR cannot
|
||||
// produce (it collapses the chain to one dir) and which a temp directory cannot
|
||||
// produce on demand — hence the writeFileAtomic seam. Every test below that uses
|
||||
// it carries its own POSITIVE CONTROL: the same instrument, unbroken, is shown to
|
||||
// give the other answer, because a "no damage" result from an instrument that
|
||||
// could not have seen damage is not a result.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// twoDirCache points the persistent/tmpfs chain at two temp directories and
|
||||
// clears SHATER_SUBS_DIR, so subCacheDirs() returns the real two-element chain.
|
||||
func twoDirCache(t *testing.T) (persistent, fallback string) {
|
||||
t.Helper()
|
||||
t.Setenv("SHATER_SUBS_DIR", "")
|
||||
persistent, fallback = t.TempDir(), t.TempDir()
|
||||
oldP, oldF := subCacheDirPersistent, subCacheDirFallback
|
||||
subCacheDirPersistent, subCacheDirFallback = persistent, fallback
|
||||
t.Cleanup(func() { subCacheDirPersistent, subCacheDirFallback = oldP, oldF })
|
||||
return persistent, fallback
|
||||
}
|
||||
|
||||
// failWritesTo makes writeFileAtomic refuse every write into dir and pass the
|
||||
// rest through. Returns a pointer to the call count so a test can prove the
|
||||
// instrument was actually exercised.
|
||||
func failWritesTo(t *testing.T, dir string, err error) *int {
|
||||
t.Helper()
|
||||
calls := 0
|
||||
old := writeFileAtomic
|
||||
writeFileAtomic = func(d, path string, data []byte) error {
|
||||
calls++
|
||||
if d == dir {
|
||||
return err
|
||||
}
|
||||
return old(d, path, data)
|
||||
}
|
||||
t.Cleanup(func() { writeFileAtomic = old })
|
||||
return &calls
|
||||
}
|
||||
|
||||
// quietCacheLog silences the degradation notices so a deliberate failure does not
|
||||
// print pages of noise, and returns the messages for inspection.
|
||||
func quietCacheLog(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
var got []string
|
||||
old := subCacheLogf
|
||||
subCacheLogf = func(f string, a ...any) { got = append(got, f) }
|
||||
t.Cleanup(func() { subCacheLogf = old })
|
||||
return &got
|
||||
}
|
||||
|
||||
// --- half 1: the cold start --------------------------------------------------
|
||||
|
||||
// TestColdStartServesTheWholeInventoryFromDisk: with no network and nothing but
|
||||
// /etc/config/shater plus the cache files, a read produces every subscription
|
||||
// node — in configuration order, with FromSub restored — alongside the manual
|
||||
// nodes. This is what a reboot on a dead uplink actually does.
|
||||
func TestColdStartServesTheWholeInventoryFromDisk(t *testing.T) {
|
||||
setSubsDir(t)
|
||||
if err := SaveSubCache("qomar", []Node{
|
||||
{Name: "nl-01", Enabled: true, URI: "vless://nl", FromSub: "qomar"},
|
||||
{Name: "de-02", Enabled: false, URI: "vless://de", FromSub: "qomar"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveSubCache("second", []Node{
|
||||
{Name: "jp-01", Enabled: true, URI: "vless://jp", FromSub: "second"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The config as it is on disk: subscriptions and a manual node, no `config
|
||||
// node` for anything the feed supplied (RenderUCIExport does not emit those).
|
||||
m, err := ParseUCIExport(`package shater
|
||||
|
||||
config subscription
|
||||
option name 'qomar'
|
||||
option url 'https://p.example/q'
|
||||
|
||||
config subscription
|
||||
option name 'second'
|
||||
option url 'https://p.example/s'
|
||||
|
||||
config node
|
||||
option name 'manual-wg'
|
||||
option uri 'wireguard://home'
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
// ReadUCI is ParseUCIExport + this call; the merge is the whole cold start.
|
||||
MergeSubCaches(m)
|
||||
|
||||
var names []string
|
||||
for _, n := range m.Nodes {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
want := []string{"manual-wg", "nl-01", "de-02", "jp-01"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("cold start produced %v, want %v — a reboot with no network must come up with the "+
|
||||
"whole inventory, not with the manual nodes only", names, want)
|
||||
}
|
||||
// The per-node state the operator set must survive too: a node parked by hand
|
||||
// that comes back enabled after a reboot puts traffic on it again.
|
||||
for _, n := range m.Nodes {
|
||||
if n.Name == "de-02" && n.Enabled {
|
||||
t.Fatal("a node switched OFF by hand came back enabled from the cache")
|
||||
}
|
||||
if n.Name == "nl-01" && n.FromSub != "qomar" {
|
||||
t.Fatalf("FromSub not restored from the file's own key: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- half 2: a failed write may not cost the inventory -----------------------
|
||||
|
||||
// TestFailedWriteLeavesThePreviousCacheIntact: when every home refuses the bytes,
|
||||
// SaveSubCache reports the failure and the node set already on disk is untouched.
|
||||
// A refresh that cannot be persisted must cost a stale inventory, never an empty
|
||||
// one — the daemon boots off this file.
|
||||
//
|
||||
// POSITIVE CONTROL: the same instrument with the failure removed IS shown to
|
||||
// replace the file, so "unchanged" here means the write was stopped and not that
|
||||
// the test could never have observed a change.
|
||||
func TestFailedWriteLeavesThePreviousCacheIntact(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
good := []Node{{Name: "nl-01", Enabled: true, URI: "vless://nl", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", good); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// CONTROL: with the instrument unbroken, a second save DOES change the file.
|
||||
replacement := []Node{{Name: "jp-09", Enabled: true, URI: "vless://jp", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", replacement); err != nil {
|
||||
t.Fatalf("control save: %v", err)
|
||||
}
|
||||
if got, _, _ := LoadSubCache("qomar"); len(got) != 1 || got[0].Name != "jp-09" {
|
||||
t.Fatalf("CONTROL FAILED: a good save did not replace the cache (%+v) — this test could not "+
|
||||
"have detected a clobber either", got)
|
||||
}
|
||||
// Put the known-good set back and make every home refuse.
|
||||
if err := SaveSubCache("qomar", good); err != nil {
|
||||
t.Fatalf("re-seed: %v", err)
|
||||
}
|
||||
callsP := failWritesTo(t, persistent, errRefused{})
|
||||
_ = callsP
|
||||
failWritesTo(t, fallback, errRefused{}) // stacked: both homes now refuse
|
||||
|
||||
if err := SaveSubCache("qomar", replacement); err == nil {
|
||||
t.Fatal("SaveSubCache reported success while every home refused the write")
|
||||
}
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok {
|
||||
t.Fatal("the previous cache is GONE after a failed write — the daemon now boots with no nodes")
|
||||
}
|
||||
if !reflect.DeepEqual(got, good) {
|
||||
t.Fatalf("the previous cache was damaged by a failed write: %+v, want %+v", got, good)
|
||||
}
|
||||
}
|
||||
|
||||
// errRefused stands in for the filesystem declining the bytes (ENOSPC/EROFS on a
|
||||
// full or read-only overlay — the routine OpenWrt state this path exists for).
|
||||
type errRefused struct{}
|
||||
|
||||
func (errRefused) Error() string { return "no space left on device" }
|
||||
|
||||
// TestDegradedWriteIsTheOneReadBack is the defect this file was written for.
|
||||
//
|
||||
// SaveSubCache degrades to tmpfs precisely when the persistent home REFUSED the
|
||||
// write, so at that instant the persistent copy is by definition the stale one.
|
||||
// The load order used to be "the earlier (higher-priority) dir wins", which meant
|
||||
// a successful `sub update` wrote the fresh nodes to /tmp, reported "N nodes
|
||||
// cached", and every reader went on serving the OLD set out of /etc — an
|
||||
// instrument reading identically in the working and the broken case.
|
||||
//
|
||||
// The winner is decided by the generation counter in the file. The first attempt
|
||||
// compared modification times and this test FAILED on Linux 5 runs out of 5 while
|
||||
// passing on Windows — see TestAuthorityIgnoresTheClock, which is the guard that
|
||||
// makes that mistake unrepeatable.
|
||||
//
|
||||
// POSITIVE CONTROL: the stale persistent file is shown to be present and
|
||||
// readable, so "the fresh set was returned" is a choice between two real
|
||||
// candidates and not the absence of one.
|
||||
func TestDegradedWriteIsTheOneReadBack(t *testing.T) {
|
||||
persistent, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
stale := []Node{{Name: "old-01", Enabled: true, URI: "vless://old", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", stale); err != nil {
|
||||
t.Fatalf("seed the persistent home: %v", err)
|
||||
}
|
||||
if _, err := statFile(filepath.Join(persistent, subCacheFileName("qomar"))); err != nil {
|
||||
t.Fatalf("seed did not land in the persistent home: %v", err)
|
||||
}
|
||||
|
||||
// The overlay fills up; the refresh succeeds over the network and degrades.
|
||||
failWritesTo(t, persistent, errRefused{})
|
||||
fresh := []Node{
|
||||
{Name: "new-01", Enabled: true, URI: "vless://new1", FromSub: "qomar"},
|
||||
{Name: "new-02", Enabled: true, URI: "vless://new2", FromSub: "qomar"},
|
||||
}
|
||||
if err := SaveSubCache("qomar", fresh); err != nil {
|
||||
t.Fatalf("degraded save should still succeed via tmpfs: %v", err)
|
||||
}
|
||||
|
||||
// CONTROL: both candidates exist. The choice below is a real choice.
|
||||
if _, err := statFile(filepath.Join(persistent, subCacheFileName("qomar"))); err != nil {
|
||||
t.Fatalf("CONTROL FAILED: the stale persistent copy is gone, so preferring the fresh one "+
|
||||
"proves nothing: %v", err)
|
||||
}
|
||||
if _, err := statFile(filepath.Join(fallback, subCacheFileName("qomar"))); err != nil {
|
||||
t.Fatalf("the degraded write did not reach tmpfs: %v", err)
|
||||
}
|
||||
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || !reflect.DeepEqual(got, fresh) {
|
||||
t.Fatalf("LoadSubCache returned %+v, want the FRESH set %+v — a refresh that reported "+
|
||||
"success is not in force", got, fresh)
|
||||
}
|
||||
all := LoadAllSubCaches()
|
||||
if !reflect.DeepEqual(all["qomar"], fresh) {
|
||||
t.Fatalf("LoadAllSubCaches returned %+v, want the FRESH set %+v (this is the call ReadUCI "+
|
||||
"makes, so it is the one the daemon actually uses)", all["qomar"], fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistentCopySurvivesTheRebootAfterADegradedWrite is the other side of the
|
||||
// same rule, and the reason it is "newest wins" rather than "tmpfs wins": once
|
||||
// tmpfs is gone (a reboot), the persistent copy is the only candidate and must
|
||||
// still come up. Stale nodes beat no nodes.
|
||||
func TestPersistentCopySurvivesTheRebootAfterADegradedWrite(t *testing.T) {
|
||||
_, fallback := twoDirCache(t)
|
||||
quietCacheLog(t)
|
||||
|
||||
stale := []Node{{Name: "old-01", Enabled: true, URI: "vless://old", FromSub: "qomar"}}
|
||||
if err := SaveSubCache("qomar", stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The reboot: tmpfs is empty again.
|
||||
if err := removeAllFiles(fallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || !reflect.DeepEqual(got, stale) {
|
||||
t.Fatalf("after a reboot the persistent cache must still come up: ok=%v %+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncSubCachesRewritesToTheModelItIsGiven pins the panel-PUT contract in the
|
||||
// direction that can LOSE nodes, so any future change to it is deliberate.
|
||||
//
|
||||
// SyncSubCaches makes the cache files equal to the FromSub nodes carried in the
|
||||
// model — including emptying one whose nodes are all gone, which is what makes
|
||||
// "delete the last node" stick. The consequence is that a caller which builds a
|
||||
// model WITHOUT MergeSubCaches (ParseUCIExport alone: no FromSub nodes at all)
|
||||
// and hands it here empties every cache the router has. The panel never does this
|
||||
// — it PUTs back the merged model it GETs — but nothing in this package enforces
|
||||
// that, and the failure would be silent and total.
|
||||
func TestSyncSubCachesRewritesToTheModelItIsGiven(t *testing.T) {
|
||||
setSubsDir(t)
|
||||
if err := SaveSubCache("qomar", []Node{{Name: "nl-01", URI: "vless://nl", FromSub: "qomar"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmerged := &Model{Subscriptions: []Subscription{{Name: "qomar", Enabled: true}}}
|
||||
if err := SyncSubCaches(unmerged); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
nodes, ok, _ := LoadSubCache("qomar")
|
||||
if !ok || len(nodes) != 0 {
|
||||
t.Fatalf("SyncSubCaches(model without FromSub nodes) left %+v (ok=%v). If this now KEEPS the "+
|
||||
"nodes the behaviour changed deliberately — update this test and the comment above it, "+
|
||||
"because \"delete the last node\" depends on the emptying half", nodes, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// statFile / removeAllFiles are the two filesystem pokes these tests need; they
|
||||
// live here rather than in subcache.go because nothing in the product does them.
|
||||
func statFile(path string) (os.FileInfo, error) { return os.Stat(path) }
|
||||
|
||||
func removeAllFiles(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWriteIsTempThenRename pins the STRUCTURE of the real writer: it builds the
|
||||
// new file beside the destination and moves it into place, so the destination is
|
||||
// never opened for writing and a failure leaves it as it was — and it cleans up
|
||||
// after itself when the move cannot happen.
|
||||
//
|
||||
// What this test can and cannot see is worth stating. There is no portable way to
|
||||
// make a filesystem answer ENOSPC on demand, so the behaviour of this function
|
||||
// under a full overlay is NOT exercised here; that is the whole reason
|
||||
// writeFileAtomic is a seam and why the guarantee under test in
|
||||
// TestFailedWriteLeavesThePreviousCacheIntact is the caller-level one. What IS
|
||||
// exercised is the part a black-box test can reach: a move that cannot succeed
|
||||
// costs neither the destination nor a stray temp file in the cache directory,
|
||||
// where LoadAllSubCaches would later have to skip it by name.
|
||||
func TestWriteIsTempThenRename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := writeFileAtomicOS(dir, filepath.Join(dir, "ok.json"), []byte("one")); err != nil {
|
||||
t.Fatalf("plain write: %v", err)
|
||||
}
|
||||
if n := tempLitter(t, dir); n != 0 {
|
||||
t.Fatalf("a SUCCESSFUL write left %d temp file(s) behind in the cache directory", n)
|
||||
}
|
||||
|
||||
// A destination the move cannot possibly take: a non-empty directory. Rename
|
||||
// onto one fails on every platform this ships to.
|
||||
blocked := filepath.Join(dir, "blocked.json")
|
||||
if err := os.MkdirAll(filepath.Join(blocked, "inside"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeFileAtomicOS(dir, blocked, []byte("two")); err == nil {
|
||||
t.Fatal("writeFileAtomicOS reported success onto a destination it cannot replace")
|
||||
}
|
||||
fi, err := os.Stat(blocked)
|
||||
if err != nil || !fi.IsDir() {
|
||||
t.Fatalf("the destination was disturbed by a write that failed: %v %v", fi, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(blocked, "inside")); err != nil {
|
||||
t.Fatalf("the destination's contents were destroyed by a write that failed: %v", err)
|
||||
}
|
||||
if n := tempLitter(t, dir); n != 0 {
|
||||
t.Fatalf("a FAILED write left %d temp file(s) in the cache directory; they accumulate on "+
|
||||
"every retry of a subscription whose refresh cannot be persisted", n)
|
||||
}
|
||||
}
|
||||
|
||||
// tempLitter counts leftover .subcache-*.tmp files in dir.
|
||||
func tempLitter(t *testing.T, dir string) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".subcache-") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
+35
-12
@@ -71,11 +71,16 @@ func ParseUCIExport(text string) (*Model, error) {
|
||||
})
|
||||
case "subscription":
|
||||
m.Subscriptions = append(m.Subscriptions, Subscription{
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
URL: s.opt("url"),
|
||||
UpdateInterval: s.opt("update_interval"),
|
||||
FetchVia: s.optOr("fetch_via", "direct"),
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
Enabled: s.optBool("enabled", true),
|
||||
URL: s.opt("url"),
|
||||
UpdateInterval: s.opt("update_interval"),
|
||||
// s.opt, NOT s.optOr(..., "direct"): an ABSENT `option fetch_via`
|
||||
// must stay absent in the model, because "" now means "inherit the
|
||||
// general fetch detour" and `direct` means "explicitly in the clear".
|
||||
// Defaulting here is what made the two indistinguishable; the v2->v3
|
||||
// migration is what converts the existing absences, once, on disk.
|
||||
FetchVia: s.opt("fetch_via"),
|
||||
FetchDetour: s.opt("fetch_detour"),
|
||||
UA: s.opt("ua"),
|
||||
HWID: s.opt("hwid"),
|
||||
@@ -148,13 +153,16 @@ func ParseUCIExport(text string) (*Model, error) {
|
||||
Name: firstNonEmpty(s.opt("name"), s.Name),
|
||||
Type: s.optOr("type", "direct"),
|
||||
Interface: s.opt("interface"),
|
||||
// Default 0, NOT 1080: the port is byedpi-only, and generate already
|
||||
// substitutes 1080 for a byedpi egress that leaves it unset. Seeding
|
||||
// it here gave every interface/direct egress a port it never asked
|
||||
// for, which then tripped the "port is ignored" warning on a config
|
||||
// the operator had written correctly.
|
||||
Port: parseInt(s.opt("port"), 0),
|
||||
DPI: s.opt("dpi"),
|
||||
// `option port` is deliberately NOT read. It served exactly one
|
||||
// egress kind — the retired `byedpi`, which dialled a local SOCKS
|
||||
// listener — and no surviving kind dials anything, so parsing it
|
||||
// would only carry a value nothing can act on. An old config still
|
||||
// carrying the option loads fine (unknown options are ignored) and
|
||||
// the option drains out on the next render, the same way the deleted
|
||||
// per-group probe_url/probe_interval did. An egress that still says
|
||||
// `type 'byedpi'` is not silent about it either — see
|
||||
// model.RetiredEgressTypes and ValidateEgresses.
|
||||
DPI: s.opt("dpi"),
|
||||
})
|
||||
case "ruleset":
|
||||
m.Rulesets = append(m.Rulesets, Ruleset{
|
||||
@@ -229,6 +237,15 @@ func ParseUCIExport(text string) (*Model, error) {
|
||||
EnableRules: nonEmpty(s.list("enable_rule")),
|
||||
DisableRules: nonEmpty(s.list("disable_rule")),
|
||||
EndpointResolver: s.opt("endpoint_resolver"),
|
||||
// Per-profile DNS/fetch overrides. All three are SCALARS and are read
|
||||
// with s.opt (never s.list): one profile names one resolver and one
|
||||
// detour. "" (absent) = inherit from globals, per field — so a profile
|
||||
// that sets only resolver_fallback keeps globals' resolver_default.
|
||||
// There is deliberately no default here: optOr would turn "not set"
|
||||
// into a value and destroy the inheritance the fields exist for.
|
||||
ResolverDefault: s.opt("resolver_default"),
|
||||
ResolverFallback: s.opt("resolver_fallback"),
|
||||
FetchDetour: s.opt("fetch_detour"),
|
||||
})
|
||||
case "resolver":
|
||||
m.Resolvers = append(m.Resolvers, Resolver{
|
||||
@@ -340,6 +357,12 @@ func applyGlobals(g *Globals, s uciSection) {
|
||||
g.ResolverDefault = s.opt("resolver_default")
|
||||
g.ResolverFallback = s.opt("resolver_fallback")
|
||||
g.EndpointResolver = s.opt("endpoint_resolver")
|
||||
// The GENERAL fetch detour. No default: "" is a state of its own ("nothing is
|
||||
// configured, fetches go direct as they always did"), and seeding it with
|
||||
// `direct` would make a config that never mentioned the option indistinguishable
|
||||
// from one that chose clear-text on purpose — the same mistake `fetch_via`
|
||||
// carried until v3.
|
||||
g.FetchDetour = s.opt("fetch_detour")
|
||||
g.ProbeURL = s.opt("probe_url")
|
||||
g.ProbeInterval = s.opt("probe_interval")
|
||||
// No sweep_interval: the background sweep is gone (the observatory probes only
|
||||
|
||||
+102
-4
@@ -287,6 +287,12 @@ func ValidateGlobals(g Globals) []Warning {
|
||||
var out []Warning
|
||||
add := func(msg string) { out = append(out, Warning{Section: "globals", Message: msg}) }
|
||||
|
||||
// The general fetch detour, judged for SHAPE only — whether the named
|
||||
// node/group/egress/chain exists is the generator's verdict.
|
||||
if msg := FetchDetourFormWarning("fetch_detour", g.FetchDetour); msg != "" {
|
||||
add(msg)
|
||||
}
|
||||
|
||||
if lvl := strings.ToLower(strings.TrimSpace(g.LogLevel)); lvl != "" &&
|
||||
!inSet(lvl, EngineLogLevels) && !inSet(lvl, SilentLogLevels) {
|
||||
add(fmt.Sprintf("log level %q is not recognised and is applied as \"warn\"; "+
|
||||
@@ -417,6 +423,84 @@ func ValidateSubscriptions(subs []Subscription) []Warning {
|
||||
"auto-detected instead; valid values are %s.", s.Format, strings.Join(SubFormatNames, "/")),
|
||||
})
|
||||
}
|
||||
// `fetch_via` has exactly three states and one of them has no spelling
|
||||
// (absent = inherit the general fetch detour). Anything else is
|
||||
// FetchViaUnknown, and nothing downstream may pick a side for it: this is
|
||||
// the branch that names it instead.
|
||||
//
|
||||
// Before schema v3 this could not be said at all, because every reader
|
||||
// asked `EqualFold(v, "proxy")` and a typo fell into the else — so
|
||||
// `fetch_via='prxoy'` put the feed URL and this router's real address on
|
||||
// the plain WAN, successfully, with nothing to see anywhere.
|
||||
if ClassifyFetchVia(s.FetchVia) == FetchViaUnknown {
|
||||
out = append(out, Warning{
|
||||
Section: "subscription",
|
||||
Name: s.Name,
|
||||
Message: fmt.Sprintf("fetch_via %q is not a value this option has; the accepted "+
|
||||
"values are %s, and leaving it out means \"use the general fetch_detour\". "+
|
||||
"Nothing routes this feed until it says one of them: the value is not demoted "+
|
||||
"to `direct` any more, because that silently put the feed URL and this "+
|
||||
"router's real address on the plain WAN.",
|
||||
s.FetchVia, strings.Join(FetchViaNames, "/")),
|
||||
})
|
||||
}
|
||||
// A subscription's own detour, judged for SHAPE only (see
|
||||
// FetchDetourFormWarning). Only meaningful when the subscription actually
|
||||
// uses it — with fetch_via=direct or absent the value is inert, and
|
||||
// apply.subscriptionFetchWarnings already owns the "proxy with no detour"
|
||||
// finding, which is about content, not shape.
|
||||
if ClassifyFetchVia(s.FetchVia) == FetchViaProxy {
|
||||
if msg := FetchDetourFormWarning("fetch_detour", s.FetchDetour); msg != "" {
|
||||
out = append(out, Warning{Section: "subscription", Name: s.Name, Message: msg})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateProfiles reports per-profile overrides that name something this
|
||||
// configuration does not have, or that are shaped so they cannot resolve.
|
||||
//
|
||||
// A profile override is the one setting whose failure is INVISIBLE by
|
||||
// construction: it only applies while that profile is active, so a typo in the
|
||||
// SIM profile's `resolver_default` is not observable at all until the ethernet
|
||||
// cable comes out — at which point DNS stops working and the config that broke it
|
||||
// looks exactly like the config that worked. That is why an unresolvable name is
|
||||
// reported here rather than left to the moment it is used.
|
||||
//
|
||||
// resolvers is the configured `config resolver` set. What is checked is
|
||||
// EXISTENCE BY NAME, which is all model can check; whether the resolver is
|
||||
// reachable is nobody's compile-time verdict.
|
||||
func ValidateProfiles(profiles []Profile, resolvers []Resolver) []Warning {
|
||||
known := make(map[string]bool, len(resolvers))
|
||||
for _, r := range resolvers {
|
||||
known[strings.TrimSpace(r.Name)] = true
|
||||
}
|
||||
var out []Warning
|
||||
for _, p := range profiles {
|
||||
// Positive, closed table: field name -> the value the profile set. Adding a
|
||||
// fifth override means adding a row here, not remembering to.
|
||||
for _, f := range []struct{ field, value string }{
|
||||
{"resolver_default", p.ResolverDefault},
|
||||
{"resolver_fallback", p.ResolverFallback},
|
||||
{"endpoint_resolver", p.EndpointResolver},
|
||||
} {
|
||||
v := strings.TrimSpace(f.value)
|
||||
if v == "" || known[v] {
|
||||
continue // not overridden, or overridden with something that exists
|
||||
}
|
||||
out = append(out, Warning{
|
||||
Section: "profile",
|
||||
Name: p.Name,
|
||||
Message: fmt.Sprintf("%s %q names no `config resolver` in this configuration. "+
|
||||
"The override cannot be honoured, so the globals value stays in force while "+
|
||||
"this profile is active — and because a profile only applies on its own "+
|
||||
"uplink, this is invisible until that uplink is the live one.", f.field, v),
|
||||
})
|
||||
}
|
||||
if msg := FetchDetourFormWarning("fetch_detour", p.FetchDetour); msg != "" {
|
||||
out = append(out, Warning{Section: "profile", Name: p.Name, Message: msg})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -552,10 +636,10 @@ func ValidateGroups(groups []Group) []Warning {
|
||||
// egressTypeHasDevice reports whether this egress KIND is one that leaves through
|
||||
// a network device of its own. Exactly one canonical kind does: `interface` (a
|
||||
// second uplink, or a wg/awg tunnel device — `tunnel` is an accepted alias that
|
||||
// CanonicalEgressType folds into it). `direct` and `byedpi` have no device by
|
||||
// design — they dial over the main table — so their empty Interface is not a
|
||||
// defect and must never be reported as one. An UNKNOWN type has no device
|
||||
// either: nothing routes it, so nothing may claim it needs an interface.
|
||||
// CanonicalEgressType folds into it). `direct` has no device by design — it
|
||||
// dials over the main table — so its empty Interface is not a defect and must
|
||||
// never be reported as one. An UNKNOWN or RETIRED type has no device either:
|
||||
// nothing routes it, so nothing may claim it needs an interface.
|
||||
//
|
||||
// It is now a thin wrapper over CanonicalEgressType, which is the point: the
|
||||
// alias table is written once (model.go) and every consumer, on both sides of
|
||||
@@ -606,6 +690,13 @@ func EgressHasDevice(eg Egress) bool {
|
||||
// # And an egress whose type is not a type
|
||||
//
|
||||
// A type outside the closed set (EgressTypeKnown) gets its own, earlier finding.
|
||||
// Two of them, in fact, because two different things happened to the operator.
|
||||
// A type this product REMOVED (RetiredEgressTypes) is reported with the sentence
|
||||
// that names the removal and the replacement; everything else is reported as
|
||||
// unrecognised. The router does the identical thing in both cases — nothing —
|
||||
// and the split is purely in what is said, which is the point: the generic
|
||||
// wording sends someone hunting for a typo in a value that was correct when they
|
||||
// wrote it.
|
||||
// Until now nothing in model said a word about it: the ONLY notice was a
|
||||
// generator warning, which is emitted when an engine config is built and says
|
||||
// nothing about the data plane. The two halves also answered differently — the
|
||||
@@ -620,6 +711,13 @@ func ValidateEgresses(egresses []Egress) []Warning {
|
||||
var out []Warning
|
||||
for _, eg := range egresses {
|
||||
if !EgressTypeKnown(eg.Type) {
|
||||
// The retired kinds are checked FIRST and are not folded into the
|
||||
// unrecognised arm: they fail closed identically, and they are the one
|
||||
// case where "not one of interface/direct" would be actively misleading.
|
||||
if msg, retired := RetiredEgressType(eg.Type); retired {
|
||||
out = append(out, Warning{Section: "egress", Name: eg.Name, Message: msg})
|
||||
continue
|
||||
}
|
||||
out = append(out, Warning{
|
||||
Section: "egress",
|
||||
Name: eg.Name,
|
||||
|
||||
@@ -298,7 +298,7 @@ func TestValidateGlobalsL3TunnelDirectConflict(t *testing.T) {
|
||||
func TestValidateUntunnelableEgress(t *testing.T) {
|
||||
egresses := []Egress{
|
||||
{Name: "wan2", Type: "interface", Interface: "wan2"},
|
||||
{Name: "dpi", Type: "byedpi", Port: 1080},
|
||||
{Name: "dpi", Type: "byedpi"}, // a RETIRED kind — no device, nowhere to route
|
||||
{Name: "nodev", Type: "tunnel"},
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestValidateUntunnelableEgress(t *testing.T) {
|
||||
// "interface" is now the ONE canonical device kind (`tunnel` is an alias
|
||||
// CanonicalEgressType folds into it), so the message names it in the singular.
|
||||
if !hasWarning(ws, "globals", "not interface") {
|
||||
t.Fatalf("a byedpi/direct egress must warn (nowhere to route to), got %v", ws)
|
||||
t.Fatalf("a device-less egress must warn (nowhere to route to), got %v", ws)
|
||||
}
|
||||
// The message has to name the offending type, or the operator cannot see
|
||||
// which of their egresses they mis-picked.
|
||||
|
||||
@@ -58,7 +58,10 @@ package netplane
|
||||
// probe fails, the gate closes, and rendering behaves exactly as it did before.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
@@ -451,3 +454,660 @@ func parseInternetForwardedZones(exportText string) map[string]bool {
|
||||
}
|
||||
return fwd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EXPLICITLY NAMED PRIVATE DESTINATIONS
|
||||
//
|
||||
// The plane bypasses every private destination before the divert can see it —
|
||||
// three copies of `ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
|
||||
// 127.0.0.0/8, 169.254.0.0/16 } accept`, one per chain. That default is right:
|
||||
// LAN-to-LAN traffic, the router's own services and every local plane must not
|
||||
// be dragged through a tunnel, and a catch-all rule must never quietly acquire
|
||||
// them.
|
||||
//
|
||||
// It is wrong in exactly one case. When an operator writes a rule whose
|
||||
// destination rule-set names a private subnet OUTRIGHT — 10.10.10.0/24 ->
|
||||
// node:awghome, a second site behind a WireGuard node — the accept above fires
|
||||
// first and the rule NEVER MATCHES. Nothing said so: no warning, no counter, no
|
||||
// difference in the rendered plan. The rule simply did nothing, for ever.
|
||||
//
|
||||
// So: the bypass stays the default, and naming a subnet outright overrides it.
|
||||
// "Outright" is the whole contract, and it is deliberately narrow:
|
||||
//
|
||||
// - the CIDR must be an ENTRY of an INLINE `config ruleset` with type=ipcidr —
|
||||
// the only destination list whose contents this stage can actually read;
|
||||
// - it must be CONTAINED in 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16. A
|
||||
// prefix that merely overlaps one (0.0.0.0/0, 10.0.0.0/7) is not a naming,
|
||||
// it is a catch-all that happens to include private space;
|
||||
// - it must be referenced by an ENABLED rule (profile-effective) whose target
|
||||
// actually routes somewhere other than plain direct — see
|
||||
// privateOverrideTarget;
|
||||
// - it must not overlap ANY network this router itself carries. That is not
|
||||
// routing, it is self-amputation, and it is refused BY NAME, not silently;
|
||||
// - 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4 and 255.255.255.255 are never
|
||||
// taken, whatever a list says.
|
||||
//
|
||||
// # Why the doubt always falls back to the bypass
|
||||
//
|
||||
// The two errors are not symmetrical. Failing to route a named subnet costs the
|
||||
// operator a feature and shows up the moment they test it. Routing a subnet we
|
||||
// should not have touched can take the router's own management network into a
|
||||
// tunnel that may not even be up — an outage reachable only over the console. So
|
||||
// every uncertain input (an unreadable list, an inventory we could not
|
||||
// enumerate, an address family we cannot check the router's own addresses in)
|
||||
// resolves to "leave it on the bypass", and says so.
|
||||
//
|
||||
// # IPv4 only, and the reason is the inventory, not the effort
|
||||
//
|
||||
// The router-network guard is built on `ubus call network.interface dump`, which
|
||||
// reports `ipv4-address` and nothing else. With no v6 inventory there is no way
|
||||
// to tell a named ULA apart from this router's OWN v6 LAN, so honouring a v6
|
||||
// private prefix would be exactly the guess the paragraph above forbids. Named
|
||||
// v6 private space is therefore left on the bypass and reported.
|
||||
//
|
||||
// # What it carries, and what it does not
|
||||
//
|
||||
// The override is rendered as TPROXY divert lines, so by itself it moves TCP and
|
||||
// UDP and nothing else: the kernel needs a socket to hand the packet to, and
|
||||
// ICMP has not got one. That is not an acceptable place to stop, because `ping`
|
||||
// is how anyone checks whether a route works — a named subnet whose TCP
|
||||
// succeeds while ping reports 100% loss reads as a broken route, which is this
|
||||
// same defect one protocol down.
|
||||
//
|
||||
// So the L3 ingress carries the echo, using the mechanism that already exists:
|
||||
// with `l3_tunnel` on, the renderer stamps the L3 mark on ICMP bound for these
|
||||
// destinations (again ABOVE the bypass, which is the only reason it was not
|
||||
// already happening), `ip rule` delivers it into the engine's TUN, the SAME
|
||||
// route rules pick the outbound there, and a WireGuard/AmneziaWG one moves it
|
||||
// for real. See model.Globals.L3Tunnel: TCP, UDP and ICMP echo are exactly what
|
||||
// sing-tun's dispatcher takes, and an outbound that cannot carry L3 drops the
|
||||
// echo honestly rather than forging a reply.
|
||||
//
|
||||
// With `l3_tunnel` OFF nothing carries ICMP and no line pretends otherwise —
|
||||
// and the rule that named the subnet is told so by name, because silence there
|
||||
// would rebuild the defect this file exists to remove.
|
||||
//
|
||||
// ESP, AH, GRE, IGMP and SCTP to a named private subnet are carried by nothing
|
||||
// here, exactly as they are to any other destination; Globals.Untunnelable and
|
||||
// Globals.UntunnelableEgress remain the only answers for them.
|
||||
|
||||
// privateOverridable are the blocks inside which an explicitly named prefix may
|
||||
// override the bypass. Positive and closed: a prefix that is not CONTAINED in
|
||||
// one of these is never taken.
|
||||
var privateOverridable = []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}
|
||||
|
||||
// privateNeverRoutable is the part of the bypass no configuration may override.
|
||||
// Loopback, link-local, multicast and the limited broadcast address are not
|
||||
// destinations that survive a tunnel, and they carry planes the LAN needs to
|
||||
// stay up. A rule naming them is a mistake, and the mistake is reported rather
|
||||
// than obeyed.
|
||||
var privateNeverRoutable = []netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("255.255.255.255/32"),
|
||||
}
|
||||
|
||||
// privateV6Bypassed mirrors the `ip6 daddr { ::1, fc00::/7, fe80::/10, ff00::/8
|
||||
// } accept` line. Used ONLY to recognise a named v6 private prefix so it can be
|
||||
// reported as not-overridden — never to route one. See the file comment.
|
||||
var privateV6Bypassed = []netip.Prefix{
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
// IsIPCIDRRulesetType reports whether a `config ruleset` type names an ADDRESS
|
||||
// list rather than a domain list. "ipcidr" is the model's spelling, "ip_cidr"
|
||||
// mirrors the engine's field name, "ip" is the shorthand; all three are exact
|
||||
// synonyms, and everything else (including the empty default) means domains.
|
||||
//
|
||||
// Exported because generate needs the identical answer when it builds the
|
||||
// engine's headless rule (generate.ruleSetTypeIsIPCIDR delegates here) and that
|
||||
// package cannot be imported from this one. One definition, two callers — the
|
||||
// alternative is a fourth copy of a vocabulary this project has already paid for.
|
||||
func IsIPCIDRRulesetType(rsType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(rsType)) {
|
||||
case "ipcidr", "ip_cidr", "ip":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addrListKind classifies a `config ruleset` by what this stage can learn from
|
||||
// it about ADDRESSES.
|
||||
type addrListKind int
|
||||
|
||||
const (
|
||||
// addrListNone: nothing here can name an address we could read or miss — a
|
||||
// domain list, a geosite list, a geo IP list of country codes, or a source
|
||||
// this build does not know.
|
||||
addrListNone addrListKind = iota
|
||||
// addrListInline: the Entries ARE the content, and we can read them.
|
||||
addrListInline
|
||||
// addrListOpaque: an address list whose content is a file, a download or a
|
||||
// geo database — real addresses, invisible here.
|
||||
addrListOpaque
|
||||
)
|
||||
|
||||
// rulesetAddrListKind is the closed positive classification of a rule-set.
|
||||
//
|
||||
// There is no open default that routes: the last return is addrListNone, so an
|
||||
// unrecognised source contributes no CIDR and makes no claim. That direction is
|
||||
// the recoverable one — an unknown source is already reported by generate
|
||||
// ("unknown source %q, skipped"), and treating it as readable would mean
|
||||
// honouring `Entries` that the engine itself ignores.
|
||||
func rulesetAddrListKind(rs model.Ruleset) addrListKind {
|
||||
switch strings.ToLower(strings.TrimSpace(rs.Source)) {
|
||||
case "inline", "":
|
||||
// "" is the UCI parser's own default for `source` (model/uci.go), so a
|
||||
// section written without one is an inline list, and a model built in code
|
||||
// that leaves the field blank means the same thing. Nothing is risked
|
||||
// either way: only `Entries` are ever read here, and a non-inline
|
||||
// rule-set's Entries are dead text the engine never looks at.
|
||||
if IsIPCIDRRulesetType(rs.Type) {
|
||||
return addrListInline
|
||||
}
|
||||
return addrListNone
|
||||
case "url", "file":
|
||||
if IsIPCIDRRulesetType(rs.Type) {
|
||||
return addrListOpaque
|
||||
}
|
||||
return addrListNone
|
||||
case "geoip":
|
||||
// A geo IP list is an address list by construction, whatever its `type`
|
||||
// says. Two cases are NOT opaque: one with no category at all builds
|
||||
// nothing (generate warns and skips it, so it can neither route nor hide
|
||||
// anything), and one whose categories are all ISO country codes holds
|
||||
// public routable space only. Everything else — `private` above all, which
|
||||
// IS the private-space list — is opaque.
|
||||
cats := geoipCategories(rs)
|
||||
switch {
|
||||
case len(cats) == 0:
|
||||
return addrListNone
|
||||
case len(geoipCountryCategories(cats)) == len(cats):
|
||||
return addrListNone
|
||||
}
|
||||
return addrListOpaque
|
||||
case "geosite", "subscription":
|
||||
return addrListNone
|
||||
}
|
||||
return addrListNone
|
||||
}
|
||||
|
||||
// geoipCategories is the rule-set's non-empty category list.
|
||||
func geoipCategories(rs model.Ruleset) []string {
|
||||
var out []string
|
||||
for _, c := range rs.Categories {
|
||||
if c = strings.TrimSpace(c); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// geoipCountryCategories returns the categories that are ISO-3166 alpha-2
|
||||
// country codes. Closed and positive: a category this function does not
|
||||
// recognise is not trusted, so a typo errs towards the disclosure rather than
|
||||
// towards silence.
|
||||
func geoipCountryCategories(cats []string) []string {
|
||||
var out []string
|
||||
for _, c := range cats {
|
||||
c = strings.ToLower(strings.TrimSpace(c))
|
||||
if len(c) != 2 || c[0] < 'a' || c[0] > 'z' || c[1] < 'a' || c[1] > 'z' {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// privateOverrideTarget answers whether rule r asks for something the private
|
||||
// bypass would deny it.
|
||||
//
|
||||
// Closed positive list. `node:`, `group:`, `chain:` and `egress:` send the
|
||||
// traffic somewhere the kernel's own routing would not, and `block` is a verdict
|
||||
// the bypass silently overrules — all four are real requests. `direct` and the
|
||||
// empty target are NOT: direct means "leave without the engine", which is
|
||||
// precisely what the bypass already does, and diverting a private destination
|
||||
// into the engine to reach the same verdict would be strictly worse — the
|
||||
// engine's direct outbound follows the DEFAULT route, so LAN-to-LAN traffic
|
||||
// could be pushed out the WAN. An unrecognised target routes to block anyway
|
||||
// (generate's ruleKillFallback) but is not honoured here: a target we cannot
|
||||
// name is not an explicit request.
|
||||
func privateOverrideTarget(r model.Rule) bool {
|
||||
kind, _ := model.SplitTarget(model.EffectiveRuleTarget(r))
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "node", "group", "chain", "egress", "block":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// routerNet is one IPv4 network this router carries, with the two things the
|
||||
// guard has to tell apart: which interface it belongs to (so a refusal can name
|
||||
// it) and whether that interface is an UPLINK or one of our own networks.
|
||||
type routerNet struct {
|
||||
Prefix netip.Prefix
|
||||
Iface string
|
||||
// WAN is true when the interface sits in a firewall zone conventionally used
|
||||
// for the uplink (model.WANZoneNames — the same list rule-source validation
|
||||
// and the coverage check use). Unknown zone means NOT wan, which is the
|
||||
// conservative reading: an unreadable firewall config must not downgrade a
|
||||
// network into "just an uplink".
|
||||
WAN bool
|
||||
}
|
||||
|
||||
// routerIPv4Networks enumerates every IPv4 network this router itself carries,
|
||||
// from the same `ubus call network.interface dump` the interface picker reads —
|
||||
// but ALL addresses of ALL interfaces, loopback and secondaries included, where
|
||||
// IfaceInfo keeps only the primary of each non-loopback one. The guard must be
|
||||
// able to see an address the panel has no reason to show.
|
||||
//
|
||||
// ok=false means "could not be enumerated", and that includes an inventory that
|
||||
// parsed but holds no IPv4 address at all: a list of zero networks cannot prove
|
||||
// a named subnet is not one of ours, and pretending otherwise is the guess this
|
||||
// whole mechanism exists to avoid.
|
||||
func routerIPv4Networks() ([]routerNet, bool) {
|
||||
out, err := execCommand("ubus", "call", "network.interface", "dump").Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var doc struct {
|
||||
Interface []struct {
|
||||
Interface string `json:"interface"`
|
||||
IPv4 []struct {
|
||||
Address string `json:"address"`
|
||||
Mask int `json:"mask"`
|
||||
} `json:"ipv4-address"`
|
||||
} `json:"interface"`
|
||||
}
|
||||
if json.Unmarshal(out, &doc) != nil {
|
||||
return nil, false
|
||||
}
|
||||
zones := zoneMap()
|
||||
var nets []routerNet
|
||||
for _, i := range doc.Interface {
|
||||
name := strings.TrimSpace(i.Interface)
|
||||
wan := model.WANZoneNames[strings.ToLower(strings.TrimSpace(zones[name]))]
|
||||
for _, a := range i.IPv4 {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(a.Address))
|
||||
if err != nil || !addr.Is4() || a.Mask < 0 || a.Mask > 32 {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, routerNet{
|
||||
Prefix: netip.PrefixFrom(addr, a.Mask).Masked(),
|
||||
Iface: name,
|
||||
WAN: wan,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nets, len(nets) > 0
|
||||
}
|
||||
|
||||
// parsePrivatePrefix reads one rule-set entry the way the engine does: a CIDR,
|
||||
// or a bare address treated as a full-length prefix. Unparseable entries are
|
||||
// skipped silently — generate already reports them by name ("bad ip_cidr entry
|
||||
// %q, skipped") and a second copy of that message from a second package would
|
||||
// only teach the operator to skim.
|
||||
func parsePrivatePrefix(entry string) (netip.Prefix, bool) {
|
||||
e := strings.TrimSpace(entry)
|
||||
if e == "" {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
if p, err := netip.ParsePrefix(e); err == nil {
|
||||
return p.Masked(), true
|
||||
}
|
||||
if a, err := netip.ParseAddr(e); err == nil {
|
||||
return netip.PrefixFrom(a, a.BitLen()), true
|
||||
}
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
|
||||
// prefixWithinAny reports whether p lies wholly inside one of blocks.
|
||||
func prefixWithinAny(p netip.Prefix, blocks []netip.Prefix) bool {
|
||||
for _, b := range blocks {
|
||||
if b.Bits() <= p.Bits() && b.Contains(p.Addr()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prefixOverlapsAny(p netip.Prefix, blocks []netip.Prefix) bool {
|
||||
for _, b := range blocks {
|
||||
if p.Overlaps(b) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// privateRoutedPlan resolves the explicitly named private destinations of this
|
||||
// model into (a) the IPv4 prefixes the ruleset must divert BEFORE the private
|
||||
// bypass and (b) everything it refused to take, said out loud.
|
||||
//
|
||||
// rules must be the PROFILE-EFFECTIVE rules at the render instant (nftPlanRules)
|
||||
// — the same list the divert-device set and the engine plan are built from, so
|
||||
// the three can never disagree about which rules are live.
|
||||
//
|
||||
// The returned prefixes are canonical (masked), de-duplicated, pairwise DISJOINT
|
||||
// and deterministically ordered; see renderablePrefixSet for why each of those
|
||||
// is load-bearing.
|
||||
func privateRoutedPlan(m *model.Model, rules []model.Rule) ([]string, []string) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
sets := make(map[string]model.Ruleset, len(m.Rulesets))
|
||||
for _, rs := range m.Rulesets {
|
||||
n := strings.TrimSpace(rs.Name)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := sets[n]; !seen {
|
||||
sets[n] = rs
|
||||
}
|
||||
}
|
||||
|
||||
// Read ONCE per plan: the answer cannot change inside one render, and the
|
||||
// probe is a process spawn.
|
||||
routerNets, haveInventory := routerIPv4Networks()
|
||||
|
||||
var warnings []string
|
||||
var accepted []netip.Prefix
|
||||
seenAccepted := map[netip.Prefix]bool{}
|
||||
// Rules that actually contributed a routed prefix, in order, once each — the
|
||||
// audience for the ICMP note below.
|
||||
var routing []string
|
||||
seenRouting := map[string]bool{}
|
||||
// One disclosure per opaque list, however many rules point at it.
|
||||
seenOpaque := map[string]bool{}
|
||||
// One verdict per (rule, rule-set, prefix): a list that repeats an entry, or a
|
||||
// rule that names the same set twice, must not say the same thing twice.
|
||||
seenVerdict := map[string]bool{}
|
||||
|
||||
for _, r := range rules {
|
||||
if !r.Enabled || !privateOverrideTarget(r) {
|
||||
continue
|
||||
}
|
||||
for _, ref := range r.DstRuleset {
|
||||
rs, ok := sets[strings.TrimSpace(ref)]
|
||||
if !ok {
|
||||
// A dangling reference: generate reports it, and it names no address.
|
||||
continue
|
||||
}
|
||||
switch rulesetAddrListKind(rs) {
|
||||
case addrListOpaque:
|
||||
if !seenOpaque[rs.Name] {
|
||||
seenOpaque[rs.Name] = true
|
||||
warnings = append(warnings, opaqueAddressListWarning(rs))
|
||||
}
|
||||
case addrListInline:
|
||||
for _, e := range rs.Entries {
|
||||
p, parsed := parsePrivatePrefix(e)
|
||||
if !parsed {
|
||||
continue
|
||||
}
|
||||
key := r.Name + "\x00" + rs.Name + "\x00" + p.String()
|
||||
if seenVerdict[key] {
|
||||
continue
|
||||
}
|
||||
seenVerdict[key] = true
|
||||
w, take := privatePrefixVerdict(r, rs, p, routerNets, haveInventory)
|
||||
if w != "" {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
if take {
|
||||
if !seenAccepted[p] {
|
||||
seenAccepted[p] = true
|
||||
accepted = append(accepted, p)
|
||||
}
|
||||
if !seenRouting[r.Name] {
|
||||
seenRouting[r.Name] = true
|
||||
routing = append(routing, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
case addrListNone:
|
||||
// Nothing in this list can name an address; nothing to read, nothing
|
||||
// to disclose.
|
||||
}
|
||||
}
|
||||
}
|
||||
cidrs := renderablePrefixSet(accepted)
|
||||
// PING IS HOW ANYONE CHECKS THIS, and with l3_tunnel off nothing carries it.
|
||||
//
|
||||
// The divert is a TPROXY divert, so it moves TCP and UDP: kernel TPROXY hands
|
||||
// the packet to a socket and there is no socket for ICMP. The L3 ingress is
|
||||
// what carries an echo into the engine (see model.Globals.L3Tunnel), and when
|
||||
// it is switched off the ruleset emits no ICMP line for these destinations at
|
||||
// all. Saying nothing here would reproduce this whole defect one protocol
|
||||
// down: `curl` works, `ping` reports 100% loss, and the operator concludes the
|
||||
// route is broken.
|
||||
if len(cidrs) > 0 && !L3Enabled(m.Globals) {
|
||||
for _, name := range routing {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"rule %q: the private destinations this rule names (%s) ARE routed into the engine, but for "+
|
||||
"TCP and UDP only — the divert is a TPROXY divert and the kernel needs a socket to hand "+
|
||||
"the packet to, which ICMP has not got. `l3_tunnel` is OFF, so nothing carries ping or "+
|
||||
"traceroute to them either: TCP and UDP will work and a ping into %s will not, which "+
|
||||
"reads as a broken route rather than as a setting. Set `option l3_tunnel '1'` to carry "+
|
||||
"ICMP echo through the same rules — an L3-capable outbound (WireGuard/AmneziaWG, or "+
|
||||
"direct) moves it for real; a vless/trojan/ss one still cannot, and drops it honestly.",
|
||||
name, strings.Join(cidrs, ", "), cidrs[0]))
|
||||
}
|
||||
}
|
||||
return cidrs, warnings
|
||||
}
|
||||
|
||||
// privatePrefixVerdict decides ONE named prefix. It returns the sentence to
|
||||
// report (empty when there is nothing to say) and whether the prefix is taken.
|
||||
//
|
||||
// Every branch that does not take the prefix says why, because a rule that
|
||||
// cannot fire and reports nothing is the defect this whole mechanism was written
|
||||
// for. The only branch that takes it is the last one, reached after every
|
||||
// refusal has had its chance.
|
||||
func privatePrefixVerdict(r model.Rule, rs model.Ruleset, p netip.Prefix, routerNets []routerNet, haveInventory bool) (string, bool) {
|
||||
if p.Addr().Is6() {
|
||||
if prefixOverlapsAny(p, privateV6Bypassed) {
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) is IPv6 private space, and the private-destination "+
|
||||
"bypass is overridden for IPv4 only. This router's interface inventory (`ubus call "+
|
||||
"network.interface dump`) reports IPv4 addresses and nothing else, so there is no way to "+
|
||||
"tell a named ULA apart from this router's OWN IPv6 LAN — and taking it on a guess could "+
|
||||
"route the LAN's own v6 into the tunnel. It stays on the bypass: this rule does NOT route "+
|
||||
"%s.", r.Name, p, rs.Name, p), false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
// ELIGIBILITY, and the order of the alternatives is load-bearing.
|
||||
//
|
||||
// A prefix is eligible only if it lies WHOLLY inside one of the three RFC1918
|
||||
// blocks and touches no never-routable space. The second half of that
|
||||
// conjunction is unreachable today — those blocks are disjoint from loopback,
|
||||
// link-local, multicast and the broadcast address — and it stays because the
|
||||
// day somebody widens privateOverridable is the day it stops being unreachable,
|
||||
// and multicast must not become divertible by an edit to a different list.
|
||||
//
|
||||
// When it is NOT eligible, "is it a catch-all?" is asked BEFORE "is it
|
||||
// reserved?", because 0.0.0.0/0 is both and only one of those is the answer
|
||||
// the operator needs: they wrote a catch-all, and the news is that it does not
|
||||
// acquire private space, not that it contains 127.0.0.0/8.
|
||||
if !prefixWithinAny(p, privateOverridable) || prefixOverlapsAny(p, privateNeverRoutable) {
|
||||
switch {
|
||||
case prefixOverlapsAny(p, privateOverridable):
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) covers private address space without naming it: it is "+
|
||||
"not CONTAINED in 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16, so it is a catch-all that "+
|
||||
"happens to include them, not an explicit destination. The private-destination bypass is "+
|
||||
"NOT overridden by it, and the private addresses it covers go to plain routing before this "+
|
||||
"rule is ever consulted. Add the subnet you actually mean (e.g. 10.10.10.0/24) as its own "+
|
||||
"entry.", r.Name, p, rs.Name), false
|
||||
case prefixOverlapsAny(p, privateNeverRoutable):
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) is loopback, link-local, multicast or broadcast space. "+
|
||||
"That is never diverted into the engine whatever a rule says — it is not a destination "+
|
||||
"that survives a tunnel, and it carries planes the LAN needs to stay up. It stays on the "+
|
||||
"bypass: this rule does NOT route %s.", r.Name, p, rs.Name, p), false
|
||||
}
|
||||
// Ordinary public space: the catch-all divert already carries it, and this
|
||||
// mechanism has nothing to say about it.
|
||||
return "", false
|
||||
}
|
||||
if !haveInventory {
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) is an explicitly named private subnet, but this router's "+
|
||||
"network inventory could not be read (`ubus call network.interface dump` returned nothing "+
|
||||
"usable), so there is no way to prove %s is not one of this router's OWN networks. Overriding "+
|
||||
"the private-destination bypass on that guess could route this router's LAN into the tunnel "+
|
||||
"and lock you out of it, so it is REFUSED: this rule does NOT route %s. Nothing needs fixing "+
|
||||
"if netifd was simply not up yet — reapply once it is.",
|
||||
r.Name, p, rs.Name, p, p), false
|
||||
}
|
||||
// THE SELF-AMPUTATION GUARD, and the distinction inside it is the difference
|
||||
// between a safety device and an obstacle.
|
||||
//
|
||||
// A collision with one of the router's OWN networks — a LAN, a guest VLAN, a
|
||||
// management link, anything not in a WAN zone — is refused outright. Those are
|
||||
// the networks whose devices this router serves; diverting them would take the
|
||||
// LAN away from the LAN, and the operator would discover it over the console.
|
||||
//
|
||||
// A collision with an UPLINK subnet is a different fact and must not be
|
||||
// treated as the same one. ISPs hand out RFC1918 WANs routinely (this router's
|
||||
// own default gateway is 10.0.0.1), and on a /8 or /12 uplink EVERY private
|
||||
// subnet in the world "collides" — so refusing on that basis would disable the
|
||||
// whole feature on exactly the routers most likely to want it, and the reason
|
||||
// would look like a bug rather than a decision. Nothing of ours lives there:
|
||||
// the router's own addresses are already accepted by `fib daddr type local`
|
||||
// above these lines, and the router's OWN egress never traverses them because
|
||||
// every divert line is scoped to LAN ingress. What is actually lost is LAN
|
||||
// clients reaching hosts inside the ISP's subnet, which is worth a sentence,
|
||||
// not a refusal.
|
||||
//
|
||||
// A WAN collision therefore ROUTES and DISCLOSES; and it is recorded rather
|
||||
// than returned immediately, so a second, non-WAN collision on the same prefix
|
||||
// still wins and refuses.
|
||||
var wanHit *routerNet
|
||||
for i, own := range routerNets {
|
||||
if !p.Overlaps(own.Prefix) {
|
||||
continue
|
||||
}
|
||||
if own.WAN {
|
||||
if wanHit == nil {
|
||||
wanHit = &routerNets[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) overlaps %s, which is a network THIS ROUTER carries "+
|
||||
"(interface %q). Diverting it would take the router's own network away from the devices on "+
|
||||
"it — local services, the panel and SSH on that subnet would be pushed into the tunnel — so "+
|
||||
"it is REFUSED and stays on the private-destination bypass: this rule does NOT route %s. "+
|
||||
"Name the subnet you actually mean, or renumber the network that collides with it.",
|
||||
r.Name, p, rs.Name, own.Prefix, own.Iface, p), false
|
||||
}
|
||||
if wanHit != nil {
|
||||
return fmt.Sprintf(
|
||||
"rule %q: destination %s (rule-set %q) IS routed into the engine, and you should know it also "+
|
||||
"overlaps %s — the subnet this router's own uplink %q sits on. Nothing of this router's is "+
|
||||
"lost (its own addresses are accepted before these lines, and its own traffic never enters "+
|
||||
"them), but LAN clients can no longer reach hosts inside that uplink subnet directly: those "+
|
||||
"destinations now go to this rule's target instead. If that is not what you meant, narrow "+
|
||||
"the rule-set entry to the subnet you actually want.",
|
||||
r.Name, p, rs.Name, wanHit.Prefix, wanHit.Iface), true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// opaqueAddressListWarning is the disclosure required by the one thing this
|
||||
// mechanism cannot do: read a list it does not hold.
|
||||
//
|
||||
// WHY IT IS UNCONDITIONAL. The alternative — warn only once the plan already
|
||||
// contains a named private subnet — is silent in precisely the case that
|
||||
// matters: an operator whose ONLY destination list is a downloaded file
|
||||
// containing 10.10.10.0/24, wondering why the rule never fires. And "warn on
|
||||
// suspicion" is not available: suspicion would mean reading the list, which is
|
||||
// the thing this stage cannot do.
|
||||
//
|
||||
// WHY IT IS NOT NOISE. It fires only for an ADDRESS list — a `url`/`file`
|
||||
// rule-set declared type=ipcidr, or a geo IP list whose category is not an ISO
|
||||
// country code — referenced by an ENABLED rule that actually asks to route
|
||||
// somewhere. A configuration of domain rule-sets and geoip country lists, which
|
||||
// is the ordinary shape, produces none of these. The domain blind spot is named
|
||||
// inside the sentence rather than given a warning of its own, for the same
|
||||
// reason: one warning per domain list would be a warning on every config, and a
|
||||
// warning on every config is read by nobody.
|
||||
func opaqueAddressListWarning(rs model.Ruleset) string {
|
||||
return fmt.Sprintf(
|
||||
"ruleset %q: this is an address list whose contents this data plane never sees (source=%s), and "+
|
||||
"there is exactly one consequence. Destinations in private space (10.0.0.0/8, 172.16.0.0/12, "+
|
||||
"192.168.0.0/16) are bypassed BEFORE the engine is consulted, and that bypass is overridden only "+
|
||||
"for a private subnet written out as an entry of an INLINE type=ipcidr rule-set. If this list "+
|
||||
"contains private addresses, the rules pointing at it do NOT route them — and this plan cannot "+
|
||||
"tell you whether it does, because it never reads the list. The same blind spot covers domain "+
|
||||
"rule-sets whose names resolve into private space. To route one, add it as an inline type=ipcidr "+
|
||||
"entry. If it contains only public addresses, nothing to do.",
|
||||
rs.Name, strings.ToLower(strings.TrimSpace(rs.Source)))
|
||||
}
|
||||
|
||||
// renderablePrefixSet turns the accepted prefixes into the deterministic,
|
||||
// pairwise-disjoint, canonical string list the ruleset renders.
|
||||
//
|
||||
// Determinism is load-bearing: the applier's idempotence check compares the
|
||||
// rendered TEXT, so a set whose order depended on map iteration would reload the
|
||||
// data plane on every reconcile.
|
||||
//
|
||||
// Disjointness is a smaller claim than it looks, and it is worth stating what
|
||||
// was actually measured rather than what one would assume. `nft -f` on this very
|
||||
// ruleset with { 10.10.10.0/24, 10.10.0.0/16 } in the set LOADED — nftables
|
||||
// 1.0.9 auto-merges overlapping members of an ANONYMOUS set rather than
|
||||
// rejecting them. So this is not rescuing a table that would otherwise fail to
|
||||
// load. It is here because (a) a covered prefix contributes nothing, so emitting
|
||||
// it is noise the operator would have to read past in `nft list ruleset`, and
|
||||
// (b) relying on auto-merge would make the rendered text's fate depend on an
|
||||
// nftables version, and this text is compared byte-for-byte by the applier.
|
||||
// Whether a NAMED interval set (`flags interval`, no `auto-merge`) would reject
|
||||
// the same input was not tested; nothing here renders one.
|
||||
func renderablePrefixSet(in []netip.Prefix) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
ps := append([]netip.Prefix(nil), in...)
|
||||
// Widest first, so a covering prefix is always already kept when the prefixes
|
||||
// it covers are examined.
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
if ps[i].Bits() != ps[j].Bits() {
|
||||
return ps[i].Bits() < ps[j].Bits()
|
||||
}
|
||||
return ps[i].Addr().Compare(ps[j].Addr()) < 0
|
||||
})
|
||||
var kept []netip.Prefix
|
||||
for _, p := range ps {
|
||||
if prefixWithinAny(p, kept) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
sort.Slice(kept, func(i, j int) bool {
|
||||
if c := kept[i].Addr().Compare(kept[j].Addr()); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
return kept[i].Bits() < kept[j].Bits()
|
||||
})
|
||||
out := make([]string, 0, len(kept))
|
||||
for _, p := range kept {
|
||||
out = append(out, p.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nftPrivateSetExpr renders the accepted prefixes as an nft anonymous set. The
|
||||
// caller has already established that the list is non-empty.
|
||||
func nftPrivateSetExpr(cidrs []string) string {
|
||||
return "{ " + strings.Join(cidrs, ", ") + " }"
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ func TestEgressDeviceResolutionParity(t *testing.T) {
|
||||
// to ignore the egress warnings entirely.
|
||||
{"direct", model.Egress{Name: "d", Type: "direct"}, false},
|
||||
{"direct with a stray interface", model.Egress{Name: "d", Type: "direct", Interface: "eth1"}, false},
|
||||
{"byedpi", model.Egress{Name: "bd", Type: "byedpi", Port: 1080}, false},
|
||||
// A RETIRED kind. It resolves to no device on either side, exactly like an
|
||||
// unknown one — the removal changed what the operator is told, and must not
|
||||
// have changed what the router does.
|
||||
{"retired type (byedpi)", model.Egress{Name: "bd", Type: "byedpi", Interface: "eth1"}, false},
|
||||
{"empty type (direct synonym)", model.Egress{Name: "e", Type: "", Interface: "eth1"}, false},
|
||||
{"unknown type", model.Egress{Name: "u", Type: "sorcery", Interface: "eth1"}, false},
|
||||
}
|
||||
@@ -98,7 +101,9 @@ func TestValidateEgressesFollowsTheParityPredicate(t *testing.T) {
|
||||
{Name: "blank", Type: "interface", Interface: " "},
|
||||
{Name: "tun-hole", Type: "tunnel", Interface: ""},
|
||||
{Name: "d", Type: "direct"},
|
||||
{Name: "bd", Type: "byedpi", Port: 1080},
|
||||
// Only KNOWN types belong in this list: `want` below models the no-device
|
||||
// warning alone, and a type outside the closed set (unknown or retired) draws
|
||||
// a different warning of its own — see TestValidateEgressesRetiredType.
|
||||
}
|
||||
warned := map[string]bool{}
|
||||
for _, w := range model.ValidateEgresses(egresses) {
|
||||
|
||||
@@ -57,14 +57,21 @@ type egressTypeContract struct {
|
||||
hasDevice bool
|
||||
// outbound: generate emits an `egress-<name>` outbound for it.
|
||||
outbound bool
|
||||
// retired: this kind EXISTED in an earlier build and was removed. It must fail
|
||||
// closed exactly like an unknown type — no device, no outbound — and it must
|
||||
// be reported by a DIFFERENT sentence, the one that names the removal and the
|
||||
// replacement. A retired kind reported as "not one of interface/direct" sends
|
||||
// the operator hunting for a typo in a value that was correct when they wrote
|
||||
// it; a retired kind that quietly acquires a device or an outbound is the
|
||||
// removal not having happened at all.
|
||||
retired bool
|
||||
}
|
||||
|
||||
func egressTypeContracts() []egressTypeContract {
|
||||
return []egressTypeContract{
|
||||
// The three canonical types.
|
||||
// The two canonical types.
|
||||
{written: "interface", canonical: "interface", hasDevice: true, outbound: true},
|
||||
{written: "direct", canonical: "direct", hasDevice: false, outbound: true},
|
||||
{written: "byedpi", canonical: "byedpi", hasDevice: false, outbound: true},
|
||||
|
||||
// The alias this test exists for. It MUST come out identical to
|
||||
// `interface` on both sides — a device AND an outbound. Flip either
|
||||
@@ -75,11 +82,18 @@ func egressTypeContracts() []egressTypeContract {
|
||||
// Spelling noise around a real type is not a new type.
|
||||
{written: " Tunnel ", canonical: "interface", hasDevice: true, outbound: true},
|
||||
{written: "INTERFACE", canonical: "interface", hasDevice: true, outbound: true},
|
||||
{written: " ByeDPI ", canonical: "byedpi", hasDevice: false, outbound: true},
|
||||
|
||||
// RETIRED. `byedpi` shipped, was configured on real routers, and is gone.
|
||||
// Both halves must refuse it, in both spellings, and the model must name
|
||||
// the removal rather than call the value unrecognised.
|
||||
{written: "byedpi", canonical: "byedpi", hasDevice: false, outbound: false, retired: true},
|
||||
{written: " ByeDPI ", canonical: "byedpi", hasDevice: false, outbound: false, retired: true},
|
||||
|
||||
// Genuinely unknown: no device, no outbound. Both halves must refuse it,
|
||||
// and the model must say so (asserted below). `proxy` and `block` are the
|
||||
// two kinds this product actually removed, so they are the realistic typos.
|
||||
// and the model must say so (asserted below). `proxy` and `block` are two
|
||||
// more kinds this product removed — long enough ago that they are now
|
||||
// realistic typos rather than live configuration, which is why they get the
|
||||
// unrecognised sentence and `byedpi` does not.
|
||||
{written: "proxy", canonical: "proxy", hasDevice: false, outbound: false},
|
||||
{written: "block", canonical: "block", hasDevice: false, outbound: false},
|
||||
{written: "wireguard", canonical: "wireguard", hasDevice: false, outbound: false},
|
||||
@@ -156,27 +170,58 @@ func TestEgressTypeMeansTheSameInBothHalves(t *testing.T) {
|
||||
// 3. The invariant that ties them, stated on its own so a future table
|
||||
// row cannot quietly re-open the split: anything the router ROUTES,
|
||||
// the engine must be able to SEND to. The converse does not hold —
|
||||
// direct and byedpi legitimately have an outbound and no device.
|
||||
// `direct` legitimately has an outbound and no device.
|
||||
if c.hasDevice && !c.outbound {
|
||||
t.Fatalf("type %q routes but cannot be sent to: the router installs a mark, an ip rule and a "+
|
||||
"routing table for it while the engine builds no outbound, so its rules are blocked over a "+
|
||||
"working table. That is the exact split `tunnel` was.", c.written)
|
||||
}
|
||||
|
||||
// 4. And model.Validate must NAME an unknown type rather than leave the
|
||||
// generator's build-time warning as the only notice of it.
|
||||
// 4. And model.Validate must NAME the type rather than leave the
|
||||
// generator's build-time warning as the only notice of it — with the
|
||||
// RIGHT sentence of the two, because a removed kind and a typo are
|
||||
// different events for the person reading it.
|
||||
ws := model.ValidateEgresses(m.Egresses)
|
||||
named := false
|
||||
var unrecognised, removed bool
|
||||
for _, w := range ws {
|
||||
if w.Section == "egress" && strings.Contains(w.Message, "is not one of") {
|
||||
named = true
|
||||
if w.Section != "egress" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(w.Message, "is not one of") {
|
||||
unrecognised = true
|
||||
}
|
||||
if strings.Contains(w.Message, "was REMOVED from this product") {
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
knownType := c.hasDevice || c.outbound
|
||||
if named == knownType {
|
||||
t.Fatalf("ValidateEgresses named-unknown = %v for type %q (known=%v), warnings=%v — "+
|
||||
"an unrecognised type must be reported at validate time, and a recognised one must not be",
|
||||
named, c.written, knownType, ws)
|
||||
switch {
|
||||
case knownType:
|
||||
if unrecognised || removed {
|
||||
t.Fatalf("ValidateEgresses complained about the SUPPORTED type %q: %v", c.written, ws)
|
||||
}
|
||||
case c.retired:
|
||||
// The exact split requirement: the removal sentence, and NOT the typo
|
||||
// sentence. Accepting either would let the retired kind fall back into
|
||||
// the generic arm the day someone deletes its table entry.
|
||||
if !removed {
|
||||
t.Fatalf("ValidateEgresses did not report type %q as REMOVED: %v — a kind that shipped and "+
|
||||
"was configured on real routers must be told it was removed and what to replace it with, "+
|
||||
"not told its value is unrecognised", c.written, ws)
|
||||
}
|
||||
if unrecognised {
|
||||
t.Fatalf("ValidateEgresses reported the retired type %q as unrecognised as well: %v — "+
|
||||
"two sentences about one egress, and the second one sends the operator looking for a "+
|
||||
"typo that is not there", c.written, ws)
|
||||
}
|
||||
default:
|
||||
if !unrecognised {
|
||||
t.Fatalf("ValidateEgresses did not report the unknown type %q: %v", c.written, ws)
|
||||
}
|
||||
if removed {
|
||||
t.Fatalf("ValidateEgresses called the never-supported type %q REMOVED: %v — the removal "+
|
||||
"sentence names a replacement, and there is nothing to replace", c.written, ws)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ func TestUntunnelableEgressResolutionLockstep(t *testing.T) {
|
||||
{"padded type", "wan2", []model.Egress{{Name: "wan2", Type: " Interface ", Interface: "eth1"}}, true},
|
||||
{"no such egress", "wan9", []model.Egress{{Name: "wan2", Type: "interface", Interface: "eth1"}}, false},
|
||||
{"wrong type (direct)", "d", []model.Egress{{Name: "d", Type: "direct"}}, false},
|
||||
{"wrong type (byedpi)", "d", []model.Egress{{Name: "d", Type: "byedpi"}}, false},
|
||||
{"retired type (byedpi)", "d", []model.Egress{{Name: "d", Type: "byedpi"}}, false},
|
||||
{"no interface", "hole", []model.Egress{{Name: "hole", Type: "interface"}}, false},
|
||||
// THE DIVERGENCE. IfaceDevice(" ") hands back " ", which is neither
|
||||
// empty nor a device: the binding used to say ok, the validator used to
|
||||
|
||||
+124
-2
@@ -925,7 +925,8 @@ func RenderHoldNftAt(m *model.Model, now time.Time) (string, error) {
|
||||
// one. The failure is not lost, only late — the first full render reports it
|
||||
// (uncoveredNetworkWarnings), and that is the honest description of this
|
||||
// branch rather than a claim that the hold plane is complete.
|
||||
devs, _ := nftDivertDevs(m, nftPlanRules(m, now))
|
||||
planRules := nftPlanRules(m, now)
|
||||
devs, _ := nftDivertDevs(m, planRules)
|
||||
validDevs, _ := nftSplitDevs(devs)
|
||||
// The holding plane has no divert, so nothing here can make one APPEAR: record
|
||||
// an empty set, or the next ApplyNft would inherit the previous full plan's
|
||||
@@ -960,6 +961,25 @@ func RenderHoldNftAt(m *model.Model, now time.Time) (string, error) {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\t\tmeta mark 0x%x oifname %q accept\n", EgressMark(m.Globals, i), dev))
|
||||
}
|
||||
// A private subnet an enabled rule names OUTRIGHT is NOT part of "keep the LAN
|
||||
// usable" — the operator asked for it to be routed, and with the engine down
|
||||
// there is nothing to route it. Dropped here, ABOVE the LAN-to-LAN accept, so
|
||||
// it fails closed like every other destination this plan would have diverted;
|
||||
// letting it through would hand it to the main table, i.e. out the default
|
||||
// WAN, which is the leak this whole plane exists to prevent.
|
||||
//
|
||||
// The warnings are discarded, as the zone-resolution error above is and for
|
||||
// the same reason: this path is called from the boot armor, where there is no
|
||||
// apply and no panel to report to. The first full render says everything.
|
||||
//
|
||||
// HONEST LIMITATION: privateRoutedPlan needs the router's interface inventory
|
||||
// to prove a named subnet is not one of ours, and at boot netifd may not have
|
||||
// answered yet. When it has not, the list is empty, no drop is emitted, and
|
||||
// that destination behaves exactly as it did before this existed — accepted
|
||||
// as LAN-to-LAN. Failing the other way would mean dropping traffic on a guess.
|
||||
if priv, _ := privateRoutedPlan(m, planRules); len(priv) > 0 {
|
||||
sb.WriteString("\t\t" + iif + " ip daddr " + nftPrivateSetExpr(priv) + " drop\n")
|
||||
}
|
||||
// Keep the LAN itself usable.
|
||||
sb.WriteString("\t\t" + iif + " ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16 } accept\n")
|
||||
sb.WriteString("\t\t" + iif + " icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept\n")
|
||||
@@ -1104,6 +1124,18 @@ func renderNft(m *model.Model, plan *UntunnelablePlan, now time.Time) (string, [
|
||||
// picker (or nowhere at all). Collected BEFORE the refusal below, so a refused
|
||||
// plan still tells the operator everything that is wrong with it.
|
||||
warnings = append(warnings, coverageWarnings(m, divertRefs)...)
|
||||
// Destinations in private space that an enabled rule names OUTRIGHT, and every
|
||||
// named private destination this plan REFUSED to take, with the reason. See
|
||||
// the block comment above privateOverridable: the bypass stays the default and
|
||||
// only an explicit naming overrides it, so a rule that cannot fire is now said
|
||||
// out loud instead of rendering as a plan indistinguishable from a working one.
|
||||
//
|
||||
// Collected here, next to the coverage warnings, because it is the same kind
|
||||
// of statement — what this plan does NOT do — and it must survive the refusals
|
||||
// below for the same reason: a refused plan should still tell the operator
|
||||
// everything that is wrong with it.
|
||||
privCIDRs, privWarnings := privateRoutedPlan(m, planRules)
|
||||
warnings = append(warnings, privWarnings...)
|
||||
// What this plan covers for ONE protocol and not the other. Deliberately NOT
|
||||
// inside coverageWarnings: everything in there is gated on the router
|
||||
// inventory, and this is derived from the model alone — see
|
||||
@@ -1339,6 +1371,72 @@ func renderNft(m *model.Model, plan *UntunnelablePlan, now time.Time) (string, [
|
||||
}
|
||||
}
|
||||
sb.WriteString("\t\tfib daddr type local accept\n")
|
||||
// --- explicitly named private destinations (see privateOverridable) ---
|
||||
// ORDER IS THE WHOLE MECHANISM. The `ip daddr { 10.0.0.0/8, ... } accept`
|
||||
// immediately below is what makes a rule like `10.10.10.0/24 -> node:awghome`
|
||||
// never fire: the packet is accepted before any divert line is reached, so the
|
||||
// engine — which holds the rule — never sees it. Putting the divert for the
|
||||
// NAMED subnets one line higher is the entire fix, and it is deliberately not
|
||||
// done by subtracting them from that set: an interval set minus a member is a
|
||||
// second thing to keep in step with three chains, while first-match order is
|
||||
// already the semantics nft gives us.
|
||||
//
|
||||
// BELOW `fib daddr type local accept` on purpose. Every address the router
|
||||
// itself holds keeps reaching the router, whatever a list says, so a named
|
||||
// subnet can never swallow the panel or SSH even if the overlap guard in
|
||||
// privatePrefixVerdict were somehow wrong. Two independent defences, and this
|
||||
// one costs a line.
|
||||
//
|
||||
// Per DEVICE, exactly like the DNS force-intercept above: the port and the
|
||||
// tcp/udp flags come from the inbound that owns each ingress device. A device
|
||||
// whose inbound does not carry a protocol gets no line for it, rather than a
|
||||
// divert to a socket the engine never opened (`tproxy` would return NFT_BREAK,
|
||||
// the rule would abort before its accept, and the packet would fall through to
|
||||
// the bypass anyway — the behaviour of not emitting the line, plus a false
|
||||
// claim in the ruleset).
|
||||
//
|
||||
// No counter. This set is the UNION of what several rules named, possibly
|
||||
// through a shared rule-set, so no packet here can be attributed to one rule;
|
||||
// a `c_rule_x` on these lines would be a fabricated measurement, which this
|
||||
// plane refuses to produce (see the appendLines comment).
|
||||
if len(privCIDRs) > 0 && hasPrimary && dnsIif != "" {
|
||||
privSet := "ip daddr " + nftPrivateSetExpr(privCIDRs)
|
||||
for _, g := range nftGroupDevs(m, inboundParams(primary), validDevs) {
|
||||
giif := nftIifExpr(g.devs)
|
||||
if giif == "" {
|
||||
continue
|
||||
}
|
||||
if g.params.tcp {
|
||||
sb.WriteString(nftDivertLine(giif, "tcp", privSet, 4, g.params.port, tproxyMark, ""))
|
||||
}
|
||||
if g.params.udp {
|
||||
sb.WriteString(nftDivertLine(giif, "udp", privSet, 4, g.params.port, tproxyMark, ""))
|
||||
}
|
||||
}
|
||||
// PING, which is how anyone checks whether this worked. TPROXY needs a
|
||||
// socket and there is none for ICMP, so the two lines above carry
|
||||
// connections and nothing else — a named subnet whose TCP works while
|
||||
// `ping` reports 100% loss looks exactly like a broken feature.
|
||||
//
|
||||
// The L3 ingress is the mechanism that already exists for this: the mark
|
||||
// plus addL3Routing's `ip rule` deliver the echo into the engine's TUN,
|
||||
// where the SAME route rules pick the outbound, and a WireGuard/AmneziaWG
|
||||
// one carries it for real (see model.Globals.L3Tunnel — the limit is
|
||||
// sing-tun's dispatcher, which takes TCP, UDP and ICMP echo). Nothing new
|
||||
// is invented here; the existing L3 line simply sits BELOW the private
|
||||
// bypass and so never sees these destinations.
|
||||
//
|
||||
// icmp only, never `l4proto != { tcp, udp }`, for the reason spelled out
|
||||
// at the L3 block below: a marked ESP/GRE/SCTP packet enters the TUN and
|
||||
// vanishes instead of receiving the untunnelable policy's honest verdict.
|
||||
//
|
||||
// With l3_tunnel OFF nothing is emitted and nothing is claimed — the
|
||||
// operator is told so by name (see privateRoutedPlan's l3 note).
|
||||
if L3Enabled(m.Globals) {
|
||||
sb.WriteString(fmt.Sprintf("\t\t%s %s ip protocol icmp meta mark set 0x%x accept\n",
|
||||
dnsIif, privSet, L3Mark(m.Globals)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\t\tip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4, 255.255.255.255 } accept\n")
|
||||
sb.WriteString("\t\tip6 daddr { ::1, fc00::/7, fe80::/10, ff00::/8 } accept\n")
|
||||
// --- L3 ingress (Globals.L3Tunnel): route what TPROXY cannot carry ---
|
||||
@@ -1526,7 +1624,31 @@ func renderNft(m *model.Model, plan *UntunnelablePlan, now time.Time) (string, [
|
||||
sb.WriteString(fmt.Sprintf("\t\tmeta mark 0x%x oifname %q accept\n", EgressMark(m.Globals, i), dev))
|
||||
}
|
||||
// 2) LAN-to-LAN / link-local (v4): the LAN itself must keep working
|
||||
// (same private-range bypass sets the prerouting chain uses).
|
||||
// (same private-range bypass sets the prerouting chain uses) —
|
||||
// EXCEPT the private subnets an enabled rule named outright, which
|
||||
// are diverted in prerouting and therefore have to be droppable
|
||||
// here like every other diverted destination. This chain sees a
|
||||
// diverted packet only when the divert FAILED (the engine's tproxy
|
||||
// socket is down, `tproxy` returned NFT_BREAK), and accepting it
|
||||
// then would send it to the main table, i.e. out the default WAN in
|
||||
// the clear. The drop must precede the accept: first match wins.
|
||||
//
|
||||
// THE L3 LEG MUST SURVIVE IT. With l3_tunnel on, prerouting marks
|
||||
// ICMP for these destinations into the engine's TUN, and that leg
|
||||
// DOES traverse this chain (iifname LAN, oifname the TUN) — unlike
|
||||
// the tproxy legs, which are delivered locally and never appear
|
||||
// here. The `oifname "shater-l3*" accept` that normally rescues it
|
||||
// sits four steps below this line, so without an exclusion this
|
||||
// drop would eat exactly the packets prerouting just routed. The
|
||||
// mark is the discriminator rather than the device name because it
|
||||
// is what prerouting actually stamped, and it needs no wildcard.
|
||||
if len(privCIDRs) > 0 {
|
||||
drop := "\t\t" + dnsIif + " ip daddr " + nftPrivateSetExpr(privCIDRs)
|
||||
if L3Enabled(m.Globals) {
|
||||
drop += fmt.Sprintf(" meta mark != 0x%x", L3Mark(m.Globals))
|
||||
}
|
||||
sb.WriteString(drop + " drop\n")
|
||||
}
|
||||
sb.WriteString("\t\t" + dnsIif + " ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16 } accept\n")
|
||||
// 3) IPv6 plane. Public v6 escapes the divert exactly like v4, so it is
|
||||
// dropped below in BOTH cases; only the surviving accepts differ:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user