Merge feat/singbox-b5: ITEM6 B5 switchover + V2.0.0
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions

Sing-box becomes the only runtime payload (master serves
singbox_config only; agents switch on its arrival, flush and
retire xray, roll back to xray on activation failure). Plus the
two rehearsal-caught accounting fixes: stats registration needs
all three name lists (recording gate enforces the switches), and
fragment tags now equal panel row tags so inbound totals land.
This commit is contained in:
Muse
2026-10-23 02:43:58 +00:00
14 changed files with 481 additions and 153 deletions
+24 -47
View File
@@ -27,7 +27,6 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行" [[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑" command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
command -v unzip >/dev/null 2>&1 || error "缺少 unzip,请先安装 unzip 后重跑"
MASTER=""; TOKEN=""; NAME="" MASTER=""; TOKEN=""; NAME=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -72,11 +71,13 @@ case "$ARCH" in
*) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";; *) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";;
esac esac
# 发行下载基址(本仓 Gitea Releases,发行 tag V1.0.0):agent 二进制、xray、geo # 发行下载基址(本仓 Gitea Releases,发行 tag V2.0.0):agent 二进制走
# 全部走自建镜像,不再依赖 GitHub CDN(部分节点极慢甚至僵死,见第 15 条)。 # 自建镜像,不再依赖 GitHub CDN(部分节点极慢甚至僵死,见第 15 条)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V1.0.0" # sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}" XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
XRAY_VER="26.6.27"
ROOT="${XUI_ROOT:-}" ROOT="${XUI_ROOT:-}"
XUI_HOME="${ROOT}/usr/local/x-ui" XUI_HOME="${ROOT}/usr/local/x-ui"
@@ -94,34 +95,15 @@ curl -fL -o "${AGENT_BIN}.new" "$XUI_URL" || error "agent 下载失败: $XUI_URL
chmod +x "${AGENT_BIN}.new" chmod +x "${AGENT_BIN}.new"
mv -f "${AGENT_BIN}.new" "${AGENT_BIN}" mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
info "下载 xray v${XRAY_VER}..." # 第 6 条 B5 正式切换:agent 只装 sing-box 核心(自编 v1.13.21,
cd "${XUI_HOME}/bin" # with_v2ray_api with_utls with_quic),xray 与 geo 下载已移除——
case "$BIN_ARCH" in # sing-box 配置零 geo 引用,节点不再需要 geo 文件;xray 本体亦不再
amd64) XRAY_ZIP="Xray-linux-64.zip";; # 随装。存量节点 bin 里已有的 xray/geo 文件本脚本不删(回滚旧主控时
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";; # 仍可用)。sing-box 资产在 NexusGate 仓的专用发行里(与 NexusGate
esac # 共用同一自编件)。其 version 输出为 unknown(自编件无版本戳),
# 每一步都显式判失败并报错退出(第 17 条):历史教训——下载/解压失败曾被 # 故另写版本记录文件供 agent 遥测读取。
# && 链条吞掉,脚本带病走完报成功,结果 bin 里没有 xray、入站全断而面板显在线。 # 每一步都显式判失败并报错退出(第 17 条):历史教训——下载失败曾被
curl -fL -o "$XRAY_ZIP" "${RELEASE_BASE}/${XRAY_ZIP}" || error "xray 下载失败: ${RELEASE_BASE}/${XRAY_ZIP}" # && 链条吞掉,脚本带病走完报成功,结果核心缺失、入站全断而面板显在线。
[[ -s "$XRAY_ZIP" ]] || error "xray 下载得到空文件: ${RELEASE_BASE}/${XRAY_ZIP}"
unzip -o -q "$XRAY_ZIP" xray || error "xray 解压失败: $XRAY_ZIP"
mv xray "xray-linux-${BIN_ARCH}" || error "xray 落位失败(解压产物缺失)"
chmod +x "xray-linux-${BIN_ARCH}"
rm -f "$XRAY_ZIP"
info "下载 geo 文件..."
curl -fL -o geoip.dat "${RELEASE_BASE}/geoip.dat" || error "geoip 下载失败: ${RELEASE_BASE}/geoip.dat"
[[ -s geoip.dat ]] || error "geoip 下载得到空文件: ${RELEASE_BASE}/geoip.dat"
curl -fL -o geosite.dat "${RELEASE_BASE}/geosite.dat" || error "geosite 下载失败: ${RELEASE_BASE}/geosite.dat"
[[ -s geosite.dat ]] || error "geosite 下载得到空文件: ${RELEASE_BASE}/geosite.dat"
cd - >/dev/null
# 第 6 条 B3 起 agent 为双能力:除 xray 外再装 sing-box 核心(自编
# v1.13.21,with_v2ray_api with_utls with_quic),主控下发 sing-box
# 配置时即由它承载。sing-box 资产在 NexusGate 仓的专用发行里(与
# NexusGate 共用同一自编件)。其 version 输出为 unknown(自编件无
# 版本戳),故另写版本记录文件供 agent 遥测读取。xray 与 geo 的
# 下载暂保留——现行运行时仍是 xray,B5 正式切换后才在本脚本移除。
SINGBOX_VER="1.13.21" SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}" SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}" SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
@@ -132,21 +114,11 @@ chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}" mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version" printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
# 安装收尾自检(第 17 条):文件在不等于能用——xray 本体须存在、可执行、 # 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# version 能真实跑通;geo 须存在且非空;agent 本体须可执行。任一不过即 # 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# 报错退出并指出缺什么,不许带病进入写配置/起服务阶段。 # agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。
self_check() { self_check() {
local xray_bin="${XUI_HOME}/bin/xray-linux-${BIN_ARCH}"
[[ -f "$xray_bin" ]] || error "自检失败:缺少 xray 本体 ${xray_bin}"
[[ -x "$xray_bin" ]] || error "自检失败:xray 本体不可执行 ${xray_bin}"
local ver_out
if ! ver_out=$("$xray_bin" version 2>/dev/null); then
error "自检失败:xray 本体无法运行(${xray_bin} version 退出非零)"
fi
[[ -n "$ver_out" ]] || error "自检失败:xray version 无输出(${xray_bin})"
info "自检通过:$(printf '%s' "$ver_out" | head -n 1)"
[[ -s "${XUI_HOME}/bin/geoip.dat" ]] || error "自检失败:geoip.dat 缺失或为空(${XUI_HOME}/bin/geoip.dat)"
[[ -s "${XUI_HOME}/bin/geosite.dat" ]] || error "自检失败:geosite.dat 缺失或为空(${XUI_HOME}/bin/geosite.dat)"
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}" local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}" [[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out local sb_out
@@ -155,6 +127,11 @@ self_check() {
fi fi
[[ -n "$sb_out" ]] || error "自检失败:sing-box version 无输出(${sb_bin})" [[ -n "$sb_out" ]] || error "自检失败:sing-box version 无输出(${sb_bin})"
info "sing-box 自检通过:$(printf '%s' "$sb_out" | head -n 1)(版本记录 ${SINGBOX_VER})" info "sing-box 自检通过:$(printf '%s' "$sb_out" | head -n 1)(版本记录 ${SINGBOX_VER})"
local sb_cfg="${XUI_HOME}/bin/config-singbox.json"
if [[ -s "$sb_cfg" ]]; then
"$sb_bin" check -c "$sb_cfg" >/dev/null 2>&1 || error "自检失败:现行 sing-box 配置未过 check(${sb_cfg})"
info "sing-box 配置自检通过:${sb_cfg}"
fi
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}" [[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
} }
self_check self_check
+113 -16
View File
@@ -97,6 +97,16 @@ type Agent struct {
lastSingboxHash string lastSingboxHash string
sbBaselineLoaded bool sbBaselineLoaded bool
// Switchover state (ITEM6 B5): set once the master serves a
// sing-box config — from then on sing-box is the node's only
// runtime and no xray payload is ever applied again, even if a
// later poll somehow lacks the sing-box keys. lastXrayConfig
// keeps the last applied xray payload so a failed sing-box
// activation during the switch can restart xray and keep the
// node serving while the switch retries on the next poll.
sbSwitched bool
lastXrayConfig json.RawMessage
// sing-box version cache (telemetry), mirroring the xray one. // sing-box version cache (telemetry), mirroring the xray one.
sbVerMu sync.Mutex sbVerMu sync.Mutex
sbVer string sbVer string
@@ -373,7 +383,10 @@ func (a *Agent) requeueProbeBatch(batch []ProbeResult) {
} }
} }
// fetchAndApplyConfig fetches the xray config from master and applies it if changed // fetchAndApplyConfig fetches the config poll response from the
// master and applies what changed: update intents, probe tasks, and
// the runtime config — sing-box since the B5 switchover, xray on a
// legacy master.
func (a *Agent) fetchAndApplyConfig() error { func (a *Agent) fetchAndApplyConfig() error {
url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL) url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL)
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
@@ -410,27 +423,57 @@ func (a *Agent) fetchAndApplyConfig() error {
} }
// One-click update intents ride this poll (item 20); handle them even // One-click update intents ride this poll (item 20); handle them even
// when the xray config itself is unchanged. // when the config itself is unchanged.
a.maybeHandleUpdate(result.AgentUpdate) a.maybeHandleUpdate(result.AgentUpdate)
// sing-box runtime (ITEM6 B3): an independent, parallel path. Its // Probe tasks change independently of any core config, so they
// failures are logged and retried on the next poll; they never fail // are applied before the runtime branches below.
// the xray apply below or mark the poll failed.
if result.SingboxConfigHash != "" && result.SingboxConfigHash != a.lastSingboxHash {
if err := a.applySingboxConfig(result.SingboxConfig); err != nil {
logger.Warningf("Failed to apply sing-box config: %v", err)
} else {
a.lastSingboxHash = result.SingboxConfigHash
logger.Info("sing-box config applied successfully")
}
}
// Probe tasks change independently of the xray config, so they
// are applied before the hash early-return below.
if result.ProbeTasks != nil && a.probes != nil { if result.ProbeTasks != nil && a.probes != nil {
a.probes.applyTasks(*result.ProbeTasks) a.probes.applyTasks(*result.ProbeTasks)
} }
// Switchover (ITEM6 B5): a master that serves a sing-box config
// has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray
// core), and never touch the xray path again — a later poll that
// somehow lacks the sing-box keys leaves both cores as they are
// rather than resurrecting xray.
if result.SingboxConfigHash != "" {
a.sbSwitched = true
if result.SingboxConfigHash != a.lastSingboxHash {
if err := a.switchApplySingbox(result.SingboxConfig); err != nil {
logger.Warningf("Failed to apply sing-box config: %v", err)
} else {
a.lastSingboxHash = result.SingboxConfigHash
logger.Info("sing-box config applied successfully")
}
} else if !a.singboxRunning() {
// The config is current but the core is not running
// (crashed, or never started because the xray flush kept
// failing before the first activation): re-apply through
// the switch sequence — activation restarts a dead core
// and is a no-op for a live one.
if err := a.switchApplySingbox(result.SingboxConfig); err != nil {
logger.Warningf("Failed to restart sing-box core: %v", err)
}
} else if a.xrayProc != nil {
// The sing-box side is live and current, but a leftover
// xray core is still running (its pre-switch flush kept
// failing, or the agent was restarted mid-switch): retire
// it now so the node converges on sing-box alone.
a.retireXrayCore()
}
return nil
}
if a.sbSwitched {
return nil
}
// Legacy path (pre-switchover master): only an xray payload is
// served, applied exactly as before.
if len(result.XrayConfig) == 0 {
return nil
}
// Skip if config hasn't changed // Skip if config hasn't changed
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" { if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
return nil return nil
@@ -442,10 +485,61 @@ func (a *Agent) fetchAndApplyConfig() error {
} }
a.lastConfigHash = result.ConfigHash a.lastConfigHash = result.ConfigHash
a.lastXrayConfig = append(json.RawMessage(nil), result.XrayConfig...)
logger.Info("Xray config applied successfully") logger.Info("Xray config applied successfully")
return nil return nil
} }
// singboxRunning reports whether the managed sing-box process is
// currently alive.
func (a *Agent) singboxRunning() bool {
a.sbMu.Lock()
defer a.sbMu.Unlock()
return a.sbProc != nil && a.sbProc.isRunning()
}
// switchApplySingbox activates a sing-box config in switchover mode.
// While the legacy xray core is still running it owns the node's
// inbound ports, so sing-box could never bind them: the order is
// flush xray's tail traffic (its counters die with the process),
// stop xray, then activate sing-box. A failed flush aborts the
// switch for this poll — nothing has been touched and the next poll
// retries. A failed activation restarts xray from the last applied
// payload so the node keeps serving while the switch retries.
func (a *Agent) switchApplySingbox(raw json.RawMessage) error {
if a.xrayProc == nil {
return a.applySingboxConfig(raw)
}
if err := a.collectAndReportXrayTraffic(); err != nil {
return fmt.Errorf("flush xray traffic before switchover: %w", err)
}
a.stopXray()
if err := a.applySingboxConfig(raw); err != nil {
logger.Warningf("sing-box activation failed during switchover (%v); restarting xray with the last applied config", err)
if len(a.lastXrayConfig) > 0 {
if rbErr := a.applyXrayConfig(a.lastXrayConfig); rbErr != nil {
logger.Warningf("xray restart after failed switchover also failed: %v", rbErr)
}
}
return err
}
logger.Info("switchover complete: xray core retired, sing-box is the node's runtime")
return nil
}
// retireXrayCore flushes and stops a leftover xray core after the
// sing-box side is already current. A failed flush keeps xray
// running (its unreported tail is worth more than a fast retire)
// and the next poll retries.
func (a *Agent) retireXrayCore() {
if err := a.collectAndReportXrayTraffic(); err != nil {
logger.Warningf("flush xray traffic before retiring xray core failed: %v (will retry next poll)", err)
return
}
a.stopXray()
logger.Info("xray core retired: sing-box is the node's runtime")
}
// ensureXrayBinary makes sure the xray binary exists at the panel-layout path. // ensureXrayBinary makes sure the xray binary exists at the panel-layout path.
// The install doc puts it at /usr/local/x-ui/bin/xray; the panel's process // The install doc puts it at /usr/local/x-ui/bin/xray; the panel's process
// manager looks for the arch-specific name, so link one to the other. // manager looks for the arch-specific name, so link one to the other.
@@ -790,6 +884,9 @@ func saveSingboxBaseline(path, epoch string, values map[string]int64) error {
func (a *Agent) collectAndReportSingboxTraffic() error { func (a *Agent) collectAndReportSingboxTraffic() error {
a.sbMu.Lock() a.sbMu.Lock()
defer a.sbMu.Unlock() defer a.sbMu.Unlock()
// Log hygiene rides this cycle (ITEM6 B5): bounded, and a no-op
// until the cap is actually crossed.
rotateSingboxLogIfOversize(singboxLogPath())
if a.sbProc == nil || !a.sbProc.isRunning() { if a.sbProc == nil || !a.sbProc.isRunning() {
return nil return nil
} }
+56 -6
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -18,12 +19,15 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/logger"
) )
// ITEM6 B3: the agent's sing-box runtime, living alongside the xray // ITEM6 B3→B5: the agent's sing-box runtime. The master delivers a
// runtime until the B5 switchover. The master delivers a complete // complete sing-box configuration on the config poll
// sing-box configuration in the same poll response as the xray one // (singbox_config + singbox_config_hash, B2). Since the B5
// (singbox_config + singbox_config_hash, B2); the agent consumes it // switchover that payload is the node's only runtime config: the
// independently: a broken sing-box payload, a missing binary or a // agent treats its arrival as the switch signal, retires the legacy
// failed check never disturbs the xray path, and vice versa. // xray core (see switchApplySingbox in agent.go) and runs sing-box
// alone. A legacy master serves no sing-box keys, and the agent
// then keeps the xray path exactly as before — the dual capability
// is what makes the agents-first rollout order safe.
// //
// Activation borrows NexusGate's discipline (agent-go activateSingBox): // Activation borrows NexusGate's discipline (agent-go activateSingBox):
// candidate file -> `sing-box check` -> flush the running core's // candidate file -> `sing-box check` -> flush the running core's
@@ -82,6 +86,52 @@ func singboxLogPath() string {
return filepath.Join(config.GetBinFolderPath(), "singbox.log") return filepath.Join(config.GetBinFolderPath(), "singbox.log")
} }
// singboxLogMaxBytes bounds the managed core's log on disk (ITEM6
// B5 rotation policy). The online tracker only ever tails the file,
// so beyond a few MB it is pure weight on small node volumes — but
// the log is also the online record, so the cap is generous rather
// than tight. Rotation keeps exactly one previous file
// (singbox.log.1), bounding the pair at ~2× the cap.
const singboxLogMaxBytes = 8 << 20 // 8 MiB
// rotateSingboxLogIfOversize copy-truncates the sing-box log once it
// passes the cap: the content is copied to singbox.log.1, then the
// live file is truncated in place. Copy-truncate (rather than
// rename) because the running core holds the file open — its fd
// stays valid and keeps appending; the online tracker already
// treats a size drop as a cursor reset, so the tail survives the
// rotation. Called on the sing-box traffic cycle; failures only log
// — log hygiene must never disturb the runtime.
func rotateSingboxLogIfOversize(path string) {
fi, err := os.Stat(path)
if err != nil || fi.Size() <= singboxLogMaxBytes {
return
}
src, err := os.Open(path)
if err != nil {
logger.Warningf("sing-box log rotation: open %s: %v", path, err)
return
}
dst, err := os.OpenFile(path+".1", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
_ = src.Close()
logger.Warningf("sing-box log rotation: create %s.1: %v", path, err)
return
}
_, copyErr := io.Copy(dst, src)
closeErr := dst.Close()
_ = src.Close()
if copyErr != nil || closeErr != nil {
logger.Warningf("sing-box log rotation: copy %s: %v / %v", path, copyErr, closeErr)
return
}
if err := os.Truncate(path, 0); err != nil {
logger.Warningf("sing-box log rotation: truncate %s: %v", path, err)
return
}
logger.Infof("sing-box log rotated at %d bytes (previous kept as %s.1)", fi.Size(), path)
}
// singboxProcess manages one sing-box child process: lifecycle only, // singboxProcess manages one sing-box child process: lifecycle only,
// no configuration semantics (mirrors the xray process discipline: // no configuration semantics (mirrors the xray process discipline:
// done channel as the race-free liveness signal, SIGTERM then SIGKILL). // done channel as the race-free liveness signal, SIGTERM then SIGKILL).
+152
View File
@@ -0,0 +1,152 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// ITEM6 B5 switchover tests: the poll-level switch sequence. The
// fake xray binary stands in for the legacy core so the flush →
// stop → activate → (rollback) ordering can be pinned without a
// real core; real-core behavior is covered by the env-gated E2E.
const fakeXrayScript = `#!/bin/sh
case "$1" in
-version|version)
echo "Xray 26.6.27 (fake)"
exit 0
;;
esac
exec sleep 3600
`
func setupFakeXray(t *testing.T, binDir string) {
t.Helper()
path := filepath.Join(binDir, xray.GetBinaryName())
if err := os.WriteFile(path, []byte(fakeXrayScript), 0o755); err != nil {
t.Fatal(err)
}
}
// startLegacyXray puts the agent into the pre-switchover state: a
// running xray core whose payload is remembered for rollback.
func startLegacyXray(t *testing.T, a *Agent) {
t.Helper()
cfg := json.RawMessage(`{"inbounds":[],"outbounds":[]}`)
if err := a.applyXrayConfig(cfg); err != nil {
t.Fatalf("apply legacy xray config: %v", err)
}
a.lastXrayConfig = append(json.RawMessage(nil), cfg...)
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
t.Fatal("fake xray core not running")
}
}
func TestSwitchoverRetiresXrayAndRunsSingbox(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
a := newApplyTestAgent()
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.switchApplySingbox([]byte(sbCfgA)); err != nil {
t.Fatalf("switch apply: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired by the switchover")
}
if !a.singboxRunning() {
t.Fatal("sing-box must be running after the switchover")
}
if got := appliedConfig(t); got != sbCfgA {
t.Fatalf("applied config = %q, want cfgA", got)
}
}
func TestSwitchoverFailureRestartsXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
a := newApplyTestAgent()
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
bad := `{"log":{"level":"info"},"inbounds":[{"type":"BADSCHEMA"}]}`
if err := a.switchApplySingbox([]byte(bad)); err == nil {
t.Fatal("switch to a check-failing config must fail")
}
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
t.Fatal("xray must be restarted from the last payload after a failed switchover")
}
if a.singboxRunning() {
t.Fatal("sing-box must not be running after a failed switchover")
}
}
func TestFetchAndApplySwitchoverIgnoresLegacyAfterSwitch(t *testing.T) {
setupFakeSingbox(t)
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
a := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"})
t.Cleanup(a.stopSingbox)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetch+apply: %v", err)
}
if !a.sbSwitched {
t.Fatal("agent must record the switchover once the master serves sing-box")
}
if !a.singboxRunning() {
t.Fatal("sing-box must be running after the switchover poll")
}
if a.lastSingboxHash != hex.EncodeToString(sum[:]) {
t.Fatalf("lastSingboxHash = %q, want the served hash", a.lastSingboxHash)
}
}
func TestRotateSingboxLogIfOversize(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "singbox.log")
small := []byte("one line\n")
if err := os.WriteFile(path, small, 0o640); err != nil {
t.Fatal(err)
}
rotateSingboxLogIfOversize(path)
if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) {
t.Fatal("a small log must not be rotated")
}
big := make([]byte, singboxLogMaxBytes+123)
for i := range big {
big[i] = 'x'
}
if err := os.WriteFile(path, big, 0o640); err != nil {
t.Fatal(err)
}
rotateSingboxLogIfOversize(path)
fi, err := os.Stat(path)
if err != nil || fi.Size() != 0 {
t.Fatalf("live log after rotation: size=%v err=%v, want truncated to 0", fi, err)
}
prev, err := os.Stat(path + ".1")
if err != nil || prev.Size() != int64(len(big)) {
t.Fatalf("previous log: size=%v err=%v, want %d", prev, err, len(big))
}
}
+5 -5
View File
@@ -78,7 +78,7 @@ func TestAgentUpdateSwapsAndRestarts(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL) a, exe := newUpdateTestAgent(t, srv.URL)
execCalls := stubExec(t, nil) execCalls := stubExec(t, nil)
a.runUpdate(updateIntent{RequestedAt: 42, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) a.runUpdate(updateIntent{RequestedAt: 42, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe) got, _ := os.ReadFile(exe)
if string(got) != "new-binary" { if string(got) != "new-binary" {
@@ -102,7 +102,7 @@ func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL) a, exe := newUpdateTestAgent(t, srv.URL)
execCalls := stubExec(t, nil) execCalls := stubExec(t, nil)
a.runUpdate(updateIntent{RequestedAt: 43, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) a.runUpdate(updateIntent{RequestedAt: 43, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe) got, _ := os.ReadFile(exe)
if string(got) != "old-binary" { if string(got) != "old-binary" {
@@ -127,8 +127,8 @@ func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL) a, exe := newUpdateTestAgent(t, srv.URL)
stubExec(t, nil) stubExec(t, nil)
// The test binary's display version defaults to A1.0.0. // The test binary's display version defaults to A2.0.0.
a.runUpdate(updateIntent{RequestedAt: 44, Target: "A1.0.0", Tag: "V1.0.0", BaseURL: srv.URL + "/"}) a.runUpdate(updateIntent{RequestedAt: 44, Target: "A2.0.0", Tag: "V2.0.0", BaseURL: srv.URL + "/"})
if hits.Load() != 0 { if hits.Load() != 0 {
t.Fatalf("release server hit %d times for an already-current agent", hits.Load()) t.Fatalf("release server hit %d times for an already-current agent", hits.Load())
@@ -148,7 +148,7 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
a, exe := newUpdateTestAgent(t, srv.URL) a, exe := newUpdateTestAgent(t, srv.URL)
stubExec(t, errExecBoom) stubExec(t, errExecBoom)
a.runUpdate(updateIntent{RequestedAt: 45, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) a.runUpdate(updateIntent{RequestedAt: 45, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
got, _ := os.ReadFile(exe) got, _ := os.ReadFile(exe)
if string(got) != "old-binary" { if string(got) != "old-binary" {
+1 -1
View File
@@ -112,7 +112,7 @@ func GetPanelVersion() string {
// defaultDisplayVersion is the display version reported when a build does // defaultDisplayVersion is the display version reported when a build does
// not inject one (plain `go build` / `go run`). // not inject one (plain `go build` / `go run`).
const defaultDisplayVersion = "1.0.0" const defaultDisplayVersion = "2.0.0"
// GetDisplayVersion returns the bare numeric display version ("1.0.0"): // GetDisplayVersion returns the bare numeric display version ("1.0.0"):
// the injected display version when the build provided one, else the // the injected display version when the build provided one, else the
+6 -6
View File
@@ -58,14 +58,14 @@ func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
t.Cleanup(func() { displayVersion = orig }) t.Cleanup(func() { displayVersion = orig })
displayVersion = "" displayVersion = ""
if got := GetDisplayVersion(); got != "1.0.0" { if got := GetDisplayVersion(); got != "2.0.0" {
t.Fatalf("default display version = %q, want 1.0.0", got) t.Fatalf("default display version = %q, want 2.0.0", got)
} }
if got := GetMasterDisplayVersion(); got != "V1.0.0" { if got := GetMasterDisplayVersion(); got != "V2.0.0" {
t.Fatalf("default master display version = %q, want V1.0.0", got) t.Fatalf("default master display version = %q, want V2.0.0", got)
} }
if got := GetAgentDisplayVersion(); got != "A1.0.0" { if got := GetAgentDisplayVersion(); got != "A2.0.0" {
t.Fatalf("default agent display version = %q, want A1.0.0", got) t.Fatalf("default agent display version = %q, want A2.0.0", got)
} }
displayVersion = "1.0.1" displayVersion = "1.0.1"
+1
View File
@@ -437,6 +437,7 @@ func convertOneInbound(tx *gorm.DB, ib *model.Inbound, rep *singboxMigrationRepo
Listen: ib.Listen, Listen: ib.Listen,
Port: ib.Port, Port: ib.Port,
Protocol: string(ib.Protocol), Protocol: string(ib.Protocol),
Tag: ib.Tag,
DisableFlow: ib.DisableFlow, DisableFlow: ib.DisableFlow,
Settings: json.RawMessage(ib.Settings), Settings: json.RawMessage(ib.Settings),
StreamSettings: json.RawMessage(ib.StreamSettings), StreamSettings: json.RawMessage(ib.StreamSettings),
+21 -1
View File
@@ -50,6 +50,11 @@ type Inbound struct {
Listen string Listen string
Port int Port int
Protocol string Protocol string
// Tag is the inbound row's panel tag; it becomes the fragment's
// sing-box tag so core stats and agent traffic reports key
// inbound totals by the panel's own identity (see fragmentTag).
// Empty keeps the historical inbound-<port> form.
Tag string
DisableFlow bool // xray DisableInboundFlow switch value for this inbound DisableFlow bool // xray DisableInboundFlow switch value for this inbound
Settings json.RawMessage Settings json.RawMessage
StreamSettings json.RawMessage StreamSettings json.RawMessage
@@ -335,12 +340,27 @@ func (c *conv) finishDisposition() {
c.res.Disposition = "direct" c.res.Disposition = "direct"
} }
// fragmentTag is the fragment's sing-box tag: the inbound row's
// panel tag, so core stats and the agent's traffic reports key
// inbound totals by the same identity the panel stores (the
// master's agent traffic handler matches reports by row tag —
// with the historical inbound-<port> form those lookups silently
// never matched once panel tags moved to the n<node>-in- scheme;
// ITEM6 B5 rehearsal). Rows without a panel tag (bare converter
// fixtures) keep the historical form.
func (c *conv) fragmentTag() string {
if c.in.Tag != "" {
return c.in.Tag
}
return fmt.Sprintf("inbound-%d", c.in.Port)
}
// base builds the common fragment head. WireGuard endpoints and tun // base builds the common fragment head. WireGuard endpoints and tun
// inbounds do not take listen/listen_port and build their own head. // inbounds do not take listen/listen_port and build their own head.
func (c *conv) base(typ string) map[string]any { func (c *conv) base(typ string) map[string]any {
frag := map[string]any{ frag := map[string]any{
"type": typ, "type": typ,
"tag": fmt.Sprintf("inbound-%d", c.in.Port), "tag": c.fragmentTag(),
} }
if c.in.Listen != "" { if c.in.Listen != "" {
frag["listen"] = c.in.Listen frag["listen"] = c.in.Listen
+2 -3
View File
@@ -2,7 +2,6 @@ package singbox
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"strconv" "strconv"
) )
@@ -315,7 +314,7 @@ func convertMasquerade(mz map[string]any) map[string]any {
func (c *conv) convertWireguard() error { func (c *conv) convertWireguard() error {
frag := map[string]any{ frag := map[string]any{
"type": "wireguard", "type": "wireguard",
"tag": fmt.Sprintf("inbound-%d", c.in.Port), "tag": c.fragmentTag(),
} }
c.res.Endpoint = true c.res.Endpoint = true
if c.in.Port != 0 { if c.in.Port != 0 {
@@ -385,7 +384,7 @@ func deriveWireguardAddress(settings map[string]any) string {
func (c *conv) convertTun() error { func (c *conv) convertTun() error {
frag := map[string]any{ frag := map[string]any{
"type": "tun", "type": "tun",
"tag": fmt.Sprintf("inbound-%d", c.in.Port), "tag": c.fragmentTag(),
} }
if name := gStr(c.set, "name"); name != "" { if name := gStr(c.set, "name"); name != "" {
frag["interface_name"] = name frag["interface_name"] = name
+49 -35
View File
@@ -591,7 +591,20 @@ func (a *AgentController) heartbeat(c *gin.Context) {
jsonObj(c, gin.H{"message": "ok"}, nil) jsonObj(c, gin.H{"message": "ok"}, nil)
} }
// getConfig returns the full xray config for the agent's bound node. // getConfig returns the full sing-box config for the agent's bound node.
//
// ITEM6 B5 (switchover): the sing-box config is the only runtime
// payload this master serves. The xray payload (config_hash /
// xray_config) is retired — generation reads only the sing-box data
// model since the B1 migration. Dual-capability agents (A2.0.0) take
// singbox_config as the switch signal: they flush their xray tail
// traffic, stop the xray core and run sing-box alone. Older agents
// ignore the unknown keys and keep running their last applied xray
// config until they are updated, which is why the rollout order is
// agents first, master second (ITEM6-SCOPE §6). A build failure is a
// hard 500: the agent keeps its current runtime state and retries on
// the next poll, and the failure stays visible in both logs instead
// of silently freezing the node's config.
func (a *AgentController) getConfig(c *gin.Context) { func (a *AgentController) getConfig(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c)) agent, ok := a.findAgent(bearerToken(c))
if !ok { if !ok {
@@ -603,20 +616,22 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"}) c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return return
} }
cfg, err := a.xrayService.GetXrayConfigForAgent(*agent.NodeID, agent.Outbounds, agent.RoutingRules) sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if err != nil { if sbErr != nil {
jsonMsg(c, "Failed to build xray config", err) logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return return
} }
raw, err := json.Marshal(cfg) sbRaw, mErr := json.Marshal(sbCfg)
if err != nil { if mErr != nil {
jsonMsg(c, "Failed to encode xray config", err) logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return return
} }
sum := sha256.Sum256(raw) sbSum := sha256.Sum256(sbRaw)
payload := gin.H{ payload := gin.H{
"config_hash": hex.EncodeToString(sum[:]), "singbox_config": json.RawMessage(sbRaw),
"xray_config": json.RawMessage(raw), "singbox_config_hash": hex.EncodeToString(sbSum[:]),
// Item 7 B4: the agent's probe task set rides the existing // Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this // config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while // master version — an empty list means "run no probes", while
@@ -624,22 +639,6 @@ func (a *AgentController) getConfig(c *gin.Context) {
// set untouched. // set untouched.
"probe_tasks": probe.PingTasksFor(int64(agent.Id)), "probe_tasks": probe.PingTasksFor(int64(agent.Id)),
} }
// ITEM6 B2: the sing-box shape rides alongside the xray config
// (parallel generation path; agents on the current runtime ignore
// the unknown keys, B3's runtime consumes them). A sing-box build
// failure must never take down the production xray payload, so it
// is logged and the keys are simply omitted.
if sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules); sbErr == nil {
if sbRaw, mErr := json.Marshal(sbCfg); mErr == nil {
sbSum := sha256.Sum256(sbRaw)
payload["singbox_config"] = json.RawMessage(sbRaw)
payload["singbox_config_hash"] = hex.EncodeToString(sbSum[:])
} else {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
}
} else {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
}
// Item 20: a pending one-click update intent rides along on the config // Item 20: a pending one-click update intent rides along on the config
// poll (no new channel — the agent already polls this endpoint). // poll (no new channel — the agent already polls this endpoint).
if agent.UpdateRequestedAt > 0 && if agent.UpdateRequestedAt > 0 &&
@@ -685,8 +684,21 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
jsonMsg(c, "Invalid request", err) jsonMsg(c, "Invalid request", err)
return return
} }
// ITEM6 B5 recording gate: a sing-box core registers counters
// only when every stats family is listed in its config, so the
// generated config can no longer omit a switched-off category
// (see the stats wiring in SingboxService). The whole-network
// accounting switches (settings.trafficStatsSwitches, the
// master switch on the Settings page) are therefore enforced
// here, at recording time: with the inbound category off
// (either direction), inbound totals and per-user traffic are
// accepted but not recorded — the off period is never counted,
// matching the switches' documented no-backfill semantics.
// Online presence is not traffic accounting and stays ungated.
sw := a.singboxService.GetTrafficStatsSwitches()
recordInbound := sw.InboundUplink && sw.InboundDownlink
db := database.GetDB() db := database.GetDB()
if agent.NodeID != nil { if recordInbound && agent.NodeID != nil {
for _, ib := range req.Inbounds { for _, ib := range req.Inbounds {
if ib.Tag == "" || ib.Up+ib.Down <= 0 { if ib.Tag == "" || ib.Up+ib.Down <= 0 {
continue continue
@@ -700,15 +712,17 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
} }
} }
var clientTraffics []*xray.ClientTraffic var clientTraffics []*xray.ClientTraffic
for _, u := range req.Users { if recordInbound {
if u.Email == "" || u.Up+u.Down <= 0 { for _, u := range req.Users {
continue if u.Email == "" || u.Up+u.Down <= 0 {
continue
}
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: u.Email,
Up: u.Up,
Down: u.Down,
})
} }
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: u.Email,
Up: u.Up,
Down: u.Down,
})
} }
if len(clientTraffics) > 0 { if len(clientTraffics) > 0 {
if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil { if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil {
@@ -13,11 +13,11 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
) )
// ITEM6 B2: the agent config endpoint serves the sing-box shape // ITEM6 B2→B5: the agent config endpoint serves the sing-box shape.
// alongside the xray config (parallel generation path). The xray // Since the B5 switchover it is the ONLY runtime payload — the xray
// payload must be untouched; the singbox keys carry the node's // keys (config_hash / xray_config) are retired and must not reappear;
// fragments re-projected against the identity layer, plus a hash of // the singbox keys carry the node's fragments re-projected against
// the exact bytes served. // the identity layer, plus a hash of the exact bytes served.
const sbToken = "singbox-config-token-00000000000000000001" const sbToken = "singbox-config-token-00000000000000000001"
func TestConfigCarriesSingboxShape(t *testing.T) { func TestConfigCarriesSingboxShape(t *testing.T) {
@@ -94,8 +94,8 @@ func TestConfigCarriesSingboxShape(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("config response not JSON: %v", err) t.Fatalf("config response not JSON: %v", err)
} }
if resp.ConfigHash == "" || len(resp.XrayConfig) == 0 { if resp.ConfigHash != "" || len(resp.XrayConfig) != 0 {
t.Fatalf("xray payload damaged: hash=%q len=%d", resp.ConfigHash, len(resp.XrayConfig)) t.Fatalf("xray payload must be retired since B5: hash=%q len=%d", resp.ConfigHash, len(resp.XrayConfig))
} }
if len(resp.SingboxConfig) == 0 || resp.SingboxConfigHash == "" { if len(resp.SingboxConfig) == 0 || resp.SingboxConfigHash == "" {
t.Fatalf("singbox payload missing: len=%d hash=%q", len(resp.SingboxConfig), resp.SingboxConfigHash) t.Fatalf("singbox payload missing: len=%d hash=%q", len(resp.SingboxConfig), resp.SingboxConfigHash)
+31 -18
View File
@@ -521,29 +521,42 @@ func assembleSingboxConfig(opts assembleSingboxOptions) map[string]any {
"final": "direct", "final": "direct",
} }
// Stats: sing-box v2ray_api counters exist per category only, so // Stats: the block exists when at least one category gate is on
// a category is generated only when BOTH of its direction // (a category counts only when BOTH of its direction switches
// switches are on (a category with either direction off is not // are on). All three name lists are always generated together:
// generated at all — the off period's traffic is never counted, // sing-box's v2ray_api stats manager registers NO counters at
// matching the switches' documented no-backfill semantics). // all unless the inbounds, users AND outbounds lists are all
// present — verified against the real core in the ITEM6 B5
// rehearsal (a config without "outbounds" answered QueryStats
// with zero entries while traffic flowed; adding the list
// produced full user/inbound/outbound counters). The per-
// category counting policy therefore cannot be enforced by
// omission here; the master enforces it at recording time
// (agent traffic report gate) instead. Outbound counters are
// registered but never reported by the agent, so their presence
// costs nothing.
inboundGate := opts.switches.InboundUplink && opts.switches.InboundDownlink inboundGate := opts.switches.InboundUplink && opts.switches.InboundDownlink
outboundGate := opts.switches.OutboundUplink && opts.switches.OutboundDownlink outboundGate := opts.switches.OutboundUplink && opts.switches.OutboundDownlink
if inboundGate || outboundGate { if inboundGate || outboundGate {
stats := map[string]any{"enabled": true} outboundTags := []string{}
if inboundGate { for _, o := range outbounds {
stats["inbounds"] = inboundTags if om, ok := o.(map[string]any); ok {
stats["users"] = userNames if t, _ := om["tag"].(string); t != "" {
} outboundTags = append(outboundTags, t)
if outboundGate {
var outboundTags []string
for _, o := range outbounds {
if om, ok := o.(map[string]any); ok {
if t, _ := om["tag"].(string); t != "" {
outboundTags = append(outboundTags, t)
}
} }
} }
stats["outbounds"] = outboundTags }
if inboundTags == nil {
inboundTags = []string{}
}
if userNames == nil {
userNames = []string{}
}
stats := map[string]any{
"enabled": true,
"inbounds": inboundTags,
"users": userNames,
"outbounds": outboundTags,
} }
cfg["experimental"] = map[string]any{ cfg["experimental"] = map[string]any{
"v2ray_api": map[string]any{ "v2ray_api": map[string]any{
+13 -8
View File
@@ -262,8 +262,13 @@ func loadGenerationFixture(t *testing.T, dir string) (*model.Inbound, singbox.In
} }
// TestSingboxStatsGates pins the trafficStatsSwitches → v2ray_api // TestSingboxStatsGates pins the trafficStatsSwitches → v2ray_api
// stats wiring: a category is generated only when both of its // stats wiring: the block is generated when at least one category
// direction switches are on; with all four off there is no // gate is on (both direction switches), and all three name lists
// are always present together — sing-box registers no counters at
// all unless inbounds, users and outbounds are all listed (real-
// core verified in the ITEM6 B5 rehearsal). The per-category
// counting policy is enforced at recording time on the master,
// not by omission here. With all four switches off there is no
// experimental block at all. // experimental block at all.
func TestSingboxStatsGates(t *testing.T) { func TestSingboxStatsGates(t *testing.T) {
mkItems := func() []*singboxInboundItem { mkItems := func() []*singboxInboundItem {
@@ -301,8 +306,8 @@ func TestSingboxStatsGates(t *testing.T) {
t.Errorf("inbounds list = %v", got) t.Errorf("inbounds list = %v", got)
} }
} }
if _, ok := stats["outbounds"]; ok { if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbound-off: outbounds list generated anyway") t.Errorf("outbounds list must always accompany the stats block (sing-box registers no counters without it)")
} }
usersJSON := marshalIndentValue(stats["users"]) usersJSON := marshalIndentValue(stats["users"])
if !strings.Contains(usersJSON, "a@example.com") || !strings.Contains(usersJSON, "b@example.com") { if !strings.Contains(usersJSON, "a@example.com") || !strings.Contains(usersJSON, "b@example.com") {
@@ -315,11 +320,11 @@ func TestSingboxStatsGates(t *testing.T) {
if stats == nil { if stats == nil {
t.Fatalf("outbound-on: no stats block") t.Fatalf("outbound-on: no stats block")
} }
if _, ok := stats["inbounds"]; ok { if _, ok := stats["inbounds"]; !ok {
t.Errorf("inbound half-off (downlink off): inbounds list generated anyway") t.Errorf("inbound half-off: inbounds list must still accompany the block (registration requirement; recording gate enforces the policy)")
} }
if _, ok := stats["users"]; ok { if _, ok := stats["users"]; !ok {
t.Errorf("inbound half-off: users list generated anyway") t.Errorf("inbound half-off: users list must still accompany the block (registration requirement)")
} }
if _, ok := stats["outbounds"]; !ok { if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbound-on: outbounds list missing") t.Errorf("outbound-on: outbounds list missing")