feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it is REVERSED there, with the reason, because the reason is the whole point. D13 adopted an external desync process on an observation: the engine's own `tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not get through, so the method was judged too weak for anything past "just fragment the ClientHello". The method was never tried. `common/tlsfragment` dropped a number of labels equal to the number of DOTS in the name, and a name always has one more label than it has dots — so the cut always landed inside the FIRST label. `www.youtube.com` was split inside `www` and `youtube` went to the wire in one piece, which is the word the DPI matches on. Of six blocked names exactly one got through: `youtube.com`, the one whose first label IS the blocked word. That defect is fixed (815011dfb,efb2177f4). With it fixed the built-in presets do the job the external process was brought in to do, and the 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 and a panel plate — all to work around fifteen lines of ours. So this is not "ByeDPI turned out to be bad". It is a good tool that turned out not to be needed, and the reason we thought it was needed was ours. A CONFIG THAT STILL SAYS `type 'byedpi'` IS THE PART THAT NEEDED WORK. Nothing is migrated and nothing is rewritten: the kind stays unbuildable, therefore fail-closed — no outbound, no mark, no `ip rule`, no routing table, so every node, group and rule bound to it is blocked rather than released onto the plain WAN. A migration to `direct` was considered and rejected: it is the only rewrite that leaves the egress routing at all, and it would silently turn a blocked egress into a live plain-WAN path with the router's real address — by an upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore not bumped either: no stored field changes meaning, and a bump would only make this build's configs unreadable to an older daemon for no gain. What changes is what the operator is TOLD. `model.RetiredEgressTypes` is a closed, positive table read by BOTH `ValidateEgresses` and the generator (one copy of the sentence, because two copies drift). It names the removal, denies that it is a typo, says nothing is built and that the traffic is blocked rather than leaked, names the replacement (`direct`/`interface` with `dpi 'record'`), refuses to promise which preset defeats a given ISP, and says `apk del byedpi`. The generic "unknown type" is still there and still says something different, on purpose: "we took this kind away" and "you mistyped something" send an operator to different places, and a value that was correct on the day it was written must not be reported as a spelling mistake. The type list stays closed and positive — `interface`, `direct`, the alias `tunnel` — and `EgressTypeKnown` does NOT admit the retired kind: being told it was removed and having it work anyway is worse than either alone. `Egress.Port` goes with the kind: no surviving egress dials anything, so the option is no longer parsed and drains out of /etc/config/shater on the next render, the same way the deleted per-group probe_url/probe_interval did. Tests, verified by mutation, each failing by name: - drop the retired branch in `ValidateEgresses` -> the retired kind is reported as "is not one of interface/direct" and TestRetiredEgressTypeIsReportedByTheValidator fails on both spellings; - drop it in the generator -> "unknown type \"byedpi\"" and TestRetiredEgressTypeIsReportedByTheGenerator fails; - the FAIL-OPEN mutation, which is the one that matters: let `byedpi` fall into the `direct` arm and be a known type -> four tests fail, including the two that check no outbound is emitted. A removal that quietly starts routing the traffic it used to block, under a reassuring message, is the failure with the worst consequence; - the panel half: empty RETIRED_EGRESS_TYPES -> two egressEdit tests fail. Controls beside the claims: `interface`, `direct`, the `tunnel` alias and the empty synonym must still resolve, warn about nothing and emit an outbound (TestSupportedEgressTypesAreUntouched), and never-supported values — `proxy`, `block`, `wireguard`, `byedpi2`, `bye dpi`, `sorcery` — must NOT draw the removal sentence, which names a replacement for something that never existed. CI and docs: the feed loses its fourth package everywhere the four were named — `apk upgrade shaterd shater-core luci-app-shater`, in CLAUDE.md, both READMEs, INSTALL.md, the release body and `shaterd`'s own diag bundle. The version exception (byedpi carried upstream's version, ours come from the git tag) is gone with it, so ci/version.sh and ci/sdk-build-apk.sh no longer have an exception to remember and the "expected >=4 of OUR .apk" collect check is now 3. INSTALL.md §5.3 gains the half a feed cannot do: dropping the package from the feed does not take it off a router it is already on, so `apk del byedpi` is written down, with what it removes and why it is safe. Panel: 368 tests -> 339. Deleted with the mechanism they covered: byedpiReady.test.ts, byedpiAge.test.ts, byedpiRefusal.test.ts (34 tests); egressEdit.test.ts gains 5 for the retired-type sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
This commit is contained in:
@@ -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) |
|
||||
|
||||
+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
|
||||
|
||||
@@ -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,69 @@ 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.
|
||||
|
||||
+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'
|
||||
|
||||
+18
-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. */
|
||||
@@ -1502,20 +1341,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 +1373,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 {
|
||||
@@ -2218,21 +2069,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')
|
||||
})
|
||||
+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
|
||||
}
|
||||
|
||||
+1
-270
@@ -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, 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 {
|
||||
@@ -221,8 +221,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' },
|
||||
@@ -904,7 +902,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 +925,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)
|
||||
|
||||
+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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) ---------------------------------------
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+73
-13
@@ -722,7 +722,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 +757,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 +832,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 +847,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 +883,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 +891,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 +907,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -200,7 +200,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+10
-7
@@ -148,13 +148,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{
|
||||
|
||||
@@ -552,10 +552,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 +606,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 +627,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1297,15 +1297,16 @@ func TestUntunnelableEgressCarriesAllProtocols(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestUntunnelableEgressFailClosedOnTypo: a name that resolves to no egress, or
|
||||
// to one the kernel cannot route by mark (byedpi/direct egresses have no device
|
||||
// and no mark routing), must change NOTHING — the untunnelable policy stays in
|
||||
// sole charge. Rendering the marking anyway would stamp packets with a mark no
|
||||
// `ip rule` serves: they fall through to the main table and leave over the
|
||||
// default WAN — a typo turned kill-switch bypass.
|
||||
// to one the kernel cannot route by mark (a `direct` egress has no device and no
|
||||
// mark routing, and neither has a RETIRED kind), must change NOTHING — the
|
||||
// untunnelable policy stays in sole charge. Rendering the marking anyway would
|
||||
// stamp packets with a mark no `ip rule` serves: they fall through to the main
|
||||
// table and leave over the default WAN — a typo turned kill-switch bypass.
|
||||
func TestUntunnelableEgressFailClosedOnTypo(t *testing.T) {
|
||||
build := func(name string) *model.Model {
|
||||
m := realisticModel()
|
||||
m.Egresses = append(m.Egresses,
|
||||
// A retired kind, which is the case an upgrade actually produces.
|
||||
model.Egress{Name: "dpi", Type: "byedpi"},
|
||||
model.Egress{Name: "straight", Type: "direct"},
|
||||
)
|
||||
|
||||
+16
-45
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -64,61 +63,33 @@ type statusResponse struct {
|
||||
apply.Status
|
||||
Version string `json:"version"`
|
||||
|
||||
// ByeDPIInstalled reports whether the `ciadpi` binary (the optional `byedpi`
|
||||
// package) is present on this router. It says NOTHING about whether anything
|
||||
// is listening: the shipped /etc/config/byedpi is inert (enabled='0'), so a
|
||||
// freshly installed package answers true here and serves no port. It is
|
||||
// therefore NOT a gate for creating a byedpi egress — GET /api/byedpi's
|
||||
// state == "listening" is. Kept because "is the package there" is still a
|
||||
// real question, and answering it costs a PATH lookup and no socket, which is
|
||||
// the only kind of work this endpoint may do.
|
||||
// There is no readiness field for an external desync helper here any more.
|
||||
// One existed — `byedpi_installed`, a PATH lookup for the `ciadpi` binary —
|
||||
// alongside a whole listener report served from a cache that a background
|
||||
// poll refreshed once it was three seconds old, while the panel polls this
|
||||
// endpoint every five. Every poll therefore started a refresh: a PATH lookup,
|
||||
// a config read and one connect per configured instance, forever, for every
|
||||
// open browser tab including a hidden one. The report moved off this endpoint
|
||||
// first; the whole external mechanism is now retired
|
||||
// (model.RetiredEgressTypes), and the field went with it.
|
||||
//
|
||||
// THE READINESS REPORT IS NOT HERE, deliberately. This response used to carry
|
||||
// the whole byedpi report, served from a cache that a poll refreshed in the
|
||||
// background once it was three seconds old — while the panel polls this
|
||||
// endpoint every five, so every poll started a refresh: a PATH lookup, a read
|
||||
// of /etc/config/byedpi and one connect per enabled instance, forever, for
|
||||
// every open browser tab including a hidden one. Measured on one enabled
|
||||
// instance: 12 connects and 13 lookups per minute per tab. Nothing read the
|
||||
// field — the readiness plate, the egress cross-check and the type gate all
|
||||
// come from GET /api/byedpi. So it is gone, and with it the background probe.
|
||||
// Ask GET /api/byedpi, which really connects, when you actually need to know.
|
||||
ByeDPIInstalled bool `json:"byedpi_installed"`
|
||||
}
|
||||
|
||||
// ciadpiPath is where the optional `byedpi` package installs its binary (see
|
||||
// openwrt/byedpi/Makefile: INSTALL_BIN → /usr/bin/ciadpi).
|
||||
const ciadpiPath = "/usr/bin/ciadpi"
|
||||
|
||||
// byedpiInstalled reports whether the ciadpi desync proxy is available: on PATH
|
||||
// (the daemon's PATH includes /usr/bin on OpenWrt) or at its packaged install
|
||||
// path — the latter as a fallback for a daemon started with a stripped PATH.
|
||||
// A package-level seam so tests can force either answer.
|
||||
var byedpiInstalled = func() bool {
|
||||
if _, err := exec.LookPath("ciadpi"); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := os.Stat(ciadpiPath)
|
||||
return err == nil
|
||||
// The rule it leaves behind still holds for anything added here: work on this
|
||||
// path is paid once per poll per tab, forever, whether or not anybody reads
|
||||
// the result.
|
||||
}
|
||||
|
||||
// handleStatus → GET /api/status: live daemon/data-plane status + version.
|
||||
//
|
||||
// This handler OPENS NO SOCKET and starts no goroutine. It is polled every 5 s
|
||||
// by the panel shell and every 4 s more by the Apply page, on a router whose CPU
|
||||
// and flash are the scarce things, so the rule for anything added here is the
|
||||
// one that had to be learned: work on this path is paid once per poll per tab,
|
||||
// forever, whether or not anybody reads the result. The byedpi listener check
|
||||
// used to be on it — see the ByeDPIInstalled comment above.
|
||||
// This handler OPENS NO SOCKET and starts no goroutine — see the statusResponse
|
||||
// comment for the poll cost that rule was learned from.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, statusResponse{
|
||||
Status: s.a.Status(),
|
||||
Version: constant.Version,
|
||||
ByeDPIInstalled: byedpiInstalled(),
|
||||
Status: s.a.Status(),
|
||||
Version: constant.Version,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ var apiRoutes = []struct {
|
||||
{"/api/subscription/update", []string{http.MethodPost}},
|
||||
{"/api/groups/test", []string{http.MethodGet, http.MethodPost}},
|
||||
{"/api/groups/health", []string{http.MethodGet}},
|
||||
{"/api/byedpi", []string{http.MethodGet}},
|
||||
}
|
||||
|
||||
// do issues a request against the test server, optionally with a session cookie.
|
||||
|
||||
@@ -1,878 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// ByeDPI readiness — is there a LISTENER, not is there a FILE.
|
||||
//
|
||||
// # The defect this replaces
|
||||
//
|
||||
// The panel used to unlock the `byedpi` egress type on byedpiInstalled(), which
|
||||
// is LookPath("ciadpi") plus an os.Stat. That answers "is the package
|
||||
// installed", and the operator's question is "will traffic sent to this egress
|
||||
// go anywhere". Those come apart on the shipped configuration, not on some
|
||||
// exotic one:
|
||||
//
|
||||
// - openwrt/byedpi/files/etc/config/byedpi ships INERT — its one instance has
|
||||
// `option enabled '0'` — so installing the package deliberately opens no
|
||||
// listener;
|
||||
// - the port is coordinated between the two packages by COMMENT ONLY. The
|
||||
// shipped config's comment says the egress port "must match an enabled
|
||||
// instance's port"; generate/outbound.go independently defaults a byedpi
|
||||
// egress to 1080. Nothing in the daemon has ever read /etc/config/byedpi
|
||||
// (verified: no Go or TypeScript file references the path), so a config on
|
||||
// port 1081 and an egress on 1080 agree with each other only by luck.
|
||||
//
|
||||
// The whole failure is silent: the panel unlocks the type, the operator makes an
|
||||
// egress on 127.0.0.1:1080, the apply is green, the egress looks healthy — and
|
||||
// its SOCKS outbound dials a port nobody is on.
|
||||
//
|
||||
// # What is checked, and what each answer is worth
|
||||
//
|
||||
// Three independent facts, kept separate because they fail separately:
|
||||
//
|
||||
// binary — is `ciadpi` on the box (the old check, demoted to one input);
|
||||
// config — does /etc/config/byedpi hold an ENABLED instance, and on which
|
||||
// port. Read from the FILE rather than through `uci export`: the
|
||||
// file is what the init script actually started from (a staged,
|
||||
// uncommitted delta is desired state, not running state), and it
|
||||
// is a conffile the operator hand-edits, so it must be readable
|
||||
// without the uci binary being present;
|
||||
// listener — does something ACCEPT a TCP connection on 127.0.0.1:<port>.
|
||||
//
|
||||
// The listener check is a connect and nothing more. It proves a socket is
|
||||
// accepting there; it does NOT prove the process behind it is ciadpi, and it
|
||||
// does not prove it speaks SOCKS5. Every sentence this file emits says so,
|
||||
// because "listening" that quietly meant "verified SOCKS5 handshake" would be a
|
||||
// promise no code here keeps.
|
||||
//
|
||||
// # Unknown is a state, not a synonym for working
|
||||
//
|
||||
// A config file that cannot be read, or a connect that fails with something
|
||||
// other than a clean refusal, produces "unknown" — never "listening", and never
|
||||
// "disabled" either. An instrument that could not look has established nothing,
|
||||
// and the one thing it must never do is let that pass for a positive result.
|
||||
|
||||
// ByeDPI service states. A CLOSED, positive list: these five strings are the only
|
||||
// values byedpiReport.State ever takes, and the panel may switch on them
|
||||
// exhaustively. Ordered here the way they degrade.
|
||||
const (
|
||||
// ByeDPIUnknown — the check could not be completed. Says nothing about the
|
||||
// service. Must never be rendered as working, and must be visibly an unknown.
|
||||
ByeDPIUnknown = "unknown"
|
||||
// ByeDPINotInstalled — no `ciadpi` binary on this box: the optional package is
|
||||
// not installed, so no instance can run whatever the config says.
|
||||
ByeDPINotInstalled = "not_installed"
|
||||
// ByeDPIDisabled — the binary is there and /etc/config/byedpi holds NO enabled
|
||||
// instance. This is the state a fresh install is in ON PURPOSE (the shipped
|
||||
// instance has enabled='0'); it is a configuration fact, not a fault.
|
||||
ByeDPIDisabled = "disabled"
|
||||
// ByeDPINotListening — the binary is there, at least one instance is enabled,
|
||||
// and NOTHING accepts on any of their ports. The service is configured to run
|
||||
// and is not running (or crashed, or was never started).
|
||||
ByeDPINotListening = "not_listening"
|
||||
// ByeDPIListening — the binary is there, at least one instance is enabled, and
|
||||
// something accepts TCP on at least one of their ports. This is the only state
|
||||
// in which a byedpi egress can carry traffic, and therefore the only one that
|
||||
// may unlock the egress type.
|
||||
ByeDPIListening = "listening"
|
||||
)
|
||||
|
||||
// Per-port listener answers. A CLOSED list, and "unknown" is deliberately NOT
|
||||
// folded into "no": a connect that timed out has not shown the port to be empty.
|
||||
const (
|
||||
ByeDPIPortListening = "yes"
|
||||
ByeDPIPortRefused = "no"
|
||||
ByeDPIPortUndetermined = "unknown"
|
||||
)
|
||||
|
||||
// Per-egress verdicts. A CLOSED list — one of these is attached to every byedpi
|
||||
// egress in the configuration, so an egress is never left without a sentence.
|
||||
const (
|
||||
// ByeDPIEgressOK — an enabled instance is configured on this egress's port and
|
||||
// something is accepting there.
|
||||
ByeDPIEgressOK = "ok"
|
||||
// ByeDPIEgressPortMismatch — byedpi IS running, but on a different port than
|
||||
// this egress dials. The failure A2 exists for: two packages that agreed only
|
||||
// in a comment.
|
||||
ByeDPIEgressPortMismatch = "port_mismatch"
|
||||
// ByeDPIEgressNotListening — an enabled instance names this port and nothing
|
||||
// accepts there.
|
||||
ByeDPIEgressNotListening = "not_listening"
|
||||
// ByeDPIEgressServiceDisabled — no instance is enabled at all.
|
||||
ByeDPIEgressServiceDisabled = "service_disabled"
|
||||
// ByeDPIEgressNotInstalled — the package is not installed.
|
||||
ByeDPIEgressNotInstalled = "not_installed"
|
||||
// ByeDPIEgressUnknown — the service state could not be determined, so neither
|
||||
// can this egress's.
|
||||
ByeDPIEgressUnknown = "unknown"
|
||||
)
|
||||
|
||||
// byedpiInstance is one `config instance` of /etc/config/byedpi that is ENABLED,
|
||||
// plus what a connect to its port found.
|
||||
type byedpiInstance struct {
|
||||
// Section is the UCI section name ('default' in the shipped config), or
|
||||
// "@instance[N]" for an anonymous one — what an operator would type at `uci`.
|
||||
Section string `json:"section"`
|
||||
// Port is the port the instance is configured to bind on 127.0.0.1. It carries
|
||||
// the init script's own default (1080) when the option is absent, so this is
|
||||
// the port the service would actually use and not merely what is written down.
|
||||
Port int `json:"port"`
|
||||
// Listening is ByeDPIPort* — see the constants. "unknown" is not "no".
|
||||
Listening string `json:"listening"`
|
||||
}
|
||||
|
||||
// byedpiEgressCheck is one configured byedpi EGRESS cross-checked against the
|
||||
// running listeners. This is the port reconciliation between the two packages
|
||||
// that used to exist only as a comment.
|
||||
type byedpiEgressCheck struct {
|
||||
Name string `json:"name"`
|
||||
// Port is the port this egress's SOCKS outbound dials, with the generator's
|
||||
// own default (1080) substituted when unset — the number that will actually be
|
||||
// dialled, not the raw field (generate/outbound.go does the same substitution).
|
||||
Port int `json:"port"`
|
||||
State string `json:"state"` // ByeDPIEgress* — closed list
|
||||
Detail string `json:"detail"` // one sentence, safe to show verbatim
|
||||
}
|
||||
|
||||
// byedpiReport is the whole answer: the service state and the evidence behind it.
|
||||
type byedpiReport struct {
|
||||
State string `json:"state"` // ByeDPI* — closed list
|
||||
// Detail is one sentence describing State, written so it can be shown
|
||||
// verbatim. It never claims more than was checked.
|
||||
Detail string `json:"detail"`
|
||||
// Binary reports the old check on its own: is `ciadpi` on the box. Kept
|
||||
// visible because "installed but off" and "not installed" call for different
|
||||
// actions and the state alone already separates them — this is the raw input.
|
||||
Binary bool `json:"binary"`
|
||||
// ConfigRead reports whether /etc/config/byedpi could be read. false with
|
||||
// State=="unknown" is the honest "could not look"; false with
|
||||
// State=="not_installed" just means there was no point looking.
|
||||
ConfigRead bool `json:"config_read"`
|
||||
// Instances is every ENABLED instance found, with its port and listener
|
||||
// answer. Always non-nil so the panel can map over it without a guard. A
|
||||
// disabled instance is omitted: it names a port nothing will ever bind, and
|
||||
// listing it beside the live ones invites reading it as an option.
|
||||
Instances []byedpiInstance `json:"instances"`
|
||||
// Egresses is the per-egress cross-check. It is populated ONLY when the
|
||||
// endpoint was given the desired-state model (GET /api/byedpi). Empty
|
||||
// therefore means "not asked for", never "no byedpi egresses".
|
||||
Egresses []byedpiEgressCheck `json:"egresses"`
|
||||
// AgeSeconds is how long ago the CONNECTS behind this report were STARTED, in
|
||||
// whole seconds. GET /api/byedpi measures on the spot, so a healthy re-check
|
||||
// reports 0; a probe that spent six seconds in dial timeouts reports 6 rather
|
||||
// than claiming to be instantaneous, because every sentence in Detail is
|
||||
// present tense and the panel unlocks the byedpi egress type on exactly this
|
||||
// report.
|
||||
//
|
||||
// It is never negative any more. It could be, once: GET /api/status served a
|
||||
// cached copy and used a negative age for "this body is not a measurement at
|
||||
// all". That endpoint no longer carries a report (see handleStatus), so the
|
||||
// only report the daemon emits is one it just took. The panel still keeps the
|
||||
// negative case apart — it also covers "the fetch failed, there is no report"
|
||||
// — and nothing here may start using negative for anything else.
|
||||
AgeSeconds int `json:"age_seconds"`
|
||||
// problems is every `config instance` section of /etc/config/byedpi that
|
||||
// /etc/init.d/byedpi would NOT start on a port this check can dial, with the
|
||||
// reason. NOT serialised: it is the material Detail is written from, and a
|
||||
// second copy on the wire that no client reads is the kind of dead field this
|
||||
// file has already paid for once.
|
||||
problems []byedpiSectionProblem
|
||||
}
|
||||
|
||||
// byedpiDial is the connect byedpiPortState performs. A package variable for the
|
||||
// same reason byedpiInstalled and byedpiConfigPath are: it is the only way a test
|
||||
// can produce the TIMEOUT outcome — the one a real loopback cannot be provoked
|
||||
// into, and the one whose COST is the reason this check is not on the status
|
||||
// poll. A check whose expensive branch cannot be exercised cannot be shown to
|
||||
// have been kept off a hot path.
|
||||
var byedpiDial = net.DialTimeout
|
||||
|
||||
// byedpiConfigPath is the shipped UCI config of the optional byedpi package
|
||||
// (openwrt/byedpi/Makefile installs it as a conffile). A package variable so a
|
||||
// test can point the reader at a fixture.
|
||||
var byedpiConfigPath = "/etc/config/byedpi"
|
||||
|
||||
// byedpiDefaultPort mirrors TWO independent defaults that must stay equal:
|
||||
// /etc/init.d/byedpi validates 'port:port:1080', and generate/outbound.go
|
||||
// substitutes 1080 for a byedpi egress with Port unset. If they ever diverge,
|
||||
// this file's cross-check is where the divergence becomes visible instead of
|
||||
// silent.
|
||||
const byedpiDefaultPort = 1080
|
||||
|
||||
// byedpiDialTimeout bounds ONE connect to 127.0.0.1. Loopback answers in
|
||||
// microseconds whether it accepts or refuses, so this is only ever spent on a
|
||||
// pathological case (a firewall dropping loopback, a wedged listener) — and it is
|
||||
// spent inside a GET /api/byedpi a human is waiting on, so it is short and the
|
||||
// result of spending it all is "unknown", not "no".
|
||||
const byedpiDialTimeout = 400 * time.Millisecond
|
||||
|
||||
// byedpiMaxInstances caps how many enabled instances are probed in one report.
|
||||
// /etc/config/byedpi is operator-editable; a file with a thousand instances must
|
||||
// not turn one re-check into a thousand connects. Instances beyond the cap are
|
||||
// reported unchecked ("unknown") rather than dropped.
|
||||
const byedpiMaxInstances = 16
|
||||
|
||||
// byedpiProbe reports the byedpi SERVICE state. It performs the file read and the
|
||||
// connects; it does NOT read the model (see byedpiReport.Egresses).
|
||||
func byedpiProbe() byedpiReport {
|
||||
rep := byedpiReport{
|
||||
Binary: byedpiInstalled(),
|
||||
Instances: []byedpiInstance{},
|
||||
Egresses: []byedpiEgressCheck{},
|
||||
}
|
||||
if !rep.Binary {
|
||||
rep.State = ByeDPINotInstalled
|
||||
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."
|
||||
return rep
|
||||
}
|
||||
|
||||
enabled, problems, err := readByeDPIInstances(byedpiConfigPath)
|
||||
if err != nil {
|
||||
rep.State = ByeDPIUnknown
|
||||
rep.Detail = "the ciadpi binary is installed, but " + byedpiConfigPath +
|
||||
" could not be read (" + err.Error() + "), so it is NOT known whether any instance is enabled or on which port. This is an unknown, not a verdict."
|
||||
return rep
|
||||
}
|
||||
rep.ConfigRead = true
|
||||
rep.problems = problems
|
||||
if len(enabled) == 0 {
|
||||
rep.State = ByeDPIDisabled
|
||||
if len(problems) == 0 {
|
||||
rep.Detail = "the ciadpi binary is installed but " + byedpiConfigPath +
|
||||
" 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."
|
||||
} else {
|
||||
rep.Detail = "the ciadpi binary is installed, but " + byedpiConfigPath +
|
||||
" holds no instance /etc/init.d/byedpi would start on a port this check can dial, so nothing is listening. " + byedpiProblemSentence(problems)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// Three separate tallies, because they are three different facts and the
|
||||
// sentence below names the one it is describing. `unchecked` in particular is
|
||||
// NOT `inconclusive`: a port past byedpiMaxInstances was never dialled, and a
|
||||
// sentence about a connection that neither succeeded nor was refused would be
|
||||
// asserting an attempt that never happened.
|
||||
// `dialled` is kept apart from `unchecked` and not merely counted: the two
|
||||
// share one Listening value ("unknown"), so a sentence built from that value
|
||||
// alone would name a never-dialled port inside a clause about a connection
|
||||
// attempt. The lists, not the counts, are what the sentences are written from.
|
||||
live := 0
|
||||
var inconclusive, unchecked []byedpiInstance
|
||||
for i, inst := range enabled {
|
||||
switch {
|
||||
case i >= byedpiMaxInstances, inst.Port < 1, inst.Port > 65535:
|
||||
// Not dialled. The port range arm is unreachable through
|
||||
// readByeDPIInstances (which only ever yields 1..65535) and is kept so
|
||||
// that any future caller lands in "not checked" rather than in a verdict.
|
||||
inst.Listening = ByeDPIPortUndetermined
|
||||
unchecked = append(unchecked, inst)
|
||||
default:
|
||||
inst.Listening = byedpiPortState(inst.Port)
|
||||
switch inst.Listening {
|
||||
case ByeDPIPortListening:
|
||||
live++
|
||||
case ByeDPIPortUndetermined:
|
||||
inconclusive = append(inconclusive, inst)
|
||||
}
|
||||
}
|
||||
rep.Instances = append(rep.Instances, inst)
|
||||
}
|
||||
|
||||
switch {
|
||||
case live > 0:
|
||||
rep.State = ByeDPIListening
|
||||
rep.Detail = "something is accepting TCP on " + byedpiPortList(rep.Instances, ByeDPIPortListening) +
|
||||
", 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."
|
||||
case len(inconclusive) > 0:
|
||||
rep.State = ByeDPIUnknown
|
||||
rep.Detail = "an instance is enabled on " + byedpiPortList(inconclusive, ByeDPIPortUndetermined) +
|
||||
", but the connection attempt neither succeeded nor was refused, so it is NOT known whether anything is listening there."
|
||||
case len(unchecked) > 0:
|
||||
rep.State = ByeDPIUnknown
|
||||
rep.Detail = byedpiConfigPath + " enables more than " + strconv.Itoa(byedpiMaxInstances) +
|
||||
" instances, so the ones past the first " + strconv.Itoa(byedpiMaxInstances) +
|
||||
" — on " + byedpiPortList(unchecked, ByeDPIPortUndetermined) +
|
||||
" — were NOT checked: no connection was attempted to them, and nothing is known about their ports. This is an unknown, not a verdict."
|
||||
default:
|
||||
rep.State = ByeDPINotListening
|
||||
rep.Detail = "an instance is enabled on " + byedpiPortList(rep.Instances, ByeDPIPortRefused) +
|
||||
", but the connection was refused there — the service is configured to run and is not running. Start it: /etc/init.d/byedpi restart."
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
rep.Detail += " " + byedpiProblemSentence(problems)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// byedpiProbeNow is byedpiProbe plus the age stamp: how long ago the FIRST
|
||||
// connect of this report was started. It is what GET /api/byedpi serves.
|
||||
//
|
||||
// # Why there is no cache here any more
|
||||
//
|
||||
// There was one. GET /api/status carried the whole report, served from a stored
|
||||
// copy that a poll refreshed in the background once it passed three seconds —
|
||||
// and the panel polls that endpoint every five. Five is more than three, so
|
||||
// every single poll started a refresh: a LookPath, a read of
|
||||
// /etc/config/byedpi, and one connect per enabled instance, for as long as any
|
||||
// browser tab was open, including a tab nobody was looking at. Measured on one
|
||||
// enabled instance: 12 connects and 13 PATH lookups per minute per tab. The
|
||||
// cache's own comment rejected a background ticker on the grounds that "a closed
|
||||
// panel costs nothing" — which was true, and said nothing about the open one it
|
||||
// had turned into a five-second timer.
|
||||
//
|
||||
// The price bought nothing. No panel code read `status.byedpi`: the readiness
|
||||
// plate, the egress cross-check and the type gate all come from GET /api/byedpi,
|
||||
// which a human triggers and which always measured on the spot. So the report
|
||||
// left GET /api/status, and with its only cached reader gone the cache went too
|
||||
// — together with the background goroutine, the staleness rules, and the
|
||||
// "negative age means this is not a measurement" contract that existed purely to
|
||||
// keep a copy from passing for a reading.
|
||||
//
|
||||
// What is left is the honest shape: the one endpoint that reports a listener is
|
||||
// the one that connects, every time it is asked.
|
||||
//
|
||||
// byedpiNow is the clock the age stamp reads — a seam so a test can prove the
|
||||
// stamp is the probe's own duration rather than a hard-coded zero.
|
||||
var byedpiNow = time.Now
|
||||
|
||||
func byedpiProbeNow() byedpiReport {
|
||||
started := byedpiNow()
|
||||
rep := byedpiProbe()
|
||||
rep.AgeSeconds = int(byedpiNow().Sub(started) / time.Second)
|
||||
return rep
|
||||
}
|
||||
|
||||
// byedpiPortList renders the ports of the instances in one listener state, for
|
||||
// the Detail sentences ("127.0.0.1:1080" / "127.0.0.1:1080, 127.0.0.1:1081").
|
||||
func byedpiPortList(instances []byedpiInstance, want string) string {
|
||||
var parts []string
|
||||
for _, in := range instances {
|
||||
if in.Listening == want {
|
||||
parts = append(parts, "127.0.0.1:"+strconv.Itoa(in.Port))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "its configured port"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// byedpiPortState connects to 127.0.0.1:port and classifies the outcome into the
|
||||
// closed ByeDPIPort* set.
|
||||
//
|
||||
// A successful connect is closed immediately: this must not consume a slot of a
|
||||
// proxy somebody is using.
|
||||
//
|
||||
// The classification is by BEHAVIOUR, not by error string. A clean refusal is
|
||||
// the kernel saying nothing is bound; anything else (timeout, an unreachable
|
||||
// loopback, a permission error) is a failure OF THE INSTRUMENT and is reported
|
||||
// as such. Mapping every error to "no" is exactly the shape of lie this whole
|
||||
// change removes — it would let a broken check print "the proxy is down".
|
||||
func byedpiPortState(port int) string {
|
||||
if port <= 0 || port > 65535 {
|
||||
return ByeDPIPortUndetermined
|
||||
}
|
||||
conn, err := byedpiDial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), byedpiDialTimeout)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return byedpiDialVerdict(err)
|
||||
}
|
||||
|
||||
// byedpiDialVerdict classifies ONE connect outcome into the closed ByeDPIPort*
|
||||
// set. Split out from the dial so the classification can be tested against the
|
||||
// errors that matter — including the ones a loopback dial cannot be made to
|
||||
// produce on demand, which are exactly the ones a careless implementation folds
|
||||
// into "no".
|
||||
//
|
||||
// Three answers, and the middle one is the whole point:
|
||||
//
|
||||
// nil — something accepted: "yes";
|
||||
// ECONNREFUSED — the kernel says nothing is bound there: "no". This is
|
||||
// the ONLY error that means that;
|
||||
// anything else — a timeout, a permission denial, an exhausted fd table:
|
||||
// the INSTRUMENT failed, and it has shown nothing about
|
||||
// the port. "unknown".
|
||||
func byedpiDialVerdict(err error) string {
|
||||
if err == nil {
|
||||
return ByeDPIPortListening
|
||||
}
|
||||
var nerr net.Error
|
||||
if errors.As(err, &nerr) && nerr.Timeout() {
|
||||
return ByeDPIPortUndetermined
|
||||
}
|
||||
if isConnRefused(err) {
|
||||
return ByeDPIPortRefused
|
||||
}
|
||||
return ByeDPIPortUndetermined
|
||||
}
|
||||
|
||||
// wsaeConnRefused is Windows' WSAECONNREFUSED (10061), the errno a Windows
|
||||
// kernel actually returns for a refused connect. It is spelled as a literal
|
||||
// because the named constant lives in golang.org/x/sys/windows, which this fork
|
||||
// does not depend on, and because syscall.ECONNREFUSED on Windows is a different
|
||||
// (POSIX-compat) value that no socket call ever produces there.
|
||||
//
|
||||
// The production target is Linux; this exists so the same detector gives the same
|
||||
// answer on a developer's box, because a check that reports "unknown" on the
|
||||
// machine its tests run on cannot be tested.
|
||||
const wsaeConnRefused = syscall.Errno(10061)
|
||||
|
||||
// isConnRefused reports the ONE error that means "the kernel says nothing is
|
||||
// bound there". Everything else that is not a timeout — a permission denial, an
|
||||
// exhausted fd table — is a failure of the instrument and must NOT be folded in
|
||||
// here: that would let a broken check print "the proxy is down".
|
||||
func isConnRefused(err error) bool {
|
||||
return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, wsaeConnRefused)
|
||||
}
|
||||
|
||||
// --- reading /etc/config/byedpi the way /etc/init.d/byedpi does ---------------
|
||||
//
|
||||
// # The defect this replaces
|
||||
//
|
||||
// The first version of this parser read `port` with strconv.Atoi and, when that
|
||||
// failed, LEFT THE DEFAULT IN PLACE. So `option port 'auto'` on an enabled
|
||||
// instance became "an enabled instance on 1080" — while /etc/init.d/byedpi,
|
||||
// which validates the section with
|
||||
//
|
||||
// uci_load_validate byedpi instance "$1" "$2" 'enabled:bool:0' 'port:port:1080' 'args:string:'
|
||||
//
|
||||
// and refuses to start a section whose validation returned non-zero, had started
|
||||
// NOTHING. If anything else on the box happened to hold 1080, this file then
|
||||
// reported state "listening" — the one state that unlocks the byedpi egress
|
||||
// type — for a proxy that does not exist. That is precisely the failure the file
|
||||
// was written to remove, reintroduced by an open `default`.
|
||||
//
|
||||
// # What the init script actually does (measured, not assumed)
|
||||
//
|
||||
// Run on the 25.12.1 testbed against /sbin/validate_data with the init script's
|
||||
// own spec, section by section:
|
||||
//
|
||||
// enabled '1' port '1080' → rc=0 enabled=1 port=1080 started on 1080
|
||||
// enabled 'yes' (no port) → rc=0 enabled=1 port=1080 started on 1080 (default)
|
||||
// enabled '1' port '' → rc=0 enabled=1 port=1080 empty reads as UNSET → default
|
||||
// enabled '1' port 'auto' → rc=255 (port unset) REFUSED, nothing started
|
||||
// enabled '1' port '99999' → rc=255 (port unset) REFUSED, nothing started
|
||||
// enabled 'ja' port '1090' → rc=255 (enabled unset) REFUSED, nothing started
|
||||
// enabled ' 1' port '1095' → rc=255 (enabled unset) REFUSED, nothing started
|
||||
// enabled 'TRUE' port '1091'→ rc=0 enabled= port=1091 NOT started: the value
|
||||
// passes as a bool but normalises to EMPTY, so the
|
||||
// script's `[ "$enabled" -eq 1 ]` never fires
|
||||
// enabled '' port '1094' → rc=0 enabled=0 port=1094 not enabled
|
||||
//
|
||||
// Two lessons the old code got wrong in the dangerous direction. An invalid
|
||||
// value does not fall back to the default — it stops the section. And the
|
||||
// boolean words start an instance only in their LOWER-CASE spelling, so
|
||||
// strings.ToLower/TrimSpace on the way in (what uciTrue used to do) made this
|
||||
// file say "enabled" about sections the router never started.
|
||||
//
|
||||
// # The rule implemented here — closed and positive
|
||||
//
|
||||
// `enabled`, matched exactly, no folding, no trimming:
|
||||
//
|
||||
// absent or "" → off, and silently: that is the shipped state
|
||||
// 1 | on | true | yes | enabled → the instance starts
|
||||
// 0 | off | false | no | disabled → off, silently
|
||||
// anything else → the instance does NOT start, and is REPORTED
|
||||
//
|
||||
// `port`, only after `enabled` said start:
|
||||
//
|
||||
// absent or "" → 1080, the init script's own default
|
||||
// all decimal digits, value 1..65535 → that port
|
||||
// anything else → NO port is assumed; the section does not
|
||||
// count as a listener, and is REPORTED
|
||||
//
|
||||
// The port rule is deliberately narrower than libvalidate's `port` datatype,
|
||||
// which also accepts a leading sign, leading whitespace and (through an integer
|
||||
// overflow) some absurdly long digit strings. Narrower is the recoverable
|
||||
// direction: the worst it can do is decline to call a working instance a
|
||||
// listener — which locks the egress type and prints the section, the option and
|
||||
// the value — whereas any widening risks the opposite, and the opposite is a
|
||||
// green apply onto a port nothing is on.
|
||||
//
|
||||
// A MISSING file is an error, not "no instances": the byedpi package ships the
|
||||
// file as a conffile, so a binary present with no config is a broken install and
|
||||
// must read as unknown rather than as a tidy "disabled".
|
||||
|
||||
// byedpiSectionProblem is one `config instance` section that will NOT yield a
|
||||
// listener this check can verify, and why. Reason is a whole sentence, safe to
|
||||
// show verbatim, and it claims only what was measured above.
|
||||
type byedpiSectionProblem struct {
|
||||
Section string
|
||||
Option string // "enabled" or "port" — the option that stopped it
|
||||
Value string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// byedpiEnabledStart is the CLOSED, POSITIVE list of `enabled` values that make
|
||||
// /etc/init.d/byedpi start an instance: the lower-case spellings, matched
|
||||
// exactly. Upper- and mixed-case spellings pass libvalidate's bool but normalise
|
||||
// to an empty string, and the script's `[ "$enabled" -eq 1 ]` does not fire on
|
||||
// those — so they belong on the other side of this list, not on this one.
|
||||
var byedpiEnabledStart = []string{"1", "on", "true", "yes", "enabled"}
|
||||
|
||||
// byedpiEnabledStop is the matching CLOSED list of values that mean "off"
|
||||
// without anything being wrong. Kept separate from "unrecognised" so that a
|
||||
// deliberate enabled='0' stays silent while a typo does not.
|
||||
var byedpiEnabledStop = []string{"0", "off", "false", "no", "disabled"}
|
||||
|
||||
func byedpiInList(v string, list []string) bool {
|
||||
for _, s := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func byedpiInListFold(v string, list []string) bool {
|
||||
for _, s := range list {
|
||||
if strings.EqualFold(v, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// byedpiParsePort maps a raw `port` value to the port the init script would bind,
|
||||
// or to ok=false meaning "no port may be assumed for this section".
|
||||
func byedpiParsePort(raw string) (port int, ok bool) {
|
||||
if raw == "" {
|
||||
return byedpiDefaultPort, true // absent or empty reads as unset — measured
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// readByeDPIInstances parses /etc/config/byedpi and returns, in file order, the
|
||||
// instances /etc/init.d/byedpi would START on a dialable port, plus every
|
||||
// section it would not and the reason. See the block comment above for the rule
|
||||
// and for the measurements it mirrors.
|
||||
func readByeDPIInstances(path string) ([]byedpiInstance, []byedpiSectionProblem, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []byedpiInstance
|
||||
var problems []byedpiSectionProblem
|
||||
// The raw text of the current section's options, kept RAW: the decision needs
|
||||
// the exact bytes (' 1' is not '1'), and it needs to know absent from empty.
|
||||
var section string
|
||||
var open bool
|
||||
var rawEnabled, rawPort string
|
||||
anon := 0
|
||||
|
||||
flush := func() {
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
sec := section
|
||||
open = false
|
||||
switch {
|
||||
case rawEnabled == "" || byedpiInList(rawEnabled, byedpiEnabledStop):
|
||||
// Off on purpose. Nothing to say — this is the shipped state.
|
||||
case byedpiInList(rawEnabled, byedpiEnabledStart):
|
||||
port, ok := byedpiParsePort(rawPort)
|
||||
if !ok {
|
||||
problems = append(problems, byedpiSectionProblem{
|
||||
Section: sec, Option: "port", Value: rawPort,
|
||||
Reason: "section '" + sec + "' sets port " + strconv.Quote(rawPort) +
|
||||
", 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.",
|
||||
})
|
||||
return
|
||||
}
|
||||
out = append(out, byedpiInstance{Section: sec, Port: port})
|
||||
case byedpiInListFold(rawEnabled, byedpiEnabledStart), byedpiInListFold(rawEnabled, byedpiEnabledStop):
|
||||
problems = append(problems, byedpiSectionProblem{
|
||||
Section: sec, Option: "enabled", Value: rawEnabled,
|
||||
Reason: "section '" + sec + "' sets enabled " + strconv.Quote(rawEnabled) +
|
||||
"; /etc/init.d/byedpi accepts that as a boolean but normalises it to an EMPTY value, so its `[ \"$enabled\" -eq 1 ]` test never fires and the instance is not started. Write it lower case (enabled '1').",
|
||||
})
|
||||
default:
|
||||
problems = append(problems, byedpiSectionProblem{
|
||||
Section: sec, Option: "enabled", Value: rawEnabled,
|
||||
Reason: "section '" + sec + "' sets enabled " + strconv.Quote(rawEnabled) +
|
||||
", which is not one of UCI's boolean words, so /etc/init.d/byedpi's validation refuses the whole section (it logs \"byedpi: validation failed for '" + sec + "'\") and starts nothing for it.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
kw, rest := cutUCIWord(line)
|
||||
switch kw {
|
||||
case "config":
|
||||
flush()
|
||||
typ, name := cutUCIWord(rest)
|
||||
if typ != "instance" {
|
||||
continue // some other section type; not ours
|
||||
}
|
||||
section = unquoteUCI(strings.TrimSpace(name))
|
||||
if section == "" {
|
||||
section = fmt.Sprintf("@instance[%d]", anon)
|
||||
anon++
|
||||
}
|
||||
rawEnabled, rawPort, open = "", "", true
|
||||
case "option":
|
||||
if !open {
|
||||
continue
|
||||
}
|
||||
key, val := cutUCIWord(rest)
|
||||
v := unquoteUCI(strings.TrimSpace(val))
|
||||
switch key {
|
||||
case "enabled":
|
||||
rawEnabled = v
|
||||
case "port":
|
||||
rawPort = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
flush()
|
||||
return out, problems, nil
|
||||
}
|
||||
|
||||
// byedpiProblemSentence renders the section problems into the report's Detail.
|
||||
// Bounded: /etc/config/byedpi is operator-editable, and a file with fifty broken
|
||||
// sections must not turn one sentence into a wall.
|
||||
func byedpiProblemSentence(problems []byedpiSectionProblem) string {
|
||||
const show = 3
|
||||
parts := make([]string, 0, show+1)
|
||||
for i, p := range problems {
|
||||
if i >= show {
|
||||
parts = append(parts, strconv.Itoa(len(problems)-show)+" further section(s) are in the same position.")
|
||||
break
|
||||
}
|
||||
parts = append(parts, p.Reason)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// cutUCIWord splits off the first whitespace-delimited token, returning it and
|
||||
// the remainder. Same shape as model/uci.go's own tokenizer; duplicated rather
|
||||
// than exported because this parses the ON-DISK format of a DIFFERENT package's
|
||||
// file, and coupling it to the shater model's parser would make an unrelated
|
||||
// change to one able to break the other.
|
||||
func cutUCIWord(s string) (word, rest string) {
|
||||
s = strings.TrimSpace(s)
|
||||
i := strings.IndexAny(s, " \t")
|
||||
if i < 0 {
|
||||
return s, ""
|
||||
}
|
||||
return s[:i], strings.TrimSpace(s[i+1:])
|
||||
}
|
||||
|
||||
// unquoteUCI strips one layer of matching single or double quotes.
|
||||
func unquoteUCI(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// byedpiCrossCheck attaches the per-egress verdicts to a service report: for each
|
||||
// `byedpi` egress in the model, does an ENABLED instance serve the port that
|
||||
// egress will dial, and is anything accepting there.
|
||||
//
|
||||
// This is the reconciliation the two packages never had. It is computed here
|
||||
// rather than left to the panel because the rule it applies — the generator's
|
||||
// port default, the init script's port default, and the listener answer — lives
|
||||
// on this side, and a client re-deriving it would be a second copy free to drift.
|
||||
func byedpiCrossCheck(rep byedpiReport, egresses []model.Egress) byedpiReport {
|
||||
checks := []byedpiEgressCheck{}
|
||||
// Port -> the enabled instance serving it (first wins; a duplicate port is a
|
||||
// misconfiguration whose second binder never starts).
|
||||
byPort := map[int]byedpiInstance{}
|
||||
for _, in := range rep.Instances {
|
||||
if _, dup := byPort[in.Port]; !dup {
|
||||
byPort[in.Port] = in
|
||||
}
|
||||
}
|
||||
for _, eg := range egresses {
|
||||
if model.CanonicalEgressType(eg.Type) != model.EgressTypeByeDPI {
|
||||
continue
|
||||
}
|
||||
port := eg.Port
|
||||
if port == 0 {
|
||||
port = byedpiDefaultPort // generate/outbound.go substitutes the same
|
||||
}
|
||||
checks = append(checks, byedpiEgressVerdict(rep, byPort, eg.Name, port))
|
||||
}
|
||||
rep.Egresses = checks
|
||||
return rep
|
||||
}
|
||||
|
||||
// byedpiEgressVerdict decides one egress's state. The dispatch is over the
|
||||
// service state's CLOSED set first — an egress cannot be healthier than the
|
||||
// service — and only then over the port match, so there is no path on which a
|
||||
// mismatch is reported against a service that is not even installed.
|
||||
func byedpiEgressVerdict(rep byedpiReport, byPort map[int]byedpiInstance, name string, port int) byedpiEgressCheck {
|
||||
c := byedpiEgressCheck{Name: name, Port: port}
|
||||
where := "127.0.0.1:" + strconv.Itoa(port)
|
||||
switch rep.State {
|
||||
case ByeDPINotInstalled:
|
||||
c.State, c.Detail = ByeDPIEgressNotInstalled,
|
||||
"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."
|
||||
return c
|
||||
case ByeDPIUnknown:
|
||||
c.State, c.Detail = ByeDPIEgressUnknown,
|
||||
"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."
|
||||
return c
|
||||
case ByeDPIDisabled:
|
||||
c.State, c.Detail = ByeDPIEgressServiceDisabled,
|
||||
"this egress dials "+where+", but no byedpi instance is enabled in "+byedpiConfigPath+", so nothing is listening on any port."
|
||||
if len(rep.problems) > 0 {
|
||||
// "no instance is enabled" would read as the shipped, deliberate state.
|
||||
// It is not: a section IS written, and the init script will not start it.
|
||||
c.Detail = "this egress dials " + where + ", but " + byedpiConfigPath +
|
||||
" holds no instance /etc/init.d/byedpi would start, so nothing is listening on any port. " +
|
||||
byedpiProblemSentence(rep.problems)
|
||||
}
|
||||
return c
|
||||
case ByeDPIListening, ByeDPINotListening:
|
||||
// fall through to the port comparison
|
||||
default:
|
||||
// The service states are a closed list; a value outside it means this file
|
||||
// and byedpiProbe have drifted apart. Say so rather than guess — guessing
|
||||
// here is what would print "ok" for a state nobody defined.
|
||||
c.State, c.Detail = ByeDPIEgressUnknown,
|
||||
"this egress dials "+where+", and the byedpi service reported an unrecognised state "+strconv.Quote(rep.State)+"; nothing can be concluded from it."
|
||||
return c
|
||||
}
|
||||
|
||||
inst, served := byPort[port]
|
||||
if !served {
|
||||
c.State = ByeDPIEgressPortMismatch
|
||||
c.Detail = "PORT MISMATCH: this egress dials " + where + ", but " + byedpiConfigPath +
|
||||
" enables no instance on that port — the enabled instances are on " + byedpiConfiguredPorts(rep.Instances) +
|
||||
". The two packages agree only by hand: change the egress port, or the instance's, so they match."
|
||||
return c
|
||||
}
|
||||
switch inst.Listening {
|
||||
case ByeDPIPortListening:
|
||||
c.State = ByeDPIEgressOK
|
||||
c.Detail = "byedpi instance '" + inst.Section + "' is enabled on " + where +
|
||||
" and something is accepting there. The check is a connection, not a SOCKS5 handshake."
|
||||
case ByeDPIPortRefused:
|
||||
c.State = ByeDPIEgressNotListening
|
||||
c.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."
|
||||
default:
|
||||
c.State = ByeDPIEgressUnknown
|
||||
c.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."
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// byedpiConfiguredPorts lists the ports of the enabled instances, for the
|
||||
// mismatch sentence. The list is what makes the message actionable: it names the
|
||||
// port to move TO instead of only saying the current one is wrong.
|
||||
func byedpiConfiguredPorts(instances []byedpiInstance) string {
|
||||
seen := map[int]bool{}
|
||||
var ports []int
|
||||
for _, in := range instances {
|
||||
if !seen[in.Port] {
|
||||
seen[in.Port] = true
|
||||
ports = append(ports, in.Port)
|
||||
}
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
return "no port at all"
|
||||
}
|
||||
sort.Ints(ports)
|
||||
parts := make([]string, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
parts = append(parts, "127.0.0.1:"+strconv.Itoa(p))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// byedpiReportRead is handleByeDPI's model seam (same pattern as
|
||||
// configGetRead / nodeTestConfigRead).
|
||||
var byedpiReportRead = model.ReadUCI
|
||||
|
||||
// handleByeDPI → GET /api/byedpi: the byedpi service state PLUS the per-egress
|
||||
// port cross-check.
|
||||
//
|
||||
// # This is the ONLY endpoint that reports a listener, and it always connects
|
||||
//
|
||||
// GET /api/status carries no readiness report at all — it once did, from a
|
||||
// cache, and the panel polls it every five seconds while reading none of it (see
|
||||
// byedpiProbeNow for the measurement). So there is one endpoint, a human asks
|
||||
// for it, and it dials every time it is asked. A re-check button answered from a
|
||||
// copy is a button that does nothing, which is worse than no button.
|
||||
//
|
||||
// The connects are therefore paid on a request somebody is waiting for — the one
|
||||
// place they are worth paying. The worst case is bounded: byedpiMaxInstances
|
||||
// ports × byedpiDialTimeout.
|
||||
//
|
||||
// Contract for the frontend:
|
||||
//
|
||||
// GET /api/byedpi
|
||||
// → 200 {"state","detail","binary","config_read","age_seconds","instances":[…],"egresses":[…]}
|
||||
//
|
||||
// - state is exactly one of "unknown" | "not_installed" | "disabled" |
|
||||
// "not_listening" | "listening". Only "listening" may unlock the byedpi
|
||||
// egress type. "unknown" is NOT a soft yes — render it as an unknown.
|
||||
// - instances[] = {section, port, listening} for every ENABLED instance;
|
||||
// listening is "yes" | "no" | "unknown", and "unknown" is not "no".
|
||||
// - egresses[] = {name, port, state, detail} for every byedpi egress in the
|
||||
// configuration; state is one of "ok" | "port_mismatch" | "not_listening" |
|
||||
// "service_disabled" | "not_installed" | "unknown". port is the port that
|
||||
// will actually be dialled (the generator's 1080 default already applied).
|
||||
// - detail is one sentence, safe to show verbatim, and never claims more than
|
||||
// was checked: the listener test is a TCP connect, not a SOCKS5 handshake.
|
||||
// - age_seconds is how long ago the connects behind the answer were started.
|
||||
// This endpoint measures on the spot, so it is normally 0, and larger only
|
||||
// when the probe itself was slow. It is never negative: every body this
|
||||
// endpoint returns is a measurement it just took.
|
||||
// - a model that cannot be read still returns 200 with the service state and an
|
||||
// EMPTY egresses[] plus a detail saying the cross-check was not performed —
|
||||
// the service facts are real and withholding them would help nobody.
|
||||
func (s *Server) handleByeDPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
rep := byedpiProbeNow()
|
||||
m, err := byedpiReportRead()
|
||||
if err != nil {
|
||||
rep.Detail += " (the configuration could not be read, so the per-egress port cross-check was not performed: " + err.Error() + ")"
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, byedpiCrossCheck(rep, m.Egresses))
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// Parity between readByeDPIInstances and /etc/init.d/byedpi.
|
||||
//
|
||||
// # The defect these tests are the regression for
|
||||
//
|
||||
// The parser's comment claimed it "mirrors /etc/init.d/byedpi exactly". It did
|
||||
// not. The init script validates every section with
|
||||
//
|
||||
// uci_load_validate byedpi instance "$1" "$2" 'enabled:bool:0' 'port:port:1080' 'args:string:'
|
||||
//
|
||||
// and start_instance refuses any section whose validation returned non-zero. The
|
||||
// Go side read `port` with strconv.Atoi and, when that failed, silently KEPT the
|
||||
// 1080 default. So `option port 'auto'` on an enabled instance — a section the
|
||||
// router starts nothing for — became "an enabled instance on 1080" here, and if
|
||||
// anything else on the box happened to be accepting there, the report came back
|
||||
// state="listening": the one state that unlocks the byedpi egress type, handed
|
||||
// out for a proxy that does not exist. That is the exact failure this whole file
|
||||
// was written to remove, let back in through an open default.
|
||||
//
|
||||
// The `enabled` half had the same shape from the other direction:
|
||||
// strings.ToLower + TrimSpace made `enabled ' 1'` and `enabled 'TRUE'` read as
|
||||
// on, while the router starts neither.
|
||||
//
|
||||
// # Where the expectations come from
|
||||
//
|
||||
// Not from reading libvalidate. Every "init script outcome" asserted below was
|
||||
// MEASURED on the 25.12.1 testbed (the revision mini_router runs) by feeding the
|
||||
// section to /sbin/validate_data with the init script's own spec:
|
||||
//
|
||||
// enabled '1' port '1080' → rc=0 enabled=1 port=1080 → started on 1080
|
||||
// enabled 'yes' (no port) → rc=0 enabled=1 port=1080 → started on 1080
|
||||
// enabled '1' port '' → rc=0 enabled=1 port=1080 → empty reads as UNSET
|
||||
// enabled '1' port 'auto' → rc=255 (port unset) → REFUSED
|
||||
// enabled '1' port '99999' → rc=255 (port unset) → REFUSED
|
||||
// enabled 'ja' port '1090' → rc=255 (enabled unset) → REFUSED
|
||||
// enabled ' 1' port '1095' → rc=255 (enabled unset) → REFUSED
|
||||
// enabled 'TRUE' port '1091' → rc=0 enabled= port=1091 → NOT started: the
|
||||
// value passes as a bool but normalises to EMPTY,
|
||||
// so `[ "$enabled" -eq 1 ]` never fires
|
||||
// enabled '' port '1094' → rc=0 enabled=0 port=1094 → not enabled
|
||||
//
|
||||
// # The one deliberate divergence
|
||||
//
|
||||
// libvalidate's `port` datatype is strtol-shaped: it also accepts a leading
|
||||
// sign, leading whitespace, and — through an integer overflow — a twenty-digit
|
||||
// number. This parser accepts only plain decimal digits in 1..65535. That is
|
||||
// narrower on purpose, and narrow is the recoverable side: the worst it can do
|
||||
// is decline to call a working instance a listener, which locks the egress type
|
||||
// and prints the section, the option and the value. Widening it risks the
|
||||
// opposite, and the opposite is a green apply onto a port nothing is on.
|
||||
|
||||
// bdAcceptAnyPort points every connect at ONE REAL LISTENER, whatever port was
|
||||
// asked for. It is "something else on the box is accepting there", made
|
||||
// deterministic without having to bind a fixed port like 1080 in a test.
|
||||
//
|
||||
// A real socket, not a fake conn: the claim under test is that the parser never
|
||||
// hands a refused section to the dialler, and a stubbed "yes" would leave open
|
||||
// the possibility that the dialler simply cannot say yes at all.
|
||||
func bdAcceptAnyPort(t *testing.T) {
|
||||
t.Helper()
|
||||
live, closer := listenLoopback(t)
|
||||
t.Cleanup(closer)
|
||||
old := byedpiDial
|
||||
byedpiDial = func(network, _ string, timeout time.Duration) (net.Conn, error) {
|
||||
return old(network, net.JoinHostPort("127.0.0.1", strconv.Itoa(live)), timeout)
|
||||
}
|
||||
t.Cleanup(func() { byedpiDial = old })
|
||||
}
|
||||
|
||||
// TestByeDPIRefusedSectionCannotFabricateListening is THE regression, with its
|
||||
// control.
|
||||
//
|
||||
// The world is the hostile one: something is accepting on every port that gets
|
||||
// dialled. Under that, a section /etc/init.d/byedpi refuses must still not come
|
||||
// back "listening" — and nothing may be dialled on its behalf at all.
|
||||
//
|
||||
// CONTROL: the same hostile world and the same instrument, with a port value the
|
||||
// init script DOES accept, produces exactly the "listening" the case above must
|
||||
// not. Without it, "not listening" would be equally consistent with a probe that
|
||||
// had stopped working.
|
||||
func TestByeDPIRefusedSectionCannotFabricateListening(t *testing.T) {
|
||||
refused := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"port auto", "config instance 'x'\n\toption enabled '1'\n\toption port 'auto'\n"},
|
||||
{"port above 65535", "config instance 'x'\n\toption enabled '1'\n\toption port '99999'\n"},
|
||||
{"port 70000", "config instance 'x'\n\toption enabled '1'\n\toption port '70000'\n"},
|
||||
{"port 0", "config instance 'x'\n\toption enabled '1'\n\toption port '0'\n"},
|
||||
{"port with a trailing space", "config instance 'x'\n\toption enabled '1'\n\toption port '1080 '\n"},
|
||||
{"enabled not a boolean word", "config instance 'x'\n\toption enabled 'ja'\n\toption port '1080'\n"},
|
||||
{"enabled padded", "config instance 'x'\n\toption enabled ' 1'\n\toption port '1080'\n"},
|
||||
{"enabled upper case", "config instance 'x'\n\toption enabled 'TRUE'\n\toption port '1080'\n"},
|
||||
}
|
||||
for _, c := range refused {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
byedpiFixture(t, c.body)
|
||||
bdAcceptAnyPort(t)
|
||||
dials := bdDialMeter(t)
|
||||
|
||||
rep := byedpiProbe()
|
||||
if rep.State == ByeDPIListening {
|
||||
t.Fatalf("state = %q (%s) — /etc/init.d/byedpi starts nothing for this section, and this is the one state that unlocks the byedpi egress type",
|
||||
rep.State, rep.Detail)
|
||||
}
|
||||
if len(rep.Instances) != 0 {
|
||||
t.Fatalf("instances = %+v — a section the router never starts is not a running instance", rep.Instances)
|
||||
}
|
||||
if got := dials.Load(); got != 0 {
|
||||
t.Fatalf("%d connect(s) were made for a section nothing was started from; want 0", got)
|
||||
}
|
||||
// The value must be named, not swallowed: an operator who wrote it is
|
||||
// otherwise told "no instance is enabled" about a file where one is.
|
||||
if !strings.Contains(rep.Detail, "'x'") {
|
||||
t.Errorf("detail does not name the section: %q", rep.Detail)
|
||||
}
|
||||
// And no sentence may assert a connection that never happened.
|
||||
for _, lie := range []string{"the connection was refused", "neither succeeded nor was refused", "something is accepting"} {
|
||||
if strings.Contains(rep.Detail, lie) {
|
||||
t.Errorf("detail claims %q for a section nothing dialled: %q", lie, rep.Detail)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// CONTROL — the same hostile world says "listening" when the section is one
|
||||
// the init script really would start.
|
||||
for _, c := range []struct{ name, body string }{
|
||||
{"port 1080", "config instance 'x'\n\toption enabled '1'\n\toption port '1080'\n"},
|
||||
{"port absent", "config instance 'x'\n\toption enabled '1'\n"},
|
||||
{"port empty reads as unset", "config instance 'x'\n\toption enabled '1'\n\toption port ''\n"},
|
||||
{"enabled yes", "config instance 'x'\n\toption enabled 'yes'\n\toption port '1080'\n"},
|
||||
} {
|
||||
t.Run("CONTROL "+c.name, func(t *testing.T) {
|
||||
byedpiFixture(t, c.body)
|
||||
bdAcceptAnyPort(t)
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPIListening {
|
||||
t.Fatalf("state = %q (%s), want %q — the init script starts this section, so the instrument must be able to say so",
|
||||
rep.State, rep.Detail, ByeDPIListening)
|
||||
}
|
||||
if len(rep.Instances) != 1 || rep.Instances[0].Port != byedpiDefaultPort {
|
||||
t.Fatalf("instances = %+v, want one on %d", rep.Instances, byedpiDefaultPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIPortParseIsAClosedPositiveList pins byedpiParsePort itself, in both
|
||||
// directions, so the rule is readable as a table rather than inferred from
|
||||
// end-to-end states.
|
||||
func TestByeDPIPortParseIsAClosedPositiveList(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
port int
|
||||
ok bool
|
||||
}{
|
||||
{"", byedpiDefaultPort, true}, // absent/empty reads as unset — measured
|
||||
{"1080", 1080, true},
|
||||
{"1", 1, true},
|
||||
{"65535", 65535, true},
|
||||
{"007", 7, true}, // plain digits; ciadpi's own atoi reads 7 too
|
||||
{"0", 0, false}, // nothing can be dialled there
|
||||
{"65536", 0, false},
|
||||
{"99999", 0, false},
|
||||
{"70000", 0, false},
|
||||
{"auto", 0, false},
|
||||
{"-1", 0, false},
|
||||
{"+1080", 0, false},
|
||||
{" 1080", 0, false},
|
||||
{"1080 ", 0, false},
|
||||
{"1080abc", 0, false},
|
||||
{"0x50", 0, false},
|
||||
{"1080.0", 0, false},
|
||||
{"99999999999999999999", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
port, ok := byedpiParsePort(c.raw)
|
||||
if ok != c.ok || (ok && port != c.port) {
|
||||
t.Errorf("byedpiParsePort(%q) = (%d, %t), want (%d, %t)", c.raw, port, ok, c.port, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIEnabledStartsOnlyOnTheLowerCaseWords. The two lists are closed and
|
||||
// disjoint, and only the exact lower-case spellings start an instance — because
|
||||
// that is all /etc/init.d/byedpi starts. The upper-case spellings are the
|
||||
// interesting half: they PASS libvalidate's bool, so a check that only asked
|
||||
// "is this a valid boolean" would call them on.
|
||||
func TestByeDPIEnabledStartsOnlyOnTheLowerCaseWords(t *testing.T) {
|
||||
starts := func(t *testing.T, value string) bool {
|
||||
t.Helper()
|
||||
body := "config instance 'x'\n\toption enabled '" + value + "'\n\toption port '1080'\n"
|
||||
got, _, err := readByeDPIInstancesFrom(t, body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return len(got) == 1
|
||||
}
|
||||
for _, v := range byedpiEnabledStart {
|
||||
if !starts(t, v) {
|
||||
t.Errorf("enabled %q did not start an instance; it is one of the words the init script starts on", v)
|
||||
}
|
||||
}
|
||||
for _, v := range byedpiEnabledStop {
|
||||
if starts(t, v) {
|
||||
t.Errorf("enabled %q started an instance; it is one of UCI's false words", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"TRUE", "Yes", "On", "Enabled", " 1", "1 ", "ja", "2", "-1"} {
|
||||
if starts(t, v) {
|
||||
t.Errorf("enabled %q started an instance; /etc/init.d/byedpi starts nothing for it", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIRefusedSectionIsReportedNotSwallowed: dropping a broken section
|
||||
// quietly would leave the operator reading "no enabled instance — that is the
|
||||
// shipped default" about a file in which they enabled one. The sentence has to
|
||||
// name the section, the option and the value, and it may not repeat the
|
||||
// shipped-default story.
|
||||
func TestByeDPIRefusedSectionIsReportedNotSwallowed(t *testing.T) {
|
||||
byedpiFixture(t, "config instance 'mine'\n\toption enabled '1'\n\toption port 'auto'\n")
|
||||
rep := byedpiProbe()
|
||||
|
||||
if rep.State != ByeDPIDisabled {
|
||||
t.Fatalf("state = %q (%s), want %q — nothing was started, and nothing was dialled either",
|
||||
rep.State, rep.Detail, ByeDPIDisabled)
|
||||
}
|
||||
for _, want := range []string{"'mine'", "port", `"auto"`} {
|
||||
if !strings.Contains(rep.Detail, want) {
|
||||
t.Errorf("detail must contain %s; got %q", want, rep.Detail)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rep.Detail, "That is the shipped default") {
|
||||
t.Errorf("detail tells the shipped-default story about a section the operator enabled: %q", rep.Detail)
|
||||
}
|
||||
|
||||
// CONTROL: with a genuinely inert file — the shipped state — the same code
|
||||
// DOES tell the shipped-default story, so the branch above is a distinction
|
||||
// and not a blanket rewording.
|
||||
byedpiFixture(t, "config instance 'default'\n\toption enabled '0'\n\toption port '1080'\n")
|
||||
inert := byedpiProbe()
|
||||
if inert.State != ByeDPIDisabled {
|
||||
t.Fatalf("inert file state = %q, want %q", inert.State, ByeDPIDisabled)
|
||||
}
|
||||
if !strings.Contains(inert.Detail, "That is the shipped default") {
|
||||
t.Errorf("an inert file must still read as the deliberate state it is; got %q", inert.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIEgressVerdictDoesNotCallARefusedSectionAnEmptyFile: the per-egress
|
||||
// sentence has the same duty as the service one. "no byedpi instance is enabled"
|
||||
// reads as "you have not set one up"; when a section IS set up and the init
|
||||
// script refuses it, that is a different problem with a different fix.
|
||||
func TestByeDPIEgressVerdictDoesNotCallARefusedSectionAnEmptyFile(t *testing.T) {
|
||||
byedpiFixture(t, "config instance 'mine'\n\toption enabled '1'\n\toption port 'auto'\n")
|
||||
rep := byedpiCrossCheck(byedpiProbe(), []model.Egress{
|
||||
{Name: "bd", Type: model.EgressTypeByeDPI, Port: 1080},
|
||||
})
|
||||
if len(rep.Egresses) != 1 {
|
||||
t.Fatalf("egresses = %+v, want one", rep.Egresses)
|
||||
}
|
||||
got := rep.Egresses[0]
|
||||
if got.State != ByeDPIEgressServiceDisabled {
|
||||
t.Fatalf("egress state = %q (%s), want %q", got.State, got.Detail, ByeDPIEgressServiceDisabled)
|
||||
}
|
||||
if !strings.Contains(got.Detail, "'mine'") {
|
||||
t.Errorf("the egress sentence must name the section that will not start; got %q", got.Detail)
|
||||
}
|
||||
|
||||
// CONTROL: with a genuinely inert file the same verdict keeps its plain
|
||||
// wording, so the branch above is triggered by the refused section and not by
|
||||
// the state alone.
|
||||
byedpiFixture(t, "config instance 'default'\n\toption enabled '0'\n")
|
||||
inert := byedpiCrossCheck(byedpiProbe(), []model.Egress{
|
||||
{Name: "bd", Type: model.EgressTypeByeDPI, Port: 1080},
|
||||
})
|
||||
if inert.Egresses[0].State != ByeDPIEgressServiceDisabled {
|
||||
t.Fatalf("inert egress state = %q, want %q", inert.Egresses[0].State, ByeDPIEgressServiceDisabled)
|
||||
}
|
||||
if !strings.Contains(inert.Egresses[0].Detail, "no byedpi instance is enabled") {
|
||||
t.Errorf("inert file: %q", inert.Egresses[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIInconclusiveSentenceNamesOnlyDialledPorts.
|
||||
//
|
||||
// "Was dialled and the answer was inconclusive" and "was never dialled" share
|
||||
// one wire value — Listening=="unknown" — so a sentence built from that value
|
||||
// alone names both. Here one instance inside byedpiMaxInstances times out and
|
||||
// one instance past the cap is never touched; the sentence about a connection
|
||||
// attempt may name the first and must not name the second.
|
||||
//
|
||||
// The control is the first assertion: the port that WAS dialled is named, so the
|
||||
// test is not passing merely because the sentence mentions no ports at all.
|
||||
func TestByeDPIInconclusiveSentenceNamesOnlyDialledPorts(t *testing.T) {
|
||||
ports := make([]int, byedpiMaxInstances+1)
|
||||
var b strings.Builder
|
||||
for i := range ports {
|
||||
ports[i] = freeLoopbackPort(t)
|
||||
fmt.Fprintf(&b, "config instance 'i%d'\n\toption enabled '1'\n\toption port '%d'\n", i, ports[i])
|
||||
}
|
||||
byedpiFixture(t, b.String())
|
||||
|
||||
// Only the FIRST instance black-holes; the rest are genuinely refused.
|
||||
wedged := ports[0]
|
||||
beyondCap := ports[byedpiMaxInstances]
|
||||
old := byedpiDial
|
||||
byedpiDial = func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
if strings.HasSuffix(addr, ":"+strconv.Itoa(wedged)) {
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Err: timeoutErr{}}
|
||||
}
|
||||
return old(network, addr, timeout)
|
||||
}
|
||||
t.Cleanup(func() { byedpiDial = old })
|
||||
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPIUnknown {
|
||||
t.Fatalf("state = %q (%s), want %q", rep.State, rep.Detail, ByeDPIUnknown)
|
||||
}
|
||||
if !strings.Contains(rep.Detail, strconv.Itoa(wedged)) {
|
||||
t.Fatalf("the sentence about a connection attempt does not name the port that was dialled (%d): %q", wedged, rep.Detail)
|
||||
}
|
||||
if strings.Contains(rep.Detail, strconv.Itoa(beyondCap)) {
|
||||
t.Fatalf("the sentence about a connection that 'neither succeeded nor was refused' names %d, a port past the %d-instance cap that was never dialled: %q",
|
||||
beyondCap, byedpiMaxInstances, rep.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIRefusedSectionBesideAWorkingOne: a file can hold both. The working
|
||||
// instance must still be reported as listening — a broken neighbour is not a
|
||||
// reason to withhold a measurement that was taken — and the broken one must
|
||||
// still be named in the same sentence.
|
||||
func TestByeDPIRefusedSectionBesideAWorkingOne(t *testing.T) {
|
||||
port, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
byedpiFixture(t,
|
||||
"config instance 'good'\n\toption enabled '1'\n\toption port '"+strconv.Itoa(port)+"'\n"+
|
||||
"config instance 'bad'\n\toption enabled '1'\n\toption port 'auto'\n")
|
||||
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPIListening {
|
||||
t.Fatalf("state = %q (%s), want %q — the good instance really is listening",
|
||||
rep.State, rep.Detail, ByeDPIListening)
|
||||
}
|
||||
if len(rep.Instances) != 1 || rep.Instances[0].Section != "good" {
|
||||
t.Fatalf("instances = %+v, want only 'good'", rep.Instances)
|
||||
}
|
||||
if !strings.Contains(rep.Detail, "'bad'") {
|
||||
t.Errorf("the section the router will not start must still be named; got %q", rep.Detail)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// What GET /api/status is allowed to cost, and what GET /api/byedpi is obliged
|
||||
// to do.
|
||||
//
|
||||
// # The defect these tests are the regression for
|
||||
//
|
||||
// The readiness report used to ride on GET /api/status, served from a cache that
|
||||
// a poll refreshed in the background once the stored copy passed
|
||||
// byedpiRefreshAfter = 3 s. The panel shell polls that endpoint every 5 s, and
|
||||
// the Apply page adds a 4 s poll of its own. Five is more than three, so EVERY
|
||||
// poll started a refresh — a PATH lookup for ciadpi, a read of
|
||||
// /etc/config/byedpi, and one connect per enabled instance — for as long as any
|
||||
// browser tab was open, a hidden one included. Measured against the code as it
|
||||
// then stood, one enabled instance, twelve polls five seconds apart (one minute
|
||||
// of one tab):
|
||||
//
|
||||
// 12 connects to 127.0.0.1 and 13 ciadpi PATH lookups.
|
||||
//
|
||||
// With byedpiMaxInstances enabled instances on ports that neither accept nor
|
||||
// refuse, the same minute is 192 connects and 12 × 6.4 s of a goroutine sitting
|
||||
// in dial — on a BPI-R3.
|
||||
//
|
||||
// And it bought nothing: `grep -rn '\.byedpi\b' panel/src` found no reader.
|
||||
// The readiness plate, the per-egress cross-check and the egress-type gate all
|
||||
// read GET /api/byedpi, which a human triggers. So the field left the status
|
||||
// response and the cache went with it.
|
||||
//
|
||||
// # Why each test here carries its own control
|
||||
//
|
||||
// "The status poll opens no socket" is a claim an instrument that had quietly
|
||||
// stopped counting would satisfy just as well. So every count below is taken
|
||||
// with the SAME meter that is shown, in the same test, registering the connects
|
||||
// a real probe makes.
|
||||
|
||||
// bdDialMeter counts every connect byedpiPortState makes. It wraps whatever
|
||||
// byedpiDial currently is, so it can be layered on top of a fixture's dialler;
|
||||
// register it AFTER the dialler it should count.
|
||||
func bdDialMeter(t *testing.T) *atomic.Int64 {
|
||||
t.Helper()
|
||||
var n atomic.Int64
|
||||
old := byedpiDial
|
||||
byedpiDial = func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
n.Add(1)
|
||||
return old(network, addr, timeout)
|
||||
}
|
||||
t.Cleanup(func() { byedpiDial = old })
|
||||
return &n
|
||||
}
|
||||
|
||||
// bdLookupMeter counts the ciadpi PATH lookups — the one piece of byedpi work
|
||||
// GET /api/status is still allowed to do, and therefore a number worth pinning
|
||||
// rather than waving at. Register it AFTER byedpiFixture, which sets the seam.
|
||||
func bdLookupMeter(t *testing.T) *atomic.Int64 {
|
||||
t.Helper()
|
||||
var n atomic.Int64
|
||||
old := byedpiInstalled
|
||||
byedpiInstalled = func() bool { n.Add(1); return old() }
|
||||
t.Cleanup(func() { byedpiInstalled = old })
|
||||
return &n
|
||||
}
|
||||
|
||||
// bdBlackHoledInstances writes n enabled instances and makes every dial to them
|
||||
// burn its full byedpiDialTimeout — the "unknown" state, the only one the probe
|
||||
// is slow in and the one the whole report exists to name honestly.
|
||||
func bdBlackHoledInstances(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
fmt.Fprintf(&b, "config instance 'i%d'\n\toption enabled '1'\n\toption port '%d'\n", i, 20000+i)
|
||||
}
|
||||
byedpiFixture(t, b.String())
|
||||
old := byedpiDial
|
||||
byedpiDial = func(network, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
time.Sleep(timeout)
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Err: timeoutErr{}}
|
||||
}
|
||||
t.Cleanup(func() { byedpiDial = old })
|
||||
}
|
||||
|
||||
// --- 1. the status poll pays nothing, and the meter can prove it counts -------
|
||||
|
||||
// TestByeDPIStatusPollOpensNoSocket is the regression, with its control.
|
||||
//
|
||||
// CONTROL: the same meter, the same fixture — one real byedpiProbe registers
|
||||
// exactly one connect. Without it, "twelve polls made zero connects" would pass
|
||||
// against a meter that had been unhooked.
|
||||
//
|
||||
// CLAIM: twelve GET /api/status — a minute of the panel's own 5 s poll — make
|
||||
// ZERO connects, and exactly twelve PATH lookups: one per poll, for the
|
||||
// byedpi_installed field, and no second one from a probe.
|
||||
func TestByeDPIStatusPollOpensNoSocket(t *testing.T) {
|
||||
port := freeLoopbackPort(t) // nothing on it: a connect here is refused, not hung
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
dials := bdDialMeter(t)
|
||||
lookups := bdLookupMeter(t)
|
||||
|
||||
// CONTROL — the instruments are live.
|
||||
if rep := byedpiProbe(); rep.State != ByeDPINotListening {
|
||||
t.Fatalf("control probe state = %q (%s), want %q — the fixture is not producing a real check",
|
||||
rep.State, rep.Detail, ByeDPINotListening)
|
||||
}
|
||||
if got := dials.Load(); got != 1 {
|
||||
t.Fatalf("CONTROL: one byedpiProbe registered %d connects, want 1 — the dial meter is not counting", got)
|
||||
}
|
||||
if got := lookups.Load(); got != 1 {
|
||||
t.Fatalf("CONTROL: one byedpiProbe registered %d PATH lookups, want 1 — the lookup meter is not counting", got)
|
||||
}
|
||||
dials.Store(0)
|
||||
lookups.Store(0)
|
||||
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
const polls = 12 // one minute of the panel shell's 5 s poll
|
||||
for i := 0; i < polls; i++ {
|
||||
resp := do(t, srv, http.MethodGet, "/api/status", cookie, "")
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
// A background refresh, if one had been started, would land within this.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
if got := dials.Load(); got != 0 {
|
||||
t.Fatalf("%d × GET /api/status opened %d socket(s) to 127.0.0.1; want 0 — the listener probe is back on the poll path",
|
||||
polls, got)
|
||||
}
|
||||
if got := lookups.Load(); got != int64(polls) {
|
||||
t.Fatalf("%d × GET /api/status made %d ciadpi PATH lookups, want exactly %d (one per poll, for byedpi_installed)",
|
||||
polls, got, polls)
|
||||
}
|
||||
t.Logf("MEASURED: %d × GET /api/status = %d connects, %d PATH lookups (was 12 and 13)", polls, dials.Load(), lookups.Load())
|
||||
}
|
||||
|
||||
// TestByeDPIStatusCarriesNoReadinessReport pins the SHAPE, not just the cost. A
|
||||
// future edit that puts the report back but memoises it would pass the socket
|
||||
// count above and reintroduce the field nobody reads; this fails on the field.
|
||||
//
|
||||
// The control is byedpi_installed in the same body: the response has not simply
|
||||
// lost its byedpi half, and the cheap question still gets its honest answer in
|
||||
// BOTH directions.
|
||||
func TestByeDPIStatusCarriesNoReadinessReport(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(freeLoopbackPort(t))) // also forces the binary present
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
body := func(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
resp := do(t, srv, http.MethodGet, "/api/status", cookie, "")
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
t.Fatalf("decode /api/status: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
m := body(t)
|
||||
if v, ok := m["byedpi"]; ok {
|
||||
t.Fatalf("GET /api/status carries a byedpi readiness report (%v). Nothing in panel/src reads it, and serving it is what put a listener probe on a 5 s poll — GET /api/byedpi is the readiness endpoint",
|
||||
v)
|
||||
}
|
||||
// CONTROL: the cheap field is there and true with the binary present…
|
||||
if v, ok := m["byedpi_installed"].(bool); !ok || !v {
|
||||
t.Fatalf("byedpi_installed = %v (present=%t) with the ciadpi binary forced present; want true", m["byedpi_installed"], ok)
|
||||
}
|
||||
// …and false without it, so the field is answering rather than hard-coded.
|
||||
forceByeDPIBinary(t, false)
|
||||
if v, ok := body(t)["byedpi_installed"].(bool); !ok || v {
|
||||
t.Fatalf("byedpi_installed = %v with the ciadpi binary forced absent; want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. the probe is genuinely expensive, which is why it is not on the poll ---
|
||||
|
||||
// TestByeDPIProbeCostControl is the other half of the argument: the check taken
|
||||
// off the status poll really does cost seconds in the state it was written to
|
||||
// report. A cheap check would not have needed removing, and a test that only
|
||||
// showed the poll to be fast would be evidence for nothing.
|
||||
func TestByeDPIProbeCostControl(t *testing.T) {
|
||||
bdBlackHoledInstances(t, byedpiMaxInstances)
|
||||
|
||||
start := time.Now()
|
||||
rep := byedpiProbe()
|
||||
cost := time.Since(start)
|
||||
|
||||
if rep.State != ByeDPIUnknown {
|
||||
t.Fatalf("black-holed ports gave state %q (%s), want %q — the fixture is not producing the slow branch",
|
||||
rep.State, rep.Detail, ByeDPIUnknown)
|
||||
}
|
||||
want := time.Duration(byedpiMaxInstances) * byedpiDialTimeout
|
||||
if cost < want/2 {
|
||||
t.Fatalf("byedpiProbe took %v on %d black-holed ports; expected about %v — the control is not measuring the timeout branch",
|
||||
cost, byedpiMaxInstances, want)
|
||||
}
|
||||
t.Logf("CONTROL: byedpiProbe on %d black-holed ports = %v (this is what a 5 s poll used to start every time)",
|
||||
byedpiMaxInstances, cost)
|
||||
}
|
||||
|
||||
// --- 3. the endpoint that DOES report a listener connects, every time ---------
|
||||
|
||||
// TestByeDPIEndpointConnectsOnEveryRequest. The world changes between two calls —
|
||||
// the listener is closed — and the second call must notice. There is no cache
|
||||
// left to answer from, and this is the test that keeps it that way: a re-check
|
||||
// button that answers from a copy is worse than no button.
|
||||
//
|
||||
// The control is the first call: it says `listening` against a real socket, so
|
||||
// the `not_listening` below is a change the endpoint observed and not the value
|
||||
// it always returns.
|
||||
func TestByeDPIEndpointConnectsOnEveryRequest(t *testing.T) {
|
||||
port, closeListener := listenLoopback(t)
|
||||
defer closeListener()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
dials := bdDialMeter(t)
|
||||
|
||||
oldRead := byedpiReportRead
|
||||
byedpiReportRead = func() (*model.Model, error) { return &model.Model{}, nil }
|
||||
defer func() { byedpiReportRead = oldRead }()
|
||||
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
get := func(t *testing.T) byedpiReport {
|
||||
t.Helper()
|
||||
resp := do(t, srv, http.MethodGet, "/api/byedpi", cookie, "")
|
||||
defer resp.Body.Close()
|
||||
var got byedpiReport
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode /api/byedpi: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// CONTROL: a live socket reads as listening, and cost exactly one connect.
|
||||
first := get(t)
|
||||
if first.State != ByeDPIListening {
|
||||
t.Fatalf("with a real listener on %d, GET /api/byedpi = %q (%s), want %q", port, first.State, first.Detail, ByeDPIListening)
|
||||
}
|
||||
if got := dials.Load(); got != 1 {
|
||||
t.Fatalf("the first GET /api/byedpi made %d connects, want 1", got)
|
||||
}
|
||||
if first.AgeSeconds != 0 {
|
||||
t.Fatalf("an on-the-spot probe reported age_seconds = %d, want 0", first.AgeSeconds)
|
||||
}
|
||||
|
||||
closeListener()
|
||||
|
||||
second := get(t)
|
||||
if second.State != ByeDPINotListening {
|
||||
t.Fatalf("after the listener was closed GET /api/byedpi still said %q (%s); want %q — it did not connect",
|
||||
second.State, second.Detail, ByeDPINotListening)
|
||||
}
|
||||
if got := dials.Load(); got != 2 {
|
||||
t.Fatalf("two GET /api/byedpi made %d connects in total, want 2 — one of them was answered without dialling", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIProbeAgeIsTheProbesOwnDuration: age_seconds is the age of the
|
||||
// measurement, not a constant. A probe that spent six seconds in dial timeouts
|
||||
// must say six — the Detail sentences are present tense, and the panel unlocks
|
||||
// the egress type on this report.
|
||||
//
|
||||
// The control is the second half: with a clock that does not move, the same code
|
||||
// reports 0. Without it, a stamp hard-wired to "6" would pass the first half.
|
||||
func TestByeDPIProbeAgeIsTheProbesOwnDuration(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(freeLoopbackPort(t)))
|
||||
|
||||
base := time.Now()
|
||||
var ticks atomic.Int64
|
||||
old := byedpiNow
|
||||
// Every read of the clock is six seconds after the previous one, so the probe
|
||||
// looks like one that sat in timeouts.
|
||||
byedpiNow = func() time.Time { return base.Add(time.Duration(ticks.Add(1)-1) * 6 * time.Second) }
|
||||
t.Cleanup(func() { byedpiNow = old })
|
||||
|
||||
if got := byedpiProbeNow().AgeSeconds; got != 6 {
|
||||
t.Fatalf("a probe whose clock advanced 6 s reported age_seconds = %d, want 6", got)
|
||||
}
|
||||
|
||||
// CONTROL: a still clock gives 0, so the stamp is a subtraction and not a
|
||||
// constant.
|
||||
byedpiNow = func() time.Time { return base }
|
||||
if got := byedpiProbeNow().AgeSeconds; got != 0 {
|
||||
t.Fatalf("a probe on a still clock reported age_seconds = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. the probe's own honesty, unchanged by any of the above -----------------
|
||||
|
||||
// TestByeDPIPortStateHasThreeAnswersThroughTheRealDial exercises the three port
|
||||
// verdicts through byedpiPortState — the code that actually dials — rather than
|
||||
// only through the classifier. The timeout case is the one that matters and the
|
||||
// one a real loopback cannot be made to produce, so it is driven through the
|
||||
// dial seam; the other two are genuine sockets.
|
||||
//
|
||||
// The guard on the property none of the above may erode: "could not check" stays
|
||||
// separate from "nothing is there".
|
||||
func TestByeDPIPortStateHasThreeAnswersThroughTheRealDial(t *testing.T) {
|
||||
live, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
empty := freeLoopbackPort(t)
|
||||
|
||||
if got := byedpiPortState(live); got != ByeDPIPortListening {
|
||||
t.Errorf("live port: %q, want %q", got, ByeDPIPortListening)
|
||||
}
|
||||
if got := byedpiPortState(empty); got != ByeDPIPortRefused {
|
||||
t.Errorf("empty port: %q, want %q", got, ByeDPIPortRefused)
|
||||
}
|
||||
|
||||
old := byedpiDial
|
||||
byedpiDial = func(network, addr string, _ time.Duration) (net.Conn, error) {
|
||||
return nil, &net.OpError{Op: "dial", Net: network, Err: timeoutErr{}}
|
||||
}
|
||||
t.Cleanup(func() { byedpiDial = old })
|
||||
if got := byedpiPortState(live); got != ByeDPIPortUndetermined {
|
||||
t.Errorf("timed-out dial to a port that IS live: %q, want %q — a timeout must never be folded into a verdict about the port",
|
||||
got, ByeDPIPortUndetermined)
|
||||
}
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/shater/model"
|
||||
)
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------
|
||||
|
||||
// byedpiFixture writes a /etc/config/byedpi at a temp path, points the reader at
|
||||
// it for the duration of the test, and forces the binary probe to `present`.
|
||||
// Returns nothing: the point is the side effect on the package seams.
|
||||
func byedpiFixture(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "byedpi")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write byedpi fixture: %v", err)
|
||||
}
|
||||
old := byedpiConfigPath
|
||||
byedpiConfigPath = path
|
||||
t.Cleanup(func() { byedpiConfigPath = old })
|
||||
forceByeDPIBinary(t, true)
|
||||
}
|
||||
|
||||
// forceByeDPIBinary overrides the ciadpi-on-disk probe for one test.
|
||||
func forceByeDPIBinary(t *testing.T, present bool) {
|
||||
t.Helper()
|
||||
old := byedpiInstalled
|
||||
byedpiInstalled = func() bool { return present }
|
||||
t.Cleanup(func() { byedpiInstalled = old })
|
||||
}
|
||||
|
||||
// listenLoopback opens a real TCP listener on 127.0.0.1 and returns its port
|
||||
// plus a closer. Real, not faked: the detector's whole claim is that it observes
|
||||
// a live socket, and a stubbed dial would test the stub.
|
||||
func listenLoopback(t *testing.T) (int, func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
closed := false
|
||||
return port, func() {
|
||||
if !closed {
|
||||
closed = true
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// freeLoopbackPort returns a port with nothing on it: bound, then released.
|
||||
func freeLoopbackPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
port, closer := listenLoopback(t)
|
||||
closer()
|
||||
return port
|
||||
}
|
||||
|
||||
func enabledInstance(port int) string {
|
||||
return fmt.Sprintf("config instance 'default'\n\toption enabled '1'\n\toption port '%d'\n", port)
|
||||
}
|
||||
|
||||
// --- the control: one instrument, both answers -------------------------------
|
||||
|
||||
// TestByeDPIProbeSeesAListenerAndItsAbsence is the CONTROL for A2.
|
||||
//
|
||||
// A detector that reports "not listening" is worth nothing until the same
|
||||
// detector, on the same config, is shown reporting "listening" when a socket is
|
||||
// genuinely there. So this opens a real loopback listener, probes, closes it,
|
||||
// and probes again — nothing else changes between the two calls.
|
||||
func TestByeDPIProbeSeesAListenerAndItsAbsence(t *testing.T) {
|
||||
port, closeListener := listenLoopback(t)
|
||||
defer closeListener()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
|
||||
up := byedpiProbe()
|
||||
if up.State != ByeDPIListening {
|
||||
t.Fatalf("with a live listener on 127.0.0.1:%d state = %q (%s), want %q",
|
||||
port, up.State, up.Detail, ByeDPIListening)
|
||||
}
|
||||
if len(up.Instances) != 1 || up.Instances[0].Port != port || up.Instances[0].Listening != ByeDPIPortListening {
|
||||
t.Fatalf("instances = %+v, want the one enabled instance on %d reported listening", up.Instances, port)
|
||||
}
|
||||
|
||||
closeListener()
|
||||
|
||||
down := byedpiProbe()
|
||||
if down.State != ByeDPINotListening {
|
||||
t.Fatalf("with the listener closed state = %q (%s), want %q",
|
||||
down.State, down.Detail, ByeDPINotListening)
|
||||
}
|
||||
if down.Instances[0].Listening != ByeDPIPortRefused {
|
||||
t.Fatalf("port state = %q, want %q", down.Instances[0].Listening, ByeDPIPortRefused)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIBinaryPresenceIsNotReadiness pins the defect A2 exists for: the old
|
||||
// check (the binary is on disk) is TRUE in exactly the situation the shipped
|
||||
// package is in, and the new state must not be "ready" there.
|
||||
func TestByeDPIBinaryPresenceIsNotReadiness(t *testing.T) {
|
||||
byedpiFixture(t, "config instance 'default'\n\toption enabled '0'\n\toption port '1080'\n")
|
||||
|
||||
rep := byedpiProbe()
|
||||
if !rep.Binary {
|
||||
t.Fatal("fixture forces the binary present; Binary must report it")
|
||||
}
|
||||
if rep.State != ByeDPIDisabled {
|
||||
t.Fatalf("state = %q (%s), want %q: the binary is installed and no instance is enabled",
|
||||
rep.State, rep.Detail, ByeDPIDisabled)
|
||||
}
|
||||
if len(rep.Instances) != 0 {
|
||||
t.Fatalf("instances = %+v, want none — a disabled instance binds nothing", rep.Instances)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIStatesAreDistinguishable walks the closed service-state list and
|
||||
// requires each input to produce ITS state — no two inputs collapsing onto one
|
||||
// answer, which is the failure mode of a check that always says the same thing.
|
||||
func TestByeDPIStatesAreDistinguishable(t *testing.T) {
|
||||
t.Run("not_installed", func(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(1080))
|
||||
forceByeDPIBinary(t, false)
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPINotInstalled {
|
||||
t.Fatalf("state = %q (%s), want %q", rep.State, rep.Detail, ByeDPINotInstalled)
|
||||
}
|
||||
if rep.ConfigRead {
|
||||
t.Error("config_read = true with no binary; nothing should have been read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown when the config cannot be read", func(t *testing.T) {
|
||||
forceByeDPIBinary(t, true)
|
||||
old := byedpiConfigPath
|
||||
byedpiConfigPath = filepath.Join(t.TempDir(), "definitely-absent")
|
||||
t.Cleanup(func() { byedpiConfigPath = old })
|
||||
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPIUnknown {
|
||||
t.Fatalf("state = %q (%s), want %q — a missing conffile beside an installed binary is a broken install, not a tidy 'disabled'",
|
||||
rep.State, rep.Detail, ByeDPIUnknown)
|
||||
}
|
||||
if rep.ConfigRead {
|
||||
t.Error("config_read = true after a failed read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
byedpiFixture(t, "config instance 'default'\n\toption enabled '0'\n")
|
||||
if got := byedpiProbe().State; got != ByeDPIDisabled {
|
||||
t.Fatalf("state = %q, want %q", got, ByeDPIDisabled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not_listening", func(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(freeLoopbackPort(t)))
|
||||
if got := byedpiProbe().State; got != ByeDPINotListening {
|
||||
t.Fatalf("state = %q, want %q", got, ByeDPINotListening)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("listening", func(t *testing.T) {
|
||||
port, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
if got := byedpiProbe().State; got != ByeDPIListening {
|
||||
t.Fatalf("state = %q, want %q", got, ByeDPIListening)
|
||||
}
|
||||
})
|
||||
|
||||
// An instance that was NOT DIALLED is a connect that never happened. That is an
|
||||
// unknown and must not be folded into "nothing is listening" — the whole point
|
||||
// of keeping three port answers. The reachable way to produce one is
|
||||
// byedpiMaxInstances: everything past the cap is left unchecked.
|
||||
//
|
||||
// (An out-of-range port used to be the other way in. It no longer is:
|
||||
// /etc/init.d/byedpi's validation rejects `port '70000'`, so such a section
|
||||
// starts nothing and is now reported as a refused section rather than as an
|
||||
// enabled instance on a port nobody could dial — see byedpi_initparity_test.go.)
|
||||
t.Run("unknown when an instance was never dialled", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; i <= byedpiMaxInstances; i++ {
|
||||
fmt.Fprintf(&b, "config instance 'i%d'\n\toption enabled '1'\n\toption port '%d'\n",
|
||||
i, freeLoopbackPort(t))
|
||||
}
|
||||
byedpiFixture(t, b.String())
|
||||
rep := byedpiProbe()
|
||||
if rep.State != ByeDPIUnknown {
|
||||
t.Fatalf("state = %q (%s), want %q — %d instances were enabled and only %d could be checked",
|
||||
rep.State, rep.Detail, ByeDPIUnknown, byedpiMaxInstances+1, byedpiMaxInstances)
|
||||
}
|
||||
last := rep.Instances[byedpiMaxInstances]
|
||||
if last.Listening != ByeDPIPortUndetermined {
|
||||
t.Fatalf("the instance past the cap reported %q, want %q — it was never dialled",
|
||||
last.Listening, ByeDPIPortUndetermined)
|
||||
}
|
||||
// The sentence may not assert a connection that never happened. Every port
|
||||
// that WAS checked here was refused, so a Detail about an attempt that
|
||||
// "neither succeeded nor was refused" would be describing nothing that ran.
|
||||
if strings.Contains(rep.Detail, "neither succeeded nor was refused") {
|
||||
t.Fatalf("detail claims a connection attempt for instances nothing dialled: %q", rep.Detail)
|
||||
}
|
||||
if !strings.Contains(rep.Detail, "no connection was attempted") {
|
||||
t.Fatalf("detail must say the instances past the cap were not dialled; got %q", rep.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestByeDPIPortStateKeepsUnknownSeparateFromNo pins the three-valued port
|
||||
// answer directly, with its control: the same function that returns "unknown"
|
||||
// for a port it could not dial returns "no" for one it dialled and found empty,
|
||||
// and "yes" for one with a listener.
|
||||
func TestByeDPIPortStateKeepsUnknownSeparateFromNo(t *testing.T) {
|
||||
live, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
empty := freeLoopbackPort(t)
|
||||
|
||||
cases := []struct {
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{live, ByeDPIPortListening},
|
||||
{empty, ByeDPIPortRefused},
|
||||
{0, ByeDPIPortUndetermined},
|
||||
{70000, ByeDPIPortUndetermined},
|
||||
{-1, ByeDPIPortUndetermined},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := byedpiPortState(c.port); got != c.want {
|
||||
t.Errorf("byedpiPortState(%d) = %q, want %q", c.port, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutErr is a net.Error that reports Timeout() — the connect that ran out of
|
||||
// time rather than being answered.
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (timeoutErr) Error() string { return "i/o timeout (test)" }
|
||||
func (timeoutErr) Timeout() bool { return true }
|
||||
func (timeoutErr) Temporary() bool { return false }
|
||||
|
||||
// TestByeDPIDialVerdictOnlyRefusalMeansNo pins the classification against the
|
||||
// errors a loopback dial cannot be provoked into producing on demand — and those
|
||||
// are precisely the ones a careless check folds into "nothing is listening".
|
||||
//
|
||||
// ECONNREFUSED is the ONE error that proves the port is empty. A permission
|
||||
// denial or an exhausted fd table is the INSTRUMENT failing, and reporting it as
|
||||
// "the proxy is down" would be the check lying about the thing it was built to
|
||||
// observe.
|
||||
func TestByeDPIDialVerdictOnlyRefusalMeansNo(t *testing.T) {
|
||||
wrap := func(e error) error {
|
||||
return &net.OpError{Op: "dial", Net: "tcp", Err: os.NewSyscallError("connect", e)}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"accepted", nil, ByeDPIPortListening},
|
||||
{"refused (posix)", wrap(syscall.ECONNREFUSED), ByeDPIPortRefused},
|
||||
{"refused (winsock)", wrap(wsaeConnRefused), ByeDPIPortRefused},
|
||||
{"permission denied", wrap(syscall.EACCES), ByeDPIPortUndetermined},
|
||||
{"timed out", &net.OpError{Op: "dial", Net: "tcp", Err: timeoutErr{}}, ByeDPIPortUndetermined},
|
||||
{"something else entirely", fmt.Errorf("the resolver melted"), ByeDPIPortUndetermined},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := byedpiDialVerdict(c.err); got != c.want {
|
||||
t.Errorf("%s: byedpiDialVerdict = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the UCI reader ----------------------------------------------------------
|
||||
|
||||
// TestReadByeDPIInstancesMirrorsTheInitScript pins the parser against the rules
|
||||
// /etc/init.d/byedpi actually applies: enabled defaults to 0, port defaults to
|
||||
// 1080, UCI's boolean vocabulary is honoured, comments and other section types
|
||||
// are ignored, and an anonymous section still gets a name to show.
|
||||
func TestReadByeDPIInstancesMirrorsTheInitScript(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"# a comment mentioning option enabled '1' — must not be parsed",
|
||||
"",
|
||||
"config instance 'default'",
|
||||
"\toption enabled '0'",
|
||||
"\toption port '1080'",
|
||||
"",
|
||||
"config instance 'on_default_port'",
|
||||
"\toption enabled 'yes'", // UCI-true, and no port => the init default
|
||||
"",
|
||||
"config instance",
|
||||
"\toption enabled 'on'",
|
||||
"\toption port '1099'",
|
||||
"",
|
||||
"config instance 'typo'",
|
||||
"\toption enabled 'ja'", // not UCI-true => stays off
|
||||
"\toption port '1100'",
|
||||
"",
|
||||
"config somethingelse 'x'",
|
||||
"\toption enabled '1'",
|
||||
"\toption port '9999'",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
got, problems, err := readByeDPIInstancesFrom(t, body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
// 'ja' is not one of UCI's boolean words, so /etc/init.d/byedpi refuses that
|
||||
// whole section — which is a fact worth reporting, not one to swallow.
|
||||
if len(problems) != 1 || problems[0].Section != "typo" || problems[0].Option != "enabled" {
|
||||
t.Fatalf("problems = %+v, want exactly the 'typo' section's enabled option", problems)
|
||||
}
|
||||
want := []byedpiInstance{
|
||||
{Section: "on_default_port", Port: byedpiDefaultPort},
|
||||
{Section: "@instance[0]", Port: 1099},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("instances = %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].Section != want[i].Section || got[i].Port != want[i].Port {
|
||||
t.Errorf("instance %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readByeDPIInstancesFrom writes body to a temp file and parses it.
|
||||
func readByeDPIInstancesFrom(t *testing.T, body string) ([]byedpiInstance, []byedpiSectionProblem, error) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "byedpi")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return readByeDPIInstances(path)
|
||||
}
|
||||
|
||||
// TestShippedByeDPIConfigIsInert reads the REAL packaged file, not a fixture.
|
||||
// The package promises installing it opens no listener; that promise lives in one
|
||||
// `option enabled '0'` and nothing has been checking it.
|
||||
func TestShippedByeDPIConfigIsInert(t *testing.T) {
|
||||
const shipped = "../../openwrt/byedpi/files/etc/config/byedpi"
|
||||
if _, err := os.Stat(shipped); err != nil {
|
||||
t.Fatalf("the packaged byedpi config is missing at %s: %v", shipped, err)
|
||||
}
|
||||
got, problems, err := readByeDPIInstances(shipped)
|
||||
if err != nil {
|
||||
t.Fatalf("the packaged byedpi config does not parse: %v", err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("the SHIPPED /etc/config/byedpi has sections /etc/init.d/byedpi would refuse: %+v", problems)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("the SHIPPED /etc/config/byedpi enables %+v — installing the package would open a listener, which it promises never to do", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShippedByeDPIPortMatchesTheEgressDefault pins the coordination that used
|
||||
// to exist only as a comment: the port the packaged instance is configured for
|
||||
// and the port a byedpi egress dials when its own port is unset must be the same
|
||||
// number. If either side is edited alone, this fails instead of the router going
|
||||
// quiet.
|
||||
func TestShippedByeDPIPortMatchesTheEgressDefault(t *testing.T) {
|
||||
const shipped = "../../openwrt/byedpi/files/etc/config/byedpi"
|
||||
body, err := os.ReadFile(shipped)
|
||||
if err != nil {
|
||||
t.Fatalf("read packaged config: %v", err)
|
||||
}
|
||||
// Flip the shipped instance on so the reader yields it, changing nothing else.
|
||||
enabled := strings.Replace(string(body), "option enabled '0'", "option enabled '1'", 1)
|
||||
got, problems, err := readByeDPIInstancesFrom(t, enabled)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("flipping the packaged instance on produced %+v", problems)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly one instance in the packaged config, got %+v", got)
|
||||
}
|
||||
if got[0].Port != byedpiDefaultPort {
|
||||
t.Fatalf("the packaged instance is on port %d but a byedpi egress defaults to %d — the two packages have drifted apart",
|
||||
got[0].Port, byedpiDefaultPort)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the port cross-check ----------------------------------------------------
|
||||
|
||||
// TestByeDPICrossCheckNamesThePortMismatch is the second half of A2: when the
|
||||
// service runs on one port and the egress dials another, that must be SAID —
|
||||
// with both numbers in the sentence — instead of the egress silently going
|
||||
// nowhere.
|
||||
//
|
||||
// The control is the second egress: the same report, the same instant, an egress
|
||||
// on the port that IS served comes back ok. A checker that flagged everything
|
||||
// would prove nothing.
|
||||
func TestByeDPICrossCheckNamesThePortMismatch(t *testing.T) {
|
||||
port, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
|
||||
other := freeLoopbackPort(t)
|
||||
rep := byedpiCrossCheck(byedpiProbe(), []model.Egress{
|
||||
{Name: "bd-wrong", Type: model.EgressTypeByeDPI, Port: other},
|
||||
{Name: "bd-right", Type: model.EgressTypeByeDPI, Port: port},
|
||||
{Name: "wan", Type: model.EgressTypeInterface, Interface: "wan"},
|
||||
})
|
||||
if len(rep.Egresses) != 2 {
|
||||
t.Fatalf("egresses = %+v, want only the two byedpi ones", rep.Egresses)
|
||||
}
|
||||
wrong, right := rep.Egresses[0], rep.Egresses[1]
|
||||
|
||||
if wrong.State != ByeDPIEgressPortMismatch {
|
||||
t.Fatalf("bd-wrong state = %q (%s), want %q", wrong.State, wrong.Detail, ByeDPIEgressPortMismatch)
|
||||
}
|
||||
if !strings.Contains(wrong.Detail, fmt.Sprint(other)) || !strings.Contains(wrong.Detail, fmt.Sprint(port)) {
|
||||
t.Errorf("the mismatch sentence must name BOTH ports (%d dialled, %d served); got %q", other, port, wrong.Detail)
|
||||
}
|
||||
if right.State != ByeDPIEgressOK {
|
||||
t.Fatalf("bd-right state = %q (%s), want %q", right.State, right.Detail, ByeDPIEgressOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPICrossCheckAppliesTheGeneratorPortDefault: an egress with Port unset
|
||||
// dials 1080 (generate/outbound.go substitutes it), so the cross-check must
|
||||
// compare against 1080 and not against 0.
|
||||
func TestByeDPICrossCheckAppliesTheGeneratorPortDefault(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(byedpiDefaultPort))
|
||||
rep := byedpiCrossCheck(byedpiProbe(), []model.Egress{{Name: "bd", Type: model.EgressTypeByeDPI}})
|
||||
if len(rep.Egresses) != 1 {
|
||||
t.Fatalf("egresses = %+v", rep.Egresses)
|
||||
}
|
||||
if rep.Egresses[0].Port != byedpiDefaultPort {
|
||||
t.Fatalf("port = %d, want the substituted default %d", rep.Egresses[0].Port, byedpiDefaultPort)
|
||||
}
|
||||
if rep.Egresses[0].State == ByeDPIEgressPortMismatch {
|
||||
t.Fatalf("an unset egress port was compared as 0 instead of %d: %s", byedpiDefaultPort, rep.Egresses[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIUnknownNeverReadsAsWorking: when the service state could not be
|
||||
// determined, no egress may come back ok. "Could not check" is not a pass.
|
||||
func TestByeDPIUnknownNeverReadsAsWorking(t *testing.T) {
|
||||
for _, rep := range []byedpiReport{
|
||||
{State: ByeDPIUnknown, Instances: []byedpiInstance{}},
|
||||
{State: ByeDPINotInstalled, Instances: []byedpiInstance{}},
|
||||
{State: ByeDPIDisabled, Instances: []byedpiInstance{}},
|
||||
{State: "a state nobody defined", Instances: []byedpiInstance{}},
|
||||
} {
|
||||
out := byedpiCrossCheck(rep, []model.Egress{{Name: "bd", Type: model.EgressTypeByeDPI, Port: 1080}})
|
||||
if len(out.Egresses) != 1 {
|
||||
t.Fatalf("state %q: egresses = %+v", rep.State, out.Egresses)
|
||||
}
|
||||
if out.Egresses[0].State == ByeDPIEgressOK {
|
||||
t.Fatalf("service state %q produced an OK egress: %s", rep.State, out.Egresses[0].Detail)
|
||||
}
|
||||
if out.Egresses[0].Detail == "" {
|
||||
t.Fatalf("service state %q produced an egress with no explanation", rep.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIAliasedEgressTypeIsCrossChecked: the model folds `tunnel`→`interface`
|
||||
// at the config boundary, but an egress arriving here with a non-canonical
|
||||
// spelling of `byedpi` must still be cross-checked rather than skipped as "some
|
||||
// other type".
|
||||
func TestByeDPIAliasedEgressTypeIsCrossChecked(t *testing.T) {
|
||||
byedpiFixture(t, enabledInstance(byedpiDefaultPort))
|
||||
rep := byedpiCrossCheck(byedpiProbe(), []model.Egress{{Name: "bd", Type: " ByeDPI "}})
|
||||
if len(rep.Egresses) != 1 {
|
||||
t.Fatalf("a byedpi egress spelled %q was skipped: %+v", " ByeDPI ", rep.Egresses)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the endpoints -----------------------------------------------------------
|
||||
|
||||
// TestByeDPIEndpointContract drives GET /api/byedpi end to end and pins the
|
||||
// documented body.
|
||||
func TestByeDPIEndpointContract(t *testing.T) {
|
||||
port, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
|
||||
oldRead := byedpiReportRead
|
||||
byedpiReportRead = func() (*model.Model, error) {
|
||||
return &model.Model{Egresses: []model.Egress{
|
||||
{Name: "bd", Type: model.EgressTypeByeDPI, Port: port},
|
||||
}}, nil
|
||||
}
|
||||
defer func() { byedpiReportRead = oldRead }()
|
||||
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
resp := do(t, srv, http.MethodGet, "/api/byedpi", cookie, "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /api/byedpi: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var got byedpiReport
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.State != ByeDPIListening {
|
||||
t.Fatalf("state = %q (%s), want %q", got.State, got.Detail, ByeDPIListening)
|
||||
}
|
||||
if len(got.Egresses) != 1 || got.Egresses[0].State != ByeDPIEgressOK {
|
||||
t.Fatalf("egresses = %+v, want the one byedpi egress reported ok", got.Egresses)
|
||||
}
|
||||
|
||||
postResp := do(t, srv, http.MethodPost, "/api/byedpi", cookie, "{}")
|
||||
postResp.Body.Close()
|
||||
if postResp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("POST /api/byedpi: got %d, want 405", postResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestByeDPIEndpointUnreadableModelStillReportsTheService: a model that cannot be
|
||||
// read must not take the service facts down with it — those were measured and
|
||||
// are real. The answer still says the cross-check did not happen.
|
||||
func TestByeDPIEndpointUnreadableModelStillReportsTheService(t *testing.T) {
|
||||
port, closer := listenLoopback(t)
|
||||
defer closer()
|
||||
byedpiFixture(t, enabledInstance(port))
|
||||
|
||||
oldRead := byedpiReportRead
|
||||
byedpiReportRead = func() (*model.Model, error) { return nil, fmt.Errorf("uci is not on this box") }
|
||||
defer func() { byedpiReportRead = oldRead }()
|
||||
|
||||
s := newTestServer(t)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
cookie := login(t, srv, s)
|
||||
|
||||
resp := do(t, srv, http.MethodGet, "/api/byedpi", cookie, "")
|
||||
defer resp.Body.Close()
|
||||
var got byedpiReport
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.State != ByeDPIListening {
|
||||
t.Fatalf("state = %q, want the measured %q", got.State, ByeDPIListening)
|
||||
}
|
||||
if len(got.Egresses) != 0 {
|
||||
t.Fatalf("egresses = %+v, want none — the cross-check could not run", got.Egresses)
|
||||
}
|
||||
if !strings.Contains(got.Detail, "cross-check was not performed") {
|
||||
t.Errorf("detail must say the cross-check did not happen; got %q", got.Detail)
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -169,12 +169,11 @@ type Server struct {
|
||||
// static content: the embedded SPA (or a placeholder when it wasn't built).
|
||||
static *staticHandler
|
||||
|
||||
// There is deliberately NO byedpi readiness cache here any more. It existed to
|
||||
// keep GET /api/status from dialling on every poll, and it owned a background
|
||||
// goroutine that Close had to stop. GET /api/status no longer carries a
|
||||
// readiness report at all (nothing read it), so the probe happens only inside
|
||||
// GET /api/byedpi, on the caller's goroutine — nothing to own, nothing to
|
||||
// stop, and no socket opened on behalf of a server that has closed.
|
||||
// There is deliberately NO readiness cache here. One existed, for an external
|
||||
// desync helper that is now retired: it kept GET /api/status from dialling on
|
||||
// every poll, and it owned a background goroutine that Close had to stop. The
|
||||
// rule it left behind outlives it — nothing on this struct may own a goroutine
|
||||
// that opens sockets on behalf of a server that has closed.
|
||||
}
|
||||
|
||||
// NewServer returns a panel Server bound to the daemon's shared Applier. It does
|
||||
@@ -243,12 +242,11 @@ func (s *Server) Start() error {
|
||||
|
||||
// Close stops the HTTP server and drops all live sessions. Idempotent.
|
||||
//
|
||||
// It has no background probe to stop: the byedpi listener check runs on the
|
||||
// goroutine of the GET /api/byedpi request that asked for it and is finished
|
||||
// before that request returns. When it did have one, Close had to wait for it —
|
||||
// a goroutine opening sockets on behalf of a closed server is the same class of
|
||||
// defect as a listener nobody closes. Anything added here that dials in the
|
||||
// background owes the same debt.
|
||||
// It has no background probe to stop: no endpoint this server serves dials
|
||||
// anything off the caller's own goroutine. When one did, Close had to wait for
|
||||
// it — a goroutine opening sockets on behalf of a closed server is the same
|
||||
// class of defect as a listener nobody closes. Anything added here that dials in
|
||||
// the background owes the same debt.
|
||||
func (s *Server) Close() error {
|
||||
s.authMu.Lock()
|
||||
s.sessions = make(map[string]time.Time)
|
||||
@@ -289,7 +287,6 @@ func (s *Server) buildRouter() http.Handler {
|
||||
mux.Handle("/api/subscription/update", s.requireSession(http.HandlerFunc(s.handleSubscriptionUpdate)))
|
||||
mux.Handle("/api/groups/test", s.requireSession(http.HandlerFunc(s.handleGroupsTest)))
|
||||
mux.Handle("/api/groups/health", s.requireSession(http.HandlerFunc(s.handleGroupsHealth)))
|
||||
mux.Handle("/api/byedpi", s.requireSession(http.HandlerFunc(s.handleByeDPI)))
|
||||
|
||||
// Any other /api/* path is an unknown endpoint → JSON 404 (NOT the SPA).
|
||||
mux.Handle("/api/", s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -27,11 +27,10 @@ func newTestServer(t *testing.T) *Server {
|
||||
SessionTTL: time.Hour,
|
||||
TokenTTL: time.Minute,
|
||||
})
|
||||
// Close it with the test. Nothing in the panel dials in the background any
|
||||
// more — GET /api/status carries no byedpi report and starts no probe, and
|
||||
// the listener check runs inside the GET /api/byedpi request that asked for
|
||||
// it — but a Server owns a listener and a session table, and the next test
|
||||
// deserves neither.
|
||||
// Close it with the test. Nothing in the panel dials in the background — no
|
||||
// endpoint starts a probe, and no handler outlives its own request — but a
|
||||
// Server owns a listener and a session table, and the next test deserves
|
||||
// neither.
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user