Compare commits

..
7 Commits
Author SHA1 Message Date
vlad 4aa02b6ba4 fix(setup): scope strategy-group GC to wizard-created balancers only
The GC that removes orphaned <sub>-<strategy> balancers matched by name
pattern, so a hand-made group (e.g. a manually created 'qomar-failover' not
yet referenced by a rule) could be deleted on the next wizard commit. Mark
wizard-created strategy groups with an _auto=1 option and GC only those —
never a group the user built by hand. xrayctl ignores the unknown option.
2026-07-13 19:49:39 +03:00
vlad 9582105ab9 feat(luci): bilingual Help page + a plain-language monkey guide
- help.js: a RU/EN reference (toggle persisted in localStorage) explaining
  every concept — wizard, subscriptions, groups, per-device modes, the global
  IP list, Advanced, and how a client actually gets proxied (gateway).
- banana.js: 'Интернет для маленьких детей и успешных обезьян' — the same map
  told through bananas and monkeys, with the real term in brackets each time.
Both are static E()-only views (no rpc/uci, no XSS surface). Added as the last
two menu tabs (Help, then 🍌 to its right).
2026-07-13 16:30:20 +03:00
vlad f39d84c4da feat(luci): guided Setup wizard (Zzerg0Pack)
A first-run 'Setup' tab that collapses the real minimal path into three steps
on top of the existing /etc/config/shater model (schema unchanged):
  1. Servers  — add/enable subscriptions, test, one-click Create route(s).
  2. Devices  — per-client policy modal (all / only-lists / except / block),
     a LAN client picker, and a whole-file global IP list (Browse & upload).
  3. Turn on  — one switch: enable engine + LAN interception, apply, show exit IP.

Written with the review's frontend fixes baked in from the start:
  - discoverClients excludes the router's own IP and the upstream gateway
    (routing either through the VPN loops and kills connectivity);
  - per-client edit prefills the server from the group/node-bearing rule, not
    the trailing direct rule (only-mode no longer loses its pinned node);
  - global-list upload never reports success when no route exists yet, and its
    apply errors surface instead of being swallowed as an upload-cancel;
  - garbage-collect orphaned <sub>-<strategy> balancer groups on commit;
  - a reloading guard so the 10s poll never redraws mid uci.unload->load;
  - .catch on every commit(); exit IP kept in state so the poll can't wipe it;
  - dead code removed (toArray, rowsById).

Adds Setup as the first menu tab; grants luci-rpc (client list) + file
read/write on /etc/xray/lists/* (global-list upload) in acl.d.
2026-07-13 16:28:32 +03:00
vlad a9b40ee9ce fix(luci): graceful Test-all failure + geodata Update button
- nodes.js: "Test all nodes" probes every node in a single ubus call; on a
  large subscription that exceeds the rpcd call timeout and left the spinner
  hung on an unhandled rejection. Add a .catch that reports it and points to
  the per-node Test / background probe.
- lists.js: add an "Update" button for geodata. GeodataDownload is a no-op
  when already present, so refreshing required a manual Remove first; Update =
  remove + re-download, enabled only when installed.

Poll intervals (overview 5s / live 3s) left as-is: the new rpcd TTL cache
makes those polls cache hits, so they no longer spawn xrayctl per tick.
2026-07-13 16:10:11 +03:00
vlad df6bdaa616 fix(rpcd): POSIX-escape single quotes in shq() instead of stripping them
shq() stripped every ' before wrapping, so a value containing a single quote
(e.g. a node named "it's fast") was silently mangled and every subsequent
enable/disable/delete/test/group targeting that name failed. Escape ' as the
standard '\'' sequence so values survive intact. Injection was already closed
by the single-quote wrapping; this is purely a data-loss fix.
2026-07-13 16:03:16 +03:00
vlad 3ba36a7bd5 perf(rpcd): read-through TTL cache for status/nodes/stats/conns
shater.uc runs inside rpcd's single-threaded loop and each read popen()'d
xrayctl synchronously (2-4s), blocking the whole ubus bus on every LuCI
dashboard poll (Overview alone fires 3 calls/5s). Cache the heavy reads on
tmpfs as a small {_ts,d} wrapper with a short TTL; invalidate after any
state-changing action so the dashboard never shows stale post-apply data.

Measured on the 570-node testbed: nodes 3.31s (miss) -> 0.09s (hit); status
~1s -> 0.00s. The bus stays responsive between refreshes.
2026-07-13 16:00:50 +03:00
vlad 55498882f7 fix(xrayctl): write run.json/last-good 0600 (they hold node credentials)
run.json and last-good.json are the rendered xray config with every node
secret expanded (VLESS/VMess UUIDs, SS/Trojan passwords, WG keys). They were
created 0644 in /etc/xray, so any local user could read all proxy credentials.
Harden writeBytesAtomic and the rollback/last-good write paths to 0600; add a
regression test.

Note: sysctl scoping (route_localnet/accept_local on lo only) and the
crash-loop teardown (infinite respawn + cron watchdog) reviewed in this pass
were already correct on main; only the file mode remained.
2026-07-13 15:54:02 +03:00
2151 changed files with 22550 additions and 412567 deletions
-31
View File
@@ -1,31 +0,0 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>"
--no-deb-generate-changes
--config-files /etc/config/sing-box
--config-files /etc/sing-box/config.json
--depends ca-bundle
--depends kmod-inet-diag
--depends kmod-tun
--depends firewall4
--depends kmod-nft-queue
--before-remove release/config/openwrt.prerm
release/config/config.json=/etc/sing-box/config.json
release/config/openwrt.conf=/etc/config/sing-box
release/config/openwrt.init=/etc/init.d/sing-box
release/config/openwrt.keep=/lib/upgrade/keep.d/sing-box
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
-23
View File
@@ -1,23 +0,0 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>"
--config-files etc/sing-box/config.json
--after-install release/config/sing-box.postinst
release/config/config.json=/etc/sing-box/config.json
release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service
release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service
release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf
release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules
release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
-26
View File
@@ -1,26 +0,0 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--vendor SagerNet
--maintainer "nekohasekai <contact-git@sekai.icu>"
--deb-field "Bug: https://github.com/SagerNet/sing-box/issues"
--no-deb-generate-changes
--config-files /etc/sing-box/config.json
--after-install release/config/sing-box.postinst
release/config/config.json=/etc/sing-box/config.json
release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service
release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service
release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf
release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules
release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
-538
View File
@@ -1,538 +0,0 @@
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
# per-arch Gitea releases consumable as an apk repository.
#
# WHAT WE SHIP
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
# SDK way:
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
# and staged into openwrt/shaterd/files/ BEFORE the SDK
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
# the arch-matched artifact. (arch-specific .apk)
# - shater-core data glue, PKGARCH=all
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
#
# 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 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).
#
# FORMAT: apk ONLY (25.12+)
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
# device we serve has an opkg binary at all, so building and signing a second
# feed served nobody.
#
# FEED SIGNING (EC / apk)
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
# secret KEY_APK; routers verify it with the committed public key
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
# invalidates every deployed router's trust.
#
# AUTO-RELEASE
# The rolling per-arch `apk-latest-<arch>` is published on EVERY run — tag runs
# included — and then read back over the API to assert it really serves the
# version just built. A tag push `vX.Y.Z` publishes the pinnable per-arch
# `apk-vX.Y.Z-<arch>` IN ADDITION. It is not an either/or: it used to be, and
# the rolling pointer then froze at 0.2.0 while v0.2.9/v0.2.10 shipped (see the
# long comment above the `release-apk` job). Publish uses the Gitea API via curl
# (ci/gitea-release.sh) — no external action needed. NOTE: the apk release tags
# deliberately do NOT start with `v` so publishing them cannot re-trigger this
# workflow's `v*` filter.
#
# PACKAGE VERSIONING (bug B4)
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
# used to be, and nobody bumped them: v0.2.2…v0.2.6 all shipped as
# `shaterd 0.2.0-r3` with different binaries inside, so `apk update` never saw
# a new version and routers could not be updated at all. Now `ci/version.sh`
# derives them from the git tag ONCE per job (the "Compute version" step,
# exported via $GITHUB_ENV):
# tag `vX.Y.Z` -> X.Y.Z-r1
# anything else -> <nearest tag>-r<commits since it + 1>
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
# into the binary's constant.Version. ci/sdk-build-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. 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
# OLD cache API that Gitea's act_runner cache server implements. v4 (and the
# v3.4.x backports) moved to GitHub's new cache service and fail on act_runner
# — the same reason upload-artifact is pinned to v3 here. If the runner's
# cache server is disabled, actions/cache degrades to a warning and the build
# proceeds uncached (correct, just slower).
# What is cached, and why each key is safe:
# - ImmortalWrt SDK tarball (.cache/sdk) — key = tarball basename (carries
# release + target + gcc), exact-only. Cold-miss fallback chain lives in
# ci/fetch-sdk.sh: own Gitea release-asset mirror (tag `sdk-cache`) ->
# upstream with stall-kill + retries; upstream success re-seeds the mirror.
# - SDK dl/ sources (.cache/dl) — key = hash of openwrt/*/Makefile
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
# prefix fallback is allowed.
# - Go module + build cache — key = hash of go.sum; shared by both build
# jobs (each builds both GOARCHes).
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
# is in it).
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
# container from the workspace cache; `feeds update` on an existing clone
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
# always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
# cache + re-clones fresh if update ever fails on a cached checkout.
# Key = lane + SDK release (shared across the two arch jobs — the same
# release pins identical feed revs; the sequential runner means the second
# arch restores what the first saved). restore-keys lets an SDK version
# bump start from the old clones (git fetch delta, not re-clone).
# Act_runner facts this design leans on (verified in run 51 logs):
# - the cache backend works: restores/saves confirmed, hashFiles() works;
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
# host daemon — "Image is up to date" each run, no re-download;
# - each actions/cache SAVE is followed by an exact 3-minute act_runner
# stall (node process lingers; hit→no-save→no stall). Steady state saves
# nothing, so adding cache entries is fine, but keys that change every
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
# A re-dispatch supersedes the still-running build of the same ref: cancel it
# instead of piling up parallel runs (the user re-triggers often). Tag builds
# are safe: each tag is its own group, so a release build is only ever cancelled
# by a re-run of the SAME tag (which supersedes it by definition). Gitea
# versions without concurrency support ignore this block harmlessly.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
# stops the release instead of shipping with it.
#
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
# workflow cannot block another one — they run side by side and a red `test`
# workflow would have published anyway. Only a `needs:` edge inside THIS
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
# call the same scripts/run-tests.sh so they cannot drift.
#
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
# three named tests. 115 of the 116 test files under shater/** had never run in
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
#
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
# transport/wireguard compiles 1 test file without the tag set and 7 with it
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
# the named, reasoned exclusions are in scripts/run-tests.sh.
test:
name: test gate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
# this even `go list` fails. Same step/reason as in build-apk below.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# Same cache key as build-apk: this job runs first, so it warms the module
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
# TypeScript run directly by `node --test`, and type stripping only exists
# from 22.6 — on node 20 `npm test` dies before running a single case.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
# ---------------------------------------------------------------------------
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
build-apk:
name: apk ${{ matrix.arch }}
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
# (with the guards in release-apk) leaves nothing published.
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# ImmortalWrt 25.12.1 official SDK tarballs (no immortalwrt/sdk docker
# tag exists for mediatek-filogic 25.12 — see ci/build-feed-apk.sh).
- arch: x86_64 # testbed VM
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/x86/64/immortalwrt-sdk-25.12.1-x86-64_gcc-14.3.0_musl.Linux-x86_64.tar.zst
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
steps:
# fetch-depth: 0 — the package version is DERIVED from the git tag
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
# shallow checkout has neither tags nor ancestry, so `git describe` would
# fail and every dispatch build would fall back to 0.0.0.
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
# present. actions/checkout does not fetch submodules by default; init ONLY
# this one (the clients/apple+android submodules are large and unneeded).
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
# THE version step (bug B4). One computation, used by both the binary
# (constant.Version) and the three tag-versioned packages, exported to
# every later step of this job:
# tag vX.Y.Z -> X.Y.Z-r1 ; off-tag -> <last tag>-r<commits+1>
- name: Compute version from git tag
run: bash ci/version.sh --env >> "$GITHUB_ENV"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# ---- caches (see the header comment for keys + version pin rationale) ----
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
- name: Cache panel node_modules
id: npm-cache
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Cache SDK dl/ (package sources)
uses: actions/cache@v3.3.2
with:
path: .cache/dl
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
restore-keys: |
dl-
- name: Cache apt archives (bookworm host-deps)
uses: actions/cache@v3.3.2
with:
path: .cache/apt
key: apt-bookworm-${{ hashFiles('ci/sdk-build-apk.sh') }}
restore-keys: |
apt-bookworm-
# The ~300 MB SDK tarball was re-downloaded EVERY run from
# downloads.immortalwrt.org, which flakes/stalls (>40 min hangs). Cache it
# by tarball basename (exact key: an SDK version bump = clean miss); on a
# cold miss ci/fetch-sdk.sh falls back to our own `sdk-cache` release-asset
# mirror, then to upstream with stall-kill + retries (and re-seeds the
# mirror) — so even with a dead cache server this never wedges the run.
- name: Compute SDK cache key
id: sdkkey
run: |
echo "tarball=$(basename '${{ matrix.sdk_url }}')" >> "$GITHUB_OUTPUT"
echo "relver=$(echo '${{ matrix.sdk_url }}' | sed -n 's#.*/releases/\([^/]*\)/.*#\1#p')" >> "$GITHUB_OUTPUT"
- name: Cache ImmortalWrt SDK tarball
uses: actions/cache@v3.3.2
with:
path: .cache/sdk
key: sdk-${{ steps.sdkkey.outputs.tarball }}
# feeds git checkouts (see header) — one entry shared by both apk arch
# jobs of one ImmortalWrt release (identical feeds.conf.default pins).
- name: Cache SDK feeds checkouts
uses: actions/cache@v3.3.2
with:
path: .cache/feeds
key: feeds-apk-${{ steps.sdkkey.outputs.relver }}
restore-keys: |
feeds-apk-
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
# everything else in CI builds with the full upstream set, so without this
# step the one combination we actually ship is never exercised. That is how
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
# binary answered a WireGuard node with "gVisor is not included in this
# build" (2026-07-25). The check runs the declared-feature/tag comparison
# and then constructs one node of every declared protocol through box.New
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
# that breaks a feature fails the release instead of shipping.
- name: Verify the shipped build-tag set (D23)
run: bash scripts/check-router-tags.sh
- name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
# the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
# (the openwrt/shaterd package only installs the staged artifact).
# $SHATER_VERSION (from the version step above) is stamped into
# constant.Version, so the binary and the package agree. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact
env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
set -eu
FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh $FAST
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
# sign the per-arch packages.adb with the EC key (secret KEY_APK).
# MIRROR_TOKEN lets ci/fetch-sdk.sh seed the `sdk-cache` mirror release
# after a (rare) upstream download — best-effort, never fails the build.
- name: Build signed apk feed (ImmortalWrt 25.12 SDK)
env:
KEY_APK: ${{ secrets.KEY_APK }}
MIRROR_TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
run: bash ci/build-feed-apk.sh "${{ matrix.arch }}" "${{ matrix.sdk_url }}" "out-apk/${{ matrix.arch }}"
- name: Show apk feed
run: ls -l "out-apk/${{ matrix.arch }}"
- name: Upload apk feed artifact
uses: actions/upload-artifact@v3
with:
name: apkfeed-${{ matrix.arch }}
path: out-apk/${{ matrix.arch }}/*
if-no-files-found: error
# ---------------------------------------------------------------------------
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
# multi-arch release would collide). Every run refreshes the ROLLING pointer
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
# publishing them cannot re-trigger the build.
#
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
# it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
# while v0.2.9/v0.2.10 published fine, and every router whose
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
# successful, silent `apk update` with nothing new. Rolling is the whole point
# of that URL, so it is now written unconditionally and asserted afterwards.
release-apk:
name: release apk
needs: [test, build-apk]
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
#
# `needs.test.result == 'success'` is the second half of the gate. Without
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
# skipped), this job runs with no artifacts at all, and — see the guard at
# the end of the publish step — used to exit 0 having published nothing. Red
# tests must SKIP this job, not "succeed" through it.
if: ${{ !cancelled() && needs.test.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all arch apk feeds
uses: actions/download-artifact@v3
with:
path: artifacts
# Identity of the VERSIONED release only. The rolling pointer is published
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
# from here.
- name: Determine release identity
id: rel
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
echo "ver=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
echo "rolling=false" >> "$GITHUB_OUTPUT"
else
echo "ver=latest" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "rolling=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish per-arch apk releases
env:
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
VER: ${{ steps.rel.outputs.ver }}
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }}
run: |
set -euo pipefail
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
# was the step's whole body: with no artifacts the glob stayed
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
# ended and the step exited 0 — "release apk" went GREEN having
# published absolutely nothing. Any upstream failure (all arches
# failing to build, an artifact-name change, a download-artifact
# hiccup) therefore looked like a successful release.
published=0
for d in artifacts/apkfeed-*; do
[ -d "$d" ] || continue
arch="${d#artifacts/apkfeed-}"
ROLL="apk-latest-$arch"
# The version we just built, read straight off the artifact
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
# this job checks out shallow, so it has no tags to describe from.
pkg=""
for a in "$d"/shaterd-*.apk; do
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
done
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
want="${pkg#shaterd-}"; want="${want%.apk}"
echo "[release-apk] arch=$arch built version=$want"
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
This build: \`$want\`.
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
── Add as an apk repository (rolling — install once, then just update) ──
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
apk update
apk add luci-app-shater # pulls shater-core + shaterd too
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
assets, so the same repo line keeps serving the newest build. To pin a
version instead, point the repo line at
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
file must be edited by hand for each upgrade.
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
apk update
apk upgrade shaterd shater-core luci-app-shater
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
those packages are upgraded along with needed dependencies\").
Full guide: docs-shater/INSTALL.md §5."
# 1) the pinnable versioned release (tag runs only)
if [ "$VER" != latest ]; then
echo "[release-apk] publishing apk-$VER-$arch from $d"
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
bash ci/gitea-release.sh "$d"/*
fi
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
# deletes the existing release before recreating it, so the old
# version's assets are REPLACED, never accumulated (two versions of
# one package in one index would let apk choose, not us).
echo "[release-apk] publishing $ROLL from $d"
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
PRERELEASE=true ROLLING=true \
bash ci/gitea-release.sh "$d"/*
# 3) ASSERT the rolling release really serves THIS build — same class
# of check as ci/sdk-build-apk.sh's package-version assert, and for
# the same reason: the previous failure mode was silent. Reads the
# published release back over the API and requires our three
# tag-versioned packages at $want, the index, the key — and NO
# left-over package asset at any other version.
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
| tr '{},' '\n\n\n' \
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
# here-string, NOT `printf | grep -q`: under `pipefail` the early
# exit of grep -q can SIGPIPE the writer and fail a passing check.
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
grep -qxF "$f" <<<"$got" || {
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
echo " A router pinned to the rolling URL would have silently"
echo " stayed on its old version with a successful apk update."
exit 13; }
done
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
-e "luci-app-shater-$want.apk" || true)"
[ -z "$stale" ] || {
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
printf ' %s\n' $stale
echo " Two versions of one package in one feed = apk picks by its"
echo " own rules, not by our intent."
exit 14; }
echo "[release-apk] OK — $ROLL serves $want"
published=$((published + 1))
done
# The assert the loop above never had. Zero feeds published is a failed
# release, not a quiet success — say so with a non-zero exit.
if [ "$published" -eq 0 ]; then
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
echo " NOTHING was published. Downloaded tree:"
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
exit 10
fi
echo "[release-apk] published $published arch feed(s)"
-85
View File
@@ -1,85 +0,0 @@
# Shater — the test gate, on every push to `main`.
#
# WHY THIS FILE EXISTS (2026-07-26)
# The fork had a full suite and no CI that ran it. Upstream's
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
# the 116 test files under shater/** had never once executed in CI, and
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
# releases.
#
# RELATIONSHIP TO release.yml
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
# gate: a separate workflow cannot block another one. The gate is the `test`
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
# drift apart.
name: test
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs-shater/**'
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: go + panel tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
# starts. Same step, same reason, as in release.yml's build job.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# v3.3.2 is the last release speaking the cache API act_runner implements
# (see the header of release.yml). Same key as the release build job, so
# whichever runs first warms the other.
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
# through `node --test`, and type stripping only exists from 22.6. On
# node 20 `npm test` dies with a syntax error before running anything.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
-1
View File
@@ -1 +0,0 @@
98d539ce67568fb911654e66a14cf4247ed833ec
-1
View File
@@ -1 +0,0 @@
github: nekohasekai
-88
View File
@@ -1,88 +0,0 @@
name: Bug report
description: "Report sing-box bug"
body:
- type: dropdown
attributes:
label: Operating system
description: Operating system type
options:
- iOS
- macOS
- Apple tvOS
- Android
- Windows
- Linux
- Others
validations:
required: true
- type: input
attributes:
label: System version
description: Please provide the operating system version
validations:
required: true
- type: dropdown
attributes:
label: Installation type
description: Please provide the sing-box installation type
options:
- Original sing-box Command Line
- sing-box for iOS Graphical Client
- sing-box for macOS Graphical Client
- sing-box for Apple tvOS Graphical Client
- sing-box for Android Graphical Client
- Third-party graphical clients that advertise themselves as using sing-box (Windows)
- Third-party graphical clients that advertise themselves as using sing-box (Android)
- Others
validations:
required: true
- type: input
attributes:
description: Graphical client version
label: If you are using a graphical client, please provide the version of the client.
- type: textarea
attributes:
label: Version
description: If you are using the original command line program, please provide the output of the `sing-box version` command.
render: shell
- type: textarea
attributes:
label: Description
description: Please provide a detailed description of the error.
validations:
required: true
- type: textarea
attributes:
label: Reproduction
description: Please provide the steps to reproduce the error, including the configuration files and procedures that can locally (not dependent on the remote server) reproduce the error using the original command line program of sing-box.
validations:
required: true
- type: textarea
attributes:
label: Logs
description: |-
In addition, if you encounter a crash with the graphical client, please also provide crash logs.
For Apple platform clients, please check `Settings - View Service Log` for crash logs.
For the Android client, please check the `/sdcard/Android/data/io.nekohasekai.sfa/files/stderr.log` file for crash logs.
render: shell
- type: checkboxes
id: supporter
attributes:
label: Supporter
options:
- label: I am a [sponsor](https://github.com/sponsors/nekohasekai/)
- type: checkboxes
attributes:
label: Integrity requirements
description: |-
Please check all of the following options to prove that you have read and understood the requirements, otherwise this issue will be closed.
Sing-box is not a project aimed to please users who can't make any meaningful contributions and gain unethical influence. If you deceive here to deliberately waste the time of the developers, you will be permanently blocked.
options:
- label: I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
required: true
- label: I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
required: true
- label: I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
required: true
- label: I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.
required: true
-88
View File
@@ -1,88 +0,0 @@
name: 错误反馈
description: "提交 sing-box 漏洞"
body:
- type: dropdown
attributes:
label: 操作系统
description: 请提供操作系统类型
options:
- iOS
- macOS
- Apple tvOS
- Android
- Windows
- Linux
- 其他
validations:
required: true
- type: input
attributes:
label: 系统版本
description: 请提供操作系统版本
validations:
required: true
- type: dropdown
attributes:
label: 安装类型
description: 请提供该 sing-box 安装类型
options:
- sing-box 原始命令行程序
- sing-box for iOS 图形客户端程序
- sing-box for macOS 图形客户端程序
- sing-box for Apple tvOS 图形客户端程序
- sing-box for Android 图形客户端程序
- 宣传使用 sing-box 的第三方图形客户端程序 (Windows)
- 宣传使用 sing-box 的第三方图形客户端程序 (Android)
- 其他
validations:
required: true
- type: input
attributes:
description: 图形客户端版本
label: 如果您使用图形客户端程序,请提供该程序版本。
- type: textarea
attributes:
label: 版本
description: 如果您使用原始命令行程序,请提供 `sing-box version` 命令的输出。
render: shell
- type: textarea
attributes:
label: 描述
description: 请提供错误的详细描述。
validations:
required: true
- type: textarea
attributes:
label: 重现方式
description: 请提供重现错误的步骤,必须包括可以在本地(不依赖与远程服务器)使用 sing-box 原始命令行程序重现错误的配置文件与流程。
validations:
required: true
- type: textarea
attributes:
label: 日志
description: |-
此外,如果您遭遇图形界面应用程序崩溃,请附加提供崩溃日志。
对于 Apple 平台图形客户端程序,请检查 `Settings - View Service Log` 以导出崩溃日志。
对于 Android 图形客户端程序,请检查 `/sdcard/Android/data/io.nekohasekai.sfa/files/stderr.log` 文件以导出崩溃日志。
render: shell
- type: checkboxes
id: supporter
attributes:
label: 支持我们
options:
- label: 我已经 [赞助](https://github.com/sponsors/nekohasekai/)
- type: checkboxes
attributes:
label: 完整性要求
description: |-
请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
sing-box 不是讨好无法作出任何意义上的贡献的最终用户并获取非道德影响力的项目,如果您在此处欺骗以故意浪费开发者的时间,您将被永久封锁。
options:
- label: 我保证阅读了文档,了解所有我编写的配置文件项的含义,而不是大量堆砌看似有用的选项或默认值。
required: true
- label: 我保证提供了可以在本地重现该问题的服务器、客户端配置文件与流程,而不是一个脱敏的复杂客户端配置文件。
required: true
- label: 我保证提供了可用于重现我报告的错误的最简配置,而不是依赖远程服务器、TUN、图形界面客户端或者其他闭源软件。
required: true
- label: 我保证提供了完整的配置文件与日志,而不是出于对自身智力的自信而仅提供了部分认为有用的部分。
required: true
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env bash
set -e -o pipefail
prepare_apk_root() {
# apk mkpkg resolves owner/group names through --root/etc/{passwd,group}.
APK_ROOT_DIR=$(mktemp -d)
mkdir -p "$APK_ROOT_DIR/etc"
cat > "$APK_ROOT_DIR/etc/passwd" <<EOF
root:x:$(id -u):$(id -g):root:/root:/sbin/nologin
EOF
cat > "$APK_ROOT_DIR/etc/group" <<EOF
root:x:$(id -g):root
EOF
}
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
prepare_apk_root
trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm755 "$PROJECT/release/config/sing-box.initd" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/sing-box.confd" "$ROOT_DIR/etc/conf.d/sing-box"
# Service files
install -Dm644 "$PROJECT/release/config/sing-box.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box.service"
install -Dm644 "$PROJECT/release/config/sing-box@.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box@.service"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/conf.d/sing-box
/etc/init.d/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk --root "$APK_ROOT_DIR" mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later with name use or association addition" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
set -e -o pipefail
prepare_apk_root() {
# apk mkpkg resolves owner/group names through --root/etc/{passwd,group}.
APK_ROOT_DIR=$(mktemp -d)
mkdir -p "$APK_ROOT_DIR/etc"
cat > "$APK_ROOT_DIR/etc/passwd" <<EOF
root:x:$(id -u):$(id -g):root:/root:/sbin/nologin
EOF
cat > "$APK_ROOT_DIR/etc/group" <<EOF
root:x:$(id -g):root
EOF
}
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
prepare_apk_root
trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm644 "$PROJECT/release/config/openwrt.conf" "$ROOT_DIR/etc/config/sing-box"
install -Dm755 "$PROJECT/release/config/openwrt.init" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/openwrt.keep" "$ROOT_DIR/lib/upgrade/keep.d/sing-box"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/config/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk --root "$APK_ROOT_DIR" mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \
--info "provider-priority:100" \
--script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
set -euo pipefail
ARCH=$1
DEB_SRC=$2
OUT_IPK=$3
PROJECT=$(dirname "$0")/../..
TMP_PATH=$(mktemp -d)
trap 'rm -rf "$TMP_PATH"' EXIT
cp "$DEB_SRC" "$TMP_PATH"/
pushd "$TMP_PATH" >/dev/null
# Derive the name from the file we copied — do not glob-parse `ls *.deb`.
DEB_NAME=$(basename "$DEB_SRC")
ar x "$DEB_NAME"
mkdir control
pushd control >/dev/null
tar xf ../control.tar.gz
rm -f md5sums
sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
cat control
tar czf ../control.tar.gz ./*
popd >/dev/null
DEB_NAME=${DEB_NAME%.deb}
tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
popd >/dev/null
cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
branches=$(git branch -r --contains HEAD)
if echo "$branches" | grep -q 'origin/stable'; then
track=stable
elif echo "$branches" | grep -q 'origin/testing'; then
track=testing
elif echo "$branches" | grep -q 'origin/oldstable'; then
track=oldstable
else
echo "ERROR: HEAD is not on any known release branch (stable/testing/oldstable)" >&2
exit 1
fi
if [[ "$track" == "stable" ]]; then
tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [[ -n "$tag" && "$tag" == *"-"* ]]; then
track=beta
fi
fi
case "$track" in
stable) name=sing-box; docker_tag=latest ;;
beta) name=sing-box-beta; docker_tag=latest-beta ;;
testing) name=sing-box-testing; docker_tag=latest-testing ;;
oldstable) name=sing-box-oldstable; docker_tag=latest-oldstable ;;
esac
echo "track=${track} name=${name} docker_tag=${docker_tag}" >&2
echo "TRACK=${track}" >> "$GITHUB_ENV"
echo "NAME=${name}" >> "$GITHUB_ENV"
echo "DOCKER_TAG=${docker_tag}" >> "$GITHUB_ENV"
-28
View File
@@ -1,28 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"commitMessagePrefix": "[dependencies]",
"extends": [
"config:base",
":disableRateLimiting"
],
"baseBranches": [
"unstable"
],
"golang": {
"enabled": false
},
"packageRules": [
{
"matchManagers": [
"github-actions"
],
"groupName": "github-actions"
},
{
"matchManagers": [
"dockerfile"
],
"groupName": "Dockerfile"
}
]
}
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.25.11"
PATCH_COMMITS=(
"afe69d3cec1c6dcf0f1797b20546795730850070"
"1ed289b0cf87dc5aae9c6fe1aa5f200a83412938"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.darwin-arm64.tar.gz"
tar -xzf "go${VERSION}.darwin-arm64.tar.gz"
#cp -a go go_bootstrap
mv go go_osx
cd go_osx
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.25/
# revert:
# 33d3f603c1: "cmd/link/internal/ld: use 12.0.0 OS/SDK versions for macOS linking"
# 937368f84e: "crypto/x509: change how we retrieve chains on darwin"
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done
# Rebuild is not needed: we build with CGO_ENABLED=1, so Apple's external
# linker handles LC_BUILD_VERSION via MACOSX_DEPLOYMENT_TARGET, and the
# stdlib (crypto/x509) is compiled from patched src automatically.
#cd src
#GOROOT_BOOTSTRAP="$HOME/go/go_bootstrap" ./make.bash
#cd ../..
#rm -rf go_bootstrap "go${VERSION}.darwin-arm64.tar.gz"
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.25.11"
PATCH_COMMITS=(
"466f6c7a29bc098b0d4c987b803c779222894a11"
"1bdabae205052afe1dadb2ad6f1ba612cdbc532a"
"a90777dcf692dd2168577853ba743b4338721b06"
"f6bddda4e8ff58a957462a1a09562924d5f3d05c"
"bed309eff415bcb3c77dd4bc3277b682b89a388d"
"34b899c2fb39b092db4fa67c4417e41dc046be4b"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.linux-amd64.tar.gz"
tar -xzf "go${VERSION}.linux-amd64.tar.gz"
mv go go_win7
cd go_win7
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
# revert:
# 693def151adff1af707d82d28f55dba81ceb08e1: "crypto/rand,runtime: switch RtlGenRandom for ProcessPrng"
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
# 48042aa09c2f878c4faa576948b07fe625c4707a: "syscall: remove Windows 7 console handle workaround"
# a17d959debdb04cd550016a3501dd09d50cd62e7: "runtime: always use LoadLibraryEx to load system libraries"
# fixes:
# bed309eff415bcb3c77dd4bc3277b682b89a388d: "Fix os.RemoveAll not working on Windows7"
# 34b899c2fb39b092db4fa67c4417e41dc046be4b: "Revert \"os: remove 5ms sleep on Windows in (*Process).Wait\""
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/MetaCubeX/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
PROJECTS=$(dirname "$0")/../..
function updateClient() {
pushd clients/$1
git fetch
git reset FETCH_HEAD --hard
popd
git add clients/$1
}
updateClient "apple"
updateClient "android"
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -e -o pipefail
SCRIPT_DIR=$(dirname "$0")
PROJECTS=$SCRIPT_DIR/../..
git -C $PROJECTS/cronet-go fetch origin main
git -C $PROJECTS/cronet-go fetch origin go
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go)
go get -x github.com/sagernet/cronet-go@$(git -C $PROJECTS/cronet-go rev-parse origin/go)
go mod tidy
git -C $PROJECTS/cronet-go rev-parse origin/go > "$SCRIPT_DIR/CRONET_GO_VERSION"
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -e -o pipefail
SCRIPT_DIR=$(dirname "$0")
PROJECTS=$SCRIPT_DIR/../..
git -C $PROJECTS/cronet-go fetch origin dev
git -C $PROJECTS/cronet-go fetch origin go_dev
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go get -x github.com/sagernet/cronet-go@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go mod tidy
git -C $PROJECTS/cronet-go rev-parse origin/dev > "$SCRIPT_DIR/CRONET_GO_VERSION"
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
PROJECTS=$(dirname "$0")/../..
go get -x github.com/sagernet/$1@$(git -C $PROJECTS/$1 rev-parse HEAD)
go mod tidy
+168 -1009
View File
File diff suppressed because it is too large Load Diff
-295
View File
@@ -1,295 +0,0 @@
name: Publish Docker Images
on:
#push:
# branches:
# - stable
# - testing
release:
types:
- published
workflow_dispatch:
inputs:
tag:
description: "The tag version you want to build"
env:
REGISTRY_IMAGE: ghcr.io/sagernet/sing-box
jobs:
build_binary:
name: Build binary
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
# Naive-enabled builds (musl)
- { arch: amd64, naive: true, docker_platform: "linux/amd64" }
- { arch: arm64, naive: true, docker_platform: "linux/arm64" }
- { arch: "386", naive: true, docker_platform: "linux/386" }
- { arch: arm, goarm: "7", naive: true, docker_platform: "linux/arm/v7" }
- { arch: mipsle, gomips: softfloat, naive: true, docker_platform: "linux/mipsle" }
- { arch: riscv64, naive: true, docker_platform: "linux/riscv64" }
- { arch: loong64, naive: true, docker_platform: "linux/loong64" }
# Non-naive builds
- { arch: arm, goarm: "6", docker_platform: "linux/arm/v6" }
- { arch: ppc64le, docker_platform: "linux/ppc64le" }
- { arch: s390x, docker_platform: "linux/s390x" }
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Clone cronet-go
if: matrix.naive
run: |
set -xeuo pipefail
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set version
run: |
set -xeuo pipefail
VERSION=$(go run ./cmd/internal/read_tag)
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
- name: Set Chromium toolchain environment
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> $GITHUB_ENV
- name: Set build tags
run: |
set -xeuo pipefail
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
- name: Prepare artifact
run: |
platform=${{ matrix.docker_platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
# Rename binary to include arch info for Dockerfile.binary
BINARY_NAME="sing-box-${{ matrix.arch }}"
if [[ -n "${{ matrix.goarm }}" ]]; then
BINARY_NAME="${BINARY_NAME}v${{ matrix.goarm }}"
fi
mv sing-box "${BINARY_NAME}"
echo "BINARY_NAME=${BINARY_NAME}" >> $GITHUB_ENV
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: binary-${{ env.PLATFORM_PAIR }}
path: ${{ env.BINARY_NAME }}
if-no-files-found: error
retention-days: 1
build_docker:
name: Build Docker image
runs-on: ubuntu-latest
needs:
- build_binary
strategy:
fail-fast: true
matrix:
include:
- { platform: "linux/amd64" }
- { platform: "linux/arm/v6" }
- { platform: "linux/arm/v7" }
- { platform: "linux/arm64" }
- { platform: "linux/386" }
# mipsle: no base Docker image available for this platform
- { platform: "linux/ppc64le" }
- { platform: "linux/riscv64" }
- { platform: "linux/s390x" }
- { platform: "linux/loong64", base_image: "ghcr.io/loong64/alpine:edge" }
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Download binary
uses: actions/download-artifact@v5
with:
name: binary-${{ env.PLATFORM_PAIR }}
path: .
- name: Prepare binary
run: |
# Find and make the binary executable
chmod +x sing-box-*
ls -la sing-box-*
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
context: .
file: Dockerfile.binary
build-args: |
BASE_IMAGE=${{ matrix.base_image || 'alpine' }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
if: github.event_name != 'push'
runs-on: ubuntu-latest
needs:
- build_docker
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Detect track
run: bash .github/detect_track.sh
- name: Download digests
uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
if: github.event_name != 'push'
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t "${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}" \
-t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}" \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
if: github.event_name != 'push'
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}
-79
View File
@@ -1,79 +0,0 @@
name: Lint
on:
push:
branches:
- oldstable
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/lint.yml'
pull_request:
branches:
- oldstable
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
build:
name: Lint ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: windows
goarch: amd64
- goos: windows
goarch: '386'
- goos: windows
goarch: arm64
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: arm
- goos: linux
goarch: '386'
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: android
goarch: arm64
# - goos: freebsd
# goarch: amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25
- name: Cache go module
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
key: go-${{ hashFiles('**/go.sum') }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
with:
version: latest
args: --timeout=30m
install-mode: binary
verify: false
-243
View File
@@ -1,243 +0,0 @@
name: Build Linux Packages
on:
#push:
# branches:
# - stable
# - testing
workflow_dispatch:
inputs:
version:
description: "Version name"
required: true
type: string
release:
types:
- published
jobs:
calculate_version:
name: Calculate version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.outputs.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
echo "version=${{ inputs.version }}"
echo "version=${{ inputs.version }}" >> "$GITHUB_ENV"
- name: Calculate version
if: github.event_name != 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/read_tag --ci --nightly
- name: Set outputs
id: outputs
run: |-
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
name: Build binary
runs-on: ubuntu-latest
needs:
- calculate_version
strategy:
matrix:
include:
# Naive-enabled builds (musl)
- { os: linux, arch: amd64, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64 }
- { os: linux, arch: arm64, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64 }
- { os: linux, arch: "386", naive: true, debian: i386, rpm: i386 }
- { os: linux, arch: arm, goarm: "7", naive: true, debian: armhf, rpm: armv7hl, pacman: armv7hl }
- { os: linux, arch: mipsle, gomips: softfloat, naive: true, debian: mipsel, rpm: mipsel }
- { os: linux, arch: riscv64, naive: true, debian: riscv64, rpm: riscv64 }
- { os: linux, arch: loong64, naive: true, debian: loongarch64, rpm: loongarch64 }
# Non-naive builds (unsupported architectures)
- { os: linux, arch: arm, goarm: "6", debian: armel, rpm: armv6hl }
- { os: linux, arch: mips64le, debian: mips64el, rpm: mips64el }
- { os: linux, arch: s390x, debian: s390x, rpm: s390x }
- { os: linux, arch: ppc64le, debian: ppc64el, rpm: ppc64le }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Clone cronet-go
if: matrix.naive
run: |
set -xeuo pipefail
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set Chromium toolchain environment
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> $GITHUB_ENV
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Set build tags
run: |
set -xeuo pipefail
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
GOMIPS64: ${{ matrix.gomips }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set mtime
run: |-
TZ=UTC touch -t '197001010000' dist/sing-box
- name: Detect track
run: bash .github/detect_track.sh
- name: Set version
run: |-
PKG_VERSION="${{ needs.calculate_version.outputs.version }}"
PKG_VERSION="${PKG_VERSION//-/\~}"
echo "PKG_VERSION=${PKG_VERSION}" >> "${GITHUB_ENV}"
- name: Package DEB
if: matrix.debian != ''
run: |
set -xeuo pipefail
sudo gem install fpm
sudo apt-get install -y debsigs
cp .fpm_systemd .fpm
fpm -t deb \
--name "${NAME}" \
-v "$PKG_VERSION" \
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \
--architecture ${{ matrix.debian }} \
dist/sing-box=/usr/bin/sing-box
curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff'
sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff'
rm -rf $HOME/.gnupg
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
EOF
debsigs --sign=origin -k ${{ secrets.GPG_KEY_ID }} --gpgopts '--pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}"' dist/*.deb
- name: Package RPM
if: matrix.rpm != ''
run: |-
set -xeuo pipefail
sudo gem install fpm
cp .fpm_systemd .fpm
fpm -t rpm \
--name "${NAME}" \
-v "$PKG_VERSION" \
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.rpm }}.rpm" \
--architecture ${{ matrix.rpm }} \
dist/sing-box=/usr/bin/sing-box
cat > $HOME/.rpmmacros <<EOF
%_gpg_name ${{ secrets.GPG_KEY_ID }}
%_gpg_sign_cmd_extra_args --pinentry-mode loopback --passphrase ${{ secrets.GPG_PASSPHRASE }}
EOF
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
EOF
rpmsign --addsign dist/*.rpm
- name: Cleanup
run: rm dist/sing-box
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.legacy_go && '-legacy' || '' }}
path: "dist"
upload:
name: Upload builds
runs-on: ubuntu-latest
needs:
- calculate_version
- build
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV"
- name: Download builds
uses: actions/download-artifact@v5
with:
path: dist
merge-multiple: true
- name: Publish packages
if: github.event_name != 'push'
run: |-
ls dist | xargs -I {} curl -F "package=@dist/{}" https://${{ secrets.FURY_TOKEN }}@push.fury.io/sagernet/
-192
View File
@@ -1,192 +0,0 @@
# On-demand builder — builds any artifact this repo knows how to build, from ANY branch.
# Lives on the default branch (lx) so `gh workflow run` can find it; each job checks
# out whatever `branch` you pass, so lx source is never required for the build.
#
# gh workflow run lx-build.yml -f target=android-aar -f branch=<branch>
# gh workflow run lx-build.yml -f target=apple-xcframework -f branch=<branch>
# gh workflow run lx-build.yml -f target=binary -f branch=<branch>
# gh workflow run lx-build.yml -f target=linux-musl -f branch=<branch>
# gh workflow run lx-build.yml -f target=all -f branch=<branch>
#
# Mirrors the build jobs in lx-release.yml but is manual-only and uploads each
# result as a workflow artifact instead of cutting a release.
name: lx build (on-demand)
on:
workflow_dispatch:
inputs:
target:
description: "What to build"
required: true
type: choice
default: android-aar
options:
- android-aar
- apple-xcframework
- binary
- linux-musl
- all
branch:
description: "Branch/tag of this repo to build from"
required: true
default: lx
jobs:
# ---- Android AAR (libbox.aar + libbox-legacy.aar) -------------------------
android-aar:
name: android-aar (${{ inputs.branch }})
if: ${{ inputs.target == 'android-aar' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with:
ndk-version: r28
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Ensure a describe-able tag
run: git tag -f "build-aar"
- name: Build (make lib_android)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Package
run: |
mkdir -p dist
cp libbox.aar dist/
cp libbox-legacy.aar dist/
- uses: actions/upload-artifact@v4
with:
name: android-aar-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Apple xcframework (Libbox.xcframework) ------------------------------
apple-xcframework:
name: apple-xcframework (${{ inputs.branch }})
if: ${{ inputs.target == 'apple-xcframework' || inputs.target == 'all' }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Ensure a describe-able tag
run: git tag -f "build-apple"
- name: Build (make lib_apple)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_apple
- name: Package
run: |
mkdir -p dist
# xcframework is a directory bundle — zip it for the artifact.
ditto -c -k --sequesterRsrc --keepParent Libbox.xcframework dist/Libbox.xcframework.zip
- uses: actions/upload-artifact@v4
with:
name: apple-xcframework-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Drop-in sing-box binary (host: linux/amd64) -------------------------
binary:
name: binary linux/amd64 (${{ inputs.branch }})
if: ${{ inputs.target == 'binary' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Ensure a describe-able tag
run: git tag -f "build-binary"
- name: Build (Makefile.lx lx-build)
run: make -f Makefile.lx lx-build LX_OUTPUT=sing-box
- name: Package
run: |
mkdir -p dist
cp sing-box dist/sing-box-linux-amd64
- uses: actions/upload-artifact@v4
with:
name: binary-linux-amd64-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Static musl router binary (linux/amd64) -----------------------------
# Mirrors build_linux_musl in lx-release.yml: cronet-go + Chromium musl
# toolchain, CGO_ENABLED=1, with_purego swapped to with_musl.
linux-musl:
name: linux-musl amd64 (${{ inputs.branch }})
if: ${{ inputs.target == 'linux-musl' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Clone cronet-go (musl toolchain provider)
run: |
CRONET_REF="$(cat .github/CRONET_GO_VERSION 2>/dev/null || echo main)"
git clone --depth=1 --branch "$CRONET_REF" https://github.com/sagernet/cronet-go ~/cronet-go \
|| git clone --depth=1 https://github.com/sagernet/cronet-go ~/cronet-go
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Download Chromium musl toolchain
run: |
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/amd64 --libc=musl download-toolchain
go run ./cmd/build-naive --target=linux/amd64 --libc=musl env >> "$GITHUB_ENV"
- name: Ensure a describe-able tag
run: git tag -f "build-musl"
- name: Build (musl + naive, static)
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: "1"
run: |
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
go build -v -trimpath -tags "$TAGS" \
-ldflags "-X github.com/sagernet/sing-box/constant.Version=$(git describe --tags --always) -checklinkname=0 -s -w -buildid=" \
-o sing-box-musl-amd64 ./cmd/sing-box
if ldd sing-box-musl-amd64 2>&1 | grep -q 'libdl.so.2'; then
echo "FAIL: dynamic libdl reference present — not a static musl build"; exit 1
fi
- name: Package
run: |
mkdir -p dist
cp sing-box-musl-amd64 dist/
- uses: actions/upload-artifact@v4
with:
name: linux-musl-amd64-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
-306
View File
@@ -1,306 +0,0 @@
name: lx-ci
# Fast per-commit gate for sing-box-lx. See docs-lx/lx-config.md and SPECS/004.
#
# Trigger policy (keep per-commit cost low — see SPECS/004 §2.2):
# * Doc-only commits (md/docs/SPECS/LICENSE) skip CI entirely (paths-ignore).
# * push / pull_request → only the cheap jobs:
# - `lint` : go vet (lx packages) + gofmt on lx-owned files
# - `build-check` : one native build (with_xhttp,with_awg) + sing-box check,
# plus a tagless baseline build for the negative check.
# * `cross` (6 platforms) and `android` (gomobile AAR) are HEAVY → they run only
# on a manual `workflow_dispatch` (Actions → lx-ci → Run workflow, or
# `gh workflow run lx-ci.yml --ref lx`). They are NEVER on push.
# * A release tag `v*-lx.*` builds all 6 desktop targets + both AARs and publishes
# them via lx-release.yml — so the full cross/AAR proof always runs before shipping.
# * Rapid successive pushes cancel superseded runs (concurrency).
on:
push:
branches: [lx]
paths-ignore: [ '**.md', 'docs/**', 'SPECS/**', 'LICENSE', '.gitignore' ]
pull_request:
branches: [lx]
paths-ignore: [ '**.md', 'docs/**', 'SPECS/**', 'LICENSE', '.gitignore' ]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: lx-ci-${{ github.ref }}
cancel-in-progress: true
env:
# Client feature set: upstream release/DEFAULT_BUILD_TAGS minus tailscale/ccm/ocm/acme
# (irrelevant to a VPN client), + with_purego for the CGO-free cross matrix. The two
# lx features (with_xhttp/with_awg) toggle on top. Mirrors Makefile.lx LX_TAGS minus
# the features. -checklinkname=0 is supplied by LX_LDFLAGS (jobs build via Makefile.lx).
BASE_TAGS: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0
jobs:
# ---- Cheap (runs on every push / PR) -------------------------------------
lint:
name: lint (vet + gofmt)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: go vet (lx packages, full tags)
run: |
# Two passes so -unsafeptr=false is scoped to the offenders only:
# upstream's TriggerDebugCrash/TriggerGoPanic (daemon/managed_service.go,
# experimental/libbox/debug.go — came in with the 1.14 merge) crash Go ON
# PURPOSE via *(*int)(unsafe.Pointer(uintptr(0)))=0, which trips vet's
# unsafeptr check. Those are upstream files we don't edit (CONSTITUTION
# §zero-diff), so only those two packages drop the analyzer; every other
# lx package keeps the full set.
go vet \
-tags "${BASE_TAGS},with_xhttp,with_awg,with_lx_command" \
./option/ ./constant/ ./transport/v2ray/ ./transport/v2rayxhttp/ \
./protocol/wireguard/ ./transport/wireguard/
go vet -unsafeptr=false \
-tags "${BASE_TAGS},with_xhttp,with_awg,with_lx_command" \
./daemon/ ./experimental/libbox/
- name: gofmt (lx-owned files only)
run: |
# Only our downstream files — never the whole upstream tree.
# Generated *.pb.go are excluded (machine output, not hand-edited).
files=$(git ls-files '*.go' | grep -E 'v2rayxhttp|_xhttp|_awg|_lx\.go|_command_lx' | grep -v '\.pb\.go' || true)
echo "checking:"; echo "$files"
if [ -n "$files" ]; then
bad=$(gofmt -l $files)
if [ -n "$bad" ]; then
echo "gofmt needs running on:"; echo "$bad"; exit 1
fi
fi
echo "OK: lx files are gofmt-clean"
build-check:
name: build-check (native build + check + negative)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # with_awg needs submodules/wireguard-go
fetch-depth: 0 # tags for the -lx version (git describe)
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Build (full lx — with_xhttp,with_awg,with_lx_command)
run: make -f Makefile.lx lx-build LX_TAGS="${BASE_TAGS},with_xhttp,with_awg,with_lx_command"
- name: Version
run: ./sing-box version
- name: Check feature configs (must pass)
run: |
./sing-box check -c lx-test/config/xhttp_reality.json
./sing-box check -c lx-test/config/awg2_basic.json
./sing-box check -c lx-test/config/awg2_ranged.json
- name: Build baseline (no lx features) for the negative check
run: make -f Makefile.lx lx-build LX_TAGS="${BASE_TAGS}" LX_OUTPUT=sing-box-baseline
- name: Baseline accepts a plain config
run: ./sing-box-baseline check -c lx-test/config/minimal.json
- name: Negative check (feature configs rejected without their tags)
run: |
set +e
./sing-box-baseline check -c lx-test/config/xhttp_reality.json; x=$?
./sing-box-baseline check -c lx-test/config/awg2_basic.json; a=$?
./sing-box-baseline check -c lx-test/config/awg2_ranged.json; r=$?
if [ $x -eq 0 ] || [ $a -eq 0 ] || [ $r -eq 0 ]; then
echo "FAIL: a feature config was accepted without its build tag"; exit 1
fi
echo "OK: xhttp/awg configs correctly rejected without their tags"
# SPEC 014 §3.6 pt.7 — prove both libbox command-protocol builds compile.
# The handler is gated by with_lx_command (real) / its absence (stub →
# codes.Unimplemented). Compile both daemon variants; the with-tag build must
# NOT carry the stub's "rebuild with -tags with_lx_command" string, the tagless
# build must. This is the cheap usbip-style proof that the seam toggles.
- name: lx_command — both builds compile (with + without the tag)
run: |
set -euo pipefail
go build -tags "${BASE_TAGS},with_lx_command" ./daemon/ ./experimental/libbox/
go build -tags "${BASE_TAGS}" ./daemon/ ./experimental/libbox/
echo "OK: daemon + libbox compile with and without with_lx_command"
- name: lx_command — stub string present in baseline, absent with the tag
run: |
set -euo pipefail
marker="rebuild with -tags with_lx_command"
if ! strings -a sing-box-baseline | grep -qF "$marker"; then
echo "FAIL: tagless baseline is missing the Unimplemented stub marker"; exit 1
fi
if strings -a sing-box | grep -qF "$marker"; then
echo "FAIL: full lx build still carries the Unimplemented stub marker"; exit 1
fi
echo "OK: with_lx_command toggles the handler (stub only in baseline)"
# ---- Heavy (manual workflow_dispatch only — never on push) ----------------
# Cross-platform: prove the merged AWG fork + XHTTP compile everywhere.
# On a release tag this is covered by lx-release.yml (all 6 desktop targets).
cross:
if: github.event_name == 'workflow_dispatch'
name: cross ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Cross-build (full lx)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
make -f Makefile.lx lx-build \
LX_TAGS="${BASE_TAGS},with_xhttp,with_awg" \
LX_OUTPUT="sing-box-${{ matrix.goos }}-${{ matrix.goarch }}"
- uses: actions/upload-artifact@v4
with:
name: sing-box-${{ matrix.goos }}-${{ matrix.goarch }}
path: sing-box-${{ matrix.goos }}-${{ matrix.goarch }}*
if-no-files-found: error
retention-days: 7
# Android libbox AAR: prove libbox builds with the lx features (gomobile).
# build_libbox bakes with_xhttp+with_awg into both AAR variants (see its lx: block).
# On a release tag this is covered by lx-release.yml (both AARs published).
android:
if: github.event_name == 'workflow_dispatch'
name: android libbox.aar
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with: { ndk-version: r28 }
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Build libbox.aar + libbox-legacy.aar
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- uses: actions/upload-artifact@v4
with:
name: libbox-aar
path: |
libbox.aar
libbox-legacy.aar
if-no-files-found: error
retention-days: 7
# Router musl builds: prove the static-musl + naive pipeline compiles and links
# statically (no libdl.so.2) for the router arches. Build + verify only, no
# publish — the release is lx-release.yml's build_linux_musl. Keep the toolchain
# steps here in sync with that job. See SPECS/006.
linux_musl:
if: github.event_name == 'workflow_dispatch'
name: linux-musl ${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-musl-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium musl toolchain
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set Chromium toolchain environment
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> "$GITHUB_ENV"
- name: Build (musl + naive, static)
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
run: |
set -xeuo pipefail
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
go build -v -trimpath -tags "$TAGS" \
-ldflags "-checklinkname=0 -s -w -buildid=" \
-o "sing-box-${{ matrix.asset }}" ./cmd/sing-box
- name: Verify static (no libdl)
run: |
set -xeuo pipefail
file "sing-box-${{ matrix.asset }}"
file "sing-box-${{ matrix.asset }}" | grep -q "statically linked"
if strings -a "sing-box-${{ matrix.asset }}" | grep -q "libdl.so.2"; then
echo "FAIL: libdl.so.2 reference present — not a static musl build"; exit 1
fi
echo "OK: statically linked, no libdl.so.2"
- uses: actions/upload-artifact@v4
with:
name: sing-box-${{ matrix.asset }}
path: sing-box-${{ matrix.asset }}
if-no-files-found: error
retention-days: 7
@@ -1,117 +0,0 @@
name: lx-musl-toolchain-mirror
# Builds the Chromium musl toolchain (clang + gn + pgo + per-arch Debian sysroot)
# once and uploads it as a release asset in the `musl-toolchain-cache` release, so
# lx-release.yml can restore it on an actions/cache miss instead of depending on
# snapshot.debian.org (which intermittently 503s and blocks releases). See SPEC 023.
#
# Run this MANUALLY (workflow_dispatch) whenever .github/CRONET_GO_VERSION changes —
# the asset name embeds the cronet-go version, so a stale mirror simply misses and
# the release falls back to snapshot.debian.org until this is re-run.
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build_mirror:
name: mirror linux-musl/${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
# Same toolchain-fetch sequence as lx-release.yml build_linux_musl, so the
# produced tree matches exactly what the release job expects to restore.
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
n=0; max=4; delay=20
until GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "keyring regen failed after $max attempts"; exit 1
fi
echo "keyring regen attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Download Chromium musl toolchain (from snapshot.debian.org)
run: |
set -xeuo pipefail
cd ~/cronet-go
# This is the ONE place the mirror producer depends on snapshot.debian.org.
# Retry generously; run this workflow when snapshot.debian.org is healthy.
n=0; max=8; delay=30
until go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "toolchain download failed after $max attempts (snapshot.debian.org may be down — retry later)"; exit 1
fi
echo "toolchain download attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Pack toolchain
id: pack
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
ASSET="toolchain-${{ matrix.arch }}-${CRONET_GO_VERSION}.tar.zst"
SRC=~/cronet-go/naiveproxy/src
# Archive rooted at naiveproxy/src so lx-release.yml restores with a plain
# `tar -C <src> -xf`. Only pack dirs that exist (pgo_profiles may be absent).
DIRS=""
for d in third_party/llvm-build gn/out chrome/build/pgo_profiles out/sysroot-build; do
[ -d "$SRC/$d" ] && DIRS="$DIRS $d"
done
tar --zstd -C "$SRC" -cf "$ASSET" $DIRS
ls -lh "$ASSET"
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
- name: Ensure mirror release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
gh release view musl-toolchain-cache --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
gh release create musl-toolchain-cache --repo "$GITHUB_REPOSITORY" \
--prerelease --title "musl toolchain cache" \
--notes "Prebuilt Chromium musl toolchains for lx-release (SPEC 023). Not a software release — do not use as 'Latest'."
- name: Upload toolchain asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
gh release upload musl-toolchain-cache "${{ steps.pack.outputs.asset }}" \
--repo "$GITHUB_REPOSITORY" --clobber
-162
View File
@@ -1,162 +0,0 @@
name: lx-rebase
# Auto-rebase the lx feature commits onto the newest STABLE upstream sing-box tag.
# See SPECS/004 §2.3. Outcomes:
# * already based on the newest stable tag → no-op (logs "up to date").
# * clean rebase that still builds + `sing-box check` passes → push lx-rebase/<tag> + open a PR.
# * rebase conflict OR build/check fails → open an issue listing the // lx seams to fix.
# NEVER force-pushes `lx` — a human reviews the PR and updates `lx` themselves.
#
# Requirements:
# * Settings → Actions → General → Workflow permissions = "Read and write".
# * For the auto-PR step: enable "Allow GitHub Actions to create and approve pull requests".
# (If it's off, the workflow falls back to an issue pointing at the ready branch.)
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch:
inputs:
tag:
description: 'Upstream tag to rebase onto (blank = newest stable)'
required: false
default: ''
permissions:
contents: write
pull-requests: write
issues: write
env:
UPSTREAM: https://github.com/SagerNet/sing-box.git
jobs:
rebase:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: lx
fetch-depth: 0
submodules: recursive
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Configure git + upstream
run: |
git config user.name "lx-rebase-bot"
git config user.email "247031499+Leadaxe@users.noreply.github.com"
git remote add upstream "$UPSTREAM" 2>/dev/null || git remote set-url upstream "$UPSTREAM"
git fetch --tags --quiet upstream
- name: Pick target tag
id: pick
run: |
INPUT="${{ github.event.inputs.tag }}"
if [ -n "$INPUT" ]; then
TARGET="$INPUT"
else
# newest STABLE upstream tag: vMAJOR.MINOR.PATCH only (excludes -alpha/-beta/-rc and our -lx.N)
TARGET=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if [ -z "$TARGET" ]; then echo "No stable upstream tag found"; exit 1; fi
if ! git rev-parse -q --verify "refs/tags/$TARGET" >/dev/null; then
echo "Tag $TARGET does not exist after fetch"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "Target upstream tag: $TARGET"
- name: Up to date?
id: check
run: |
TARGET="${{ steps.pick.outputs.target }}"
if git merge-base --is-ancestor "$TARGET" HEAD; then
echo "uptodate=true" >> "$GITHUB_OUTPUT"
echo "::notice::lx already contains $TARGET — nothing to rebase."
else
echo "uptodate=false" >> "$GITHUB_OUTPUT"
fi
- name: Attempt rebase
id: rebase
if: steps.check.outputs.uptodate == 'false'
run: |
TARGET="${{ steps.pick.outputs.target }}"
BRANCH="lx-rebase/${TARGET}"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
git checkout -b "$BRANCH"
if git rebase "$TARGET"; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "::notice::clean rebase onto $TARGET"
else
git diff --name-only --diff-filter=U > /tmp/conflicts.txt || true
git rebase --abort || true
echo "result=conflict" >> "$GITHUB_OUTPUT"
echo "::warning::rebase conflict onto $TARGET"
fi
- name: Build + check (clean rebase only)
id: verify
if: steps.rebase.outputs.result == 'clean'
run: |
git submodule update --init --recursive
if make -f Makefile.lx lx-build \
&& ./sing-box check -c lx-test/config/xhttp_reality.json \
&& ./sing-box check -c lx-test/config/awg2_basic.json; then
echo "build=pass" >> "$GITHUB_OUTPUT"
else
echo "build=fail" >> "$GITHUB_OUTPUT"
fi
- name: Push branch + open PR (clean + builds)
if: steps.rebase.outputs.result == 'clean' && steps.verify.outputs.build == 'pass'
env:
GH_TOKEN: ${{ github.token }}
run: |
TARGET="${{ steps.pick.outputs.target }}"
BRANCH="${{ steps.rebase.outputs.branch }}"
git push -f origin "$BRANCH"
cat > /tmp/pr.md <<EOF
Automated rebase of the lx feature commits onto upstream \`$TARGET\`.
- clean rebase (no conflicts)
- \`make -f Makefile.lx lx-build\` succeeds
- \`sing-box check\` passes for XHTTP + AWG2 sample configs
Review the \`// lx\` seams (\`grep -rn "// lx"\`), then update \`lx\` (rebase / fast-forward) **manually** — this workflow never force-pushes \`lx\`.
EOF
if gh pr create --base lx --head "$BRANCH" \
--title "lx-rebase: onto upstream $TARGET" --body-file /tmp/pr.md; then
echo "::notice::PR opened for $BRANCH"
else
# auto-PR likely blocked (the "Allow Actions to create PRs" toggle is off) — fall back to an issue
gh issue create --title "lx-rebase: branch ready for $TARGET (open PR manually)" \
--body "Branch \`$BRANCH\` is rebased onto \`$TARGET\`, builds, and passes \`check\`. Auto-PR was blocked — enable Settings → Actions → General → \"Allow GitHub Actions to create and approve pull requests\", or open the PR by hand."
fi
- name: Open issue (conflict or build failure)
if: steps.rebase.outputs.result == 'conflict' || steps.verify.outputs.build == 'fail'
env:
GH_TOKEN: ${{ github.token }}
run: |
TARGET="${{ steps.pick.outputs.target }}"
if [ "${{ steps.rebase.outputs.result }}" = "conflict" ]; then
REASON="rebase conflict"
else
REASON="build/check failed after a clean rebase (likely a semantic conflict)"
fi
{
echo "Automated rebase onto upstream \`$TARGET\` needs manual attention: **$REASON**."
echo
if [ -s /tmp/conflicts.txt ]; then
echo "Conflicted files:"; echo '```'; cat /tmp/conflicts.txt; echo '```'; echo
fi
echo "Resolve locally:"
echo '```'
echo "git fetch upstream --tags"
echo "git checkout -b lx-rebase/$TARGET lx && git rebase $TARGET"
echo "# fix the // lx: seams, then: git push origin lx-rebase/$TARGET and open a PR into lx"
echo '```'
} > /tmp/issue.md
gh issue create --title "lx-rebase: needs manual attention for $TARGET" --body-file /tmp/issue.md
-474
View File
@@ -1,474 +0,0 @@
name: lx-release
# Cross-builds the drop-in `sing-box` binary for all platforms and publishes a
# GitHub Release with archives + checksums. Triggered by pushing a tag like
# v1.13.13-lx.1, or manually via workflow_dispatch. See SPECS/004.
on:
push:
tags: ['v*-lx.*']
workflow_dispatch:
inputs:
tag:
description: 'Release tag, e.g. v1.13.13-lx.1 (created at the current ref if missing)'
required: true
permissions:
contents: write
# Build tags are owned by Makefile.lx (single source of truth). The desktop/CLI build
# uses its LX_TAGS default (which KEEPS with_clash_api — CLI binaries are driven by
# external dashboards over the Clash REST API); the Android AAR uses build_libbox's own
# tag set (which DROPS with_clash_api — LxBox uses the native CommandClient). The two
# sets diverge by design. `make -f Makefile.lx -s lx-print-tags` prints the desktop set
# for the release notes so nothing is duplicated here.
jobs:
build:
name: build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Linux lives in the build_linux_musl job (static musl + naive, for
# routers). See SPECS/006. This job covers the purego/native targets
# where libdl is a non-issue — plus targets cronet can't reach at all
# (no_naive: pure-Go static, naive/purego dropped).
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: windows, goarch: amd64, ext: .exe }
- { goos: windows, goarch: arm64, ext: .exe }
# Windows 7 (32-bit): built with a Win7-patched Go, and without
# with_naive_outbound (cronet-go has no windows/386 build). See SPECS/004.
- { goos: windows, goarch: "386", ext: .exe, legacy_win7: true, legacy_name: windows-7 }
# Big-endian MIPS routers (OpenWrt mips_24kc, e.g. Atheros AR93xx) — issue #6.
# Chromium has no big-endian MIPS toolchain, so the musl/naive path is
# impossible here; a pure-Go CGO_ENABLED=0 build is statically linked anyway
# (runs on musl), it just drops naive/cronet (with_purego doesn't compile on
# mips either — purego has no mips port).
- { goos: linux, goarch: mips, gomips: softfloat, no_naive: true }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
if: ${{ ! matrix.legacy_win7 }}
with:
go-version-file: go.mod
check-latest: true
# Win7 needs a Go toolchain that still targets Windows 7: setup_go_for_windows7.sh
# fetches stock Go and applies MetaCubeX/go patches reverting the Win7 removals.
- name: Cache Win7 Go toolchain
if: matrix.legacy_win7
id: cache-go-win7
uses: actions/cache@v4
with:
path: ~/go/go_win7
key: go_win7_${{ hashFiles('.github/setup_go_for_windows7.sh') }}
- name: Build Win7 Go toolchain
if: matrix.legacy_win7 && steps.cache-go-win7.outputs.cache-hit != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash .github/setup_go_for_windows7.sh
- name: Use Win7 Go toolchain
if: matrix.legacy_win7
run: |
echo "PATH=$HOME/go/go_win7/bin:$PATH" >> "$GITHUB_ENV"
echo "GOROOT=$HOME/go/go_win7" >> "$GITHUB_ENV"
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOMIPS: ${{ matrix.gomips }}
run: |
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
if [ "${{ matrix.legacy_win7 }}" = "true" ]; then
# cronet-go (with_naive_outbound) has no windows/386 build — drop it.
TAGS="${TAGS/with_naive_outbound,/}"
fi
if [ "${{ matrix.no_naive }}" = "true" ]; then
# No cronet for this target at all: drop naive AND its purego loader.
TAGS="${TAGS/with_naive_outbound,/}"
TAGS="${TAGS/with_purego,/}"
fi
make -f Makefile.lx lx-build \
LX_TAGS="$TAGS" \
LX_VERSION="${{ steps.ver.outputs.version }}" \
LX_OUTPUT="sing-box${{ matrix.ext }}"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ -n "${{ matrix.gomips }}" ]; then
NAME="${NAME}-${{ matrix.gomips }}"
fi
if [ -n "${{ matrix.legacy_name }}" ]; then
NAME="${NAME}-legacy-${{ matrix.legacy_name }}"
fi
mkdir -p "stage/$NAME" dist
cp "sing-box${{ matrix.ext }}" "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd stage && zip -qr "../dist/$NAME.zip" "$NAME")
else
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
# Static musl Linux builds for routers (AsusWRT Merlin, OpenWrt, Keenetic).
# The desktop `build` job ships Linux via with_purego, which pulls a dynamic
# libdl.so.2 dependency (purego's //go:cgo_import_dynamic) and won't load on
# musl. Here we mirror upstream build.yml: clone cronet-go, fetch the Chromium
# musl toolchain via its cmd/build-naive, and build CGO_ENABLED=1 with
# `with_musl` — libcronet.a is linked statically and naive is preserved.
# See SPECS/006.
build_linux_musl:
name: build linux-musl/${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# cronet-go carries the build-naive tool + the naiveproxy/src Chromium
# toolchain sources. Pin to the same commit go.mod depends on.
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
# generate_keyring.sh fetches Debian archive keys from keyservers,
# which can also be flaky — retry with backoff. (lx CI)
n=0; max=4; delay=20
until GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "keyring regen failed after $max attempts"; exit 1
fi
echo "keyring regen attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Cache Chromium toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-musl-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
# Second source, between actions/cache and snapshot.debian.org: our own
# durable mirror (release `musl-toolchain-cache`, produced by
# lx-musl-toolchain-mirror.yml). Restores on an actions/cache miss —
# including the common ref-scoping miss where a tag build can't see another
# tag's cache — so a snapshot.debian.org outage no longer blocks releases.
# SPEC 023. If the mirror also misses, the download step below falls back to
# snapshot.debian.org exactly as before.
- name: Restore musl toolchain from lx mirror
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
SRC=~/cronet-go/naiveproxy/src
# actions/cache hit already populated the tree — nothing to do.
if [ -d "$SRC/out/sysroot-build" ] && [ -d "$SRC/third_party/llvm-build" ]; then
echo "actions/cache hit — skipping lx mirror"; exit 0
fi
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
ASSET="toolchain-${{ matrix.arch }}-${CRONET_GO_VERSION}.tar.zst"
if gh release download musl-toolchain-cache --repo "$GITHUB_REPOSITORY" \
--pattern "$ASSET" --dir /tmp/lxtc 2>/dev/null; then
tar --zstd -C "$SRC" -xf "/tmp/lxtc/$ASSET"
echo "restored toolchain from lx mirror ($ASSET)"
else
echo "lx mirror miss ($ASSET) — falling back to snapshot.debian.org"
fi
- name: Download Chromium musl toolchain
run: |
set -xeuo pipefail
cd ~/cronet-go
# The toolchain download pulls sysroot .deb packages from
# snapshot.debian.org, which intermittently 503s ("No healthy
# backends"). get-clang.sh already retries internally but back-to-back,
# so a whole outage window fails all attempts. Retry the step with a
# growing backoff to ride out a transient mirror outage. (lx CI)
n=0; max=5; delay=30
until go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "toolchain download failed after $max attempts"; exit 1
fi
echo "toolchain download attempt $n failed; retrying in ${delay}s (snapshot.debian.org may be flaky)"
sleep "$delay"; delay=$((delay*2))
done
- name: Set Chromium toolchain environment
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> "$GITHUB_ENV"
- name: Build (musl + naive, static)
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
run: |
set -xeuo pipefail
# LX_TAGS is the single source of truth (Makefile.lx). Swap the purego
# cronet loader for the static musl one; with_naive_outbound stays.
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
mkdir -p dist
go build -v -trimpath -tags "$TAGS" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ steps.ver.outputs.version }}' -checklinkname=0 -s -w -buildid=" \
-o dist/sing-box ./cmd/sing-box
- name: Verify static (no libdl)
run: |
set -xeuo pipefail
file dist/sing-box
file dist/sing-box | grep -q "statically linked"
if strings -a dist/sing-box | grep -q "libdl.so.2"; then
echo "FAIL: libdl.so.2 reference present — not a static musl build"; exit 1
fi
echo "OK: statically linked, no libdl.so.2"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.asset }}"
mkdir -p "stage/$NAME"
cp dist/sing-box "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.asset }}
path: dist/*.tar.gz
if-no-files-found: error
build_android:
name: build android (libbox.aar)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with:
ndk-version: r28
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# build_libbox stamps Libbox.version() from `git describe` — ensure the tag exists.
git tag "$TAG" -f
- name: Build libbox.aar (with_xhttp + with_awg baked in by build_libbox)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Package AARs
run: |
mkdir -p dist
V="${{ steps.ver.outputs.version }}"
cp libbox.aar "dist/libbox-$V.aar"
cp libbox-legacy.aar "dist/libbox-legacy-$V.aar"
- uses: actions/upload-artifact@v4
with:
name: dist-android
path: dist/*
if-no-files-found: error
release:
name: publish release
needs: [build, build_linux_musl, build_android]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Resolve tag
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# Derive the upstream base version for the release notes instead of hardcoding it
# (it drifted to alpha.35 across rc.14/15/16 and had to be hand-fixed each time).
# Primary source: the nearest reachable upstream alpha tag in HEAD's ancestry
# (`git describe`). This reads the commit GRAPH, not commit-message text, so it's
# correct even when a merge subject omits the alpha number — e.g. alpha.37 was
# merged in a commit titled "Merge upstream/testing (bump version, fix linux ping)"
# with no "alpha.37" in it, which the subject-grep fallback misses (shipped rc.17/18
# notes as alpha.36). Fallback: scan "Merge upstream" subjects for the highest
# alpha.NN. Last resort: a generic label so notes never carry a stale hardcoded version.
#
# NOTE: actions/checkout only fetches THIS repo's tags, not upstream's. The
# v1.14.0-alpha.* tags are SagerNet/sing-box tags, so without this fetch `git
# describe` finds nothing in CI and silently drops to the weaker subject-grep (which
# is why CI kept resolving alpha.36 while a local clone with upstream tags gets 37).
# Fetch the upstream alpha tags first so the primary `git describe` path works.
git remote add upstream https://github.com/SagerNet/sing-box.git 2>/dev/null || true
git fetch --no-tags upstream 'refs/tags/v1.14.0-alpha.*:refs/tags/v1.14.0-alpha.*' 2>/dev/null || true
BASE="$(git describe --tags --match 'v1.14.0-alpha.*' --abbrev=0 HEAD 2>/dev/null || true)"
if [ -z "$BASE" ]; then
BASE_N="$(git log --grep='Merge upstream' --pretty=%s | grep -oE 'alpha\.[0-9]+' | sed 's/alpha\.//' | sort -n | tail -1)"
[ -n "$BASE_N" ] && BASE="v1.14.0-alpha.${BASE_N}"
fi
[ -z "$BASE" ] && BASE="v1.14.x"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "Resolved upstream base: $BASE"
- name: Checksums
run: (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS)
- name: Release notes
run: |
# What's-new for THIS tag comes from docs-lx/lx-changelog.md (single source of
# truth, kept per release) — extract the section for this version, between its
# "#### vX" header and the next "#### ", into a SEPARATE file. It must NOT be
# interpolated through the heredoc below: changelog prose contains backticks and
# $(...) that the shell would execute (command injection from doc text). We splice
# the file in with sed after the heredoc instead.
VERSION="${{ steps.ver.outputs.version }}"
awk -v v="#### v${VERSION}" '
$0==v {f=1; next} /^#### / {f=0} f' docs-lx/lx-changelog.md > changes.md
if [ -z "$(tr -d '[:space:]' < changes.md)" ]; then
echo "See [docs-lx/lx-changelog.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-changelog.md)." > changes.md
fi
cat > notes.md <<EOF
**sing-box-lx ${{ steps.ver.outputs.version }}** — a thin downstream of [sing-box](https://github.com/SagerNet/sing-box) (base **${{ steps.ver.outputs.base }}**, branch \`lx\`).
### What's new in this release
__CHANGES__
### Standing features
- **AmneziaWG 2.0** (\`with_awg\`) — \`wireguard\` endpoint with \`jc/jmin/jmax\`, \`s1\`–\`s4\`, \`h1\`–\`h4\`, \`i1\`–\`i5\`.
- **XHTTP** transport (\`with_xhttp\`) — Xray-compatible "splithttp", composes with Reality (use \`auto\`; \`stream-one\` has a known framing bug).
- **CommandClient extensions** (\`with_lx_command\`) — native libbox gRPC parity for the Clash API dropped from the **Android AAR**: URLTestOutbound, GetRules, GetGroups/GetOutbounds, Connection.Detour, SubscribeDNSQueries. (Desktop/CLI binaries keep \`with_clash_api\` for external dashboards.)
### Binaries
Drop-in \`sing-box\` for **darwin / windows** × {amd64, arm64}, plus a **Windows 7 (32-bit)** legacy build (\`sing-box-${{ steps.ver.outputs.version }}-windows-386-legacy-windows-7.zip\` — built with a Win7-patched Go; without naive/cronet, which has no windows/386 target).
**Linux — static musl builds for routers** (AsusWRT Merlin, OpenWrt, Keenetic): \`linux-amd64\`, \`linux-arm64\`, \`linux-armv7\`, \`linux-mipsle-softfloat\`. These are statically linked (no \`libdl.so.2\`/glibc dependency) and **keep NaïveProxy** — they run on musl routers where the previous dynamic builds failed with \`libdl.so.2: cannot open shared object file\`. See SPECS/006.
**Linux — big-endian MIPS** (OpenWrt \`mips_24kc\`, e.g. Atheros AR93xx): \`linux-mips-softfloat\` — pure-Go static build **without NaïveProxy** (Chromium/cronet has no big-endian MIPS toolchain); everything else matches the desktop tag set.
Each archive contains the \`sing-box\` binary (\`sing-box version\` reports \`${{ steps.ver.outputs.version }}\`). Verify downloads against \`SHA256SUMS\`.
### Android
\`libbox-${{ steps.ver.outputs.version }}.aar\` (+ \`libbox-legacy-…\` for SDK 21) — gomobile build of \`experimental/libbox\` with \`with_xhttp\`+\`with_awg\` enabled, for embedding in an Android app. \`Libbox.version()\` reports the lx version.
### Build tags
Desktop binary: \`$(make -f Makefile.lx -s lx-print-tags)\`
Config reference: [docs-lx/lx-config.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-config.md).
EOF
# Splice the changelog section in place of the placeholder. `sed r` inserts the
# file verbatim (no shell/sed interpretation of its contents), so backticks and
# $(...) in the prose are inert. Then drop the placeholder line itself. Written
# via a temp file (not sed -i, whose flag syntax differs BSD vs GNU).
sed -e '/__CHANGES__/r changes.md' -e '/__CHANGES__/d' notes.md > notes.final.md
mv notes.final.md notes.md
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
# Pre-release tags (-rc.N / -alpha.N / -beta.N) publish as GitHub
# pre-releases so an unverified build never becomes "Latest". A plain
# vX.Y.Z-lx.N tag (no such suffix) publishes as a normal release.
EXTRA=""
case "${{ steps.ver.outputs.tag }}" in
*-rc.*|*-alpha.*|*-beta.*) EXTRA="--prerelease" ;;
esac
# --repo is REQUIRED: the base-version step adds an `upstream` remote
# (SagerNet/sing-box) so git-describe can see the alpha tags, and without
# --repo `gh` resolves the target repo from the remotes and picks upstream
# → HTTP 403 (the token has no rights there). Pin it to this repo.
gh release create "${{ steps.ver.outputs.tag }}" dist/* \
--repo "${{ github.repository }}" \
--title "sing-box-lx ${{ steps.ver.outputs.version }}" \
--notes-file notes.md \
--target "$GITHUB_SHA" \
$EXTRA
-16
View File
@@ -1,16 +0,0 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 5 days'
days-before-stale: 60
days-before-close: 5
exempt-issue-labels: 'bug,enhancement'
-55
View File
@@ -1,55 +0,0 @@
name: Test
on:
push:
branches:
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
go:
- ~1.24
- ~1.25
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Set build tags and ldflags
shell: bash
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
- name: Test (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
+2 -60
View File
@@ -1,4 +1,3 @@
# -- shater overlay -----------------------------------------------
# testbed downloads / VM images / SDKs (agent-managed, large, machine-local)
testbed/
*.img
@@ -14,12 +13,6 @@ testbed/
*.apk
bin/
node_modules/
out/
out-apk/
# CI caches (SDK tarballs, dl/ sources, apt archives, prebuilt tools) —
# persisted between runs by actions/cache, never committed
.cache/
# editor / os
.DS_Store
@@ -28,63 +21,12 @@ Thumbs.db
# go build artifacts
*.exe
/package/xrayctl/xrayctl
# windows reserved-name junk from stray > NUL redirects
NUL
nul
out/
# playwright MCP screenshots/snapshots
.playwright-mcp/
# working-session screenshots dropped in the repo root (not shipped docs)
/*.png
# throwaway build binaries / scratch staged under tmp/
/tmp/
# -- upstream sing-box-lx -----------------------------------------
/.idea/
.idea/
/vendor/
/*.json
/*.srs
/*.db
/site/
/build/
/*.jar
/*.aar
/*.xcframework/
/experimental/libbox/*.aar
/experimental/libbox/*.xcframework/
/experimental/libbox/*.nupkg
/sing-box
/sing-box.exe
/config.d/
/venv/
/test/cache.db
# feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
/dist/
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
/.claude/
# panel SPA embedded into shaterd: build copies panel/dist/* into
# shater/panel/webroot/; those artifacts are gitignored, the .gitkeep marker
# (which keeps the dir embeddable when the SPA isn't built) stays tracked.
/shater/panel/webroot/*
!/shater/panel/webroot/.gitkeep
# prebuilt shaterd binaries staged for the openwrt/shaterd package by
# scripts/build-shaterd.sh — release artifacts, not source. The .gitkeep marker
# (which keeps the dir present so the package build can stage into it) stays tracked.
/openwrt/shaterd/files/shaterd-*
!/openwrt/shaterd/files/.gitkeep
# throwaway VM traffic generator (never shipped)
shater/cmd/trafgen/
errors.log
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
-16
View File
@@ -1,16 +0,0 @@
[submodule "clients/apple"]
path = clients/apple
url = https://github.com/SagerNet/sing-box-for-apple.git
[submodule "clients/android"]
path = clients/android
url = https://github.com/SagerNet/sing-box-for-android.git
[submodule "submodules/wireguard-go"]
path = submodules/wireguard-go
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
# The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
# conn/bind_std.go at all, so a `git submodule update --remote` against it
# would silently restore the bug where ClientBind/StdNetBind shred the
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
# the line the pin is actually on.
branch = lx-awg2-v005
-55
View File
@@ -1,55 +0,0 @@
version: "2"
run:
go: "1.24"
build-tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
- with_ccm
- with_ocm
- badlinkname
- tfogo_checklinkname0
linters:
default: none
enable:
- ineffassign
- staticcheck
- unused
- modernize
settings:
modernize:
disable:
- omitzero # nested struct omitempty -> omitzero changes JSON output semantics
staticcheck:
checks:
- all
- -QF1008 # could remove embedded field "<interface>" from selector
- -ST1003 # should not use ALL_CAPS in Go names; use CamelCase instead
- -QF1001 # could apply De Morgan's law
exclusions:
generated: lax
presets:
- comments
- common-false-positives
paths:
- transport/simple-obfs
- \.pb\.go$
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofumpt
settings:
gci:
sections:
- standard
- prefix(github.com/sagernet/)
- default
custom-order: true
+85
View File
@@ -0,0 +1,85 @@
# Проект: OpenWrt-плагин для XRAY — сводка и план (anchor)
Индекс: [01-аудит роутера](01-mini_router-audit.md) · [02-дизайн](02-xray-plugin-design.md)
· [03-фичи и модель](03-features-and-model.md) · [04-ops/надёжность/сборка/статы](04-ops-reliability-build-stats.md)
## Видение (одной строкой)
Переносимый OpenWrt-плагин управления XRAY: подписки → ноды → цепочки → правила →
выбор egress, с прозрачным роутингом, железной надёжностью и удобным UI.
Ориентир — passwall2, но **чище, быстрее, без лагов и спагетти**.
## Дифференциаторы (почему «круче passwall»)
1. Multi-hop цепочки (L1→L2→L3) с посубскрипшн-слоями (твой xray_chain — из коробки).
2. Subscription-driven балансер + observatory с авто-выпилом мёртвых нод.
3. **Выбор egress first-class** (правило/цепочка → любой iface/туннель/цепочка/direct).
4. Современные транспорты (reality/xhttp/vision) как первый класс.
5. Прозрачность: «откуда→куда→как→почему» (explain/trace), видимые метки/таблицы.
6. Один процесс xray + nftables fast-path → без лагов passwall.
## Locked-решения
- Движок: **только xray-core** (не форкаем; xrayctl = отдельный control-plane; sing-box — потом).
- Генератор: **Go-компаньон `xrayctl`** (парсинг share-links через libXray, валидация `xray -test`).
- MVP: **solid single transparent-proxy сначала**; цепочки/per-client — фаза 2.
- Тест-стенд: **Docker/QEMU на ПК** (QEMU для netfilter/tproxy, Docker/WSL2 для сборки+логики).
- Сборка: **OpenWrt + ImmortalWrt/BananaWRT**, версии 24.10(ipk)+25.x(apk), мультиарк, CI+фид.
- Инбаунд: **только TPROXY**, но **мульти-LAN** (несколько LAN-сетей/интерфейсов, явное указание подсети).
- DNS: **оба режима переключаемо** (nftset-split дефолт + FakeIP опция), **полностью настраиваемый**
(named resolvers, DNS-routing per-domain/client, per-rule DNS, detour).
- Kill-switch: **per-policy, глобальный дефолт fail-closed**.
- Гео-данные: **скачивать по требованию**.
- Правила: source subnet/host/mac/iface **+ dest domain/domain-list + ip/ip-list + geosite/geoip + port/l4**.
- Reusable **Lists/Rulesets** (domain/ip; источники inline/файл/URL-auto) — объект №8 модели.
## Консолидированные требования (из всего брейншторма)
**Модель (7 объектов):** Node, Group(+balancer), Chain, Egress/Outlet, Inbound, Rule, Profile(WAN-mode).
**Правила:** матч source по CIDR/хост (`192.168.11.14/32`)/MAC/iface/зона; dest domain/geo/ip/port/l4;
target chain|group|node|direct|block + egress. Per-client policy. Explain/trace «откуда→куда→как→почему».
**Egress:** выбор физ-WAN / туннеля(awg) / цепочки / direct, и комбо.
**Надёжность (топ-приоритет):** атомарный apply, свои метки/таблицы (не трогаем fw4), идемпотентный
реконсайл, персист через hotplug, rollback + commit-confirm, fail-safe mgmt-bypass, watchdog.
**DNS:** DoH + hijack :53 + блок DoT/DoH + сплит + опц. FakeIP; IPv6.
**Статистика/дашборд:** per-proxy трафик (xray Stats API), per-rule (nft counters), live-пинг
(observatory), история (collectd/RRD), дашборд в LuCI (throughput/ноды/правила/health/trace).
**Переносимость:** без хардкод iface/IP; детект WAN; всё через UCI.
**Дистрибуция:** подписанный фид, self-update, one-line установка.
## Non-goals (чтобы не утонуть как passwall)
- НЕ форкаем xray. НЕ поддерживаем сразу sing-box/mihomo. НЕ тащим китайские gfwlist/chnroute-специфики.
- НЕ per-node процессы. НЕ legacy iptables. MVP НЕ включает цепочки/FakeIP/расписания (фаза 2+).
## Как грамотно строить план (методология)
1. **Contract-first.** Сначала зафиксировать «контракт»: UCI-схему `/etc/config/shater` +
интерфейс `xrayctl` (CLI+ubus). От модели зависит ВСЁ (генератор, фаервол, UI, тесты) —
это корень зависимостей, начинаем с него.
2. **Walking skeleton (вертикальный срез).** Тончайший сквозной путь, доказывающий
архитектуру: 1 подписка → parse → build → 1 tproxy-outbound → трафик идёт в QEMU →
виден в минимальном статусе. Сначала де-рискуем ТЯЖЁЛОЕ (tproxy/routing/надёжность),
а не строим UI на шатком фундаменте (надёжность — топ-требование).
3. **Итерации по тирам**, каждая — шипабельна и с тест-гейтом (definition of done + прогон в QEMU).
4. **Артефакты плана:** этот бэклог (по модели+тирам), risk-register, decision-log, тест-стратегия по фазам.
## План по фазам (с критериями выхода)
- **Ф0 — Контракт & каркас.** UCI-схема + `xrayctl` интерфейс (спека) + скелет репозитория
(xrayctl/, luci-app/, CI-заготовка) + QEMU-стенд поднят.
*Выход:* спека утверждена, QEMU-OpenWrt с тест-клиентом пингуется, CI собирает пустой пакет.
- **Ф1a — xrayctl ядро.** fetch/parse(libXray)/build/test/apply, чтение UCI. Юнит-тесты (где угодно).
*Выход:* из тест-подписки генерится валидный xray JSON, `xray -test` ок.
- **Ф1b — Data-plane (надёжность).** procd + своя nft-таблица (tproxy TCP+UDP) + policy-routing
+ hotplug-персист + атомарный apply/rollback + mgmt-bypass. DNS: DoH+hijack+базовый сплит.
*Выход:* в QEMU LAN-клиент выходит через прокси, exit-IP меняется, reboot/`network reload`
не ломает; rollback работает; связь не теряется.
- **Ф1c — LuCI MVP.** Ноды(живой пинг)/подписки/настройки/статус + ubus-бэкенд.
*Выход:* всё выше управляется из веб-UI.
- **Ф2 — Дифференциаторы.** Egress-selection UI, per-client policy, **multi-hop цепочки**
(перенос xray_chain), гео/домен-правила, explain/trace, дашборд статистики.
- **Ф3 — Расширения.** WAN-mode профили/failover, awg-wrap, FakeIP, sing-box-адаптер, расписания.
## Risk-register (ключевое)
DNS-leak · tproxy-петля (sockopt.mark) · flow-offload ломает tproxy · IPv6 dual-stack ·
kmod↔ядро pin · дрейф схемы xray · boot-order (fw→dnsmasq→xray+hotplug) · lockout (→ commit-confirm).
## Ближайшие 3 шага
1. Написать **спеку контракта**: UCI-схема `/etc/config/shater` + командный/ubus-интерфейс `xrayctl`.
2. Завести **скелет репозитория** в `shater/` (git init, структура из дока 02) + CI-заготовка.
3. Поднять **QEMU-стенд** (OpenWrt x86-64 + тест-клиент) и собрать пустой пакет через SDK (WSL2).
</content>
+77
View File
@@ -0,0 +1,77 @@
# mini_router (BPi-R3 Mini) — полный аудит (2026-07-08)
SSH: `mini_router` = 192.168.11.1 (root, пароль). Hostname `BananaWRT`.
Железо: Bananapi BPi-R3 Mini (aarch64_cortex-a53), ImmortalWrt 24.10.3, аптайм ~24 дня.
Модем: Fibocom FM350-GL (`/dev/ttyUSB1`=AT, `ttyUSB3`=data→eth2). Оператор МегаФон, APN internet.
## Роль узла
Шлюз с **двухслойным обходом** (xray-цепочка поверх AmneziaWG) + **тройной failover WAN** + доступ к домашней сети `10.10.10.0/24`.
## WAN failover (по metric)
| iface | UCI | что | metric | статус |
|-------|-----|-----|--------|--------|
| eth1 | `ewan` (dhcp) | Ethernet-аплинк, 10.0.0.125, шлюз 10.0.0.1 (BPI-R4) | 10 | **АКТИВЕН** |
| phy0-sta0 | `wwan` (dhcp) | Wi-Fi client | 20 | down |
| eth2 | `wan` (atc, FM350) | SIM МегаФон, CGNAT 100.97.58.232/28 | 30 | up, резерв |
## Активный путь трафика (режим ETH)
```
LAN(br-lan) ─nft xray_tproxy (tcp+udp tproxy→:12345, mark 0x162)→
xray_chain :12345 ─3 хопа L1→L2→L3 (подписка qomar)→
(local out → ip rule prio10 → table160: default dev awgOut)→
awgOut (AmneziaWG, свои пакеты mark 0x11 → prio5 → main → eth1)→
orange_pi 146.158.118.199:25423 → VPS-exit → Internet
```
Параллельно **awgHome** (endpoint :58052) даёт доступ к дому 10.10.10.0/24 (там blockchain-ноды, teamspeak .160, камеры, main_router).
## Метки / таблицы маршрутизации
- `0x162` — xray_chain (LAN tproxy → :12345), ip rule prio2 → table162 (`local default dev lo`)
- `0x163` — xray-sim (awg-UDP → :10810), prio3 → table163 (`local default dev lo`) — ТОЛЬКО в SIM-режиме
- `0x11` — свои пакеты awgOut/awgHome, prio5 → main (физический WAN), разрыв петли
- table160 (prio10, from all): `default dev awgOut`, `10.10.10.0/24 dev awgHome`, `10.11.0.0/24 dev awgHome`, `192.168.11.0/24 dev br-lan`
- sysctl: ip_forward=1, rp_filter=0, route_localnet=1, accept_local=1
## nftables (live tables: fw4, ttl_mangle, dnsmasq, xray_tproxy)
- **xray_tproxy** (inet): prerouting mangle — bypass mark 0x162 + все RFC1918/loopback/multicast, затем `iifname br-lan tcp/udp tproxy to :12345 mark 0x162`.
- **ttl_mangle** (ip): `oifname eth2 ip ttl set 64` (антидетект тетеринга).
- fw4: DNS-hijack (LAN :53 → DNAT 192.168.11.1:53 / [::1]), redirect **Sunshine** (WAN 47984-48010 → 192.168.11.240, game-streaming).
## DNS (задействован)
LAN → dnsmasq (`:53`, форс через nft DNAT + fw4 redirect) → **https-dns-proxy** DoH `127.0.0.1:5533` → Quad9 (9.9.9.9). dnsmasq `noresolv=1`, rebind-protection. Утечки закрыты. (unbound установлен, но `off`.)
## Сервисы: РАБОТАЕТ / РЕЗЕРВ / МУСОР
### 🟢 Работает
- **xray_chain** (pid 2003, бинарь **26.6.22** — обновлён сегодня): цепочка, слушает :12345 + внутр. 10001/10002. Выход рабочий (exit ≠ IP роутера).
- **awgOut / awgHome** — оба с недавним хендшейком, живы. Дом пингуется.
- **dnsmasq + https-dns-proxy** — DNS-контур.
- Policy routing (table160/162), метки 0x11/0x162.
- Sunshine port-forward, SMS-tool, ttyd (:7681), statistics/collectd/vnstat, aria2, usteer.
### 🟡 Горячий резерв (только в SIM-режиме, сейчас простаивает — 0 соединений)
- **xray-sim** (pid 19544): 56 VLESS-нод renawave, балансер leastPing + observatory, инбаунды 10808/10809/dokodemo 10810. Таблица `ip tproxy_awg` сейчас НЕ загружена → в него ничего не заворачивается.
- **sim-setup.sh** + table163 + `tproxy-awg.nft` — петля «awg-UDP → VLESS» для обхода whitelist МегаФона. Поднимается при `default dev eth2`.
- cron `*/30` крутит `update_config.sh` (renawave), рестартит xray-sim даже вхолостую.
- ⚠️ renawave **не релеит произвольный UDP** (только DNS) → SIM-контур для awg боем не проверен, вероятно не поднимется в whitelist-часы.
### 🔴 Мусор / не используется
| Компонент | Состояние | Вердикт |
|-----------|-----------|---------|
| **tun2socks** (pid 5721) | запущен из `/etc/rc.local`, `-device tun1 -proxy ss://…` | tun1 DOWN, нет маршрутов → **балласт**, ест ~1.2 ГБ VSZ |
| **naiveproxy** | S99 вкл., `enable='0'`, не запущен | dead |
| сток **xray** (S99xray) | `enabled '0'`, только config.json.example | dead, дублирует бинарь |
| **unbound** | пакет стоит, init off | не используется; block_unbound/block_dot rules `enabled='0'` |
| **imei-spoof** | init off, но IMEI сейчас спуфнут (358473112729568, в NVRAM) | эффект есть, при сбросе модема не переприменится |
| Файлы-хвосты | update.sh.bak2/3/4, xray_config.json.pre_xhttp, config.json.bak.*/broken, panther_cache.txt, pyhpk6Ze0SNzvexY (207КБ), update_sub.sh.disabled, update_panther_sub.sh | наследие |
## Сделано сегодня
1. `update.sh` (xray_chain): добавлена поддержка **xhttp/httpupgrade**, `raw→tcp` (был баг — xhttp молча превращался в tcp). Протестировано, залито, прогнано.
2. Бинарь xray обновлён **26.3.27 → 26.6.22** (ручная замена, sha256 сверен). Бэкап `/usr/bin/xray.bak.26.3.27`.
- ⚠️ opkg-пакет `xray-core` числит 25.2.21 (метаданные устарели) — НЕ делать `opkg upgrade xray-core` (откатит).
## Подписки в игре
- **qomar** (`pro.qomar.pw/sub/...`) — 3 группы sub0/sub1/sub2 → цепочка xray_chain L1→L2→L3.
- **renawave** (`sub.renawave.space/eQHzgWHz6xwZwLJP`, HWID 4378f94b..., Happ-заголовки) → xray-sim балансер.
- `servers.txt` (локально) — курированный список RU-whitelist reality/xhttp серверов (SNI ads.x5.ru / yandex.cloud / yandex.ru).
</content>
</invoke>
+156
View File
@@ -0,0 +1,156 @@
# luci-app-xray (рабочее название) — дизайн-черновик
**Цель:** отдельный OpenWrt-плагин для управления XRAY (транспарент-прокси, подписки,
цепочки, роутинг), переносимый на любой роутер и не привязанный к текущей задаче.
Ориентир: passwall / passwall2, но чище, удобнее, современнее.
## Реально ли? — Да.
Прецеденты: passwall/passwall2, luci-app-ssr-plus, **homeproxy** (sing-box, чистый ucode+JS),
OpenClash / nikki (mihomo), shadowsocks-libev+luci. Технически всё это стандартный
OpenWrt-стек: UCI + LuCI(JS) + ubus/rpcd + procd + fw4/nftables + policy routing.
## Чем существующие плохи (наша ниша «лучше»)
- **passwall/passwall2**: мощно, но тяжело; смесь iptables/nftables; UI перегружен;
конфиг-спагетти; куча хелпер-бинарей; заточка под Китай (chnroute/gfwlist); хрупкие апгрейды.
- **homeproxy**: чисто и красиво, но только sing-box; слабое многоуровневое чейнинг/ротация.
- **OpenClash/nikki**: mihomo-центрично.
- Чего никто не делает хорошо (наши дифференциаторы):
1. **Multi-hop цепочки** (L1→L2→L3) с посубскрипшн-слоями (наш xray_chain — ровно это).
2. **Subscription-driven балансер + observatory** с авто-выпилом мёртвых нод.
3. **Прозрачная модель роутинга** (метки/таблицы видно, не магия).
4. **Современные транспорты** (reality/xhttp/vision) как первый класс.
5. **RU-whitelist сценарии** (reality с whitelisted-SNI) из коробки.
## Ключевые архитектурные решения (обсудить)
### 1. Движок
xray-core (наша экспертиза: reality/xhttp/vision). Генератор конфига делать
**pluggable backend**, чтобы позже подключить sing-box — но НЕ абстрагировать преждевременно.
### 2. Генератор конфига (сердце) — самое важное
Уход от хрупкого shell/sed JSON (текущий update.sh). Варианты:
- **(A) ucode** — нативный OpenWrt, без зависимостей (важно на слабых роутерах), но парсинг
vless/vmess/xhttp share-links надо писать самим.
- **(B) Go-компаньон-бинарь** — маленький хелпер, **переиспользует парсер share-link'ов
самого xray-core** → корректность vless/vmess/trojan/ss/**xhttp/reality** бесплатно,
никаких regex-багов (как тот xhttp→tcp). Килл-фича.
- Рекомендация: **B** для парсинга подписок + генерации, **ucode** для склейки UCI→JSON.
Либо целиком Go-хелпер `xrayctl` (sub-fetch + build + validate `xray -test`).
### 3. UCI-схема `/etc/config/shater`
Секции: `node` (сервер), `subscription` (url+headers+layer), `chain` (L1..Ln),
`policy` (правила роутинга/сплит), `inbound` (tproxy/socks/http), `dns`, `global`.
LuCI правит UCI → генератор рендерит xray JSON.
### 4. Роутинг/фаервол — только fw4/nftables (без legacy iptables)
- **TPROXY** для TCP+UDP (не REDIRECT — UDP/QUIC). `/etc/nftables.d/` чанки внутри `table inet fw4`.
- Дисциплина меток: divert-mark → `ip rule → table N → local default dev lo`;
sockopt.mark на всех in/outbound xray = разрыв петли (у нас 0x162; в скиле 0xff).
- sysctl: rp_filter=0, route_localnet=1, ip_forward=1. flow_offloading ОБЯЗАТЕЛЬНО off.
- Опционально TUN-режим (tun2socks/sing-box) как escape-hatch.
- Split по домену/гео: dnsmasq `nftset` → `@proxy4/@proxy6` (нужен dnsmasq-full).
### 5. DNS anti-leak (где все текут)
Форс LAN DNS на роутер + блок DoT/DoH; DoH-резолвер; сплит-DNS; опц. **FakeIP/FakeDNS**
(zero-leak). Зеркалить всё на IPv6 или течёт.
### 6. Подписки
Fetch с кастомными заголовками (Happ HWID/User-Agent), кеш, несколько провайдеров,
привязка sub→layer, авто-обновление (cron/timer), health-based прунинг.
### 7. Сервисы (procd)
Мультиинстанс (chain + sim/failover), reload-on-config-change (`procd_set_param file`),
корректный boot-order: network → firewall(sets+chains) → dnsmasq(@proxy) → xray + hotplug(route).
### 8. UI/UX (где живёт «лучше»)
Современный client-side JS LuCI (не Lua CBI):
- список нод с **живым пингом** (из observatory через ubus), one-click импорт подписки,
визуальные правила роутинга, **per-client policy** (девайс X → цепочка, Y → direct),
дашборд статуса (активная нода, throughput, health), просмотр логов.
- backend: ucode rpcd-объект (`xray.status/nodes/reload/...`) + ACL.
### 9. Переносимость
Никаких хардкод-интерфейсов/IP. Детект WAN, всё через UCI. Arch-agnostic:
LuCI-часть `PKGARCH:=all`; xray-core как зависимость/feed; фид на несколько таргетов (CI).
### 10. Дистрибуция
opkg/apk фид, CI-сборка (openwrt/gh-action-sdk), подпись (usign/APKINDEX), self-update.
## Риски / трудные места
DNS-leak (вечное); UDP/FullCone NAT; смены схемы xray; fw4↔procd↔hotplug boot-order;
IPv6; миграция конфигов при апгрейде; тесты на разных таргетах; flow offloading.
## MVP (чтобы реально выкатить, а не утонуть как passwall)
1. UCI-схема + генератор (Go `xrayctl`: fetch+parse+build+`xray -test`).
2. Один tproxy-инстанс, TCP+UDP, br-lan.
3. Импорт подписки (vless/vmess/trojan/ss + xhttp/reality) через xray-парсер.
4. Балансер + observatory + прунинг мёртвых.
5. fw4-интеграция + policy routing (hotplug-персист).
6. DNS: DoH + hijack + базовый сплит (bypass RU/LAN).
7. Минимальный LuCI: ноды+живой пинг, подписки, on/off, статус.
Далее: multi-hop цепочки, per-client policy, гео/домен-правила, WAN-failover-режимы.
## РЕШЕНИЯ (locked, 2026-07-08)
- **Движок: только xray-core.** Генератор пишем так, чтобы sing-box можно было
добавить позже адаптером, но сейчас не абстрагируем.
- **Генератор: Go-компаньон `xrayctl`**, переиспользует парсинг share-link'ов
(кандидат — **libXray** `github.com/XTLS/libXray`, ф-я `ConvertShareLinksToXrayJson`),
валидация через `xray -test` до применения.
- **MVP: solid single transparent-proxy сначала** (класс homeproxy). Multi-hop
цепочки / per-client / гео — фаза 2.
## Компоненты MVP
1. **`xrayctl`** (Go, отдельный пакет через golang-package.mk):
- `sub fetch` — тянет подписку(и) с заголовками (Happ HWID/UA), кеш.
- `sub parse` — share-links → xray-outbounds (libXray).
- `build` — из UCI `/etc/config/shater` собирает полный JSON (inbounds+routing+dns+balancer).
- `test` — `xray -test` перед применением.
- `apply` — пишет конфиг, дёргает reload сервиса.
- `status`/`nodes` — отдаёт JSON (observatory latency, active node) для UI.
2. **UCI `/etc/config/shater`**: секции `global`, `subscription`, `node`, `inbound`,
`dns`, `policy` (routing), позже `chain`.
3. **procd** `/etc/init.d/xray` — xray run + respawn + reload-on-file.
4. **fw4** `/etc/nftables.d/` — tproxy TCP+UDP, дисциплина меток, разрыв петли (sockopt.mark).
5. **policy routing** — hotplug (ip rule + `local default dev lo` в отд. таблице) + sysctl.
6. **DNS** — dnsmasq nftset (@proxy4/6) + DoH + hijack :53 + блок DoT/DoH.
7. **LuCI `luci-app-<name>`** (client-side JS): views Nodes(живой пинг)/Subscriptions/
Settings/Status; backend — ucode rpcd-объект `xray` → зовёт `xrayctl`; menu.d+acl.d+uci-defaults.
8. **Дистрибуция** — luci.mk (app, PKGARCH=all) + golang-package.mk (xrayctl), CI
gh-action-sdk на мультиарк, свой фид (usign/APKINDEX).
## Репозиторий (черновик структуры)
```
<name>/
├── xrayctl/ # Go companion (go.mod, main.go, sub/ build/ ...)
│ └── Makefile # golang-package.mk
├── luci-app-<name>/
│ ├── Makefile # luci.mk, LUCI_DEPENDS:=+xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy
│ ├── htdocs/luci-static/resources/view/<name>/{nodes,subs,settings,status}.js
│ └── root/
│ ├── usr/share/luci/menu.d/…json
│ ├── usr/share/rpcd/acl.d/…json
│ ├── usr/share/rpcd/ucode/<name>.uc
│ ├── etc/uci-defaults/xx_<name>
│ ├── etc/init.d/<name>
│ ├── etc/nftables.d/90-<name>-tproxy.nft
│ └── etc/hotplug.d/iface/99-<name>
├── .github/workflows/build.yml # multi-arch SDK build
└── feed/ # generated Packages/APKINDEX
```
## Фазы
- **P1a** xrayctl (fetch/parse/build/test/apply) + UCI-схема + procd — CLI-рабочий.
- **P1b** fw4 + policy routing + DNS (один tproxy-инстанс, br-lan, TCP+UDP).
- **P1c** LuCI (nodes+пинг / subs / settings / status).
- **P2** multi-hop цепочки (перенос xray_chain), per-client policy, гео/домен, failover, sing-box-адаптер.
## Тест-стратегия (важно: не сломать прод)
mini_router в проде (xray_chain на :12345/mark 0x162). Новый стек обкатывать
**параллельно** на других портах/метке/таблице и отдельном UCI, не трогая боевой
xray_chain; переключать только когда готово. Либо тест-таргет (VM/контейнер/спейр-роутер).
## Открыто (не блокирует старт)
- Имя проекта/бренд (варианты: xwrt / proxywrt / xgate / xkit).
- libXray vs вендорить свой Go-парсер share-links (решим при первом коде xrayctl).
- Публикация: свой GitHub + фид сразу, или сначала приватно.
</content>
+124
View File
@@ -0,0 +1,124 @@
# Фиче-модель и концепция (обсуждение)
## Форкаем ли xray? — НЕТ.
xray-core остаётся **неизменным** (это data-plane движок, ставим как есть).
Наш `xrayctl` — **отдельный control-plane демон рядом**, а не форк:
- генерит xray JSON из UCI-модели,
- управляет подписками, роутингом, фаерволом, policy-routing, procd,
- читает **API xray** (gRPC Stats/Observatory) для live-пинга/трафика в UI,
- использует xray как **библиотеку** только для парсинга share-links (libXray) и валидации.
Форк = ад поддержки (xray на 26.6.x, релизы каждые недели — мы это уже видели).
Аналогия: xray = двигатель, xrayctl = ЭБУ+панель. passwall/homeproxy делают ровно так —
не форкают ядро, а оркестрируют его.
Два уровня «переиспользования кода xray» (оба ≠ форк):
- (a) импорт xray/libXray как Go-модуля в xrayctl (парсинг/валидация, опц. встроить рантайм);
- (b) shell к стоковому бинарю `xray` для run/test.
Рекомендация: гибрид — libXray для парсинга (корректность), стоковый xray под procd для data-plane.
Если чего-то не хватит в xray — контрибьютим апстрим или через его API, но не форк.
## Концепция (data-model) — как описать ВСЁ декларативно, не утонув
Ключевой инсайт из текущего сетапа: это **конвейер слоёв + policy-routing**.
Чистая модель из 7 объектов покрывает всё, что есть сейчас, и больше:
1. **Node** — сервер/эндпоинт (vless/vmess/trojan/ss/xhttp/reality; позже wg/awg-peer).
2. **Group** — набор нод (обычно = подписка) + стратегия выбора
(balancer: leastPing / random / roundrobin / failover-priority + observatory health).
3. **Chain** — упорядоченные хопы L1→L2→L3; каждый хоп = Group (случайная нода) или фикс-Node.
(Ровно твой xray_chain «inverted chain».)
4. **Outlet / Egress** — куда в итоге выходит: **интерфейс** (eth1/wwan/eth2), **туннель**
(awgOut/awgHome/любой wg), **Chain/Group**, **direct**, **block**. ← «выбор интерфейса выхода».
5. **Inbound / Entry** — как трафик входит: tproxy (LAN transparent), socks/http (локально),
dokodemo (заворот awg-UDP).
6. **Rule** — matcher → target(+egress). Упорядочены (первый match побеждает). Явно:
- **source**: CIDR/хост (`192.168.11.0/24`, `192.168.11.14/32`, IPv6), **MAC** (девайс
даже при смене IP по DHCP), **interface/зона** (br-lan / гостевой SSID / VLAN), (позже) расписание.
- **dest**: domain / geosite / geoip / ip-CIDR / port / l4proto.
- **target**: Chain | Group | Node | direct | block, с привязкой **Egress**.
Где применяется source-CIDR: **два слоя** — (a) в nft prerouting грубо решаем, какие
источники вообще заходят в прокси (`ip saddr 192.168.11.14 … tproxy`), (b) в xray
routing тонко выбираем target (`"source": ["192.168.11.14/32"] → balancerTag/outboundTag`).
→ полный контроль «девайс X → цепочка A через awgOut, девайс Y → direct».
**Визуализация «откуда→куда→как→почему» (killer-фича):**
- `xrayctl explain <src> <dst>` — прогоняет набор правил и печатает путь решения:
matched rule → выбранная Chain/Node → Egress-iface → exit-IP (аналог `ip route get`,
но для прокси-политики). В UI — «trace/explain» кнопка.
- live-соединения из xray-API: src → dst → какой outbound реально используется.
7. **Profile / WAN-mode** — условные оверрайды по активному WAN
(«если default=eth2(SIM) → заворачивать awg в VLESS»). Обобщение твоего sim-setup.sh.
8. **List / Ruleset** — переиспользуемые именованные списки (**domain-list** / **ip-cidr-list**),
источник **inline / файл / URL** (авто-обновление + кеш). Ссылаются из Rule (`dest in @list`)
и из DNS-правил. Реализация: domain→dnsmasq nftset + xray domain-rules; ip→nft set + xray ip-rules.
**Мульти-LAN:** инбаунд-перехват настраивается на **одну или несколько** LAN-сетей/интерфейсов/
бриджей (br-lan / guest / VLAN'ы), LAN-подсеть указывается явно; правила матчат по inbound/source-зоне.
Генератор (`xrayctl build`) из этой модели рендерит:
- xray JSON (inbounds, outbounds по нодам, balancers, observatory, routing inboundTag→balancerTag
для слоёв цепочки — твой inverted-chain паттерн),
- nftables-чанки (tproxy, метки, per-egress),
- ip rule + таблицы (по каждой привязке egress),
- dnsmasq nftset + DoH,
- procd-инстансы.
→ Твой текущий xray_chain + awg-wrap + failover становится **выразим декларативно** и воспроизводим на любом роутере.
## Выбор egress (твой явный запрос) — first-class
Любое Rule/Chain может «прибить» выход к:
- физическому WAN (eth1 / wwan / eth2),
- туннелю (awgOut / awgHome / любой wg/awg),
- прокси-Chain/Group,
- direct,
- и комбо (Chain → затем через awgOut → затем физический WAN).
Реализация: policy routing (fwmark → table → dev) + xray outbound sockopt.mark + бинд на iface.
Плагин **сам** раскладывает метки/таблицы/правила из модели — вот где passwall неуклюж, а мы чисто.
## Почему passwall2 лагает и как мы не лагаем
passwall гоняет кучу хелпер-процессов (dns2socks, chinadns, ipt2socks, haproxy, процесс на ноду),
тяжёлый shell, iptables → лаг = лишние userspace-хопы + нет offload + DNS-оверхед.
Мы:
- **один процесс xray** мультиплексирует всё внутри (routing rules + balancers), не процесс-на-ноду;
- **nftables tproxy** — kernel fast-path; bypass-трафик (RU/CN/private) **не заходит в userspace** (nftset);
- **flow-offload** оставляем для bypass-трафика;
- live-health из **xray API**, а не curl-спам;
- рычаги: XUDP/mux, reality 0-RTT, sniffing routeOnly, без per-conn DNS.
## Фичи по тирам
- **Tier 0 (MVP):** ноды+подписки, один tproxy, balancer+health, DNS-сплит, базовые правила
(domain/geo bypass), UI (ноды с пингом / подписки / статус).
- **Tier 1:** **выбор egress** (per-rule бинд на iface/tunnel), **per-client policy** (девайс→таргет),
**multi-hop цепочки** (перенос xray_chain).
- **Tier 2:** **WAN-mode профили/failover**, **awg-wrap** (заворот UDP-туннеля в VLESS),
FakeIP/FakeDNS, полный IPv6, дашборд статистики (throughput/health), мультиинстанс.
- **Tier 3:** sing-box-адаптер, экспорт/импорт конфигов, **правила по расписанию**
(whitelist-часы), внешний API, темы UI.
## Источники нод и их идентичность (выбор группа vs отдельная нода)
**Node source:**
- `subscription` — remote URL, обновляемый. Профили заголовков:
- **HAPP-эмуляция:** x-hwid (**задать вручную ИЛИ авто-сгенерить и запомнить per-sub**),
x-device-os / x-ver-os / x-device-model, User-Agent `Happ/x.y.z`.
- обычная подписка (base64-список / plain / позже Clash-yaml).
- `manual` — статические ноды, НЕ обновляются и НЕ пропадают. Способы добавления:
вставка одной share-link-строки, **много строк через `\n`**, **загрузка текст-файла**.
**Идентичность ноды (fingerprint):** стабильный хеш по connection-defining полям
(protocol+address+port+id/password+network+security+sni+path/serviceName). Правила и выбор
ссылаются на ноду **по fingerprint** (или group+fingerprint), НЕ по индексу.
**Персистентность при обновлении подписки (явный вопрос пользователя):**
- reconcile по fingerprint: новые — добавить; исчезнувшие — пометить `stale`
(держим N обновлений / до ручного удаления), НЕ удаляем молча;
- если «прибитая» правилом нода исчезла → **fallback по политике**: балансер группы /
direct / block (выбор). Никогда не тихий обрыв;
- manual-ноды стабильны по своему id, авто-удалению не подлежат.
**Selection:** target правила = **группа** (динамический состав + балансер) ИЛИ
**конкретная нода** (по fingerprint; для подписочных = pin + fallback выше).
## Открытые вопросы для следующего шага
- Насколько глубоко сразу закладывать egress-selection и profiles в UCI-схему (даже если UI позже)?
- Живой пинг/статы: тянуть из gRPC-API xray (нужен `api`+`policy`+`stats` в конфиге) — ок?
- Нужен ли TUN-режим (для приложений, которые tproxy не ловит) или tproxy достаточно?
</content>
+71
View File
@@ -0,0 +1,71 @@
# Операционные требования: надёжность, сборка, статистика
## A. Надёжность («железно, таблицы не ломаются, просто работает»)
Главный страх (болезнь passwall) — оставить битые ip rule/route/nft после краша или
переконфига и потерять связь. Контракт:
1. **Атомарное применение (всё-или-ничего).** `xrayctl apply` собирает ПОЛНОЕ новое
состояние, валидирует, потом применяет атомарно:
- xray: `xray -test` до подмены; старый инстанс жив, пока новый не провалидирован.
- nftables: весь ruleset одним `nft -f` (nft транзакционен — либо весь файл, либо ничего).
- ip rule/route: реконсайл (desired vs current), не слепой append.
2. **Свои ресурсы, чужое не трогаем.** Резервируем **свой диапазон fwmark**, **свои
table-ID**, и **собственную таблицу `inet xproxy`** (НЕ правим fw4-таблицу).
fw4 reload не вайпит нашу таблицу (она отдельная) → изоляция + атомарный swap.
dnsmasq nftset направляем в нашу таблицу (`…#inet#xproxy#set`).
3. **Идемпотентный реконсайл.** Каждый apply сходится к desired-состоянию (добавить
недостающее, убрать устаревшее), без накопления дублей (грех passwall). Всё «наше»
помечено → чисто находим и удаляем только своё.
4. **Персистентность.** ip rule/route вайпятся на `network reload`/reboot →
переприменяем через **hotplug** (`/etc/hotplug.d/iface`) на ifup/ifdown; procd
reload-on-config. (Твой sim-setup уже делает это — формализуем в реконсайл.)
5. **Rollback + commit-confirm.** Держим снапшот last-known-good; `xrayctl rollback`.
Опция «apply, и если нет подтверждения за N сек → авто-откат» (как safe-mode
MikroTik / LuCI apply-unchecked) → **никогда не залочишь себя**.
6. **Fail-safe.** Управляющий доступ (SSH/LuCI/LAN, DNS роутера) ВСЕГДА в обход прокси.
Поведение при падении xray — выбор: fail-open (direct) / fail-closed (block) на политику.
7. **Watchdog/дрейф.** procd respawn для xray; периодический реконсайл xrayctl ловит
расхождение и переприменяет.
8. **Гейты валидации:** `xray -test` + `nft -c -f` + connectivity-probe до commit.
9. **Loop-guards встроены** (sockopt.mark, bypass RFC1918/loopback/IP-сервера) — из tproxy-скила.
## B. Сборка под OpenWrt + ImmortalWrt/BananaWRT
**BananaWRT** = кастомная **ImmortalWrt**-сборка (SuperKali) под BPi-R3 Mini + Fibocom
FM350, свой CI и репозиторий. То есть это ImmortalWrt-форк → **формат пакетов тот же**,
что у OpenWrt/ImmortalWrt (ABI-совместимо). Значит:
- **Дистрибутивы:** собираем против **OpenWrt SDK** и **ImmortalWrt SDK** (BananaWRT
ставит ImmortalWrt/OpenWrt-пакеты штатно).
- **Версии:** 24.10 (opkg/`.ipk`) + 25.x (apk/`.apk`) — **шипаем оба** (Makefile один,
формат решает SDK).
- **Арки:** `aarch64_cortex-a53` (этот роутер) + `x86_64` (QEMU-стенд) + ходовые
(`mipsel_24kc`, `arm_cortex-a7_neon-vfpv4`, `aarch64_generic`…).
- **CI:** `openwrt/gh-action-sdk` матрица {SDK: openwrt/immortalwrt × версия} × {арка}.
- `luci-app-<name>` — `PKGARCH:=all` (собираем один раз).
- `xrayctl` — per-arch (Go, `golang-package.mk`, dep `golang/host`, `$(GO_ARCH_DEPENDS)`).
- ImmortalWrt-таргеты: их SDK-образы (можно указать URL SDK / отдельный action-инстанс).
- **Фид:** подписанный (usign для ipk / EC для apk), GitHub Pages/Release, one-line
установка ключа+фида, self-update по bump `PKG_RELEASE`.
- ⚠️ **kmod'ы пиннятся к точному хешу ядра** — `kmod-nft-tproxy`/`kmod-nft-socket`
либо в DEPENDS как системные (обычно уже есть), либо собирать против идентичного ядра.
## C. Статистика / дашборд («сколько трафика съела прокся», per-rule, дашборд)
Источники (всё без форка, из штатного xray + nft):
- **Per-proxy трафик:** xray **StatsService (gRPC API)** — счётчики
`outbound>>>TAG>>>traffic>>>uplink|downlink` (включить `api`+`stats`+`policy.system.
statsOutboundUplink/Downlink`). → «какая нода/прокся сколько байт съела».
- **Live-пинг/health:** **Observatory** (per-outbound alive+latency).
- **Per-rule статистика:** xray правила сами не считаются → вешаем **nft `counter`**
на каждое наше marking-правило (packets/bytes на firewall-слое). → hits/байты по правилу.
- **История/графики:** на роутере УЖЕ есть **collectd + luci-statistics (RRD)** —
можно фидить xray-статы туда → готовые исторические графики. Или свой лёгкий ring-buffer.
- **Отдача в UI:** `xrayctl stats` (ubus-метод) агрегирует xray-API + nft-counters +
observatory → LuCI поллит → дашборд: throughput во времени, байты по нодам,
хиты/байты по правилам, health, активная нода, live-соединения (src→dst→outbound).
Дашборд-виджеты (MVP-плюс): сводка (актив-нода, вверх/вниз, аптайм туннеля),
таблица нод (пинг/alive/трафик/выбор), таблица правил (матчи/байты), лог, «explain trace».
</content>
+120
View File
@@ -0,0 +1,120 @@
# Полный каталог фич (WHAT) — master
Теги: **[MVP]** фаза 1 · **[T1]** дифференциаторы · **[T2]** расширения · **[T3]** дальний прицел.
Принцип: **всё настраивается** (easy-режим с пресетами поверх advanced-режима).
## 1. Ноды / Endpoints
- Протоколы: **[MVP]** vless, vmess, trojan, shadowsocks (вкл. 2022-blake3), socks, http;
**[T2]** wireguard/amneziawg-outbound, dokodemo; **[T3]** hysteria2/tuic (только через sing-box-адаптер).
- Транспорты: **[MVP]** tcp/raw, ws, grpc, **xhttp**, httpupgrade; kcp/quic — по возможности.
- Security: **[MVP]** none, tls, **reality**; flow `xtls-rprx-vision`, fingerprint, alpn, sni, allowInsecure.
- Мультиплексирование: **[T1]** mux/XUDP (вкл/выкл, concurrency), per-node sockopt/mark.
- Добавление вручную: **[MVP]** одна share-link; **много строк через `\n`**; **загрузка текст-файла**;
**[T1]** импорт из буфера; **[T2]** импорт по QR (из картинки), экспорт ноды в QR/ссылку.
- Идентичность: **[MVP]** fingerprint (хеш connection-полей), stale-пометка, ручное удаление.
- Тест ноды: **[MVP]** ping/alive (реальный коннект), latency; **[T1]** real-delay через узел, speedtest.
## 2. Группы / Подписки
- Источник: **[MVP]** `subscription` (URL) / `manual` (статический набор).
- Форматы подписки: **[MVP]** base64-список share-links, plain-список; **[T2]** Clash/Mihomo YAML,
sing-box JSON, Xray JSON.
- Заголовки: **[MVP]** **HAPP-эмуляция** (x-hwid ручной/**авто-генерируемый и запоминаемый**,
x-device-os/x-ver-os/x-device-model, User-Agent Happ/x.y.z); **[MVP]** произвольные custom-헤aders.
- Обновление: **[MVP]** **интервал НА КАЖДУЮ подписку** (напр. 30m / 6h / 24h / custom) +
глобальный дефолт + **кнопка «обновить сейчас»** + при бутe; jitter + ретраи; fetch **напрямую
ИЛИ через прокси** (когда хост подписки заблокирован); кеш + use-cache-on-fail; после апдейта →
авто-reconcile нод (fingerprint, не рвём пины). Реализация: procd-timer / cron на подписку.
- **[T1]** userinfo из ответа подписки (лимит/остаток трафика, срок действия) → дашборд + алерт «истекает».
- Фильтры: **[T1]** include/exclude по regex имени/тега, по протоколу, по стране (эмодзи-флаги/гео), дедуп.
- Стратегия группы (балансер): **[MVP]** leastPing, random, roundRobin, single(fixed);
**[T1]** leastLoad, **failover(priority order)**. Observatory: probeURL/interval/timeout/sampling.
- Персистентность выбора при refresh (см. док 03): reconcile по fingerprint + fallback.
## 3. Цепочки (multi-hop) [T1]
- Упорядоченные хопы; каждый хоп = группа (балансер) или фикс-нода.
- Сборка = inverted proxySettings (перенос твоего xray_chain); число слоёв произвольное.
- End-to-end health-probe цепочки; цепочка как **target** правила и как **egress**.
## 4. Инбаунды / точки входа
- **[MVP]** Transparent (**TPROXY**), **TCP+UDP**. **Мульти-LAN:** выбираешь одну или **несколько
LAN-сетей/интерфейсов/бриджей** (br-lan/guest/VLAN'ы) — каждую перехватываем отдельно; LAN-подсеть
указывается явно; порт(ы) настраиваются. Правила матчат по inbound/source-зоне.
- **[T1]** Локальные SOCKS/HTTP инбаунды (для самого роутера и других приложений).
- **[T2]** dokodemo для заворота конкретного трафика (awg-wrap).
- **[T2]** TUN-режим (tun2socks/sing-box) как альтернатива tproxy для краевых случаев.
## 5. Роутинг / движок правил
- Match: **[MVP]** source (CIDR/**host `/32`**/**MAC**/iface/зона), dest (domain / domain-suffix /
domain-keyword / **geosite** / **geoip** / ip-CIDR / port / port-range), l4proto (tcp/udp), inbound;
**[T3]** расписание (время суток / дни).
- Sniffing: **[MVP]** recover SNI/Host/QUIC для domain-правил (routeOnly).
- Target: **[MVP]** chain*/group/node/direct/block + **egress-binding**. (*chain — с T1)
- Порядок + first-match + default-rule. **[MVP]**
- Пресет-паки правил: **[MVP]** bypass RU+private+LAN, all-else→proxy; **[T1]** block-ads
(geosite:category-ads), split по спискам; тумблеры пресетов.
- **Per-client policy** (девайс → target) как отдельный удобный UI поверх правил. **[T1]**
- Гео-данные: **[MVP]** менеджмент geoip.dat/geosite.dat (скачать/обновить), выбор источника.
- **explain/trace**: «откуда→куда→как→почему» (`xrayctl explain src dst`). **[T1]**
## 5b. Списки / Rulesets (переиспользуемые)
- **List** — именованный, типизированный: **domain-list** / **ip-cidr-list** / (port-list). **[MVP]**
- Источник: **inline** (правка в UI) / **файл** (upload/путь) / **URL** (remote, авто-обновление
по интервалу + кеш). **[MVP inline/файл; T1 URL-auto]** — «домен-листы и айпи-листы, в т.ч. выходные».
- Форматы-адаптеры: plain (строку/строку), dnsmasq, **Clash rule-provider**, **geosite-category**,
sing-box ruleset (srs), v2ray dat. (MVP: plain + clash-provider + geosite.)
- Использование в правилах: `dest domain in @list:ru-bypass`, `dest ip in @list:ad-ips`, `source in @list`.
- Реализация: domain-list → dnsmasq nftset + xray domain-rules; ip-list → nft set + xray ip-rules.
- **[T1]** курируемые бандлы по требованию (RU-bypass antifilter/re:filter, ads, private) + полностью кастомные.
## 6. Egress / Выходы
- **[MVP/T1]** физ-WAN (любой iface), туннель (любой wg/awg), прокси (chain/group/node), direct, block.
- **[T1]** комбо (цепочка → затем через конкретный iface).
- **[MVP]** автодетект доступных интерфейсов.
## 7. Профили / WAN-mode / Failover [T2/T3]
- Условные оверрайды по: активному default-iface / connectivity-probe / расписанию.
- Кейсы: SIM-режим → заворот awg-UDP в VLESS; whitelist-часы → RU-reality серверы.
- Атомарное переключение профиля (без разрыва управляющего доступа).
## 8. DNS (полностью настраиваемый)
- **Named resolvers** **[MVP]**: тип (DoH / DoT / plain / local-dnsmasq / **FakeIP**) + адрес +
опц. **detour** (резолв через конкретный outbound / direct / цепочку).
- **DNS-routing rules** **[MVP]**: match domain / domain-list / geosite / **client(source)** → резолвер X
(per-domain / per-client / per-rule DNS). Default-резолвер + fallback.
- **Split-DNS** **[MVP]**: direct-домены → ISP/local; proxied → proxy-side резолвер (без утечки; resolved==routed).
- **FakeIP** **[T1]** (решение: оба режима переключаемо): pool 198.18.x + fakeip-domain-list / real-ip-list.
- **[MVP]** hijack LAN :53 → роутер; блок клиентского DoT/DoH-байпаса; **IPv6-зеркало**.
- **[MVP]** domain-list → nftset для роутинга; согласованность resolved==routed.
## 9. Надёжность / Ops (топ-приоритет — см. док 04)
- **[MVP]** атомарный apply, свои таблицы/метки, идемпотентный реконсайл, hotplug-персист,
**rollback + commit-confirm**, fail-safe **mgmt-bypass**, watchdog, boot-order.
- **[MVP]** kill-switch: fail-closed(block)/fail-open(direct) — глобально и/или на политику.
## 10. Observability / Дашборд
- **[MVP]** статус: throughput up/down, активная нода/цепочка, health туннеля, uptime.
- **[MVP]** таблица нод: ping/alive/трафик/выбор; сортировка по latency; поиск/фильтр.
- **[T1]** per-proxy трафик (байты, xray Stats API); per-rule hits/байты (nft counters);
live-соединения (src→dst→outbound); история-графики (collectd/RRD).
- **[MVP]** просмотр логов; **[T1]** explain/trace виджет.
## 11. UX
- **[MVP]** one-click импорт подписки; вставка из буфера; RU/EN i18n.
- **[T1]** bulk-actions над нодами; **[T2]** QR импорт/экспорт; dark-mode (тема LuCI).
- **[MVP]** экспорт/импорт всего конфига (backup/restore); **[T2]** несколько профилей конфига + переключение.
- **[MVP]** easy-режим (выбрал ноды — работает на дефолтах) поверх advanced (полные правила).
## 12. Дистрибуция / жизненный цикл
- **[MVP]** сборка OpenWrt + ImmortalWrt/BananaWRT, 24.10(ipk)+25.x(apk), мультиарк, CI (gh-action-sdk).
- **[MVP]** подписанный фид (usign/EC), one-line установка ключа+фида, self-update (bump PKG_RELEASE).
- **[T1]** миграция UCI-схемы между версиями плагина; **[T1]** проверка совместимости версии xray.
## Открытые проектные развилки (нужен выбор — см. вопросы в чате)
1. Форматы подписок в MVP (только share-links vs +Clash/sing-box).
2. Инбаунды в MVP (tproxy-only vs +local socks/http vs +TUN).
3. DNS по умолчанию (nftset-split vs FakeIP).
4. Kill-switch по умолчанию (fail-closed vs fail-open).
5. Гео-данные (бандлить geoip/geosite ~МБ vs скачивать по требованию) — важно на флеше.
6. IPv6 в MVP (полный dual-stack сразу vs IPv4-first).
7. Имя/бренд проекта + лицензия.
</content>
+58
View File
@@ -0,0 +1,58 @@
# 06 — North Star («имба»): амбициозные фичи
**Принцип:** имба ≠ раздувание. Тяжёлая аналитика живёт в Go-демоне/оффлайн, **НЕ в пакетном
пути** (требование «без лагов» — священно). Роутер остаётся быстрым; вау — в данных и UI, не в датаплейне.
## Статистика по потребителям (per-client) — как именно (реализуемо)
Цель: «кто сколько съел» — по устройству, по проксе, по домену; live + история.
- **Per-client байты (up/down), live+тотал:** nftables **dynamic counter set**
(`set clients { type ipv4_addr; flags dynamic; counter; }` + правило `update @clients { ip saddr }`)
→ на каждый source-IP авто-счётчик в ЯДРЕ; дельта раз в N сек = live-throughput.
Имена устройств: IP→MAC→hostname из dnsmasq-leases.
- **Per-client × per-proxy** (какой девайс через какую проксю сколько): счётчики по (saddr × mark),
mark кодирует выбранную политику/outbound → реконструкция «клиент→прокся→байты».
+ xray Stats API даёт per-outbound тотал (сверка).
- **Per-client × per-domain/SNI:** парсинг xray access-log (source+domain) в Go-демоне → агрегация
top-доменов на клиента. Тяжелее → T2, с семплингом, не в горячем пути.
- **История:** RRD (collectd уже стоит) или встроенный ring; ретенция настраивается.
Опц. интеграция **nlbwmon** (штатный per-host монитор) для готовых тоталов.
- Скорость датаплейна не страдает: счётчики в ядре, агрегация в userspace-демоне.
## Дашборд мечты
- **Live flow (Sankey):** client → rule → chain → egress → exit, анимация throughput.
- **Leaderboard потребителей:** top talkers — кто сколько съел (устройство/домен/прокся).
- **Node board:** live-пинг, alive, трафик, **SLA-uptime %**, история latency, «лучшая нода сейчас».
- **Гео-карта** выходов и назначений; **connection inspector** (живые conn src→dst→outbound, kill).
- **Explain/trace:** «почему этот трафик пошёл именно сюда».
## Контроль / автоматизация (вот это «имба»)
- **Профили-сцены:** именованные снапшоты ВСЕГО конфига (Home / Travel / Whitelist-night / Gaming) —
переключение в один тап (+авто по расписанию/WAN-mode).
- **Per-device квоты** (лимиты трафика) → действие throttle/block/notify (родительский контроль).
- **Расписания** (правила по времени: детские девайсы мимо прокси ночью; whitelist-часы авто).
- **Авто-оптимизация маршрута:** непрерывный проб нод → авто-выбор быстрейшей per-destination.
- **One-click «протестить всё»:** проб всех нод → ранжированная таблица → авто-сборка лучшей цепочки.
- **Config-history + diff + rollback:** каждый apply версионируется, визуальный дифф, откат в тап.
- **Rule-simulator:** вводишь src+dst → видишь решение/ноду/exit-IP ДО применения.
- **Алерты (Telegram/webhook):** нода легла, квота, новый девайс, деградация цепочки, подписка истекает.
- **Импорт из чего угодно:** подписка/буфер/файл/QR + конверт из passwall/homeproxy/clash-конфига.
- **Fleet-режим (moonshot):** пуш конфига/подписок на НЕСКОЛЬКО роутеров из одного UI (у тебя их много).
## Сетевая глубина
- Мульти-WAN + мульти-прокси балансировка; egress «быстрейший из набора» / round-robin WAN.
- Chain-шаблоны (L1→L2→L3 из групп в один клик); полный IPv6-паритет; per-policy kill-switch;
per-client DNS + ad-block + FakeIP.
## Интеграции
- REST/gRPC API; опц. **Telegram-бот** управления; экспорт нод в QR/ссылку/подписку.
## Тиры (честно про реализм)
- **Cheap-wins-that-feel-premium (T1):** per-client leaderboard · explain/trace · one-click test-all+rank ·
профили-сцены · config-history+rollback · Telegram-алерты · node SLA. — дёшево в коде, вау-эффект огромный.
- **T2:** per-domain stats · квоты · расписания · FakeIP · гео-карта · connection inspector · авто-оптимизация.
- **Moonshots (T3+):** fleet-мультироутер · per-destination latency-routing · конвертеры чужих конфигов.
## Бюджет производительности (священно)
Датаплейн — только ядро (nft tproxy + counters) + один xray. Аналитика/UI — Go-демон + LuCI-поллинг.
Никаких per-connection userspace-хопов и парсеров в горячем пути. **Имба в данных, не в лаге.**
</content>
+135
View File
@@ -0,0 +1,135 @@
# 07 — Архитектура и схемы (Mermaid)
Диаграммы рендерятся прямо в Gitea. Наглядная версия — `architecture.html`.
## 1. Системная архитектура (control-plane vs data-plane)
Тяжёлого в пакетном пути нет: config едет вниз, телеметрия — вверх.
```mermaid
flowchart TB
subgraph PRES["Presentation"]
LUCI["LuCI app (JS)"]
UBUS["ubus, rpcd"]
end
subgraph CTRL["Control plane — xrayctl (Go)"]
UCIM["UCI model — etc-config-xray"]
FETCH["sub fetch + parse (libXray)"]
GEN["config generator — xray JSON, nft, ip-rules"]
REC["reconciler — atomic apply, rollback"]
TEL["telemetry — xray API, nft counters, logs"]
end
subgraph DATA["Data plane — kernel + one xray"]
XRAY["xray-core — tproxy in, balancers, observatory"]
NFT["nftables fw4 — own table, tproxy, marks, counters"]
DNSM["dnsmasq + DoH, FakeIP"]
ROUTE["ip rule, route — policy routing"]
PROCD["procd — supervise, respawn"]
end
LUCI <--> UBUS
UBUS <--> CTRL
CTRL -->|"render + atomic apply"| DATA
DATA -->|"telemetry"| TEL
```
## 2. Модель данных — 8 объектов
```mermaid
flowchart LR
SUB["Subscription — URL + HAPP headers"] --> NODE["1 — Node"]
MAN["manual — paste, file, link"] --> NODE
NODE --> GRP["2 — Group +balancer"]
GRP --> CH["3 — Chain L1..Ln"]
NODE --> CH
INB["5 — Inbound (multi-LAN tproxy)"] --> RULE["6 — Rule"]
RULE --> CH
RULE --> GRP
RULE --> NODE
RULE --> EG["4 — Egress"]
LIST["8 — List, Ruleset"] --> RULE
LIST --> DNS["DNS"]
PROF["7 — Profile (WAN-mode)"] -.->|"override"| RULE
EG --> OUT["iface, tunnel, chain, direct, block"]
```
## 3. Путь трафика
```mermaid
flowchart LR
C["LAN client"] -->|"nft tproxy, mark to :12345"| IN["xray inbound — dokodemo :12345, sniff SNI+Host"]
IN --> R{"rule match — src, dst, list, geo"}
R --> T["target — Chain L1-L2-L3, Group, Node"]
T -->|"mark 0x11 to table"| E["egress — awgOut, eth, wifi"]
E --> NET["Internet — exit IP"]
```
Loop-guard: `sockopt.mark` на egress xray + bypass RFC1918 и сервера; flow-offload остаётся ON для direct.
## 4. DNS (настраиваемый, без утечек)
```mermaid
flowchart LR
C["client :53"] -->|"hijack DNAT"| DM["dnsmasq — noresolv, cache"]
DM --> DR{"DNS router — domain, list, geo, client"}
DR --> R1["ISP, local"]
DR --> R2["DoH — detour via outbound"]
DR --> R3["FakeIP 198.18.x"]
DR --> SET[("domain to nftset — для роутинга")]
```
## 5. Жизненный цикл ноды и идентичность
```mermaid
flowchart LR
S["subscription — HAPP HWID fixed or auto"] --> P["parse (libXray)"]
P --> FP["fingerprint — hash addr, port, id, net, sec, sni"]
FP --> RC{"reconcile"}
RC -->|"new"| ADD["add"]
RC -->|"present"| KEEP["keep"]
RC -->|"missing"| ST["stale N refreshes then remove"]
ADD --> SEL["selection — Group dynamic or pinned Node"]
KEEP --> SEL
SEL -->|"pinned and missing"| FB["fallback — group, direct, block"]
```
Обновление подписки: **интервал на каждую подписку** + вручную + при бутe; после апдейта → этот reconcile.
## 6. Надёжность — state machine («железно»)
```mermaid
stateDiagram-v2
[*] --> Edit
Edit --> Build
Build --> Validate: xray-test, nft-c
Validate --> KeepOld: fail
Validate --> Stage: ok
Stage --> Apply: atomic nft-f + swap
Apply --> ConfirmWindow
ConfirmWindow --> Committed: confirmed
ConfirmWindow --> Rollback: timeout N s
Rollback --> LastGood
KeepOld --> [*]
Committed --> [*]
LastGood --> [*]
```
Свои метки, таблицы (fw4 не трогаем) · идемпотентный reconcile · hotplug-персист · mgmt-bypass всегда.
## 7. Per-consumer статистика (ядро считает, демон агрегирует)
```mermaid
flowchart LR
subgraph K["Kernel — in-path, free"]
C1["nft dynamic counter set — per saddr"]
C2["saddr x mark counters — client x proxy"]
end
subgraph U["Userspace — aggregate"]
X["xray Stats API — per-outbound"]
L["access-log parse — T2 per-domain"]
RRD["RRD, nlbwmon — history"]
end
C1 --> AGG["xrayctl aggregator"]
C2 --> AGG
X --> AGG
L --> AGG
RRD --> AGG
AGG -->|"ubus"| DASH["Dashboard — leaderboard, per-proxy, Sankey, conn-inspector"]
```
## 8. Роадмап
| Тир | Содержание |
|-----|-----------|
| **MVP** | подписки+manual импорт · TPROXY multi-LAN · balancer+observatory · rules (src, dst, domain-list, ip-list) · DNS (DoH+hijack+split, ±FakeIP) · atomic apply+rollback+kill-switch · LuCI (nodes, subs, status) · build OpenWrt+ImmortalWrt+feed |
| **T1** | multi-hop chains · per-client policy + egress-select · explain, trace · consumer leaderboard + SLA · profiles, scenes · config-history + rollback · one-click test-all · Telegram-алерты |
| **T2** | per-domain stats · connection inspector · device quotas · schedules · FakeIP · geo-map · WAN-mode profiles · awg-wrap · auto route-optimization · QR |
| **T3** | fleet (много роутеров) · per-destination latency-routing · config-конвертеры · sing-box адаптер · REST, gRPC API · TG-бот |
+186
View File
@@ -0,0 +1,186 @@
# ACCEPTANCE — per-feature gap analysis vs. T1 scope
Read-only audit (2026-07-09) of `xrayctl/*.go` + `luci-app-shater/**` against
`05-feature-catalog.md` (T1 items), `CONTRACT.md`, `STATUS.md`.
Status legend: **DONE** (rendered into xray/nft/routing output and reachable) ·
**PARTIAL** (works for a subset / one plane only) · **STUB** (parsed/modelled but
NOT rendered into any output) · **MISSING** (no code path at all).
Evidence is `file:line` or `func`. "Rendered" = ends up in the xray JSON, the
`inet shater` nft table, or `ip rule/route` — not merely stored in the `Model`.
---
## 1. Subscriptions
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| Per-sub update interval | **STUB** | `uci.go:57` parses `update_interval`; nothing schedules it. `main.go:cmdSub` fetches only on demand. No procd-timer/cron emitted anywhere. | Generate a per-sub timer (procd `service_triggers`/cron) keyed on `update_interval`; wire `sub update NAME` to it. |
| HAPP headers (UA/hwid/os/model/custom) | **DONE** | `sub.go:141 applyHappHeaders`, `sub.go:168 resolveHWID` (auto uuid persisted to `/etc/xray/state/<name>.hwid`), custom `Headers` loop `sub.go:159`. | — |
| hwid auto / fixed | **DONE** | `sub.go:168-183`: `auto` → generate+persist; fixed passthrough; empty → none. | — |
| Fetch-via-proxy | **STUB** | `uci.go:58` parses `fetch_via`; `sub.go:119 FetchSubscription` always uses a plain `http.Client` — `FetchVia` is never read. | Build an HTTP client whose transport dials through the local socks/tproxy path when `fetch_via=proxy`. |
| Cache + use-cache-on-fail | **PARTIAL** | `sub.go:298 LoadSubCache`, `main.go:157` on fetch error `continue`s (keeps old cache). No explicit "serve stale on fail" beyond not overwriting. | Fine for MVP; add an explicit last-good-timestamp / staleness surfaced to UI. |
| Import from string / `\n` / file | **PARTIAL** | `sub.go:197 ParseSubscriptionBody` (base64 or `\n` list); `generate.go:116 BuildConfigFromLinks` + `main.go:cmdGen --links FILE`. BUT this only feeds `gen` — it does **not** persist nodes into UCI/cache, and there is **no UI** to paste/import. | Add an `import` CLI verb that writes `config node`/cache, and a LuCI import box. |
| Import from clipboard (T1) | **MISSING** | No UI node/import view exists (menu has no such page). | Add paste-from-buffer control on a Nodes/Import page. |
| Fingerprint reconcile (stale/fallback) | **DONE** | `sub.go:48 Fingerprint` (8-byte sha256 over conn fields), `sub.go:239 ReconcileSub` (match by FP, keep pins, `maxStaleRefreshes=3` aging). | — |
| userinfo (traffic limit / expiry) (T1) | **MISSING** | `FetchSubscription` discards response headers; no `subscription-userinfo` parse. | Parse the `Subscription-Userinfo` header + surface to dashboard/alert. |
| Filters by proto/country, dedup (T1) | **PARTIAL** | dedup by FP `sub.go:254`; regex include/exclude on **name** only (`generate.go:257 resolveGroupMembers`). No proto/country/geo filter. | Add proto/country filter fields to group resolution. |
## 2. Groups / Balancers + Observatory
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| leastPing / random / roundRobin | **DONE** | `generate.go:622 xrayStrategy`; balancer emitted `generate.go:243`. | — |
| single (fixed / lone member) | **DONE** | `generate.go:240` — routes straight to member, no balancer. | — |
| failover (priority order) | **STUB** | `generate.go:628`: `failover` is aliased to xray `leastLoad` — this is **not** a priority-ordered failover. | Emit real failover (ordered member list / `strategy:leastLoad` with costs, or observatory-driven priority). |
| Observatory config emitted | **PARTIAL** | `generate.go:103-108` emits `observatory{subjectSelector,probeURL,probeInterval}` only when a leastping/failover group exists. | OK; but see below — results never surfaced. |
| Live ping / alive surfaced in UI | **MISSING** | `status.go:38 NodesJSON` sets `Alive=false, LatencyMS=0` always; observatory results (xray API) are never read. `node_test` (`status.go:70`) does a **plain TCP connect to the endpoint**, not a through-node real-delay. | Read xray Observatory/`StatsService` over the API port and merge into `nodes`. |
| Group CRUD from UI | **MISSING** | No `groups.js` (menu.d has overview/nodes/subscriptions/rules/settings only). Groups exist only by hand-editing `/etc/config/shater`. | Add a Groups LuCI page (strategy, source, members, include/exclude, probe). |
## 3. Multi-hop chains L1→L2→L3
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| Backend chain generation | **PARTIAL/DONE** | `generate.go:341 emitChain` — inverted `proxySettings` via internal localhost socks pairs per layer; exit tag via `chainExitTag` (`generate.go:504`). Logic looks correct but is **not exercised by any bundled test/fixture** (selftest only builds from links). | Add a chain fixture to `selftest`/regression + real `xray -test` on a 3-hop chain. |
| Chain as rule target | **DONE** | `generate.go:493 resolveTarget` case `chain`. | — |
| Chain as egress | **MISSING** | Egress plane not rendered at all (see §5). | — |
| Build/edit chains from UI | **MISSING** | No chains page; `Chain` model has no UI. | Add a Chains LuCI page (ordered hop list referencing groups/nodes). |
## 4. Rules
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| src CIDR / host `/32` | **DONE** | `generate.go:436` → xray `source`. | — |
| src MAC / iface / zone | **STUB (buggy)** | `generate.go:436` dumps **all** `r.Src` verbatim into xray `source` via `cidrsOnly` (which only trims blanks, `generate.go:653` — misnamed, does not filter to CIDRs). A `MAC`, `iface:x`, or `zone:x` value goes into xray `source`, which accepts IP/CIDR only → invalid/ignored, never translated to nft. | Split src by kind: CIDR/host → xray source; MAC/iface/zone → nft meta match in `RenderNft`. |
| dst domain / suffix / keyword / geosite | **DONE** | `generate.go:438,446` → xray `domain` (geosite passthrough needs geodata). | ship/manage geosite.dat. |
| dst domain-list (ruleset) | **PARTIAL** | inline entries via `generate.go:583 expandRuleset`; only `inline` source works (see §6). | — |
| dst ip / geoip / ip-list | **PARTIAL** | `generate.go:449` → xray `ip`; geoip passthrough; ip-list inline only. | geodata + url/file lists. |
| dst port / range / list | **DONE** | `generate.go:452` passes `DstPort` string through. | — |
| proto tcp/udp | **DONE** | `generate.go:455 normalizeProto`. | — |
| target chain/group/node/direct/block | **DONE** | `generate.go:477 resolveTarget` + `setTargetTag` `generate.go:598`. | — |
| egress-binding | **STUB** | `uci.go:119` parses `rule.egress`; `model.go:139 Rule.Egress`; **never read** in `buildRoutingRules`. `Model.Egresses` is never used in `BuildConfig`. STATUS.md line 32 admits this. | Render egress: interface→sockopt.mark + policy route to that iface; proxy→chain/group tag. |
| kill-mode (per-rule) | **STUB** | `uci.go:120` parses `rule.kill`; never rendered. Global `kill_switch=closed` also never forced (STATUS.md line 35). | When effective kill=closed, emit a trailing `outboundTag:block` catch-all / fail-closed route. |
| per-client policy UI | **MISSING** | Only raw Rules grid; no device→target convenience UI. | Add per-client policy view atop rules (src=host → target). |
| explain / trace | **PARTIAL** | `status.go:120 ExplainJSON` (CLI + `rules.js` widget). geosite/geoip return `false` (`status.go:238` — no geodata); egress/kill echoed from model but egress isn't actually applied. | Load geodata for offline match; reflect real egress once rendered. |
## 5. Egress selection (iface / tunnel / chain / direct)
**STUB.** `uci.go:89 egress` section is parsed into `Model.Egresses` (`model.go:107`),
but `BuildConfig` never touches `m.Egresses` and `buildRoutingRules` never reads
`r.Egress`. No `interface`/`tunnel` binding reaches policy routing or xray sockopt.
No egress LuCI page. **To complete:** render egress name→(a) `type=interface`:
outbound `streamSettings.sockopt.mark` + an `ip rule`/table to that device; (b)
`type=proxy`: resolve `target` to a chain/group tag; (c) direct/block. Add a page.
## 6. Lists / Rulesets
| Source | Status | Evidence | To complete |
|---|---|---|---|
| inline | **PARTIAL** | `generate.go:583 expandRuleset` returns `rs.Entries` inline into a rule's domain/ip arrays. Works for xray routing only. | — |
| file | **MISSING** | `uci.go:103` parses `path`; never read/loaded. | Load file into entries at gen time. |
| url (auto-update) (T1) | **MISSING** | `url`/`update_interval`/`format` parsed (`uci.go:101-104`); no fetch, no timer, no cache. | Fetch+cache+timer like subscriptions; format adapters. |
| format adapters (plain/clash/geosite) | **MISSING** | `Format` field ignored; only raw entries used. | Implement plain/clash-provider/geosite adapters. |
| Rendered to nftset (domain-list→dnsmasq nftset, ip-list→nft set) | **MISSING** | `RenderNft` (`apply.go:211`) has no sets; dnsmasq config not generated. Rulesets only inlined into xray routing. | Emit nft sets + dnsmasq `nftset=` lines for DNS-consistent routing. |
| Lists LuCI page | **MISSING** | No ruleset view. | Add one. |
## 7. DNS
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| Named resolvers (DoH/DoT/plain/local/FakeIP) | **STUB** | `settings.js:98` UI + `uci.go:122` parse. But `generate.go:537 buildDNS` only emits a **flat `servers` list of `r.Address`** — type (doh/dot/local), `detour`, and FakeIP `pool` are all ignored. FakeIP only sets `queryStrategy:UseIP` (`generate.go:548`). | Emit per-server xray dns objects with `address`/`detour`/`domains`; real FakeIP pool. |
| per-domain / per-client DNS routing | **STUB** | `dns_rule` parsed into `Model.DNSRules` (`uci.go:129`); `buildDNS` never reads `m.DNSRules`. STATUS.md line 33 admits it. | Render dns_rules into xray `dns.servers[].domains` + client-source scoping. |
| hijack :53 + block client DoT/DoH | **MISSING** | No dnsmasq redirect and no nft rule blocking 853/DoH in `RenderNft`. | Add nft redirect of LAN :53→router and drop/redirect DoT/known-DoH. |
| split-DNS (resolved==routed) | **MISSING** | No nftset population; direct vs proxied resolver split not emitted. | Tie DNS answers to nftset used by routing. |
| nftset for routing | **MISSING** | `dns_mode=nftset` is the default but `RenderNft` emits no sets and no dnsmasq `nftset=`. | Generate nft sets + dnsmasq integration. |
| IPv6 mirror | **PARTIAL** | `applyRouting` does `-4`/`-6` (`apply.go:257`); nft has ip6 mgmt bypass. DNS side has no v6 handling. | v6 DNS servers/sets. |
## 8. Reliability / Ops
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| Atomic apply | **DONE** | `apply.go:35 writeJSON` (tmp+rename); `apply.go:177` nft `-c` then `-f`; test-before-swap `apply.go:70`. | — |
| commit-confirm | **PARTIAL** | `apply.go:95` arms; `armAutoRollback` (`apply.go:276`) is a detached `sh -c "sleep N; … rollback"`. Survives, but crude and unsupervised. | Use a procd/atd-style supervised timer; record intended timeout in status. |
| rollback | **PARTIAL (incomplete)** | `apply.go:113 Rollback` restores `run.json` + reloads xray **only** — it does **not** revert the `inet shater` nft table or `ip rule/route`. A bad nft/routing apply is not undone. | Snapshot+restore nft table and routing in rollback too. |
| hotplug-persist | **PARTIAL/unverified** | `Reconcile` (`apply.go:127`) is the intended hook; the hotplug script lives in the `shater-core` package (not in the audited tree). | Confirm hotplug calls `xrayctl reconcile`; not verifiable here. |
| watchdog | **MISSING** | No watchdog loop in `xrayctl`; `Reconcile` exists but nothing periodically calls it. | Add a procd respawn/watchdog that pings xray + reconciles. |
| kill-switch (fail-closed/open) | **STUB** | `Globals.KillSwitch`/`Rule.Kill` parsed; never rendered. Default `closed` is not enforced (traffic falls through to `direct` — `generate.go:68` freedom is the baseline default). Effectively **fail-open** regardless of setting. | Emit trailing block rule when effective policy is closed. |
| mgmt-bypass | **DONE (basic)** | `apply.go:227-229` unconditional RFC1918 + loopback + multicast + loop-mark bypass in nft prerouting. | Consider explicit SSH/LuCI port carve-outs for edge cases. |
## 9. Stats
| Capability | Status | Evidence | To complete |
|---|---|---|---|
| per-node (xray Stats API) | **STUB** | Dashboard renders a per-node table (`overview.js:124`), but generated config has **no `api`/`stats`/`policy` blocks** (`BuildConfig` keys `generate.go:92`), so xray exposes no counters. `StatsJSON` (`status.go:105`) never queries xray. Table is always empty. | Emit `api{services:[StatsService]}`+`stats{}`+`policy` + a local API inbound; query it in `StatsJSON`. |
| per-client (nft counters) | **STUB** | `RenderNft` has only two aggregate `counter` tokens on the tproxy accept lines (`apply.go:236,240`) — **no per-saddr set/map**. `StatsJSON` dumps raw `nft -j list table` with no per-client breakdown. UI leaderboard (`overview.js:136`) stays empty. | Add an nft per-source counter map and parse it. |
| per-rule | **STUB** | No per-rule counters emitted; UI table (`overview.js:143`) empty. | Tag rules with nft counters or xray per-rule stats. |
| Dashboard | **PARTIAL** | `overview.js` renders status cards + 3 stat tables and polls every 5s, but all traffic/throughput/uptime fields are absent from `StatusJSON` (`status.go:16` has no throughput/uptime/active-node), so cards show `-`. | Populate status with live throughput/active node/uptime. |
## 10. LuCI pages — CRUD / live data / apply
Existing pages (menu.d): overview, nodes, subscriptions, rules, settings.
| Page | CRUD | Live data | Saves→UCI / applies | Notes |
|---|---|---|---|---|
| Overview | n/a | ✅ polls `status`+`stats` | Apply/Confirm/Reload buttons call ubus (`overview.js:11-13`) | Data mostly empty (see §9). Apply button OK. |
| Nodes | **read-only** | ✅ `nodes`, `node_test` | no add/remove | `nodes.js` — Test + "Select" (Select just redirects to Rules, `nodes.js:96`). No manual-node add. |
| Subscriptions | ✅ full grid CRUD | Update-now buttons (`subscriptions.js:89`) | ✅ form.Map on `xray` | Solid. Save commits UCI; apply separate. |
| Rules | ✅ full grid CRUD + explain widget | explain live | ✅ form.Map | Good; but egress/kill fields don't render into output (§4). |
| Settings | ✅ globals+inbounds+resolvers+dns_rules | — | ✅ form.Map | resolvers/dns_rules are cosmetic until §7 renders them. |
**Missing pages:** Groups, Chains, Egress, Lists/Rulesets, standalone
Resolvers/DNS-rules (currently only in Settings), Profiles, Nodes-add/Import,
per-client policy, Stats/graphs history.
**rpc/acl/ucode consistency:** ✅ All frontend `rpc.declare` methods
(`status, stats, apply, confirm, reload, nodes, node_test, sub_update, explain`)
exist in `shater.uc` (`return {xray:{…}}`) and are all granted in
`acl.d/luci-app-shater.json`. No mismatches found.
## 11. Profiles / WAN-mode (T2 — listed for completeness)
**MISSING.** No `Profile` type in `model.go`, no `profile` case in `uci.go`, no
UI. CONTRACT.md §profile is entirely unimplemented.
---
## Notable bugs / dead code found
1. **`cidrsOnly` is misnamed** (`generate.go:653`) — it only trims empties; MAC/
iface/zone src values leak into xray `source` (invalid). §4.
2. **`Rollback` is partial** (`apply.go:113`) — reverts xray but not nft/routing.
3. **`Model.Egresses` dead** — parsed, never consumed by any generator. §5.
4. **`DNSRules`, resolver `type`/`detour`, ruleset `url`/`file`/`format`, per-sub
`update_interval`, `fetch_via`, `rule.kill`/`egress`** are all parsed-but-never-
rendered — the UCI schema promises far more than the generator emits.
5. **Kill-switch semantics inverted from intent** — default `closed` behaves
fail-open because the baseline outbound is `freedom/direct` and no block
catch-all is emitted.
6. **Observatory emitted but never read back** — nodes always report `alive:false`.
7. **`node_test` measures endpoint TCP reachability, not through-proxy delay** —
misleading "latency"/"alive" for a proxy node.
---
## Prioritized TODO (most foundational first)
1. **Render egress binding + kill-switch** (§4/§5/§8) — these are core routing
semantics already in the schema/UI but producing no output; fixes the biggest
"configured but silently ignored" class. Add block catch-all for fail-closed.
2. **Fix src matching** (§4 bug 1) — route MAC/iface/zone to nft; keep only real
CIDRs in xray `source`. Prevents invalid configs.
3. **DNS rendering** (§7) — per-resolver objects, dns_rules, nftset + dnsmasq
hijack/split; without this "resolved==routed" and leak-prevention don't hold.
4. **Rulesets: file + url sources + nft sets** (§6) — needed by rules/DNS above.
5. **Complete rollback** to snapshot/restore nft + routing (§8 bug 2), and move
commit-confirm/watchdog onto a supervised timer.
6. **Stats plumbing** (§9) — enable xray Stats API + per-client nft counter map;
fill Overview status (throughput/active-node/uptime). UI already exists.
7. **Observatory read-back + real node latency** (§2) — surface live ping/alive.
8. **Missing LuCI pages**: Groups, Chains, Egress, Lists, Nodes-add/Import,
per-client policy. (Backend for chains/groups already exists; UI is the gap.)
9. **Subscription scheduling** (§1) — per-sub timers; `fetch_via=proxy`;
userinfo (quota/expiry) parse + alert.
10. **failover strategy** real priority order (§2); geodata management for
geosite/geoip in both generate and explain.
11. **Profiles / WAN-mode** (T2) — model + generator + UI from scratch.
-38
View File
@@ -1,38 +0,0 @@
# Руководство для AI-агентов — sing-box-lx
`sing-box-lx` — **тонкий downstream** апстрима [SagerNet/sing-box](https://github.com/SagerNet/sing-box): upstream **плюс ровно две фичи** и ничего больше:
1. **XHTTP** — клиентский v2ray-транспорт (совместимость с Xray XHTTP).
2. **AmneziaWG 2.0 (AWG2)** — клиентский endpoint поверх WireGuard.
Главная ценность проекта — **согласованность с upstream**. Любое изменение оценивается по тому, насколько легко оно переживёт ребейз на следующий тег upstream.
---
## Что читать в первую очередь
| Документ | Назначение |
|----------|------------|
| **SPECS/CONSTITUTION.md** | Неизменяемые принципы: приоритеты, build-tag изоляция, минимальный дифф, запреты |
| **SPECS/IMPLEMENTATION_PROMPT.md** | DoD, git/ребейз-ритуал, команды сборки и тестов, контракт выхода |
| **SPECS/README.md** | Формат задач `NNN-T-S-NAME` (Spec Kit), workflow |
Перед реализацией задачи из `SPECS/` обязательно прочитай её **SPEC.md → PLAN.md → TASKS.md** и применяй **IMPLEMENTATION_PROMPT.md**.
---
## Жёсткие границы (детали — в CONSTITUTION)
- **Только две фичи.** Любая фича вне XHTTP/AWG2 — вне скоупа, спросить пользователя.
- **Go module path остаётся `github.com/sagernet/sing-box`** (для чистых ребейзов).
- **Всё новое — за build-tag** (`with_xhttp`, `with_awg`) и **в новых файлах/пакетах**.
- **Правки upstream-файлов** — только помеченными `// lx:` блоками, атомарными коммитами.
- **Никаких merge с upstream — только rebase.** `origin/lx` всегда ребейзится на тег.
- **Имя бинаря — `sing-box`** (drop-in для лаунчера); идентичность `-lx` — в версии.
- **Scope — client-only**: outbound/endpoint. Server/inbound отложены.
---
## Язык
Спеки, отчёты и ответы в чате — **русский**. Код, комментарии, коммиты — английский, в стиле upstream sing-box.
+202
View File
@@ -0,0 +1,202 @@
# BUILD & INSTALL — shater packages
Three OpenWrt packages live under `package/`:
| package | kind | PKGARCH | build input |
|--------------------|------------------------------|---------|--------------------|
| `xrayctl` | Go control-plane daemon | per-arch | `golang-package.mk` |
| `luci-app-shater` | LuCI web app (client-side JS)| `all` | `luci.mk` |
| `shater-core` | system/init + fw4/routing | `all` | plain `package.mk` |
Engine dependency `xray-core` is **not** ours — it comes from the router's own
package feed (ImmortalWrt/BananaWRT/OpenWrt packages feed).
Two ways to build: **CI** (multi-arch, signed feed — `.github/workflows/build.yml`)
or **locally with the SDK in Docker** (below). Feed publishing/install lives in
`dist/README.md`.
---
## 1. Local build with the SDK (Docker)
The testbed already runs SDK builds via `testbed/scripts/sdk-build.ps1`, but that
script builds a single, dependency-free `hello` package: it bind-mounts **one**
package dir into `/builder/package/<pkg>` and runs `make package/<pkg>/compile`.
Our three packages need (a) each other, (b) the `golang` host/target packages
(for `xrayctl`) and (c) `luci-base` (for `luci-app-shater`), so they must be
registered as a **feed** inside the SDK and built with the package feeds updated.
Below is that generalization — run it as-is; it does **not** modify `testbed/`.
SDK images (match the CI pin — OpenWrt **24.10.4**, opkg/`.ipk`):
| matrix arch | SDK Docker image (target) | notes |
|----------------------|------------------------------------------|--------------------------|
| `x86_64` | `openwrt/sdk:x86_64-24.10.4` | VM / testbed |
| `aarch64_cortex-a53` | `openwrt/sdk:mediatek-filogic-24.10.4` | **the router** (BananaWRT)|
> The testbed script currently hardcodes `-24.10.3`; bump it to `-24.10.4` (or
> just use the `docker run` below) so local builds match CI. Build as an
> unprivileged user, from a path without spaces.
### x86_64
```powershell
# from repo root: C:\Users\Admin\Desktop\shater
New-Item -ItemType Directory -Force .\dist\out\x86_64 | Out-Null
docker run --rm `
-v "${PWD}\package:/builder/shater-pkgs:ro" `
-v "${PWD}\dist\out\x86_64:/builder/artifacts" `
openwrt/sdk:x86_64-24.10.4 bash -eus -c @'
set -e
# register our repo package dir as an SDK feed (src-link needs an ABS path)
echo "src-link shater /builder/shater-pkgs" >> feeds.conf.default
./scripts/feeds update -a
./scripts/feeds install -a -p shater # -p = prefer our feed
make defconfig
for p in xrayctl shater-core luci-app-shater; do
make package/$p/compile V=s -j"$(nproc)"
done
make package/index
find bin -name "*.ipk" -exec cp -v {} /builder/artifacts/ \;
'@
```
### aarch64_cortex-a53 (the router)
Identical, only the image and output dir change:
```powershell
New-Item -ItemType Directory -Force .\dist\out\aarch64_cortex-a53 | Out-Null
docker run --rm `
-v "${PWD}\package:/builder/shater-pkgs:ro" `
-v "${PWD}\dist\out\aarch64_cortex-a53:/builder/artifacts" `
openwrt/sdk:mediatek-filogic-24.10.4 bash -eus -c @'
set -e
echo "src-link shater /builder/shater-pkgs" >> feeds.conf.default
./scripts/feeds update -a
./scripts/feeds install -a -p shater
make defconfig
for p in xrayctl shater-core luci-app-shater; do
make package/$p/compile V=s -j"$(nproc)"
done
make package/index
find bin -name "*.ipk" -exec cp -v {} /builder/artifacts/ \;
'@
```
(Bash/Git-Bash equivalent: same body, replace the PowerShell `docker run ... @'
...'@` wrapper with `docker run --rm -v "$PWD/package:/builder/shater-pkgs:ro"
-v "$PWD/dist/out/<arch>:/builder/artifacts" <image> bash -eu -c '<body>'`.)
Artifacts land in `dist/out/<arch>/*.ipk` (plus the `Packages*` index). To
build just one package, keep only its line in the `for` loop — but the feed
setup (`feeds update`/`install`) is required for `xrayctl` (golang) and
`luci-app-shater` (luci-base) to resolve.
### Signing the local feed
```sh
dist/make-feed.sh dist/out/aarch64_cortex-a53 /path/to/secret.key
```
---
## 2. Installing on the router
Two options. `xray-core` (the engine) must come from the router's OWN feed, not
ours — see the kmod caveat below (same rule applies to the two kmods).
### Option A — signed feed (recommended, gets you upgrades)
Full one-liner and key setup are in [`dist/README.md`](dist/README.md). Short form:
```sh
ARCH=$(. /etc/openwrt_release; echo "$DISTRIB_ARCH")
FEED=https://omar.git.qomar.pw/shater
wget -O /tmp/k "$FEED/public.key" && mv /tmp/k "/etc/opkg/keys/$(usign -F -p /tmp/k)"
echo "src/gz shater $FEED/$ARCH" >> /etc/opkg/customfeeds.conf
opkg update
opkg install xray-core xrayctl shater-core luci-app-shater
```
### Option B — manual `.ipk`, in dependency order
Copy the built `.ipk` files to the router (e.g. `/tmp`) and install bottom-up so
each package's depends are already present:
```sh
opkg update
# 1) engine + kernel bits from the ROUTER's feed (not ours):
opkg install xray-core kmod-nft-tproxy kmod-nft-socket dnsmasq-full
# 2) our packages, dependency order:
opkg install /tmp/xrayctl_*.ipk # Go daemon (control plane)
opkg install /tmp/shater-core_*.ipk # init + fw4/routing glue
opkg install /tmp/luci-app-shater_*.ipk # LuCI UI (depends on the above)
```
Order rationale: `luci-app-shater` depends on `xrayctl` + `shater-core` +
`xray-core`; `shater-core` wires fw4/routing and pulls the kmods; `xrayctl` is
the leaf binary. Installing a `.ipk` whose deps are missing fails, hence
bottom-up.
---
## 3. kmod caveat (READ THIS for the real router)
**Kernel modules pin to an exact kernel version+hash.** `kmod-nft-tproxy` and
`kmod-nft-socket` (needed for the TPROXY/mark data path) built by *our* SDK will
**refuse to load** on the router unless its kernel is byte-identical to the SDK's.
The router is ImmortalWrt/BananaWRT, not stock OpenWrt 24.10.4 — its kernel hash
differs. Therefore:
- **Do NOT install our SDK's kmods on the router.** Pull `kmod-nft-tproxy` and
`kmod-nft-socket` from the **router's own feed** (`opkg update && opkg install
kmod-nft-tproxy kmod-nft-socket`), which matches its running kernel.
- Our packages only *depend on* those kmods; they don't (and must not) ship them.
- Same principle for `xray-core` and `dnsmasq-full`: prefer the router's feed.
- Our built `.ipk` set (`xrayctl`, `luci-app-shater`, `shater-core`) contains no
kmods — `xrayctl` is a userspace Go binary and the other two are `PKGARCH=all`
— so they install cleanly across the 24.10.x line regardless of kernel hash.
---
## 4. Releasing a new version (Gitea auto-build)
CI (`.github/workflows/build.yml`) runs on every push and publishes releases
automatically via the Gitea API — no manual upload:
- **Push to `main`** → rebuilds both arches and refreshes a rolling **`latest`**
pre-release (always the newest feed).
- **Push a tag `vX.Y.Z`** (`git tag v0.2.0 && git push origin v0.2.0`) →
publishes a **versioned** release with the same assets.
Each release carries, per architecture:
- `shater-feed-<arch>.tar.gz` — a ready-to-serve opkg feed (`Packages` + all
`.ipk`); and the loose `.ipk` files for `opkg install <url>`.
- `x86_64` = testbed VM; `aarch64_cortex-a53` = **both** routers
(BPI-R3 `mini_router` + BPI-R4 `main_router`, mediatek/filogic).
**To ship an upgrade** that `opkg upgrade` will pick up, bump the package
release number, then push:
- `xrayctl` → `xrayctl/Makefile` `PKG_RELEASE`
- `shater-core` → `shater-core/Makefile` `PKG_RELEASE`
- `luci-app-shater` → `luci-app-shater/Makefile` `PKG_RELEASE`
The data-`.ipk` packers (`ci/pack-core.sh`, `ci/pack-luci.sh`) read these, so a
bump propagates to the built version string.
**Install on a router** (aarch64_cortex-a53):
```sh
wget -O /tmp/f.tgz https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed-aarch64_cortex-a53.tar.gz
mkdir -p /tmp/shater && tar -C /tmp/shater -xzf /tmp/f.tgz
opkg update
opkg install /tmp/shater/xrayctl_*_aarch64_cortex-a53.ipk \
/tmp/shater/shater-core_*_all.ipk \
/tmp/shater/luci-app-shater_*_all.ipk
```
(Feed is unsigned unless the `KEY_BUILD` secret is set — install the `.ipk`
directly as above, or add `option check_signature '0'`. Pull `kmod-nft-tproxy`,
`kmod-nft-socket`, `xray-core`, `dnsmasq-full` from the router's own feed — see §3.)
**Optional:** set a repo secret `RELEASE_TOKEN` (a token with `write:repository`)
if the auto `GITHUB_TOKEN` lacks release permission on your Gitea instance.
-160
View File
@@ -1,160 +0,0 @@
# Режим работы: оркестратор + исполнители
Ты (основная модель) — архитектор и тимлид. Ты НЕ пишешь код сам.
Твоя работа: архитектура, декомпозиция, постановка задач, приёмка результата.
## Правила делегирования
1. ЛЮБАЯ реализация (код, тесты, конфиги, рефакторинг, отладка) выполняется
субагентами через инструмент Agent. Сам ты правишь файлы только в одном
случае: тривиальная правка в 1–2 строки, где постановка задачи дороже самой
правки.
2. **Модель выбирает исполнитель задачи, а не привычка.** `fable` — быстрый и
дешёвый, годится для механической работы с ясным контрактом. `opus` — для
всего, где нужно рассуждение: поиск причины, аудит, дизайн, работа в чужом
коде. Если у `fable` кончилась квота — молча переходи на `opus`, это не повод
останавливать работу. Не спрашивай владельца, какую модель брать.
3. Перед делегированием ты сам исследуешь код настолько, чтобы написать точное
ТЗ. В каждом задании субагенту обязательно указывай:
- контекст: что за проект и над чем идёт работа;
- конкретные файлы и функции (пути, а не «найди сам»);
- контракт: сигнатуры, форматы данных, инварианты, что менять НЕЛЬЗЯ;
- definition of done: какие команды прогнать и какой ждать результат;
- что вернуть: изменённые файлы, результаты проверок, найденные проблемы,
принятые решения.
4. **Скиллы использовать по максимуму — и тебе, и агентам.** Это не
формальность: в них лежит выстраданное знание по ровно тем предметным
областям, в которых мы работаем, и игнорировать их — значит переоткрывать
чужие грабли. См. раздел «Скиллы» ниже.
5. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
сообщении. Зависимые — последовательно, передавая результаты предыдущего.
**Делишь файлы между параллельными агентами явно** и пишешь каждому, кто ещё
работает в дереве и что трогать нельзя. Запрещай им `git stash`,
`git checkout <файл>`, `git reset` — в этом проекте агент уже сносил правки
соседа через `git stash push`.
6. Приёмка: результат каждого субагента ты проверяешь сам — читаешь diff
ключевых мест, гоняешь проверки из definition of done. Не принимай отчёт на
слово: сегодня отчёт «тесты зелёные» дважды сопровождался тестом, который
ничего не прибивал. Если результат не принят — не переделывай сам, а верни
задачу тому же агенту через SendMessage (у него сохранён контекст).
7. Финальный отчёт владельцу: что сделано, сколько агентов, что проверено,
**что осталось непроверенным и почему** — последнее так же важно.
## Инженерные стандарты
Это не пожелания. Каждый пункт здесь появился после того, как его отсутствие
стоило рабочего дня.
- **Тест обязан быть проверен мутацией.** Откатить фикс → показать, что тест
падает, и с каким текстом → вернуть фикс. Тест, не падающий на сломанном коде,
не тест, а украшение.
- **Прибор без контроля не доказывает ничего.** Отрицательный результат чего-то
стоит, только если показано, что этот же прибор умеет дать положительный.
«Утечки не нашли» прибором, который не мог её увидеть, — это не результат.
- **Опровержение ценнее согласия.** В каждом ТЗ прямо разрешай субагенту
сказать «твоя версия неверна» и требуй доказательства, а не вежливости.
Лучшие результаты этого проекта приходили именно так.
- **Не обещать непроверенного.** Комментарий, предупреждение и текст в панели —
это утверждения о поведении. Если поведение не проверено, так и писать.
Формально верная фраза, которая читается как «работает», — тоже ложь.
- **Умолчание падает в восстановимую сторону.** Открытый `default:` в разборе
вариантов — источник целого класса дефектов: неучтённое значение уходит туда,
где дороже всего ошибиться. Списки делать положительными и закрытыми.
- **Проверка присутствия обязана покрывать всё, что ставит её Apply-двойник.**
Иначе идемпотентный быстрый путь становится ловушкой: «всё на месте» при
отсутствующем маршруте.
- **Никакого молчаливого скипа.** Тест, который не выполнился, обязан быть
назван поимённо в выводе гейта. Однажды CI гонял два теста из 116 файлов, и
все считали, что покрыто.
## Скиллы
**Правило: если задача касается области, по которой есть скилл, — скилл
вызывается ДО начала работы, а не после того, как что-то не заработало.**
Это относится и к тебе, и к каждому субагенту.
Субагент не видит наш диалог и сам не догадается, что скиллы существуют.
Поэтому **в каждом ТЗ перечисляй поимённо**, какие скиллы он обязан вызвать
через инструмент Skill: «сначала вызови Skill "openwrt-nftables" и Skill
"openwrt-networking", следуй им». Требуй в отчёте сказать, что именно из скилла
он применил, — так видно, вызвал он его или упомянул.
Соответствие областей этого проекта и скиллов:
| Трогаешь | Обязательные скиллы |
|---|---|
| `/etc/config/*`, `uci`, uci-defaults, парсер модели | `openwrt-uci` |
| nftables, fw4, зоны, метки, tproxy, kill-switch | `openwrt-nftables` |
| интерфейсы, мосты, VLAN, policy routing, `ip rule`, sysctl, dnsmasq | `openwrt-networking` |
| init-скрипты, procd, respawn, service triggers, boot armor | `openwrt-procd-services` |
| перехват трафика целиком (tproxy + маршрутизация + DNS) | `openwrt-transparent-proxy` |
| сборка пакетов, SDK, фид, CI, подпись, `apk`/`opkg` | `openwrt-package-build-ci`, `openwrt-native-packages` |
| LuCI-приложение, ubus/rpcd, ucode | `openwrt-luci-plugin`, `openwrt-ubus-rpcd`, `openwrt-ucode` |
| панель (React/TS) | `react-expert`, `frontend-design:frontend-design` |
| Go: конкурентность, каналы, профилирование, идиоматика | `fullstack-dev-skills:golang-pro` |
| TypeScript | `fullstack-dev-skills:typescript-pro` |
| стратегия тестирования, покрытие, тестовые данные | `fullstack-dev-skills:test-master` |
| поиск причины по логам и трассам | `fullstack-dev-skills:debugging-wizard` |
| проверка в браузере, скриншоты | `fullstack-dev-skills:playwright-expert` |
| ревью | `review`, `fullstack-dev-skills:code-reviewer` |
| безопасность | `security-review`, `fullstack-dev-skills:security-reviewer` |
| графики и визуализация данных | `dataviz` |
Список неполный — **смотри доступные скиллы под задачу**, а не только в эту
таблицу. Если скилл выглядит смежным, дешевле вызвать его и не воспользоваться,
чем не вызвать и потом отлаживать то, что там уже описано.
## Проверки
- **Гейт:** `bash scripts/run-tests.sh` — Linux в Docker, боевой набор тегов,
`-race`, и шаг, требующий вердикта по имени для привилегированных тестов.
Зелёный гейт — необходимое условие, но не достаточное: он не видит стыков с
ядром, procd и nftables.
- **Стенд:** сервер `local_openwrt` в ssh-manager — ImmortalWrt 25.12.1 той же
ревизии, что боевой роутер. Сюда — всё, что касается init-скриптов, nft,
policy routing, TUN.
- **Боевой роутер:** `mini_router` (BPI-R3), через него идёт весь домашний
трафик. Перед изменением конфигурации — резервная копия. Проверять приборно,
а не по логу: лог может печатать одно и то же в честном и в ложном случае.
## Релиз и деплой
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
`apk upgrade shaterd shater-core luci-app-shater`.
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
иначе.
## Фронтенд (admin panel)
Дизайн-направление ЗАФИКСИРОВАНО: **Faceplate** (панель сетевого железа).
Спека, токены и компоненты — в [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md).
Эталон: https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
- **Стек:** Vite + React + TypeScript в `panel/`. SPA встраивается в бинарь —
тяжёлые зависимости недопустимы.
- **Панель целиком на английском.** Ни одного символа кириллицы в `panel/src`.
- **В КАЖДОМ ТЗ на панель:** ссылка на `DESIGN.md` и на эталон; требование
сначала вызвать Skill `react-expert` и Skill
`frontend-design:frontend-design`; список существующих компонентов, которые
надо ПЕРЕИСПОЛЬЗОВАТЬ (`<Faceplate> <Module> <Toggle> <Led> <SegMeter>
<QueryLog>` и кнопки), а не изобретать заново; какие токены и семантические
цвета применять; DoD — совпадение с языком эталона, адаптив, фокус,
`prefers-reduced-motion`.
- Оранжевый — только акцент; семантика good/warn/crit — отдельно.
- **Панель не должна врать про состояние.** Значение, которое движок примет,
не может рисоваться как «never matches»; настройка, которой управляет другая
подсистема, не может описываться так, будто управляет ею.
+303
View File
@@ -0,0 +1,303 @@
# CONTRACT (0c) — UCI-схема `/etc/config/shater` + интерфейс `xrayctl`
Контракт, от которого зависит весь код. UCI = desired state; `xrayctl` рендерит из него
xray JSON + nft + policy-routing и применяет атомарно.
## Файлы на устройстве
```
/etc/config/shater # UCI desired state (редактирует LuCI/пользователь)
/etc/xray/run.json # сгенерированный боевой конфиг xray
/etc/xray/last-good.json # последний валидный (для rollback)
/etc/xray/subs/<name>.json # кеш нод подписки (после parse+reconcile)
/etc/xray/nft/shater.nft # сгенерированная nft-таблица (наша, отдельная)
/var/run/xray/ # runtime: pid, pending-apply, stats
/usr/bin/xrayctl # Go control-plane
/usr/bin/xray # движок (не наш, зависимость)
```
## UCI-схема (`/etc/config/shater`)
### globals (одна секция)
```
config globals 'globals'
option enabled '1'
option loglevel 'warning' # xray loglevel
option kill_switch 'closed' # closed|open — глобальный дефолт
option dns_mode 'nftset' # nftset|fakeip
option ipv6 '1'
option fwmark_base '0x2000' # наш зарезервированный диапазон меток
option table_base '0x2000' # база id таблиц маршрутизации
option confirm_timeout '0' # сек; 0 = без commit-confirm
```
### inbound (0..N — мульти-LAN)
```
config inbound
option name 'lan'
option enabled '1'
option type 'tproxy' # tproxy|socks|http|dokodemo (default tproxy)
option network 'lan' # tproxy: UCI-интерфейс(ы) LAN для перехвата
option tproxy_port '12345'
option tcp '1'
option udp '1'
option sniff '1' # recover SNI/Host/QUIC
# --- локальные socks/http/dokodemo (type != tproxy); НЕ участвуют в tproxy-nft ---
option listen '127.0.0.1' # socks/http/dokodemo: адрес прослушки
option port '1080' # socks/http/dokodemo: порт
option auth 'noauth' # socks/http: noauth|password
option user '' # auth=password
option pass ''
option target_addr '' # dokodemo: фикс. адрес назначения (awg-wrap)
option target_port '' # dokodemo: фикс. порт
option target_network 'udp' # dokodemo: tcp|udp|tcp,udp
```
### subscription (0..N)
```
config subscription
option name 'qomar'
option enabled '1'
option url 'https://pro.qomar.pw/sub/...'
option update_interval '6h' # 30m|6h|24h|<n>{m,h,d}
option fetch_via 'direct' # direct|proxy
# HAPP-эмуляция:
option ua 'Happ/3.13.0'
option hwid 'auto' # auto (сгенерить и запомнить) | <фикс.>
option device_os 'Android'
option ver_os '14'
option device_model 'SM-G998B'
list header 'x-key: val' # произвольные доп. заголовки
```
### node (0..N — ручные; ноды подписки НЕ здесь, они в кеше)
```
config node
option name 'reality-nl'
option enabled '1'
option uri 'vless://...' # share-link, xrayctl парсит
# схемы: vless|vmess|trojan|ss|wireguard|wg. WireGuard/AmneziaWG-нода —
# uri вида wireguard://<b64-secret>@host:port?publickey=..&address=..&mtu=..
# (или вставь целый wg-quick .conf в Import-box — он сконвертится в URI).
# --- per-node мультиплексирование + sockopt (T1); все опциональны ---
option mux '0' # мультиплексирование вкл/выкл (игнор. для VLESS XTLS-Vision)
option mux_concurrency '8' # потоков на mux-соединение
option xudp_concurrency '16' # только vless/vmess
option xudp_udp443 'reject' # reject|allow|skip (vless/vmess)
option sockopt_mark '0' # 0 = loop-guard 255 (иное значение игнорируется)
option tcp_fast_open '' # ''|1|0
option tcp_keepalive_idle '0' # секунды; 0 = off
```
### group (0..N)
```
config group
option name 'sub0'
option source 'subscription' # subscription|manual
option subscription 'qomar' # если source=subscription
list node 'reality-nl' # если source=manual
option strategy 'leastping' # leastping|random|roundrobin|failover|single
list include '' # regex по имени ноды (фильтр)
list exclude ''
option probe_url 'http://www.gstatic.com/generate_204'
option probe_interval '60s'
```
### chain (0..N — multi-hop)
```
config chain
option name 'triple'
list hop 'group:sub0' # порядок = слои L1..Ln; group:<n>|node:<n>
list hop 'group:sub1'
list hop 'group:sub2'
```
### egress (0..N)
```
config egress
option name 'via-awg'
option type 'interface' # interface|proxy|direct|block
option interface 'awgOut' # для type=interface (любой iface/туннель)
option target 'chain:triple' # для type=proxy
```
### ruleset (0..N — переиспользуемые списки доменов/IP)
```
config ruleset
option name 'ru-bypass'
option type 'domain' # domain|ipcidr
option source 'url' # inline|file|url
option url 'https://...' # для url
option path '/etc/xray/lists/ru.txt' # для file
option format 'plain' # plain|clash|geosite
option update_interval '24h'
list entry 'example.com' # для inline
```
### rule (0..N — упорядочены по order, first-match)
```
config rule
option name 'pc-triple'
option enabled '1'
option order '10'
list src '192.168.11.14/32' # cidr|host|mac|iface:<name>|zone:<name>
list dst_domain 'geosite:telegram' # домен|suffix|keyword|geosite:x
list dst_ruleset 'ru-bypass' # ссылки на ruleset (domain или ip)
list dst_ip '1.2.3.0/24' # cidr|geoip:x
option dst_port '443' # port|range|список
option proto 'tcp,udp'
option target 'chain:triple' # chain:|group:|node:|direct|block
option egress 'via-awg' # опц. — привязка выхода
option kill 'default' # default|closed|open
# --- расписание (T3): правило активно только в окне (лок. время) ---
option sched_enabled '0'
list sched_day 'mon' # mon..sun; пусто = каждый день
option sched_start '22:00' # HH:MM; пусто = 00:00
option sched_end '06:00' # HH:MM; пусто/равно start = весь день; end<start = через полночь
option sched_tz '' # опц. IANA (Europe/Moscow); пусто = TZ роутера
```
### preset (0..3 — встроенные пресет-паки правил, T1)
```
config preset 'block_ads'
option name 'block-ads' # block-ads|ru-bypass|private
option enabled '0'
option order '' # опц. переопределение порядка (иначе дефолт пака: 5/8/9)
option target '' # опц. переопределение target
```
Пресеты инжектятся синтетическими правилами ПЕРЕД пользовательскими (по order).
Без geodata гео-only паки (block-ads/ru-bypass) выкидываются (fail-open); private
работает всегда (литеральные RFC1918/ULA).
### profile (0..N — WAN-mode / условные оверрайды, T2)
```
config profile 'sim_mode'
option name 'sim-mode'
option enabled '1'
option priority '10' # выше = приоритетнее среди активных
# условия (все указанные И-объединяются; профиль без условий НЕ активируется):
list match_iface 'wwan0' # активен когда default-route dev ∈ списке
option probe_url '' # опц. connectivity-probe (HTTP)
option probe_mode 'up' # up|down (down = failover: активен когда probe лежит)
list sched_day 'mon' # окно по дням/времени (как в rule)
option sched_start '09:00'
option sched_end '18:00'
option sched_tz ''
# оверрайды при активности:
list enable_rule 'sim-vless-wrap' # форс-включить правила по имени
list disable_rule 'direct-ru' # выключить правила
option default_target 'group:ru-reality' # переопределить catch-all target
option default_egress ''
```
Активный профиль (наивысший priority с выполненными условиями) вычисляется при
gen/reconcile; `xrayctl wanmode` показывает активный. shater-cron переприменяет при
смене активного профиля (входит в scheduleSignature). Кейсы: SIM-аплинк→VLESS-egress,
whitelist-часы→RU-reality.
### resolver (0..N) + dns_rule (0..N) — DNS
```
config resolver
option name 'proxy-doh'
option type 'doh' # doh|dot|plain|local|fakeip
option address 'https://dns.quad9.net/dns-query'
option detour 'chain:triple' # ПРИНЯТО, НО НЕ РЕНДЕРИТСЯ: xray dns
# не поддерживает per-server detour
# (skip логируется + note в dns-объекте)
config dns_rule
option order '10'
list match_domain 'geosite:category-ads'
list match_src '192.168.11.0/24' # per-client DNS
option resolver 'block' # <resolver name>|block
```
`globals.resolver_default` / `globals.resolver_fallback` — имена resolver-секций:
default рендерится ПЕРВЫМ в xray `dns.servers`, fallback — ПОСЛЕДНИМ (xray
использует порядок списка); значение, не совпадающее ни с одной секцией,
трактуется как литеральный адрес сервера.
### profile (0..N — WAN-mode/условные оверрайды) [T2]
```
config profile
option name 'sim'
option when 'wan:eth2' # wan:<iface>|probe:<url>|time:<HH-HH>
list set 'rule.pc-triple.egress=via-sim-vless' # оверрайды k=v
```
## Интерфейс `xrayctl` (CLI)
```
xrayctl gen [--out FILE] # UCI -> xray JSON (stdout/FILE), без применения
xrayctl test # gen + `xray -test`
xrayctl apply [--confirm N] # gen -> test -> атомарно применить (xray+nft+routing)
# --confirm N: авто-rollback через N c без `confirm`
xrayctl confirm # подтвердить pending apply (отменить авто-rollback)
xrayctl rollback # откат к last-good
xrayctl reconcile # идемпотентно переприменить (для hotplug/boot/watchdog)
xrayctl sub update [NAME] # fetch+parse+reconcile подписк(и), обновить кеш
xrayctl node test [NAME] # проб нод(ы): alive/latency
xrayctl status # JSON: enabled, xray up, активные ноды, режим
xrayctl nodes # JSON: [{name,group,proto,alive,latency,fingerprint,stale}]
xrayctl stats # JSON: per-node bytes + per-client (saddr) bytes + per-rule
xrayctl explain SRC DST [--proto] # JSON: какое правило, target, egress, exit
xrayctl selftest # smoke: парсеры, генератор на фикстурах
xrayctl geodata status|download|remove # опц. geoip/geosite (скачать по кнопке)
xrayctl schedule due # (для cron) печатает "1" на границе окна расписания
xrayctl backup [--file PATH] # tar.gz конфиг+кеши подписок (stdout если без --file)
xrayctl restore --file PATH [--confirm N] # валидация(xray -test)->swap->apply(commit-confirm)
xrayctl profile save|list|switch|delete [NAME] [--confirm N] # именованные снапшоты конфига
xrayctl migrate # прогнать миграции UCI-схемы (идемпотентно; newer -> отказ)
xrayctl compat # JSON: версия xray vs требуемые фичи конфига
```
Коды выхода: 0 ok, !=0 ошибка. Все команды тихие в stdout кроме JSON-выдач и `gen`.
## Интерфейс ubus (объект `xray`) — для LuCI
Реализация: rpcd-ucode плагин, тонкие обёртки, зовущие `xrayctl … ` и отдающие JSON.
```
ubus call xray status
ubus call xray nodes
ubus call xray stats
ubus call xray explain '{"src":"192.168.11.14","dst":"youtube.com"}'
ubus call xray sub_update '{"name":"qomar"}'
ubus call xray apply
ubus call xray confirm
ubus call xray reload
```
ACL `/usr/share/rpcd/acl.d/luci-app-shater.json`: read uci `xray` + ubus `xray` (status/nodes/
stats/explain); write uci `xray` + ubus `xray` (sub_update/apply/confirm/reload).
## Инварианты надёжности (кодируются в apply/reconcile)
- Наши ресурсы: nft-таблица `inet shater`, fwmark из `fwmark_base`, таблицы из `table_base`.
fw4-таблицу НЕ трогаем.
- apply: build → `xray -test` + `nft -c` → атомарный swap (nft -f одним файлом; xray reload;
ip rule/route reconcile) → опц. commit-confirm с авто-rollback.
- mgmt-bypass: SSH/LuCI/LAN и трафик роутера к mgmt всегда в обход.
- Персист: `reconcile` вызывается из hotplug (ifup/ifdown) и при boot (procd) —
но ТОЛЬКО пока поднят live-флаг `/var/run/shater.active` (ставится `start`,
снимается `stop`): админский `stop` прилипает, фоновые акторы не воскрешают
перехват. `reconcile` сериализуется flock'ом (`/var/lock/xrayctl.lock`),
hash-сравнивает run.json и рестартит движок (SIGTERM→respawn) только при
реальном изменении — идемпотентные вызовы бесплатны и не рвут туннель.
- kill-switch: per-rule `kill` (default → globals.kill_switch). При
`kill_switch=closed` честный fail-closed: пустая/нераспарсенная группа
резолвится в `block` (не `direct`); при `ipv6 '0'` LAN-ingress IPv6 дропается
в forward (кроме ICMPv6-ND/link-local/multicast) — обхода по v6 нет.
- Правило, у которого `src` состоит только из MAC/iface:/zone:, резолвится в
CIDR'ы на этапе генерации (ip neigh + dhcp.leases + ubus network); если
ничего не разрезолвилось — правило матчит НИЧЕГО (255.255.255.255/32),
никогда «всё».
## Уточнения схемы (v0.1 — по итогам ревью, закрывают неоднозначности)
- **Идентичность секций.** Секции анонимные, но адресуются по обязательной опции `name`,
которая **уникальна в пределах своего типа** (два `group` не могут иметь одинаковый `name`).
Ссылки (`target`, `profile.set`, `group:x`, `chain` hop) резолвятся по `name`.
- **`globals.resolver_default` / `globals.resolver_fallback`** — имена resolver-секций
(дефолтный и fallback). Добавлены в блок `globals`.
- **`dst_domain` подформы** (как в xray): `example.com` (суффикс/поддомены), `full:host`
(точное), `keyword:kw` (подстрока), `regexp:re`, `geosite:cat`.
- **`resolver type=fakeip`**: `option pool '198.18.0.0/15'` (диапазон FakeIP), `address` не нужен.
- **Цепочка (`chain`)**: `hop` ссылаются на **разные** `group`/`node` — это и есть слои L1..Ln
(напр. три подписки = три группы = три хопа). Одна группа = один источник.
- **`rule.src` форматы**: `1.2.3.0/24` (CIDR), `1.2.3.4/32` (хост), `iface:<name>`,
`zone:<name>`, `AA:BB:CC:DD:EE:FF` (MAC).
- **`rule.dst_port`**: одиночный (`443`), диапазон (`1000-2000`), список (`80,443,8443`).
- **`group.strategy=single`**: берётся первая живая нода группы (пиннинг); балансер не создаётся.
То же поведение авто-применяется, если у группы ровно одна живая нода.
- **`inbound.network`**: допускает несколько значений (мульти-LAN) — как повторяемый `list network`.
-26
View File
@@ -1,26 +0,0 @@
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
COPY . /go/src/github.com/sagernet/sing-box
WORKDIR /go/src/github.com/sagernet/sing-box
ARG TARGETOS TARGETARCH
ARG GOPROXY=""
ENV GOPROXY ${GOPROXY}
ENV CGO_ENABLED=0
ENV GOOS=$TARGETOS
ENV GOARCH=$TARGETARCH
RUN set -ex \
&& apk add git build-base \
&& export COMMIT=$(git rev-parse --short HEAD) \
&& export VERSION=$(go run ./cmd/internal/read_tag) \
&& export TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) \
&& export LDFLAGS_SHARED=$(cat release/LDFLAGS) \
&& go build -v -trimpath -tags "$TAGS" \
-o /go/bin/sing-box \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" $LDFLAGS_SHARED -s -w -buildid=" \
./cmd/sing-box
FROM --platform=$TARGETPLATFORM alpine AS dist
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& apk add --no-cache --upgrade bash tzdata ca-certificates nftables
COPY --from=builder /go/bin/sing-box /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]
-14
View File
@@ -1,14 +0,0 @@
ARG BASE_IMAGE=alpine
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& if command -v apk > /dev/null; then \
apk add --no-cache --upgrade bash tzdata ca-certificates nftables; \
else \
apt-get update && apt-get install -y --no-install-recommends bash tzdata ca-certificates nftables \
&& rm -rf /var/lib/apt/lists/*; \
fi
COPY sing-box-${TARGETARCH}${TARGETVARIANT} /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]
+165
View File
@@ -0,0 +1,165 @@
# Installing & updating shater from the package feed
You don't have to copy `.ipk` files around. Every push to `main` publishes an
**opkg package feed** on Gitea, so a router can install shater once and then pull
upgrades with a plain `opkg upgrade` — exactly like the official OpenWrt feeds.
- Feed URL (rolling, always the newest build): `https://git.qomar.pw/omar/shater/releases/download/latest`
- Pinned to a version: `https://git.qomar.pw/omar/shater/releases/download/vX.Y.Z`
The feed is a single Gitea release that carries a **usign-signed** `Packages.gz`
index plus every `.ipk`. opkg filters a feed by CPU architecture, so **one feed
line works on every device**: the BPI‑R3 / BPI‑R4 routers pick the
`aarch64_cortex-a53` build, the x86‑64 testbed picks `x86_64`, and both pick the
arch‑independent (`all`) packages.
Signing key fingerprint: **`5ac4b177689cb8e0`** (public key: `shater-feed.pub`,
shipped both in this release and in the repo at `dist/shater-feed.pub`).
---
## 1. Add the feed (one time)
The feed is **signed**, so — unlike an unsigned feed — you keep opkg's signature
checking **on** and just install the public key once:
```sh
# 1. trust the feed's public key (filename MUST be the key fingerprint)
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
# 2. add the feed
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
```
That's it — `opkg update` now verifies `Packages.sig` against the key, exactly
like the official OpenWrt feeds. Confirm the key landed:
```sh
opkg-key list 2>/dev/null | grep 5ac4b177689cb8e0 || ls -l /etc/opkg/keys/5ac4b177689cb8e0
```
<details><summary>Older / unsigned builds, or if you'd rather not install the key</summary>
Pass `--no-check-signature` to the shater opkg commands, e.g.
`opkg --no-check-signature update`. Or disable checking globally by
**removing/commenting** the option — note `option check_signature 0` does NOT
disable it (opkg treats the option's mere *presence* as on and will reject an
unsigned feed): `sed -i 's/^option check_signature.*/# option check_signature/' /etc/opkg.conf`.
</details>
---
## 2. Install
```sh
opkg update
opkg install luci-app-shater
```
`luci-app-shater` depends on `xrayctl` and `shater-core`, so opkg pulls all three.
Its **runtime** dependencies that live in the standard OpenWrt feed —
`xray-core`, `dnsmasq-full`, the `kmod-nft-tproxy` family — are resolved from that
feed automatically as long as it is reachable. If your router still has the stock
`dnsmasq` (not `-full`), opkg will swap it as part of the install.
Then open **LuCI → Services → Shater (xray)** and run the Quick‑start wizard.
---
## 3. Update / upgrade
```sh
opkg update # refresh the index (signature verified)
opkg list-upgradable # see what's newer
opkg upgrade xrayctl shater-core luci-app-shater
```
Version comparison is on the package release (`0.1.0-r19` > `0.1.0-r7`), so a new
build always wins. Nothing restarts your tunnel unexpectedly: `shater-core`'s init
regenerates its config on start, and `xrayctl` only reloads the engine when the
rendered config actually changed.
To upgrade *everything* installed from every feed: `opkg upgrade` with no args is
**not** supported by opkg; upgrade the shater packages by name as above.
---
## 4. Pin to a specific version (optional)
The rolling `latest` feed tracks `main`. To hold a box on a known-good release,
point it at a version tag instead:
```sh
sed -i 's#/releases/download/latest#/releases/download/v0.2.0#' /etc/opkg/customfeeds.conf
opkg update
```
Version tags are produced by pushing a `vX.Y.Z` git tag (see `BUILD.md` §4).
---
## 5. How the signing works (and rotating the key)
The feed **is** signed — you don't need to do anything to enable it. CI builds
`usign` (`ci/install-usign.sh`), and `ci/make-index.sh` signs `Packages` →
`Packages.sig` with the secret half of the feed key, held in the Gitea repo
secret **`KEY_BUILD`**. The public half lives in the repo at `dist/shater-feed.pub`
and is published with every release. Routers verify against it (§1).
If `KEY_BUILD` is set but `usign` can't sign, the build **fails** on purpose —
better than silently shipping an unsigned feed that routers then reject.
To **rotate** the key (e.g. it leaked):
```sh
usign -G -s shater-feed.sec -p shater-feed.pub -c "shater feed signing key"
usign -F -p shater-feed.pub # new fingerprint
```
1. Replace `dist/shater-feed.pub` in the repo and commit.
2. Update the Gitea repo secret `KEY_BUILD` with the new `shater-feed.sec` contents:
`PUT /api/v1/repos/omar/shater/actions/secrets/KEY_BUILD` `{"data":"<sec>"}`.
3. Re-install the new public key on each router (§1) under its **new** fingerprint
filename, and delete the old `/etc/opkg/keys/<old-fp>`.
The current key fingerprint is **`5ac4b177689cb8e0`**.
---
## 6. Remove
```sh
opkg remove luci-app-shater xrayctl shater-core
# and, if you want the feed gone too:
sed -i '/releases\/download\/.*shater/d;/ shater /d' /etc/opkg/customfeeds.conf
```
`shater-core`'s uninstall tears down the nftables table, policy routing and the
kill‑switch, so removing it returns the router to plain routing.
---
## 7. A note on `apk` (OpenWrt 25.x and snapshots)
OpenWrt is migrating from `opkg` to **`apk`** (the Alpine package manager). The VM
and both BPI routers here run 24.10.x, which is **opkg** — so this guide uses opkg.
When you move to an apk-based build, the same release assets can be served as an
apk repository (`apk add --repository <url> luci-app-shater`), but the index format
differs (`APKINDEX.tar.gz`, signed with an apk key). The CI `make-index.sh` step
would need an apk-index variant; that's not wired yet because no target here uses
apk. Open an issue when a device moves to 25.x and it's a small addition.
---
## Troubleshooting
| Symptom | Fix |
|---|---|
| `opkg update` → *Signature check failed* / shater list vanishes | The feed's public key isn't installed. Do step 1 of §1 (fetch `shater-feed.pub` into `/etc/opkg/keys/5ac4b177689cb8e0`). As a stopgap use `opkg --no-check-signature update`. |
| Set `option check_signature 0` and it still rejects the feed | opkg treats the option's mere *presence* as “on”, whatever the value. Install the key (§1), or **comment/remove** the line — don't set it to `0`. |
| `Package xrayctl … has no valid architecture, ignoring` | Harmless. The combined index carries every arch; opkg ignores the builds that don't match this device and installs the right one. |
| `opkg update` → *wget returned 4 / SSL* | The router lacks CA certs: `opkg install ca-bundle` (from the stock feed) or, offline, the tarball method in the release notes. |
| `install` → *cannot satisfy dependency xray-core* | The standard OpenWrt package feed isn't reachable. Fix connectivity, or install `xray-core` from your usual source first. |
| Installed but the wrong arch | opkg only offers packages matching your `arch` list (`opkg print-architecture`). If your target isn't `x86_64` or `aarch64_cortex-a53`, build for it — see `BUILD.md`. |
-17
View File
@@ -1,17 +0,0 @@
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
In addition, no derivative work may use the name or imply association
with this application without prior consent.
-288
View File
@@ -1,288 +0,0 @@
NAME = sing-box
COMMIT = $(shell git rev-parse --short HEAD)
TAGS ?= $(shell cat release/DEFAULT_BUILD_TAGS_OTHERS)
GOHOSTOS = $(shell go env GOHOSTOS)
GOHOSTARCH = $(shell go env GOHOSTARCH)
VERSION=$(shell CGO_ENABLED=0 GOOS=$(GOHOSTOS) GOARCH=$(GOHOSTARCH) go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest)
LDFLAGS_SHARED = $(shell cat release/LDFLAGS)
PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$(VERSION)' $(LDFLAGS_SHARED) -s -w -buildid="
MAIN_PARAMS = $(PARAMS) -tags "$(TAGS)"
MAIN = ./cmd/sing-box
PREFIX ?= $(shell go env GOPATH)
SING_FFI ?= sing-ffi
LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json
.PHONY: test release docs build
build:
export GOTOOLCHAIN=local && \
go build $(MAIN_PARAMS) $(MAIN)
race:
export GOTOOLCHAIN=local && \
go build -race $(MAIN_PARAMS) $(MAIN)
ci_build:
export GOTOOLCHAIN=local && \
go build $(PARAMS) $(MAIN) && \
go build $(MAIN_PARAMS) $(MAIN)
generate_completions:
go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN)
install:
go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN)
fmt:
@golangci-lint fmt
fmt_docs:
go run ./cmd/internal/format_docs
lint:
GOOS=linux golangci-lint run ./...
GOOS=android golangci-lint run ./...
GOOS=windows golangci-lint run ./...
GOOS=darwin golangci-lint run ./...
# GOOS=freebsd golangci-lint run ./...
lint_install:
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
proto:
@go run ./cmd/internal/protogen
@gofumpt -l -w .
@gofumpt -l -w .
proto_install:
go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install -v google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
update_certificates:
go run ./cmd/internal/update_certificates
release:
go run ./cmd/internal/build goreleaser release --clean --skip publish
mkdir dist/release
mv dist/*.tar.gz \
dist/*.zip \
dist/*.deb \
dist/*.rpm \
dist/*_amd64.pkg.tar.zst \
dist/*_arm64.pkg.tar.zst \
dist/release
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release
rm -r dist/release
release_repo:
go run ./cmd/internal/build goreleaser release -f .goreleaser.fury.yaml --clean
release_install:
go install -v github.com/tcnksm/ghr@latest
update_android_version:
go run ./cmd/internal/update_android_version
build_android:
cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease :app:assembleOtherLegacyRelease && ./gradlew --stop
upload_android:
mkdir -p dist/release_android
cp ../sing-box-for-android/app/build/outputs/apk/other/release/*.apk dist/release_android
cp ../sing-box-for-android/app/build/outputs/apk/otherLegacy/release/*.apk dist/release_android
VERSION_CODE=$$(grep VERSION_CODE ../sing-box-for-android/version.properties | cut -d= -f2); \
VERSION_NAME=$$(grep VERSION_NAME ../sing-box-for-android/version.properties | cut -d= -f2); \
printf '{\n "version_code": %s,\n "version_name": "%s"\n}\n' "$$VERSION_CODE" "$$VERSION_NAME" > dist/release_android/SFA-version-metadata.json
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android
rm -rf dist/release_android
release_android: build_android upload_android
publish_android:
cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop
# TODO: find why and remove `-destination 'generic/platform=iOS'`
# TODO: remove xcode clean when fix control widget fixed
build_ios:
cd ../sing-box-for-apple && \
rm -rf build/SFI.xcarchive && \
xcodebuild clean -scheme SFI -derivedDataPath build/SFI.dd && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -derivedDataPath build/SFI.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_ios_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
build_ios_deb:
$(MAKE) -C ../sing-box-for-apple build_ios_deb
upload_ios_deb:
ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64.deb"
release_ios: build_ios upload_ios_app_store
release_ios_deb: build_ios_deb upload_ios_deb
build_macos:
cd ../sing-box-for-apple && \
rm -rf build/SFM.xcarchive && \
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -derivedDataPath build/SFM.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_macos_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFM.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
release_macos: build_macos upload_macos_app_store
build_macos_standalone:
$(MAKE) -C ../sing-box-for-apple archive_macos_standalone
build_macos_dmg:
$(MAKE) -C ../sing-box-for-apple build_macos_dmg
build_macos_pkg:
$(MAKE) -C ../sing-box-for-apple build_macos_pkg
notarize_macos_dmg:
$(MAKE) -C ../sing-box-for-apple notarize_macos_dmg
notarize_macos_pkg:
$(MAKE) -C ../sing-box-for-apple notarize_macos_pkg
upload_macos_dmg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.dmg "dist/SFM/SFM-${VERSION}-Apple.dmg"
cp ../sing-box-for-apple/build/SFM-Intel.dmg "dist/SFM/SFM-${VERSION}-Intel.dmg"
cp ../sing-box-for-apple/build/SFM-Universal.dmg "dist/SFM/SFM-${VERSION}-Universal.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.dmg"
upload_macos_pkg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.pkg "dist/SFM/SFM-${VERSION}-Apple.pkg"
cp ../sing-box-for-apple/build/SFM-Intel.pkg "dist/SFM/SFM-${VERSION}-Intel.pkg"
cp ../sing-box-for-apple/build/SFM-Universal.pkg "dist/SFM/SFM-${VERSION}-Universal.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.pkg"
replace_macos_pkg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.pkg "dist/SFM/SFM-${VERSION}-Apple.pkg"
cp ../sing-box-for-apple/build/SFM-Intel.pkg "dist/SFM/SFM-${VERSION}-Intel.pkg"
cp ../sing-box-for-apple/build/SFM-Universal.pkg "dist/SFM/SFM-${VERSION}-Universal.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.pkg"
upload_macos_dsyms:
mkdir -p dist/SFM
cd ../sing-box-for-apple/build/SFM.System-universal.xcarchive && zip -r SFM.dSYMs.zip dSYMs
cp ../sing-box-for-apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM/SFM-${VERSION}.dSYMs.zip"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}.dSYMs.zip"
replace_macos_dsyms:
mkdir -p dist/SFM
cd ../sing-box-for-apple/build/SFM.System-universal.xcarchive && zip -r SFM.dSYMs.zip dSYMs
cp ../sing-box-for-apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM/SFM-${VERSION}.dSYMs.zip"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}.dSYMs.zip"
release_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg upload_macos_dsyms
replace_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg upload_macos_dsyms
build_tvos:
cd ../sing-box-for-apple && \
rm -rf build/SFT.xcarchive && \
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -derivedDataPath build/SFT.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_tvos_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
export_tvos_ipa:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFT && \
cp build/SFT/sing-box.ipa dist/SFT.ipa
upload_tvos_ipa:
cd dist && \
cp SFT.ipa "SFT-${VERSION}.ipa" && \
ghr --replace --draft --prerelease "v${VERSION}" "SFT-${VERSION}.ipa"
release_tvos: build_tvos upload_tvos_app_store
update_apple_version:
go run ./cmd/internal/update_apple_version
update_macos_version:
MACOS_PROJECT_VERSION=$(shell go run -v ./cmd/internal/app_store_connect next_macos_project_version) go run ./cmd/internal/update_apple_version
release_apple: lib_apple update_apple_version release_ios release_macos release_tvos release_macos_standalone
release_apple_beta: update_apple_version release_ios release_macos release_tvos
publish_testflight:
go run -v ./cmd/internal/app_store_connect publish_testflight $(filter-out $@,$(MAKECMDGOALS))
prepare_app_store:
go run -v ./cmd/internal/app_store_connect prepare_app_store
publish_app_store:
go run -v ./cmd/internal/app_store_connect publish_app_store
test:
@go test -v ./... && \
cd test && \
go mod tidy && \
go test -v -tags "$(TAGS_TEST)" .
test_stdio:
@go test -v ./... && \
cd test && \
go mod tidy && \
go test -v -tags "$(TAGS_TEST),force_stdio" .
lib_android:
go run ./cmd/internal/build_libbox -target android
lib_apple:
go run ./cmd/internal/build_libbox -target apple
lib_windows:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type csharp
lib_android_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type android
lib_apple_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple
lib_install:
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.13
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.13
docs:
venv/bin/mkdocs serve
publish_docs:
venv/bin/mkdocs gh-deploy -m "Update" --force --ignore-version --no-history
docs_install:
python3 -m venv venv
source ./venv/bin/activate && pip install --force-reinstall mkdocs-material=="9.7.2" mkdocs-static-i18n=="1.2.*"
clean:
rm -rf bin dist sing-box
rm -f $(shell go env GOPATH)/sing-box
update:
git fetch
git reset FETCH_HEAD --hard
git clean -fdx
%:
@:
-70
View File
@@ -1,70 +0,0 @@
# Makefile.lx — sing-box-lx downstream build helpers.
# New file (zero edits to upstream Makefile) — see SPECS/CONSTITUTION.md §3.2.
# Usage: make -f Makefile.lx lx-build
# Canonical lx build-tag set for the desktop/CLI binaries — single source of truth
# (mirror changes in SPECS/004). = upstream feature set (release/DEFAULT_BUILD_TAGS)
# minus tags irrelevant to a VPN client — with_tailscale (no tailscale endpoints),
# with_ccm/with_ocm (Claude Code / OpenAI Codex proxy services), with_acme (server-side
# TLS cert issuance) — plus with_purego (CGO-free cross-compile covers
# with_naive_outbound via cronet prebuilts) and our downstream features.
#
# with_clash_api IS kept here: the desktop/CLI binary is driven through the Clash REST
# API by external dashboards (yacd / MetaCubeXD / clash-dashboard); there is no native
# CommandClient channel outside the gomobile/libbox binding, so dropping it would leave
# a CLI user with no way to manage the core (a config using experimental.clash_api would
# fail fast). It is dropped ONLY from the Android AAR (cmd/internal/build_libbox/main.go),
# where LxBox manages the core over the native libbox CommandClient and the Clash server
# is dead weight. So the two tag sets diverge by design — do NOT blindly mirror the AAR
# set here.
#
# with_purego/badlinkname need -checklinkname=0 in LX_LDFLAGS, otherwise the linker
# rejects badtls' go:linkname into crypto/tls.
LX_TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
# lx build counter over a given upstream base (override in CI/release: make -f Makefile.lx lx-build LX_BUILD=3).
LX_BUILD ?= 1
# Upstream base version from the nearest stable tag, excluding our own -lx tags (e.g. 1.13.13).
UPSTREAM_VERSION := $(shell git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*lx*' 2>/dev/null | sed 's/^v//')
LX_VERSION ?= $(UPSTREAM_VERSION)-lx.$(LX_BUILD)
# Stamp the version via ldflags only — constant/version.go stays untouched (zero upstream diff).
# -checklinkname=0: required by badlinkname/tfogo_checklinkname0 (Go 1.24 blocks the
# crypto/tls go:linkname in common/badtls otherwise) — mirrors upstream build_libbox.
LX_LDFLAGS = -X 'github.com/sagernet/sing-box/constant.Version=$(LX_VERSION)' -checklinkname=0 -s -w -buildid=
LX_OUTPUT ?= sing-box
# Pinned proto toolchain (SPEC 014 §3.5). Upstream `make proto` installs the codegen
# plugins at @latest, so .pb.go cannot be reproduced byte-for-byte across a rebase. We
# pin both plugins to versions matching go.mod (protobuf v1.36.11) and a gRPC codegen
# release compatible with the generated SupportPackageIsVersion9. `protoc` itself is an
# external dependency (install via your package manager, e.g. `brew install protobuf`);
# the generator at cmd/internal/protogen drives it and strips its version banner, so the
# protoc build number does not leak into the output. gofumpt normalises imports to match
# the committed style. Regenerate, never hand-edit, the .pb.go / _grpc.pb.go files.
LX_PROTOC_GEN_GO_VERSION ?= v1.36.11
LX_PROTOC_GEN_GO_GRPC_VERSION ?= v1.5.1
.PHONY: lx-build lx-version lx-print-tags lx-check lx-proto-install lx-proto
lx-proto-install: ## Install the pinned protoc-gen-go / protoc-gen-go-grpc plugins.
go install google.golang.org/protobuf/cmd/protoc-gen-go@$(LX_PROTOC_GEN_GO_VERSION)
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(LX_PROTOC_GEN_GO_GRPC_VERSION)
go install mvdan.cc/gofumpt@latest
lx-proto: lx-proto-install ## Regenerate *.pb.go reproducibly from the merged .proto (needs system protoc on PATH).
@command -v protoc >/dev/null 2>&1 || { echo "protoc not found on PATH — install it (e.g. brew install protobuf)"; exit 1; }
go run ./cmd/internal/protogen
gofumpt -w .
lx-build: ## Build the drop-in `sing-box` binary with lx features.
CGO_ENABLED=0 go build -v -trimpath -tags "$(LX_TAGS)" -ldflags "$(LX_LDFLAGS)" -o "$(LX_OUTPUT)" ./cmd/sing-box
lx-version: ## Print the computed lx version string (e.g. 1.13.13-lx.1).
@echo "$(LX_VERSION)"
lx-print-tags: ## Print the canonical LX_TAGS set (so CI/release don't duplicate it).
@echo "$(LX_TAGS)"
lx-check: lx-build ## Validate sample configs with the freshly built binary.
./$(LX_OUTPUT) check -c lx-test/config/minimal.json
+222
View File
@@ -0,0 +1,222 @@
# PROOFS — доказательная база v1 (goal acceptance)
Каждый пункт гола подтверждается воспроизводимым доказательством (команда + вывод).
Стенд: OpenWrt 24.10.3 QEMU VM (Docker), плагин установлен из opkg-фида.
## ✅ KEYSTONE — реальный проксинг через живую ноду из подписок пользователя (2026-07-09)
Метод: xray observatory (leastPing balancer) над нодами подписок → сам выбирает живую → socks :10808.
```
-- DIRECT exit IP -- 45.131.214.140 (прямой выход VM через slirp)
-- via SOCKS (node) -- 185.226.172.8 (трафик прошёл ЧЕРЕЗ живую ноду)
-- observatory -- g_main_node8 is alive; g_main_node7/9/31..38 is dead (реальная detection)
```
Вывод: data-plane реально проксирует внешний трафик через рабочую ноду из подписок
(`pro.qomar.pw`/`no-route-today`); observatory корректно отличает живые/мёртвые.
Ноды ротируются — метод воспроизводим: `xrayctl gen --links <sub>` → balancer+observatory → socks-тест.
Осталось подтвердить полным путём плагина (LAN-клиент netns/veth → tproxy → нода → exit-IP меняется)
после интеграции фиче-веток (см. ACCEPTANCE.md).
## Реестр (заполняется по мере проверки)
- [x] Egress с VM работает (`wget https://api.ipify.org` → 45.131.214.140).
- [x] Плагин ставится из репозитория (opkg feed) + апгрейд r2.
- [x] CI зелёный (x86_64 + aarch64_cortex-a53), фид с корректными Depends.
- [x] LuCI Overview/Nodes рендерят живые данные ubus (Playwright).
- [x] `xrayctl apply` строит nft `inet shater` (tproxy iifname br-lan) + policy-routing.
- [ ] mgmt-bypass: router-egress НЕ ломается при apply (сейчас баг «Operation not permitted») — чинит агент B.
- [ ] Полный LAN-клиент → нода → exit-IP меняется.
- [ ] Egress-binding / kill-switch / DNS / rulesets / stats / live-ping — реализуются агентами, проверить.
- [ ] Каждая LuCI-страница (Groups/Chains/Egress/Lists/DNS/Rules CRUD) — Playwright.
## Интеграционные заметки (для lead)
- main.go: доинтегрировать CLI-диспетч под новые команды агентов: `node import`, `sub update [name]`,
`ruleset update [name]` (агенты C экспортируют функции, main.go — на мне).
- Согласовать shape stats JSON (агент B) ↔ overview.js (агент D): `stats.nodes[]/clients[]/rules[]`.
- Рабочая нода для E2E: пере-найти через observatory на момент проверки (ноды живут недолго).
## ✅ ПОЛНЫЙ END-TO-END через плагин (интегрированный T1-код, 2026-07-09)
Конфиг: 50 реальных нод из подписок → группа `main` (leastPing) → tproxy-правило. `xrayctl apply`.
```
xrayctl nodes (via xray API): 39 alive (live-ping/alive реально работает)
LAN client (netns 192.168.1.60 в br-lan) exit: 116.203.69.142 (проксировано через живую ноду)
router direct exit: 45.131.214.140 (mgmt-bypass: НЕ проксирован)
```
Вывод: трафик реального LAN-клиента прошёл через плагин (nft tproxy → xray balancer → живая нода) —
**exit-IP сменился**. Полный keystone цели закрыт.
## ✅ mgmt-bypass (router-egress не ломается при apply) — фикс B
При активном tproxy: `wget https://api.ipify.org` с роутера → `45.131.214.140`, SSH жив,
`fib daddr type local accept` в nft. Раньше был «Operation not permitted» — починено.
## ✅ Статистика per-client / per-inbound / per-node
После клиентского трафика:
```
nft set clients: { 192.168.1.60 counter packets 69 bytes 6533 }
xrayctl stats: clients:[{ip:192.168.1.60,bytes:6533,packets:69}], inbounds:[{name:lan,c_in_lan,...}], nodes:[...] (xray Stats API)
```
## ✅ Генератор со всеми T1-фичами → реальный xray -test
Конфиг с egress-binding(interface) + resolvers(doh/fakeip+pool) + dns_rule(geosite) + kill-switch(closed)
+ stats/api + observatory → `xray -test: Configuration OK` (с установленными v2ray-geoip/geosite).
Фичи в run.json: api, stats, policy, observatory, fakedns, egress-viawan, sockopt.
---
## ✅ КРИТИЧЕСКИЙ ФИКС: fork-storm/OOM в reconcile (2026-07-09, 512M VM)
**Симптом:** после `xrayctl apply`/на буте VM (512M) уходила в 100% на обоих vCPU,
жуткий лаг; `ps` → OOM-killer. Причина: **бесконечная взаимная рекурсия**
`Reconcile() → reloadXray() → «/etc/init.d/shater reload» → reload_service →
shater_reconcile → xrayctl reconcile → Reconcile() → …`. Каждый уровень блокируется на
`.CombinedOutput()` и плодит процесс → **854×`xrayctl reconcile` + 853×`sh …/shater reload`**
→ форк-бомба → исчерпание памяти.
```
до фикса: 5239 процессов, loadavg ~200, docker CPU 216%, OOM-killer в логе ядра
```
**Фикс (код):**
- `apply.go Reconcile()` больше НЕ вызывает `reloadXray()` (procd сам рестартит xray по
file-watch на run.json). Reconcile вызывается ИЗ init-цикла → обратный вызов init = рекурсия.
- `apply.go reloadXray()` теперь `/etc/init.d/shater start` (не `reload`); зовётся только из
`Apply()`/`Rollback()` (LuCI/ubus/CLI), не из init-цикла → рекурсии нет.
- init `reload_service()` упрощён до `start`/`stop` (убран двойной reconcile).
**Проверено вживую (512M, активный конфиг из 254 нод):**
```
xrayctl apply → real 0m0.39s (было: вис >185s)
после apply: 93 процесса, loadavg 0.00 (стабильно 3× замера)
xray слушает: 127.0.0.1:10853 (API) + :::12345 tcp/udp (tproxy)
nft inet shater = OK; ip rule: from all fwmark 0x2000 lookup shater
```
## ✅ ФИКС КОНФЛИКТА ПАКЕТОВ: UCI-namespace `xray` → `shater` (2026-07-09)
**Симптом:** `opkg install` клал shater-конфиг в `/etc/config/xray-opkg`, активным
оставался стоковый `/etc/config/xray` пакета **xray-core** (зависимость). Конфликт
conffile: два пакета владели `/etc/config/xray`.
**Фикс:** UCI-namespace переименован `xray` → `shater` во всём коде (Go `uci export/commit`,
LuCI `form.Map/uci.*` во всех 11 view + acl, init-скрипты, hotplug, Makefile/conffiles,
examples). Рантайм-пути (`/etc/xray/run.json`, `/usr/share/xray`, бинарь/пакет `xray-core`,
ubus-объект `xray`) НЕ тронуты. `go build`+`gofmt`+`go test` зелёные.
```
после: /etc/config/shater (1202B, схема shater) — конфликта нет; uci show shater OK
```
## ✅ ПОЛНЫЙ FEED-ФЛОУ на чистом OpenWrt 24.10.3 (512M, свежий диск)
`vm-start.ps1 -Fresh` → pristine OpenWrt → штатная установка:
```
opkg install: xray-core kmod-nft-tproxy kmod-nft-socket ip-full kmod-veth v2ray-geoip
v2ray-geosite luci dnsmasq-full openssh-sftp-server (из офиц. репозиториев)
src/gz shater file:///tmp/shfeed → opkg update → opkg install luci-app-shater
→ подтянул xrayctl (r3) + shater-core (r3) из фида; /etc/config/shater установлен корректно
xrayctl исполняемый (postinst chmod), xrayctl gen читает shater-config (260 тегов)
```
## ✅ KEYSTONE E2E — реальный LAN-клиент → плагин → живая нода → EXIT-IP СМЕНИЛСЯ
Подписка пользователя `pro.qomar.pw` → `xrayctl sub update sub0` → **254 ноды** в кеш →
группа `main` (leastPing) → правило `def`→group:main → inbound `lan` tproxy → `xrayctl apply`.
LAN-клиент = netns `shater_lan` (192.168.1.60), veth в br-lan (реальный transit).
```
LAN-клиент exit (через плагин): 104.156.233.234
роутер direct exit (mgmt-bypass): 45.131.214.140
```
**Exit-IP реального LAN-клиента сменился** → трафик прошёл nft tproxy → xray balancer →
живую ноду подписки. Полный путь плагина доказан на 512M без шторма.
## ✅ Статистика per-client / per-inbound / per-node (живой трафик)
```
nft set clients: { 192.168.1.60 counter packets 18 bytes 1134 }
xrayctl stats: clients:[{ip:192.168.1.60,bytes:1134,packets:18}],
inbounds:[{name:lan,counter:c_in_lan,packets:18,bytes:1134}], nodes:[…xray API…]
xrayctl nodes: per-node JSON {name,group,proto,tag,alive,latency_ms,selected,fingerprint,stale}
```
## ✅ DNS anti-leak: LAN :53 hijack + DoT block (2026-07-09)
RenderNft `inet shater` теперь эмитит: `dnsnat` (nat/prerouting) redirect LAN :53→роутер;
prerouting reject DoT/DoQ :853 (до tproxy); :53 исключён из tproxy (иначе catch-all
проглатывает). Проверено на LAN-клиенте:
```
client -> nslookup example.com 8.8.8.8 => 172.66.147.243 (ответ дал РОУТЕР, не 8.8.8.8: bypass невозможен)
client -> :853 (DoT) => refused (exit 1); :443 control => connect (exit 0) => DoT заблокирован
```
xray dns-объект (named resolvers DoH/dot/plain/local/FakeIP, per-domain/client routing,
FakeIP-пул, queryStrategy, IPv6) рендерится и проходит `xray -test`. Проксируемый трафик
маршрутизируется по SNI (resolved==routed на выходе). explain/trace даёт верное решение:
```
xrayctl explain 192.168.1.60 example.com => matched_rule=def target=group:main
xrayctl explain 192.168.1.61 example.com => matched_rule=clientB-direct target=direct
```
## ✅ Multi-hop цепочка (2 хопа) реально проксирует
chain c1 = node:DE-vless-8 (L1) → node:NL-ss-4 (L2), inverted proxySettings через socks-пару.
```
LAN-клиент через chain:c1 exit = 193.29.139.249 (≠ direct 45.131.214.140, ≠ single-hop 104.156.233.234)
```
## ✅ Per-client policy (два устройства одного LAN, раздельно)
```
A (192.168.1.60) -> group:main exit = 104.156.233.234 (проксирован)
B (192.168.1.61) -> direct exit = 45.131.214.140 (= router direct, НЕ проксирован)
```
## ✅ Observatory (живой пинг/alive)
```
xrayctl nodes: 186/254 alive, latency_ms реальные (напр. 149ms), proto/fingerprint/stale
```
## ✅ Rollback / commit-confirm (атомарный apply + safety)
Фикс: procd file-watch на этой сборке не срабатывает пассивно → reloadXray SIGTERM'ит xray
(procd respawn перечитывает run.json без reconcile-из-UCI → не затирает rollback); init
`respawn 10 2 5` чтобы operator-рестарты не считались краш-циклом.
```
apply(config change) -> xray RELOAD (pid сменился, новый роутинг)
apply good -> apply block -> xrayctl rollback -> run.json g_main восстановлен + xray его крутит
apply --confirm 30 -> pending ARMED -> confirm -> CLEARED (конфиг остаётся)
apply block --confirm 6 (без confirm) -> через ~6s авто-rollback -> last-good восстановлен, pending CLEARED
```
## ✅ Апгрейд из фида (opkg upgrade r3->r4)
```
opkg upgrade xrayctl shater-core: 0.1.0-r3 -> 0.1.0-r4 (оба), init обновлён (respawn 10 2 5),
conffile /etc/config/shater сохранён (правки пользователя переживают апгрейд), data-plane не нарушен
```
## ✅ CI зелёный на обеих арках (Gitea Actions)
```
run #14 (05920f83, fork-storm fix): x86_64=success aarch64_cortex-a53=success
run #16 (e7b71d7, финальный HEAD): x86_64=success aarch64_cortex-a53=success
```
(проверено через git.qomar.pw/api/v1/repos/omar/shater/actions/tasks)
## ✅ Полировка панели (2026-07-09) — по фидбеку пользователя
- **Nodes: кнопка Test чинена** — колонка Latency читала `n.latency`, а API даёт `latency_ms`
(всегда «-»); теперь показывает реальные ms, сортирует по ним; кнопка Test делает живую
TCP-пробу (`node test`, конкурентно 32 воркера) → ячейка «73 ms ✓» + тост «alive 73 ms»;
«Test all» 254 ноды за секунды («Probed 254 nodes — 188 reachable»).
- **Overview переработан** — вместо дампа 254 нод по 0 B: KPI-карточки (Engine running,
Live nodes 188/254, Download/Upload rate+total, Active clients, Active node), курированные
таблицы (только ноды/клиенты с трафиком, share-бары), rate из дельт поллинга, theme-aware.
- **Egress exit-интерфейс — доведён до рабочего E2E**: правило→egress(interface=wan) →
outbound `egress-viawan` (freedom, `sockopt.interface:"eth1"` резолвнут из UCI-имени + mark),
policy-routing `ip rule fwmark 0x2100→table 8208` + `default via 10.0.2.2 dev eth1`, nft-bypass;
LAN-клиент через egress вышел в интернет (45.131.214.140). explain показывает egress.
- **Все 11 LuCI-страниц** повторно прогнаны Playwright'ом на переименованном `shater`-конфиге:
Overview/Nodes/Subscriptions/Groups/Chains/Egress/Rules/Lists/DNS/Settings/Profiles — рендерят
живые данные, 0 console-ошибок. Установлено апгрейдом из фида до r5, UI работает из пакета.
## ✅ Настраиваемые проверки доступности (2026-07-09) — по запросу пользователя
Вопрос: «reachable in observatory — каким методом? почему доступно? ничего не настраивается».
Ответ+реализация:
- **Observatory alive/latency** = xray тянет probe-URL ЧЕРЕЗ каждую ноду раз в интервал (204=alive).
URL+интервал теперь глобальные (`globals.probe_url`/`probe_interval`, страница Settings) —
дефолт gstatic/generate_204, группа может переопределить. Проверено: `globals.probe_url=cloudflare`
→ observatory-блок в run.json = `probeURL: cloudflare, probeInterval: 30s`.
- **On-demand Node Test — метод настраиваем, TCP остаётся дефолтом**:
- `tcp` (быстро): сырой connect к эндпоинту (без изменений).
- `http` GET/HEAD: РЕАЛЬНАЯ проба через прокси — эфемерный xray {socks→нода} + stdlib SOCKS5 +
HTTP → status+latency+exit-IP. Проверено: `node test DE-vless-8 --method http` → 200, exit 91.107.150.14.
- **Проверка связки** (`chain test NAME`): проба всей цепочки end-to-end (реальная chain-машинерия +
её внутренний socks-routing). Проверено: chain DE-vless-8→NL-ss-4 → 200, 379ms, exit 193.29.139.249
(совпадает с tproxy-E2E). UI: Nodes (селектор TCP/HTTP GET/HEAD+URL), Chains («Test chain» → reachable·ms·exit),
Settings (Health-check URL/interval) — прогнано Playwright'ом.
-130
View File
@@ -1,130 +0,0 @@
<!-- Language: [Русский](README.md) · **English** -->
# shater
**A self-hosted internet-control appliance for OpenWrt routers.** One box turns a
home or office network into a transparent VPN gateway, a network-wide
ad/tracker/malware blocker, per-device parental control, and a live traffic
dashboard — all local, all configured from a rich built-in web panel.
> The primary README is Russian — [README.md](README.md). This is a condensed
> English mirror.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
## What it is
shater is a network proxy stack for **OpenWrt / ImmortalWrt / BananaWRT** routers
(Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic
through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and
is managed from a built-in web panel.
The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — exactly what
`shater/parse` can read and `shater/registry` registers in the engine.
A thin **LuCI launcher** (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
(default `:8088`).
## Highlights
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks
— `:53` interception is on by default and covers the queries a client sends to the
router itself, not just the ones aimed around it (`globals.dns_intercept`, D24).
- First-match routing by source / destination / list / geo / client → outbound /
selector / chain / direct / block; node groups with balancer/observatory;
multi-hop chains; per-rule egress.
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
`inet shater` nft table; atomic apply with `nft -c` validation. Commit-confirm
auto-rollback exists but **ships OFF** (`confirm_timeout=0`) — arm it yourself.
- **DNS filtering & blocklists** with flexible sources (inline / file / url /
geosite), compiled `.srs` matcher; Block-DoH/DoT to stop filter bypass.
- Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow,
schedules) and per-domain/client/device statistics from in-process DNS events.
Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
## Install
One signed **apk** feed (OpenWrt / ImmortalWrt / BananaWRT **25.12+**), one
release per arch. Verbatim commands, the manual `.apk` install and the
rolling-vs-pinned choice are in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
```sh
wget -O /etc/apk/keys/shater-apk.pem "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" > /etc/apk/repositories.d/shater.list
apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
shater ships **inert** (globals off) so install never breaks connectivity. After
configuring nodes/rules:
```sh
uci set shater.globals.enabled=1
uci set shater.globals.confirm_timeout=120 # commit-confirm ships OFF — arm it
uci commit shater
shaterd apply && shaterd confirm
```
Without that middle line `shaterd apply` arms no auto-rollback (and says so), so an
apply that costs you SSH/LuCI access has to be undone by hand.
Once an enabled, fail-closed config has been applied, `/etc/init.d/shater-armor`
loads a saved fail-closed plane at **boot**, before the daemon exists: LAN→WAN
forwarding is blocked until `shaterd` applies, while SSH/LuCI/the panel stay
reachable on purpose (the chain hooks `forward` only). What arms it, what refuses
to arm, and how to switch it off — `INSTALL.md` §4.
## Build from source
`scripts/build-shaterd.sh [VERSION] [--fast]` builds the SPA (Vite), embeds it via
`//go:embed`, cross-builds musl-static `{amd64, arm64}` and UPX-packs the artifact
into `openwrt/shaterd/files/`. Details in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
`bash scripts/run-tests.sh` is the test gate: the whole suite under the **shipped**
build tags (`scripts/router-tags.sh`), on linux (it re-execs in Docker from a
non-linux host), with `-race`, plus three machine checks against a silent skip —
the tag set may only add test files, every package with tests must report `ok` by
name, and every `TestIntegration*` must produce a verdict by name.
`scripts/check-router-tags.sh` separately proves no feature declared in
`FEATURES.md` lost a build tag it needs. A green gate is necessary but not
sufficient: it does not see the kernel, procd or nftables seams.
## Repository layout
| Path | What |
|------|------|
| `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` |
| `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 |
| `docs/`, `mkdocs.yml` | **Upstream** sing-box docs (mkdocs) — kept as-is |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | sing-box-lx engine tree |
## CI, upstream & license
CI (`.gitea/workflows/release.yml`) builds all 4 packages and publishes a signed
per-arch apk repo (EC key `shater-apk.pem`). A `vX.Y.Z` tag → the pinnable
`apk-vX.Y.Z-<arch>`; every run also refreshes the rolling `apk-latest-<arch>` and
asserts over the API that it really serves the version just built.
The engine is the **sing-box-lx** fork — a thin downstream of upstream sing-box that
lives by **rebase, never merge**; its constitution is
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md). Licensed under
[GPL-3.0](LICENSE), like upstream sing-box. Unofficial fork, not affiliated with
SagerNet.
+54 -346
View File
@@ -1,364 +1,72 @@
<!-- Язык: **Русский** · [English](README.en.md) -->
# shater
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
трекеров и вредоносных доменов, средство родительского контроля по устройствам
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
настраивается из богатой веб-панели.
**OpenWrt-плагин управления XRAY** — как passwall2, только чище, быстрее и без лагов.
Переносимый: подписки → ноды → цепочки → правила → выбор egress, с прозрачным роутингом,
железной надёжностью, per-consumer статистикой и удобным UI.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
![feed: apk 25.12+](https://img.shields.io/badge/feed-apk%2025.12%2B-orange.svg)
> Статус: **MVP собран, проверен на тестовом OpenWrt, CI зелёный, ставится из репозитория.**
---
## Установка (opkg-фид)
## Что это
**shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
веб-панели.
Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — ровно то, что умеет разобрать
`shater/parse` и что регистрирует `shater/registry` в движке.
Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
поднятую демоном на собственном порту (по умолчанию `:8088`).
---
## Ключевые возможности
**Прозрачный прокси и маршрутизация**
- TPROXY data-plane для нескольких LAN-интерфейсов (TCP + UDP), сниффинг
SNI/Host/QUIC, без утечек DNS.
- Правила маршрутизации по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
(domain/suffix/keyword/geosite), спискам, порту, протоколу →
outbound / selector / chain / direct / block.
- Группы узлов с балансировщиком/обсерваторией (least-ping / failover /
round-robin), **мульти-хоп цепочки** и выбор egress по правилу.
**Надёжность («железно»)**
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
трогаем.
- Атомарный apply с валидацией движком и `nft -c`. **Commit-confirm** с
авто-откатом к последней рабочей конфигурации есть, но **на стоковой установке
выключен**: `confirm_timeout` поставляется нулём, и apply не вооружает ничего,
пока вы не зададите окно (см. «Включение»).
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
(SSH/LuCI/LAN) всегда в обход.
**DNS, фильтрация, блокировки**
- Перехват `:53`, DNS движка sing-box в процессе; резолверы DoH/DoT/plain/FakeIP,
выбор резолвера по домену.
- **Блок-листы с гибкими источниками**: `inline` / `file` / `url` (авто-обновление) /
категория `geosite`; hosts-файл, plain-список или AdBlock-стиль `||domain^`
компилируются в локальный `.srs`. Эффективный компилированный матчер вместо
dnsmasq-мегасписков.
- **Block-DoH/DoT** — не даёт устройствам обходить фильтр через свой шифрованный DNS.
**Подписки и узлы**
- Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
интервал обновления + вручную + на загрузке; стабильная идентичность узла между
обновлениями; квоты/срок из `subscription-userinfo`.
- Ручные узлы: share-ссылки, импорт файла, `wg-quick`/AmneziaWG `.conf`.
- Health board: TCP + реальная проба через прокси-путь, exit-IP, «протестировать
все».
**Контроль по устройствам**
- Авто-обнаружение устройств (dhcp.leases + `ip neigh`), имена, живой статус/трафик.
- Тумблеры на устройство: прокси on/off, блок-листы on/off, страна/узел выхода;
блок/allow домена для одного устройства или для всех; расписания.
**Статистика и видимость**
- Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
устройствам, таймлайны — из DNS-событий движка в процессе (без скрейпинга логов).
- Трафик по клиенту/узлу/правилу (байты) из nft-счётчиков; живой query-log.
**Панель и профили**
- Встроенная SPA-панель (собственный порт, вшита в бинарь): overview, узлы и
подписки, правила маршрутизации, DNS/блок-листы, устройства, apply/rollback.
- Именованные профили/сцены и WAN-профили (условные оверрайды).
Полный список с тегами MVP/T1/T2 — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
---
## Архитектура
Один бинарь `shaterd` держит движок, control-plane, DNS-фильтр и веб-сервер панели
в одном процессе; OpenWrt-обвязка (тонкий LuCI + procd/system glue) оборачивает его.
Конфиг — UCI desired-state; демон рендерит его в конфиг движка и применяет;
телеметрия течёт обратно в панель.
```mermaid
flowchart TB
subgraph BIN["shaterd — один бинарь (форк sing-box-lx)"]
ENG["движок sing-box\nпротоколы · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
CTRL["control-plane (shater/)\nUCI-модель · генерация конфига · apply/rollback · nft/routing"]
FILT["DNS-фильтр + блок-листы + политика по устройствам (shater/)"]
STAT["агрегатор статистики (shater/)"]
PANEL["веб-сервер панели + вшитая SPA (свой порт, токен-auth)"]
end
subgraph WRT["OpenWrt-обвязка (openwrt/)"]
LUCI["тонкий LuCI — мини-дашборд + кнопка «Открыть панель»"]
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
end
LUCI -->|"ubus: mint token"| PANEL
PROCD --> BIN
CTRL --> ENG
FILT --> ENG
ENG --> STAT
STAT --> PANEL
```
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
(проксировано) · direct (обычный маршрут, без туннеля) · block. TPROXY несёт
только TCP и UDP; ICMP и остальные протоколы — через отдельные опциональные
механизмы (`l3_tunnel`, `untunnelable_egress`, ARCHITECTURE §3a). Подробные
диаграммы (auth-handoff, data-plane, DNS-flow, apply-flow) — в
[`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
---
## Установка
shater поставляется одним подписанным **apk-фидом** (OpenWrt / ImmortalWrt /
BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/apk/keys/`).
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
`shaterd` подтягивается автоматически как зависимость.
### Фид apk
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
Каждый push в `main` публикует на Gitea готовый **подписанный (usign) opkg-фид**, поэтому роутер ставится один раз и потом обновляется обычным `opkg upgrade` — с включённой проверкой подписи. opkg фильтрует по архитектуре, так что **одни и те же команды подходят любому устройству** (BPI-R3/R4 → `aarch64_cortex-a53`, тест-VM → `x86_64`):
```sh
# 1) доверяем ключу apk-фида (любое имя *.pem под /etc/apk/keys подходит).
wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
# 2) добавляем репозиторий — строка указывает на сам ФАЙЛ-ИНДЕКС packages.adb.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
# 1. один раз — доверяем публичному ключу фида (имя файла = отпечаток ключа)
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
# 2. добавляем фид и ставим
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
opkg update
opkg install luci-app-shater # тянет xrayctl + shater-core
```
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
пакеты.
Дальше — **LuCI → Services → Shater (xray)** и мастер быстрой настройки (вставь ссылку подписки → один клик).
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater
# эквивалент, дополнительно закрепляющий пакеты в world:
# apk add -u shaterd shater-core luci-app-shater
```
Обновление: `opkg update && opkg upgrade xrayctl 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`.
> Фид подписан ключом `5ac4b177689cb8e0` — `check_signature` остаётся включённым. Пиннинг на версию, ротация ключа, заметка про apk (OpenWrt 25.x) и разбор граблей — в **[FEED.md](FEED.md)**. Прямая установка из `.ipk`/тарбола — в [BUILD.md](BUILD.md).
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
> «поставил и забыл»: `apk update` сам видит новую сборку. `apk-vX.Y.Z-<arch>` —
> фиксация на конкретной сборке: роутер не получит ничего нового, пока
> `/etc/apk/repositories.d/shater.list` не отредактируют руками — на каждом
> роутере и на каждый релиз. На `mini_router` сознательно прописан
> версионированный URL, и ручная правка — его цена. Подробнее —
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §5.1.
## Ключевые решения
- Движок: **только xray-core** — НЕ форкаем; `xrayctl` = отдельный control-plane демон (Go). sing-box — потом адаптером.
- Генератор: **Go `xrayctl`** (парсинг share-links через libXray, валидация `xray -test`).
- MVP: **надёжный одиночный transparent-proxy** (TPROXY, мульти-LAN); цепочки/per-client — фаза 2.
- Сборка: **OpenWrt + ImmortalWrt/BananaWRT**, 24.10(ipk)+25.x(apk), мультиарк, CI + подписанный фид.
- Тест-стенд: Docker/QEMU (QEMU для netfilter/tproxy, WSL2 для сборки).
> Версии пакетов CI берёт из git-тега (`vX.Y.Z` → `X.Y.Z-r1`, сборка вне тега →
> `X.Y.Z-r<коммитов+1>`), поэтому каждая новая сборка действительно видна
> менеджеру пакетов как новая. Подробности — `docs-shater/INSTALL.md` §2.1.
## Документы
| # | Файл | О чём |
|---|------|-------|
| 00 | [00-summary-and-plan.md](00-summary-and-plan.md) | Сводка, locked-решения, план по фазам, риски |
| 01 | [01-mini_router-audit.md](01-mini_router-audit.md) | Аудит боевого роутера (эталон того, что автоматизируем) |
| 02 | [02-xray-plugin-design.md](02-xray-plugin-design.md) | Дизайн, структура пакета, фазы |
| 03 | [03-features-and-model.md](03-features-and-model.md) | Модель (8 объектов), правила, egress, идентичность нод |
| 04 | [04-ops-reliability-build-stats.md](04-ops-reliability-build-stats.md) | Надёжность («железно»), сборка, статистика |
| 05 | [05-feature-catalog.md](05-feature-catalog.md) | Полный каталог фич (12 доменов, теги MVP/T1/T2/T3) |
| 06 | [06-north-star-imba.md](06-north-star-imba.md) | Амбициозные «мечты»: дашборд, per-client, автоматизация |
| 07 | [07-architecture.md](07-architecture.md) | **Все схемы (Mermaid): архитектура, flow, lifecycle, SM** |
> Полные инструкции — ручная установка из `.apk`, фиксация версии
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
Визуальная версия схем (артефакт): см. `architecture.html` (тот же контент, наглядно).
### Включение
## Модель в одну строку
`Node → Group(+balancer) → Chain(L1..Ln) → Egress` · `Rule(src/dst/list/geo → target+egress)` ·
`Inbound(multi-LAN tproxy)` · `Profile(WAN-mode)` · `List(domain/ip, auto-update)`.
shater ставится **инертным** (globals выключены), чтобы установка не рвала связь.
Настройте узлы/правила (через панель или `uci`), затем включите и примените:
```sh
uci set shater.globals.enabled=1
# Предохранитель: commit-confirm поставляется ВЫКЛЮЧЕННЫМ (confirm_timeout=0),
# и без этой строки apply ничем не подстрахован. 120 с — окно на проверку связи.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply # применить и вооружить авто-откат на 120 с
shaterd confirm # подтвердить в пределах окна (отменяет авто-откат)
```
`shaterd apply` печатает, вооружил ли он что-нибудь, и почему нет: при
`confirm_timeout=0` он прямо говорит, что автоматического отката НЕТ. Оставить
ноль — сознательный выбор: тогда apply, отрезавший вам SSH/LuCI, придётся
откатывать руками.
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
панель (`:8088` по умолчанию).
После первого же применённого включённого fail-closed конфига появляется
**загрузочная защита**: `/etc/init.d/shater-armor` (START=21) грузит сохранённый
fail-closed план ещё до старта демона, закрывая те секунды между поднятием LAN и
первым apply, когда роутер форвардил трафик в WAN открытым. Форвардинг LAN→WAN
заблокирован, пока `shaterd` не применит конфиг; SSH, LuCI и панель при этом
доступны **намеренно** — цепочка вешается только на `forward`. Чем защита
вооружается, когда отказывается вооружаться и как её снять —
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §4.
---
## Сборка из исходников
Ship-артефакт — бинарь `shaterd` со вшитой SPA. Собирается вне дерева SDK скриптом
`scripts/build-shaterd.sh`:
```sh
scripts/build-shaterd.sh [VERSION] [--fast]
```
Что он делает: (1) собирает панель — `cd panel && npm ci && npm run build` (Vite →
`panel/dist`); (2) копирует `panel/dist/*` в `shater/panel/webroot/`, откуда
`//go:embed` вшивает **реальную** SPA в бинарь; (3) кросс-собирает под `{amd64,
arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), stripped/trimmed;
(4) прогоняет UPX `--lzma --best` (~42 МБ → ~8–11 МБ); (5) стейджит артефакт в
`openwrt/shaterd/files/` для пакета.
Затем OpenWrt-пакеты из `openwrt/` собираются каноническим путём SDK. Детали
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Проверка
```sh
bash scripts/run-tests.sh # полный гейт
bash scripts/run-tests.sh --no-race # без -race, для локального цикла
```
Гейт гоняет весь набор **под теми же build-тегами, с которыми собирается
роутерный бинарь** (`scripts/router-tags.sh`), на Linux (с не-Linux хоста — сам
перезапускается в Docker), с `-race`, и содержит три машинные проверки против
молчаливого скипа: набор тегов может только ДОБАВЛЯТЬ тест-файлы; каждый пакет с
тестами обязан отчитаться `ok` поимённо; каждый `TestIntegration*` обязан выдать
вердикт по имени. Причина такая: до 2026-07 релизный тракт не гонял почти ничего
— 115 тест-файлов из 116 под `shater/**` в CI не исполнялись ни разу.
Отдельно `scripts/check-router-tags.sh` проверяет, что ни одна заявленная в
`FEATURES.md` фича не потеряла нужный ей build-тег.
Зелёный гейт — необходимое, но не достаточное условие: он не видит стыков с
ядром, procd и nftables. Это проверяется на стенде (см.
[`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md)).
---
## Структура репозитория
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
| Путь | Что это |
|------|---------|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанный apk-фид |
| `SPECS/` | Конституция форка движка и спеки (Spec Kit) |
| `docs-lx/` | Справочник конфигурации фич движка (`lx-config.md`, `.ru.md`) |
| `lx-test/`, `submodules/` | Примеры конфигов движка и submodule AmneziaWG-рантайма |
| `docs/`, `mkdocs.yml` | **Апстрим** документация sing-box (mkdocs) — как есть |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | Дерево движка sing-box-lx |
---
## CI и релизы
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
публикует **подписанные фиды**:
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
Gitea-secret `KEY_APK`).
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
развёрнутых роутерах.
---
## Связь с upstream и движок
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Это набор
самого форка, а не shater: MASQUE/CONNECT-IP мы намеренно **не регистрируем** —
`shater/generate` его не порождает, а отказ от него и остального незадействованного
зоопарка экономит ~6 МБ бинаря и столько же RAM на роутере (`shater/registry`). Форк
разрабатывается по Spec Kit; неизменяемые принципы — в
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md), справочник фич движка — в
[`docs-lx/lx-config.ru.md`](docs-lx/lx-config.ru.md).
История: **v0.1** (движок на xray-core, полностью рабочая и VM-проверенная версия)
сохранена на ветке **[`v0.1`](../../src/branch/v0.1)**. v0.2 схлопнула runtime в
один форкнутый бинарь.
---
## Документация
| Документ | О чём |
## Код, контракт, сборка
| Файл/дир | О чём |
|----------|-------|
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка apk-фида (роллинг/фиксация) |
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary дизайн, auth-handoff, data/DNS/apply-потоки (диаграммы) |
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Фазовый план |
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия |
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Визуальная система панели — «Faceplate», токены, компоненты |
| [`docs-shater/PORTING.md`](docs-shater/PORTING.md) | Порт проверенных кусков из v0.1 |
| [CONTRACT.md](CONTRACT.md) | UCI-схема `/etc/config/shater` + интерфейс `xrayctl` (CLI+ubus) |
| [STATUS.md](STATUS.md) | Что собрано и **чем проверено** (доказательная база) |
| [BUILD.md](BUILD.md) | Сборка `.ipk` (SDK/CI) + установка на роутер |
| [FEED.md](FEED.md) | Подключение репозитория как **opkg-фида**: install/update/upgrade, подпись, пиннинг |
| `xrayctl/` | Go control-plane (парсер, генератор, apply) |
| `shater-core/` | Системный пакет (procd/hotplug/sysctl/дефолт-конфиг) |
| `luci-app-shater/` | LuCI-приложение (JS-views + ubus-бэкенд) |
| `examples/` | Примеры `/etc/config/shater` + share-link фикстуры |
| `.github/workflows/build.yml`, `dist/` | CI (gh-action-sdk) + фид |
Индекс папки — [`docs-shater/README.md`](docs-shater/README.md).
---
## Оборудование
Арка `aarch64_cortex-a53` покрывает Banana Pi **BPI-R3** (MT7986/Filogic 830) и
**BPI-R4** (MT7988/Filogic 880) — оба таргет OpenWrt `mediatek/filogic`. `x86_64` —
QEMU-стенд для тестов.
---
## Лицензия
[GPL-3.0](LICENSE) — как у upstream sing-box. Подробности — в
[`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) (D6). Неофициальный форк, не
аффилирован с SagerNet.
## Статус
**Дизайн + MVP-ядро готовы и проверены** (см. [STATUS.md](STATUS.md)): генератор конфига
проходит реальный `xray -test` (`Configuration OK`) и на links-, и на UCI-пути; data-plane
(nft tproxy + policy routing) доказан на реальном ядре с реальным LAN-клиентом; `shater-core.ipk`
собран в SDK. Дальше — live `xrayctl apply`, полная CI-сборка, затем T1 (цепочки в UI, per-client, статистика).
</content>
-19
View File
@@ -1,19 +0,0 @@
# shater — этот файл переехал
Лицо этого репозитория — продукт **shater** (управляемый интернет-шлюз для
роутеров на OpenWrt). Основной README на русском — **[README.md](README.md)**;
краткая английская версия — **[README.en.md](README.en.md)**.
Раньше здесь лежал README форка движка **sing-box-lx**, который shater
вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
- **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)** — справочник конфигурации
фич движка (XHTTP, AmneziaWG 2.0, MASQUE).
- **[SPECS/CONSTITUTION.md](SPECS/CONSTITUTION.md)** — конституция тонкого форка
(принципы, build-tag изоляция, ребейз-модель).
- **[SPECS/README.md](SPECS/README.md)** — формат задач Spec Kit.
- Апстрим-README самого sing-box —
[на GitHub](https://github.com/Leadaxe/sing-box-lx).
> Файл оставлен как указатель, чтобы у репозитория был один основной русский
> README (`README.md`), а не два конкурирующих.
@@ -1,45 +0,0 @@
# IMPLEMENTATION_REPORT — 001 FORK_BOOTSTRAP
**Дата:** 2026-06-09 · **Статус:** Complete · **База:** upstream `v1.13.13`
## Что сделано
Заложен скелет downstream'а `sing-box-lx`: репозиторий, ветка, воспроизводимая сборка drop-in бинаря с версией `-lx`, CI-скелет. Фич-кода (XHTTP/AWG) нет — это 002/003.
## Изменённые / новые файлы
**Новые (зона касания upstream = ноль):**
- `Makefile.lx` — `LX_TAGS` (канонический набор), `LX_VERSION = <upstream>-lx.<N>`, цели `lx-build` / `lx-version` / `lx-check`. Версия штампуется **только ldflags** (`-X …constant.Version`), output — `sing-box`.
- `.github/workflows/lx-ci.yml` — build(lx tags) → version → `go vet` → `sing-box check` (linux/amd64; полная матрица — в 004).
- `lx-test/config/minimal.json` — валидный конфиг для `check` (mixed-in + direct-out). Положен в `lx-test/`, **не** в upstream `test/` (там отдельный Go-модуль).
- `AGENTS.md` — указатель для агентов (force-add: upstream его `.gitignore`-ит; новый файл → нулевой конфликт при ребейзе).
- `SPECS/**` — Spec Kit (CONSTITUTION, IMPLEMENTATION_PROMPT, README, задачи 001–004).
**Правок upstream-файлов: 0.** `constant/version.go`, `Makefile`, `.gitignore` — не тронуты.
## Проверки (DoD)
```
$ make -f Makefile.lx lx-version → 1.13.13-lx.1
$ make -f Makefile.lx lx-build → ./sing-box (28 MB)
$ ./sing-box version → 1.13.13-lx.1
Tags: …,with_xhttp,with_awg (пока no-op — кода нет)
$ ./sing-box check -c lx-test/config/minimal.json → OK
$ go vet ./constant/... → OK
```
## Решения по ходу
- **`Makefile.lx` вместо правки upstream `Makefile`** — отдельный файл = нулевая зона касания (CONSTITUTION § 3.2). Цели вызываются `make -f Makefile.lx <target>`.
- **Версия через ldflags** — upstream и сам так делает (`PARAMS = -ldflags "-X …constant.Version=…"`), поэтому `constant/version.go` не правим.
- **Email privacy** — коммиты переведены на `247031499+Leadaxe@users.noreply.github.com` (репо-локальный `user.email`), иначе GitHub отклоняет push.
- **Sample-конфиги** — каталог `lx-test/config/` (upstream `test/` — обособленный Go-модуль со своим `config/`).
## Зона касания upstream для будущих ребейзов
**Нулевая** (всё в новых файлах). Первый реальный `// lx:` дифф появится в 002 (диспетчер транспортов) и 003 (`go.mod`, wireguard-endpoint).
## Вне скоупа (передано дальше)
- Полная CI-матрица, авто-ребейз, релизы → **004**.
- Прунинг зеркальных веток origin — опционально, отложено.
-41
View File
@@ -1,41 +0,0 @@
# PLAN: 001 — FORK_BOOTSTRAP
## 1. Канонический набор build-тегов lx
Единый источник истины (использовать в Makefile, CI, DoD):
```
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_xhttp,with_awg
```
Хранить в `Makefile` (переменная `LX_TAGS`) и продублировать в `SPECS/CONSTITUTION.md` при изменениях.
> **Обновлено в §004:** набор расширен до полного upstream feature-set (`release/DEFAULT_BUILD_TAGS`) + `with_purego` + наши две фичи, с обязательным `-checklinkname=0` в `LX_LDFLAGS`. Актуальный источник истины — `Makefile.lx` (`make -f Makefile.lx lx-print-tags`) и `SPECS/004`.
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `Makefile` | new (или дополнение) | Цель `lx-build`: `go build -tags "$(LX_TAGS)" -ldflags "$(LX_LDFLAGS)" -o sing-box ./cmd/sing-box`; переменные `LX_TAGS`, `VERSION=…-lx.$(LX_BUILD)` |
| `.github/workflows/lx-ci.yml` | new | Скелет: checkout → setup-go → `make lx-build` → `go vet` → `./sing-box check -c lx-test/config/xhttp_smoke.json` (заглушка появится в 002) |
| `lx-test/config/*.json` | new | Sample-конфиги для `sing-box check` (минимальный валидный, без фич — для 001) |
| `SPECS/001-.../IMPLEMENTATION_REPORT.md` | new | Отчёт |
> Версия: upstream хранит строку версии в `constant/version.go` (или собирается через ldflags в `cmd/sing-box`). Проверить фактический механизм и **задавать `-lx` суффикс через `-ldflags -X`**, не правя `constant/version.go` напрямую (иначе лишний `// lx:` дифф на каждый ребейз). Если upstream не поддерживает ldflags-override — тогда минимальная `// lx:` правка в `constant/version.go`.
## 3. Зона касания upstream
- В идеале **ноль** правок upstream-файлов (всё через новые файлы + ldflags).
- Допустимый минимум: одна `// lx:` строка в `constant/version.go`, если ldflags-override невозможен.
## 4. Порядок работ
1. Проверить механизм версии upstream (`constant/version.go`, `cmd/sing-box`).
2. `Makefile` с `LX_TAGS`/`LX_LDFLAGS`/`lx-build`.
3. Sample-конфиг + CI-скелет.
4. Прогнать DoD, заполнить отчёт.
## 5. Риски
- Версионный механизм upstream может не принимать ldflags-override — fallback на `// lx:` правку.
- `with_xhttp`/`with_awg` как несуществующие теги не ломают сборку (Go игнорирует неизвестные build-теги) — но файлов с этими тегами пока нет, это нормально.
-49
View File
@@ -1,49 +0,0 @@
# SPEC: 001 — FORK_BOOTSTRAP
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Заложить скелет downstream'а `sing-box-lx`: remotes, рабочая ветка, build-теги, версия с `-lx`, конвенция маркеров `// lx:` и шаблон гейтинга. После задачи репозиторий — корректный «upstream + ноль фич», готовый принимать XHTTP (002) и AWG2 (003).
---
## 1. Проблема / контекст
`Leadaxe/sing-box-lx` — форк-зеркало upstream (родословная `SagerNet/sing-box` сохранена). Нужна повторяемая инфраструктура downstream'а, при которой каждое будущее изменение изолировано и ребейзопригодно (см. CONSTITUTION § 3).
## 2. Требования
### 2.1 Git
- `origin = Leadaxe/sing-box-lx`, `upstream = SagerNet/sing-box`. **(сделано)**
- Ветка `lx` базируется на стабильном теге `v1.13.13`. **(сделано)**
- Default branch на GitHub = `lx`; шумные зеркальные ветки (`dependabot/*`, `dev-*`, `copilot/*`) — вне внимания (можно удалить с origin, не обязательно).
### 2.2 Build-теги
- Ввести **`with_xhttp`** и **`with_awg`** как опознаваемые теги проекта (фактический код — в 002/003). Зафиксировать **канонический набор тегов сборки lx** в одном месте (см. PLAN), переиспользуемый в DoD и CI.
- Инвариант: без `with_xhttp`/`with_awg` бинарь ведёт себя как upstream.
### 2.3 Версия
- `sing-box version` должен печатать суффикс **`-lx.N`** (напр. `1.13.13-lx.1`).
- Суффикс задаётся при сборке (ldflags), не хардкодом в исходниках upstream (минимальный дифф).
### 2.4 Конвенции
- Документировать и применять маркеры правок upstream-файлов: `// lx:begin <feat>` … `// lx:end <feat>`.
- Принять шаблон гейтинга `include/<feat>.go` (+ `<feat>_stub.go`), как у upstream `include/wireguard.go`.
### 2.5 CI-скелет
- Минимальный workflow: сборка `lx` с каноническим набором тегов под linux/amd64 + `go vet` + `sing-box check` на sample-конфиге. (Полная матрица и авто-ребейз — в 004.)
## 3. Критерии приёмки
- `go build ./...` (без тегов) — ок.
- `go build -tags "<канон lx>" ./cmd/sing-box` — ок (теги пока no-op).
- Собранный бинарь: `./sing-box version` содержит `-lx.`.
- Имя выходного файла — `sing-box`.
- CI-скелет зелёный на push в `lx`.
## 4. Вне скоупа
- Любой код XHTTP/AWG (это 002/003).
- Полная CI-матрица, релизы, авто-ребейз (это 004).
-26
View File
@@ -1,26 +0,0 @@
# TASKS — 001-FORK_BOOTSTRAP
## Git / GitHub
- [x] `origin` = Leadaxe/sing-box-lx, `upstream` = SagerNet/sing-box
- [x] Ветка `lx` от тега `v1.13.13`
- [x] Default branch на GitHub → `lx`, push `lx`
- [x] Коммиты переведены на GitHub noreply-email (email privacy)
- [ ] (опц., отложено) Удалить шумные зеркальные ветки с origin (`dependabot/*`, `copilot/*`, `dev-*`)
## Build / Version
- [x] Изучён механизм версии upstream: `constant/version.go` = `var Version = "unknown"`, штампуется через `-ldflags -X …constant.Version`
- [x] **`Makefile.lx`** (новый файл, ноль правок upstream-Makefile): `LX_TAGS`, `LX_LDFLAGS`, цель `lx-build` (output `sing-box`)
- [x] Версия печатает `-lx.N` через ldflags (`1.13.13-lx.1`); `constant/version.go` **не тронут**
## Конвенции
- [x] Маркеры `// lx:begin/end` зафиксированы в CONSTITUTION § 3.3
- [x] Шаблон `include/<feat>.go` + `<feat>_stub.go` подтверждён на upstream `include/wireguard.go`
## CI-скелет
- [x] `.github/workflows/lx-ci.yml`: build(lx tags) + version + vet + `sing-box check`
- [x] `lx-test/config/minimal.json` (валидный конфиг без фич)
## Закрытие
- [x] DoD: `lx-build` ок, `version` → `-lx.1`, `check` OK, `go vet` OK; имя бинаря `sing-box`
- [x] IMPLEMENTATION_REPORT.md
- [x] Папка → статус `C`
@@ -1,142 +0,0 @@
# IMPLEMENTATION_REPORT — 002 XHTTP_CLIENT_TRANSPORT
---
## v2 — полная клиентская поддержка параметров (2026-06-29)
**Статус:** реализация code-complete + все проверки зелёные; **дефолтный путь лайв-подтверждён на реальных нодах** (4 живых XHTTP-сервера, packet-up + stream-one/reality, скачивание 1 МБ); **лайв obfs/placement** — остаётся открытым TODO (нужен сервер с такой настройкой).
**База:** upstream v1.14.0-alpha.* (`lx-1.14`) · **Ветка реализации:** `lx-1.14-xhttp-full` · **Спека:** [SPEC.md](SPEC.md) + [PARAM_MAP.md](PARAM_MAP.md).
### Что сделано
Расширил клиент до **полной клиентской поддержки** расширенных XHTTP-параметров Xray / sing-box-extended,
оставаясь client-only и без вендоринга Xray. Основание — глубокий аудит исходников XTLS/Xray-core
(`transport/internet/splithttp`) и shtorm-7/sing-box-extended (`transport/v2rayxhttp` + `option`), с
adversarial-верификацией каждого факта. Карта всех 16 полей — в [PARAM_MAP.md](PARAM_MAP.md).
**Реализованы (12 клиентских + 2 tuning-бонуса):**
- session/seq **placement** (path/query/header/cookie) + ключи (`session_key`/`seq_key`);
- uplink-data **placement** (body/auto/header/cookie, chunked base64) + `uplink_data_key` + `uplink_chunk_size`;
- `uplink_http_method` (upper-case; GET только в packet-up — вне auto-fallback на POST + warning, не ошибка);
- **X-Padding obfs**: `x_padding_obfs_mode` + placement (cookie/header/query/queryInHeader) +
`x_padding_key`/`x_padding_header` + method (`repeat-x` и `tokenish` с HPACK-Huffman-тюнингом);
- packet-up tuning: `sc_max_each_post_bytes` (разбиение), `sc_min_posts_interval_ms` (троттлинг).
**Server-only (accept-but-ignore, в struct, не используются клиентом):** `server_max_header_bytes`,
`no_sse_header`, `sc_max_buffered_posts`, `sc_stream_up_server_secs`. Клиент не инспектирует Content-Type
ответа, поэтому корректен и с SSE-заголовком, и без него.
**Файлы:**
- `option/v2ray_xhttp.go` — +20 полей в `V2RayXHTTPOptions` (всё новый код, нулевое касание upstream).
- `transport/v2rayxhttp/meta.go` (новый) — placement-движок (`applyMeta`), нормализация/валидация
(`normalizeMeta`, mode-gate'ы, дефолты по placement), uplink-data сборка (`applyUplinkData`/`chunkEncoded`).
- `transport/v2rayxhttp/xpadding.go` (новый) — obfs-движок (`applyXPadding`), генераторы
`repeat-x`/`tokenish` (`generateTokenishPaddingBase62` на `crypto/rand` + `golang.org/x/net/http2/hpack`).
- `transport/v2rayxhttp/client.go` — `Client.meta`/`paddingRange`; `NewClient` зовёт `normalizeMeta`;
`requestURL`/`padding` заменены на `baseURL` + `newRequest(ctx, method, sessionID, seqStr, body)`.
- `transport/v2rayxhttp/conn.go` — dial-функции под новую сигнатуру; `packetConn.Write` разбивает по
`sc_max_each_post_bytes`, троттлит по `sc_min_posts_interval_ms`, раскладывает payload по placement.
- `transport/v2rayxhttp/xhttp_test.go` (новый) + обновлённый `url_test.go` — 16 тест-функций.
- `lx-test/config/xhttp_obfs_full.json` — VLESS+Reality+xhttp со всеми 20 новыми полями.
### Архитектурное решение: range-поля строкой
`uplink_chunk_size`, `sc_*` (range) представлены строкой `"min-max"` (как существующий `x_padding_bytes`),
а **не** `*badoption.Range[int]`: в нашем `sing` badoption нет типа `Range`, а строковая форма уже есть и
парсится одним хелпером (`parseRange`). Это отступление от proto-типа Xray зафиксировано в SPEC §4.1.
### Проверки (все зелёные)
- `make -f Makefile.lx lx-build` → ок.
- `./sing-box check -c` на `xhttp_reality.json`, `xhttp_auto_reality.json`, `xhttp_obfs_full.json` → **все PASS**
(полный obfs-конфиг проходит реальный `box.New`-путь — проверка против badjson-схлопывания слайсов).
- `go test -tags with_xhttp ./transport/v2rayxhttp/` → **16/16 PASS** (включая нулевую регрессию дефолта,
все placement'ы, uplink-data сборку/разборку, 4×2 obfs-комбинации, tokenish HPACK-длину, валидацию).
- `go vet -tags with_xhttp ./...` → чисто (2 предсуществующих unsafe.Pointer в daemon/libbox — не наши).
- `gofmt -l` → чисто. `go build ./...` (tagged/untagged) → ок. `go test` (untagged) → ок.
- Негатив: бинарь **без** `with_xhttp` отвергает obfs-конфиг (`unknown transport type: xhttp`).
### Нулевая регрессия дефолта
Все новые поля имеют дефолты, сохраняющие v1-поведение байт-в-байт: obfs off → `x_padding` в Referer;
session/seq в path; payload в body; метод POST. Тест `TestDefaultLegacyPadding` это фиксирует.
### Лайв-верификация на реальных нодах (2026-06-30)
Просканировал публичные подписки `igareck/vpn-configs-for-russia` (6 файлов), извлёк и
дедуплицировал **10 уникальных XHTTP-нод**, прогнал каждую через наш бинарь (`with_xhttp`)
с mixed-inbound и реальным трафиком. **4 ноды живые** — все скачали 1 МБ, трафик вышел через IP
сервера:
| Нода | Режим (резолв) | 1 МБ | Exit-IP |
|------|----------------|------|---------|
| 92.38.139.63:9891 (plain) | packet-up | ✅ 0.85s | 144.31.218.159 |
| hu99.bearbeer.digital:443 (reality) | **stream-one** | ✅ 0.38s | 37.221.210.58 |
| 82.202.142.216:9881 (plain) | packet-up | ✅ 8.0s | 85.158.57.210 |
| bez3.stream-room.com:8080 (reality) | **stream-one** | ✅ 0.22s | 144.31.178.8 |
**Главное:** две reality-ноды резолвятся `auto`→**stream-one** и работают живьём — это **закрывает
открытый с задачи 011 TODO** (stream-one ранее был принят только на синтетике). Подтверждено на двух
независимых серверах с реальной загрузкой.
Остальные 6 нод мертвы **по причинам на стороне сервера**, не нашего кода: `504 Gateway Timeout`,
`frame too large / HTTP/1.1 header` (нода не H2/XHTTP), `connection reset`, TLS/reality handshake hang.
Во всех случаях наш транспорт корректно строит и шлёт запрос и выдаёт диагностируемую ошибку.
### Остаточные пробелы
1. **Лайв obfs/placement-режима** — дефолтный путь (packet-up + stream-one) лайв-подтверждён выше, но
не-дефолтные obfs/placement-комбинации (`x_padding_obfs_mode=true`, header/cookie placement, tokenish)
требуют сервера, *настроенного* на них; ни одна публичная нода так не настроена. Покрыто unit-тестами +
`check`, лайв — остаётся TODO.
2. **HTTP/3 (`alpn=h3`)** — клиент HTTP/2-only; h3-only ноды не обслуживаются (вне SPEC 002, отдельная
задача). Задокументировано в [URL_PARSING.md](URL_PARSING.md).
3. Зависимость `golang.org/x/net/http2/hpack` для tokenish — уже транзитивно в go.mod (HTTP/2 transport).
4. xmux/переиспользование соединений — вне скоупа (см. SPEC §8).
---
## v1 (история)
**Дата:** 2026-06-09 · **Статус:** Complete — lean-native клиент, **проверен живым Xray/3x-ui сервером** (packet-up/auto) · **База:** `v1.13.13`
## Что сделано
Клиентский XHTTP-транспорт, подход **lean-native** (на примитивах sing-box, минимум зависимостей) — реализован многоагентным workflow в изолированном worktree, влит в `lx` (коммиты `2d97ff56` registry/const + `d1b434fc` транспорт).
**Файлы (новые, если не указано иное):**
- `transport/v2ray/registry.go`, `// lx` в `transport/v2ray/transport.go`, константа в `constant/v2ray.go` — registry-рефактор (ранее).
- `option/v2ray_xhttp.go` — тип `V2RayXHTTPOptions` (Host, Path, Mode, Headers, padding).
- `option/v2ray_transport.go` — **единственная upstream-правка** (// lx): поле `XHTTPOptions` + xhttp-case в Marshal/Unmarshal.
- `transport/v2rayxhttp/{client,conn,register}.go` — клиент; `register.go` под `//go:build with_xhttp`.
- `include/v2rayxhttp.go` (`//go:build with_xhttp`) — blank-import для запуска `init()`.
- `lx-test/config/xhttp_reality.json` — VLESS+xhttp+reality для `check`.
## Проверки (DoD = compiles + check)
- `make -f Makefile.lx lx-build` → ок; `./sing-box check -c lx-test/config/xhttp_reality.json` → **pass**.
- `go vet` (lx-теги) по `transport/v2rayxhttp`, `option`, `transport/v2ray` → чисто; `go build ./...` без тегов → ок; `gofmt` чисто.
- Негатив: бинарь **без** `with_xhttp` отвергает xhttp-конфиг (`unknown transport type: xhttp`). Невалидный mode → `v2ray-xhttp: unknown mode`. Все 4 mode конструируются.
## Зона касания upstream (ребейз)
Ровно **1 файл**: `option/v2ray_transport.go` (3 правки в // lx-маркерах). Реестр и весь пакет `v2rayxhttp` — новые файлы, конфликтов не дают.
## Лайв-тест (реальный Xray/3x-ui XHTTP-сервер)
Проверено против VLESS + Reality + `type=xhttp` ноды (панель 3x-ui):
- ✅ **packet-up** (и `auto` → packet-up): handshake + DNS + HTTPS (example.com 200) + скачивание 2 МБ @ ~2.1 МБ/с — трафик выходит через IP сервера.
- ❌ **stream-one**: `unknown version` — баг при чтении downlink-ответа (выбирается только явно). → **Исправлено в задаче 011** (корень: stream-one должен слать голый путь без sessionId; auto+reality → stream-one). Принято на синтетике, лайв отложен.
**Ключевой фикс (по исходникам Xray hub.go/config.go + лайв):** padding кладётся как `x_padding=<нули>` в **query внутри заголовка `Referer`** (Xray default `PlacementQueryInHeader`, key `x_padding`), а **не** отдельным `X-Padding`. Сервер валидирует длину `x_padding` (дефолт 100–1000) и без неё отвечает **400 Bad Request**. Плюс `mode=auto` переключён на **packet-up**. Коммит `5a398a5e`. Также ранее: `sessionId` → UUID-формат, path-layout `<path>/<sessionId>[/<seq>]` сверены.
## Остаточные пробелы
1. ~~**stream-one** — баг framing downlink (`unknown version`)~~ → **исправлено в 011** (голый путь без sessionId; `auto`+reality → stream-one). Лайв-подтверждение — открытый TODO в 011.
2. **packet-up** без xmux/переиспользования соединений; **stream-up** не лайв-тестился.
3. `x_padding_bytes` — строка «min-max» (нет Range-типа в badoption); дефолт 100–1000.
## Дальше
- Лаунчер: маппинг `type=xhttp` (его задача 023 сейчас маппит в `httpupgrade`) → реальный xhttp-транспорт.
- Опционально: починить stream-one, добавить xmux.
@@ -1,364 +0,0 @@
# XHTTP PARAM_MAP — карта параметров Xray XHTTP («splithttp»)
> Сопровождающий документ к [SPEC.md](SPEC.md) (002 — XHTTP_CLIENT_TRANSPORT).
> Детальная карта **«какой параметр что делает в Xray»** по всем расширенным полям XHTTP,
> собранная глубоким аудитом исходников **XTLS/Xray-core** (`transport/internet/splithttp/*`,
> ветка `main`) и форка **shtorm-7/sing-box-extended** (`transport/v2rayxhttp/*` +
> `option/v2ray_transport.go`, ветка `extended`), с adversarial-верификацией каждой карточки
> против исходника.
## 0. Как читать эту карту
- **Xray-поле** — имя в `config.proto` / сгенерированном `config.pb.go` (camelCase) и Go-поле.
- **JSON (наш)** — рекомендованное `snake_case` имя в конфиге sing-box-lx (как в `sing-box-extended`).
- **Клиент?** — потребляет ли поле **клиентская** сторона. Мы строим **client-only** транспорт,
поэтому это главный столбец: server-only поля документируются, но **не реализуются**.
- **Тир** — `core` (нужно для базовой совместимости), `obfs` (анти-DPI), `tuning` (производительность
packet-up), `server-only` (нереализуемо на клиенте).
- Цитаты дают **функцию/символ**, а не номер строки: номера строк в обоих апстримах дрейфуют между
ревизиями (верификация это подтвердила), а имена функций стабильны.
### Сводная таблица (16 полей из списка NekoBox+/sing-box-extended)
| # | Параметр (camelCase) | JSON (наш snake_case) | Клиент? | Тир | Одной строкой |
|---|----------------------|------------------------|:-------:|-----|----------------|
| 1 | `sessionPlacement` | `session_placement` | ✅ | core | Куда класть session id: path/query/header/cookie |
| 2 | `sessionKey`* | `session_key` | ✅ | core | Имя ключа для session id (когда не path) |
| 3 | `seqPlacement` | `seq_placement` | ✅ | core | Куда класть номер пакета (packet-up): path/query/header/cookie |
| 4 | `seqKey` | `seq_key` | ✅ | core | Имя ключа для seq (когда не path) |
| 5 | `uplinkDataPlacement` | `uplink_data_placement` | ✅ | obfs | Куда класть payload upload (packet-up): body/header/cookie/auto |
| 6 | `uplinkDataKey` | `uplink_data_key` | ✅ | obfs | Базовое имя header/cookie для chunked-payload |
| 7 | `uplinkChunkSize` | `uplink_chunk_size` | ✅ | tuning | Размер чанка (в base64-символах) для header/cookie-payload |
| 8 | `uplinkHTTPMethod` | `uplink_http_method` | ✅ | core | HTTP-метод upload-запросов (default POST) |
| 9 | `xPaddingObfsMode` | `x_padding_obfs_mode` | ✅ | obfs | Главный переключатель: legacy (Referer) vs configurable obfs |
| 10 | `xPaddingKey` | `x_padding_key` | ✅ | obfs | Имя cookie/query-параметра для padding (obfs-режим) |
| 11 | `xPaddingHeader` | `x_padding_header` | ✅ | obfs | Имя заголовка для padding (obfs-режим) |
| 12 | `xPaddingPlacement` | `x_padding_placement` | ✅ | obfs | Куда класть padding: cookie/header/query/queryInHeader |
| 13 | `xPaddingMethod` | `x_padding_method` | ✅ | obfs | Алгоритм генерации padding: repeat-x / tokenish |
| 14 | `serverMaxHeaderBytes`| `server_max_header_bytes`| ❌ | server-only | Лимит размера заголовков на **сервере** |
| 15 | `noSSEHeader` | `no_sse_header` | ❌ | server-only | Сервер не шлёт `Content-Type: text/event-stream` |
| 16 | `scMaxBufferedPosts` | `sc_max_buffered_posts` | ❌ | server-only | Глубина буфера переупорядочивания upload на сервере |
| 17 | `scStreamUpServerSecs`| `sc_stream_up_server_secs`| ❌ | server-only | Интервал keepalive-padding в ответе stream-up (сервер) |
| 18 | `scMaxConcurrentPosts`| `sc_max_concurrent_posts`| ❌ | legacy/ignore | Legacy-лимит параллельных upload-POST; **удалён из текущего Xray** |
\* `sessionKey` не входил в исходный список NekoBox+ из 16, но это парный к `sessionPlacement`
ключ (так же как `seqKey` парен к `seqPlacement`); без него placement query/header/cookie для session
неполон. Считаем его частью core-набора.
### Бонус: клиентские upload-tuning поля (вне списка 16, но клиент их читает)
Аудит вскрыл два поля, которые **читает клиент** в packet-up, но которых нет в исходном списке:
| Параметр | JSON | Клиент? | Тир | Что делает |
|----------|------|:-------:|-----|------------|
| `scMaxEachPostBytes` | `sc_max_each_post_bytes` | ✅ | tuning | Макс. размер одного upload-POST (порог разбиения) |
| `scMinPostsIntervalMs` | `sc_min_posts_interval_ms` | ✅ | tuning | Мин. интервал между upload-POST (анти-burst) |
Включаем их в реализацию для полноты packet-up (детали в §6 SPEC).
---
## 1. Текущая база sing-box-lx ↔ дефолт Xray (важно!)
**Наша уже существующая реализация (`transport/v2rayxhttp`) совместима с дефолтным Xray-сервером**
без новых полей, потому что:
| Аспект | Наш текущий код | Эквивалент в терминах этих полей |
|--------|------------------|----------------------------------|
| Padding | `x_padding=<нули>` в query внутри заголовка `Referer` | `xPaddingObfsMode=false` (legacy-ветка Xray) |
| Session id | path-сегмент `<path>/<sessionId>` | `sessionPlacement=path` |
| Seq | path-сегмент `<path>/<sessionId>/<seq>` | `seqPlacement=path` |
| Upload payload | тело POST | `uplinkDataPlacement=body` |
| Upload-метод | `POST` (`GET` для download) | `uplinkHTTPMethod=POST` |
То есть **расширение = добавление альтернативных режимов поверх рабочей base-линии**, а не
переписывание. Дефолты всех новых полей выбраны так, чтобы поведение «из коробки» осталось байт-в-байт
прежним.
---
## 2. Группа: session / seq placement (core)
### `sessionPlacement` + `sessionKey`
- **Xray-поле:** `Config.SessionIDPlacement` (proto `sessionIDPlacement=20`), `Config.SessionIDKey`
(`sessionIDKey=21`). ⚠️ В Xray поле называется `sessionID*`, а sing-box-extended экспонирует JSON
как `session_placement`/`session_key` (нормализатор `GetNormalizedSessionPlacement`). **Несовпадение
имён** — учитываем при реализации.
- **Назначение:** где разместить **session id** на каждом запросе (и GET-download, и POST-upload), чтобы
сервер мог демультиплексировать логические соединения, разделяющие один HTTP-origin, и сшить
upload-POST с соответствующим download-GET.
- **Значения:** `path` | `query` | `header` | `cookie`. (В отличие от uplink-data — **нет** `body`/`auto`.)
Иное значение отвергается: `unsupported session placement: …`.
- **Default:** `path` (пустое → `path`).
- **On-wire:**
- `path` → первый path-сегмент после base-path: `<path>/<sessionId>` (session **перед** seq).
- `query` → `?<sessionKey>=<sessionId>`.
- `header` → `<sessionKey>: <sessionId>`.
- `cookie` → `Cookie: <sessionKey>=<sessionId>`.
- **Ключ (`sessionKey`):** `GetNormalizedSessionKey` — default `X-Session` для header, `x_session` для
cookie/query, `""` (не используется) для path. Регистр асимметричен: header — каноничный `X-Session`,
cookie/query — нижний `x_session`. Клиент и сервер обязаны совпасть.
- **Генерация id:** `GenerateSessionID` — N случайных символов из `SessionIDTable`/`SessionIDLength`,
иначе UUID-строка. Наш текущий `newSessionID()` уже даёт UUID-формат → совместимо.
- **Сервер:** `ExtractMetaFromRequest`. Пустой sessionId → HTTP 400, **кроме** режимов
`""`/`auto`/`stream-one`/`stream-up` (там допустима одна неявная сессия).
- **Источник:** Xray `config.go` `GetNormalizedSessionPlacement`/`…Key`/`ApplyMetaToRequest`/
`ExtractMetaFromRequest`; extended `transport/v2rayxhttp/dialer.go` `ApplyMetaToRequest`,
`utils.go` `GenerateSessionID`, `option/v2ray_transport.go` валидация + нормализаторы.
### `seqPlacement` + `seqKey`
- **Xray-поле:** `Config.SeqPlacement` (`seqPlacement=22`), `Config.SeqKey` (`seqKey=23`).
- **Назначение:** где разместить **номер пакета** (`seqStr`) на каждом uplink-POST в **packet-up**. seq —
монотонный `int64` с 0, формат — десятичная ASCII-строка (`strconv.FormatInt(seq,10)`), по одному на
чанк, чтобы сервер переупорядочил пришедшие не по порядку POST в корректный поток. **Только packet-up**;
в stream-up/stream-one seqStr = `""` и не отправляется.
- **Значения:** `path` | `query` | `header` | `cookie`. Иначе — `unsupported seq placement: …`.
- **Default:** `path` (пустое → `path`).
- **On-wire:**
- `path` → **второй** path-сегмент: `<path>/<sessionId>/<seq>` (session первый, seq второй — **порядок
нагруженный**, сервер разбирает сегменты позиционно).
- `query` → `?<seqKey>=<seq>`.
- `header` → `<seqKey>: <seq>`.
- `cookie` → `Cookie: <seqKey>=<seq>`.
- Значение — всегда сырая десятичная строка; placement только переносит её, не кодирует.
- **Ключ (`seqKey`):** `GetNormalizedSeqKey` — default `X-Seq` (header) / `x_seq` (cookie/query) / `""` (path).
- **Сервер:** читает обратно симметрично; парсит `strconv.ParseUint(seqStr,10,64)`; ошибка парсинга →
HTTP **500**. GET с непустым seq = uplink; GET с пустым seq = downlink.
- ⚠️ **Коррекция верификации:** заголовок `Access-Control-Allow-Credentials: true` **НЕ выставляется**
ни для cookie/query placement, ни где-либо ещё (в исходниках обоих апстримов его нет — была
галлюцинация в черновике аудита).
- **Источник:** extended `dialer.go` `ApplyMetaToRequest` + `appendToPath` (вставляет `/`-разделитель),
`client.go` (`var seq int64` / `seqStr := strconv.FormatInt(seq,10)` / `seq += 1`); Xray `config.go`
одноимённые методы; сервер `hub.go`/`server.go` `strconv.ParseUint`.
---
## 3. Группа: uplink-data (obfs + tuning)
### `uplinkDataPlacement`
- **Xray-поле:** `Config.UplinkDataPlacement` (`uplinkDataPlacement=24`).
- **Назначение:** где нести **payload upload** для одного packet-up POST/GET-запроса. Влияет **только**
на packet-up (stream-up/stream-one всегда стримят body).
- **Значения:** `body` | `auto` | `header` | `cookie`.
- `body`/`auto` → payload = сырое тело запроса, `Content-Length` выставлен. (На **клиенте** `auto` ведёт
себя как `body`; различие `auto` есть только на сервере, который при `auto` конкатенирует header+cookie+body.)
- `header` → payload `base64.RawURLEncoding`, нарезается на чанки, каждый → заголовок `<uplinkDataKey>-<i>`
(i с 0, по возрастанию).
- `cookie` → то же, но cookie `<uplinkDataKey>_<i>` (разделитель `_`, не `-`).
- **Default:** после валидации — `auto` (пустое → `auto`); чистый `GetNormalizedUplinkDataPlacement`
возвращает `body` для пустого. Чистое клиентское поведение по умолчанию = payload в body.
- **Mode-gate:** `header`/`cookie` **отвергаются**, если `mode != packet-up`
(`UplinkDataPlacement can be <x> only in packet-up mode`). ⚠️ Этот gate живёт **только в
sing-box-extended** option-слое, не в Xray-core core.
- **Сервер:** переразбирает, итерируя индексы `i=0..` пока есть keyed header/cookie, `strings.Join` без
разделителя, затем `base64.RawURLEncoding.DecodeString`. Битый base64 → 400; превышение
`scMaxEachPostBytes` → 413.
- **Источник:** extended `utils.go` `FillPacketRequest` + `GetRequestHeaderWithPayload`/
`GetRequestCookiesWithPayload`; сервер `server.go`; Xray `config.go`/`hub.go`.
### `uplinkDataKey`
- **Xray-поле:** `Config.UplinkDataKey` (`uplinkDataKey=25`).
- **Назначение:** базовое **имя** header/cookie для chunked-payload (placement header/cookie). Это
**имя/обфускационный ключ, не криптографический** — payload base64url-кодируется, не шифруется.
- **On-wire:** header `fmt.Sprintf("%s-%d", key, i)` (`X-Data-0`, `X-Data-1`, …); cookie
`fmt.Sprintf("%s_%d", key, i)` (`x_data_0`, …).
- **Default:** при placement≠body и пустом ключе — `X-Data` (header/auto) / `x_data` (cookie). Для body —
пусто (не используется).
- **Источник:** extended `utils.go` тех же функций; default — `checkV2RayXHTTPBaseOptions`.
### `uplinkChunkSize`
- **Xray-поле:** `Config.UplinkChunkSize` (`uplinkChunkSize=26`, тип `RangeConfig`).
- **Назначение:** `Range[int]` (From..To) — размер **в base64-символах** каждого чанка при header/cookie
payload. Для каждого чанка клиент берёт `min(Range.Rand(), остаток)`. Не влияет на body-placement
(там размер регулирует `scMaxEachPostBytes`).
- **Default (зависит от placement):** cookie → `[2048, 3072]`; header → `[3000, 4000]`; иначе → значение
`scMaxEachPostBytes` (default `[1_000_000, 1_000_000]`). Пол: `From < 64` → подтягивается к 64.
- **Сервер:** **не использует** для переразбора — итерирует индексы вслепую и join'ит. Значит чанк-сайз —
чисто клиентская emission-политика; любой валидный сплит, который сервер сможет собрать, работает.
- **Источник:** extended `option/v2ray_transport.go` `GetNormalizedUplinkChunkSize`; usage в `utils.go`.
### `uplinkHTTPMethod`
- **Xray-поле:** `Config.UplinkHTTPMethod` (`uplinkHTTPMethod=19`).
- **Назначение:** HTTP-метод client→server **upload**-запросов (packet-up POST и stream-up/stream-one
upstream-запрос с телом). Download (stream-down) — всегда `GET`, не затрагивается. Позволяет замаскировать
upload под не-POST глагол.
- **On-wire:** метод запроса (request-line / `:method`). `GET` при body==nil (download), иначе настроенный
метод.
- **Default:** `POST`. ⚠️ Upper-casing значения — **только** в sing-box-extended option-слое; Xray-core
`GetNormalizedUplinkHTTPMethod` возвращает значение как есть.
- **Mode-gate (lx: soft-fallback):** `GET` осмыслен **только** при `mode=packet-up`, т.к. stream-up/
stream-one нужен запрос с телом, а GET-с-телом сервер трактует как stream-down. Раньше `GET` вне
packet-up был **жёсткой ошибкой** (`uplink_http_method can be GET only in packet-up mode`) — но это
роняло **весь** конфиг из-за одной кривой ноды подписки (наблюдалось: `initialize outbound[N] … can
be GET only in packet-up mode`). Теперь вместо ошибки — **fallback на `POST`** (безопасный дефолт,
валиден во всех режимах) + `WARN` в лог; в `packet-up` `GET` сохраняется. Gate — в extended
(`normalizeMeta`, `meta.go`). См. `SPEC.md` §поведение и тест `TestUplinkGetFallsBackToPostOutsidePacketUp`.
- **Сервер:** маршрутизирует по методу, не по равенству настроенному значению: `GET` + непустой seq =
uplink; любой не-GET = uplink. Явной проверки настроенного метода нет.
- **Источник:** extended `dialer.go` `OpenStream`/`PostPacket`; `option/v2ray_transport.go`
`GetNormalizedUplinkHTTPMethod`; Xray `config.go`/`client.go`/`hub.go`.
---
## 4. Группа: X-Padding obfs (obfs)
### `xPaddingObfsMode`
- **Xray-поле:** `Config.XPaddingObfsMode` (bool, `xPaddingObfsMode=14`).
- **Назначение:** **главный переключатель** схемы padding.
- `false` (default/legacy): padding **всегда** как `queryInHeader` в `Referer` — запрос несёт
`Referer: <scheme>://<host><path>?x_padding=<padding>`. Ключ жёстко `x_padding`, заголовок жёстко
`Referer`, метод неявно repeat-x.
- `true` (obfs): padding по настраиваемым `xPaddingKey`/`xPaddingHeader`/`xPaddingPlacement`/
`xPaddingMethod` — можно перенести в произвольный cookie/header/query/queryInHeader и выбрать алгоритм.
- **Padding обязателен в обоих режимах** — `x_padding_bytes` нельзя отключить. Меняется только форма и где
сервер ищет/валидирует.
- **Default:** `false` (JSON `x_padding_obfs_mode`, omitempty).
- **On-wire:**
- `false` → `Referer: …?x_padding=XXXX…` (наш **текущий** код).
- `true` → padding по `xPaddingPlacement` в header (default `X-Padding`)/cookie/query/queryInHeader.
- Ответ сервера зеркалит: obfs → той же placement; non-obfs → `header` с именем `X-Padding`
(default ответа **отличается** от Referer-дефолта запроса).
- **Сервер:** `ExtractXPaddingFromRequest(request, obfsMode)` → `IsPaddingValid` против
`GetNormalizedXPaddingBytes`. Невалидно → HTTP **400**. Также гейтит
`obfsPaddingAccepted := XPaddingObfsMode && paddingValue != ""`, что (вместе с `scStreamUpServerSecs.To>0`
**или** legacy-Referer-маркером) включает периодический серверный keepalive-padding.
- ⚠️ **Коррекция верификации:** серверный keepalive-тикер гейтится
`(legacyRefererCompatMarker || obfsPaddingAccepted) && scStreamUpServerSecs.To > 0` — `obfsPaddingAccepted`
это **одно из двух** условий, не единственное.
- **Out-of-band:** этот bool **не согласуется по проводу** — client и server должны иметь одинаковую
настройку в конфиге.
- **Источник:** Xray `config.go` `FillStreamRequest`/`FillPacketRequest`; `hub.go` `ServeHTTP`;
extended `utils.go` тех же + `xpadding.go` `ExtractXPaddingFromRequest`.
### `xPaddingKey`
- **Xray-поле:** `Config.XPaddingKey` (`xPaddingKey=15`).
- **Назначение:** **имя** для несения padding в obfs-режиме — имя cookie (cookie), имя query-параметра
(query), или имя query-параметра внутри header-URL (queryInHeader). **Не используется** при
`xPaddingPlacement=header` (там значение = весь заголовок). Это **идентификатор-строка, не крипто-ключ**:
нигде нет keyed-hash/шифрования padding.
- **On-wire:** cookie `Cookie: <key>=<pad>; Path=/`; query `?<key>=<pad>`; queryInHeader — URL
`<RawURL>?<key>=<pad>` в заголовке.
- **Default:** в proto пусто; extended при пустом → `x_padding`. В non-obfs режиме литерал `x_padding`
жёстко зашит независимо от поля.
- **Тонкость:** Xray в queryInHeader присваивает `u.RawQuery = key + "=" + paddingValue` **без**
URL-escaping. Безопасно, т.к. padding — только `[A-Za-z0-9]` (base62) или `X`.
- **Источник:** Xray `xpadding.go` `ExtractXPaddingFromRequest`/`ApplyXPaddingToHeader`/cookie/query;
default — extended `checkV2RayXHTTPBaseOptions`.
### `xPaddingHeader`
- **Xray-поле:** `Config.XPaddingHeader` (`xPaddingHeader=16`).
- **Назначение:** **имя заголовка** для padding в obfs+header-based placement.
- `PlacementHeader`: всё значение заголовка = padding.
- `PlacementQueryInHeader`: значение = полный URL с `?<key>=<padding>`.
- Игнорируется при cookie/query placement.
- **Default:** пусто в proto; extended → `X-Padding`. Non-obfs серверный ответ тоже жёстко `X-Padding`.
- **HTTP/2:** имя заголовка в проводе lowercase'ится (HPACK) — нормально, матчинг case-insensitive.
- **Источник:** Xray `xpadding.go` `ApplyXPaddingToHeader`/`ExtractXPaddingFromRequest`; default — extended.
### `xPaddingPlacement`
- **Xray-поле:** `Config.XPaddingPlacement` (`xPaddingPlacement=17`).
- **Назначение:** **где** физически разместить padding в obfs-режиме: `cookie` | `header` | `query` |
`queryInHeader`. (Константы `path`/`body`/`auto` существуют для других полей, но для padding
**невалидны** — extended-валидатор отвергает.) Только при `xPaddingObfsMode=true`; в non-obfs принудительно
queryInHeader(Referer) для запросов, header(X-Padding) для ответа.
- **On-wire:** cookie `Cookie: <key>=<pad>`; header `<XPaddingHeader>: <pad>`; query `?<key>=<pad>`;
queryInHeader `<XPaddingHeader>: <reqURL>?<key>=<pad>`. `ApplyXPaddingToResponse` обрабатывает только
header/queryInHeader/cookie (query-on-response нет).
- **Default:** пусто → `queryInHeader` (extended).
- ⚠️ **Коррекция верификации:** никакого `Access-Control-Allow-Credentials` для cookie-placement нет
(была галлюцинация). CORS-логика в `config.go WriteResponseHeader` касается других заголовков.
- **Источник:** Xray `xpadding.go` `ApplyXPaddingToRequest`/`…ToResponse`/`Extract…`; константы + валидация —
extended `option/v2ray_transport.go`.
### `xPaddingMethod`
- **Xray-поле:** `Config.XPaddingMethod` (`xPaddingMethod=18`).
- **Назначение:** **алгоритм** генерации байт padding:
- `repeat-x`: N литеральных `X` (длина == целевому числу байт точно).
- `tokenish`: случайная base62-строка (`[0-9A-Za-z]`), чья **HPACK/QPACK-Huffman-кодированная** длина
итеративно подгоняется в пределах ±2 байт от цели, чтобы после HTTP/2-сжатия заголовков размер в
проводе попадал в диапазон. `tokenish` делает padding похожим на случайный токен, а не на ряд `X`.
- **On-wire:** repeat-x — `strings.Repeat("X", length)`. tokenish — base62-токен через
`hpack.HuffmanEncodeLength`; `X`/`Z` берутся как filler (у них 8-битные Huffman-коды → не сжимаются),
чтобы длина была стабильной.
- **Default:** пусто → `repeat-x` (extended).
- **Сервер:** `IsPaddingValid` с тем же методом. repeat-x → `len(value) ∈ [from,to]`; tokenish →
`hpack.HuffmanEncodeLength(value) ∈ [from-2, to+2]`. **Client и server обязаны использовать один метод** —
tokenish-значение, проверенное как repeat-x, сравнит сырую длину, не Huffman.
- **Реализация tokenish:** не нужен вендоринг Xray — нужен `golang.org/x/net/http2/hpack` (уже транзитивно
в go.mod). Порт `GenerateTokenishPaddingBase62` (~40 строк): `crypto/rand` base62 длины ≈ `ceil(target/0.8)`,
затем тюнинг `HuffmanEncodeLength` в ±2, чередуя filler `X`/`Z`, лимит 150 итераций.
- **HTTP/1.1:** сырая длина = длине в проводе → repeat-x и tokenish по длине эквивалентны.
- **Источник:** Xray `xpadding.go` `GeneratePadding`/`GenerateTokenishPaddingBase62`/`IsPaddingValid`;
extended `xpadding.go` байт-в-байт зеркало.
---
## 5. Группа: server-only (документируем, НЕ реализуем)
Все 4 поля **не читаются клиентом** ни в Xray-core, ни в sing-box-extended. Ключевой факт: **клиент вообще
не инспектирует `Content-Type` ответа** (`stream-down` → `wrc.Set(resp.Body)`; `stream-up` →
`io.Copy(io.Discard, resp.Body)`), поэтому корректно работает и с SSE-заголовком, и без него, без какого-либо
кода.
| Параметр | Что делает (на сервере) | Default | Почему клиенту не нужно |
|----------|--------------------------|---------|--------------------------|
| `serverMaxHeaderBytes` | `http.Server{MaxHeaderBytes}` — лимит размера заголовков входящего запроса | `8192` | У client-only транспорта нет `http.Server` |
| `noSSEHeader` | Сервер не шлёт `Content-Type: text/event-stream` на stream-down GET | `false` (SSE шлётся) | Клиент не читает Content-Type — обрабатывает оба случая без кода |
| `scMaxBufferedPosts` | Ёмкость серверной очереди переупорядочивания upload-POST (packet-up) | `30` | Клиент не знает о глубине буфера сервера |
| `scStreamUpServerSecs` | Интервал (сек, Range) периодической записи `X`-padding в ответ stream-up | `{20,80}` | Клиент тихо отбрасывает эти байты (`io.Discard`) |
**Решение для реализации:** не реализуем. В конфиге — `expose-but-ignore` (принимаем поля, чтобы
server-образные конфиги не падали на парсинге), помечены как inbound-only. Альтернатива (просто
document-and-skip без полей в struct) тоже допустима; финальный выбор зафиксирован в SPEC §6.
- **Источник:** Xray `config.go` `GetNormalizedServerMaxHeaderBytes`/`…ScMaxBufferedPosts`/
`…ScStreamUpServerSecs`; `hub.go` `ListenXH`/`ServeHTTP`/`upsertSession`; extended `server.go` зеркало.
`noSSEHeader` — без нормализатора, читается как сырой bool.
### `scMaxConcurrentPosts` — legacy, удалено из upstream (accept-but-ignore)
- **Статус:** **поля НЕТ** в текущих Xray-core и sing-box-extended. Подтверждено: `grep` пусто,
GitHub code search `scMaxConcurrentPosts repo:XTLS/Xray-core` → `total: 0`, нет
`GetNormalizedScMaxConcurrentPosts`. Это knob **старого** релиза Xray, удалённый при редизайне
upload-пути. Встречается в реальных `vless://`-ссылках (в `extra={...}`) как legacy-артефакт клиента,
сгенерировавшего ссылку.
- **Что было:** когда-то ограничивал число параллельных upload-POST в packet-up.
- **Текущий механизм Xray (вместо него):** **не семафор**, а (a) bounded pipe (backpressure) +
`scMaxBufferedPosts` (server reorder window, default 30) и (b) сериализация на
`<-wroteRequest.Wait()` для `DefaultDialerClient` — следующий POST не диспетчится, пока тело текущего
не дописано в провод. Фактически **1 POST-тело в полёте за раз**. Seq инкрементируется per-packet
синхронно в writer-goroutine; переупорядочивание — server-side по seq (`upload_queue.go`, heap).
- **Наш клиент:** `packetConn.Write` шлёт upload-POST **последовательно** (один `sendPacket` за раз) —
это уже соответствует текущему поведению Xray (1 тело за раз). Истинная bounded-concurrency (N в
полёте) была бы **улучшением над upstream**, не совместимостью.
- **Решение:** поле `sc_max_concurrent_posts` принимается в опциях (чтобы legacy-конфиги/ссылки не
падали на парсинге), но **игнорируется**. Тир — `legacy/ignore`.
- **Источник:** Xray `dialer.go` writer-loop (нет concurrency-cap), `upload_queue.go` (heap по `Seq`);
отсутствие символа подтверждено code search.
---
## 6. Что из этого реализуем в sing-box-lx (резюме)
- **Реализуем (12 клиентских + 2 tuning-бонуса):** session/seq placement+key, uplink-data
placement/key/chunk/method, полный X-Padding obfs (placement/key/header/method, включая `tokenish`),
плюс `sc_max_each_post_bytes` / `sc_min_posts_interval_ms` для packet-up.
- **Не реализуем (4 server-only):** `server_max_header_bytes`, `no_sse_header`, `sc_max_buffered_posts`,
`sc_stream_up_server_secs` — accept-but-ignore в опциях.
- **Не реализуем (1 legacy):** `sc_max_concurrent_posts` — удалено из upstream; наш последовательный
upload = текущий Xray; accept-but-ignore.
- **Зависимости:** только `golang.org/x/net/http2/hpack` для tokenish (уже есть). Без вендоринга Xray.
- **Совместимость:** все дефолты сохраняют текущее (рабочее, лайв-проверенное) поведение байт-в-байт.
Детали маппинга в Go-структуры, точки касания upstream и план реализации — в [SPEC.md](SPEC.md) §5–§7.
-49
View File
@@ -1,49 +0,0 @@
# PLAN: 002 — XHTTP_CLIENT_TRANSPORT
## 1. Архитектура
**Было (upstream):** `transport/v2ray/transport.go::NewClientTransport` — `switch options.Type { case … }`.
**Станет:** реестр конструкторов.
```go
// transport/v2ray/registry.go (new)
var clientRegistry = map[string]ClientConstructor{}
func RegisterClient(typ string, ctor ClientConstructor) { clientRegistry[typ] = ctor }
```
- Встроенные типы регистрируются в `init()` (в `transport.go` или соседнем файле) — поведение для http/ws/quic/grpc/httpupgrade без изменений.
- `NewClientTransport` → `ctor, ok := clientRegistry[options.Type]`; нет — прежняя ошибка.
- XHTTP-конструктор регистрируется из пакета `v2rayxhttp` через `init()` **только** под `//go:build with_xhttp` (через проводящий файл, чтобы импорт пакета подтягивался лишь с тегом).
Конструктор XHTTP должен соответствовать сигнатуре `ClientConstructor` (см. upstream `transport.go`): `(ctx, dialer, serverAddr, options, tlsConfig) → (adapter.V2RayClientTransport, error)`. Опции достаются из `options.XHTTPOptions`.
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `transport/v2ray/registry.go` | **new** | `clientRegistry`, `RegisterClient`, `init()` встроенных типов |
| `transport/v2ray/transport.go` | `// lx:` | `NewClientTransport`: `switch` → lookup в реестре (минимальная правка) |
| `transport/v2rayxhttp/*.go` | **new** | Клиент XHTTP: `client.go`, `conn.go`, `dialer.go`, `http.go`, `mux.go`, `upload_queue.go`, `writer.go` |
| `transport/v2rayxhttp/register.go` | **new** | `//go:build with_xhttp` — `init(){ v2ray.RegisterClient(C.V2RayTransportTypeXHTTP, New) }` |
| `constant/v2ray.go` | `// lx:` | `V2RayTransportTypeXHTTP = "xhttp"` |
| `option/v2ray_transport.go` | `// lx:` | одна строка: поле `XHTTPOptions` в `_V2RayTransportOptions` |
| `option/v2ray_xhttp.go` | **new** | тип `XHTTPOptions` (mode, path, host, headers, padding…) |
| `include/v2rayxhttp_stub.go` | **new** | `//go:build !with_xhttp` — понятная ошибка/нет регистрации |
| `lx-test/config/xhttp_*.json` | **new** | Конфиги для `sing-box check` |
## 3. Зона касания upstream (для ребейза)
Только: `transport/v2ray/transport.go`, `constant/v2ray.go`, `option/v2ray_transport.go`. Все — с `// lx:` маркерами, атомарными коммитами. Реестр (`registry.go`) — новый файл, конфликтов не даёт.
## 4. Порядок работ
1. `registry.go` + рефактор `NewClientTransport` (поведение идентично — прогнать существующие тесты транспорта).
2. Константа + опции (`v2ray_xhttp.go` + `// lx:` поле).
3. Пакет `v2rayxhttp` (портировать клиент, сверить с Xray).
4. `register.go` под тегом + `_stub.go` без тега.
5. Конфиги, `sing-box check`, ручной коннект.
## 5. Риски
- **XHTTP — движущаяся цель** в Xray; нужна периодическая сверка параметров (`mode`, padding).
- `mode=auto` в sing-box-портах исторически падает в `packet-up`, что ломало, напр., аплоад в Telegram ([hiddify#2082](https://github.com/hiddify/hiddify-app/issues/2082)) — задокументировать фактический выбор режима.
- Рефактор `switch`→registry должен **точно** сохранить семантику ошибок и nil-обработку (`options.Type == ""` → `nil, nil`).
-187
View File
@@ -1,187 +0,0 @@
# SPEC: 002 — XHTTP_CLIENT_TRANSPORT (full)
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | A (active) — расширение до полной клиентской поддержки |
| База | upstream v1.14.0-alpha.* (ветка `lx-1.14`) |
| Реализация | ветка `lx-1.14-xhttp-full` |
| История | v1 (минимальный lean-native клиент, Complete) → см. [SPEC_v1.md](SPEC_v1.md) |
Расширить **клиентский XHTTP-транспорт** (`transport/v2rayxhttp`, build-тег `with_xhttp`) до
**полной клиентской поддержки** расширенных параметров Xray XHTTP / sing-box-extended: настраиваемые
placement'ы session/seq/uplink-data, ключи, метод upload и полноценный **X-Padding obfs-режим**
(включая `tokenish`/HPACK). Сохранить байт-в-байт текущее (лайв-проверенное) поведение по умолчанию.
> **Карта параметров** (что каждое поле делает в Xray, клиент/сервер, дефолты, on-wire) вынесена в
> отдельный документ: **[PARAM_MAP.md](PARAM_MAP.md)** — читать его первым.
---
## 1. Проблема / контекст
- v1 (см. [SPEC_v1.md](SPEC_v1.md)) дал рабочий lean-native клиент с 6 полями (`host`, `path`, `mode`,
`headers`, `x_padding_bytes`, `no_grpc_header`) и проверенным коннектом к Xray/3x-ui (packet-up/auto).
- Xray и форки (sing-box-extended, NekoBox+) ушли далеко вперёд: добавлены настраиваемые **placement'ы**
(path/query/header/cookie/body) для session id, seq, payload; **obfs-режим X-Padding** с произвольными
ключами/заголовками и алгоритмами (`repeat-x`/`tokenish`); метод upload; tuning packet-up. Сервера,
настроенные на эти режимы, нашим v1-клиентом **не обслуживаются**.
- Цель — закрыть **весь клиентский** surface, оставаясь client-only и не вендоря Xray-внутренности.
## 2. Цель
VLESS/VMess/Trojan outbound с `transport.type=xhttp` поднимает рабочее соединение к XHTTP-серверу Xray
в **любой** из поддерживаемых сервером клиентских конфигураций placement/obfs, поверх TLS/Reality.
Дефолты идентичны v1 (нулевая регрессия). Без `with_xhttp` тип `xhttp` отвергается как прежде.
## 3. Аудит (основание спеки)
Глубокий аудит исходников **XTLS/Xray-core** (`transport/internet/splithttp/*`, `main`) и
**shtorm-7/sing-box-extended** (`transport/v2rayxhttp/*`, `option/v2ray_transport.go`, `extended`),
с adversarial-верификацией каждого факта против исходника. Полные карточки — в [PARAM_MAP.md](PARAM_MAP.md).
**Итог по 16 полям из списка NekoBox+:**
- **12 клиентских** (реализуем): `sessionPlacement`(+`sessionKey`), `seqPlacement`(+`seqKey`),
`uplinkDataPlacement`, `uplinkDataKey`, `uplinkChunkSize`, `uplinkHTTPMethod`, `xPaddingObfsMode`,
`xPaddingKey`, `xPaddingHeader`, `xPaddingPlacement`, `xPaddingMethod`.
- **4 server-only** (НЕ реализуем, accept-but-ignore): `serverMaxHeaderBytes`, `noSSEHeader`,
`scMaxBufferedPosts`, `scStreamUpServerSecs`. Ни одно не читается клиентом; клиент даже не инспектирует
`Content-Type` ответа.
- **+2 клиентских tuning-поля** вне списка, которые клиент реально читает в packet-up:
`scMaxEachPostBytes`, `scMinPostsIntervalMs` — добавляем для полноты.
**Ключевой факт совместимости:** текущий v1-код = дефолтный Xray (obfs off → x_padding в Referer;
session/seq в path; payload в body; метод POST). Расширение — это **добавление альтернативных режимов**,
а не переписывание. Все новые поля имеют дефолты, сохраняющие текущее поведение.
**Коррекции верификации, влияющие на реализацию:**
1. `Access-Control-Allow-Credentials` **не выставляется** нигде (cookie/query placement его не ставят).
2. Mode-gate'ы (`header`/`cookie` uplink только в packet-up; `GET` метод только в packet-up) живут **только**
в extended option-слое — переносим как нашу валидацию. **Исключение (lx):** `GET` вне packet-up — НЕ
ошибка, а **soft-fallback на `POST` + `WARN`** (чтобы одна кривая нода подписки не роняла весь конфиг);
`header`/`cookie` uplink вне packet-up остаётся жёсткой ошибкой (нет безопасного дефолта). См. журнал.
3. Upper-casing `uplink_http_method` — только в extended option-слое.
4. Серверный keepalive-padding гейтится `(legacyMarker || obfsAccepted) && scStreamUpServerSecs.To>0` —
нас (клиент) не касается, но учтено в карте.
## 4. Требования
### 4.1 Опции (`option/v2ray_xhttp.go` — новый код, нулевое касание upstream)
Расширить `V2RayXHTTPOptions`, **сохранив** 6 существующих полей. Добавить (JSON snake_case, как в
sing-box-extended; все `omitempty`):
**Placement / keys (core):**
- `session_placement` (string: `path`|`query`|`header`|`cookie`; default `path`)
- `session_key` (string; default `X-Session`/`x_session` по placement)
- `seq_placement` (string: `path`|`query`|`header`|`cookie`; default `path`)
- `seq_key` (string; default `X-Seq`/`x_seq` по placement)
**Uplink data (obfs/tuning):**
- `uplink_data_placement` (string: `body`|`auto`|`header`|`cookie`; default `auto`≈body)
- `uplink_data_key` (string; default `X-Data`/`x_data` по placement)
- `uplink_chunk_size` (`*badoption.Range[int]`; default зависит от placement)
- `uplink_http_method` (string; default `POST`; upper-case)
**X-Padding obfs:**
- `x_padding_obfs_mode` (bool; default false)
- `x_padding_key` (string; default `x_padding`)
- `x_padding_header` (string; default `X-Padding`)
- `x_padding_placement` (string: `cookie`|`header`|`query`|`queryInHeader`; default `queryInHeader`)
- `x_padding_method` (string: `repeat-x`|`tokenish`; default `repeat-x`)
**Packet-up tuning (бонус):**
- `sc_max_each_post_bytes` (`*badoption.Range[int]`; default `[1000000,1000000]`)
- `sc_min_posts_interval_ms` (`*badoption.Range[int]`; default `[30,30]`)
**Server-only (accept-but-ignore — присутствуют в struct, помечены `// server-only, ignored by client`):**
- `server_max_header_bytes` (int), `no_sse_header` (bool), `sc_max_buffered_posts` (int64),
`sc_stream_up_server_secs` (`*badoption.Range[int]`)
Нормализация и валидация (mode-gate'ы, дефолты по placement, upper-case метода, отказ на неизвестных
значениях с понятными ошибками) — реплицируем семантику extended `checkV2RayXHTTPBaseOptions` +
`GetNormalized*`.
### 4.2 Транспорт (`transport/v2rayxhttp/*` — новый код)
- **placement-движок:** единая функция `applyMeta(req, sessionID, seqStr)` раскладывающая session id и seq
по настроенным placement/key (path/query/header/cookie). Path сохраняет порядок «session первый, seq
второй». Заменяет нынешнюю жёсткую path-логику в `requestURL`.
- **uplink-data:** для packet-up — `body`/`auto` (тело, как сейчас) или header/cookie chunked
(`base64.RawURLEncoding`, чанки `<key>-<i>`/`<key>_<i>`, размер по `uplink_chunk_size`).
- **uplink-метод:** upload-запросы используют `uplink_http_method` (download — всегда GET).
- **X-Padding движок (`xpadding.go`, новый файл):**
- non-obfs (default): как сейчас — `x_padding=<pad>` в query внутри `Referer`.
- obfs: `applyXPadding(req)` по `x_padding_placement` (cookie/header/query/queryInHeader) с именами
`x_padding_key`/`x_padding_header`.
- генератор: `repeat-x` (`strings.Repeat`) и `tokenish` (порт `GenerateTokenishPaddingBase62` на
`crypto/rand` + `golang.org/x/net/http2/hpack.HuffmanEncodeLength`, ±2 тюнинг, filler `X`/`Z`, ≤150 итер).
- **packet-up tuning:** разбиение upload по `sc_max_each_post_bytes`, троттлинг по `sc_min_posts_interval_ms`.
- Конструктор `NewClient` принимает расширенные опции, нормализует, валидирует, кэширует разобранные
placement/method.
### 4.3 Зона касания upstream
Без изменений против v1: ровно `option/v2ray_transport.go` (уже прокидывает весь `XHTTPOptions`),
`constant/v2ray.go`, `transport/v2ray/transport.go` (registry). Все новые поля и логика — в новых файлах
(`option/v2ray_xhttp.go`, `transport/v2rayxhttp/*`).
### 4.4 TLS/Reality
Без изменений — `tlsConfig` прокидывается как прежде; XHTTP+Reality работает. (XHTTP+XTLS-Vision
несовместимы — ограничение протокола.)
## 5. Маппинг в Go (точки реализации)
| Слой | Файл | Изменение |
|------|------|-----------|
| Опции | `option/v2ray_xhttp.go` | +20 полей в `V2RayXHTTPOptions`; новый файл нормализации/валидации (можно `option/v2ray_xhttp_normalize.go`) |
| Placement | `transport/v2rayxhttp/meta.go` (новый) | `applyMeta` + резолверы ключей/дефолтов |
| Padding | `transport/v2rayxhttp/xpadding.go` (новый) | obfs-движок + `repeat-x`/`tokenish` |
| Клиент | `transport/v2rayxhttp/client.go` | приём опций, ветвление newRequest на obfs/non-obfs |
| Conn | `transport/v2rayxhttp/conn.go` | uplink-data placement, packet-up tuning |
| Тесты | `transport/v2rayxhttp/*_test.go` | юнит-тесты на каждый placement/метод/генератор |
## 6. Критерии приёмки
- `sing-box check -c` принимает VLESS + `transport.type=xhttp` со всеми новыми полями (валидные конфиги
под каждый placement/obfs-режим в `lx-test/config/`).
- **Нулевая регрессия дефолта:** конфиг без новых полей даёт байт-в-байт тот же on-wire запрос, что v1
(тест сравнения с золотым образцом Referer/path/body).
- Юнит-тесты зелёные на каждый:
- placement session/seq (path/query/header/cookie) — корректные URL/заголовки/cookie;
- uplink-data (body/header/cookie) — корректная сборка base64-чанков;
- X-Padding obfs (4 placement × 2 метода) — корректное размещение и длина;
- `tokenish` — HPACK-Huffman-длина в [from-2, to+2];
- валидация — отказ на невалидных значениях и mode-gate'ах с правильными ошибками.
- Сборка `-tags with_xhttp` — ок; сборка без тега — `xhttp` отвергается.
- `go test ./transport/v2rayxhttp/`, `go vet` (lx-теги), `gofmt -l` — зелёные.
- Ребейз-проверка: зона касания upstream не расширилась против v1.
- **Лайв (если есть сервер):** коннект к Xray-серверу хотя бы в одном не-дефолтном режиме (например
`x_padding_obfs_mode=true` + `x_padding_placement=header`) — иначе помечается как открытый TODO, как в v1.
## 7. План реализации (ветка `lx-1.14-xhttp-full`)
1. Расширить `option/v2ray_xhttp.go` (+ нормализация/валидация). `gofmt`, компиляция.
2. `transport/v2rayxhttp/meta.go` — placement-движок + резолверы. Юнит-тесты.
3. `transport/v2rayxhttp/xpadding.go` — obfs + repeat-x/tokenish. Юнит-тесты (включая HPACK-длину).
4. Прошить в `client.go`/`conn.go`: ветвление obfs/non-obfs, uplink-data placement, метод, tuning.
5. Конфиги `lx-test/config/xhttp_*.json` под новые режимы; `sing-box check`.
6. Тест нулевой регрессии дефолта.
7. `go test` + `go vet` + `gofmt` + сборка с тегом/без. `make -f Makefile.lx lx-build`.
8. IMPLEMENTATION_REPORT, TASKS, статус папки.
## 8. Вне скоупа
- **XHTTP server/inbound** (отдельная задача) — server-only поля только accept-but-ignore.
- **xmux** (мультиплексирование соединений) — отдельная оптимизация, не входит в параметры.
- Маппинг `vless://…type=xhttp` в лаунчере (его репозиторий).
## 9. Ссылки
- [PARAM_MAP.md](PARAM_MAP.md) — детальная карта всех параметров (основной справочник).
- [SPEC_v1.md](SPEC_v1.md) — исходная минимальная спека (история).
- Xray-core splithttp: https://github.com/XTLS/Xray-core/tree/main/transport/internet/splithttp
- sing-box-extended (ветка `extended`): https://github.com/shtorm-7/sing-box-extended
- [V2Ray Transport — sing-box](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/)
@@ -1,74 +0,0 @@
# SPEC: 002 — XHTTP_CLIENT_TRANSPORT
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Добавить **клиентский XHTTP-транспорт** (совместимость с Xray XHTTP) для VLESS/VMess/Trojan, встроив его через **registry-рефактор** диспетчера v2ray-транспортов, за build-тегом `with_xhttp`.
---
## 1. Проблема / контекст
- Upstream sing-box XHTTP не поддерживает и не планирует ([#3550](https://github.com/SagerNet/sing-box/issues/3550)). Сервера на Xray всё чаще только XHTTP (после депрекации части транспортов в Xray).
- В sing-box диспетчер v2ray-транспортов — **хардкод-`switch`** по `options.Type` в `transport/v2ray/transport.go`. Добавлять `case` на каждый ребейз — точка постоянных конфликтов.
## 2. Цель
VLESS/VMess/Trojan outbound с `transport.type = "xhttp"` поднимают рабочее соединение к XHTTP-серверу Xray, в т.ч. поверх **TLS/Reality**. Без тега `with_xhttp` тип `xhttp` отвергается с понятной ошибкой.
## 3. Требования
### 3.1 Registry-рефактор диспетчера (точка касания upstream)
- Превратить выбор клиентского транспорта в **реестр**: `transport.RegisterClient(type, ClientConstructor)` + `map[string]ClientConstructor`, заполняемый при `init()`.
- Встроенные транспорты (`http`, `ws`, `quic`, `grpc`, `httpupgrade`) регистрируются как раньше (поведение идентично upstream).
- `NewClientTransport` ищет конструктор в реестре вместо `switch` (поведение для известных типов — без изменений; для неизвестных — та же ошибка `unknown transport type`).
- **Серверный** диспетчер (`NewServerTransport`) — **не трогаем** (scope client-only); xhttp-сервер отложен.
### 3.2 Пакет `transport/v2rayxhttp` (новый код)
- Клиентская реализация XHTTP (референс — [`hiddify/hiddify-sing-box`](https://github.com/hiddify/hiddify-sing-box) `transport/v2rayxhttp`, сверка параметров с Xray-core).
- Поддержать режимы Xray: `auto`, `packet-up`, `stream-up`, `stream-one`; параметры `path`, `host`, `headers`, и padding-расширения (`x_padding_bytes` и т.п.) — в объёме, нужном для совместимости.
- Регистрация конструктора через `init()` в файле за `//go:build with_xhttp`.
### 3.3 Опции и константа
- `constant/v2ray.go`: `V2RayTransportTypeXHTTP = "xhttp"` (внутри `// lx:` маркера).
- `option/v2ray_transport.go`: поле `XHTTPOptions XHTTPOptions` в `_V2RayTransportOptions` + тип `XHTTPOptions` (в новом файле `option/v2ray_xhttp.go`, чтобы минимизировать дифф основного файла; в `_V2RayTransportOptions` — одна `// lx:` строка).
### 3.4 TLS/Reality
- `tlsConfig` прокидывается в конструктор как у прочих транспортов → связка **XHTTP + Reality** работает без доп. кода. (XHTTP + XTLS-Vision несовместимы — ограничение протокола, не наше.)
## 4. Критерии приёмки
- `sing-box check -c` принимает VLESS + `transport.type=xhttp` + `tls.reality`.
- Реальный коннект к XHTTP-серверу Xray (ручная проверка), хотя бы `mode=stream-one` и `packet-up`.
- Сборка **без** `with_xhttp`: конфиг с `xhttp` → ошибка `unknown transport type: xhttp` (или эквивалент реестра).
- `go test ./transport/...`, `go vet ./...` зелёные.
- Ребейз-проверка: при следующем upstream-теге конфликты возможны **только** в `transport/v2ray/transport.go`, `constant/v2ray.go`, `option/v2ray_transport.go`.
## 5. Вне скоупа
- **XHTTP server/inbound** (отдельная будущая задача).
- Маппинг `vless://…type=xhttp` в самом лаунчере (репозиторий `singbox-launcher`, follow-up к его задаче 023).
- 100% паритет всех Xray-расширений XHTTP — только то, что нужно для рабочего коннекта.
## 6. Ссылки
- [V2Ray Transport — sing-box](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/)
- [hiddify-sing-box (референс XHTTP)](https://github.com/hiddify/hiddify-sing-box)
- [XHTTP overview (Habr)](https://habr.com/en/articles/990208/)
---
## 7. Разведка порта и выбор подхода (добавлено по ходу)
**Что показал референс `hiddify/hiddify-sing-box` (`transport/v2rayxhttp/client.go`):** XHTTP в hiddify реализован НЕ поверх примитивов sing-box, а через **вендорённое поддерево Xray** под `common/xray/{buf,net,pipe,signal/done,uuid}` + зависимости `quic-go`, `http3`, `golang.org/x/net/http2`, абстракция `DialerClient`/`XmuxClient` и опции `option.V2RayXHTTPOptions{ V2RayXHTTPBaseOptions }`. Целевой интерфейс прост — `adapter.V2RayClientTransport = { DialContext(ctx) (net.Conn, error); Close() error }` — но реализация тянет много транзитивного кода и завязана на старую версию sing-box hiddify.
**Развилка подхода (зафиксировать перед кодом порта):**
- **(A) Faithful-vendor.** Перенести hiddify `common/xray/*` + пакет `v2rayxhttp` как **новые файлы** (namespaced), адаптировать импорты под v1.13.13. Плюс: максимальная совместимость с реальными XHTTP-серверами, проверенный код. Минус: больший footprint (но всё — новые файлы → **нулевая зона касания upstream**, что согласуется с CONSTITUTION). Тащит `quic-go`/`http3` (часть уже в go.mod sing-box).
- **(B) Lean-native.** Написать компактный XHTTP-клиент на примитивах sing-box (по образцу in-tree `transport/v2rayhttpupgrade`). Плюс: меньше кода, меньше зависимостей. Минус: больше оригинальной работы и риск несовпадения с Xray по краям (`mode=auto`, padding, xmux).
**Рекомендация:** **(A)** — приоритет проекта №2 (корректность/совместимость) важнее объёма, а изоляция в новых файлах сохраняет ребейзопригодность. Footprint велик, но не увеличивает конфликтность ребейза.
**Обязательно для приёмки:** живой XHTTP-сервер (Xray) для end-to-end проверки — синтетического `sing-box check` недостаточно (XHTTP под активной разработкой, версии client↔server должны совпадать).
-54
View File
@@ -1,54 +0,0 @@
# TASKS — 002-XHTTP_CLIENT_TRANSPORT
## v2 — полная клиентская поддержка (ветка `lx-1.14-xhttp-full`)
### Аудит (основание спеки) — ✅ сделано
- [x] Аудит Xray-core `transport/internet/splithttp` + sing-box-extended `transport/v2rayxhttp`/`option`
- [x] Adversarial-верификация каждого из 16 параметров против исходника
- [x] Карта параметров → [PARAM_MAP.md](PARAM_MAP.md) (клиент/сервер, дефолты, on-wire, impl-заметки)
- [x] Классификация: 12 клиентских + 2 tuning-бонуса реализуем; 4 server-only — accept-but-ignore
### Спека (ветка `lx-1.14`) — ✅ сделано
- [x] `SPEC.md` (минимальная) → `SPEC_v1.md` (история сохранена)
- [x] Новая полная `SPEC.md` + `PARAM_MAP.md` — коммит `cafbe546`
### Опции — ✅ сделано
- [x] `option/v2ray_xhttp.go`: +20 полей в `V2RayXHTTPOptions` (placement/key/obfs/tuning + server-only)
- [x] Range-поля строкой `"min-max"` (нет `badoption.Range` в `sing`; решение в SPEC §4.1)
- [x] `option/v2ray_transport.go` уже прокидывает весь `XHTTPOptions` (без новых правок)
### Транспорт — ✅ сделано
- [x] `meta.go`: placement-движок `applyMeta`, `normalizeMeta` (mode-gate'ы, дефолты), uplink-data сборка
- [x] `xpadding.go`: obfs-движок `applyXPadding`, `repeat-x` + `tokenish` (HPACK-Huffman-тюнинг)
- [x] `client.go`: `meta`/`paddingRange`, `NewClient`→`normalizeMeta`, `baseURL`+`newRequest(...)`
- [x] `conn.go`: dial-функции под новую сигнатуру; `packetConn.Write` (разбиение + троттлинг + placement)
### Проверки — ✅ сделано
- [x] `xhttp_test.go` + `url_test.go`: 16 тест-функций (placement/uplink/obfs/tokenish/валидация/регрессия)
- [x] `go test -tags with_xhttp ./transport/v2rayxhttp/` → 16/16 PASS
- [x] `lx-test/config/xhttp_obfs_full.json` + `./sing-box check` → PASS (все 3 xhttp-конфига)
- [x] `make -f Makefile.lx lx-build` → ок; `go vet`/`gofmt` → чисто; tagged/untagged build → ок
- [x] Негатив: без `with_xhttp` → `unknown transport type: xhttp`
### Лайв-верификация
- [x] **Дефолтный путь лайв-подтверждён** на реальных нодах (igareck/vpn-configs): 4 живых XHTTP-сервера,
packet-up + stream-one(reality), скачивание 1 МБ через IP сервера — закрывает stream-one-TODO из §011
- [ ] **Лайв obfs/placement** против сервера, *настроенного* на obfs (публичные ноды на дефолте — не закрывают)
### Закрытие
- [x] IMPLEMENTATION_REPORT.md (секция v2)
- [ ] Merge ветки `lx-1.14-xhttp-full` → `lx-1.14` (по решению пользователя)
---
## v1 (история) — Complete
### Registry-рефактор (касание upstream) — ✅
- [x] `transport/v2ray/registry.go`: реестр + `RegisterClient` — коммит `e111f800`
- [x] `// lx:` правка `NewClientTransport` — lookup вместо `switch` — коммит `2d97ff56`
### Опции / константа / клиент — ✅
- [x] `V2RayTransportTypeXHTTP="xhttp"` — `2d97ff56`
- [x] lean-native клиент (`client.go`/`conn.go`/`register.go`) — `d1b434fc`
- [x] padding в Referer, sessionId path-layout, stream-one bare-path fix (задача 011) — `5a398a5e`
- [x] Лайв packet-up/auto против Xray/3x-ui (см. IMPLEMENTATION_REPORT v1)
@@ -1,343 +0,0 @@
# Разбор `vless://…type=xhttp` → sing-box transport (для парсера ссылок)
> Справочник для команд **Android-клиента** и **лаунчера**: как превратить VLESS-ссылку
> с XHTTP-транспортом в `outbound.transport` конфига sing-box-lx (тег сборки `with_xhttp`).
> Источник полей — [PARAM_MAP.md](PARAM_MAP.md) (в этой же папке спеки).
> Версия транспорта: SPEC 002 v2 (полная клиентская поддержка).
---
## 0. TL;DR
Ссылка вида:
```
vless://<uuid>@<host>:<port>?type=xhttp&<params...>[&extra=<urlencoded-json>]#<remark>
```
даёт `outbound`:
```jsonc
{
"type": "vless",
"server": "<host>",
"server_port": <port>,
"uuid": "<uuid>",
"flow": "", // XHTTP несовместим с xtls-rprx-vision → flow всегда пустой
"tls": { ... }, // из security/sni/fp/pbk/sid/alpn (см. §3)
"transport": {
"type": "xhttp",
... // из xhttp-параметров и extra (см. §2)
}
}
```
**Два источника XHTTP-полей в URL:**
1. Плоские query-параметры (`path`, `mode`, `host`, …).
2. Параметр **`extra`** — это **URL-encoded JSON** с дополнительными полями (`scMaxEachPostBytes`,
`xPaddingBytes`, `noGRPCHeader`, …). Его надо: `urldecode` → `JSON.parse` → влить в transport.
---
## 1. Алгоритм парсера (по шагам)
1. Срезать схему `vless://`, отделить `#remark` (фрагмент) — это только подпись ноды.
2. `userinfo@host:port` → `uuid` / `server` / `server_port`.
3. Разобрать query-string в map. **Все значения percent-decoded.**
4. Если `type` (он же может прийти как `transport`/`net` в других форматах) != `xhttp` — это не наш транспорт, парсить по другой ветке.
5. Если есть `extra` → `JSON.parse(urldecode(extra))` и слить ключи в ту же map (extra имеет приоритет для своих ключей).
6. Собрать `tls` (§3) и `transport` (§2) по таблицам ниже.
7. Поля, которых нет в URL, **не выставлять** — у транспорта корректные дефолты (см. колонку «дефолт»).
---
## 2. Маппинг XHTTP-параметров → `transport`
JSON-ключи sing-box — **snake_case**. Источник в URL — camelCase (как в Xray/extended).
### 2.1 Базовые (приходят как плоские query ИЛИ в `extra`)
| URL-параметр | → transport JSON | Тип | Дефолт | Примечание |
|--------------|------------------|-----|--------|------------|
| `host` | `host` | str | SNI/server | HTTP Host header |
| `path` | `path` | str | `/` | префикс пути; **обрезать `?…` хвост** (см. §4) |
| `mode` | `mode` | str | `auto` | `auto`\|`packet-up`\|`stream-up`\|`stream-one` |
| `xPaddingBytes` | `x_padding_bytes` | str | `100-1000` | формат `"min-max"` или одиночное число |
| `noGRPCHeader` | `no_grpc_header` | bool | `false` | |
| (headers) | `headers` | obj | — | произвольные доп. заголовки (если клиент их хранит) |
### 2.2 Placement / keys (расширенные, v2)
| URL-параметр | → transport JSON | Тип | Дефолт | Допустимые |
|--------------|------------------|-----|--------|------------|
| `sessionPlacement` | `session_placement` | str | `path` | path\|query\|header\|cookie |
| `sessionKey` | `session_key` | str | `X-Session`/`x_session` | |
| `seqPlacement` | `seq_placement` | str | `path` | path\|query\|header\|cookie |
| `seqKey` | `seq_key` | str | `X-Seq`/`x_seq` | |
| `uplinkDataPlacement`| `uplink_data_placement` | str | `auto` | body\|auto\|header\|cookie |
| `uplinkDataKey` | `uplink_data_key` | str | `X-Data`/`x_data` | |
| `uplinkChunkSize` | `uplink_chunk_size` | str | (зависит от placement) | `"min-max"` |
| `uplinkHTTPMethod` | `uplink_http_method` | str | `POST` | upper-case; `GET` только в packet-up (вне — auto-fallback на `POST` + warning, не ошибка) |
### 2.3 X-Padding obfs (расширенные, v2)
| URL-параметр | → transport JSON | Тип | Дефолт | Допустимые |
|--------------|------------------|-----|--------|------------|
| `xPaddingObfsMode` | `x_padding_obfs_mode` | bool | `false` | |
| `xPaddingKey` | `x_padding_key` | str | `x_padding` | |
| `xPaddingHeader` | `x_padding_header` | str | `X-Padding` | |
| `xPaddingPlacement` | `x_padding_placement` | str | `queryInHeader` | cookie\|header\|query\|queryInHeader |
| `xPaddingMethod` | `x_padding_method` | str | `repeat-x` | repeat-x\|tokenish |
### 2.4 Packet-up tuning (обычно приходят в `extra`)
| URL-параметр (extra) | → transport JSON | Тип | Дефолт |
|----------------------|------------------|-----|--------|
| `scMaxEachPostBytes` | `sc_max_each_post_bytes` | str (`"min-max"`) | `1000000-1000000` |
| `scMinPostsIntervalMs` | `sc_min_posts_interval_ms` | str (`"min-max"`) | `30-30` |
> ⚠️ В `extra` эти значения часто приходят **числом** (`"scMaxEachPostBytes":"1000000"`,
> `"scMinPostsIntervalMs":30.0`). Транспорт sing-box-lx ждёт **строку `"min-max"`** — превратить
> одиночное число `N` в строку `"N-N"` (или просто `"N"` — парсер примет и то, и то). Дробную часть
> у `30.0` отбросить → `"30"`.
### 2.5 Игнорируемые / серверные
| URL-параметр | Действие |
|--------------|----------|
| `scMaxConcurrentPosts` | **Accept-but-ignore.** Legacy-поле старого Xray (в текущем Xray/extended его нет — там 1 POST-тело за раз). Клиент sing-box-lx шлёт upload-POST последовательно (= текущий Xray). Можно влить как `sc_max_concurrent_posts` (принято, но не используется) — или опустить (см. §6). |
| `serverMaxHeaderBytes`, `noSSEHeader`, `scMaxBufferedPosts`, `scStreamUpServerSecs` | server-only. Можно влить как `server_max_header_bytes`/`no_sse_header`/`sc_max_buffered_posts`/`sc_stream_up_server_secs` (клиент их принимает, но игнорирует) — или просто опустить. |
| `fragment`, `fm`, `fragment=...` | TLS-фрагментация (Xray-специфика). **Не часть XHTTP.** Маппить в свою TLS-fragment-фичу, если есть; иначе опустить. |
| `flow` | Для XHTTP всегда пустой (vision несовместим). |
---
## 3. TLS / Reality (из общих VLESS-параметров)
| URL-параметр | → JSON | Примечание |
|--------------|--------|------------|
| `security=tls` | `tls.enabled=true` | |
| `security=reality` | `tls.enabled=true` + `tls.reality.enabled=true` | |
| `security=none` / отсутствует | без `tls` (plaintext h2c) | редкие plain-XHTTP ноды |
| `sni` | `tls.server_name` | |
| `fp` | `tls.utls.fingerprint` (+ `tls.utls.enabled=true`) | `chrome`/`firefox`/… |
| `alpn` | `tls.alpn` (split по `,`) | напр. `h2,http/1.1` → `["h2","http/1.1"]` |
| `pbk` | `tls.reality.public_key` | только при reality |
| `sid` | `tls.reality.short_id` | только при reality |
| `spx` | (Xray spiderX) — у sing-box нет аналога, опустить | |
| `allowInsecure` / `insecure=1` | `tls.insecure=true` | |
---
## 4. Подводные камни (обязательно учесть)
1. **`path` с query-хвостом.** Реальные ноды дают `path=/GaMeOpTiMiZeR?ed=2048`. Часть после `?` — это
НЕ путь; либо отрезать (`path` = `/GaMeOpTiMiZeR`), либо сохранить как есть, если ваш клиент это умеет.
sing-box-lx сам нормализует путь, но `?` внутри `path` лучше срезать на стороне парсера.
2. **`extra` — это JSON, не query.** Сначала `urldecode`, потом `JSON.parse`. Не пытаться парсить как `&k=v`.
3. **Числа vs строки в `extra`.** `scMaxEachPostBytes`/`scMinPostsIntervalMs` приходят числами →
привести к строке `"min-max"` (см. §2.4).
4. **`mode=auto` сам резолвится в транспорте** (reality→stream-one, иначе→packet-up). Парсеру **не нужно**
подменять `auto` на конкретный режим — передавать `auto` как есть.
5. **`flow` всегда пустой** для XHTTP. Если в ссылке `flow=xtls-rprx-vision` — это ошибка ноды для XHTTP;
ставить `flow=""`.
6. **camelCase → snake_case** — не передавать camelCase-ключи в JSON sing-box, он их не поймёт.
---
## 5. Готовые примеры (из реальных подписок)
### Пример A — Reality + auto (минимальный целевой кейс)
URL:
```
vless://4b5cdcab-289e-4d9a-8ebd-f70a4f49db6a@sup.le3service.ir:443?mode=auto&path=/&security=reality&encryption=none&pbk=cmPAZWGaEWFOPF92El1peuQFoScxyS6XsGADu8nhjVc&host=sup.le3service.ir&fp=chrome&spx=/w22l0muhE4dqe8u&type=xhttp&sni=varzesh3.com&sid=5f14f1185c#France
```
→ sing-box:
```jsonc
{
"type": "vless",
"server": "sup.le3service.ir",
"server_port": 443,
"uuid": "4b5cdcab-289e-4d9a-8ebd-f70a4f49db6a",
"flow": "",
"tls": {
"enabled": true,
"server_name": "varzesh3.com",
"utls": { "enabled": true, "fingerprint": "chrome" },
"reality": {
"enabled": true,
"public_key": "cmPAZWGaEWFOPF92El1peuQFoScxyS6XsGADu8nhjVc",
"short_id": "5f14f1185c"
}
},
"transport": {
"type": "xhttp",
"host": "sup.le3service.ir",
"path": "/",
"mode": "auto"
}
}
```
### Пример B — TLS + packet-up с `extra` (tuning-поля)
URL (фрагмент с `extra`):
```
vless://c59eb5ed-…@199.232.244.214:443?type=xhttp&mode=packet-up&security=tls&sni=manage.fastly.com&host=oh6.global.ssl.fastly.net&path=%2F&alpn=h3&fp=chrome&encryption=none&extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMaxConcurrentPosts%22%3A100.0%2C%22scMinPostsIntervalMs%22%3A30.0%2C%22xPaddingBytes%22%3A%22100-1000%22%2C%22noGRPCHeader%22%3Afalse%7D#France
```
`extra` после `urldecode` + `JSON.parse`:
```json
{
"scMaxEachPostBytes": "1000000",
"scMaxConcurrentPosts": 100.0,
"scMinPostsIntervalMs": 30.0,
"xPaddingBytes": "100-1000",
"noGRPCHeader": false
}
```
→ sing-box transport (числа из extra приведены к `"min-max"`-строкам; `scMaxConcurrentPosts` отброшен):
```jsonc
{
"type": "xhttp",
"host": "oh6.global.ssl.fastly.net",
"path": "/",
"mode": "packet-up",
"x_padding_bytes": "100-1000",
"sc_max_each_post_bytes": "1000000-1000000",
"sc_min_posts_interval_ms": "30-30",
"no_grpc_header": false
}
```
(плюс `tls.enabled=true`, `tls.server_name="manage.fastly.com"`, `tls.utls.fingerprint="chrome"`, `tls.alpn=["h3"]`)
### Пример C — obfs-режим (расширенный, как настраивают анти-DPI ноды)
Если нода-сервер настроена на obfs (поля в URL приходят плоскими или в `extra`):
```
...&type=xhttp&mode=packet-up&xPaddingObfsMode=true&xPaddingPlacement=header&xPaddingMethod=tokenish&sessionPlacement=header&seqPlacement=query&uplinkDataPlacement=header...
```
→
```jsonc
{
"type": "xhttp",
"mode": "packet-up",
"x_padding_obfs_mode": true,
"x_padding_placement": "header",
"x_padding_method": "tokenish",
"session_placement": "header",
"seq_placement": "query",
"uplink_data_placement": "header"
}
```
---
## 6. Известные ограничения клиента (что НЕ маппить)
- `scMaxConcurrentPosts` — legacy-поле (удалено из текущего Xray-core и sing-box-extended; там upload сериализован в 1 POST-тело за раз). Наш клиент тоже шлёт последовательно = текущий Xray. Поле принимается (`sc_max_concurrent_posts`), но игнорируется.
- `downloadSettings` (асимметричный download-транспорт) — не поддержан; `mode=auto`+reality+downloadSettings
у нас всё равно даст stream-one, не stream-up.
- `spx` (spiderX), Xray browser-dialer — нет аналога.
- **HTTP/3 (`alpn=h3` / QUIC).** Наш XHTTP-клиент работает поверх **HTTP/2** (`http2.Transport`). Xray
умеет H1/H2/H3. Ноды, помеченные `alpn=h3`, мы обслуживаем по H2 (если сервер допускает); если сервер
**требует строго h3** — коннект не встанет. Это архитектурное ограничение транспорта, вне SPEC 002
(отдельная будущая задача «XHTTP over HTTP/3»). Парсеру: `alpn` маппить как есть, но `h3`-only ноды
помечать как потенциально неработающие.
- `fragment` / `fm` (TLS-фрагментация Xray) — не часть XHTTP; маппить в свою TLS-fragment-фичу (если есть)
или опускать.
---
## 7. Чек-лист для интегратора
- [ ] `type=xhttp` распознаётся как XHTTP-транспорт.
- [ ] `extra` декодируется как URL-encoded JSON и вливается в transport.
- [ ] Числовые `sc*`-поля из `extra` → строка `"min-max"`.
- [ ] camelCase → snake_case по таблицам §2.
- [ ] `path` с `?`-хвостом обрезается/обрабатывается.
- [ ] `security=reality` → `tls.reality.{public_key,short_id}` из `pbk`/`sid`.
- [ ] `mode=auto` передаётся как есть (резолвится в ядре).
- [ ] `flow=""` для XHTTP.
- [ ] `scMaxConcurrentPosts` и server-only поля игнорируются (или приняты-но-неактивны).
- [ ] Результат проходит `sing-box check -c`.
---
## 8. Эталон (golden fixture для round-trip / маппинг-тестов)
Готовая фикстура: все **14 новых полей** в **не-дефолтных** значениях (чтобы тест ловил перепутанные
имена/значения, а не просто отсутствие ключа). ✅ **Проверено `sing-box check -c` на текущем lx-бинаре
(`with_xhttp`).**
> Реальный аналог в репозитории: [lx-test/config/xhttp_obfs_full.json](../../lx-test/config/xhttp_obfs_full.json)
> (та же фикстура + server-only/legacy поля для покрытия accept-but-ignore).
### 8.1 Эталонный `transport`-блок (sing-box JSON)
```jsonc
{
"type": "xhttp",
"host": "www.example.com",
"path": "/xhttp",
"mode": "packet-up",
"headers": { "User-Agent": "Mozilla/5.0" },
"x_padding_bytes": "100-1000",
"no_grpc_header": true,
"session_placement": "header", // != default "path"
"session_key": "X-Session",
"seq_placement": "query", // != default "path"
"seq_key": "x_seq",
"uplink_data_placement": "header", // != default "auto" (требует mode=packet-up)
"uplink_data_key": "X-Data",
"uplink_chunk_size": "3000-4000",
"uplink_http_method": "POST",
"x_padding_obfs_mode": true, // != default false
"x_padding_key": "x_padding",
"x_padding_header": "X-Padding",
"x_padding_placement": "header", // != default "queryInHeader"
"x_padding_method": "tokenish", // != default "repeat-x"
"sc_max_each_post_bytes": "1000000-1000000",
"sc_min_posts_interval_ms": "30-30"
}
```
(Полный outbound с этим блоком: vless + tls/utls + этот transport → проходит `sing-box check`.)
### 8.2 Эквивалентная `vless://`-ссылка (плоский camelCase — то, что пишет `toUri()`)
Тот же transport в URL-форме (для round-trip-теста `parseUri` → `toSingbox`):
```
vless://b831381d-6324-4d53-ad4f-8cda48b30811@www.example.com:443?type=xhttp&security=tls&sni=www.example.com&fp=chrome&encryption=none&host=www.example.com&path=%2Fxhttp&mode=packet-up&xPaddingBytes=100-1000&noGRPCHeader=true&sessionPlacement=header&sessionKey=X-Session&seqPlacement=query&seqKey=x_seq&uplinkDataPlacement=header&uplinkDataKey=X-Data&uplinkChunkSize=3000-4000&uplinkHTTPMethod=POST&xPaddingObfsMode=true&xPaddingKey=x_padding&xPaddingHeader=X-Padding&xPaddingPlacement=header&xPaddingMethod=tokenish&scMaxEachPostBytes=1000000&scMinPostsIntervalMs=30#golden
```
(`scMaxEachPostBytes`/`scMinPostsIntervalMs` тут даны одиночным числом `1000000`/`30` — транспорт
принимает и `"N"`, и `"N-N"`; на выходе `toSingbox` нормализуйте в строку.)
### 8.3 Таблица дефолтов (для omitempty в `toUri()` — НЕ писать, если == дефолт)
| Поле (camelCase) | Дефолт | Писать в toUri только если |
|------------------|--------|----------------------------|
| `sessionPlacement` | `path` | != path |
| `seqPlacement` | `path` | != path |
| `uplinkDataPlacement`| `auto` | != auto |
| `uplinkHTTPMethod` | `POST` | != POST |
| `xPaddingObfsMode` | `false` | == true |
| `xPaddingPlacement` | `queryInHeader`| != queryInHeader |
| `xPaddingMethod` | `repeat-x` | != repeat-x |
| `xPaddingBytes` | `100-1000` | != 100-1000 |
| `*Key` / `*Header` | placement-зависимый (`X-Session`/`x_session`, `X-Seq`/`x_seq`, `X-Data`/`x_data`, `x_padding`/`X-Padding`) | задан явно != дефолта |
| `scMaxEachPostBytes` | `1000000` | != 1000000 |
| `scMinPostsIntervalMs` | `30` | != 30 |
> Записывая в `toUri()` только не-дефолтные поля, вы сохраняете инвариант `parseUri(toUri(spec)) ≈ spec`
> без раздувания URI: на входе отсутствующее поле и поле-с-дефолтом дают одну и ту же `spec`.
-59
View File
@@ -1,59 +0,0 @@
# HISTORY — SPEC 003 AWG2 client endpoint
Хронология вендоренного wireguard-go: базы графта, миграции, что менял upstream. Актуальное состояние — в [SPEC.md](SPEC.md); здесь только «как было раньше и почему переделали».
---
## Почему граф, а не прямой `replace` на amneziawg-go
Первая идея — подключить `amnezia-vpn/amneziawg-go` напрямую через `replace`. **Не работает:** amneziawg-go основан на *upstream* wireguard-go и не имеет sagernet-добавок (`Send(offset)`, `InputPacket`, `conn` reserved/control), на которых держится `transport/wireguard` sing-box. Прямой replace ломает сборку.
Решение — **3-way graft**: обфускация Amnezia накладывается поверх `sagernet/wireguard-go` (а не наоборот). Так контракт sing-box↔device остаётся sagernet'овским, обфускация аддитивна. Форк-модуль — `Leadaxe/wireguard-go-awg2-lx`.
## База графта: эволюция
| Дата | Submodule commit | Sagernet-база | wireguard-go версия | Контекст |
|------|------------------|---------------|---------------------|----------|
| 2026-06-09 | `27290b6` | `506b7631853c` | (pre-v0.0.3) | Первый граф на `v1.13.13`. 3-way merge `amnezia/master` (`f4f4c99`, AWG2 + S4-keepalive) поверх sagernet. |
| ~2026-07-02 | `e5feca7` | `19b0d35` (v0.0.3) | v0.0.3 | Миграция форка на ветку `lx-1.14` (upstream `v1.14.0-alpha.*`). Re-graft на v0.0.3. §010-GRO-guard из графа выброшен (v0.0.3 фиксит на источнике). |
| 2026-07-08 | `4b3a6c9` | `2c27bbf4f97f` (v0.0.5) | v0.0.5 | Re-graft на v0.0.5 вслед за upstream/testing. См. ниже. |
## Re-graft v0.0.3 → v0.0.5 (2026-07-08)
**Триггер:** upstream `sagernet/wireguard-go` двинулся `v0.0.3 → v0.0.5` (коммит-пин `2c27bbf4f97f`) ради **L3-forwarding**. sing-box upstream/testing забампил pin; форк нужно догнать.
**Что upstream изменил (v0.0.3 → v0.0.5), 5 коммитов, 8 файлов:**
- `9de6dc3 Add batched InputPackets` + `2c27bbf FIx batched InputPackets` — новый батч-вход `InputPackets([]*InputPacketRef) []*InputPacketRef` (возвращает unmatched refs — для L3-forward, где нет пира → вызывающий строит ICMP-unreachable). **`InputPacket` (singular) НЕ удалён** — переписан на size-based буфер + backpressure-кап `maxQueuedInputPackets`.
- `8403cdb Rework outbound buffer management` — **`QueueOutboundElement.buffer` сменил тип `*[MaxMessageSize]byte` → `[]byte`** (size-based пул через `GetOutboundBuffer(n)`/`PutOutboundBuffer` из sing-аллокатора, вместо фиксированного `messageBuffers`-пула). Элемент-пулы `outboundElements*` перешли с `WaitPool` на `sync.Pool`. Добавлен `peer.queuedOutboundPackets atomic.Int32` (backpressure-счётчик).
- `57baac9 Add batched UDP I/O on Darwin` + `fcbb7c4 Coalesce UDP GSO segments` — новый `conn/msgx_darwin.go` (sendmsg_x/recvmsg_x), GSO-iovec coalescing в `bind_std.go`.
**Оценка риска для графа ДО работы** (по памяти) была завышена: «`buffer`-type change ломает все AWG-хуки в send.go — основная работа». **По факту оказалось иначе:**
**Итог re-graft (`git apply --3way` граф-diff'а на v0.0.5):**
- **15 из 16** граф-файлов легли **чисто**. Конфликт — **только `send.go`**, и **на одной строке**: upstream добавил `peer.queuedOutboundPackets.Add(-…)` там, где граф добавил пустую строку. Взяли upstream (backpressure нужен).
- **Почему `buffer`-type change НЕ сломал граф:** AWG-хуки уже везде работают с `elem.buffer` как со **срезом** (`buffer[:MessageTransportHeaderSize]`, сдвиг `buffer[i+padding]`), а не как с массивом-указателем. Переход `*[N]byte → []byte` для них прозрачен.
- **Почему upstream `InputPacket`/`InputPackets` встали verbatim:** граф `send.go` их **не трогает** (junk-логика графа — в `SendHandshakeInitiation`, а не в input-пути), поэтому конфликта не было — upstream-версии сохранились.
- **Почему `RoutineEncryption` сшилась без ручного weave:** при `MessageEncapsulatingTransportSize = 0` upstream-offset `buffer[METS:METS+HeaderSize]` схлопывается к графовому `buffer[:HeaderSize]`. Граф-версия (заголовок в начале, без финального encapsulating re-slice) наложилась как есть.
**Вывод:** несущий инвариант `MessageEncapsulatingTransportSize = 0` — то, что делает re-graft дешёвым: он нейтрализует единственную точку, где upstream и граф расходятся по layout буфера.
Сборка после re-graft: device/conn/tun на linux/android/windows/darwin ✅; полный sing-box CLI с LX_TAGS (Go 1.24.7) ✅; тесты `transport/wireguard` + `protocol/wireguard` зелёные ✅.
## MTU / EMSGSIZE — находка 2026-06-10
При лайв-тесте AWG2-узла рукопожатие проходило, но трафик не шёл: `sendmsg: message too long` (**EMSGSIZE**). Причина — `S3`/`S4`: junk дописывается к **каждому** transport-сообщению, и обфусцированный data-пакет перерастает path MTU (1500, DF). Handshake маленький — проходит; transport — нет. Plain WG к тому же серверу с `mtu 1420` работает (S-junk нет).
Эмпирика (тот же узел, менялся только `mtu`, `S3=S4=60`):
| mtu | результат |
|----:|-----------|
| 1420 | ❌ EMSGSIZE |
| 1380 | ✅ ~58 ms |
| 1280 | ✅ ~55 ms |
| 1200 | ✅ ~60 ms |
Результат — MTU-политика в текущем SPEC.md (auto-default 1280 + warn при превышении бюджета). Источник находки — заметка агента лаунчера (`singbox-launcher`). Это не баг ядра, а размерный оверхед S-junk.
## Безопасность
Секреты живого AWG-сервера **никогда** не попадали в репозитории — лайв-конфиг держался только в `/tmp` и затирался (`shred`). Репо-конфиг `lx-test/config/awg2_basic.json` — с фейк-ключами.
@@ -1,69 +0,0 @@
# IMPLEMENTATION_REPORT — 003 AWG2_CLIENT_ENDPOINT
**Дата:** 2026-06-09 · **Статус:** Complete — **функционален и проверен живым AWG2-сервером** · **База:** `v1.13.13`
## Итог
Полноценный клиент **AmneziaWG 2.0**: конфиг валидируется, собирается `with_awg`, и **реально подключается** к серверу AmneziaWG 2.0 с обфускацией (junk + S1–S4 + H1–H4 + CPS I1–I5).
## Архитектура (две части)
**1. sing-box-lx (скаффолдинг + S3/S4):**
- `option/wireguard_awg.go` — `AmneziaWGOptions`: `Jc/Jmin/Jmax`, **`S1/S2/S3/S4`**, `H1–H4` (uint32), `I1–I5` (string, регистр сохраняется), встроены (promoted) в `WireGuardEndpointOptions`.
- `transport/wireguard/device_awg.go` (`//go:build with_awg`) — `awgIpcLines()` шлёт IpcSet-ключи `jc=/jmin=/jmax=/s1..s4=/h1..h4=/i1..i5=` в device; `device_stub_awg.go` без тега даёт явную ошибку при заданных AWG-полях.
- Проброс опций: `option.WireGuardEndpointOptions` → `protocol/wireguard/endpoint.go` → `transport/wireguard/endpoint.go` (всё `// lx`).
**2. amneziawg-go активирован через merged-форк (главное достижение):**
- amneziawg-go основан на *upstream* wireguard-go и не имеет sagernet-добавок (`Send(offset)`, `InputPacket`, `conn` reserved/control), на которых держится `transport/wireguard`. Прямой `replace` ломает сборку.
- Решение **(A)**: `Leadaxe/wireguard-go` = **git 3-way merge** (`merge-base 469159e`) обфускации `amnezia/master` (тип `f4f4c99`, AWG2 + S4-keepalive) поверх `sagernet/wireguard-go@506b7631853c`. Контракт sing-box сохранён (база — sagernet), обфускация добавлена.
- Ключевое упрощение: **`MessageEncapsulatingTransportSize = 0`** — нейтрализует 8-байтный headroom sagernet (sing-box-lx его не использует), и обфускация Amnezia встаёт чисто без weave-конфликтов в send-пути.
- `conn/tun/ipc` оставлены **чисто sagernet** (обфускация только в `device/`: новые `obf*.go`+`magic-header.go` + графты в `send/receive/device/uapi`).
- Подключение: git submodule `submodules/wireguard-go` (Leadaxe/wireguard-go @`27290b6`) + `// lx` `replace github.com/sagernet/wireguard-go => ./submodules/wireguard-go`. Воспроизводимо для CI.
## Проверки
- `make -f Makefile.lx lx-build` → ок; `check awg2_basic.json` → pass; сборка без `with_awg` → обычный WG; gofmt/синтаксис merge чисты.
- **Лайв-тест (реальный сервер AmneziaWG 2.0):**
```
peer - sending handshake initiation
peer - received handshake response ← обфусцированный хендшейк прошёл
peer - receiving keepalive packet ← keepalive 25s
curl --socks5 через туннель → <server IP> ← трафик идёт через сервер
```
Параметры: Jc=10/Jmin=50/Jmax=100, S1=S2=20/S3=S4=60, H1–H4, I1–I3 (I1/I2 — мимикрия под STUN `0x2112a442`, I3 random).
## MTU при ненулевых S3/S4 (EMSGSIZE) — дополнение 2026-06-10
При лайв-тесте AWG2-узла рукопожатие проходило, но трафик не шёл: ядро спамило `failed to send data packets: … sendmsg: message too long` (**EMSGSIZE**). Причина — прямое следствие `s3`/`s4`: junk дописывается к **каждому transport-сообщению**, и обфусцированный data-пакет перерастает path MTU физического интерфейса (1500, DF). Handshake маленький — проходит; transport — нет. Plain WG к тому же серверу с `mtu 1420` работает (S-junk нет).
Бюджет: `mtu ≤ 1500 − 28 (UDP/IP) − 32 (WireGuard) − max(S3, S4)`. Для `S3=S4=60` → `mtu ≤ 1380`; рекомендуемый клиентский MTU AmneziaWG — **1280** (запас на PPPoE/вложенные туннели). Эмпирика (тот же узел/сервер, менялся только `mtu`):
| mtu | результат |
|----:|-----------|
| 1420 | ❌ EMSGSIZE, данные не уходят |
| 1380 | ✅ ~58 ms, 0 ошибок |
| 1280 | ✅ ~55 ms |
| 1200 | ✅ ~60 ms |
Сделано:
- `docs-lx/lx-config.md` §2 — подраздел **MTU** (механика, формула, симптом, рекомендация 1280, держать `jmax` ниже path MTU) + пример понижен `1420 → 1280`.
- `transport/wireguard/endpoint.go` (`// lx`, gated `max(s3,s4) > 0` — plain WG нетронут):
- **auto-default**: при незаданном `mtu` на AWG-эндпоинте ставим рекомендованный **1280** вместо upstream-дефолта `1408` (который сам бы превышал бюджет и триггерил наш же warn).
- **warn**: при явно заданном `mtu` выше бюджета — предупреждение (handshake пройдёт, данные — нет). Path MTU зашит консервативно **1492** (PPPoE): `mtu ≤ 1492 − 28 − 32 − max(s3,s4)` → для `s3=s4=60` это `1372`. Эмпирический потолок выше (1380), т.к. тест шёл по реальному 1500-Ethernet; 1492 — запас под узкие пути.
- Проверено (`check`): AWG `s3=s4=60` без `mtu` → тихо (default 1280); `mtu=1420` → `WARN … consider mtu <= 1372`; plain WG без `mtu` → тихо (1408).
Подтверждение (amneziawg-go docs): рекомендуемый клиентский MTU 1280; если `Jmax` ≥ системного MTU — junk-пакет фрагментируется и теряется на узких путях. Это не баг ядра, а размерный оверхед S-junk. Источник находки — заметка агента лаунчера (`singbox-launcher`, 2026-06-10).
## Безопасность
Секреты сервера **никогда** не попадали в репозитории — лайв-конфиг держался только в `/tmp` и затёрт (`shred`). Репо `lx-test/config/awg2_basic.json` — с фейк-ключами.
## Зона касания upstream (ребейз)
sing-box-lx: `go.mod` (replace), `option/wireguard*`, `protocol/wireguard/endpoint.go`, `transport/wireguard/*` — всё `// lx`. Форк wireguard-go ребейзится отдельно на новый тег sagernet (повтор 3-way merge амнезии).
## Остаточное / дальше
- reserved-feature не применяет reserved-байты в obfuscated send (для plain-AWG не нужно — карта пуста).
- Можно перевести `replace` с submodule на pinned-pseudoversion (submodule достаточно).
- Лаунчер: AWG-поля (S1–S4, I1–I5) в визард + парсер `.conf`/awg-quick; рассматривает кламп MTU для AWG-узлов (первичная истина про оверхед `s3`/`s4` — здесь, см. раздел MTU).
-47
View File
@@ -1,47 +0,0 @@
# PLAN: 003 — AWG2_CLIENT_ENDPOINT
## 1. Архитектура
AmneziaWG = WireGuard-девайс с расширенным конфигом. В sing-box девайс создаётся в `transport/wireguard` поверх `github.com/sagernet/wireguard-go`. Стратегия: **подменить модуль на `amneziawg-go`** (API-совместим с wireguard-go) и **под `with_awg`** прокидывать AWG-поля в строку конфигурации девайса; endpoint остаётся типом `wireguard`.
## 2. Зависимость
- Submodule: `submodules/amneziawg-go` → `amnezia-vpn/amneziawg-go` (pin commit).
- `patches/` — для локальных фиксов поверх amneziawg-go (применяются скриптом сборки; пусто, если не нужны).
- `go.mod` `// lx:` replace `github.com/sagernet/wireguard-go => ./submodules/amneziawg-go`.
> Проверить: совпадает ли публичный API amneziawg-go (пакеты `device`, `conn`, `tun`) с тем, что импортирует `transport/wireguard`. Если расходится — минимальные `patches/` или адаптерный слой в новом файле.
## 3. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `.gitmodules`, `submodules/amneziawg-go` | **new** | Submodule, pinned commit |
| `patches/*.patch` | **new** | (опц.) патчи поверх amneziawg-go |
| `go.mod` / `go.sum` | `// lx:` | `replace` wireguard-go → submodule |
| `option/wireguard_awg.go` | **new** | Под-структура/поля `Jc,Jmin,Jmax,S1,S2,H1..H4,I1..I5` + парс/валидация |
| `option/…wireguard endpoint options` | `// lx:` | встроить AWG-поля в `WireGuardEndpointOptions` (минимум строк) |
| `transport/wireguard/device_awg.go` | **new** (`//go:build with_awg`) | Формирование AWG-строки конфига девайса |
| `transport/wireguard/device_stub_awg.go` | **new** (`//go:build !with_awg`) | Ошибка «awg not built», если AWG-поля заданы |
| `protocol/wireguard/endpoint.go` | `// lx:` | Прокинуть AWG-опции в создание девайса (1 ветка под флагом) |
| `include/awg.go` / правка `include/wireguard.go` | new/`// lx:` | Проводка под тегом (если нужно) |
| `lx-test/config/awg2_*.json` | **new** | Конфиги для `sing-box check` |
## 4. Зона касания upstream (для ребейза)
`go.mod`/`go.sum`, файл опций wireguard-endpoint, `protocol/wireguard/endpoint.go`, `transport/wireguard/*` (минимально). Девайс-логика и опции AWG — в **новых** файлах под тегом → основной конфликт только в `go.mod` и одной ветке endpoint.
## 5. Порядок работ
1. Submodule + `go.mod` replace; собрать обычный WG (без `with_awg`) — поведение upstream.
2. Сверить API amneziawg-go vs `transport/wireguard`; при необходимости `patches/`.
3. Опции AWG (`wireguard_awg.go` + `// lx:` поля).
4. `device_awg.go` (формат `jc=/h1=/i1=…`) под `with_awg`; stub без тега.
5. Прокидка в endpoint; конфиги; `check`; ручной коннект к AWG2-серверу.
## 6. Риски
- **API-дрейф** amneziawg-go относительно версии wireguard-go, на которую завязан upstream (`v0.0.2-beta.1.0.20260224…`). Возможен лаг — фиксировать совместимый коммит сабмодуля, не «latest».
- **Регистр I1–I5** (uppercase) — silent ignore при ошибке; валидировать.
- Взаимодействие junk/CPS с `persistent_keepalive` и MTU — проверять на реальном сервере.
- Доменный `server` + FakeIP: может потребоваться override резолва (референс hoaxisr) — добавлять только при подтверждённой необходимости.
-98
View File
@@ -1,98 +0,0 @@
# SPEC 003 — AmneziaWG 2.0 клиентский endpoint
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) — функционален, проверен живым AWG2-сервером |
| Тег сборки | `with_awg` (обфускация) поверх `with_gvisor` (стек) |
Клиентский **AmneziaWG 2.0** endpoint: обычный WireGuard-endpoint sing-box плюс обфускация DPI (junk-пакеты, магические заголовки, размерный padding, CPS-пакеты `I1–I5`). Без тега `with_awg` — обычный WireGuard upstream; AWG-поля в конфиге дают явную ошибку «не собрано».
---
## Что это
AmneziaWG обходит DPI, маскируя WireGuard-трафик:
- **Junk** (`Jc`/`Jmin`/`Jmax`) — `Jc` случайных пакетов размером `rand(Jmin..Jmax)` перед handshake initiation.
- **Магические заголовки** (`H1–H4`) — подменяют 4-байтный тип сообщения (init/response/cookie/transport); в AWG 2.0 — диапазоны `"N-M"`, из которых значение генерируется на лету.
- **Размерный padding** (`S1/S2` — на handshake, `S3/S4` — на **каждый** transport-пакет).
- **CPS-пакеты** (`I1–I5`) — снимки реального протокола (напр. QUIC Initial, STUN), которые уходят вперемешку с handshake, имитируя посторонний трафик. `I1` — центральный (см. [SPEC 009](../009-WIRESOCK_MASQUERADE_PROFILES/SPEC.md) — декларативные masquerade-профили `ip=quic/sip/dns`, которые генерируют `I1`).
Upstream sing-box AWG не принимает ([#4045](https://github.com/SagerNet/sing-box/issues/4045), closed not-planned) — реализовано в форке.
## Архитектура (два слоя)
Обфускация живёт **в вендоренном wireguard-go** (submodule), а sing-box только пробрасывает параметры. Это ключевое разделение: контракт `transport/wireguard` ↔ device остаётся sagernet'овским, обфускация — аддитивна.
### Слой 1 — вендоренный wireguard-go (`submodules/wireguard-go`)
Форк `Leadaxe/wireguard-go-awg2-lx` = **3-way graft** обфускации AmneziaWG 2.0 поверх `sagernet/wireguard-go`. Подключён как git submodule + `// lx` `replace github.com/sagernet/wireguard-go => ./submodules/wireguard-go` в `go.mod`; pin на конкретный graft-коммит.
**Что граф добавляет** (16 файлов в `device/`):
- **10 net-new**: `magic-header.go` (генератор `H1–H4` из спеки `"N"`/`"N-M"`) + `obf*.go` (CPS-цепочки `I1–I5`, junk-байты, timestamp/datasize кодеки).
- **6 modified**: `device.go` (AWG-state: `junk`, `headers`, `paddings`, `ipackets [5]*obfChain`), `send.go` (junk + CPS + padding в handshake/transport-путях), `receive.go` (детект magic-header на входе), `cookie.go`/`noise-protocol.go`/`uapi.go` (типы сообщений через генератор, парсинг AWG-ключей в IpcSet).
**Ключевой инвариант — `MessageEncapsulatingTransportSize = 0`** ([device/noise-protocol.go](../../submodules/wireguard-go/device/noise-protocol.go)). Upstream держит 8-байтный headroom перед transport-заголовком (для `conn.Bind.Send()`-префикса). Граф его **обнуляет**: AWG-обфускация формирует префикс сама (junk/CPS уходят отдельными буферами через `SendBuffers`, а не через encapsulating-space). При `= 0` upstream-выражения вида `buffer[MessageEncapsulatingTransportSize+MessageTransportHeaderSize:]` схлопываются к графовому виду `buffer[MessageTransportHeaderSize:]` — поэтому большинство upstream-функций компонуются с графом **без ручного weave**. Это несущий инвариант re-graft (§ ниже).
**Что граф НЕ трогает:** `conn/`, `tun/` — чисто sagernet (берутся из upstream verbatim). Обфускация замкнута в `device/`.
### Слой 2 — sing-box (проброс параметров, всё `// lx`)
- **`option/wireguard_awg.go`** — `AmneziaWGOptions`: `Jc/Jmin/Jmax`, `S1–S4`, `H1–H4` (тип `MagicHeader` — строка `"N"` или диапазон `"N-M"`, JSON-совместим с прежним uint32), `I1–I5` (string, регистр сохраняется). Promoted-встроены в `WireGuardEndpointOptions`.
- **`transport/wireguard/device_awg.go`** (`//go:build with_awg`) — `awgIpcLines()` рендерит IpcSet-ключи `jc=/jmin=/jmax=/s1..s4=/h1..h4=/i1..i5=`, дописываемые к WireGuard-конфигу устройства. `device_stub_awg.go` (`//go:build !with_awg`) даёт явную ошибку при заданных AWG-полях.
- **`transport/wireguard/endpoint.go`** — MTU-политика для AWG (см. ниже).
- **`validateJunk`** — отвергает `jmin > jmax` до старта: `amneziawg-go` считает `rand(0..jmax-jmin)+jmin`, и `jmax < jmin` даёт `rand.Int` с аргументом `≤ 0` → **паника ядра**. Гардим только этот crash-кейс.
Регистрация endpoint остаётся `C.TypeWireGuard` (AWG = WG + доп. поля, отдельный тип не вводим).
## MTU-политика (следствие S3/S4)
`S3`/`S4` дописывают junk к **каждому** transport-сообщению → обфусцированный data-пакет перерастает path MTU физического интерфейса (1500, DF) → ядро спамит `sendmsg: message too long` (**EMSGSIZE**), handshake проходит, а трафик — нет.
Бюджет: `mtu ≤ pathMTU − 28 (UDP/IP) − 32 (WireGuard) − max(S3, S4)`.
Логика в [transport/wireguard/endpoint.go](../../transport/wireguard/endpoint.go) (gated `max(s3,s4) > 0`, plain WG нетронут):
- **auto-default**: при незаданном `mtu` на AWG-эндпоинте — рекомендованный **1280** вместо upstream-дефолта 1408.
- **warn**: при явном `mtu` выше бюджета — предупреждение (`pathMTU = 1492`, консервативно под PPPoE). Для `s3=s4=60` → `mtu ≤ 1372`.
Держать `Jmax` ниже системного MTU (иначе junk-пакет фрагментируется и теряется на узких путях). Подробности: `docs-lx/lx-config.md` §2 (MTU).
## Процедура re-graft (при бампе upstream wireguard-go)
Когда upstream `sagernet/wireguard-go` двигает версию, граф переносится на новую базу. **Не merge, а controlled 3-way apply** граф-diff'а:
1. **База**: submodule → новый sagernet-коммит.
2. **Apply graft**: `git diff <старая-база> <старый-graft> | git apply --3way`. По практике 15/16 файлов ложатся чисто; конфликтует обычно только `send.go` (плотный upstream-путь).
3. **Разрешить конфликты вручную**, порядок по риску: `cookie`→`device`→`noise-protocol`→`uapi`→`receive`→**`send.go`** (высший — junk/padding-хуки в hot-path).
4. **Сверить несущие инварианты**: `MessageEncapsulatingTransportSize = 0`; графовый `RoutineEncryption` (заголовок в начале буфера, без финального encapsulating re-slice); AWG-state поля в `device.go`.
5. **Проверки**: сборка `device/conn/tun` на linux/android/windows/**darwin** (darwin особо — там upstream добавляет платформенный batch-send), затем полный `sing-box` с LX_TAGS, `go test ./transport/wireguard/ ./protocol/wireguard/`, **device-verify** живого AWG-туннеля (junk/handshake/трафик).
История конкретных re-graft'ов (какие базы, что менял upstream) — в [HISTORY.md](HISTORY.md).
## Критерии готовности
- `sing-box check -c` принимает wireguard-endpoint c `jc/h1/i1…` под `with_awg`.
- Реальный коннект к AmneziaWG 2.0 (device-verify): `sending handshake initiation` → `received handshake response` → keepalive → трафик через сервер, с непустыми `Jc` и хотя бы одним `I1`.
- Сборка **без** `with_awg`: обычный WG как upstream; AWG-поля → явная ошибка.
- `gofmt -l` чист на граф-файлах; `go vet`, тесты `transport/wireguard` + `protocol/wireguard` — зелёные.
## Изоляция и merge-зона
- **sing-box-lx**: `go.mod` (replace + pin), `option/wireguard_awg.go` + `// lx`-поля в основной struct, `transport/wireguard/device_awg*.go`, MTU-блок в `transport/wireguard/endpoint.go`, проброс в `protocol/wireguard/endpoint.go` — всё `// lx`.
- **submodule wireguard-go**: ребейзится отдельно (см. процедуру re-graft), не входит в merge-зону основного репо кроме pin в `go.mod`.
## Смежные фичи
- [SPEC 009](../009-WIRESOCK_MASQUERADE_PROFILES/SPEC.md) — декларативные masquerade-профили (`ip=quic/sip/dns`), генерирующие `I1`/`I2`.
- [SPEC 020](../020-MULTI_WG_IDLE_BUFFER_HEAT/SPEC.md) — idle-suspend WG/AWG-устройств (Down/Up); опирается на стабильный device-API той же вендоренной базы.
## Вне скоупа
- AWG inbound/server — форк client-focused.
- Парсинг `awg-quick`/`.conf` — забота лаунчера/UI.
- AmneziaWG 1.x как отдельный режим (2.0 обратно совместима по базовым полям).
## Ссылки
- [AmneziaWG 2.0 — Amnezia Docs](https://docs.amnezia.org/documentation/instructions/new-amneziawg-selfhosted/)
- [amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) · [hoaxisr/amnezia-box (референс интеграции)](https://github.com/hoaxisr/amnezia-box)
-28
View File
@@ -1,28 +0,0 @@
# TASKS — 003-AWG2_CLIENT_ENDPOINT
## Зависимость
- [ ] Submodule `submodules/amneziawg-go` (pin коммит, совместимый с wireguard-go upstream)
- [ ] `// lx:` `replace` в `go.mod`; `go mod tidy`; сборка обычного WG без `with_awg` = upstream
- [ ] Сверить API amneziawg-go vs `transport/wireguard`; при расхождении — `patches/`
## Опции
- [ ] `option/wireguard_awg.go`: `Jc,Jmin,Jmax,S1,S2,H1..H4` (int), `I1..I5` (string, регистр)
- [ ] `// lx:` встроить AWG-поля в `WireGuardEndpointOptions`
- [ ] Без `with_awg` + заданы AWG-поля → явная ошибка «awg not built»
## Девайс
- [ ] `transport/wireguard/device_awg.go` (`//go:build with_awg`): строка конфига `jc=/jmin=/jmax=/s1=/s2=/h1..h4=/i1..i5=`
- [ ] `device_stub_awg.go` (`//go:build !with_awg`)
- [ ] `// lx:` прокидка опций в `protocol/wireguard/endpoint.go`
- [ ] Проводка под тегом (`include/awg.go` или правка `include/wireguard.go`)
## Проверки
- [ ] `lx-test/config/awg2_basic.json` + `sing-box check`
- [ ] Ручной коннект к серверу AmneziaWG 2.0 (непустой `Jc`, хотя бы `I1`)
- [ ] Сборка без тега: обычный WG ок; AWG-поля → ошибка
- [ ] `go vet ./...`, тесты затронутых пакетов
## Закрытие
- [ ] DoD-чеклист
- [ ] IMPLEMENTATION_REPORT.md (зафиксировать pin-коммит сабмодуля, формат конфиг-строки)
- [ ] Папка → `C`
@@ -1,53 +0,0 @@
# IMPLEMENTATION_REPORT — 004 BUILD_CI_RELEASE
**Дата:** 2026-06-09 · **Статус:** Complete — конвейер сборки/CI/релиза/ребейза рабочий, **релиз `v1.13.13-lx.3` опубликован** · **База:** `v1.13.13`
## Итог
Воспроизводимый downstream-конвейер: кросс-платформенный бинарь `sing-box` + Android `libbox.aar`, дешёвый per-commit CI, авто-ребейз на upstream-теги и публикация релизов. End-to-end доказано релизом **`v1.13.13-lx.3`** (6 desktop-архивов + 2 AAR + `SHA256SUMS`, весь прогон зелёный).
## Сборка
**Desktop** — `Makefile.lx` (`lx-build`, output `sing-box`); единый источник тегов — `make -f Makefile.lx lx-print-tags`:
`with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg`
= upstream-клиент **− acme/tailscale/ccm/ocm** **+ `with_purego`** (CGO-free кросс-сборка `with_naive_outbound`/cronet при CGO=0) **+ `with_xhttp,with_awg`**. `LX_LDFLAGS` += **`-checklinkname=0`** (badtls `go:linkname` в `crypto/tls`, Go 1.24).
**Android AAR** — `cmd/internal/build_libbox/main.go` (`// lx`-блок): `with_xhttp+with_awg` зашиты в `sharedTags`, `with_tailscale` снят (`// lx:no-tailscale`) → `libbox.aar` (SDK23) + `libbox-legacy.aar` (SDK21) через `make lib_install && make lib_android` (NDK r28 + OpenJDK 17 + gomobile). `Libbox.version()` → `-lx.N`.
## CI — `lx-ci.yml` (политика «дёшево на коммит»)
- Триггеры: `push`/`pull_request` (`paths-ignore`: `**.md`/`docs/**`/`SPECS/**`/`LICENSE`) + `workflow_dispatch`; `concurrency: cancel-in-progress`.
- **push/PR → только дешёвое:** `lint` (go vet lx-пакетов + `gofmt` lx-файлов `v2rayxhttp|_xhttp|_awg`) + `build-check` (1 нативный build `full` + `sing-box check` XHTTP/AWG2 + tagless baseline → negative-check, что фичеконфиги без тегов отвергаются).
- **`workflow_dispatch` → тяжёлое (вручную):** `cross` `{linux,darwin,windows}×{amd64,arm64}` (полный `LX_TAGS`, CGO=0 — проверка `with_purego` кросс-сборки) + `android` AAR. На push **не** запускаются.
- doc-only коммиты CI не триггерят.
## Релизы — `lx-release.yml`
- on tag `v*-lx.*` → `build` (6 desktop, tar.gz/zip) + `build_android` (2 AAR) → `release`: `SHA256SUMS` + GitHub Release с notes (база `v1.13.13` + фичи + `lx-print-tags` + строка про AAR). Версия из тега, `sing-box version` → `-lx.N`.
- **`v1.13.13-lx.3` опубликован** (Latest): 6 архивов + `libbox-1.13.13-lx.3.aar` + `libbox-legacy-1.13.13-lx.3.aar` + `SHA256SUMS` — всё зелёное. Этот прогон впервые вживую подтвердил тяжёлый путь (cross ×6 с naive/cronet/purego + gomobile AAR + publish).
- **Windows 7 (32-bit)** legacy-таргет: `windows/386` собирается **пропатченным Go** (`.github/setup_go_for_windows7.sh` — реверты удаления Win7 из `MetaCubeX/go`, как в upstream `build.yml`) и **без `with_naive_outbound`** (`cronet-go` не имеет windows/386 — build constraints исключают всё). Артефакт `sing-box-<ver>-windows-386-legacy-windows-7.zip` — под лаунчер-сборку `singbox-launcher-win7-32` (она тоже 386). Остальной `LX_TAGS` (gvisor/quic/xhttp/awg/…) под 386 компилируется — проверено.
## Авто-ребейз — `lx-rebase.yml`
- `schedule` (Пн 06:00 UTC) + `workflow_dispatch` (опц. `tag`).
- fetch upstream → новейший **стабильный** тег (`^v[0-9]+\.[0-9]+\.[0-9]+$` — отсекает `-alpha/-beta/-rc` и наши `-lx.N`).
- **up-to-date** → no-op; **чистый ребейз + build + `check`** → ветка `lx-rebase/<tag>` + **PR**; **конфликт / build-fail** → **issue** с конфликтными файлами и рецептом ручного ребейза.
- **Никогда не force-push'ит `lx`** — только новая ветка + PR/issue на ревью.
- Демо (`workflow_dispatch tag=v1.13.13`): `Pick target` → `Up to date?` → success, остальное skipped, **0 side-effects** (ни веток, ни PR, ни issue).
## Операционные настройки репозитория (критично для CI)
`gh api repos/OWNER/REPO/actions/permissions/workflow`:
- **`default_workflow_permissions: write`** — иначе `gh release create` падает с `403 Resource not accessible by integration` (это и был корень падений первых релизных прогонов lx.2). NB: «релиз для тега уже существует» — **другая** ошибка (`already exists`), не 403.
- **`can_approve_pull_request_reviews: true`** («Allow GitHub Actions to create and approve pull requests») — иначе авто-PR ребейза ботом блокируется (есть fallback в issue).
- Оба включены 2026-06-09.
## Зона касания upstream (ребейз)
Все lx-артефакты — **новые файлы**: `.github/workflows/lx-{ci,release,rebase}.yml`, `Makefile.lx`, `lx-test/config/`. Единственная правка upstream-файла — `// lx`-блок в `cmd/internal/build_libbox/main.go` (теги AAR). При ребейзе новые файлы переносятся как есть, блок в `build_libbox` — вручную по маркеру.
## Остаточное / дальше
- Старый релиз `v1.13.13-lx.1` можно удалить (предшествует XHTTP-фиксу / полным тегам / libbox; `lx.3` его замещает).
- Лаунчер (репо `singbox-launcher`, отдельно): маппинг `type=xhttp` → реальный xhttp (его задача 023 сейчас в httpupgrade); AWG-поля (Jc/S1–S4/H1–H4/I1–I5) в визард + парсер `awg.conf`; замена бандлового `bin/sing-box` на lx-релиз.
- (опц.) XHTTP `stream-one` framing-баг (`auto`/`packet-up` работают, не блокер).
-48
View File
@@ -1,48 +0,0 @@
# PLAN: 004 — BUILD_CI_RELEASE
## 1. Файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `Makefile.lx` | дополнение (из 001) | `lx-build` + новый `lx-print-tags`; `LX_TAGS` = клиентский feature-set (upstream минус `tailscale`/`ccm`/`ocm`/`acme`) + `with_purego` + наши 2; `LX_LDFLAGS` += `-checklinkname=0` |
| `cmd/internal/build_libbox/main.go` | upstream-правка (lx:-маркер, §3.3) | `with_xhttp,with_awg` в `sharedTags` → попадают в `libbox.aar` (SDK23) и `libbox-legacy.aar` (SDK21) |
| `.github/workflows/lx-ci.yml` | расширение (из 001) | full-tag матрица OS×ARCH (CGO=0) + feature-toggle + `go vet` + job **`android`** (`make lib_android`) |
| `.github/workflows/lx-rebase.yml` | **new** | schedule/dispatch: fetch upstream tags → rebase → build → PR/issue |
| `.github/workflows/lx-release.yml` | расширение | on tag `v*-lx.*`: cross-build desktop (через `Makefile.lx`, без дублирования тегов) + job **`build_android`** (AAR) → zip/checksums/GitHub Release |
| `lx-test/config/xhttp_reality.json`, `awg2_basic.json` | из 002/003 | Используются в CI `check` |
## 2. Версия / ldflags
`LX_LDFLAGS = -X github.com/sagernet/sing-box/constant.Version=<upstream>-lx.<N> -checklinkname=0 -s -w -buildid=`. `<N>` — счётчик lx-релизов поверх upstream-тега. **`-checklinkname=0` обязателен** для полного набора тегов (`badlinkname` → `go:linkname` в `crypto/tls` через `common/badtls`; Go 1.24 блокирует без флага). AAR версионируется отдельно — `build_libbox` берёт `git describe`, поэтому в обоих workflow перед сборкой AAR создаётся/обновляется тег.
**Наборы тегов (два разных, по типу сборки):**
- Desktop (cross, `CGO_ENABLED=0`): `release/DEFAULT_BUILD_TAGS` **минус `tailscale`/`ccm`/`ocm`/`acme`** + `with_purego` + `with_xhttp,with_awg` → `Makefile.lx` `LX_TAGS`.
- AAR (gomobile, NDK/CGO): upstream `build_libbox` mobile-set **минус `with_tailscale`** (`// lx:no-tailscale`, как desktop) + `with_xhttp,with_awg`. `with_purego`/`with_acme` не добавляем — это desktop/server-теги.
## 3. Авто-ребейз (lx-rebase.yml) — логика
```
fetch upstream --tags
latest = max(stable tags)
if base(lx) == latest: exit "up to date"
git checkout -b lx-rebase/$latest lx
git rebase $latest # конфликты → собрать дифф // lx: зон, открыть issue, stop
make lx-build && go vet && sing-box check ... # упало → issue
push origin lx-rebase/$latest ; gh pr create
```
## 4. Порядок работ
1. `Makefile.lx`: полный `LX_TAGS` + `-checklinkname=0` + `lx-print-tags`; `build_libbox` — теги фич в AAR.
2. `lx-ci.yml`: full-tag матрица OS×ARCH + feature-toggle + job `android`.
3. `lx-release.yml`: desktop cross-build (через `Makefile.lx`) + job `build_android` → публикация desktop-архивов + AAR.
4. `lx-rebase.yml` (сначала `workflow_dispatch`, потом cron).
5. Демо-прогон ребейз-workflow на текущем теге.
## 5. Риски
- **`-checklinkname=0`** (РЕШЕНО): без него полный набор не линкуется (`badtls`/`crypto/tls`). Локально подтверждено для linux/amd64 и windows/arm64; остальные 4 таргета верифицирует CI-матрица.
- **`with_naive_outbound` через cronet** тянет prebuilt `cronet-go/lib/<os>_<arch>` — если под какой-то таргет prebuilt отсутствует, naive там не соберётся → дропнуть naive на этой платформе (или из набора целиком). Проверяет CI-матрица.
- **AAR-сборка**: требует NDK r28 + OpenJDK 17 + gomobile (`make lib_install`); `build_libbox.checkJavaVersion()` ждёт строго `openjdk 17`. Версия AAR = `git describe`, поэтому тег должен существовать в чекауте.
- Авто-ребейз на **alpha/beta** теги нежелателен — фильтровать только стабильные (`vX.Y.Z` без суффиксов).
- `git submodule` в CI — не забыть `--init --recursive` и pin (нужно и для `with_awg`, и для AAR).
-65
View File
@@ -1,65 +0,0 @@
# SPEC: 004 — BUILD_CI_RELEASE
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Собрать воспроизводимый конвейер сборки/CI/релизов `sing-box-lx`: кросс-платформенные бинари `sing-box` **и Android `libbox.aar`** с клиентским feature-set (полный upstream минус серверные/AI-теги) + lx-фичами (`with_xhttp`/`with_awg`), версия `-lx.N`, и **авто-ребейз на новый upstream-тег**.
> **Процедура выпуска → [docs-lx/lx-release-runbook.md](../../docs-lx/lx-release-runbook.md).**
> Главное правило: **перед любым тегом проверить дрейф upstream и обычно смержить его себе, и только
> потом резать релиз/пререлиз.** На ветке `lx-1.14` авто-ребейз на стабильный тег (ниже) заменён
> ручным `git merge upstream/testing` — пока upstream на `v1.14.*-alpha`, стабильного тега нет, а
> rc-линия `vX-lx.1-rc.N` сама является форматом поставки.
---
## 1. Проблема / контекст
Реальная стоимость downstream'а — не первичная разработка, а N ребейзов в год и регулярные сборки на 3 платформы. Нужен конвейер, который ловит «фичи поломались об новый upstream» раньше пользователя и выпускает drop-in бинарь для лаунчера.
## 2. Требования
### 2.1 Сборка
- **Desktop-бинарь `sing-box`** (drop-in для лаунчера): цель `make -f Makefile.lx lx-build`, output `sing-box`.
- **Набор `LX_TAGS`** — upstream feature-set (`release/DEFAULT_BUILD_TAGS`) **минус нерелевантные клиенту**: `with_tailscale` (нет tailscale-endpoint'ов), `with_ccm`/`with_ocm` (прокси Claude Code / OpenAI Codex — серверные AI-сервисы), `with_acme` (серверный выпуск TLS-сертов). Итог = `gvisor/quic/dhcp/wireguard/utls/clash_api/naive_outbound + badlinkname/tfogo_checklinkname0` **+ `with_purego`** (CGO-free кросс-сборка `with_naive_outbound` через prebuilt cronet) **+ `with_xhttp,with_awg`**. `Makefile.lx` — единственный источник истины (`make -f Makefile.lx lx-print-tags`).
- **`LX_LDFLAGS` обязан содержать `-checklinkname=0`** — иначе `badlinkname`/`tfogo_checklinkname0` ломают линк (`common/badtls` использует `go:linkname` в `crypto/tls`, который Go 1.24 блокирует). Зеркалит upstream `build_libbox`.
- **Android `libbox.aar`**: `make lib_install && make lib_android` (gomobile, NDK r28 + OpenJDK 17). `with_xhttp`/`with_awg` зашиты в `cmd/internal/build_libbox` (lx:-блок) → попадают в `libbox.aar` (SDK 23) и `libbox-legacy.aar` (SDK 21). Набор тегов AAR = upstream mobile-set **минус `with_tailscale`** (как desktop — самая тяжёлая либа в APK; правка обёрнута `// lx:no-tailscale`) + наши две фичи (NDK/CGO-сборка, `with_purego` не нужен).
- Версия `vX.Y.Z-lx.N` через ldflags (из 001); для AAR — через `git describe` внутри `build_libbox`.
### 2.2 CI-матрица
- **Политика триггеров (стоимость per-commit ↓).** Doc-only коммиты (`**.md`/`docs/**`/`SPECS/**`/LICENSE) **не запускают CI** (`paths-ignore`). На каждый push/PR — **только дешёвые** job'ы `lint` + `build-check`. Тяжёлые `cross` (6 таргетов) и `android` (gomobile AAR) — **только вручную, на `workflow_dispatch`** (`gh workflow run lx-ci.yml --ref lx` или кнопка Actions → Run workflow); на push их нет. Полную кросс-сборку + обе AAR на каждый релиз-тег и так гарантирует `lx-release.yml`. Серия быстрых пушей отменяет устаревшие прогоны (`concurrency: cancel-in-progress`).
- **`lint`** (push/PR): `go vet` по lx-пакетам с полными тегами + `gofmt` только по lx-файлам (`v2rayxhttp|_xhttp|_awg`, не по всему дереву upstream).
- **`build-check`** (push/PR): один нативный build `with_xhttp,with_awg` + `sing-box check` XHTTP/AWG2-конфигов (должны пройти); затем tagless baseline-бинарь → `check minimal.json` (проходит) + negative-check (XHTTP/AWG2-конфиги без тегов отвергаются).
- **`cross`** (dispatch): `{linux, darwin, windows} × {amd64, arm64}`, full `LX_TAGS`, CGO=0 — проверка, что полный набор + `with_purego` кросс-собирается везде.
- **`android`** (dispatch): `make lib_android` (NDK r28 + JDK17 + gomobile) — libbox AAR собирается с lx-фичами.
- Все job'ы — с submodule (`submodules: recursive`) и `fetch-depth: 0` (для `-lx` версии через `git describe`).
### 2.3 Авто-ребейз на upstream-тег
- Workflow по расписанию/`workflow_dispatch`:
1. `git fetch upstream --tags`, определить новейший стабильный тег `> текущей базы`.
2. Попытка `git rebase <tag>` ветки `lx` в CI.
3. Успех + сборка/`check` зелёные → пуш ветки `lx-rebase/<tag>` и **PR**; конфликт → **issue** с диффом `// lx:` зон.
- Никогда не пушить силой в `lx` автоматически — только через PR с ревью.
### 2.4 Релизы
- Тег `vX.Y.Z-lx.N` → артефакты: desktop-архивы (`sing-box` × 6 платформ) **+ `libbox-<ver>.aar` и `libbox-legacy-<ver>.aar`**, общий `SHA256SUMS`.
- Release notes: upstream-база + состояние фич (`with_xhttp`/`with_awg`) + полный `LX_TAGS` desktop-бинаря (через `lx-print-tags`) + строка про AAR.
## 3. Критерии приёмки
- CI зелёный: на push/PR — дешёвые `lint` + `build-check`; полная матрица (`cross` ×6 с полным `LX_TAGS` + `android` AAR) — вручную на `workflow_dispatch`. Doc-only коммиты CI не триггерят; релиз-тег собирает всё через `lx-release.yml`.
- Артефакты собираются, бинарь называется `sing-box`, `version` → `-lx.N`.
- **libbox AAR собирается в CI (job `android`) и публикуется в Release**; `Libbox.version()` → `-lx.N`; конфиг с AWG2/XHTTP не падает с «support not built».
- Авто-ребейз workflow отрабатывает на `workflow_dispatch` (демо на текущем теге → «уже актуально» или PR).
## 4. Вне скоупа
- Подпись кода/нотаризация; публикация AAR в Maven/jitpack (отдаём только GitHub Release asset).
- Интеграция AAR в приложение-потребитель (LxBox) — задача на стороне приложения.
- Полностью автоматический мёрж ребейза (всегда ревью).
## 5. Ссылки
- [Build from source — sing-box](https://sing-box.sagernet.org/installation/build-from-source/)
-36
View File
@@ -1,36 +0,0 @@
# TASKS — 004-BUILD_CI_RELEASE
## Сборка (desktop)
- [x] `Makefile.lx`: `lx-build` (output `sing-box`), `LX_TAGS`, `LX_LDFLAGS`, версия `-lx.N`
- [x] `LX_TAGS` → клиентский feature-set (`DEFAULT_BUILD_TAGS` минус tailscale/ccm/ocm/acme) + `with_purego` + `with_xhttp,with_awg`
- [x] `LX_LDFLAGS` += `-checklinkname=0` (иначе линк падает на `badtls`/`crypto/tls`)
- [x] `lx-print-tags` — единый источник истины для CI/release (без дублирования строки тегов)
## Сборка (Android libbox AAR)
- [x] `cmd/internal/build_libbox/main.go`: `with_xhttp,with_awg` в `sharedTags` + дроп `with_tailscale` (lx:-маркеры) → обе AAR-варианта
- [x] CI собирает `libbox.aar` + `libbox-legacy.aar` зелёным (NDK r28 + OpenJDK 17 + gomobile) — подтверждено в релизе v1.13.13-lx.3 (job `build android` = success)
- [x] `Libbox.version()` отдаёт `-lx.N`; AAR (libbox-1.13.13-lx.3.aar + legacy) опубликованы в Release
## CI
- [x] `lx-ci.yml` триггеры: push/PR (paths-ignore docs) + `workflow_dispatch`; `concurrency: cancel-in-progress`
- [x] **push/PR — дёшево:** `lint` (go vet lx-пакетов + gofmt lx-файлов) + `build-check` (1 нативный build `full` + `check`, baseline-бинарь + negative-check)
- [x] **`workflow_dispatch` — тяжёлое (вручную):** `cross` `{linux,darwin,windows}×{amd64,arm64}` на полном `LX_TAGS` + `android` (`make lib_android`); на push не запускаются
- [x] submodule init (`submodules: recursive`) + `fetch-depth: 0` во всех job'ах
- [x] Зелёный прогон `lint`+`build-check` на push (подтверждено); `cross` ×6 + `android` AAR — зелёные в релизном прогоне v1.13.13-lx.3 (cronet/naive/purego на всех 6)
## Авто-ребейз
- [x] `lx-rebase.yml`: fetch upstream tags → выбрать новейший **стабильный** (`^v[0-9]+\.[0-9]+\.[0-9]+$`) → rebase в CI
- [x] Успех+билд → ветка `lx-rebase/<tag>` + PR; конфликт/билд-фейл → issue с диффом `// lx:` зон; up-to-date → no-op
- [x] Запрет авто-force-push в `lx` (пушим только в `lx-rebase/<tag>`); PR/issue; `schedule` (еженедельно) + `workflow_dispatch`
- [x] Демо-прогон `workflow_dispatch` (tag=v1.13.13 → «up to date», 0 side-effects). PR-путь требует чекбокс «Allow Actions to create PRs» (иначе fallback в issue)
## Релизы
- [x] `lx-release.yml`: on tag `v*-lx.*` → cross-build desktop, zip, checksums, GitHub Release
- [x] job `build_android` → `libbox-<ver>.aar` + `libbox-legacy-<ver>.aar` в Release
- [x] Release notes: upstream-база + `with_xhttp`/`with_awg` + `LX_TAGS` (через `lx-print-tags`) + AAR
- [x] Проверить релиз end-to-end — **v1.13.13-lx.3 опубликован** (6 desktop + 2 AAR + SHA256SUMS, всё зелёное). Корень 403: Workflow permissions = Read/write
## Закрытие
- [x] DoD: зелёная CI-матрица (desktop ×6 + AAR) — релиз v1.13.13-lx.3
- [x] IMPLEMENTATION_REPORT.md
- [x] Статус → `C` (шапка SPEC.md + Roadmap)
@@ -1,71 +0,0 @@
# IMPLEMENTATION_REPORT — 005 AWG2_RANGED_MAGIC_HEADERS
**Дата:** 2026-06-11 · **Статус:** Complete — **функционален, проверен живым awg2-сервером с ranged-конфигом** · **База:** `v1.13.13`
## Итог
`H1`–`H4` в конфиге wireguard-endpoint теперь принимают **диапазон** AWG 2.0 (`"43613244-384550127"`) наряду с одиночным числом (`1234567890`, обратная совместимость). Spec-строка доезжает до IpcSet, vendored `submodules/wireguard-go` (который уже умел диапазоны) поднимает обфусцированный handshake. Реальный awg2-экспорт (`seliv_for_awg2.conf`) импортируется без правок.
Изменения — **только в lx-собственных файлах** (`option/wireguard_awg.go`, `transport/wireguard/device_awg.go`, оба созданы в 003). Ноль новых касаний upstream, vendored wireguard-go не тронут.
## Архитектура
**Тип `option.MagicHeader` (string-based, comparable):**
- `UnmarshalJSON` принимает JSON number (back-compat с прежним `uint32`) и JSON string `"N"`/`"N-M"`. Канонизация: пробелы обрезаются, `N-N` → `N`, `0`/`"0"`/`""` → `""` (unset — прежняя zero-value семантика `omitempty`).
- `MarshalJSON` сохраняет type-fidelity: одиночное значение → JSON number, диапазон → JSON string.
- База `string` ⇒ `AmneziaWGOptions` остаётся comparable, `IsSet()` (`o != AmneziaWGOptions{}`) работает.
- `Spec()` повторно валидирует и отдаёт канон для IpcSet — ловит опции, собранные в коде (libbox/лаунчер) мимо JSON.
- Валидация (uint32, start ≤ end) и в `UnmarshalJSON`, и в `Spec()`; имя поля в ошибке даёт contextjson (`h1: invalid magic header …`) на парсе и `E.Cause(err, "h1")` в `awgIpcLines` на сборке device.
**Эмит (`transport/wireguard/device_awg.go`):**
- `h1..h4` через `writeStr`-путь (spec-строка), unset → не эмитится.
- Plain WG (без AWG-полей) по-прежнему даёт `""` → byte-identical конфиг.
## Изменённые / новые файлы
| Файл | Тип | Изменение |
|------|-----|-----------|
| `option/wireguard_awg.go` | lx-own | тип `MagicHeader` + Unmarshal/Marshal/Spec/normalize; `H1..H4 uint32` → `MagicHeader` |
| `option/wireguard_awg_test.go` | **new** | unmarshal number/string/range/ошибки; marshal round-trip; IsSet; field-name; Spec |
| `transport/wireguard/device_awg.go` | lx-own | `writeUint("h1"…)` → `writeMagic` (spec + валидация с ключом); unset не эмитится |
| `transport/wireguard/device_awg_test.go` | **new** (`with_awg`) | ipc: диапазон, одиночные, plain WG `""`, unset-omit, невалидный header |
| `lx-test/config/awg2_ranged.json` | **new** | check-фикстура с ranged H1–H4 (фейк-ключи) |
| `.github/workflows/lx-ci.yml` | wiring | `awg2_ranged.json` в позитивную + негативную проверку |
| `docs-lx/lx-config.md` | docs | `h1`–`h4`: `int \| "min-max"`, no-overlap, пример, маппинг awg.conf |
| `SPECS/README.md` | docs | строка 005 в roadmap |
## DoD
- ✅ `go build ./...` без тегов — ок (поведение upstream).
- ✅ `make -f Makefile.lx lx-build` (full tags) — собирается; `sing-box version` → `1.13.13-lx.N`.
- ✅ `go vet` (с тегами и без) для `option`, `transport/wireguard` — чисто.
- ✅ `go test ./option/ ./transport/wireguard/` (и `-tags with_awg`) — зелёные.
- ✅ `sing-box check` — `awg2_basic.json` (одиночные H), `awg2_ranged.json` (диапазоны), `minimal.json` — приняты.
- ✅ baseline (без `with_awg`) — `awg2_ranged.json` отклонён явной ошибкой «awg support not built».
- ✅ Правки только в lx-own файлах; новые `// lx:`-зоны не добавлялись.
## Приёмка (живой сервер)
Лайв-тест против awg2-сервера из `seliv_for_awg2.conf` (ranged H1–H4):
```
peer - sending handshake initiation
peer - received handshake response ← uapi принял ranged-строки, обфусцированный handshake прошёл
```
- `curl --socks5-hostname 127.0.0.1:21080 https://api.ipify.org` → `{"ip":"64.188.69.128"}` (выходной IP = endpoint сервера): трафик идёт сквозь туннель.
- 0 ошибок `message too long` / EMSGSIZE (MTU не задан → дефолт `1280` под s3/s4 из 003 / `f806e24f`).
- IpcError при выставлении h1..h4-строк — **нет**.
Секреты в репозиторий не попадали: лайв-конфиг и лог держались в `/tmp`, затёрты после теста. `lx-test/config/awg2_ranged.json` — с фейк-ключами.
## Зона касания при следующем ребейзе
Без изменений относительно 003: `option/wireguard_awg.go` и `transport/wireguard/device_awg.go` — **lx-собственные** файлы, в upstream их нет, конфликтов на ребейзе не дают. Vendored `submodules/wireguard-go` не тронут.
## Вне скоупа
- Перепин `app/android/libbox.version` в лаунчере — задача на стороне LxBox.
- AWG inbound/server — отдельная будущая задача (как в 003).
## Релиз
Тег `v1.13.13-lx.6` → `lx-release.yml` (6 desktop + 2 AAR + Win7-386).
@@ -1,43 +0,0 @@
# PLAN: 005 — AWG2_RANGED_MAGIC_HEADERS
## 1. Архитектура
Диапазон уже понимает нижний слой (`submodules/wireguard-go`: `device/magic-header.go` + `device/uapi.go` case `"h1".."h4"` → `newMagicHeader("N"/"N-M")`). Задача — донести spec-строку от JSON-конфига до IpcSet. Меняются **только lx-собственные файлы** (`option/wireguard_awg.go`, `transport/wireguard/device_awg.go`) — ноль новых касаний upstream, ребейз-стоимость не растёт.
Тип `option.MagicHeader` — `string` с канонизацией при парсе:
- `UnmarshalJSON`: JSON number → канон `"N"`; JSON string → парс `"N"`/`"N-M"` (uint32, start ≤ end), канонизация (`N-N` → `N`, обрезка пробелов); number `0` / строка `""`/`"0"` → unset (`""`) — сохраняет прежнюю omitempty-семантику нуля.
- `MarshalJSON`: одиночное значение → JSON number (type-fidelity со старым `uint32`), диапазон → JSON string.
- База string ⇒ тип comparable, `IsSet()` (`o != AmneziaWGOptions{}`) работает; zero value `""` = unset.
- Повторная валидация в `awgIpcLines` с именем ключа в ошибке (`E.Cause(err, "h1")`) — покрывает и программно собранные опции (libbox/лаунчер мимо JSON).
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `option/wireguard_awg.go` | lx-own | `H1..H4 uint32` → `MagicHeader`; тип + Unmarshal/Marshal/Validate |
| `option/wireguard_awg_test.go` | **new** | unmarshal number / string / диапазон / ошибки; marshal round-trip; IsSet |
| `transport/wireguard/device_awg.go` | lx-own | `writeUint("h1"…)` → write magic-spec с валидацией; unset → не эмитить |
| `transport/wireguard/device_awg_test.go` | **new** (`//go:build with_awg`) | ipc-строки: одиночные, диапазон, plain WG = `""` |
| `lx-test/config/awg2_ranged.json` | **new** | check-фикстура с ranged H1–H4 (фейк-ключи) |
| `docs-lx/lx-config.md` | docs | `h1`–`h4`: `int \| "min-max"`, пример, маппинг awg.conf |
| `SPECS/README.md` | docs | строка 005 в roadmap |
## 3. Зона касания upstream (для ребейза)
**Ничего нового.** Оба изменяемых Go-файла — lx-собственные (созданы в 003), upstream-файлы не трогаются, `// lx:`-зоны не расширяются. Vendored `submodules/wireguard-go` не меняется.
## 4. Порядок работ
1. `option`: тип `MagicHeader` + замена полей + тесты.
2. `transport/wireguard`: эмит spec-строки + тесты (под `with_awg`).
3. Фикстура `awg2_ranged.json`; `go build` (с тегами и без), `go vet`, `go test`, `sing-box check` обеих AWG-фикстур.
4. Docs + roadmap.
5. Лайв/handshake-приёмка (конфиг с реальными ключами — только во временных файлах, как в 003).
6. REPORT, статус C, тег `v1.13.13-lx.6`.
## 5. Риски
- **Совместимость marshal**: код, который сериализует опции обратно в JSON (`sing-box format`, экспорт из лаунчера), должен получить number для одиночных значений — закрыто MarshalJSON-логикой + тестом round-trip.
- **`"h1": 0` / `"0"`**: прежняя семантика «0 = не задано» (omitempty по zero value uint32) сохраняется канонизацией в `""`.
- **Ошибка без имени поля** при парсе JSON — закрыто дублирующей валидацией в `awgIpcLines` с ключом и тестом текста ошибки.
@@ -1,60 +0,0 @@
# SPEC: 005 — AWG2_RANGED_MAGIC_HEADERS
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Поддержать **диапазонные magic headers** AmneziaWG 2.0 (`H1`–`H4` вида `N-M`) в конфиге sing-box-lx. Только прослойка option → IpcSet; протокольный слой (vendored `Leadaxe/wireguard-go`) уже умеет диапазоны.
---
## 1. Проблема / контекст
- Лаунчер (LxBox) начал импортировать реальные awg2-экспорты. Живые конфиги содержат H-поля в новом формате AWG 2.0 — **диапазон** вместо числа:
```ini
H1 = 43613244-384550127
H2 = 826869626-2105069164
```
- Vendored merged-форк `submodules/wireguard-go` **уже умеет** диапазоны: `device/magic-header.go` — `newMagicHeader(spec)` принимает `"N"` и `"N-M"` (uint32, start ≤ end); `device/uapi.go` case `"h1".."h4"` парсит value через `newMagicHeader`.
- Но прослойка ядра не пропускает диапазон:
- `option/wireguard_awg.go`: `H1..H4 uint32` — диапазон не выразить, JSON-строка `"h1": "N-M"` не анмаршалится;
- `transport/wireguard/device_awg.go`: `writeUint("h1", o.H1)` — в IpcSet уходит только одиночное число.
## 2. Цель
Конфиг wireguard-endpoint с `"h1": "43613244-384550127"` (и одиночными `"h1": 1234567890` как раньше) парсится, проходит `sing-box check`, и диапазонная spec-строка доезжает до uapi девайса без IpcError.
## 3. Требования
### 3.1 Опции (`option/wireguard_awg.go`)
- `H1..H4` → тип «число-или-диапазон» (`MagicHeader` на базе string):
- `UnmarshalJSON`: принимает **JSON number** (обратная совместимость — существующие конфиги с `"h1": 1234567890` читаются без изменений) и **JSON string** `"N"` / `"N-M"`;
- валидация: обе части — uint32, start ≤ end; мусор → явная ошибка с именем поля;
- `MarshalJSON`: одиночное значение → number (type-fidelity как раньше), диапазон → string;
- тип comparable — `IsSet()` (`o != AmneziaWGOptions{}`) продолжает работать.
### 3.2 Девайс (`transport/wireguard/device_awg.go`)
- `h1..h4` эмитятся как spec-строка (`writeStr`-путь), unset → не эмитить.
- Гарантия «plain WG даёт byte-identical конфиг» сохраняется.
### 3.3 Документация (`docs-lx/lx-config.md`)
- `h1`–`h4`: `int | "min-max"` + пример с диапазоном; обновить таблицу и маппинг awg.conf.
## 4. Критерии приёмки
- Тесты: unmarshal number / string-число / диапазон / ошибки (start > end, > uint32, мусор); ipc-строки с диапазоном (`\nh1=43613244-384550127`); существующие AWG-фикстуры не ломаются.
- `sing-box check` принимает конфиг с ranged H1–H4.
- Лайв-тест против awg2-сервера с ranged-конфигом (инфраструктура lx-test из 003), либо хотя бы handshake-проверка, что uapi принимает выставленные строки без IpcError.
- Сборка без `with_awg`: поведение upstream, AWG-поля → явная ошибка (как раньше).
- `go vet`, тесты затронутых пакетов — зелёные.
## 5. Вне скоупа
- Vendored `submodules/wireguard-go` — **не трогать**, он уже умеет диапазоны.
- Перепин `app/android/libbox.version` в лаунчере — задача на стороне LxBox.
- Диапазоны для S1–S4/Jc — в формате AWG 2.0 их нет (только H1–H4).
## 6. Релиз
Тег `v1.13.13-lx.6`, AAR через `lx-release.yml`.
@@ -1,24 +0,0 @@
# TASKS — 005-AWG2_RANGED_MAGIC_HEADERS
## Опции
- [x] `option/wireguard_awg.go`: тип `MagicHeader` (string-based, comparable) — UnmarshalJSON (number + `"N"`/`"N-M"`), MarshalJSON (number ↔ string), валидация uint32 / start ≤ end
- [x] `H1..H4` → `MagicHeader`; `IsSet()` работает как раньше
- [x] `option/wireguard_awg_test.go`: number / string-число / диапазон / ошибки (start > end, > uint32, мусор, отрицательное); marshal round-trip; `0` → unset
## Девайс
- [x] `transport/wireguard/device_awg.go`: `h1..h4` → spec-строка через writeStr-путь; валидация с именем ключа; unset → не эмитить
- [x] `device_awg_test.go` (`with_awg`): `\nh1=43613244-384550127`; одиночные значения как раньше; plain WG → `""`
## Проверки
- [x] `lx-test/config/awg2_ranged.json` (фейк-ключи) + `sing-box check` обеих AWG-фикстур
- [x] Сборка с тегами и без; `go vet`; `go test` затронутых пакетов
- [x] Существующие AWG-фикстуры (`awg2_basic.json`) не ломаются
## Приёмка
- [x] Лайв/handshake-тест против awg2-сервера с ranged-конфигом (uapi принял строки без IpcError, handshake + трафик прошли); секреты — только в temp-файлах, затёрты
## Документация и закрытие
- [x] `docs-lx/lx-config.md`: `h1`–`h4` `int | "min-max"`, no-overlap, пример с диапазоном, маппинг awg.conf
- [x] `SPECS/README.md`: строка 005 в roadmap
- [x] IMPLEMENTATION_REPORT.md, DoD-чеклист, статус → `C` (шапка SPEC.md + Roadmap)
- [x] Тег `v1.13.13-lx.6` → `lx-release.yml` (desktop + AAR)
@@ -1,56 +0,0 @@
# IMPLEMENTATION_REPORT — 006 LINUX_MUSL_STATIC_ROUTER_BUILDS
**Дата:** 2026-06-12 · **Статус:** Complete — **приёмка CI пройдена (4/4 арки статикой)** · **База:** `v1.13.13`
## Итог
Релизные Linux-бинари переведены на **статическую musl-сборку с сохранением NaïveProxy**, добавлены роутерные арки. Закрывает [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1): `libdl.so.2: cannot open shared object file` на AsusWRT Merlin + отсутствие `linux-armv7`.
**Go-кода нет** — задача чисто инфраструктурная (CI). Единственная Go-правка в этой ветке — hotfix gofmt-выравнивания в `option/wireguard_awg.go` (хвост 005, см. ниже).
## Диагноз (эмпирически подтверждён)
`with_naive_outbound` тянет `cronet-go`. Прежний релиз собирался в режиме `with_purego` (`CGO_ENABLED=0`): `purego` на Linux содержит `//go:cgo_import_dynamic … "libdl.so.2"` → бинарь **динамический**, требует `libdl.so.2`. На glibc ок, на musl (роутеры) — падает до старта. Кросс-сборкой проверено: `file` → `dynamically linked`, `strings|grep libdl.so.2` → 1; без naive/purego → `statically linked`, 0.
naive **сохраняем** — это upstream-фича (`release/DEFAULT_BUILD_TAGS` содержит `with_naive_outbound`; `protocol/naive/outbound.go` без `lx:`-маркеров). Поэтому не дропаем, а используем третий режим cronet-go — `with_musl` (статический `libcronet.a` + musl-toolchain), как делает upstream `build.yml`.
## Что сделано
**`.github/workflows/lx-release.yml`** — новый job `build_linux_musl` (зеркало upstream musl-pipeline):
- матрица: `linux-amd64`, `linux-arm64`, `linux-armv7` (`GOARM=7`), `linux-mipsle-softfloat` (`GOMIPS=softfloat`);
- clone `cronet-go` по pin `.github/CRONET_GO_VERSION` (`2faf34666c2c`, = go.mod) + submodules; regenerate keyring; cache + download Chromium **musl** toolchain через `cmd/build-naive --libc=musl`; set env;
- `CGO_ENABLED=1 go build` с тегами `LX_TAGS` (с заменой `with_purego`→`with_musl`, `with_naive_outbound` сохранён);
- verify-шаг: `file → statically linked`, `! grep libdl.so.2`;
- Linux убран из desktop-job `build` (остаются darwin/windows/win7); `release.needs += build_linux_musl`; release-notes обновлены.
**`.github/workflows/lx-ci.yml`** — dispatch-only smoke-job `linux_musl` (те же 4 арки): полный musl-pipeline + build + verify static, **без публикации** — безопасная приёмка. Помечен «keep in sync with build_linux_musl».
**Нейминг** — по upstream-схеме арочных суффиксов (`armv7` = arm+`v`+GOARM; `mipsle-softfloat` = arch+GOMIPS), но **без суффикса `-musl`**: upstream добавляет его, т.к. собирает и glibc, и musl на арку; у нас Linux — единственный (musl) вариант, и `linux-arm64`/`linux-armv7` совпадают с ожиданием скриптов потребителей.
## Приёмка
- ✅ YAML валиден (`python yaml`), `actionlint` чист (rc=0) для обоих workflow.
- ✅ CI smoke (`lx-ci` workflow_dispatch, job `linux_musl` ×4, run [27407702652](https://github.com/Leadaxe/sing-box-lx/actions/runs/27407702652)) — все 4 **success**, `file` → `statically linked`, `libdl.so.2=0`:
- `linux-amd64`: ELF 64-bit x86-64, statically linked;
- `linux-arm64`: ELF 64-bit ARM aarch64, statically linked;
- `linux-armv7`: ELF 32-bit ARM EABI5, statically linked;
- `linux-mipsle-softfloat`: ELF 32-bit MIPS32 rel2, statically linked — **mipsle+naive собрался musl-статикой**, fallback не понадобился.
- ✅ Боевой релиз [v1.13.13-lx.7](https://github.com/Leadaxe/sing-box-lx/releases/tag/v1.13.13-lx.7) опубликован — 4 musl-арки + desktop (darwin/win/win7) + 2 AAR + SHA256SUMS.
- ✅ **Field-verified** репортером issue #1 на AsusWRT Merlin RT-AX (`linux/arm64`): ядро устанавливается, стартует, работает; `sing-box version` → `1.13.13-lx.7`, теги включают `with_naive_outbound,with_musl`, `CGO: enabled`. Подтверждение на реальном устройстве, не только в CI.
> **Нейминг — апдейт от потребителя:** репортер подтвердил, что суффикс `-musl` для его скрипта **некритичен** (берёт архив с суффиксом или без). То есть наш выбор «без `-musl`» валиден без оглядки на чужой скрипт — это просто следствие единственного варианта на арку. `-softfloat` у mipsle при этом **обязателен** (FP-ABI, не линковка): softfloat запускается на любом MIPS-роутере, hardfloat — только на чипах с FPU.
## Побочный hotfix (хвост 005)
`option/wireguard_awg.go`: расширение `H1..H4 uint32 → MagicHeader` сменило самый длинный тип в struct, gofmt перевыровнял json-теги. `go vet` это не ловит, поэтому ушло в lx.6 с красным дешёвым CI (`lint` job). Исправлено `gofmt -w`, format-only. Урок: прогонять `gofmt -l` на lx-owned файлах перед коммитом.
## Зона касания upstream (для ребейза)
`lx-release.yml` / `lx-ci.yml` — **lx-собственные** файлы (в upstream их нет) → конфликтов на ребейзе не дают. `.github/CRONET_GO_VERSION` — upstream-файл, **только читаем**. Паттерн musl-pipeline заимствован из upstream `build.yml` как референс.
## Вне скоупа
- Экзотика (`386`/`riscv64`/`loong64`/`mips64le`) — точечно по запросу; cronet-musl под `mips64le` нет.
- DEB/RPM/Pacman/OpenWrt-пакеты — не публикуем (только `.tar.gz`).
- naive на Win7 (windows/386) — физически невозможен (нет `cronet-go/lib/windows_386`).
- Перепин `libbox.version` в лаунчере — на стороне LxBox.
@@ -1,67 +0,0 @@
# PLAN: 006 — LINUX_MUSL_STATIC_ROUTER_BUILDS
## 1. Архитектура
Один новый job в `lx-release.yml` — `build_linux_musl` — повторяющий upstream `build.yml` musl-секцию, но с нашим `LX_TAGS`. Существующий desktop-путь не ломаем: из старого `build` job убираем строки `linux/*`, остальное (darwin/windows/win7) остаётся на `Makefile.lx`/purego.
```
build (desktop, как сейчас минус linux): darwin×2, windows×2, win7-386
build_linux_musl (NEW): linux musl-static + naive: amd64, arm64, armv7, mipsle
build_android (без изменений): libbox.aar ×2
release (как сейчас): собирает артефакты всех job
```
## 2. `build_linux_musl` — шаги (зеркало upstream)
Матрица:
```yaml
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
```
Шаги:
1. `checkout` (submodules: recursive — нужен `submodules/wireguard-go` для with_awg).
2. `setup-go` (go-version-file: go.mod).
3. **Clone cronet-go**: `CRONET=$(cat .github/CRONET_GO_VERSION)`; `git init ~/cronet-go`; remote `sagernet/cronet-go`; `fetch --depth=1 $CRONET`; checkout; `submodule update --init --recursive --depth=1` (тянет naiveproxy/src — Chromium toolchain source).
4. **Regenerate Debian keyring**: `…/sysroot_scripts/generate_keyring.sh` (для sysroot download).
5. **Cache Chromium toolchain** (`actions/cache`): пути `llvm-build/`, `gn/out/`, `pgo_profiles/`, `out/sysroot-build/`; key по arch+`CRONET_GO_VERSION`. Гигабайты — кеш обязателен.
6. **Download toolchain**: `cd ~/cronet-go && go run ./cmd/build-naive --target=linux/<arch> --libc=musl download-toolchain`.
7. **Set toolchain env**: `… --libc=musl env >> $GITHUB_ENV` (выставляет `CC`/`CXX`/CGO_*).
8. **Build tags**: `TAGS=$(make -f Makefile.lx -s lx-print-tags)`; `TAGS="${TAGS/with_purego/with_musl}"`.
9. **Build (musl)**: `CGO_ENABLED=1 GOOS=linux GOARCH=<arch> GOARM=<goarm> GOMIPS=<gomips> go build -trimpath -tags "$TAGS" -ldflags "<LX_LDFLAGS>" -o dist/sing-box ./cmd/sing-box`.
10. **Verify static** (приёмка в логах): `file dist/sing-box` → `statically linked`; `! strings dist/sing-box | grep -q libdl.so.2`.
11. **Package**: `sing-box-<ver>-<asset>.tar.gz` (binary + LICENSE/README), как в desktop job.
12. `upload-artifact`.
> `LX_LDFLAGS` берём из Makefile.lx (`-checklinkname=0 -s -w -buildid=` + Version). Тот же набор, что у desktop-сборки.
## 3. Изменяемые / новые файлы
| Файл | Тип | Изменение |
|------|-----|-----------|
| `.github/workflows/lx-release.yml` | lx-own CI | новый job `build_linux_musl`; из `build` убрать linux-строки; `release.needs` += новый job; notes.md — роутерные арки |
| `SPECS/006/*`, `SPECS/README.md` | docs | спека + roadmap |
**Go-кода нет.** `Makefile.lx` править не обязательно (теги берём из него же; musl-логика живёт в CI, т.к. требует Chromium-toolchain env, которого в Makefile не выразить переносимо).
## 4. Зона касания upstream (для ребейза)
`lx-release.yml` — **lx-собственный** файл (создан в 004), в upstream его нет → конфликтов на ребейзе не даёт. Паттерн заимствован из upstream `build.yml`, но как референс, не как правка upstream-файла. `.github/CRONET_GO_VERSION` — upstream-файл, мы его **только читаем** (pin уже совпадает с go.mod), не меняем.
## 5. Порядок работ
1. SPEC/PLAN/TASKS (done).
2. Реализовать `build_linux_musl` + почистить linux из `build` + `release.needs` + notes.
3. Локально: валидация YAML (actionlint/python-yaml). Полную musl-сборку локально не проверить — Chromium toolchain только в CI.
4. Прогон `workflow_dispatch` (dev-тег) → читать логи, чинить toolchain/линковку итеративно.
5. На зелёном — verify-шаги (static/libdl) в логах; по возможности запуск armv7 под qemu-user.
6. REPORT, статус C, ответ в issue #1. Боевой релиз — тегом `v1.13.13-lx.7`.
## 6. Риски
- **Локально не верифицируемо** — отладка только через CI; закладываем несколько прогонов. Кеш toolchain критичен для скорости.
- **Время/размер**: Chromium toolchain — гигабайты; musl-бинарь крупнее (вшит libcronet, +неск. МБ). Приемлемо для релиза по тегу (не на каждый push).
- **mipsle softfloat**: проверить, что toolchain build-naive поддерживает target `linux/mipsle` + `GOMIPS=softfloat`. Если cronet-musl/mipsle не соберётся — fallback: mipsle через `DEFAULT_BUILD_TAGS_OTHERS` (без naive, `CGO_ENABLED=0`, статика) как делает upstream для арок без cronet. Зафиксировать в REPORT.
- **keyring/sysroot download** может флапать (внешняя Chromium infra) — ретраи.
@@ -1,68 +0,0 @@
# SPEC: 006 — LINUX_MUSL_STATIC_ROUTER_BUILDS
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Публиковать **статические musl-бинари** `sing-box` под роутерные Linux-арки, **сохраняя NaïveProxy-outbound**. Закрывает [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1): нужен `linux-armv7`, и текущий `linux-arm64` не запускается на AsusWRT Merlin (`libdl.so.2: cannot open shared object file`).
---
## 1. Проблема / контекст
- Лаунчер-аудитория ставит ядро на роутеры (AsusWRT Merlin, OpenWrt, Keenetic) — это **musl**-окружения.
- Текущие релизные `linux-amd64/arm64` собраны в режиме `with_purego` (`CGO_ENABLED=0`). `purego` через `//go:cgo_import_dynamic … "libdl.so.2"` делает бинарь **динамическим** и вешает зависимость от `libdl.so.2`. На glibc-десктопе ок, на musl — загрузчик падает до старта. Проверено эмпирически: `file` → `dynamically linked`, `strings | grep libdl.so.2` → 1 совпадение.
- `linux-armv7` в релизе **отсутствует** вовсе.
- **NaïveProxy-outbound — штатная upstream-фича** (`release/DEFAULT_BUILD_TAGS` содержит `with_naive_outbound`; `protocol/naive/outbound.go` — upstream-код без `lx:`-маркеров). По CONSTITUTION (upstream + ровно 2 фичи, из upstream ничего не выкусываем) её **нельзя** дропать ради статики.
## 2. Цель
Релиз публикует статические, самодостаточные (без `libdl`) musl-бинари под 4 роутерные арки, **с работающим naive**:
| Арка | Параметры | Покрытие |
|------|-----------|----------|
| `linux-amd64` | musl | x86 софт-роутеры, контейнеры, универсальный |
| `linux-arm64` | musl | современные роутеры (ASUS AX/AXE, GL.iNet, Xiaomi), arm64-роутер автора |
| `linux-armv7` | musl, `GOARM=7` | ASUS на старых SoC — прямой запрос issue |
| `linux-mipsle` | musl, `GOMIPS=softfloat` | классика OpenWrt (MediaTek/Atheros) |
Без `with_awg`/`with_xhttp` поведение не меняется — это чисто сборочная задача (CI), **Go-кода нет**.
## 3. Требования
### 3.1 Механизм — по подобию upstream `build.yml`
- Сборка musl-варианта повторяет upstream: clone `cronet-go` по pin `.github/CRONET_GO_VERSION` (уже совпадает с `go.mod`: `2faf34666c2c`), regenerate Debian keyring, download Chromium **musl** toolchain через `go run ./cmd/build-naive --target=linux/<arch> --libc=musl download-toolchain`, выставить env (`… env >> $GITHUB_ENV`), затем `CGO_ENABLED=1 go build` с тегом `with_musl` — `libcronet.a` линкуется статически.
- **zig не используется** — официальный путь cronet-go (Chromium toolchain) надёжнее и совпадает с upstream.
### 3.2 Теги
- Брать `LX_TAGS` (Makefile.lx, single source of truth), заменить `with_purego` → `with_musl`. `with_naive_outbound` **остаётся**. Остальные фичи (`with_xhttp,with_awg,…`) без изменений.
### 3.3 Нейминг артефактов
- По upstream-схеме арочных суффиксов: `armv7` (= `arm` + `v` + `GOARM`), `mipsle-softfloat` (= arch + `GOMIPS`).
- **Без суффикса `-musl`**: upstream добавляет его, т.к. собирает и glibc, и musl на арку; у нас на Linux единственный вариант — musl, поэтому суффикс избыточен и сломал бы ожидание скриптов (`linux-arm64`/`linux-armv7`).
- Итог: `sing-box-<ver>-linux-amd64.tar.gz`, `…-linux-arm64.tar.gz`, `…-linux-armv7.tar.gz`, `…-linux-mipsle-softfloat.tar.gz`.
### 3.4 Не-Linux платформы — без изменений
- darwin amd64/arm64, windows amd64/arm64 — текущий `with_purego` путь (libdl чисто Linux-glibc, проблемы нет).
- Win7-386 — без naive (нет cronet под windows/386 — отдельная физическая причина, не линковка).
- Android AAR — отдельный job, без изменений.
## 4. Критерии приёмки
- Релизные `linux-{amd64,arm64,armv7,mipsle}` — `file` → **statically linked**, `strings | grep libdl.so.2` → **0**.
- naive присутствует (тег `with_naive_outbound` в сборке; по возможности — функциональная проверка).
- armv7/mipsle запускаются на реальном/эмулированном musl-роутере (`sing-box version` без ошибки загрузчика).
- darwin/windows/win7/android-ассеты не изменились по составу.
- **Верификация — через CI** (`workflow_dispatch`): Chromium musl-toolchain (гигабайты) недоступен локально на macOS, поэтому локальной сборки musl нет — приёмка по прогону workflow.
## 5. Вне скоупа
- Экзотические арки (`386`, `riscv64`, `loong64`, `mips64le`) — добавляются точечно строкой матрицы по запросу; cronet-musl под `mips64le` вообще нет.
- DEB/RPM/Pacman/OpenWrt-пакеты (upstream их делает) — нам не нужны, публикуем `.tar.gz`.
- naive на Win7 — физически невозможен (нет `cronet-go/lib/windows_386`).
## 6. Ссылки
- [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1)
- upstream `.github/workflows/build.yml` (v1.13.13) — референс musl-pipeline.
@@ -1,25 +0,0 @@
# TASKS — 006-LINUX_MUSL_STATIC_ROUTER_BUILDS
## CI: musl-linux job
- [x] `lx-release.yml`: новый job `build_linux_musl`, матрица amd64/arm64/armv7(GOARM=7)/mipsle(GOMIPS=softfloat)
- [x] Clone cronet-go по `.github/CRONET_GO_VERSION` + submodules; regenerate keyring
- [x] Cache + download Chromium **musl** toolchain (`cmd/build-naive … --libc=musl`); set env
- [x] Build: `CGO_ENABLED=1`, теги `LX_TAGS` с `with_purego`→`with_musl`, `with_naive_outbound` сохранён
- [x] Из старого `build` job убрать строки `linux/*`; `release.needs` += `build_linux_musl`
## Нейминг + notes
- [x] Ассеты: `linux-amd64`, `linux-arm64`, `linux-armv7`, `linux-mipsle-softfloat` (без `-musl` суффикса)
- [x] `notes.md`: секция про роутерные musl-static арки + что naive сохранён
## Приёмка
- [x] Валидация YAML (actionlint/синтаксис)
- [x] Прогон `workflow_dispatch`; зелёные musl-сборки
- [x] В логах: `file` → statically linked, `strings|grep libdl.so.2` → 0
- [x] (по возможности) запуск armv7/arm64 под qemu-user — `sing-box version` без ошибки загрузчика
- [x] mipsle: подтвердить musl+naive; иначе fallback `_OTHERS` без naive (зафиксировать)
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, DoD
- [x] `SPECS/README.md` roadmap-строка → C
- [x] Ответ в issue #1 (Dr4tez): armv7+arm64 musl-static с naive, имена ассетов; Win7-naive невозможен
- [ ] Боевой релиз `v1.13.13-lx.7` (ждёт ОК пользователя)
@@ -1,158 +0,0 @@
# IMPLEMENTATION_REPORT — 007 AWG_OVER_WIREGUARD_DETOUR_GUARD
**Дата:** 2026-06-15, ревизия 2026-06-16 · **Статус:** Complete (код + тесты + DoD; смена подхода после field-теста) · **База:** `v1.13.13`
## Итог
AmneziaWG-нода, чей `detour` (прямо или по транзитивной цепочке) ведёт на
**любой WireGuard-based** endpoint (плоский WG или AWG), больше не вешает ядро на
Android: такая связка отвергается (**вариант B** — ядро и остальные узлы живут,
этот узел не поднимается, ошибка в лог). `detour` AWG-ноды на не-WireGuard (VLESS
и т.д.) — разрешён.
## ⚠️ Смена подхода (2026-06-16) — два эшелона вместо одного
**Первая реализация (lx.8) ловила только лениво в `DetourDialer.init()` — и на
устройстве не сработала.** Field-тест AWG→AWG-конфига на Android (lx.8, logcat
`/tmp/logcat_brokennode.txt`): ядро виснет в `Starting` — последняя строка
`LxBoxNet: defaultNetwork`, затем 27 c тишины, `Libbox.newService()` не
возвращает управление; нашей guard-ошибки `connect to server: amneziawg…` в логе
**нет вовсе**.
**Почему ленивый guard не сработал:** он живёт в `DetourDialer.init()`, который
вызывается из `ClientBind.connect()` — то есть **при первом dial**, из bind-горутин.
А зависание происходит **синхронно в `Endpoint.Start`**, раньше dial:
`transport/wireguard/endpoint.go` `Start(resolve=true)` для peer с доменным
адресом (в конфиге — `mfi.tribukvy.ltd`) синхронно зовёт `ResolvePeer` **через
detour** (нижний туннель), + device запускает junk-handshake. До `connect()` дело
не доходит → ленивый guard архитектурно мёртв для этого сценария.
**Решение — Start-guard** (`protocol/wireguard.Endpoint.Start`): статический обход
транзитивного замыкания `detour` через `OutboundManager` + `Dependencies()`; при
достижении `Type()==wireguard` — device не поднимается, `started=false`, лог,
`return nil`. **Вариант B**: ядро и прочие узлы живут. На группе (selector/urltest)
обход **останавливается** (цель рантайм-зависима). Field-verified на Android lx.9.
> **Ревизия после lx.9: ленивый dialer-guard (lx.8) удалён.** Изначально его
> оставляли «вторым эшелоном» для случая «селектор по середине». Но: (1) непроверяем
> — в UI LxBox detour наводится только на реальный сервер, не на группу;
> (2) кэширует через `sync.Once` → **не ловит** смену селектора в рантайме; (3) в
> field-тесте вообще не сработал (виснет в `Start` до dial). Файлы
> `common/dialer/{detour,dialer}.go` возвращены к upstream.
**Решение «селектор по середине» — selector-guard** (`protocol/group.Selector.SelectOutbound`):
при переключении селектора на член, ведущий к WireGuard, **до** коммита выбора
(`s.selected.Store`) идём вверх по `OutboundManager.ConsumersOf` (reverse-deps,
транзитивно) и для каждого AmneziaWG-потребителя зовём `SuspendAmneziaWG()`
(`device.Down`, `started=false`). Гашение **до** переключения закрывает гонку: к
моменту, когда группа укажет на WG, AWG-потребитель уже опущен, его reconnect →
«not ready» → junk в WG не уйдёт. Маркер `adapter.AmneziaWGSuspendable` и метод
`OutboundManager.ConsumersOf` добавлены, чтобы `protocol/group` действовал без
импорта `protocol/wireguard`.
**Итог: два дополняющих guard'а** — Start-guard (статическая прямая цепь,
field-verified) + selector-guard (рантайм-переключение селектора). Оба — вариант B.
Главный инвариант: **ядро поднимать, коннект не стартовать, ошибку в лог.**
Баг **нашей** дельты (фича 003 AWG2), не upstream → в скоупе (CONSTITUTION §3.1).
## Диагноз (по данным с устройства)
Матрица из тестов автора:
| Источник (`detour`-ит) | Цель | Результат |
|---|---|---|
| **AWG** | **WG** | ❌ беда |
| **AWG** | **AWG** | ❌ беда |
| **AWG** | **VLESS** | ✅ работает |
| **WG** | AWG | ✅ работает |
⇒ триггер — **источник AWG + цель = WireGuard-туннель** (по пакетам: AWG внутри
WG). Не «два junk-слоя», как предполагалось вначале, и не сам `detour`.
Механика (статич. разбор `submodules/wireguard-go/device/send.go`):
`SendHandshakeInitiation` синхронно генерирует junk и зовёт `SendBuffers` →
`bind.Send()` **без таймаута**, держа `device.net.RLock()`. Когда AWG-трафик
инкапсулируется в WireGuard-устройство, запись блокируется на нижнем туннеле; на
Android (нет watchdog/перезапуска) — зависание. Первопричину статикой **не
доказывали** — задача намеренно про **guard**, не про лечение блокировки.
## Что сделано
### Итерация 1 (lx.8) — ленивый dialer-guard, **откачена**
Поведение копировало ядровой запрет `detour to an empty direct outbound`
(upstream `fb622ccb`) — guard в `DetourDialer.init()` (`common/dialer/detour.go`),
флаг владельца через `dialer.Options.IsAmneziaWG`. **Не сработал на устройстве**
(см. выше) и удалён ревизией 2026-06-16: `common/dialer/{detour,dialer}.go`
возвращены к upstream, тест `awg_detour_guard_test.go` удалён. Описание оставлено
как история — в текущем коде этого guard нет.
### Итерация 2 (lx.9) — Start-guard (актуальное)
**`protocol/wireguard/endpoint.go`** (`// lx:` AWG-шов):
- поля `Endpoint.awgActive` (= `AmneziaWGOptions.IsSet()`), `detour`,
`awgChainBlocked`;
- в `Start` (стадия `StartStateStart`), если `awgActive` и есть `detour`:
`awgDetourChainReachesWireGuard(outboundManager, detour, …)` обходит
**транзитивную** цепочку detour (через `OutboundManager.Outbound` +
`Dependencies()`), и если достигает `Type()==wireguard` — логирует ошибку, ставит
`awgChainBlocked=true`, **не вызывает** `w.endpoint.Start` и возвращает `nil`
(вариант B: `started` остаётся false, dial узла → «WireGuard is not ready yet»,
device с junk не поднимается → нет зависания). `PostStart` тоже пропускается.
- `awgDetourChainReachesWireGuard` **останавливается на группе** (selector/urltest)
— её рантайм-цель ловит selector-guard (ниже). Защита от циклов — set посещённых.
**`protocol/wireguard/awg_start_guard_test.go`** (новый lx-файл): direct WG,
транзитив через vless→WG, цепь без WG, селектор-в-середине (пропуск), цикл,
неизвестный тег.
### Итерация 3 — selector-guard (рантайм-переключение)
- **`adapter/outbound.go`** (`// lx:`): метод `OutboundManager.ConsumersOf(tag)`
(reverse-deps) + интерфейс-маркер `AmneziaWGSuspendable {IsAmneziaWG; SuspendAmneziaWG}`.
- **`adapter/outbound/manager.go`** (`// lx:`): реализация `ConsumersOf` из
`dependByTag` (копия под RLock).
- **`protocol/wireguard/endpoint.go`** (`// lx:`): `IsAmneziaWG()` и
`SuspendAmneziaWG()` (CAS `started`→false + лог при реальном гашении; `Suspend()`
device).
- **`transport/wireguard/endpoint.go`** (`// lx:`): `Suspend()` → `device.Down()`
без Close (идемпотентно).
- **`protocol/group/selector.go`** (`// lx:`): в `SelectOutbound` — `Swap`→`Load`+`Store`,
вызов `suspendAmneziaWGConsumersOnWireGuardSwitch` **до** `Store`.
- **`protocol/group/awg_selector_guard.go`** (новый lx-файл): `chainReachesWireGuard`
(член ведёт к WG?) + `suspendAmneziaWGConsumers` (обход вверх по `ConsumersOf`).
- **`protocol/group/awg_selector_guard_test.go`** (новый lx-файл): тесты.
## Приёмка (DoD)
- ✅ `go build ./...` без тегов — ок (для плоского WG `awgActive=false`/`IsAmneziaWG()==false` → guard'ы no-op).
- ✅ `go build -tags "with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api,with_xhttp,with_awg" ./cmd/sing-box` — ок.
- ✅ `go test ./protocol/wireguard/...` — `TestAwgDetourChainReachesWireGuard` (Start-guard, 6 кейсов).
- ✅ `go test ./protocol/group/...` — `chainReachesWireGuard` + `suspendAmneziaWGConsumers`
+ `…SkippedForNonWireGuardSwitch` (selector-guard: прямой/транзитивный AWG suspend,
не-AWG не трогается, не-WG переключение — no-op).
- ✅ `gofmt -l` изменённых файлов — пусто (урок 006/005 учтён).
- ✅ `go vet ./protocol/wireguard/... ./protocol/group/... ./adapter/...` — чисто.
- ✅ **Field-verified на lx.9 (Android, 2026-06-15 22:21)** — Start-guard: AWG→AWG
конфиг, ядро поднимается, узел не встаёт, остальное работает. selector-guard —
юнит-тестами (в UI LxBox недостижим: detour только на реальные серверы).
- Текст ошибок — единый, по архитектуре: «amneziawg over wireguard is not supported».
## Зона касания upstream (для ребейза)
- `protocol/wireguard/endpoint.go`, `transport/wireguard/endpoint.go`,
`protocol/group/selector.go`, `adapter/outbound.go`, `adapter/outbound/manager.go`
— upstream-файлы, правки **только** в `// lx:` блоках. Конфликт на ребейзе — лишь
если upstream перепишет `Endpoint.Start`/`Endpoint.Close`, `Selector.SelectOutbound`,
интерфейс `OutboundManager` или конструкторы.
- `common/dialer/{detour,dialer}.go` — ревизией 2026-06-16 **возвращены к upstream**.
- `awg_start_guard_test.go`, `awg_selector_guard.go`, `awg_selector_guard_test.go`
— lx-собственные, конфликтов не дают.
## Вне скоупа
- **Лечение первопричины** в `submodules/wireguard-go` (таймауты/неблокирующая
отправка junk; смежный баг `jmin>jmax` закрыт задачей 008) — отдельная задача.
- Цепочки AWG-over-WireGuard через route-rule action, а не `detour`.
@@ -1,72 +0,0 @@
# PLAN: 007 — AWG_OVER_WIREGUARD_DETOUR_GUARD
## Архитектура решения
Guard живёт в **ленивом резолве detour** (`common/dialer/detour.go`
`DetourDialer.init()`), рядом с upstream-проверкой `empty direct outbound`. Это
даёт **вариант B** тем же механизмом, что и empty-direct: ошибка кэшируется в
`initErr` (через `sync.Once`), `DialContext`/`ListenPacket` возвращают её вместо
соединения. Ядро стартует без ошибок, прочие узлы работают, AWG-нода фейлит
каждый dial. Не fatal на старте.
### Откуда «источник AWG»
`DetourDialer` сам по себе не знает тип владельца. Прокидываем флаг:
`dialer.Options.IsAmneziaWG` → `NewDetour(..., ownerIsAmneziaWG)`.
`protocol/wireguard.NewEndpoint` выставляет его из `options.AmneziaWGOptions.IsSet()`.
### Как «цель WireGuard»
Резолвленная detour-цель приводится к `adapter.Outbound`; триггер —
`Type() == C.TypeWireGuard` (покрывает и плоский WG, и AWG — тип один). Группы
(`adapter.OutboundGroup`) раскрываются рекурсивно через `All()` +
`OutboundManager.Outbound(tag)`, с set'ом посещённых против циклов.
## Изменённые файлы
| Файл | Зона | Что |
|------|------|-----|
| `common/dialer/detour.go` | `// lx:` upstream | поле `ownerIsAmneziaWG`, guard в `init()`, рекурсивный `detourTargetIsWireGuard`, новый параметр `NewDetour` |
| `common/dialer/dialer.go` | `// lx:` upstream | поле `Options.IsAmneziaWG`, проброс в `NewDetour` |
| `protocol/wireguard/endpoint.go` | `// lx:` upstream (AWG-шов) | `IsAmneziaWG: options.AmneziaWGOptions.IsSet()` в `dialer.Options` |
| `common/dialer/awg_detour_guard_test.go` | **новый lx-файл** | unit-тест матрицы + init-пути |
## Логика guard (как реализовано)
```go
// detour.go init(), после empty-direct:
if d.ownerIsAmneziaWG && detourTargetIsWireGuard(mgr, dialer, map[string]bool{}) {
d.initErr = E.New("amneziawg endpoint cannot detour through a wireguard-based "+
"endpoint (detour: ", d.detour, "): AmneziaWG inside a WireGuard tunnel "+
"hangs the kernel on Android; use a non-wireguard detour (e.g. vless)")
return
}
func detourTargetIsWireGuard(mgr, ob, visited) bool {
if o, ok := ob.(adapter.Outbound); ok && o.Type() == C.TypeWireGuard { return true }
if g, ok := ob.(adapter.OutboundGroup); ok {
for _, tag := range g.All() {
if visited[tag] { continue }
visited[tag] = true
if m, loaded := mgr.Outbound(tag); loaded && detourTargetIsWireGuard(mgr, m, visited) { return true }
}
}
return false
}
```
## DoD (IMPLEMENTATION_PROMPT §2) — факт
- [x] `go build ./...` без тегов — ок.
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок.
- [x] `go test ./common/dialer/...` — зелёный (8 подтестов).
- [x] `gofmt -l` изменённых файлов — пусто.
- [x] `go vet ./common/dialer/... ./protocol/wireguard/...` — чисто.
## Зона касания upstream (для ребейза)
- `common/dialer/detour.go`, `common/dialer/dialer.go`,
`protocol/wireguard/endpoint.go` — upstream-файлы, правки только в `// lx:`
блоках. Конфликт на ребейзе — лишь если upstream перепишет `DetourDialer.init`
/ сигнатуру `NewDetour` / `dialer.Options` / конструктор endpoint.
- `awg_detour_guard_test.go` — lx-собственный, конфликтов не даёт.
- Сигнатура `NewDetour` расширена 4-м параметром — единственный внешний вызов в
`dialer.go` обновлён; других вызовов в дереве нет.
@@ -1,173 +0,0 @@
# SPEC: 007 — AWG_OVER_WIREGUARD_DETOUR_GUARD
| Поле | Значение |
|------|----------|
| Тип | B (bug) |
| Статус | C (complete) — guard **снят** (см. баннер ниже) |
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
>
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
> возвращал управление). Android для shater не платформа и ей не станет —
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
> на его применение.
>
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
> отклонённая, а как «нода почему-то не работает».
>
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
> тест краснел.
>
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
WireGuard-based** endpoint — плоский WireGuard **или** AmneziaWG. По пакетам это
AWG-трафик, инкапсулированный внутри WireGuard-туннеля; на Android такая связка
**вешает ядро**. `detour` AWG-ноды на не-WireGuard outbound (VLESS, Trojan,
direct, …) — рабочий сценарий и остаётся разрешённым.
Баг в фиче [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT) **нашей**
дельты (AWG-проводка + merged-форк wireguard-go), не upstream → в скоупе по
CONSTITUTION §3.1.
---
## 1. Проблема / контекст
Матрица из тестов на устройстве (автор):
| Источник (`detour`-ит) | Цель | Результат |
|---|---|---|
| **AWG** | **WG** (плоский WireGuard) | ❌ беда (ядро виснет / handshake не уходит) |
| **AWG** | **AWG** | ❌ беда |
| **AWG** | **VLESS** | ✅ работает |
| **WG** (без AWG-полей) | AWG | ✅ работает |
Вывод: триггер — **источник AWG + цель = любой WireGuard-туннель**, а не «два junk-слоя».
По пакетам — AWG внутри WG. По конфигу — «у ноды AWG прописан `detour` на WG/AWG».
Механика (статический разбор `submodules/wireguard-go`):
`SendHandshakeInitiation` (device/send.go) синхронно генерирует junk и зовёт
`SendBuffers` → `bind.Send()` **без таймаута на запись**, удерживая
`device.net.RLock()`. Когда AWG-трафик заворачивается в WireGuard-устройство,
запись блокируется на нижнем туннеле; на Android (нет watchdog) это проявляется
как зависание. Точную первопричину статикой **не доказали** — для guard это и не
нужно: цель — **быстро отклонять** заведомо опасную связку, пока (отдельной
задачей) не вылечена сама блокировка в wireguard-go.
## 2. Цель
AWG-нода с `detour` на WireGuard-based цель **не поднимает соединение**.
Поведение — **вариант B** (согласовано с автором и
[LxBox §128](https://github.com/Leadaxe/LxBox/blob/develop/docs/spec/tasks/128-force-direct-out-detour.md)):
ядро стартует, остальные узлы работают, эта нода не встаёт, ошибка в логе. **Не
крашимся.**
> **Ревизия 2026-06-16 (см. IMPLEMENTATION_REPORT):** реализация прошла итерации.
>
> 1. **lx.8 — ленивый guard в `DetourDialer.init()`** (на первом dial). Field-тест
> показал: **не срабатывает** — AWG→WG виснет синхронно в `Endpoint.Start`
> (резолв peer-домена через detour + junk-handshake), **до** первого dial.
> **Удалён** (непроверяем в UI, `sync.Once`-кэш не ловит смену селектора).
> 2. **lx.9 — Start-guard в `protocol/wireguard.Endpoint.Start`** (статический обход
> транзитивной detour-цепи; device не поднимается). **Field-verified на Android.**
> 3. **selector-guard в `protocol/group` (`SelectOutbound`)** — закрывает случай
> «селектор по середине», который Start-guard статически пропускает: при
> переключении селектора на член, ведущий к WireGuard, **до** коммита выбора
> гасятся (suspend → `device.Down`, `started=false`) все AmneziaWG-потребители,
> что detour-ят на эту группу (транзитивно вверх через `ConsumersOf`).
>
> **Итог: два дополняющих guard'а — Start-guard (статический, прямая цепь) +
> selector-guard (рантайм, переключение селектора).** Оба дают вариант B и не
> крашатся. Гашение selector-guard'ом — **до** переключения, поэтому AWG-потребитель
> опущен раньше, чем группа укажет на WG → гонки нет.
## 3. Требования
### 3.1 Критерий «источник»
- Источник-триггер — AWG-endpoint: `option.AmneziaWGOptions.IsSet()` (любое
AWG-поле). Плоский WG источником-триггером **не** является (WG→AWG разрешён).
### 3.2 Критерий «цель»
- Цель-триггер — **любой WireGuard-based outbound**: `Type() == C.TypeWireGuard`
(один тип `"wireguard"` покрывает и плоский WG, и AWG — AWG отличается лишь
набором полей, тип тот же). Решение автора: детектировать **по типу**.
- Цепочка обходится **транзитивно** по `detour` (через `OutboundManager.Outbound`
+ `Dependencies()`): AWG→X→…→WG ловится на любой глубине. Защита от циклов — set
посещённых тегов.
- На группе (selector/urltest) Start-guard обход **останавливается** — выбранный
член рантайм-зависим; этот случай ловит selector-guard (§3.3). Не-WireGuard цели
(VLESS и т.д.) — **не** триггер.
### 3.3 Где ловить — два guard'а
**(a) Start-guard** — `protocol/wireguard.Endpoint.Start` (стадия `StartStateStart`),
**до** `w.endpoint.Start()`. Зависание происходит синхронно в `Start` (резолв
peer-домена через detour + junk-handshake), до первого dial — ленивый guard туда не
успевает (доказано на lx.8). Источник — `Endpoint.awgActive`
(`AmneziaWGOptions.IsSet()`); цель — `awgDetourChainReachesWireGuard(...)`
(транзитивный обход detour, **на группе останавливается**). Поведение — **вариант B**:
device не поднимается, `started=false`, `return nil` (НЕ error — иначе abort
инстанса), ошибка в лог. Все outbound'ы зарегистрированы до любого `Start`.
**(b) selector-guard** — `protocol/group.Selector.SelectOutbound`, **до** коммита
выбора (`s.selected.Store`). Если новый член ведёт к WireGuard
(`chainReachesWireGuard`: сам тип / detour вниз / вложенные группы), идём **вверх**
по `OutboundManager.ConsumersOf` (reverse-deps, транзитивно: AWG→vless→group) и для
каждого потребителя с `IsAmneziaWG()` зовём `SuspendAmneziaWG()` (`device.Down`,
`started=false`). Гашение **до** `Store` → к моменту переключения AWG уже опущен,
его reconnect вернёт «not ready» → junk в WG не уйдёт (**гонки нет**). Лог — при
реальном гашении (`CompareAndSwap(true,false)`), без спама на повторных переключениях.
### 3.4 Изоляция (CONSTITUTION §3.2–3.3)
- Start-guard — `// lx:` блоки в `protocol/wireguard/endpoint.go` +
`transport/wireguard/endpoint.go` (`Suspend()`); тест `awg_start_guard_test.go`.
- selector-guard — новый файл `protocol/group/awg_selector_guard.go` + один вызов в
`selector.go`; маркер `adapter.AmneziaWGSuspendable` и `OutboundManager.ConsumersOf`
в `adapter/outbound.go` + `adapter/outbound/manager.go` (`// lx:`); тест
`awg_selector_guard_test.go`.
- `common/dialer/{detour,dialer}.go` ревизией 2026-06-16 **возвращены к upstream**.
- Поведение **без** `with_awg` не меняется: AWG-конфиг отвергается раньше; для
плоского WG `awgActive=false`/`IsAmneziaWG()==false` → guard'ы no-op.
## 4. Критерии приёмки
- AWG `detour`→WG и AWG `detour`→AWG: узел не поднимается, ошибка в лог; ядро и
прочие узлы живут (вариант B). ✅ field-verified на Android lx.9.
- AWG `detour`→VLESS и WG `detour`→AWG: проходит.
- AWG→X→…→WG (транзитивно по detour): ловится Start-guard'ом; циклы не виснут.
- Переключение селектора на WG-член при AWG-потребителе: AWG suspend **до** выбора;
не-AWG потребители не трогаются; переключение на не-WG ничего не гасит.
- Юнит-тесты зелёные: `awgDetourChainReachesWireGuard` (Start-guard) +
`chainReachesWireGuard`/`suspendAmneziaWGConsumers` (selector-guard).
- `go build ./...` без тегов — ок; сборка с `with_awg` — ок; `gofmt -l` пусто.
## 5. Вне скоупа
- **Гонка selector-guard:** закрыта порядком (гасим до `Store`). Остаётся
теоретический случай, если потребитель переподключается строго между нашим
suspend и его собственным dial в том же тике — практически невозможен, т.к. suspend
синхронен и до коммита выбора.
- **Лечение первопричины** (таймауты/неблокирующая отправка junk в
`submodules/wireguard-go`) — отдельная будущая задача.
- Цепочки через route-rule action, а не `detour` — вне скоупа.
## 6. Ссылки
- Фича [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT)
- LxBox §128 — образец поведения «вариант B» (ленивая detour-ошибка, ядро живёт):
`Leadaxe/LxBox/docs/spec/tasks/128-force-direct-out-detour.md`
- `submodules/wireguard-go/device/send.go` (junk-генерация в SendHandshakeInitiation)
- Образец detour-проверки: `common/dialer/detour.go` (empty-direct, upstream `fb622ccb`)
@@ -1,35 +0,0 @@
# TASKS — 007-B-AWG_OVER_WIREGUARD_DETOUR_GUARD
## Итерация 1 — ленивый dialer-guard (lx.8) — ОТКАЧЕНА
- [x] guard в `common/dialer/detour.go` `init()` + проброс через `dialer.Options`
- [x] **Удалено** (не сработало на устройстве; `common/dialer/{detour,dialer}.go` → upstream)
## Итерация 2 — Start-guard (lx.9) — field-verified
- [x] `protocol/wireguard/endpoint.go`: поля `awgActive`/`detour`/`awgChainBlocked`,
`awgDetourChainReachesWireGuard` (транзитивный обход detour, стоп на группе),
вариант B в `Start` (device не поднимается, `started=false`, лог, `return nil`)
- [x] `awg_start_guard_test.go`: direct/транзитив/нет-WG/селектор-пропуск/цикл/unknown
- [x] Текст ошибки — «amneziawg over wireguard is not supported» (архитектура, не платформа)
## Итерация 3 — selector-guard (рантайм-переключение)
- [x] `adapter/outbound.go`: `OutboundManager.ConsumersOf` + маркер `AmneziaWGSuspendable`
- [x] `adapter/outbound/manager.go`: реализация `ConsumersOf` (reverse-deps под RLock)
- [x] `protocol/wireguard/endpoint.go`: `IsAmneziaWG()` + `SuspendAmneziaWG()` (CAS+лог)
- [x] `transport/wireguard/endpoint.go`: `Suspend()` (device.Down, идемпотентно)
- [x] `protocol/group/selector.go`: гашение **до** `Store` (Swap→Load+Store)
- [x] `protocol/group/awg_selector_guard.go`: `chainReachesWireGuard` + `suspendAmneziaWGConsumers`
- [x] `awg_selector_guard_test.go`: прямой/транзитивный AWG suspend, не-AWG не трогается, не-WG → no-op
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [x] `go test ./protocol/wireguard/... ./protocol/group/...` — зелёные
- [x] `gofmt -l` изменённых lx-файлов — пусто
- [x] `go vet` затронутых пакетов — чисто
- [x] **Field-verified** Start-guard на Android lx.9; selector-guard — юнит-тестами
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, SPEC, DoD
- [x] `SPECS/README.md` roadmap-строка 007
- [x] GH issue #2: комментарии со ссылками на коммиты + закрыт
- [x] Статус → `C` (шапка SPEC.md + Roadmap)
@@ -1,63 +0,0 @@
# IMPLEMENTATION_REPORT — 008 AWG_JUNK_PARAM_VALIDATION
**Дата:** 2026-06-16 · **Статус:** Complete (код + тесты + DoD) · **База:** `v1.13.13`
## Итог
AmneziaWG junk-диапазон с `jmin > jmax` теперь отвергается **при построении
endpoint'а** (`awgIpcLines` → `wireguard.NewEndpoint` → `check`/старт) понятной
ошибкой, вместо рантайм-**паники** в горутине таймера ретрансмита handshake.
Баг **нашей** дельты (merged-форк wireguard-go + AWG-проводка), найден при разборе
[007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) / [issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2)
→ в скоупе (CONSTITUTION §3.1).
## Диагноз
`submodules/wireguard-go/device/send.go:147-149` перед handshake:
```go
nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(jmax-jmin+1)))
```
При `jmin > jmax` аргумент `jmax-jmin+1 <= 0` → `rand.Int` паникует
(`crypto/rand: argument to Int is <= 0`) в timer-горутине → краш. `device/uapi.go`
проверяет `jc/jmin/jmax > 0` по отдельности, но не их связь.
## Решение (узкое — по согласованию с автором)
Валидируем **только** `jmin <= jmax` — ровно краш-кейс. jc-несогласованность
(`jc>0` без размеров → пустой junk; размеры без `jc` → junk не шлётся) **осознанно
не ловим**: безвредна (туннель встаёт, не паникует), а строгое правило (а) сломало
бы существующий тест `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) и
(б) рисковало бы отклонить рабочий awg2-экспорт — против приоритета совместимости
с реальными серверами (CONSTITUTION §2.2) и минимального диффа.
**`transport/wireguard/device_awg.go`** (lx, под `with_awg`):
- `validateJunk(o)` — `if o.Jmin > o.Jmax { return error }`;
- вызов в начале `awgIpcLines` (после `IsSet()`), до рендера IpcSet-строк.
**`transport/wireguard/device_awg_test.go`** (lx, под `with_awg`):
- `TestAwgIpcLinesJminGreaterThanJmax` — `jmin=70 jmax=40` → ошибка (`jmin`/`jmax`
в тексте) + `require.NotPanics`;
- `TestAwgIpcLinesValidJunkRange` — `jc4/jmin40/jmax70` ок, junk-off ок.
## Приёмка (DoD)
- ✅ `go build ./...` без тегов — ок (без `with_awg` AWG отвергается раньше).
- ✅ `go build -tags "...,with_awg" ./cmd/sing-box` — ок.
- ✅ `go test -tags with_awg ./transport/wireguard/...` — зелёный, 7 ipc-тестов
(5 прежних + 2 новых), `TestAwgIpcLinesUnsetHeadersOmitted` не сломан.
- ✅ `gofmt -l` изменённых файлов — пусто.
## Зона касания upstream (для ребейза)
- `device_awg.go` / `device_awg_test.go` — lx-собственные файлы под `with_awg`,
в upstream их нет → конфликтов на ребейзе не дают.
- `submodules/wireguard-go` **не трогали** — guard в `awgIpcLines` ловит раньше,
до того как значения уйдут в устройство.
## Вне скоупа
- Правка панического `rand.Int` в самом `submodules/wireguard-go` (наш guard
делает её ненужной для конфигов, проходящих через `awgIpcLines`).
- jc/размеры-согласованность (см. «Решение» — осознанно не делаем).
- s1–s4 / h1–h4 (h уже валидируются `MagicHeader.Spec()`) / i1–i5.
@@ -1,49 +0,0 @@
# PLAN: 008 — AWG_JUNK_PARAM_VALIDATION
## Архитектура
Одна функция-валидатор `validateJunk(o)` в `transport/wireguard/device_awg.go`,
вызывается в начале `awgIpcLines` (сразу после `IsSet()`-проверки). `awgIpcLines`
уже возвращает `error` и уже на пути `wireguard.NewEndpoint` → endpoint build →
`check`/старт, поэтому ошибка fail-fast доходит до пользователя без паники.
Под тег `with_awg` (файл целиком под ним). Без тега — стаб `device_stub_awg.go`
уже даёт «awg support not built», поведение upstream не меняется.
## Изменённые файлы
| Файл | Зона | Что |
|------|------|-----|
| `transport/wireguard/device_awg.go` | lx (под `with_awg`) | `validateJunk` + вызов в `awgIpcLines` |
| `transport/wireguard/device_awg_test.go` | lx (под `with_awg`) | тесты правил + «не паникует при jmin>jmax» |
## Логика (узкое правило — только краш-кейс)
```go
func validateJunk(o option.AmneziaWGOptions) error {
if o.Jmin > o.Jmax {
return E.New("amneziawg: jmin (", F.ToString(o.Jmin), ") must be <= jmax (", F.ToString(o.Jmax), ")")
}
return nil
}
```
Одна проверка — ровно тот случай, что паникует в `send.go`. jc-несогласованность
осознанно **не** ловим (см. SPEC §3.1): безвредна, и строгое правило сломало бы
существующий тест `jc=4`-без-размеров и рисковало бы отклонить рабочий awg2-конфиг.
`jmin=0,jmax=0` (junk off) → `0>0` false → ок. `jmin>0,jmax=0` → ловится (это либо
краш-риск при jc>0, либо явная опечатка).
## DoD
- [ ] `go build ./...` без тегов — ок
- [ ] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [ ] `go test -tags with_awg ./transport/wireguard/...` — зелёный (вкл. no-panic)
- [ ] `gofmt -l` — пусто
- [ ] существующие `lx-test/config/awg2_*.json` остаются валидными
## Зона касания upstream (для ребейза)
- `device_awg.go` / `device_awg_test.go` — lx-собственные файлы под `with_awg`,
в upstream их нет → конфликтов на ребейзе не дают.
- `submodules/wireguard-go` — **не трогаем** (guard ловит раньше).
-108
View File
@@ -1,108 +0,0 @@
# SPEC: 008 — AWG_JUNK_PARAM_VALIDATION
| Поле | Значение |
|------|----------|
| Тип | B (bug) |
| Статус | C (complete) |
Отклонять невалидную junk-связку AmneziaWG (`jc`/`jmin`/`jmax`) **на уровне
конфига** (при построении endpoint, до handshake), а не падать рантайм-паникой в
горутине таймера. Главный мотив: при `jmin > jmax` вендоренный amneziawg-go
**паникует** (`rand.Int` с аргументом ≤ 0) — это краш ядра, а не управляемая
ошибка.
Баг в фиче [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT).
Найден при разборе [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) /
[issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2). Баг **нашей** дельты
(merged-форк wireguard-go + наша AWG-проводка) → в скоупе (CONSTITUTION §3.1).
---
## 1. Проблема / контекст
Семантика junk (из `submodules/wireguard-go/device/send.go:143-154`):
```go
jc := peer.device.junk.count // сколько junk-пакетов слать перед handshake
jmin := peer.device.junk.min
jmax := peer.device.junk.max
for i := 0; i < jc; i++ {
nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(jmax-jmin+1))) // ← паника при jmax<jmin
n := int(nBig.Int64()) + jmin
buf := make([]byte, n); rand.Read(buf)
sendBuffer = append(sendBuffer, buf)
}
```
- **`jmin > jmax`**: `jmax-jmin+1 <= 0` → `rand.Int` паникует
(`crypto/rand: argument to Int is <= 0`). Происходит в горутине таймера
ретрансмита handshake → **краш**, не ловится валидацией. uapi (`device/uapi.go`)
проверяет только `jc/jmin/jmax > 0` по отдельности, связь — нет.
- **`jc > 0`, но `jmin`/`jmax` не заданы**: цикл шлёт `jc` пустых (нулевого
размера) пакетов — junk фактически не работает, тихая деградация обфускации.
- **`jmin`/`jmax` заданы, `jc == 0`**: цикл не исполняется — размеры заданы
впустую, junk не шлётся. Вероятная ошибка конфига.
Реальные awg2-экспорты задают триаду **всегда вместе** (тест-конфиги:
`jc=4 jmin=40 jmax=70`; `jc=5 jmin=10 jmax=50`), так что правило согласованности
рабочие конфиги не ломает.
## 2. Цель
Невалидная junk-триада отвергается при построении endpoint'а с **понятной
ошибкой** (`./sing-box check` / старт фейлится управляемо), вместо рантайм-паники
позже. Это **fail-fast на уровне конфига** — в отличие от [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD)
(связка узлов, ловится лениво в dialer): здесь невалидно **одно** поле-сочетание
одного endpoint'а, видно сразу.
## 3. Требования
### 3.1 Правило (узкое — только краш-кейс)
- **`jmin <= jmax`** — единственная проверка. При `jmin > jmax` амнезия-форк
паникует (`rand.Int` arg ≤ 0) → краш; это и чиним.
**Осознанно НЕ валидируем** (решение автора, рекомендация — минимальный дифф):
- `jc > 0` без jmin/jmax — шлёт пустые junk-пакеты: бесполезно, но **не
паникует**, туннель поднимается. Расширять guard на это — навязывать мнение о
конфиге и рисковать отклонить рабочий чужой awg2-экспорт (CONSTITUTION §2.2,
совместимость с реальными серверами).
- размеры без `jc` — junk не шлётся, безвредно.
Существующий тест `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) при
узком правиле **остаётся валиден** — чужое осознанное решение не ломаем.
### 3.2 Точка проверки
- `transport/wireguard/device_awg.go` `awgIpcLines` — она уже возвращает `error`
и уже вызывается из `wireguard.NewEndpoint` (→ `NewEndpoint` outbound →
`check`/старт). Ошибка доходит до пользователя **до** handshake, без паники.
- Под тег `with_awg` (как и весь `awgIpcLines`). Без тега AWG-конфиг и так
отвергается раньше («awg support not built») — поведение upstream не меняется.
### 3.3 Изоляция
- Правка — в существующем lx-файле `device_awg.go` (он целиком lx, под тегом).
Новых upstream-швов не добавляем. Тест — в `device_awg_test.go` (уже lx).
## 4. Критерии приёмки
- `jmin > jmax` → endpoint не строится, ошибка с упоминанием `jmin`/`jmax`; **нет
паники** (`require.NotPanics`).
- Валидная триада (`jc=4 jmin=40 jmax=70`), `jc=4` без размеров, и junk-off —
проходят. Тест-конфиги `awg2_basic`/`awg2_ranged` валидны; существующий тест
`TestAwgIpcLinesUnsetHeadersOmitted` не сломан.
- Юнит-тест зелёный; сборка с `with_awg` ок; `go build ./...` без тегов ок;
`gofmt -l` пусто.
## 5. Вне скоупа
- Правка самого `submodules/wireguard-go` (добавить проверку в uapi/убрать
панику) — наш guard в `awgIpcLines` ловит раньше, форк не трогаем (его правка —
отдельный осознанный шаг, CONSTITUTION §4 про сабмодуль).
- Валидация s1–s4 / h1–h4 / i1–i5 (h-поля уже валидируются `MagicHeader.Spec()`).
- MTU/размерные предупреждения — уже есть в `wireguard.NewEndpoint`.
## 6. Ссылки
- `submodules/wireguard-go/device/send.go:143-154` (junk-цикл, паника)
- `submodules/wireguard-go/device/uapi.go:310-341` (uapi: только `>0`, связи нет)
- `transport/wireguard/device_awg.go` `awgIpcLines` (точка проверки)
- [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) / [issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2) — где баг найден
@@ -1,21 +0,0 @@
# TASKS — 008-B-AWG_JUNK_PARAM_VALIDATION
## Код
- [x] `device_awg.go`: `validateJunk(o)` — единственное правило `jmin <= jmax`
- [x] Вызвать `validateJunk` в начале `awgIpcLines` (после IsSet)
## Тест
- [x] `device_awg_test.go`: jmin>jmax → ошибка + `require.NotPanics`; валидная триада `jc4/jmin40/jmax70` ок; junk-off (только header) ок
- [x] Существующий `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) не сломан
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [x] `go test -tags with_awg ./transport/wireguard/...` — зелёный (7 ipc-тестов)
- [x] `gofmt -l` изменённых lx-файлов — пусто
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, DoD
- [ ] `SPECS/README.md` roadmap-строка 008
- [ ] Коммит (со ссылкой), затем GH issue + комментарий с ссылкой на коммит + закрыть
- [ ] Папка `008-B-O-…` → `008-B-C-…`
@@ -1,242 +0,0 @@
# EXAMPLES — WireSock-style маскировка `id` / `ip` / `ib`
Практический how-to по полям маскировки фичи 009. Это сахар над AmneziaWG `i1`:
вместо ручной CPS-строки `i1=<b 0x...>` пишешь домен/протокол/браузер, а движок
сам собирает пакет-приманку нужного протокола и шлёт его как `i1` перед handshake.
> Требуется сборка с `with_awg`. Без тега любой `id`/`ip`/`ib` отвергается:
> `AmneziaWG (awg) support is not included in this build, rebuild with -tags with_awg`.
---
## 1. Три поля
| Поле | Имя | Значения | Обязательно |
|------|-----|----------|-------------|
| `id` | домен | LDH-хост (`www.google.com`, `ozon.ru`, `_dmarc.example.com`) | **обязателен только для `quic`** (SNI); опционален для `dns` (QNAME или псевдо-домен), `sip` (host или псевдо-host) и `stun` (игнорируется) |
| `ip` | протокол | `quic` \| `dns` \| `stun` \| `sip` | да |
| `ib` | браузер | `chrome` \| `firefox` \| `curl` | нет (только при `ip=quic`) |
Минимум: `ip` всегда; плюс `id` — для `quic`. Для `dns`/`sip`/`stun` хватает одного `ip`
(`id` опционален: для `sip` без него генерируется псевдо-host, для `stun` он не идёт в пакет).
Если `id` задан — он всегда валидируется (LDH).
### Куда `id` реально попадает на провод
| `ip` | пакет-приманка | `id` виден цензору? |
|------|----------------|---------------------|
| `dns` | EDNS OPT query (QR=0), `id` = **QNAME** | **да**, открытым текстом |
| `sip` | SIP INVITE request, `id` = **host** в URI (или псевдо-host) | **да** (если задан), открытым текстом |
| `quic` | фрагментированный QUIC Initial, `id` = **SNI** в ClientHello | **да** — цензор выводит ключи из DCID и читает SNI (если соберёт фрагменты по порядку) |
| `stun` | STUN Binding Success Response | **нет** — в STUN нет поля под домен |
**Вывод:** `ip=dns`/`ip=sip`/`ip=quic` несут домен на провод (`id` как QNAME /
SIP-host / SNI соответственно). `stun` даёт приманку без домена (маскировка по
*форме* протокола, а не по имени хоста).
---
## 2. Примеры по профилям
Во всех примерах опущены `log`/`outbounds`/`route` — оставлены только поля
endpoint'а. Подставь свои `private_key` / `peers` / `address`.
### 2.1 QUIC (под Cloudflare WARP)
```jsonc
{
"type": "wireguard",
"tag": "warp",
"mtu": 1280,
"address": ["172.16.0.2/32", "2606:4700:110:8000::2/128"],
"private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "www.google.com",
"ip": "quic",
"ib": "chrome",
"peers": [
{
"address": "engage.cloudflareclient.com",
"port": 2408,
"public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"allowed_ips": ["0.0.0.0/0", "::/0"],
"persistent_keepalive_interval": 25
}
]
}
```
Генерирует **out-of-order фрагментированный QUIC Initial** (RFC 9001): реалистичный
ClientHello, где `id` (`www.google.com`) — это **SNI**, разбитый на CRYPTO-фреймы,
которые идут на провод **не по порядку** — первый CRYPTO-фрейм имеет offset≠0, а
offset-0 фрейм лежит ближе к концу, с интерливом PING/PADDING. Line-rate DPI хватает
первый фрейм, считает его offset 0, парсит мусор и пропускает (fail-open). `ib`
валидируется (`chrome|firefox|curl`), но на байты пакета не влияет — bypass держится
на фрагментации, а не на TLS-fingerprint (JA3 не имитируется). Генератор —
`quic_initial_awg.go`, `quic_clienthello_awg.go`, `quic_crypto_awg.go`.
`id` для `quic` **обязателен** (он становится SNI в ClientHello). Минимальный
вариант — `"id": "<домен>", "ip": "quic"` (плюс опциональный `"ib"`):
```jsonc
{ /* ...endpoint... */ "jc": 4, "jmin": 40, "jmax": 70, "id": "www.google.com", "ip": "quic", "ib": "chrome" }
```
### 2.2 DNS (домен виден на проводе)
```jsonc
{
"type": "wireguard",
"tag": "awg-dns",
"mtu": 1280,
"address": ["10.0.0.2/32"],
"private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "www.google.com",
"ip": "dns",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует клиентский DNS **query** (~87 байт): DNS-заголовок (QR=0, RD=1), вопрос с
QNAME = `www.google.com` и QTYPE HTTPS (тип 65), OPT RR (TYPE 41, UDP-size 1232) и
случайные cover-байты как opaque-данные неизвестной EDNS-опции `0xFDE9`. Парсится как один
валидный DNS-запрос. **`id` тут — QNAME, виден цензору.**
> **Не подтверждён на WARP-DPI.** На тестовом LTE/WARP DPI `ip=dns` — Timeout (как `stun`):
> DPI режет DNS/STUN к WARP-edge `:2408` как класс протокола (raw DNS живёт на :53, а не на
> дата-центровом IP). Для WARP используй `ip=quic`. `ip=dns` оставлен для других провайдеров,
> чей DPI проверяет только корректность пакета.
### 2.3 STUN
```jsonc
{
"type": "wireguard", "tag": "awg-stun", "mtu": 1280,
"address": ["10.0.0.2/32"], "private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "stun.l.google.com",
"ip": "stun",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует STUN Binding Success Response (52 байта): type `0x0101`, magic cookie
`0x2112A442`, случайный transaction ID, XOR-MAPPED-ADDRESS + SOFTWARE. **`id`
валидируется, но в STUN-пакет не попадает** (поля под домен нет).
### 2.4 SIP (домен виден на проводе)
```jsonc
{
"type": "wireguard", "tag": "awg-sip", "mtu": 1280,
"address": ["10.0.0.2/32"], "private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "pbx.example.com",
"ip": "sip",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует начало SIP-звонка из **двух пакетов** одного диалога: **i1 — полный INVITE**
(`INVITE sip:<user>@pbx.example.com SIP/2.0`, Via(branch)/To/From(tag)/Call-ID/CSeq:N INVITE/
Max-Forwards:70/Contact, `Content-Length: 0`, без SDP) и **i2 — `SIP/2.0 100 Trying`** (тот же
диалог: общий branch/tag/Call-ID/CSeq). Каждый пакет валиден сам по себе (UDP не реассемблируется).
Имена пользователей — произносимые псевдо-строки (не захардкожены, не RFC-маяк alice/bob).
**`id` — host в URI, виден цензору.** `id` для sip **опционален**: без него генерируется
правдоподобный псевдо-host.
> **Нужен `junk`.** Профиль рассчитан на `jc/jmin/jmax > 0` (в примере выше заданы) — SIP-декои
> уходят вместе с junk-пакетами в одном пред-handshake-залпе.
>
> **Статус на WARP-DPI: ожидает проверки.** Почему прежде `dns`/`stun`/`sip` упирались в Timeout —
> точно не установлено. По подсказке `sip` переведён на пару INVITE + 100 Trying (стандартный
> call-setup) с junk; заработает ли против WARP — проверяется на устройстве. `ip=quic` остаётся
> подтверждённо рабочим.
---
## 3. Что выбрать
- **Коннект к WARP под реальным DPI** → `ip=quic`, `id=<популярный домен>`,
`ib=chrome`. Фрагментированный QUIC Initial с `id` как SNI; device-proven против
реального LTE-DPI.
- **Нужно, чтобы DPI увидел «разрешённый» домен** → `ip=quic`/`ip=dns`/`ip=sip` с
региональным популярным `id` (SNI / QNAME / SIP-host).
- **STUN** — нишево (выглядит как ответ STUN-сервера); домен не несёт.
---
## 4. Проверка
```sh
# сборка со всеми нужными тегами
go build -tags "with_wireguard with_gvisor with_awg" -o ./sing-box ./cmd/sing-box
# проверка конфига
./sing-box check -c config.json # пусто = ок
```
Соответствие `awg.conf` (WireSock/awg-quick): поля `id`/`ip`/`ib` — это аналог
`I1` из `[Interface]`, только в декларативной форме. Нельзя задавать `i1` и
`id`/`ip`/`ib` одновременно.
---
## 5. Частые ошибки (дословные сообщения)
| Конфиг | Ошибка |
|--------|--------|
| `i1` **и** `id`/`ip`/`ib` вместе | `amneziawg: id/ip/ib masquerade conflicts with an explicit i1; use one or the other` |
| `id` есть, `ip` нет | `amneziawg: ip (masquerade protocol) is required when id/ib is set; one of quic\|dns\|stun\|sip` |
| `ip` не из набора | `amneziawg: unknown masquerade protocol "ftp"; one of quic\|dns\|stun\|sip` |
| `ip=quic` без `id` | `amneziawg: id (masquerade domain) is required for ip=quic (it becomes the ClientHello SNI)` |
| `ip=dns` без `id` | **не ошибка** — генерится псевдо-домен (QNAME) |
| `ip=sip` без `id` | **не ошибка** — `id` опционален, генерируется псевдо-host |
| `ip=stun` без `id` | **не ошибка** — `id` опционален, decoy без домена |
| домен с `\r\n`/`;`/`@`/пробелом | `amneziawg: invalid masquerade domain "...": illegal character (only a-z A-Z 0-9 - _ allowed)` |
| `ib` не из набора | `amneziawg: unknown masquerade browser "safari"; one of chrome\|firefox\|curl` |
| `ib` с `ip≠quic` | `amneziawg: ib (browser) is only meaningful with ip=quic, got ip="dns"` |
| без `with_awg` | `AmneziaWG (awg) support is not included in this build, rebuild with -tags with_awg` |
Домен проходит **строгую LDH-валидацию** (как SNI у WireSock): метки из
`a-z A-Z 0-9 - _`, ≤63 байта, без дефиса по краям; всё имя ≤253, без точки в начале;
одна точка в конце допускается. Это security-граница — домен идёт в текст SIP и в
DNS QNAME, поэтому control-байты и метасимволы отвергаются (защита от инъекции).
---
## 6. Ограничения
- Это **decoy перед handshake**, не полноценная протокол-сессия. Один пакет нужной
формы, дальше — обычный AWG-трафик.
- **QUIC раскрывает SNI** (фрагментированный Initial): `id` идёт на провод как
SNI в ClientHello. `stun` домен не несёт (см. §1). DPI-обход — за счёт out-of-order
фрагментации CRYPTO-фреймов (line-rate DPI парсит первый фрейм как offset 0, видит
мусор и пропускает), не за счёт сокрытия SNI.
- **`ib` валидируется, но на байты пакета не влияет.** JA3/JA4-fingerprint не
имитируется: DPI-обход держится на out-of-order фрагментации CRYPTO-фреймов, а не на
TLS-fingerprint. `ib` принимается для совместимости синтаксиса с WireSock-конфигами.
- Байт-в-байт replay именно WireSock-трафика **не** делается: энтропия наша
(криптослучайная `<r>`, для QUIC — свежие DCID/random/x25519 на вызов), а не
payload-seeded PRNG (это standalone I1, а не серверный S1–S4 padding).
- Полевая проверка: `ip=quic` (фрагментированный Initial) **device-proven против
реального LTE-DPI**. Для `dns`/`stun`/`sip` систематическая полевая A/B-проверка
против конкретного DPI не проводилась — подтверждены приём движком, структурная
валидность и `sing-box check`.
См. также: [SPEC.md](SPEC.md),
[IMPLEMENTATION_REPORT.md](IMPLEMENTATION_REPORT.md),
краткая версия — `docs-lx/lx-config.md` (секция «Masquerade id/ip/ib»).
@@ -1,180 +0,0 @@
# IMPLEMENTATION_REPORT — 009 WIRESOCK_MASQUERADE_PROFILES
**Статус:** Closed. Код + тесты + DoD; device-smoke на активном DPI пройден (туннель + трафик).
**Коммиты:** `51d5cff1` (id/ip/ib + dns/stun/sip + QUIC), `64ce4a47` (QUIC → out-of-order
фрагментированный Initial).
---
## Принятые решения
### Р1. Механизм — I1 CPS, не S1–S4
`Id/Ip/Ib` разворачиваются в `i1` CPS-строку в option/transport-слое; сабмодуль
`submodules/wireguard-go` не трогается. S1–S4 padding отвергнут: init/response должны
оставаться бит-в-бит как plain WG, иначе Cloudflare WARP отвергает handshake.
Код: `masqueI1` — `transport/wireguard/masque_awg.go:64`; проводка в `awgIpcLines`
(`device_awg.go`); decoy шлётся `Obfuscate(buf, nil)` (`submodules/wireguard-go/device/send.go:135`).
### Р2. QUIC — out-of-order фрагментированный Initial
`ip=quic` эмитит полный QUIC Initial (RFC 9001) с реалистичным ClientHello (SNI=`Id`),
нарезанным на 6 CRYPTO-фреймов в перемешанном порядке: первый wire-фрейм `offset≠0`,
`offset=0` почти в конце, PING/PADDING между ними (инварианты I1–I4). Line-rate DPI берёт
первый фрейм как `offset 0`, парсит середину ClientHello как начало → мусор → fail-open;
настоящий сервер реассемблирует по offset. Причина выбора: 1-RTT short header был
эмпирически заблокирован реальным LTE-DPI; фрагментированный Initial device-proven против
того DPI.
Код: диспетч `masque_awg.go:97`; `masqueQUICInitialCPS` — `quic_initial_awg.go:384`;
`buildInitialPacket` — `quic_initial_awg.go:319`; frame-план `etalonWirePlan` —
`quic_initial_awg.go:139`; cutpoints `etalonCutpoints` — `quic_initial_awg.go:128`.
### Р3. Крипта — копия RFC 9001-примитивов из qtls, не своя реализация
HKDF-Expand-Label / QUIC v1 salt / AES-128-GCM-XOR-nonce AEAD скопированы байт-в-байт из
`common/sniff/internal/qtls/qtls.go` (этот `internal/` не импортируется из `transport/`,
поэтому копия, а не импорт) → выводимые ключи совпадают с боевым снифером. Единственный
недостающий примитив — varint-энкодер `appendQUICVarint` (RFC 9000 §16), написан вручную.
Код: `transport/wireguard/quic_crypto_awg.go` (`quicHKDFExpandLabel`, `quicAEADAESGCMTLS13`,
`quicSaltV1`); `deriveInitialKeys` — `quic_initial_awg.go:269`; `encryptInitial` (шифрование +
header protection) — `quic_initial_awg.go:287`.
### Р4. `Id` обязателен только для quic
`Id` идёт на провод как SNI (quic) → обязателен только для `quic`; пустой при quic
отвергается на `sing-box check`. Опционален для `sip` (пуст → псевдо-host) и `stun`
(hostname-less). Заданный `Id` всегда LDH-валидируется.
dns/sip при пустом `Id` генерируют псевдо-имя (PseudoGen), stun его игнорирует. Код: guard `masque_awg.go` (quic ветка); LDH-валидатор `validateMasqueDomain`
(security-граница, зеркало `is_valid_sni_hostname`).
### Р5. flex-PADDING — payload пинится к length-полю при любой длине SNI
Один PADDING-run в frame-плане помечен `padFlex` и вычисляется как остаток до payload-таргета
→ payload всегда ровно 1232 (length-поле) для любой длины ClientHello (длинный домен удлиняет
CH и укорачивает flex-run). Без этого валидный домен >77 символов переполнял фиксированную
раскладку и ронял генерацию (находка адверсариального ревью).
Код: `planEntry.padFlex` — `quic_initial_awg.go:115`; `buildInitialPayload` — `quic_initial_awg.go:212`;
ClientHello добивается до ≥294б padding-расширением (`quicCHTargetLen=294`, `quicCHMinLen=291`) —
`quic_clienthello_awg.go:31,35`.
### Р6. ClientHello — реалистичный, без JA3-имитации
TLS 1.3 ClientHello: непустой cipher_suites, key_share (реальный ephemeral x25519,
`ecdh.X25519().GenerateKey` — `quic_initial_awg.go:330`), ALPN "h3", quic_transport_params,
supported_versions, GREASE `0x0a0a` (RFC 8701 — `quic_clienthello_awg.go:117`). Конкретный
браузерный JA3/JA4 не имитируется: bypass держится на фрагментации, не на fingerprint. `Ib`
валидируется (`normalizeMasqueBrowser` — `masque_awg.go:183`), но на байты пакета не влияет.
Код: `buildClientHello` — `quic_clienthello_awg.go:67`.
### Р7. sip — начало звонка: INVITE (i1) + 100 Trying (i2), один диалог
`ip=sip` эмитит **начало SIP-звонка из двух самостоятельных пакетов** одного диалога (RFC 3261
§17 call-setup), `sip_invite_awg.go`: i1 = полный INVITE **request** (`masqueSIPInviteCPS`):
request-line `INVITE sip:<user>@<host> SIP/2.0`, Via(branch=z9hG4bK)/To(без tag)/From(tag)/Call-ID/
CSeq:N INVITE/Max-Forwards:70/Contact/Content-Type: application/sdp/`Content-Length: 0`, пустая
строка — **без SDP-тела**; i2 = полный `SIP/2.0 100 Trying` provisional response
(`masqueSIPTryingCPS`): статус-строка + те же Via/To/From/Call-ID/CSeq + `Content-Length: 0`.
Каждый пакет — валидное самодостаточное SIP-сообщение: UDP не реассемблируется, пакетный DPI
смотрит каждую датаграмму отдельно. **Один диалог**: Via branch / From tag / Call-ID / CSeq
идентичны в i1 и i2 — общий диалог строит `newSIPDialog(domain)` одним проходом, оба слота
наполняет диспетчер `masqueI1I2`. Значения запечены в `<b>` на сборке (НЕ per-packet `<rc>`/`<rd>`),
уникальны между юзерами. Имена пользователей (display+local) и host (при пустом `Id`) —
произносимые псевдо-строки через `PseudoGen` (`pseudo_gen_awg.go`, портирован из LxBox §127),
свежие на генерацию; **не** хардкод RFC-примера `alice@atlanta.com`/`bob@biloxi.com` (публичный
DPI-маяк). Профиль **требует junk** (`jc/jmin/jmax > 0`): декои уходят вместе с junk-пакетами в
одном пред-handshake-залпе. `Id` опционален для sip (пуст → `pgHost()`). Прежняя одиночная форма
(один INVITE с SDP, затем короткая версия «фрагментация INVITE на head→i1 + SDP→i2») — заменена.
(DNS см. Р10.)
### Р8. Рандомизация QUIC-раскладки + robustness-ручки
Раскладка фрейм-плана генерится на каждый вызов: случайные точки разреза
(`planFragmentsN`) и случайный out-of-order порядок (`randomizedWirePlan`), при этом I1–I4
держатся по построению (перестановка фрагментов + ремонт «offset-0 не первый» + flex-PADDING).
Убирает фиксированную межюзерную сигнатуру (раньше был зашит `etalonWirePlan`, удалён).
`quicGenParams` (`defaultQUICGenParams` — 6 фрагментов, 2 PING, 1250б) — ручки эскалации без
правки кода: число фрагментов/PING и диапазон размера датаграммы; length-поле/payload
пересчитываются от размера. Код: `quic_initial_awg.go` (`quicGenParams`, `planFragmentsN`,
`randomizedWirePlan`, `pickTotalLen`). Стресс-тест: 300 случайных пакетов держат I1–I4.
### Р9. STUN — Binding Request вместо Response
`ip=stun` теперь эмитит WebRTC Binding **Request** (`0x0001`): USERNAME, ICE-CONTROLLING,
PRIORITY, SOFTWARE=`libwebrtc`, MESSAGE-INTEGRITY (HMAC-SHA1 по случайному ICE-ключу),
FINGERPRINT (CRC-32). Причина: Success Response (`0x0101`), посланный клиентом первым и без
запроса — аномалия направления; Request — то, что ICE-клиент шлёт первым. Свежий
txn/ufrag/ключ на вызов. Код: `stun_request_awg.go` (`buildSTUNBindingRequest`,
`masqueSTUNRequestCPS`); диспетч `masque_awg.go:103`. Старый `masqueSTUNResponseCPS` удалён.
**Device-результат:** ни Binding Request, ни полный WebRTC-вариант с MESSAGE-INTEGRITY **не
прошли** тестовый LTE/WARP DPI (Timeout, тогда как `quic` ✅ ~340 мс). См. общий вывод после Р10.
### Р10. DNS — query вместо response
`ip=dns` теперь эмитит клиентский DNS **query** (`masqueDNSQueryCPS`, `masque_awg.go`): flags
`0x0100` (QR=0, RD=1), QNAME=`Id`, QTYPE **HTTPS** (`0x0041`), OPT RR с cover-байтами в опции
`0xFDE9`. Причина: прежний response (`0x8180`, QR=1) — аномалия направления (ответ без запроса в
слоте клиента), как у STUN. Правка от прежнего кода — только flags и QTYPE; `encodeDNSName`/OPT/
cover переиспользованы. TXID/cover свежие на пакет (`<r 2>`/`<r 40>`).
**Device-результат:** DNS query — **Timeout** (как STUN). SIP (Р7, двухпакетный INVITE+100 Trying)
**ожидает проверки на устройстве** — причина прошлых таймаутов точно не установлена, сейчас
проверяем гипотезу с multi-packet-формой и junk.
**Общий вывод (Р9+Р10).** Качество пакета и направление (request vs response) вторичны; решает
триплет **(протокол + назначение)**. DPI режет STUN/DNS/SIP к WARP-edge `162.159.x:2408` как
класс протокола — raw STUN/DNS/SIP к дата-центровому IP сами по себе аномальны (DNS живёт на
:53, STUN — на STUN-сервере). `quic` обходит проверку назначения: QUIC/HTTP3 легитимно идёт
куда угодно, поэтому QUIC к Cloudflare-IP — ожидаемый трафик. `quic` — единственный проверенный
рабочий механизм здесь; `dns`/`stun`/`sip` реализованы в правильной клиент-инициированной форме
и сохранены для других провайдеров (DPI без проверки протокол-к-назначению).
### Р11. Многопакетный QUIC (i1+i2) — рассмотрен и ОТКЛОНЁН
Был реализован вариант «два независимых Initial» (i1+i2) и device-проверен как безопасный для
WARP-handshake (туннель без регресса латентности). Затем **отклонён** как концептуально неверный:
каждый DCID — отдельное QUIC-соединение, поэтому два Initial с разными DCID читаются как два
*брошенных* соединения, что для DPI с DCID-tracking более аномально, не менее. Настоящее
«продолжение» невозможно (short-header device-blocked; 1-RTT до ответа сервера — невозможное
состояние). Итог: `ip=quic` = **один** фрагментированный Initial с браузер-точным ClientHello (§4);
`masqueI1I2` для quic возвращает i2="", `masqueQUICSecondInitialCPS` удалён. Безопасность slots-механизма
(send.go: decoy перед независимым `MessageInitiation`) остаётся актуальной для sip i1+i2 (Р7).
### Р12. `Ib` → реальный браузерный JA3 через uTLS
`ib=chrome`/`firefox` теперь строит ClientHello через uTLS (`github.com/metacubex/utls`, тот же,
что у Reality) в QUIC-режиме (`UQUICClient` + `HelloChrome_120`/`HelloFirefox_120`) → настоящий
браузерный JA3/JA4. ALPN форсируется в `h3`, PQ-гибрид key_share (`X25519MLKEM768`) удаляется (не
влез бы в один Initial; паттерн из `reality_client.go`). Решение «`ib` опционален»: `ib=""`/`curl`
→ generic device-proven ~294б CH (дефолт не трогаем); `ib=chrome`/`firefox` → uTLS (~510–620б).
Build-tag split: `quic_clienthello_utls_awg.go` (`with_utls`) / `…_stub_awg.go` (`!with_utls` →
fallback на generic). Фрагментация (`planFragmentsN`) режет CH любой длины — I1–I4 держатся.
Это задел против будущего JA3/JA4-DPI; на текущем DPI `ip=quic` проходит на фрагментации, поэтому
uTLS-вариант сам по себе device не верифицирован, а дефолт `ib=""` остаётся проверенным.
---
## Верификация
- **§5-векторы обратным разбором** (`quic_initial_awg_test.go`): AEAD-тег сходится; ≥6 CRYPTO
+ ≥1 PING + PADDING; первый offset≠0; реассембл в валидный ClientHello, SNI=`Id`; размер
1250 / length 1232; уникальность DCID+random; длинный домен генерируется.
- **Cross-check боевым снифером:** сгенерированный Initial парсится `common/sniff/quic.go`
(SNI извлечён, классификация chromium).
- **Рандомизация QUIC** (`quic_initial_awg_test.go`): `TestQUICInitialRandomizedInvariants`
(80 сэмплов, I1–I4 + offset'ы различаются) и `TestQUICInitialRobustnessKnobs` (4/10/12
фрагментов, переменный размер).
- **STUN Request** (`masque_awg_test.go`): `TestMasqueSTUNRequestStructure` (тип `0x0001`,
атрибуты тайлят сообщение, FINGERPRINT CRC-32 сходится, USERNAME+MESSAGE-INTEGRITY есть),
`TestMasqueSTUNRequestUniqueness`.
- **dns/sip + валидация** (`masque_awg_test.go`, `masque_cps_test.go`): `TestMasqueDNSQueryStructure`
(QR=0, QNAME round-trips, QTYPE HTTPS, OPT до конца); `TestMasqueSIPInviteStructure` +
`TestMasqueSIPInviteNoID` проверяют пару i1 INVITE (`Content-Length: 0`, без SDP) + i2
`100 Trying` и согласованность диалога (`assertSIPInvite`/`assertSIPTrying`/`assertSameSIPDialog`:
request-line INVITE, To без tag, общий branch/tag/Call-ID/CSeq, имена не захардкожены, пустой
id → псевдо-host); инъекция домена отвергается; конфликт с `i1` /
неизвестный ip/ib / пустой id для quic — ошибки.
- **Адверсариальный ревью** (workflow): подтверждённые находки исправлены (flex-PADDING для
длинного SNI — Р5; GREASE `0x4469`→`0x0a0a` — Р6; response→request для STUN — Р9; SIP missing
Contact / static caller@ — закрыто request-формой с Contact + PseudoGen-именами в Р7).
- `go build` (с тегами и без) ок; `go test -tags with_awg ./transport/wireguard/...` зелёный;
`gofmt -l` lx-файлов пусто; `sing-box check` на quic/dns/stun/sip ок (dns/sip/stun без id тоже), пустой id для quic
отвергнут; gating без `with_awg` → «awg support not built».
- **Device-smoke:** узел `ip=quic` с фрагментированным Initial поднимает туннель и проводит
реальный трафик через активный DPI.
---
## Зона касания upstream (для ребейза)
Все новые файлы под `with_awg` (в upstream их нет) → конфликтов на ребейзе не дают.
`option/wireguard_awg.go` — lx-файл целиком. `device_awg.go` — lx-файл под тегом. Сабмодуль
`submodules/wireguard-go` не трогали.
@@ -1,376 +0,0 @@
# SPEC: 009 — WIRESOCK_MASQUERADE_PROFILES
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Декларативные поля маскировки **`Id` / `Ip` / `Ib`** (домен / протокол / браузер) —
из [WireSock Secure Connect](https://www.wiresock.net/) — которые **на уровне конфига
разворачиваются в AmneziaWG `I1` CPS-строку**. Вместо ручного `i1=<b 0x...>` пользователь
пишет осмысленные поля, а движок собирает пакет-приманку нужного протокола.
Расширение [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT)
и [005 AWG2_RANGED_MAGIC_HEADERS](../005-AWG2_RANGED_MAGIC_HEADERS).
Тег `with_awg`; новые файлы в зонах lx; сабмодуль не трогается.
---
## 1. Что это
`Id/Ip/Ib` — декларативная обёртка над `I1`. На корне endpoint'а пользователь задаёт:
```jsonc
{ "type": "wireguard", /* ... */ "id": "www.google.com", "ip": "quic", "ib": "chrome" }
```
и получает сгенерированную `i1`-строку, как если бы вписал её руками. Новый рантайм в
device не добавляется — генерация целиком в option/transport-слое.
| Поле | Имя | Значение |
|------|-----|----------|
| `Id` | **Domain** | домен для маскировки (массовый легитимный: `www.google.com`, `ozon.ru`…). Идёт на провод как SNI / QNAME / SIP-host |
| `Ip` | **Protocol** | протокол маскировки: **quic** \| **dns** \| **stun** \| **sip** |
| `Ib` | **Browser** | `chrome` \| `firefox` \| `curl`. Валидируется; на сгенерированный пакет не влияет (нет JA3-имитации — см. §4) |
> Нейминг проприетарный WireSock (`i`nterface **d**omain/**p**rotocol/**b**rowser); `ip` —
> это «protocol», НЕ IP-адрес. Эти ключи понимают только WireSock и это ядро; меняться
> не могут (контракт на входе). Результат же — стандартный AmneziaWG `i1` CPS-тег.
---
## 2. Механизм — I1 CPS
Генерируется CPS-строка в option/transport-слое; device-стек не меняется, сабмодуль
`submodules/wireguard-go` не трогается. Путь: option → `masqueI1` → `awgIpcLines` →
vendored `obf.go` (`newObfChain`). `I1`-пакет шлётся приманкой перед handshake с
`Obfuscate(buf, nil)` (src=nil, реальных данных нет — `send.go:135`).
Семантика CPS-движка (`submodules/wireguard-go/device/obf.go`): `<b 0xHEX>` статичные
байты · `<r N>` N криптослучайных байт · `<rc N>` ASCII-буквы · `<rd N>` цифры. Decoy
самодостаточен — это `<b>`-скелет плюс, где нужно, `<r>/<rc>/<rd>`-энтропия.
S1–S4 padding не используется: он невозможен против Cloudflare WARP (init/response
должны оставаться бит-в-бит как plain WG, иначе сервер отвергает handshake), ради
упрощения коннекта к которому фича и существует.
---
## 3. Профили
Все профили — собственные клиент-инициированные генераторы: `quic` — фрагментированный QUIC
Initial (§3.1), `stun` — WebRTC Binding **Request** (§3.2), `dns` — клиентский DNS query (§3.3),
`sip` — начало звонка: INVITE (i1) + 100 Trying (i2), два самостоятельных пакета одного диалога
(§3.2). Структура — стандартный SIP call-setup (RFC 3261 §17). LDH-валидатор домена совпадает с
WireSock-референсом ([`amneziawg-install`](https://github.com/wiresock/amneziawg-install), MIT),
`quic_handshake.rs::is_valid_sni_hostname`.
> **Device-результат (тест-телефон, LTE с активным DPI):** на момент прошлых прогонов проходил
> **только `quic`** (~340 мс); `stun` (Binding Request и полный WebRTC-вариант с
> MESSAGE-INTEGRITY) и `dns` (query QR=0, QTYPE HTTPS) — **Timeout**. `sip` тогда был одиночным
> пакетом и не проверялся отдельно.
>
> **Гипотеза, которую мы сейчас проверяем.** Почему `dns`/`stun`/`sip` упирались в Timeout,
> точно **не установлено**. Рабочая гипотеза была «DPI режет STUN/DNS/SIP к WARP-edge
> `162.159.x:2408` как класс протокола» (raw STUN/DNS/SIP к дата-центровому IP аномальны по
> назначению), но это не доказано. По подсказке `sip` переведён на **multi-packet i1+i2**:
> i1 = полный INVITE, i2 = полный `100 Trying` того же диалога (стандартный call-setup), оба —
> самостоятельные валидные SIP-пакеты, и профиль рассчитан на работу с `junk`. Так поток читается
> как начало реального звонка, а не одиночный опенер. Заработает ли это против WARP —
> **ожидает device-проверки**; `quic` остаётся подтверждённо рабочим механизмом, `dns`/`stun`
> реализованы в правильной клиент-инициированной форме и сохранены для проверки/других провайдеров.
### 3.1 QUIC — out-of-order фрагментированный Initial
`ip=quic` эмитит полный **QUIC Initial (RFC 9001)** с реалистичным браузерным
ClientHello, где `Id` идёт как **SNI**. ClientHello нарезан на CRYPTO-фреймы, выложенные в
payload в **перемешанном (out-of-order) порядке**: первый CRYPTO-фрейм на проводе имеет
`offset≠0`, фрейм с `offset=0` — не первый, между CRYPTO-фреймами вставлены PING и PADDING.
**Раскладка рандомизируется на каждый вызов** (случайные точки разреза + случайный
out-of-order порядок), при сохранении инвариантов I1–I4 — так нет фиксированной
межюзерной сигнатуры. Параметры robustness (`quicGenParams`: число фрагментов, число PING,
диапазон размера датаграммы) — «ручки» для эскалации обфускации без правки кода, если DPI
поумнеет (напр. начнёт держать reassembly-буфер → больше фрагментов). По умолчанию —
device-проверенная база: 6 фрагментов, 2 PING, 1250б.
**Почему так.** Настоящий QUIC-сервер реассемблирует CRYPTO-фреймы по offset до TLS-парсинга;
line-rate DPI reassembly-буфер не держит — берёт первый CRYPTO-фрейм, считает, что он с
offset 0, и парсит TLS оттуда. При первом фрейме `offset≠0` DPI парсит середину ClientHello
как начало → длины TLS-записи не сходятся → парс прерывается → DPI пропускает (fail-open:
настоящий Chrome тоже легитимно фрагментирует большие ClientHello). Сервер фреймы
переупорядочит, DPI — нет.
`i1` — decoy (src=nil, шлётся перед WG-handshake); реальный TLS-handshake он не завершает,
его задача — чтобы первый пакет потока выглядел как легитимный старт QUIC-сессии к CDN.
Инварианты (проверяются обратным разбором в тестах):
- **I1.** первый CRYPTO-фрейм в wire-порядке имеет `offset≠0`;
- **I2.** фрейм с `offset=0` выложен не первым;
- **I3.** между CRYPTO-фреймами есть PADDING-runs и ≥1 PING;
- **I4.** объединение CRYPTO-фреймов по offset = непрерывный валидный ClientHello `[0..N)`,
без дыр/перекрытий, SNI на месте.
Крипта — RFC 9001 §5 (HKDF-Extract по DCID → `client in` → `quic key/iv/hp`,
AES-128-GCM, header protection). Свежие DCID + TLS random + ephemeral x25519 на каждый
вызов → разный ciphertext (нет общей сигнатуры между юзерами). Пакет ≈1250б, length-поле
1232 (padded ≥1200, RFC 9000 §14.1).
### 3.2 DNS / STUN / SIP
- **dns** — клиентский DNS **query**. Flags `0x0100` (QR=0, RD=1; byte2 ноль), QDCOUNT=1,
ARCOUNT=1; QNAME из `Id`, QTYPE **HTTPS** (`0x0041`, RR-type 65 — самый частый запрос
современного браузера), QCLASS IN; OPT RR (TYPE `0x0029`, CLASS=1232, TTL=0, DO=0) с одной
неизвестной EDNS-опцией код `0xFDE9` (IANA local-use), OPTION-LENGTH покрывает cover-байты →
весь датаграм парсится как один DNS query. TXID `<r 2>` и cover `<r 40>` свежие на пакет.
Query, а не response: клиент первым шлёт запрос. Генератор — `masqueDNSQueryCPS`.
- **stun** — WebRTC Binding **Request**. type `0x0001`, magic cookie `0x2112A442`, свежий
txn; атрибуты USERNAME (`0x0006`), ICE-CONTROLLING (`0x802a`), PRIORITY (`0x0024`),
SOFTWARE (`0x8022` = `libwebrtc`), MESSAGE-INTEGRITY (`0x0008`, HMAC-SHA1), FINGERPRINT
(`0x8028`, CRC-32). Request, а не response: клиент первым шлёт именно запрос.
MESSAGE-INTEGRITY структурно валиден, но по произвольному ICE-ключу (реального пароля у
decoy нет — on-path DPI HMAC всё равно не проверит). Свежая энтропия на вызов; hostname не
несёт. Генератор — `stun_request_awg.go`.
- **sip** — начало SIP-звонка (call setup, RFC 3261 §17) — **два самостоятельных пакета** одного диалога:
**i1 = полный INVITE** (request-line + Via(branch=z9hG4bK)/To(без tag)/From(tag)/Call-ID/CSeq:N
INVITE/Max-Forwards:70/Contact/Content-Type/`Content-Length: 0`, **без SDP-тела**), **i2 = полный
`SIP/2.0 100 Trying`** (статус-строка + те же Via/To/From/Call-ID/CSeq + `Content-Length: 0`).
**Почему два целых пакета, а не фрагментация.** i1/i2 уходят как **независимые UDP-датаграммы**
(amneziawg-go `send.go`, `src=nil`), а у UDP нет потоковой реассемблеризации — пакетный DPI
смотрит каждую датаграмму отдельно. INVITE и 100 Trying валидны **каждый сам по себе**; вместе —
каноническое начало вызова (UAC шлёт INVITE → сервер сразу отвечает 100 Trying). Прежняя
фрагментация одного INVITE (head→i1, SDP→i2) оставляла каждую датаграмму битой и заменена.
**Один диалог.** Via branch / From tag / Call-ID / CSeq **идентичны** в i1 и i2 — поэтому строятся
**одним проходом** (`newSIPDialog` → `masqueSIPInviteCPS` + `masqueSIPTryingCPS`) и запекаются в
`<b>` обеих половин (не per-packet `<rc>`/`<rd>`, иначе токены разошлись бы между слотами).
Имена пользователей (display + local) и (если `Id` пуст) host — произносимые `PseudoGen`-строки,
свежие на генерацию; это **не** хардкод RFC-примера `alice@atlanta.com`/`bob@biloxi.com` (он —
публичный DPI-маяк). `Id` опционален: задан → host, пуст → `pgHost()`. Явный `i2` рядом с
`id/ip/ib` отвергается как конфликт (зеркало гарда `i1`). Генератор — `sip_invite_awg.go`,
диспетчер обоих слотов — `masqueI1I2`.
**Требует junk** (`jc/jmin/jmax > 0`): профиль рассчитан на отправку вместе с junk-пакетами в
том же пред-handshake-залпе.
---
## 4. Браузер (`Ib`)
`Ib` валидируется (`chrome|firefox|curl`, только при `ip=quic`) и **управляет JA3/JA4
ClientHello** (build с `with_utls`):
- **`ib=""` / `ib=curl`** → собственный generic ClientHello (~294б, device-proven; §3.1). uTLS не
имеет curl-QUIC-fingerprint, поэтому curl деградирует на generic.
- **`ib=chrome` / `ib=firefox`** → ClientHello строится через **uTLS** (`github.com/metacubex/utls`,
тот же, что у Reality): `UQUICClient` с fingerprint `HelloChrome_120` / `HelloFirefox_120` →
настоящий браузерный JA3/JA4 (cipher_suites, supported_groups, порядок extensions, GREASE у
Chrome). ALPN форсируется в `h3` (это QUIC, не TCP-TLS). PQ-гибрид key_share
(`X25519MLKEM768`, ~1.2КБ) удаляется — он не влез бы в один Initial; следствие: JA3 как у
конца-2023 браузера, не у текущего PQ-включённого. CH крупнее (~510–620б), фрагментация
адаптируется (planFragmentsN режет любую длину, I1–I4 держатся).
- Без тега `with_utls` `ib=chrome/firefox` грациозно деградируют на generic CH (stub-файл).
**Назначение `Ib` — задел против будущего JA3/JA4-классифицирующего DPI.** На текущем целевом DPI
`ip=quic` проходит на фрагментации (fingerprint не проверяется), поэтому дефолт `ib=""` сохраняет
device-proven generic-путь; uTLS-вариант крупнее и сам по себе на устройстве не верифицирован.
Код: `quic_clienthello_utls_awg.go` (+ stub `…_utls_stub_awg.go`).
---
## 5. Валидация (fail-fast)
- **Взаимоисключение с `I1`** — задан и `i1`, и `id/ip/ib` → ошибка.
- **`Ip ∈ {quic,dns,stun,sip}`** (lower); пусто при заданном `Id`/`Ib` → ошибка.
- **`Id` обязателен только для `quic`** (SNI); **опционален для `dns`** (QNAME или псевдо-домен) и для
`sip`** (задан → SIP host, пуст → генерируется псевдо-host) и **`stun`** (hostname-less).
- **Строгий LDH-чек** применяется **всегда, когда `Id` задан** (метки alnum+hyphen+`_`,
без edge-hyphen, ≤63, всего ≤253, трейлинг-дот ок). Это security-граница: домен идёт в
SIP-текст / DNS QNAME / TLS SNI — control-байты (`\r\n\0\t`) и SIP/URI-метасимволы
(`> ; @ "`) дали бы инъекцию. Совпадает с `is_valid_sni_hostname`.
- **`Ib` ∈ {chrome,firefox,curl}** и только при `ip=quic`; иначе ошибка.
---
## 6. Файлы (зоны)
| Файл | Зона | Что |
|------|------|-----|
| `option/wireguard_awg.go` | lx | поля `Id/Ip/Ib` |
| `transport/wireguard/masque_awg.go` | lx, `with_awg` | диспетчер `masqueI1` + валидация + DNS query + `cpsBuilder` |
| `transport/wireguard/quic_initial_awg.go` | lx, `with_awg` | QUIC Initial: varint, рандомизированный frame-план (I1–I4) + `quicGenParams`, сборка RFC 9001 |
| `transport/wireguard/quic_clienthello_awg.go` | lx, `with_awg` | generic TLS 1.3 ClientHello (SNI=`Id`) + диспетч по `Ib` |
| `transport/wireguard/quic_clienthello_utls_awg.go` | lx, `with_awg && with_utls` | uTLS браузерный ClientHello (chrome/firefox JA3, §4) |
| `transport/wireguard/quic_clienthello_utls_stub_awg.go` | lx, `with_awg && !with_utls` | fallback на generic, когда uTLS не собран |
| `transport/wireguard/quic_crypto_awg.go` | lx, `with_awg` | HKDF / AES-128-GCM / header protection |
| `transport/wireguard/stun_request_awg.go` | lx, `with_awg` | STUN WebRTC Binding Request (FINGERPRINT + MESSAGE-INTEGRITY) |
| `transport/wireguard/sip_invite_awg.go` | lx, `with_awg` | начало SIP-звонка: INVITE (i1) + `100 Trying` (i2), один диалог, без SDP |
| `transport/wireguard/pseudo_gen_awg.go` | lx, `with_awg` | произносимые псевдо-имена/host/IP (для SIP) |
| `transport/wireguard/device_awg.go` | lx, `with_awg` | вызов `masqueI1` в `awgIpcLines` |
| `transport/wireguard/masque_awg_test.go`, `quic_initial_awg_test.go` | lx, `with_awg` | тесты |
Сабмодуль `submodules/wireguard-go` не трогается.
---
## 7. Критерии приёмки
- **Структурная валидность каждого профиля** (обратным разбором, не тавтология): QUIC —
собственный вывод AEAD-расшифровывается (тег сходится), frame-walk даёт ≥6 CRYPTO + ≥1
PING + PADDING, первый CRYPTO `offset≠0` (I1), CRYPTO реассемблируются в валидный
ClientHello с SNI=`Id` (I4); DNS — валидный EDNS-OPT **query** (QR=0, QNAME=`Id`, QTYPE
HTTPS, опция `0xFDE9`, без хвостов); STUN — Binding **Request** (cookie, атрибуты тайлят сообщение, FINGERPRINT
CRC-32 сходится, USERNAME + MESSAGE-INTEGRITY присутствуют); SIP — **два самостоятельных
пакета** одного диалога: i1 — валидный INVITE **request** (request-line `INVITE ... SIP/2.0`,
Via/Max-Forwards/From/To-без-tag/Call-ID/CSeq/Contact, `Content-Length: 0`, без SDP-тела),
i2 — валидный `SIP/2.0 100 Trying` (статус-строка + те же Via/To/From/Call-ID/CSeq,
`Content-Length: 0`); branch/tag/Call-ID/CSeq **идентичны** в i1 и i2 (один диалог), имена
не захардкожены.
- **Рандомизация QUIC:** раскладка фрейм-плана и точки разреза свежие на каждый вызов;
инварианты I1–I4 держатся на каждом сэмпле (стресс-тест), две генерации → разные offset'ы
фрагментов (нет фикс-сигнатуры). Robustness-ручки (`quicGenParams`: 4–12 фрагментов,
переменный размер) тоже держат I1–I4.
- **Уникальность:** два вызова QUIC с одним SNI → разные DCID/TLS random → разный
ciphertext; два вызова STUN → разный txn/ufrag/ключ → разный blob.
- **`Ib` JA3 (build с `with_utls`):** `ib=chrome`/`firefox` → uTLS-ClientHello, расшифровывается,
SNI=`Id`, первый CRYPTO offset≠0 (I1), пакет 1250б; chrome содержит GREASE cipher, firefox нет;
длины chrome≠firefox≠generic. `ib=""`/`curl` → generic ~294б. Без `with_utls` chrome/firefox
деградируют на generic (stub компилируется и тестируется).
- **Длинный домен:** валидный LDH-домен любой длины (≤253) генерируется без ошибки (payload
пинится к length-полю flex-PADDING-run; CH растёт с длиной SNI, инварианты сохраняются).
- **CPS принят реальным движком:** прогон через `newObfChain` из `submodules/wireguard-go`.
- **Валидация:** конфликт с `I1`, неизвестный `Ip`, пустой `Id` для quic,
control-байт/метасимвол в домене, `Ib` вне набора / не при quic — ошибки; нет паники.
- **Gating:** `Id/Ip/Ib` без `with_awg` → «awg support not built».
- **Регресс:** плоский WG и явный `I1` без masquerade — байт-в-байт.
- `go build` (без тегов и `-tags with_awg`) ок; `go test -tags with_awg ./transport/wireguard/...`
зелёный; `gofmt -l` lx-файлов пусто.
- **Device-smoke:** узел `ip=quic` с фрагментированным Initial поднимает туннель и проводит
реальный трафик через активный DPI — проверено вживую.
---
## 8. Active probing — граница односторонней маскировки (гипотезы)
Этот раздел — **гипотезы**, не device-факты. Device-факты у нас ровно те, что в §3: `quic`
проходит (~340 мс), `dns`/`stun`/`sip` — Timeout даже после исправления направления на
клиент-инициированное. Здесь — *почему* (механизм под эмпирическим выводом §3), и почему это
механистически ограничивает односторонний (client-only) decoy. Внутреннюю логику DPI мы не
наблюдаем, поэтому всё ниже — модель, а не измерение.
> **H3 (тезис, высокая уверенность). Односторонний decoy силён ровно настолько, насколько то,
> что ЦЕЛЕВОЙ СЕРВЕР реально отдаёт на этом порту.** Клиент эмитит только клиентскую сторону
> протокола; ответить на проверку он не может. Это структурно неизбежно (см. §2 и `send.go:135`:
> decoy шлётся как `Obfuscate(buf, nil)`, src=nil — самодостаточный датаграм без ciphertext-хвоста;
> единственный серверный ответ в device — `SendHandshakeResponse`, и тот лишь на валидный
> WG-handshake, не на произвольную протокол-проверку). Это «(протокол + назначение)» из §3,
> доведённое до механизма. H3 робастна при любой из трёх причинных моделей ниже.
Конкретная причина, по которой `dns`/`stun`/`sip` к WARP-edge `162.159.x:2408` падают, нами **не
наблюдаема** и совместима как минимум с тремя моделями DPI:
1. **Пассивная репутация назначения (модель, которую сейчас прямо поддерживает §3).** raw
DNS/STUN/SIP к дата-центровому IP сам по себе аномален (DNS живёт на `:53`-резолвере, STUN — на
STUN-сервере) и режется как класс протокол-к-назначению, **без всякой проверки и без ответа**.
При этой модели `quic` проходит не потому, что кто-то ответил, а потому что QUIC/HTTP3-к-CDN —
ожидаемая по форме трафика картина (responder не нужен вообще).
2. **Пассивный allowlist протоколов на класс назначения** — частный случай (1): на дата-центровый
IP разрешён ожидаемый набор, QUIC в нём есть, raw DNS/STUN/SIP — нет.
3. **Active probing (сильнейшая, но наименее подтверждённая форма).** Увидев decoy, DPI сам шлёт
проверочный пакет на тот же 5-tuple (свой QUIC Initial / version-negotiation-триггер, DNS-query,
STUN Binding Request, SIP OPTIONS) и ждёт протокол-корректного ответа. Тогда:
> **H1 (гипотеза активной проверки, средняя уверенность).** Если DPI активно проверяет
> `quic`-decoy и Cloudflare-edge **действительно отвечает QUIC на этом 5-tuple**, проба
> удовлетворяется и поток классифицируется как легитимный QUIC. Decoy «одалживает» чужой
> настоящий responder, который сам не держит.
> **H2 (гипотеза активной проверки, средняя уверенность).** На том же `162.159.x:2408` нет
> DNS-резолвера, STUN-сервера и SIP-UA. Активная проба по этим протоколам не получит
> протокол-корректного ответа никогда — как бы хорошо ни был сделан клиентский decoy.
> **Слабое звено H1 — непроверенное условие.** `:2408` — это **WireGuard**-порт WARP. Отвечает ли
> Cloudflare-edge QUIC/HTTP3 **именно на этом UDP-порту** (а не на штатном `:443`) — пакетным
> захватом не подтверждено. «Edge говорит QUIC по всему флоту» не равно «этот порт отвечает на QUIC
> Initial». Поэтому «Cloudflare отвечает QUIC на `:2408`» — условие, а не факт; его проверяет
> тест T3.
**Общий вывод и его условность.** При активной модели для `dns`/`stun`/`sip` нужен
**контролируемый сервер с probe-responder** (двусторонняя модель WireSock `amneziawg-proxy`, см.
§9), и они применимы только к **self-hosted AmneziaWG**, не к WARP. Но если DPI **пассивный**
(модель §3), responder ничего не чинит: проблема не в отсутствии ответа, а в том, что raw
DNS/STUN/SIP к дата-центровому IP аномальны по назначению — помогает только **протокол-уместное
назначение** (DNS на `:53` и т.д.), не co-located responder на том же WG-порту. То есть вывод
«не-QUIC нужен self-hosted responder» **корректен только при активной модели** и остаётся
гипотезой. Поэтому код заполняет только `i1`/`i2` (QUIC для WARP), а `i3..i5` оставлены свободными
под self-hosted/мульти-decoy — это реальный задел.
Репутация назначения подробнее описана в `transport/wireguard/masque_awg.go` (DNS-генератор) и в
§3; этот раздел обобщает их в probe-модель. Серверная сторона / probe-response — вне скоупа (§10).
Источник device-фактов — LxBox-задача 146; статья habr 1047080 описывает двустороннюю модель
WireSock архитектурно (без байт-спек и без измерений) — подтверждает
**механизм** H3, но не служит device-доказательством.
### 8.1 Фальсифицируемые device-тесты
Те же телефон + LTE/WARP-DPI, что дали §3. Все decoy в `i1`, если не сказано иное; `jc`/junk и
реальный WG-handshake — константа.
- **T1 — назначение vs протокол (проверяет H2/H3).** Направить `ip=dns` (и отдельно `stun`,
`sip`) на **self-hosted AmneziaWG**, хост которого реально держит соответствующий responder на
том же UDP-порту. *Предсказание:* проходят, тогда как к WARP `:2408` — Timeout → подтверждает
H2+H3. Если падают даже с co-located responder → H2/H3 (в активной форме) опровергнуты (блокер
иной — напр. сигнатура raw-протокол-к-любому-дата-центру).
- **T2 — активная проба vs пассивная репутация.** На контролируемом сервере логировать входящий
UDP на WARP-5-tuple после каждого decoy. *При активной модели:* после QUIC-decoy виден непрошеный
входящий QUIC-образный проб (не от WG-сервера). Если проба нет, а `dns`/`stun`/`sip` всё равно
падают → активная проба опровергнута, механизм — пассивная репутация назначения (H3 держится в
слабой форме).
- **T3 — контроль «бесплатного responder» QUIC (проверяет H1).** Направить `ip=quic`-decoy на
IP/порт **без** QUIC-responder (plain UDP-echo или `:2408` не-Cloudflare хоста). *Предсказание:*
`ip=quic` начинает Timeout, как dns/stun/sip — значит успех нёс настоящий Cloudflare-responder, а
не сами байты QUIC. Если `quic` всё равно проходит → H1 опровергнута. T3 — единственное, что
снимает слабое звено H1 (`:2408` vs `:443`).
---
## 9. QUIC — один Initial (multi-packet рассмотрен и отклонён)
`ip=quic` эмитит **ОДИН** фрагментированный Initial (`i1`; `i2..i5` пусты). Один Initial — это
ровно то, что реальный клиент шлёт, открывая одну QUIC-сессию; правдоподобие даёт
браузер-точный ClientHello (`Ib` → uTLS, §4), а не число пакетов.
**Отклонённая альтернатива — два независимых Initial (i1+i2).** Идея «развивающейся сессии» была
реализована и device-проверена как безопасная для WARP-handshake (туннель встаёт без регресса
латентности), но **концептуально неверна**: каждый DCID — отдельное QUIC-соединение, поэтому два
Initial с разными DCID читаются как **два брошенных соединения**, а не одна развивающаяся сессия —
для DPI с отслеживанием по DCID это *более* аномально, не менее. Настоящее «продолжение» (1-RTT
short-header с тем же DCID) невозможно: short-header device-blocked (коммит `64ce4a47`), а 1-RTT до
ответа сервера — невозможное QUIC-состояние. Вывод: один чистый Initial честнее любого
двухпакетного варианта. (`masqueQUICSecondInitialCPS` удалён.)
> **Замечание про send.go (валидно для sip i1+i2, §3.2).** Decoy-слоты `i1..i5` шлются как
> независимые UDP-датаграмы ПЕРЕД подлинным `MessageInitiation`: `CreateMessageInitiation` считается
> до цикла по `ipackets`, каждый decoy — `Obfuscate(buf, nil)` отдельным элементом `sendBuffer`,
> подлинный пакет добавляется последним и байт-идентичен стоковому WG; Cloudflare реагирует только
> на валидный `MessageInitiation`, decoy отбрасывает как не-WG-шум. Поэтому любой decoy (в т.ч.
> sip-i2) не может изменить handshake — это и делало multi-packet безопасным.
---
## 10. Вне скоупа
- `dns`/`stun` фрагментация (отдельная таска при необходимости).
- Серверная сторона / probe-response (client-only) — но см. §8: non-QUIC профили осмысленны
только со своим сервером-ответчиком (это и есть серверная сторона, вне скоупа 009).
- Byte-identical имитация конкретного снимка трафика (рандомизация снижает сигнатуру).
- Многопакетный QUIC (i1+i2) — рассмотрен и отклонён (§9).
- Поведенческая плоскость (timing / вариативность размеров между подключениями) — отдельное
направление, если passive-shape станет недостаточно.
---
## 9. Ссылки
- RFC 9000 §16 (varint), §14.1 (Initial ≥1200), §17.2.2 (Initial), §19.6 (CRYPTO), §19.7 (PING), §19.1 (PADDING)
- RFC 9001 §5 (Initial secrets), §5.4 (header protection) · RFC 6891 (EDNS OPT) · RFC 5389 (STUN) · RFC 3261 (SIP)
- WireSock open-source (dns/stun/sip структура): <https://github.com/wiresock/amneziawg-install> (`amneziawg-proxy/src/transform.rs`, `quic_handshake.rs`)
- CPS-движок: `submodules/wireguard-go/device/obf.go`, `send.go:135`
- Проводка: `transport/wireguard/device_awg.go`, `option/wireguard_awg.go`
- Память: [[wiresock-id-ip-ib-feasibility]], [[qtls-helpers-reuse-for-quic-initial]]
@@ -1,52 +0,0 @@
# TASKS — 009-WIRESOCK_MASQUERADE_PROFILES
## Решения
- [x] Механизм: **I1 CPS только** (S1–S4 невозможен против WARP; сабмодуль не трогаем)
- [x] QUIC: **out-of-order фрагментированный QUIC Initial** (RFC 9001) с SNI=`id`
- [x] Структуры dns/stun/sip: порт из WireSock `transform.rs`
- [x] `Ib`: chrome/firefox → uTLS браузерный ClientHello (реальный JA3); ""/curl → generic; build-tag split utls/stub
## Код
- [x] `option/wireguard_awg.go`: `Id/Ip/Ib string` (json `id`/`ip`/`ib`)
- [x] `transport/wireguard/masque_awg.go`: `masqueI1` диспетчер + валидация + `cpsBuilder`
- [x] `validateMasqueDomain` (LDH, зеркало `is_valid_sni_hostname`) — security-граница
- [x] `normalizeMasqueBrowser` (chrome|firefox|curl; только quic)
- [x] DNS → EDNS OPT query (`masqueDNSQueryCPS`, QR=0, QTYPE HTTPS, QNAME из `Id`)
- [x] STUN → WebRTC Binding Request (`stun_request_awg.go`, FINGERPRINT + MESSAGE-INTEGRITY)
- [x] SIP → INVITE (i1) + `100 Trying` (i2), один диалог, без SDP, требует junk (`sip_invite_awg.go`: `newSIPDialog`/`masqueSIPInviteCPS`/`masqueSIPTryingCPS`, диспетч `masqueI1I2`, PseudoGen-имена, `Id`/псевдо-host)
- [x] `transport/wireguard/quic_initial_awg.go`: varint, рандомизированный frame-план (I1–I4) + `quicGenParams`, сборка Initial
- [x] `transport/wireguard/quic_clienthello_awg.go`: реалистичный TLS 1.3 ClientHello (SNI=`Id`)
- [x] `transport/wireguard/quic_crypto_awg.go`: HKDF / AES-128-GCM / header protection
- [x] `device_awg.go`: вызов `masqueI1` в `awgIpcLines`, подстановка как `i1`
## Тест
- [x] `masque_awg_test.go`: структурная валидность dns/stun/sip обратным парсингом + валидация
- [x] `quic_initial_awg_test.go`: обратный разбор QUIC (decrypt, frame-walk, reassembly, SNI)
- [x] QUIC §5-векторы: AEAD-тег сходится (§5.1); ≥6 CRYPTO/≥1 PING, первый offset≠0 (I1/I2/I3);
реассембл в валидный ClientHello, SNI=`id` (I4); размер 1250/length 1232; уникальность DCID+random
- [x] QUIC рандомизация: раскладка свежая на вызов, I1–I4 на каждом сэмпле, offset'ы различаются; robustness-ручки
- [x] длинный валидный домен (>77 симв.) генерируется без ошибки (flex-PADDING)
- [x] DNS: парсится как EDNS OPT query (QR=0, QTYPE HTTPS), QNAME=`Id`, RDLENGTH/OPTION-LENGTH до конца
- [x] STUN: парсится как Binding Request (0x0001), FINGERPRINT CRC-32 сходится, USERNAME+MESSAGE-INTEGRITY есть
- [x] SIP: i1 INVITE (request-line, обязательные заголовки + To без tag, `Content-Length: 0`, без SDP) + i2 `100 Trying`, согласованный диалог (общий branch/tag/Call-ID/CSeq), имена не захардкожены; пустой id → псевдо-host
- [x] валидация: конфликт с I1, неизвестный ip/ib, пустой id (только quic), ib без quic — ошибки
- [x] инъекция домена (CRLF/метасимволы) — отвергается
- [x] `masque_cps_test.go`: верный реплей CPS-парсера (зеркало `newObfChain`)
- [x] cross-check: сгенерированный QUIC Initial парсится боевым снифером `common/sniff/quic.go`
(SNI извлечён, классификация chromium)
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "with_wireguard with_gvisor with_awg" ./cmd/sing-box` — ок
- [x] `go test -tags with_awg ./transport/wireguard/...` — зелёный
- [x] `sing-box check` на конфигах id/ip/ib (quic/dns/stun/sip); конфликт с i1 отвергнут; пустой id для quic отвергнут
- [x] gating: id/ip/ib без `with_awg` → «awg support not built»
- [x] `gofmt -l` lx-файлов — пусто
## Закрытие
- [x] адверсариальный ревью генераторов (workflow) — проведён, подтверждённые findings учтены
- [x] `docs-lx/lx-config.md` — секция id/ip/ib (+ссылка на EXAMPLES.md)
- [x] `EXAMPLES.md` — how-to с примерами (прогнаны через `sing-box check`)
- [x] `IMPLEMENTATION_REPORT.md`
- [x] Device-результат (LTE/WARP DPI): **только `quic` проходит** (~340 мс); `dns`/`stun` — Timeout
(DPI режет к WARP-edge `:2408` как класс протокола; `quic` обходит проверку назначения). `sip` — по аналогии.
@@ -1,47 +0,0 @@
# 010 — Верификация фикса (GRO split-brain) на железе
Фикс из SPEC шаг 1 применён. Этот `.aar` = **фикс + probe** (probe оставлен
специально, чтобы прогон показал, что фикс взвёл `rxoffload=false`).
## Что в фиксе
Гейт за `!android` (TX/GSO не тронуты):
- `controlfns_linux.go`: `setsockopt(UDP_GRO)` пропускается на android.
- `features_linux.go`: `rxOffload` не читается на android → всегда `false`.
submodule `wg-gro-android-fix` @ `fb8d8d8` (чистый фикс) →
verify-вариант `wg-gro-android-fix-verify` @ `08947cc` (фикс + probe-геттер).
## Артефакт (verify)
| Файл | SDK | sha256 |
|------|-----|--------|
| `dist/lx-wg-gro-fix-verify/libbox-gro-fix-verify.aar` | 23 | `b454c35b08aa7ada6634a41278fa6caf281a7a5a64488231f757c4df48d3d098` |
| `dist/lx-wg-gro-fix-verify/libbox-legacy-gro-fix-verify.aar` | 21 | `6eacc5b2b6c2166d35531a58404964e3e61404a8d458b631958b55b680cbe2c1` |
Собрано из `lx-wg-gro-fix-verify` @ `9996dc0a`; submodule `08947cc` — подтверждено
по логу checkout CI. probe-маркер вкомпилирован.
## Прогон (тот же, что для probe)
1. Вложить `.aar`, `core_logs_enabled=true`.
2. WARP-endpoint **без `detour`** (та же нода, где download был мёртв).
3. Дать трафик, снять core-log: `GET /logs?source=core&q=gro-probe`.
4. **Прогнать реальный download** (плотный поток) и сравнить с предыдущим прогоном.
## Критерий приёмки
| Сигнал | Вывод |
|---|---|
| `rxoffload4=false rxoffload6=false` (+ `dispatch=single`) | фикс взвёлся — GRO на android выключен |
| **download ожил** (сопоставим с `detour: direct`), upload жив | **фикс подтверждён на железе** |
Если `rxoffload` стал `false`, **но download всё ещё мёртв** → GRO был не единственной
причиной; открываем кандидат №2 (тихий хэндовер, `monitor.go`) — см. `SPEC.md`.
## После подтверждения
- probe удаляется (endpoint.go Errorf-строка + OffloadState-геттер) — он временный.
- на merge в `lx` идёт чистый фикс: submodule `wg-gro-android-fix` (`fb8d8d8`),
main — бамп pin без probe.
- временные ветки (`*-verify`, `gro-probe-010`, `lx-gro-probe-010`) удаляются.
@@ -1,206 +0,0 @@
# 010 — Задание команде ядра: probe v2 (вывод не в stderr, а в core-log)
**Кому:** команда `sing-box-lx` (правка submodule `wireguard-go` + `transport/wireguard/endpoint.go`).
**От кого:** прогон probe v1 на реальном устройстве (LxBox, 2026-06-21).
**Зачем:** probe v1 на тест-телефоне **немой** — не из-за бага в логике, а из-за
канала вывода. Нужна v2 с выводом в канал, который на Android реально виден.
---
## ⚠️ ОБНОВЛЕНИЕ 2026-06-21 (после прогона v2): нужна v2.1 — `Errorf`, не `Verbosef`
Probe **v2** (`endpoint.go:260-264`, `logger.Verbosef("LX-GRO-PROBE …")`) прогнан на
том же CPH2411/Android 15 — и **снова немой**. Причина установлена на железе и
финальна:
**На Android уровень DEBUG не доходит до platform-forwarding вообще.** Факты прогона:
- `endpoint.go:228-229` `Verbosef` → `e.options.Logger.**Debug**(strings.ToLower(...))`.
- Поднял `log.level` конфига до `debug` (`PUT /config`, подтверждено
`{"level":"debug"}`, ядро переподняло endpoint) → в LxBox core-log
(`GET /logs?source=core`, 500 записей) **ноль записей уровня `debug`** — только
`info`/`error`. Т.е. весь DEBUG-класс отсекается в libbox-тракте до наблюдателя,
несмотря на то что `log/observable.go:140-141` выглядит как безусловный
`platformWriter.WriteMessage` (в реальной .aar-сборке debug всё равно не проходит).
- Контроль: `endpoint/wireguard[…]: outbound connection` (это **INFO**) — доходит
пачками; `error`-записи — доходят. Значит канал core-log жив, не проходит именно
**уровень**.
- Дополнительно: формат прогоняется через `strings.ToLower` (`endpoint.go:229`) →
строка пришла бы как `lx-gro-probe …` (нижний регистр) — учесть при поиске, но это
вторично: при DEBUG её всё равно нет.
**Что сделать (v2.1) — минимальная правка `endpoint.go`:** поднять уровень
probe-строки с `Verbosef` (Debug) на **`Errorf`** (Error) — Error на Android
**подтверждённо доходит** до core-log. Т.е. строки 262-263:
```go
// было:
logger.Verbosef("LX-GRO-PROBE: GOOS=%s txOffload4=%v rxOffload4=%v txOffload6=%v rxOffload6=%v dispatch=%s",
goos, tx4, rx4, tx6, rx6, dispatch)
// стало (v2.1):
logger.Errorf("LX-GRO-PROBE: GOOS=%s txOffload4=%v rxOffload4=%v txOffload6=%v rxOffload6=%v dispatch=%s",
goos, tx4, rx4, tx6, rx6, dispatch)
```
`Errorf` → `e.options.Logger.Error` (`endpoint.go:231-232`). NB: там тоже
`strings.ToLower` — итоговая строка будет `lx-gro-probe: goos=…`; снимать по
`q=gro-probe` (lowercase). Это диагностический probe, временный — Error-уровень для
него приемлем (на проде строки нет).
**Что НЕ нужно (проверено впустую на стороне LxBox):**
- Поднимать `log.level` конфига — DEBUG всё равно не проходит на Android.
- Bypass trace/DEBUG-фильтра в `BoxService.writeDebugMessage` (LxBox Kotlin) — строка
до него не доходит, отсекается раньше уровнем.
Канал (core-log) и место лога (после `IpcSet`, каст `*conn.StdNetBind`) из v2 —
**правильные, не трогать**. Меняется ровно одно: `Verbosef` → `Errorf`.
---
## Что произошло на устройстве (факты прогона)
Probe v1 (`gro-probe.patch`, ветка submodule `gro-probe-010` @ `21423f6`) собран,
вложен в LxBox, установлен и прогнан:
| Параметр | Значение |
|---|---|
| Устройство | OnePlus **CPH2411**, **Android 15** (SDK 35), arm64-v8a, ColorOS |
| Сборка | release-APK с probe-`.aar` (`libbox-gro-probe-010.aar`, sha `4006811a…`), vc 2714 |
| Профиль | WARP-endpoint **без `detour`** (`🔥⛈️ WARP (AWG 1.5)`, peer `162.159.192.6:854`, single-peer → `isConnect=true`) |
| Что подтверждено | **`StdNetBind.Open` вызывался** — endpoint поднялся, был handshake и трафик (`down_total` доходил до 5.8 МБ; core-log: `endpoint/wireguard[🔥⛈️ WARP (AWG 1.5)]: outbound connection to …`) |
| Что НЕ получилось | **probe-строка `LX-GRO-PROBE` не появилась нигде** — ни в logcat, ни в `stderr.log`, ни в core/app-логах |
Маркер в бинаре есть (проверено: `strings libbox.so | grep LX-GRO-PROBE` = 2 в
arm64). То есть код probe в сборке, путь исполнялся — **молчит именно вывод**.
---
## Корень немоты: Go-stderr на этом Android уходит в `/dev/null`
Probe v1 пишет через `fmt.Fprintf(os.Stderr, …)`. На стороне LxBox stderr ловится
через `Libbox.redirectStderr(File(filesDir, "stderr.log"))` (best-effort,
`BoxApplication.initializeLibbox`). На **CPH2411/ColorOS/Android 15** этот редирект
по факту **не наполняет файл**:
- `stderr.log` **отсутствует/пуст** — Debug API `GET /files/local?name=stderr.log`
стабильно отдаёт `not_found` (читает `getApplicationDocumentsDirectory()/stderr.log`
= тот же `filesDir`, куда пишет redirectStderr — путь верный, файла просто нет).
- В logcat probe тоже нет (Android по умолчанию направляет stdout/stderr приложения
в `/dev/null`; `setprop log.redirect-stdio true` — **запрещён** non-root adb на
ColorOS, `Failed to set property … See dmesg`).
- `run-as` — **запрещён** (release-APK не debuggable → sandbox недоступен).
- Warn `redirectStderr failed` в logcat **нет** → редирект не падает явно, но и не
работает (dup2 на закрытый fd 2 — типовое поведение Android-приложения; молча
no-op).
Вывод: **канал stderr на реальном целевом устройстве для probe непригоден.**
Инструкция из `PROBE.md` («`adb logcat | grep LX-GRO-PROBE`») исходила из допущения
«stderr → logcat», которое на этом OEM неверно.
---
## Что РАБОТАЕТ как канал: sing-box core-log (PlatformInterface)
Единственный канал из ядра, который на устройстве **подтверждённо доходит** до
наблюдателя — штатный лог sing-box. Он виден через LxBox Debug API
`GET /logs?source=core` (живые записи `endpoint/wireguard[…]: outbound connection`,
`router: …` и т.п. снимались в реальном времени).
В `wireguard-go` этот канал уже подключён рядом с bind'ом
(`transport/wireguard/endpoint.go`):
```go
// endpoint.go ~227
logger := &device.Logger{
Verbosef: func(format string, args ...any) {
e.options.Logger.Debug(fmt.Sprintf(strings.ToLower(format), args...))
},
Errorf: func(format string, args ...any) {
e.options.Logger.Error(fmt.Sprintf(strings.ToLower(format), args...))
},
}
wgDevice := device.NewDevice(e.options.Context, deviceInput, bind, logger, e.options.Workers)
```
`device.Logger.Verbosef/Errorf` → `options.Logger.Debug/Error` → **core-log → виден
в `/logs?source=core`.** Это целевой канал для probe v2.
---
## Задание: probe v2
Цель та же, что v1 (см. `SPEC.md` шаг 0): **снять `txOffload`/`rxOffload`/`dispatch`
для WG-endpoint-сокета на android**. Меняется только транспорт вывода.
**Требование:** probe-строка должна уходить в `device.Logger` (→ core-log), НЕ в
`os.Stderr`.
Сложность: `StdNetBind` создаётся в `endpoint.go` (развилка ~200-215) **раньше**, чем
`device.Logger` (~227), и логгер в bind не передаётся — поэтому v1 и взял `os.Stderr`
(в `Open` логгера нет под рукой). Варианты, на выбор команды ядра (любой допустим):
1. **Логировать на стороне `endpoint.go` после `bind.Open`/после `NewDevice`.**
`StdNetBind` уже хранит `ipv4TxOffload/ipv4RxOffload/ipv6TxOffload/ipv6RxOffload`
как поля (см. v1-патч — они проставляются в `Open`). Добавить геттер на bind
(напр. `OffloadState() (tx4, rx4, tx6, rx6 bool)`) и сразу после поднятия device
вызвать `logger.Verbosef("LX-GRO-PROBE: GOOS=%s tx=%v rx=%v dispatch=%s", …)`.
Чисто, не тащит logger внутрь conn-пакета.
2. **Пробросить лёгкий лог-хук в `StdNetBind`.** Поле-функция
`OnProbe func(string)` на bind, выставляемое из `endpoint.go` до `Open`; внутри
`Open` дёргать его вместо `fmt.Fprintf(os.Stderr, …)`. Хук замыкается на
`e.options.Logger.Debug`.
Формат строки оставить как в v1 (по строке на v4/v6 сокет), маркер `LX-GRO-PROBE`,
поля `GOOS / txOffload / rxOffload / dispatch`. `dispatch` — `single` на android,
`split` на linux (логика `groProbeDispatch()` из v1 переносится без изменений).
**Уровень:** через `Verbosef` (→ `options.Logger.Debug`). На устройстве снимем
`GET /logs?source=core` — debug-уровень в core-log проходит (проверено: INFO/router
видны; LxBox core-log не режет debug на этом канале при включённом core_logs).
> NB на стороне LxBox: чтобы debug-строки точно дошли, на устройстве включим
> `core_logs_enabled=true` (App Settings → Diagnostics) перед прогоном — это наша
> зона, отдельного действия от ядра не требует.
**Не трогать:** саму offload-логику (`controlfns_linux.go` / `features_linux.go` /
`bind_std.go` dispatch). Это по-прежнему **только замер**, не фикс. Фикс (гейт
`UDP_GRO` за `!android`) — отдельным шагом после того, как v2 даст
`rxOffload`-факт.
---
## Сборка и передача (как с v1)
```bash
# в submodule wireguard-go: ветка gro-probe-010, новый commit поверх 21423f6
# в main repo: ветка lx-gro-probe-010 бампит pin submodule
gh workflow run lx-build.yml -f target=android-aar -f branch=lx-gro-probe-010
gh run download <run-id> -D dist/lx-gro-probe-010
```
Отдать обновлённый `libbox-gro-probe-010.aar` (+ обновить sha в `PROBE.md`). LxBox-сторона
готова: APK-обвязка, профиль WARP-без-detour, прогон-плейбук уже отлажены — повторный
прогон = вложить новый `.aar`, пересобрать release-APK, поднять WARP-endpoint, снять
`GET /logs?source=core | grep LX-GRO-PROBE`.
---
## Как читать результат (без изменений к SPEC.md)
| core-log строка | Вывод | Дальше |
|---|---|---|
| `rxOffload=true` (+ `dispatch=single`) | GRO взведён, не разбирается — **корень №1 подтверждён** | фикс: гейт `UDP_GRO` за `!android` |
| `rxOffload=false` | GRO не активируется — **корень №1 мёртв** | кандидат №2 (тихий хэндовер, `monitor.go`) |
---
## Статус LxBox-стороны (готово, переиспользуемо)
- Probe-`.aar` кладётся в `app/android/app/libs/libbox.aar` (бэкап реального ядра
`v1.13.13-lx.12` в `/tmp/libbox-real-lx12.aar.bak`). **Восстановить перед релизом.**
- Сборка: `flutter build apk --release --split-per-abi --target-platform android-arm64`
**напрямую** (НЕ через `build-local-apk.sh` — его `fetch-libbox.sh` затрёт probe).
Подпись `CN=BoxVPN` → встаёт `adb install -r` поверх без uninstall; vc 2714 > 2702.
- Прогон через Debug API (token, порт — в LxBox `project_dev_endpoints`):
`POST /action/start-vpn` → `POST /action/switch-node?tag=<WARP urlenc>` →
трафик → `GET /logs?source=core&q=LX-GRO-PROBE`.
@@ -1,109 +0,0 @@
# 010 — Probe-ядро для LxBox: замер `rxOffload` на Android
Тестовое (НЕ релизное) ядро с временным диагностическим логом. Цель — один факт:
**взводит ли ядро Android `rxOffload` для WG-endpoint-сокета**. От этого зависит,
какой из двух кандидатов чинить (см. `SPEC.md`, шаг 0).
---
## Что в ядре (probe v2.1)
> **Эволюция канала:** v1 писал в `os.Stderr` → на CPH2411/ColorOS он уходит в
> `/dev/null` (нем). v2 перешёл на `device.Logger` → core-log, но через `Verbosef`
> (DEBUG-уровень) — а **DEBUG отсекается внутри libbox-тракта на Android** (проверено
> эмпирически: при `log.level=debug` в core-log ноль debug-записей, INFO/Error идут).
> **v2.1 пишет через `Errorf` (Error-уровень), который на Android до core-log
> доходит.** Полный разбор — в `PROBE-V2-TASK.md`.
- `submodules/wireguard-go/conn/bind_std.go`: `StdNetBind.Open` больше **не** печатает
в stderr; вместо этого геттер `OffloadState()` — отдаёт `ipv4/ipv6 Tx/RxOffload` +
`dispatch`. Offload-логика не тронута.
- `transport/wireguard/endpoint.go`: сразу после `IpcSet` (device поднят, `bind.Open`
отработал) логирует строку через `logger.Errorf` (→ `options.Logger.Error` →
core-log). Только no-detour путь (`*conn.StdNetBind`); detour-путь (`ClientBind`)
offload не имеет и строку не печатает.
Формат строки (одна; обёртка `Errorf` прогоняет формат через `strings.ToLower`,
поэтому строка приходит в **нижнем регистре**, маркер — `lx-gro-probe`):
```
lx-gro-probe: goos=android txoffload4=<bool> rxoffload4=<bool> txoffload6=<bool> rxoffload6=<bool> dispatch=<split|single>
```
- `rxoffload4`/`rxoffload6` — главное поле: включился ли GRO на приёме.
- `dispatch` — какую RX-ветку берёт диспетчер: `single` = одиночный `ReadMsgUDP` без
разбора GRO (баг), `split` = разбор коалесированных сообщений.
- На Android ожидаем `goos=android` и `dispatch=single` всегда. Вопрос только в
`rxoffload*`.
---
## Готовый artefact (собран CI)
Probe-`.aar` собран через on-demand CI (`gh workflow run lx-build.yml
-f target=android-aar -f branch=lx-gro-probe-010`) и лежит в локальном дереве:
| Файл | SDK | sha256 (v2.1) |
|------|-----|--------|
| `dist/lx-gro-probe-010/libbox-gro-probe-010.aar` | 23 (main) | `9f609cac40782065bcc0d3c9ebde6f6ad969246a177b010c4bb13cff61cd2ad1` |
| `dist/lx-gro-probe-010/libbox-legacy-gro-probe-010.aar` | 21 (legacy) | `432d3e0a6f8200214b3c99a486ee2d32872df18836b9cd93efa657763c9befcb` |
Собрано из `lx-gro-probe-010` @ `6177c6b2` (v2.1, `Errorf`) — подтверждено по логу
checkout CI. Маркер `LX-GRO-PROBE` вкомпилирован в `libbox.so`.
Подложить `.aar` в `app/libs/` приложения, собрать debug-APK — и переходить к прогону.
## Откуда берётся probe
- **submodule** `wireguard-go`: ветка `gro-probe-010`, commit `21423f6` (probe-лог в
`conn/bind_std.go`). Базовый pin lx — `27290b6`.
- **main repo**: ветка `lx-gro-probe-010` бампит pin submodule на probe-commit. `lx`
не тронута (на ней — только CI-файл `lx-build.yml`).
- Голый diff probe рядом: `gro-probe.patch`.
Пересобрать в любой момент:
```bash
gh workflow run lx-build.yml -f target=android-aar -f branch=lx-gro-probe-010
gh run download <run-id> -D dist/lx-gro-probe-010
```
## Как прогнать (команда LxBox)
1. Подложить готовый `.aar` (см. выше) в сборку приложения.
2. Включить core-log: **App Settings → Diagnostics → `core_logs_enabled=true`**.
(Уровень `log.level` менять НЕ нужно — probe идёт через Error, а Error в core-log
на Android проходит независимо от `log.level`.)
3. Поднять профиль с **WG-endpoint без `detour`** (тот самый конфиг, где download
мёртв).
4. Запустить туннель, дать пройти трафику, снять core-log через Debug API
(строка в **нижнем регистре** — ищем по `gro-probe`):
```bash
GET /logs?source=core&q=gro-probe
```
5. Скопировать строку `lx-gro-probe: …` и вернуть её нам.
> Замечание: строка пишется один раз при поднятии device (старт туннеля), после
> `IpcSet`. Если в core-log пусто — либо путь пошёл в `ClientBind` (проверьте, что
> endpoint **без** `detour`), либо не включён `core_logs_enabled`.
---
## Как читать результат
| core-log строка | Вывод | Дальше |
|---|---|---|
| `rxoffload4/6=true` (+ `dispatch=single`) | GRO взведён, но не разбирается — **корень №1 подтверждён** | чиним submodule (гейт `UDP_GRO` за `!android`) |
| `rxoffload4/6=false` | GRO на этом ядре не активируется — **корень №1 мёртв** | переключаемся на кандидат №2 (тихий хэндовер, `monitor.go`) |
Бонус для физического repro (по желанию): прогнать на **стабильном Wi-Fi** и на
**сотовой** — если download мёртв только на сотовой/при переключениях, это в пользу
кандидата №2 независимо от `rxOffload`.
---
## После замера
Probe — временный. Удалить из `bind_std.go`: две `fmt.Fprintf(... "LX-GRO-PROBE" ...)`
строки, функцию `groProbeDispatch()`, импорт `os` (если он добавлялся только под
probe). Это submodule-правка — не должна попасть в релизное ядро.

Some files were not shown because too many files have changed in this diff Show More