Compare commits
7
Commits
main
..
Zzerg0Pack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa02b6ba4 | ||
|
|
9582105ab9 | ||
|
|
f39d84c4da | ||
|
|
a9b40ee9ce | ||
|
|
df6bdaa616 | ||
|
|
3ba36a7bd5 | ||
|
|
55498882f7 |
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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 +0,0 @@
|
||||
98d539ce67568fb911654e66a14cf4247ed833ec
|
||||
@@ -1 +0,0 @@
|
||||
github: nekohasekai
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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`. |
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -1,364 +1,72 @@
|
||||
<!-- Язык: **Русский** · [English](README.en.md) -->
|
||||
|
||||
# shater
|
||||
|
||||
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
|
||||
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
|
||||
трекеров и вредоносных доменов, средство родительского контроля по устройствам
|
||||
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
|
||||
настраивается из богатой веб-панели.
|
||||
**OpenWrt-плагин управления XRAY** — как passwall2, только чище, быстрее и без лагов.
|
||||
Переносимый: подписки → ноды → цепочки → правила → выбор egress, с прозрачным роутингом,
|
||||
железной надёжностью, per-consumer статистикой и удобным UI.
|
||||
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
> Статус: **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>
|
||||
|
||||
@@ -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 — опционально, отложено.
|
||||
@@ -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-теги) — но файлов с этими тегами пока нет, это нормально.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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 должны совпадать).
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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) — добавлять только при подтверждённой необходимости.
|
||||
@@ -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)
|
||||
@@ -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` работают, не блокер).
|
||||
@@ -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).
|
||||
@@ -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/)
|
||||
@@ -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 ловит раньше).
|
||||
@@ -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
Reference in New Issue
Block a user