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
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:
+24
-47
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user