ITEM6 B3: agent sing-box runtime (parallel path)
Agent consumes singbox_config alongside xray: candidate->check-> flush->swap activation with .previous rollback, wire-level v2ray_api stats client with NexusGate epoch discipline (mid-query flip voids the round, new epoch counts in full, agent-first snapshot baselines), access-log online tracker (authenticated-connection lines, ANSI-safe, merged with snapshot-active users), per-core traffic baselines with the sing-box one carrying its epoch. install-agent.sh installs the self-built sing-box v1.13.21 next to xray (xray/geo removal deferred to the B5 switchover). Env-gated real-core E2E covers vless-reality, ss2022, mixed, trojan and the forwarding leg end to end; hysteria2 is probe-gated (sandbox blocks UDP replies) and stays a B5 pilot item.
This commit is contained in:
@@ -116,6 +116,22 @@ curl -fL -o geosite.dat "${RELEASE_BASE}/geosite.dat" || error "geosite 下载
|
||||
[[ -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_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}}"
|
||||
info "下载 sing-box v${SINGBOX_VER}..."
|
||||
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
|
||||
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
|
||||
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}"
|
||||
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
|
||||
|
||||
# 安装收尾自检(第 17 条):文件在不等于能用——xray 本体须存在、可执行、
|
||||
# version 能真实跑通;geo 须存在且非空;agent 本体须可执行。任一不过即
|
||||
# 报错退出并指出缺什么,不许带病进入写配置/起服务阶段。
|
||||
@@ -131,6 +147,14 @@ self_check() {
|
||||
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}"
|
||||
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
|
||||
local sb_out
|
||||
if ! sb_out=$("$sb_bin" version 2>/dev/null); then
|
||||
error "自检失败:sing-box 本体无法运行(${sb_bin} version 退出非零)"
|
||||
fi
|
||||
[[ -n "$sb_out" ]] || error "自检失败:sing-box version 无输出(${sb_bin})"
|
||||
info "sing-box 自检通过:$(printf '%s' "$sb_out" | head -n 1)(版本记录 ${SINGBOX_VER})"
|
||||
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
|
||||
}
|
||||
self_check
|
||||
|
||||
+178
-5
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -84,6 +85,37 @@ type Agent struct {
|
||||
updateFailedAt time.Time
|
||||
updateReport *agentUpdateStatus
|
||||
exePathOverride string
|
||||
|
||||
// sing-box runtime (ITEM6 B3, see singbox.go). sbMu serializes
|
||||
// config activation, the sing-box traffic cycle and shutdown: a
|
||||
// flush-before-change must never race a periodic snapshot.
|
||||
sbMu sync.Mutex
|
||||
sbProc *singboxProcess
|
||||
sbStats *singboxStatsClient
|
||||
sbOnline *singboxOnlineTracker
|
||||
sbStatsAddr string
|
||||
lastSingboxHash string
|
||||
sbBaselineLoaded bool
|
||||
|
||||
// sing-box version cache (telemetry), mirroring the xray one.
|
||||
sbVerMu sync.Mutex
|
||||
sbVer string
|
||||
sbVerFor string
|
||||
sbVerMod time.Time
|
||||
}
|
||||
|
||||
// ensureSingboxState lazily creates the sing-box runtime state.
|
||||
// Callers hold sbMu (or are in single-threaded setup).
|
||||
func (a *Agent) ensureSingboxState() {
|
||||
if a.sbProc == nil {
|
||||
a.sbProc = &singboxProcess{}
|
||||
}
|
||||
if a.sbStats == nil {
|
||||
a.sbStats = newSingboxStatsClient(a.sbProc.epoch)
|
||||
}
|
||||
if a.sbOnline == nil {
|
||||
a.sbOnline = newSingboxOnlineTracker(singboxLogPath())
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new agent instance
|
||||
@@ -239,6 +271,7 @@ func (a *Agent) Run() error {
|
||||
case <-a.stopCh:
|
||||
logger.Info("Agent stopping...")
|
||||
a.stopXray()
|
||||
a.stopSingbox()
|
||||
return nil
|
||||
case <-heartbeatTicker.C:
|
||||
if err := a.sendHeartbeat(); err != nil {
|
||||
@@ -324,9 +357,11 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
AgentUpdate *updateIntent `json:"agent_update"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
SingboxConfig json.RawMessage `json:"singbox_config"`
|
||||
SingboxConfigHash string `json:"singbox_config_hash"`
|
||||
AgentUpdate *updateIntent `json:"agent_update"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
@@ -336,6 +371,18 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
// when the xray config itself is unchanged.
|
||||
a.maybeHandleUpdate(result.AgentUpdate)
|
||||
|
||||
// sing-box runtime (ITEM6 B3): an independent, parallel path. Its
|
||||
// failures are logged and retried on the next poll; they never fail
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if config hasn't changed
|
||||
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
|
||||
return nil
|
||||
@@ -613,11 +660,25 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectAndReportTraffic snapshots the local xray counters and reports
|
||||
// collectAndReportTraffic runs one reporting cycle per running core
|
||||
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate
|
||||
// clients and baselines; a failure in one never blocks the other.
|
||||
func (a *Agent) collectAndReportTraffic() error {
|
||||
var firstErr error
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
if err := a.collectAndReportSingboxTraffic(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// collectAndReportXrayTraffic snapshots the local xray counters and reports
|
||||
// the deltas since the last master-acknowledged baseline. The XrayAPI
|
||||
// instance is kept across polls so baselines survive; xray restarts are
|
||||
// handled (counters resetting to zero are treated as fresh).
|
||||
func (a *Agent) collectAndReportTraffic() error {
|
||||
func (a *Agent) collectAndReportXrayTraffic() error {
|
||||
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
@@ -632,6 +693,118 @@ func (a *Agent) collectAndReportTraffic() error {
|
||||
return a.reportTrafficWith(&a.xrayAPI, a.postTrafficReport)
|
||||
}
|
||||
|
||||
// singboxBaselineFile is the on-disk form of the sing-box tracker's
|
||||
// last committed baseline. The epoch travels with the values: after
|
||||
// an agent restart against a still-running (or since-restarted) core,
|
||||
// a baseline from another core instance must not be restored.
|
||||
type singboxBaselineFile struct {
|
||||
Version int `json:"version"`
|
||||
Epoch string `json:"epoch"`
|
||||
Values map[string]int64 `json:"values"`
|
||||
}
|
||||
|
||||
// singboxBaselinePath derives the sing-box baseline location from the
|
||||
// xray one (agent-traffic-baseline.json -> agent-traffic-baseline-singbox.json),
|
||||
// so main's placement next to agent.json carries over.
|
||||
func (a *Agent) singboxBaselinePath() string {
|
||||
p := a.baselinePath()
|
||||
if strings.HasSuffix(p, ".json") {
|
||||
return strings.TrimSuffix(p, ".json") + "-singbox.json"
|
||||
}
|
||||
return p + "-singbox"
|
||||
}
|
||||
|
||||
func loadSingboxBaseline(path string) (*singboxBaselineFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var f singboxBaselineFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse sing-box traffic baseline: %w", err)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func saveSingboxBaseline(path, epoch string, values map[string]int64) error {
|
||||
data, err := json.Marshal(singboxBaselineFile{Version: 1, Epoch: epoch, Values: values})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// collectAndReportSingboxTraffic runs one sing-box reporting cycle.
|
||||
func (a *Agent) collectAndReportSingboxTraffic() error {
|
||||
a.sbMu.Lock()
|
||||
defer a.sbMu.Unlock()
|
||||
if a.sbProc == nil || !a.sbProc.isRunning() {
|
||||
return nil
|
||||
}
|
||||
return a.reportSingboxTrafficLocked()
|
||||
}
|
||||
|
||||
// reportSingboxTrafficLocked is the sing-box counterpart of
|
||||
// reportTrafficWith, with the same contract — commit only after the
|
||||
// master acknowledges (HTTP 200), persist after every commit — plus
|
||||
// the epoch carried through restore/persist. The online set merges
|
||||
// the access-log tracker with the users whose counters just moved.
|
||||
// Callers hold sbMu.
|
||||
func (a *Agent) reportSingboxTrafficLocked() error {
|
||||
a.ensureSingboxState()
|
||||
if a.sbStatsAddr == "" {
|
||||
// The applied config carries no v2ray_api stats block (the
|
||||
// panel-side stats switches are off): nothing exists to read.
|
||||
return nil
|
||||
}
|
||||
a.sbStats.SetAddr(a.sbStatsAddr)
|
||||
|
||||
if !a.sbBaselineLoaded {
|
||||
a.sbBaselineLoaded = true
|
||||
if f, err := loadSingboxBaseline(a.singboxBaselinePath()); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
logger.Warningf("sing-box traffic baseline at %s unusable (%v); starting from a fresh baseline", a.singboxBaselinePath(), err)
|
||||
}
|
||||
} else {
|
||||
a.sbStats.RestoreTrafficBaseline(f.Values, f.Epoch)
|
||||
}
|
||||
}
|
||||
|
||||
a.sbOnline.Poll(time.Now())
|
||||
|
||||
snap, err := a.sbStats.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
if errors.Is(err, errSingboxEpochChanged) {
|
||||
// The core restarted around this poll (config change or
|
||||
// crash): this round establishes nothing; the next poll
|
||||
// rebases onto the new instance. Not a fault.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
report := buildTrafficReport(snap, mergeOnlineUsers(a.sbOnline.Online(time.Now()), snap))
|
||||
if !report.empty() {
|
||||
if err := a.postTrafficReport(report); err != nil {
|
||||
// No commit: deltas are recomputed against the same
|
||||
// baseline next poll, exactly like the xray path.
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
a.sbStats.CommitTrafficSnapshot(snap)
|
||||
if err := saveSingboxBaseline(a.singboxBaselinePath(), a.sbProc.epoch(), snap.BaselineValues()); err != nil {
|
||||
// The in-memory commit stands (see the xray path's reasoning).
|
||||
logger.Warningf("Failed to persist sing-box traffic baseline: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig loads agent config from file
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// ITEM6 B3: the agent's sing-box runtime, living alongside the xray
|
||||
// runtime until the B5 switchover. The master delivers a complete
|
||||
// sing-box configuration in the same poll response as the xray one
|
||||
// (singbox_config + singbox_config_hash, B2); the agent consumes it
|
||||
// independently: a broken sing-box payload, a missing binary or a
|
||||
// failed check never disturbs the xray path, and vice versa.
|
||||
//
|
||||
// Activation borrows NexusGate's discipline (agent-go activateSingBox):
|
||||
// candidate file -> `sing-box check` -> flush the running core's
|
||||
// counters -> swap with a .previous copy kept -> start -> on failure
|
||||
// restore .previous and start that. A config whose bytes are unchanged
|
||||
// and whose process is alive is a no-op.
|
||||
|
||||
// errSingboxNotInstalled marks "the sing-box binary is not on this
|
||||
// node yet" — an expected state during the rollout, logged without
|
||||
// alarming the operator every poll (apply is only attempted when the
|
||||
// delivered hash changes).
|
||||
var errSingboxNotInstalled = errors.New("sing-box binary not installed on this node")
|
||||
|
||||
// SingboxVersionRecord is the install-time version record file. The
|
||||
// self-built sing-box binaries carry no version stamp (`sing-box
|
||||
// version` prints "unknown"), so install-agent.sh records the release
|
||||
// version next to the binary for telemetry, NexusGate-style.
|
||||
const singboxVersionRecord = "sing-box.version"
|
||||
|
||||
// singboxBinaryName mirrors the xray naming: sing-box-linux-<arch>.
|
||||
func singboxBinaryName() string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "arm" {
|
||||
arch = "arm32"
|
||||
}
|
||||
return fmt.Sprintf("sing-box-%s-%s", runtime.GOOS, arch)
|
||||
}
|
||||
|
||||
// singboxBinaryPath resolves the managed sing-box binary: the
|
||||
// arch-specific name install-agent.sh installs, falling back to a
|
||||
// plain "sing-box" in the same bin folder. "" when neither exists.
|
||||
func singboxBinaryPath() string {
|
||||
bin := config.GetBinFolderPath()
|
||||
if p := filepath.Join(bin, singboxBinaryName()); fileExists(p) {
|
||||
return p
|
||||
}
|
||||
if p := filepath.Join(bin, "sing-box"); fileExists(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// singboxConfigPath is the live configuration file the managed process
|
||||
// runs with; candidate/previous siblings implement the swap discipline.
|
||||
func singboxConfigPath() string {
|
||||
return filepath.Join(config.GetBinFolderPath(), "config-singbox.json")
|
||||
}
|
||||
|
||||
func singboxCandidatePath() string { return singboxConfigPath() + ".candidate" }
|
||||
func singboxPreviousPath() string { return singboxConfigPath() + ".previous" }
|
||||
|
||||
// singboxLogPath is where the managed process's stdout/stderr land.
|
||||
// The online tracker (singbox_online.go) tails this file for the
|
||||
// authenticated-connection lines sing-box logs per user.
|
||||
func singboxLogPath() string {
|
||||
return filepath.Join(config.GetBinFolderPath(), "singbox.log")
|
||||
}
|
||||
|
||||
// singboxProcess manages one sing-box child process: lifecycle only,
|
||||
// no configuration semantics (mirrors the xray process discipline:
|
||||
// done channel as the race-free liveness signal, SIGTERM then SIGKILL).
|
||||
type singboxProcess struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
logFile *os.File
|
||||
exitErr error
|
||||
startedAt time.Time
|
||||
intentionalStop bool
|
||||
}
|
||||
|
||||
func (p *singboxProcess) isRunning() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cmd == nil || p.cmd.Process == nil || p.done == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// pid returns the running process's pid, 0 when not running.
|
||||
func (p *singboxProcess) pid() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return 0
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return 0
|
||||
default:
|
||||
return p.cmd.Process.Pid
|
||||
}
|
||||
}
|
||||
|
||||
// epoch identifies this process instance for the stats tracker:
|
||||
// "sing-box:<pid>:<agent-side start time>". The agent starts the
|
||||
// process itself, so its recorded start time distinguishes instances
|
||||
// even when the pid is recycled (NexusGate reads the kernel start
|
||||
// tick for the same reason). "" while no process is running.
|
||||
func (p *singboxProcess) epoch() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cmd == nil || p.cmd.Process == nil || p.done == nil {
|
||||
return ""
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return ""
|
||||
default:
|
||||
}
|
||||
return fmt.Sprintf("sing-box:%d:%d", p.cmd.Process.Pid, p.startedAt.UnixNano())
|
||||
}
|
||||
|
||||
// start launches sing-box with the given config file, appending its
|
||||
// output to the log file the online tracker tails.
|
||||
func (p *singboxProcess) start(binPath, configPath, logPath string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.cmd != nil && p.done != nil {
|
||||
select {
|
||||
case <-p.done:
|
||||
default:
|
||||
return errors.New("sing-box is already running")
|
||||
}
|
||||
}
|
||||
if p.logFile != nil {
|
||||
_ = p.logFile.Close()
|
||||
p.logFile = nil
|
||||
}
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sing-box log: %w", err)
|
||||
}
|
||||
cmd := exec.Command(binPath, "run", "-c", configPath)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
return fmt.Errorf("start sing-box: %w", err)
|
||||
}
|
||||
p.cmd = cmd
|
||||
p.logFile = logFile
|
||||
p.done = make(chan struct{})
|
||||
p.exitErr = nil
|
||||
p.startedAt = time.Now()
|
||||
p.intentionalStop = false
|
||||
done := p.done
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
p.mu.Lock()
|
||||
if err != nil && !p.intentionalStop {
|
||||
p.exitErr = err
|
||||
}
|
||||
p.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop terminates the process (SIGTERM, then SIGKILL after a grace
|
||||
// period) and waits for it to be reaped.
|
||||
func (p *singboxProcess) stop() {
|
||||
p.mu.Lock()
|
||||
cmd, done := p.cmd, p.done
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.intentionalStop = true
|
||||
proc := cmd.Process
|
||||
logFile := p.logFile
|
||||
p.mu.Unlock()
|
||||
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
p.finishStop(logFile)
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = proc.Kill()
|
||||
} else if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(singboxStopGrace):
|
||||
_ = proc.Kill()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(singboxStopForce):
|
||||
}
|
||||
}
|
||||
}
|
||||
p.finishStop(logFile)
|
||||
}
|
||||
|
||||
func (p *singboxProcess) finishStop(logFile *os.File) {
|
||||
p.mu.Lock()
|
||||
p.cmd = nil
|
||||
p.done = nil
|
||||
if p.logFile != nil {
|
||||
p.logFile = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// exitedErr reports the error the process died with, if it has exited
|
||||
// unexpectedly.
|
||||
func (p *singboxProcess) exitedErr() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.exitErr
|
||||
}
|
||||
|
||||
var (
|
||||
singboxStopGrace = 5 * time.Second
|
||||
singboxStopForce = 2 * time.Second
|
||||
// singboxStartSettle is the post-start window in which an
|
||||
// immediate exit means the new config cannot run (the check gate
|
||||
// catches shape errors earlier; this catches runtime failures
|
||||
// like a port already in use).
|
||||
singboxStartSettle = 300 * time.Millisecond
|
||||
singboxCheckTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// checkSingboxConfig runs `sing-box check` against a candidate file.
|
||||
func checkSingboxConfig(binPath, candidate string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), singboxCheckTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, binPath, "check", "-c", candidate).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sing-box check failed: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// singboxStatsListen extracts experimental.v2ray_api.listen from a
|
||||
// sing-box config, the address the stats client must query. "" when
|
||||
// the config carries no v2ray_api block (no stats to read).
|
||||
func singboxStatsListen(raw json.RawMessage) string {
|
||||
var cfg struct {
|
||||
Experimental struct {
|
||||
V2RayAPI struct {
|
||||
Listen string `json:"listen"`
|
||||
} `json:"v2ray_api"`
|
||||
} `json:"experimental"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Experimental.V2RayAPI.Listen
|
||||
}
|
||||
|
||||
// applySingboxConfig validates and activates a newly delivered
|
||||
// sing-box configuration. Errors leave the previously running core
|
||||
// (if any) completely untouched.
|
||||
func (a *Agent) applySingboxConfig(raw json.RawMessage) error {
|
||||
a.sbMu.Lock()
|
||||
defer a.sbMu.Unlock()
|
||||
a.ensureSingboxState()
|
||||
return a.applySingboxConfigLocked(raw)
|
||||
}
|
||||
|
||||
func (a *Agent) applySingboxConfigLocked(raw json.RawMessage) error {
|
||||
binPath := singboxBinaryPath()
|
||||
if binPath == "" {
|
||||
return errSingboxNotInstalled
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return fmt.Errorf("invalid sing-box config JSON: %w", err)
|
||||
}
|
||||
|
||||
configPath := singboxConfigPath()
|
||||
candidate := singboxCandidatePath()
|
||||
if err := os.WriteFile(candidate, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("write sing-box candidate config: %w", err)
|
||||
}
|
||||
if err := checkSingboxConfig(binPath, candidate); err != nil {
|
||||
_ = os.Remove(candidate)
|
||||
return err
|
||||
}
|
||||
|
||||
current, readErr := os.ReadFile(configPath)
|
||||
unchanged := readErr == nil && string(current) == string(raw)
|
||||
if unchanged && a.sbProc.isRunning() {
|
||||
_ = os.Remove(candidate)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush before change: the running core's counters die with the
|
||||
// process, so report them while it still lives. A failed flush
|
||||
// aborts the swap — NexusGate's discipline; the epoch guard in the
|
||||
// stats tracker is the second line of defence.
|
||||
if a.sbProc.isRunning() {
|
||||
if err := a.reportSingboxTrafficLocked(); err != nil {
|
||||
_ = os.Remove(candidate)
|
||||
return fmt.Errorf("flush traffic before sing-box config change: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !unchanged {
|
||||
if readErr == nil {
|
||||
if err := os.WriteFile(singboxPreviousPath(), current, 0o600); err != nil {
|
||||
_ = os.Remove(candidate)
|
||||
return fmt.Errorf("keep previous sing-box config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(candidate, configPath); err != nil {
|
||||
_ = os.Remove(candidate)
|
||||
return fmt.Errorf("activate sing-box config: %w", err)
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(candidate)
|
||||
}
|
||||
|
||||
// The stats address follows the applied config; a config without
|
||||
// a v2ray_api block clears it so the traffic cycle stays quiet.
|
||||
a.sbStatsAddr = singboxStatsListen(raw)
|
||||
|
||||
a.sbProc.stop()
|
||||
if err := a.startSingboxLocked(binPath, configPath); err != nil {
|
||||
// Roll back to the previous config (if one exists) so the node
|
||||
// keeps serving; the apply itself reports failure either way.
|
||||
if rbErr := a.rollbackSingboxLocked(binPath, configPath); rbErr != nil {
|
||||
logger.Warningf("sing-box rollback failed: %v", rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startSingboxLocked starts the process and verifies it survives the
|
||||
// settle window.
|
||||
func (a *Agent) startSingboxLocked(binPath, configPath string) error {
|
||||
if err := a.sbProc.start(binPath, configPath, singboxLogPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(singboxStartSettle)
|
||||
if !a.sbProc.isRunning() {
|
||||
if exitErr := a.sbProc.exitedErr(); exitErr != nil {
|
||||
return fmt.Errorf("sing-box exited immediately after start: %w", exitErr)
|
||||
}
|
||||
return errors.New("sing-box exited immediately after start")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackSingboxLocked restores the .previous config and starts it.
|
||||
func (a *Agent) rollbackSingboxLocked(binPath, configPath string) error {
|
||||
previous, err := os.ReadFile(singboxPreviousPath())
|
||||
if err != nil {
|
||||
// Nothing to roll back to: drop the bad config so a later poll
|
||||
// starts from a clean slate.
|
||||
_ = os.Remove(configPath)
|
||||
return fmt.Errorf("no previous sing-box config to roll back to: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, previous, 0o600); err != nil {
|
||||
return fmt.Errorf("restore previous sing-box config: %w", err)
|
||||
}
|
||||
a.sbProc.stop()
|
||||
return a.startSingboxLocked(binPath, configPath)
|
||||
}
|
||||
|
||||
// stopSingbox stops the managed sing-box process, if any.
|
||||
func (a *Agent) stopSingbox() {
|
||||
a.sbMu.Lock()
|
||||
defer a.sbMu.Unlock()
|
||||
if a.sbProc != nil {
|
||||
a.sbProc.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// parseSingboxVersion extracts the version from `sing-box version`
|
||||
// output ("sing-box version 1.13.21"). Self-built binaries print
|
||||
// "unknown"; the caller falls back to the install-time record.
|
||||
func parseSingboxVersion(out string) (string, bool) {
|
||||
line := out
|
||||
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 || !strings.EqualFold(fields[0], "sing-box") || !strings.EqualFold(fields[1], "version") {
|
||||
return "", false
|
||||
}
|
||||
v := fields[2]
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// cachedSingboxVersion mirrors cachedXrayVersion for the sing-box
|
||||
// binary: re-read only when the binary file changes; an "unknown"
|
||||
// stamp falls back to the version record install-agent.sh wrote.
|
||||
func (a *Agent) cachedSingboxVersion() (string, bool) {
|
||||
path := singboxBinaryPath()
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
a.sbVerMu.Lock()
|
||||
defer a.sbVerMu.Unlock()
|
||||
if a.sbVer != "" && a.sbVerFor == path && fi.ModTime().Equal(a.sbVerMod) {
|
||||
return a.sbVer, true
|
||||
}
|
||||
v := ""
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
out, err := exec.CommandContext(ctx, path, "version").Output()
|
||||
cancel()
|
||||
if err == nil {
|
||||
if parsed, ok := parseSingboxVersion(string(out)); ok && parsed != "unknown" {
|
||||
v = parsed
|
||||
}
|
||||
}
|
||||
if v == "" {
|
||||
if data, err := os.ReadFile(filepath.Join(config.GetBinFolderPath(), singboxVersionRecord)); err == nil {
|
||||
v = strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
a.sbVer, a.sbVerFor, a.sbVerMod = v, path, fi.ModTime()
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSingbox installs a shell-script sing-box stand-in into a temp
|
||||
// bin folder: `check` rejects configs containing BADSCHEMA, `run`
|
||||
// dies instantly on RUNTIMEFAIL and otherwise sleeps (standing in for
|
||||
// the long-running core). The real binary's behavior is covered by
|
||||
// the env-gated E2E; these tests pin the agent's activation
|
||||
// discipline: check gate, no-change no-restart, rollback.
|
||||
const fakeSingboxScript = `#!/bin/sh
|
||||
case "$1" in
|
||||
version)
|
||||
echo "sing-box version 9.9.9"
|
||||
exit 0
|
||||
;;
|
||||
check)
|
||||
if grep -q BADSCHEMA "$3" 2>/dev/null; then
|
||||
echo "check: invalid config" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
if grep -q RUNTIMEFAIL "$3" 2>/dev/null; then
|
||||
echo "fatal: cannot start" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec sleep 3600
|
||||
;;
|
||||
esac
|
||||
exit 2
|
||||
`
|
||||
|
||||
func setupFakeSingbox(t *testing.T) (binDir string) {
|
||||
t.Helper()
|
||||
binDir = t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
path := filepath.Join(binDir, singboxBinaryName())
|
||||
if err := os.WriteFile(path, []byte(fakeSingboxScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return binDir
|
||||
}
|
||||
|
||||
func newApplyTestAgent() *Agent {
|
||||
return New(&Config{MasterURL: "http://127.0.0.1:1", Token: "tok", NodeName: "n"})
|
||||
}
|
||||
|
||||
const (
|
||||
sbCfgA = `{"log":{"level":"info"},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}]}`
|
||||
sbCfgB = `{"log":{"level":"debug"},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}]}`
|
||||
)
|
||||
|
||||
func appliedConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(singboxConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read applied config: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestApplySingboxStartsProcess(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
a := newApplyTestAgent()
|
||||
t.Cleanup(a.stopSingbox)
|
||||
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
running := a.sbProc.isRunning()
|
||||
pid := a.sbProc.pid()
|
||||
a.sbMu.Unlock()
|
||||
if !running || pid <= 0 {
|
||||
t.Fatal("sing-box process not running after apply")
|
||||
}
|
||||
if got := appliedConfig(t); got != sbCfgA {
|
||||
t.Fatalf("applied config = %q, want cfgA", got)
|
||||
}
|
||||
if _, err := os.Stat(singboxCandidatePath()); !os.IsNotExist(err) {
|
||||
t.Fatal("candidate file must not linger after a successful apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySingboxCheckGateKeepsRunningCore(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
a := newApplyTestAgent()
|
||||
t.Cleanup(a.stopSingbox)
|
||||
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("apply A: %v", err)
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
pidBefore := a.sbProc.pid()
|
||||
a.sbMu.Unlock()
|
||||
|
||||
bad := `{"log":{"level":"info"},"inbounds":[{"type":"BADSCHEMA"}]}`
|
||||
err := a.applySingboxConfig([]byte(bad))
|
||||
if err == nil || !strings.Contains(err.Error(), "check") {
|
||||
t.Fatalf("apply bad config err = %v, want a check failure", err)
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
pidAfter := a.sbProc.pid()
|
||||
running := a.sbProc.isRunning()
|
||||
a.sbMu.Unlock()
|
||||
if !running || pidAfter != pidBefore {
|
||||
t.Fatalf("check failure disturbed the running core: pid %d -> %d running=%v", pidBefore, pidAfter, running)
|
||||
}
|
||||
if got := appliedConfig(t); got != sbCfgA {
|
||||
t.Fatalf("active config changed after failed check: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySingboxUnchangedNoRestart(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
a := newApplyTestAgent()
|
||||
t.Cleanup(a.stopSingbox)
|
||||
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("apply A: %v", err)
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
pidBefore := a.sbProc.pid()
|
||||
a.sbMu.Unlock()
|
||||
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("re-apply A: %v", err)
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
pidAfter := a.sbProc.pid()
|
||||
a.sbMu.Unlock()
|
||||
if pidAfter != pidBefore {
|
||||
t.Fatalf("unchanged config restarted the core: pid %d -> %d", pidBefore, pidAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySingboxRuntimeFailureRollsBack(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
a := newApplyTestAgent()
|
||||
t.Cleanup(a.stopSingbox)
|
||||
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("apply A: %v", err)
|
||||
}
|
||||
|
||||
bad := `{"log":{"level":"info"},"note":"RUNTIMEFAIL","inbounds":[]}`
|
||||
if err := a.applySingboxConfig([]byte(bad)); err == nil {
|
||||
t.Fatal("apply of a config that dies at startup must fail")
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
running := a.sbProc.isRunning()
|
||||
a.sbMu.Unlock()
|
||||
if !running {
|
||||
t.Fatal("rollback must leave a running core")
|
||||
}
|
||||
if got := appliedConfig(t); got != sbCfgA {
|
||||
t.Fatalf("active config after rollback = %q, want cfgA", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySingboxMissingBinary(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
a := newApplyTestAgent()
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); !errors.Is(err, errSingboxNotInstalled) {
|
||||
t.Fatalf("err = %v, want errSingboxNotInstalled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySingboxInvalidJSON(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
a := newApplyTestAgent()
|
||||
t.Cleanup(a.stopSingbox)
|
||||
if err := a.applySingboxConfig([]byte("{not json")); err == nil {
|
||||
t.Fatal("invalid JSON must fail")
|
||||
}
|
||||
a.sbMu.Lock()
|
||||
proc := a.sbProc
|
||||
a.sbMu.Unlock()
|
||||
if proc != nil && proc.isRunning() {
|
||||
t.Fatal("no process may start from invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSingboxVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
out string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"sing-box version 1.13.21\n\nEnvironment: go1.26.9 linux/amd64", "1.13.21", true},
|
||||
{"sing-box version unknown\n", "unknown", true},
|
||||
{"sing-box version \n", "", false},
|
||||
{"xray version 1.0\n", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := parseSingboxVersion(tc.out)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("parseSingboxVersion(%q) = %q,%v want %q,%v", tc.out, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// ITEM6 B3 end-to-end, env-gated: XUI_SINGBOX_BIN must point at the
|
||||
// self-built sing-box v1.13.21 (with_v2ray_api with_utls with_quic).
|
||||
// Skipped otherwise so the package suite stays green anywhere.
|
||||
//
|
||||
// Topology: a stub master serves a B2-shaped singbox_config; the real
|
||||
// Agent applies it and runs the real core; a second core instance is
|
||||
// the client (vless-reality / ss2022 / mixed / trojan / hysteria2 /
|
||||
// a forwarding inbound), a third is the forwarding landing.
|
||||
// Assertions cover: apply+serve, per-user deltas against known byte
|
||||
// counts, online users, forwarding, flush-before-change + epoch
|
||||
// rebase across a core restart (no double counting), the check gate.
|
||||
//
|
||||
// Environment limits of the build sandbox, probed at runtime: direct
|
||||
// internet is unreachable (the reality handshake target is a local
|
||||
// TLS server — production points it at real sites), and UDP sends
|
||||
// are blocked outright (EPERM), so the hysteria2 leg only joins when
|
||||
// a UDP loopback probe succeeds; its on-node verification belongs
|
||||
// to the B5 pilot, like trojan+flow (N5) and wireguard (N7).
|
||||
|
||||
type e2eMaster struct {
|
||||
mu sync.Mutex
|
||||
cfg []byte
|
||||
hash string
|
||||
reports []trafficReport
|
||||
}
|
||||
|
||||
func (m *e2eMaster) setConfig(cfg []byte) {
|
||||
sum := sha256.Sum256(cfg)
|
||||
m.mu.Lock()
|
||||
m.cfg = cfg
|
||||
m.hash = hex.EncodeToString(sum[:])
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *e2eMaster) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/agent/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/agent/heartbeat", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
resp := map[string]any{
|
||||
"config_hash": "xray-placeholder",
|
||||
"xray_config": json.RawMessage(`{}`),
|
||||
"singbox_config": json.RawMessage(m.cfg),
|
||||
"singbox_config_hash": m.hash,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
var rep trafficReport
|
||||
if err := json.NewDecoder(r.Body).Decode(&rep); err == nil {
|
||||
m.mu.Lock()
|
||||
m.reports = append(m.reports, rep)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// totals folds every report the master received into per-user and
|
||||
// per-inbound cumulative deltas plus the last online set.
|
||||
func (m *e2eMaster) totals() (users map[string][2]int64, inbounds map[string][2]int64, onlines []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
users = map[string][2]int64{}
|
||||
inbounds = map[string][2]int64{}
|
||||
for _, rep := range m.reports {
|
||||
for _, u := range rep.Users {
|
||||
v := users[u.Email]
|
||||
users[u.Email] = [2]int64{v[0] + u.Up, v[1] + u.Down}
|
||||
}
|
||||
for _, in := range rep.Inbounds {
|
||||
v := inbounds[in.Tag]
|
||||
inbounds[in.Tag] = [2]int64{v[0] + in.Up, v[1] + in.Down}
|
||||
}
|
||||
if len(rep.Onlines) > 0 {
|
||||
onlines = rep.Onlines
|
||||
}
|
||||
}
|
||||
return users, inbounds, onlines
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func freeUDPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
return c.LocalAddr().(*net.UDPAddr).Port
|
||||
}
|
||||
|
||||
// udpLoopbackWorks probes whether UDP over loopback supports a
|
||||
// full round trip in this environment. A one-way check is not
|
||||
// enough: the build sandbox lets an unbound client socket send but
|
||||
// refuses (EPERM) the bound server socket's reply, which no
|
||||
// QUIC-based protocol can survive.
|
||||
func udpLoopbackWorks() bool {
|
||||
srv, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer srv.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 16)
|
||||
_ = srv.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
_, addr, err := srv.ReadFromUDP(buf)
|
||||
if err == nil {
|
||||
_, _ = srv.WriteToUDP([]byte("pong"), addr)
|
||||
}
|
||||
}()
|
||||
cli, err := net.DialUDP("udp", nil, srv.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Write([]byte("ping")); err != nil {
|
||||
return false
|
||||
}
|
||||
_ = cli.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
buf := make([]byte, 16)
|
||||
_, err = cli.Read(buf)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func selfSignedCert(t *testing.T, dir string, dnsNames ...string) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: dnsNames[0]},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPath = filepath.Join(dir, "cert-"+dnsNames[0]+".pem")
|
||||
keyPath = filepath.Join(dir, "key-"+dnsNames[0]+".pem")
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// startHandshakeServer runs the local TLS endpoint the reality
|
||||
// server mirrors.
|
||||
func startHandshakeServer(t *testing.T, certPath, keyPath string) (port int, stop func()) {
|
||||
t.Helper()
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
if tc, ok := c.(*tls.Conn); ok {
|
||||
_ = tc.Handshake()
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().(*net.TCPAddr).Port, func() { _ = ln.Close() }
|
||||
}
|
||||
|
||||
func runCore(t *testing.T, bin, name string, cfg any, dir string) *exec.Cmd {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logFile, err := os.Create(filepath.Join(dir, name+".log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bin, "run", "-c", path)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
_ = logFile.Close()
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func waitPort(t *testing.T, port int, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("port %d (%s) never came up", port, what)
|
||||
}
|
||||
|
||||
// fetchViaSocks GETs url through the socks5 listener at port and
|
||||
// returns the body length.
|
||||
func fetchViaSocks(t *testing.T, port int, url string) int {
|
||||
t.Helper()
|
||||
dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("127.0.0.1:%d", port), nil, proxy.Direct)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &http.Transport{DialContext: func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}}
|
||||
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET via socks %d: %v", port, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET via socks %d: status %d", port, resp.StatusCode)
|
||||
}
|
||||
return len(body)
|
||||
}
|
||||
|
||||
func TestSingboxEndToEnd(t *testing.T) {
|
||||
bin := os.Getenv("XUI_SINGBOX_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("XUI_SINGBOX_BIN not set; skipping real-core E2E")
|
||||
}
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
t.Skipf("sing-box binary unusable: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
udpOK := udpLoopbackWorks()
|
||||
if !udpOK {
|
||||
t.Log("UDP loopback unavailable in this environment; hysteria2 leg excluded (pilot-node item)")
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------
|
||||
gen := func(args ...string) string {
|
||||
out, err := exec.Command(bin, args...).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("sing-box %v: %v", args, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
var privKey, pubKey string
|
||||
for _, line := range strings.Split(gen("generate", "reality-keypair"), "\n") {
|
||||
if strings.HasPrefix(line, "PrivateKey:") {
|
||||
privKey = strings.TrimSpace(strings.TrimPrefix(line, "PrivateKey:"))
|
||||
}
|
||||
if strings.HasPrefix(line, "PublicKey:") {
|
||||
pubKey = strings.TrimSpace(strings.TrimPrefix(line, "PublicKey:"))
|
||||
}
|
||||
}
|
||||
aliceUUID := gen("generate", "uuid")
|
||||
ssServerPass := strings.TrimSpace(gen("generate", "rand", "--base64", "16"))
|
||||
ssBobPass := strings.TrimSpace(gen("generate", "rand", "--base64", "16"))
|
||||
|
||||
hsCert, hsKey := selfSignedCert(t, dir, "www.microsoft.com")
|
||||
hsPort, stopHS := startHandshakeServer(t, hsCert, hsKey)
|
||||
defer stopHS()
|
||||
tlsCert, tlsKey := selfSignedCert(t, dir, "example.com")
|
||||
|
||||
targetBody := strings.Repeat("0123456789", 500) // 5000 bytes
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, targetBody)
|
||||
}))
|
||||
defer target.Close()
|
||||
targetURL := target.URL + "/"
|
||||
|
||||
pVless := freePort(t)
|
||||
pSS := freePort(t)
|
||||
pMixed := freePort(t)
|
||||
pFwd := freePort(t)
|
||||
pTrojan := freePort(t)
|
||||
pHy2 := freeUDPPort(t)
|
||||
pStats := freePort(t)
|
||||
pLanding := freePort(t)
|
||||
cVless := freePort(t)
|
||||
cSS := freePort(t)
|
||||
cMixed := freePort(t)
|
||||
cFwd := freePort(t)
|
||||
cTrojan := freePort(t)
|
||||
cHy2 := freePort(t)
|
||||
|
||||
type leg struct {
|
||||
name string
|
||||
clientPort int
|
||||
user string
|
||||
serverTag string
|
||||
}
|
||||
legs := []leg{
|
||||
{"vless-reality", cVless, "alice@example.com", "vless-reality"},
|
||||
{"ss2022", cSS, "bob@example.com", "ss22"},
|
||||
{"mixed", cMixed, "carol@example.com", "mixed-in"},
|
||||
{"trojan-tls", cTrojan, "frank@example.com", "trojan-in"},
|
||||
{"forwarding", cFwd, "dave@example.com", "mixed-fwd"},
|
||||
}
|
||||
if udpOK {
|
||||
legs = append(legs, leg{"hysteria2", cHy2, "erin@example.com", "hy2-in"})
|
||||
}
|
||||
|
||||
serverInbounds := []any{
|
||||
map[string]any{
|
||||
"type": "vless", "tag": "vless-reality", "listen": "127.0.0.1", "listen_port": pVless,
|
||||
"users": []any{map[string]any{"name": "alice@example.com", "uuid": aliceUUID, "flow": "xtls-rprx-vision"}},
|
||||
"tls": map[string]any{
|
||||
"enabled": true, "server_name": "www.microsoft.com",
|
||||
"reality": map[string]any{
|
||||
"enabled": true,
|
||||
"handshake": map[string]any{"server": "127.0.0.1", "server_port": hsPort},
|
||||
"private_key": privKey, "short_id": []string{"abcd"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "shadowsocks", "tag": "ss22", "listen": "127.0.0.1", "listen_port": pSS,
|
||||
"method": "2022-blake3-aes-128-gcm", "password": ssServerPass,
|
||||
"users": []any{map[string]any{"name": "bob@example.com", "password": ssBobPass}},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "mixed", "tag": "mixed-in", "listen": "127.0.0.1", "listen_port": pMixed,
|
||||
"users": []any{map[string]any{"username": "carol@example.com", "password": "carolpass"}},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "mixed", "tag": "mixed-fwd", "listen": "127.0.0.1", "listen_port": pFwd,
|
||||
"users": []any{map[string]any{"username": "dave@example.com", "password": "davepass"}},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "trojan", "tag": "trojan-in", "listen": "127.0.0.1", "listen_port": pTrojan,
|
||||
"users": []any{map[string]any{"name": "frank@example.com", "password": "frankpass"}},
|
||||
"tls": map[string]any{"enabled": true, "server_name": "example.com", "certificate_path": tlsCert, "key_path": tlsKey},
|
||||
},
|
||||
}
|
||||
statsInbounds := []string{"vless-reality", "ss22", "mixed-in", "mixed-fwd", "trojan-in"}
|
||||
statsUsers := []string{"alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "frank@example.com"}
|
||||
if udpOK {
|
||||
serverInbounds = append(serverInbounds, map[string]any{
|
||||
"type": "hysteria2", "tag": "hy2-in", "listen": "127.0.0.1", "listen_port": pHy2,
|
||||
"users": []any{map[string]any{"name": "erin@example.com", "password": "erinpass"}},
|
||||
"tls": map[string]any{"enabled": true, "server_name": "example.com", "certificate_path": tlsCert, "key_path": tlsKey},
|
||||
})
|
||||
statsInbounds = append(statsInbounds, "hy2-in")
|
||||
statsUsers = append(statsUsers, "erin@example.com")
|
||||
}
|
||||
|
||||
buildServerCfg := func(logLevel string) map[string]any {
|
||||
return map[string]any{
|
||||
"log": map[string]any{"level": logLevel, "timestamp": true},
|
||||
"inbounds": serverInbounds,
|
||||
"outbounds": []any{
|
||||
map[string]any{"type": "direct", "tag": "direct"},
|
||||
map[string]any{"type": "socks", "tag": "fwd-1", "server": "127.0.0.1", "server_port": pLanding, "version": "5"},
|
||||
},
|
||||
"route": map[string]any{
|
||||
"final": "direct",
|
||||
"rules": []any{
|
||||
map[string]any{"inbound": []string{"mixed-fwd"}, "action": "route", "outbound": "fwd-1"},
|
||||
},
|
||||
},
|
||||
"experimental": map[string]any{
|
||||
"v2ray_api": map[string]any{
|
||||
"listen": fmt.Sprintf("127.0.0.1:%d", pStats),
|
||||
"stats": map[string]any{
|
||||
"enabled": true,
|
||||
"inbounds": statsInbounds,
|
||||
"outbounds": []string{"direct", "fwd-1"},
|
||||
"users": statsUsers,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Landing for the forwarding leg: a bare no-auth mixed inbound.
|
||||
runCore(t, bin, "landing", map[string]any{
|
||||
"log": map[string]any{"level": "warn"},
|
||||
"inbounds": []any{map[string]any{"type": "mixed", "tag": "landing-in", "listen": "127.0.0.1", "listen_port": pLanding}},
|
||||
"outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}},
|
||||
}, dir)
|
||||
waitPort(t, pLanding, "landing")
|
||||
|
||||
// Client core: one local socks port per protocol leg.
|
||||
clientInbounds := []any{
|
||||
map[string]any{"type": "mixed", "tag": "in-vless", "listen": "127.0.0.1", "listen_port": cVless},
|
||||
map[string]any{"type": "mixed", "tag": "in-ss", "listen": "127.0.0.1", "listen_port": cSS},
|
||||
map[string]any{"type": "mixed", "tag": "in-mixed", "listen": "127.0.0.1", "listen_port": cMixed},
|
||||
map[string]any{"type": "mixed", "tag": "in-fwd", "listen": "127.0.0.1", "listen_port": cFwd},
|
||||
map[string]any{"type": "mixed", "tag": "in-trojan", "listen": "127.0.0.1", "listen_port": cTrojan},
|
||||
}
|
||||
clientOutbounds := []any{
|
||||
map[string]any{
|
||||
"type": "vless", "tag": "out-vless", "server": "127.0.0.1", "server_port": pVless,
|
||||
"uuid": aliceUUID, "flow": "xtls-rprx-vision",
|
||||
"tls": map[string]any{
|
||||
"enabled": true, "server_name": "www.microsoft.com",
|
||||
"utls": map[string]any{"enabled": true, "fingerprint": "chrome"},
|
||||
"reality": map[string]any{"enabled": true, "public_key": pubKey, "short_id": "abcd"},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "shadowsocks", "tag": "out-ss", "server": "127.0.0.1", "server_port": pSS,
|
||||
"method": "2022-blake3-aes-128-gcm", "password": ssServerPass + ":" + ssBobPass,
|
||||
},
|
||||
map[string]any{"type": "socks", "tag": "out-mixed", "server": "127.0.0.1", "server_port": pMixed, "version": "5",
|
||||
"username": "carol@example.com", "password": "carolpass"},
|
||||
map[string]any{"type": "socks", "tag": "out-fwd", "server": "127.0.0.1", "server_port": pFwd, "version": "5",
|
||||
"username": "dave@example.com", "password": "davepass"},
|
||||
map[string]any{
|
||||
"type": "trojan", "tag": "out-trojan", "server": "127.0.0.1", "server_port": pTrojan, "password": "frankpass",
|
||||
"tls": map[string]any{"enabled": true, "server_name": "example.com", "insecure": true},
|
||||
},
|
||||
}
|
||||
clientRules := []any{
|
||||
map[string]any{"inbound": []string{"in-vless"}, "action": "route", "outbound": "out-vless"},
|
||||
map[string]any{"inbound": []string{"in-ss"}, "action": "route", "outbound": "out-ss"},
|
||||
map[string]any{"inbound": []string{"in-mixed"}, "action": "route", "outbound": "out-mixed"},
|
||||
map[string]any{"inbound": []string{"in-fwd"}, "action": "route", "outbound": "out-fwd"},
|
||||
map[string]any{"inbound": []string{"in-trojan"}, "action": "route", "outbound": "out-trojan"},
|
||||
}
|
||||
clientPorts := []int{cVless, cSS, cMixed, cFwd, cTrojan}
|
||||
if udpOK {
|
||||
clientInbounds = append(clientInbounds,
|
||||
map[string]any{"type": "mixed", "tag": "in-hy2", "listen": "127.0.0.1", "listen_port": cHy2})
|
||||
clientOutbounds = append(clientOutbounds, map[string]any{
|
||||
"type": "hysteria2", "tag": "out-hy2", "server": "127.0.0.1", "server_port": pHy2, "password": "erinpass",
|
||||
"tls": map[string]any{"enabled": true, "server_name": "example.com", "insecure": true, "alpn": []string{"h3"}},
|
||||
})
|
||||
clientRules = append(clientRules,
|
||||
map[string]any{"inbound": []string{"in-hy2"}, "action": "route", "outbound": "out-hy2"})
|
||||
clientPorts = append(clientPorts, cHy2)
|
||||
}
|
||||
clientOutbounds = append(clientOutbounds, map[string]any{"type": "direct", "tag": "direct"})
|
||||
runCore(t, bin, "client", map[string]any{
|
||||
"log": map[string]any{"level": "warn"},
|
||||
"inbounds": clientInbounds,
|
||||
"outbounds": clientOutbounds,
|
||||
"route": map[string]any{"final": "direct", "rules": clientRules},
|
||||
}, dir)
|
||||
for _, p := range clientPorts {
|
||||
waitPort(t, p, "client")
|
||||
}
|
||||
|
||||
// --- agent under test --------------------------------------------
|
||||
binDir := filepath.Join(dir, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(binDir, singboxBinaryName())
|
||||
if err := os.Symlink(bin, linkPath); err != nil {
|
||||
data, rerr := os.ReadFile(bin)
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if werr := os.WriteFile(linkPath, data, 0o755); werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
}
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
|
||||
master := &e2eMaster{}
|
||||
cfgA, _ := json.Marshal(buildServerCfg("info"))
|
||||
master.setConfig(cfgA)
|
||||
srv := httptest.NewServer(master.handler())
|
||||
defer srv.Close()
|
||||
|
||||
agt := New(&Config{
|
||||
MasterURL: srv.URL,
|
||||
Token: "e2e-token",
|
||||
NodeName: "e2e",
|
||||
BaselinePath: filepath.Join(dir, "agent-traffic-baseline.json"),
|
||||
})
|
||||
t.Cleanup(agt.stopSingbox)
|
||||
|
||||
// Apply. The xray half of the poll fails by design (no xray
|
||||
// binary in the sandbox bin folder); the sing-box half must
|
||||
// have succeeded before that error is returned.
|
||||
_ = agt.fetchAndApplyConfig()
|
||||
agt.sbMu.Lock()
|
||||
running := agt.sbProc != nil && agt.sbProc.isRunning()
|
||||
agt.sbMu.Unlock()
|
||||
if !running {
|
||||
t.Fatal("agent did not start sing-box from the delivered config")
|
||||
}
|
||||
for _, p := range []int{pVless, pSS, pMixed, pFwd, pTrojan, pStats} {
|
||||
waitPort(t, p, "agent sing-box")
|
||||
}
|
||||
|
||||
cycle := func() {
|
||||
t.Helper()
|
||||
if err := agt.collectAndReportSingboxTraffic(); err != nil {
|
||||
t.Fatalf("traffic cycle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// First cycle only establishes the baseline: traffic generated
|
||||
// before it must NOT show up as deltas.
|
||||
if n := fetchViaSocks(t, cSS, targetURL); n != len(targetBody) {
|
||||
t.Fatalf("ss leg body = %d, want %d", n, len(targetBody))
|
||||
}
|
||||
cycle()
|
||||
users, _, _ := master.totals()
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("first cycle reported user deltas before baseline: %v", users)
|
||||
}
|
||||
|
||||
// --- per-protocol traffic, then a reporting cycle -----------------
|
||||
downloads := map[string]int{}
|
||||
for _, lg := range legs {
|
||||
n := fetchViaSocks(t, lg.clientPort, targetURL)
|
||||
if n != len(targetBody) {
|
||||
t.Fatalf("%s leg body = %d, want %d", lg.name, n, len(targetBody))
|
||||
}
|
||||
downloads[lg.user] += n
|
||||
}
|
||||
cycle()
|
||||
|
||||
users, inbounds, onlines := master.totals()
|
||||
for _, lg := range legs {
|
||||
got := users[lg.user]
|
||||
want := int64(downloads[lg.user])
|
||||
if got[1] < want || got[1] > want*3/2+2048 {
|
||||
t.Fatalf("%s (%s): reported down = %d, want within [%d, %d]", lg.name, lg.user, got[1], want, want*3/2+2048)
|
||||
}
|
||||
if got[0] <= 0 {
|
||||
t.Fatalf("%s (%s): reported up = %d, want > 0", lg.name, lg.user, got[0])
|
||||
}
|
||||
found := false
|
||||
for _, e := range onlines {
|
||||
if e == lg.user {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s (%s) missing from onlines %v", lg.name, lg.user, onlines)
|
||||
}
|
||||
if got := inbounds[lg.serverTag]; got[1] <= 0 {
|
||||
t.Fatalf("inbound %s reported down = %d, want > 0", lg.serverTag, got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// --- config change: flush + epoch rebase, no double counting -----
|
||||
if n := fetchViaSocks(t, cSS, targetURL); n != len(targetBody) {
|
||||
t.Fatalf("pre-change ss body = %d", n)
|
||||
}
|
||||
downloads["bob@example.com"] += len(targetBody)
|
||||
cfgB, _ := json.Marshal(buildServerCfg("debug"))
|
||||
master.setConfig(cfgB)
|
||||
agt.sbMu.Lock()
|
||||
pidBefore := agt.sbProc.pid()
|
||||
agt.sbMu.Unlock()
|
||||
_ = agt.fetchAndApplyConfig()
|
||||
agt.sbMu.Lock()
|
||||
pidAfter := agt.sbProc.pid()
|
||||
running = agt.sbProc.isRunning()
|
||||
agt.sbMu.Unlock()
|
||||
if !running || pidAfter == pidBefore {
|
||||
t.Fatalf("config change did not restart the core: pid %d -> %d running=%v", pidBefore, pidAfter, running)
|
||||
}
|
||||
for _, p := range []int{pVless, pSS, pMixed, pFwd, pTrojan, pStats} {
|
||||
waitPort(t, p, "agent sing-box after restart")
|
||||
}
|
||||
|
||||
if n := fetchViaSocks(t, cSS, targetURL); n != len(targetBody) {
|
||||
t.Fatalf("post-change ss body = %d", n)
|
||||
}
|
||||
downloads["bob@example.com"] += len(targetBody)
|
||||
cycle()
|
||||
cycle() // a second cycle must add nothing new
|
||||
|
||||
users, _, _ = master.totals()
|
||||
bob := users["bob@example.com"]
|
||||
want := int64(downloads["bob@example.com"])
|
||||
if bob[1] < want || bob[1] > want*3/2+4096 {
|
||||
t.Fatalf("bob total down after restart = %d, want within [%d, %d] (double counting or loss)", bob[1], want, want*3/2+4096)
|
||||
}
|
||||
|
||||
// --- the check gate -------------------------------------------------
|
||||
badCfg := []byte(`{"inbounds":[{"type":"no-such-type","tag":"x"}],"outbounds":[{"type":"direct","tag":"direct"}]}`)
|
||||
badSum := sha256.Sum256(badCfg)
|
||||
master.setConfig(badCfg)
|
||||
_ = agt.fetchAndApplyConfig()
|
||||
if agt.lastSingboxHash == hex.EncodeToString(badSum[:]) {
|
||||
t.Fatal("bad config hash must not be recorded as applied")
|
||||
}
|
||||
agt.sbMu.Lock()
|
||||
pidNow := agt.sbProc.pid()
|
||||
running = agt.sbProc.isRunning()
|
||||
agt.sbMu.Unlock()
|
||||
if !running || pidNow != pidAfter {
|
||||
t.Fatalf("check-failing config disturbed the core: pid %d -> %d running=%v", pidAfter, pidNow, running)
|
||||
}
|
||||
applied, err := os.ReadFile(singboxConfigPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(applied) != string(cfgB) {
|
||||
t.Fatal("active config file changed after a rejected apply")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// ITEM6 B3: online users for the sing-box runtime (the §4.4 spike,
|
||||
// concluded here).
|
||||
//
|
||||
// sing-box's v2ray_api has no online-user source. The access log
|
||||
// does: for every authenticated connection sing-box logs
|
||||
//
|
||||
// [<conn-id> <elapsed>] inbound/<type>[<tag>]: [<user>] inbound connection to <dest>
|
||||
//
|
||||
// with the same user name the stats counters use (verified against
|
||||
// the real v1.13.21 binary for vless, shadowsocks and mixed;
|
||||
// unauthenticated attempts only ever produce the preceding
|
||||
// "inbound connection from" line, never this one). The tracker tails
|
||||
// the managed process's log file from a cursor — NexusGate's
|
||||
// observation discipline: a truncated/rotated file resets the cursor,
|
||||
// reads are window-capped, only complete lines are consumed.
|
||||
//
|
||||
// Semantics versus the xray core's live-connection view: a user is
|
||||
// online while an authenticated connection was observed within
|
||||
// singboxOnlineGrace, OR while their counters moved in the latest
|
||||
// snapshot (the traffic cycle merges that in — it covers long-lived
|
||||
// connections that keep transferring). A connected-but-idle user
|
||||
// ages out after the grace window: sing-box logs nothing per
|
||||
// connection after setup, so idle-liveness is not observable without
|
||||
// opening the clash_api connection table, which the §4.4 evidence
|
||||
// base rules out. The wire contract is unchanged (onlines = [email]).
|
||||
|
||||
const singboxOnlineGrace = 150 * time.Second
|
||||
|
||||
// maxSingboxLogWindow caps one poll's read, NexusGate-style: when the
|
||||
// cursor falls further behind (long downtime), only the tail is
|
||||
// scanned for recent users.
|
||||
const maxSingboxLogWindow = 2 * 1024 * 1024
|
||||
|
||||
var (
|
||||
// ANSI CSI sequences (sing-box colorizes the level and conn-id
|
||||
// even when redirected to a file) are stripped before matching.
|
||||
ansiCSIRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]")
|
||||
// The authenticated-connection line. The user name is sing-box's
|
||||
// stats identity: email for most protocols, username for
|
||||
// mixed/socks/http.
|
||||
singboxAuthLineRegex = regexp.MustCompile(`\[([^\]\s]+)\] inbound connection to `)
|
||||
)
|
||||
|
||||
// singboxOnlineTracker derives the online-user set from the managed
|
||||
// process's access log. Safe for concurrent use.
|
||||
type singboxOnlineTracker struct {
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
offset int64
|
||||
lastSeen map[string]int64 // user -> unix seconds of latest authenticated connection
|
||||
}
|
||||
|
||||
func newSingboxOnlineTracker(path string) *singboxOnlineTracker {
|
||||
return &singboxOnlineTracker{path: path, lastSeen: make(map[string]int64)}
|
||||
}
|
||||
|
||||
// stripANSI removes terminal color/control sequences from one log line.
|
||||
func stripANSI(line string) string {
|
||||
if !bytes.ContainsRune([]byte(line), 0x1b) {
|
||||
return line
|
||||
}
|
||||
return ansiCSIRegex.ReplaceAllString(line, "")
|
||||
}
|
||||
|
||||
// parseSingboxAuthLine extracts the user from one raw log line, or ""
|
||||
// when the line is not an authenticated-connection record.
|
||||
func parseSingboxAuthLine(line string) string {
|
||||
m := singboxAuthLineRegex.FindStringSubmatch(stripANSI(line))
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// Poll consumes the log written since the previous poll and records
|
||||
// the users it proves active. Poll errors are swallowed by design:
|
||||
// online data is best-effort and must never fail a traffic cycle.
|
||||
func (t *singboxOnlineTracker) Poll(now time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
info, err := os.Stat(t.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
size := info.Size()
|
||||
if size < t.offset {
|
||||
t.offset = 0 // rotated/truncated: rescan from the top
|
||||
}
|
||||
start := t.offset
|
||||
if floor := size - maxSingboxLogWindow; floor > start {
|
||||
start = floor
|
||||
}
|
||||
if start >= size {
|
||||
t.offset = size
|
||||
return
|
||||
}
|
||||
f, err := os.Open(t.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, size-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil {
|
||||
return
|
||||
}
|
||||
end := bytes.LastIndex(buf, []byte("\n"))
|
||||
if end < 0 {
|
||||
return // no complete line yet; retry next poll
|
||||
}
|
||||
ts := now.Unix()
|
||||
for _, line := range bytes.Split(buf[:end], []byte("\n")) {
|
||||
if user := parseSingboxAuthLine(string(line)); user != "" {
|
||||
t.lastSeen[user] = ts
|
||||
}
|
||||
}
|
||||
t.offset = start + int64(end) + 1
|
||||
// Prune entries far beyond the grace window so the map stays
|
||||
// bounded on long-lived nodes.
|
||||
cutoff := ts - int64(singboxOnlineGrace/time.Second)*4
|
||||
for user, seen := range t.lastSeen {
|
||||
if seen < cutoff {
|
||||
delete(t.lastSeen, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Online returns the users seen within the grace window.
|
||||
func (t *singboxOnlineTracker) Online(now time.Time) []string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
cutoff := now.Unix() - int64(singboxOnlineGrace/time.Second)
|
||||
out := make([]string, 0, len(t.lastSeen))
|
||||
for user, seen := range t.lastSeen {
|
||||
if seen >= cutoff {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeOnlineUsers unions the log tracker's view with the users whose
|
||||
// counters moved in the latest snapshot (activity within the last
|
||||
// poll interval), deduplicated, in xray.OnlineUser wire shape.
|
||||
func mergeOnlineUsers(logUsers []string, snap *xray.TrafficSnapshot) []xray.OnlineUser {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]xray.OnlineUser, 0, len(logUsers))
|
||||
add := func(email string) {
|
||||
if email == "" {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[email]; dup {
|
||||
return
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
out = append(out, xray.OnlineUser{Email: email})
|
||||
}
|
||||
for _, u := range logUsers {
|
||||
add(u)
|
||||
}
|
||||
if snap != nil {
|
||||
for _, ct := range snap.ClientTraffics {
|
||||
if ct != nil && ct.Up+ct.Down > 0 {
|
||||
add(ct.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Real line shapes captured from sing-box v1.13.21 in the B3 spike,
|
||||
// ANSI color sequences included (the core colorizes the level and
|
||||
// the connection id even when its output is redirected to a file).
|
||||
const (
|
||||
spikeAuthSS = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: [bob@example.com] inbound connection to 127.0.0.1:18080"
|
||||
spikeAuthMixed = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;231m1630640343\x1b[0m 0ms] inbound/mixed[mixed-in]: [carol@example.com] inbound connection to 127.0.0.1:18080"
|
||||
spikeFromLine = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: inbound connection from 127.0.0.1:52076"
|
||||
spikeErrorLine = "+0000 2026-10-22 21:54:57 \x1b[31mERROR\x1b[0m [\x1b[38;5;158m398453065\x1b[0m 85ms] inbound/vless[vless-reality]: process connection from 127.0.0.1:42732: TLS handshake: REALITY: processed invalid connection"
|
||||
spikeStartLine = "+0000 2026-10-22 21:53:31 \x1b[36mINFO\x1b[0m inbound/mixed[mixed-in]: tcp server started at 127.0.0.1:24445"
|
||||
)
|
||||
|
||||
func TestParseSingboxAuthLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
want string
|
||||
}{
|
||||
{spikeAuthSS, "bob@example.com"},
|
||||
{spikeAuthMixed, "carol@example.com"},
|
||||
{spikeFromLine, ""}, // unauthenticated stage: never counts
|
||||
{spikeErrorLine, ""}, // failed handshake: never counts
|
||||
{spikeStartLine, ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := parseSingboxAuthLine(tc.line); got != tc.want {
|
||||
t.Errorf("parse(%q) = %q, want %q", tc.line, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxOnlineTrackerPollAndGrace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "singbox.log")
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
tr := newSingboxOnlineTracker(path)
|
||||
|
||||
// No file yet: quiet no-op.
|
||||
tr.Poll(now)
|
||||
if got := tr.Online(now); len(got) != 0 {
|
||||
t.Fatalf("online = %v, want empty", got)
|
||||
}
|
||||
|
||||
content := spikeFromLine + "\n" + spikeAuthSS + "\n" + spikeErrorLine + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Poll(now)
|
||||
got := tr.Online(now)
|
||||
if len(got) != 1 || got[0] != "bob@example.com" {
|
||||
t.Fatalf("online = %v, want [bob@example.com]", got)
|
||||
}
|
||||
|
||||
// Re-poll without new content: no double counting, same set.
|
||||
tr.Poll(now)
|
||||
if got := tr.Online(now); len(got) != 1 {
|
||||
t.Fatalf("online after idle poll = %v", got)
|
||||
}
|
||||
|
||||
// Past the grace window the user ages out.
|
||||
if got := tr.Online(now.Add(singboxOnlineGrace + time.Second)); len(got) != 0 {
|
||||
t.Fatalf("online after grace = %v, want empty", got)
|
||||
}
|
||||
|
||||
// A partial line (no trailing newline) is not consumed until it
|
||||
// completes. (bob is still within his grace window here; only
|
||||
// carol's appearance is being timed.)
|
||||
partial := spikeAuthMixed // no trailing \n yet
|
||||
if err := appendToFile(path, partial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later := now.Add(time.Minute)
|
||||
tr.Poll(later)
|
||||
if got := tr.Online(later); strings.Contains(strings.Join(got, ","), "carol@example.com") {
|
||||
t.Fatalf("partial line must not count, online = %v", got)
|
||||
}
|
||||
if err := appendToFile(path, "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Poll(later)
|
||||
got = tr.Online(later)
|
||||
if !strings.Contains(strings.Join(got, ","), "carol@example.com") {
|
||||
t.Fatalf("online after line completes = %v, want carol@example.com present", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxOnlineTrackerTruncation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "singbox.log")
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
tr := newSingboxOnlineTracker(path)
|
||||
if err := os.WriteFile(path, []byte(spikeAuthSS+"\n"+spikeAuthSS+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Poll(now)
|
||||
|
||||
// File shrinks below the cursor (rotation): rescan from the top.
|
||||
if err := os.WriteFile(path, []byte(spikeAuthMixed+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Poll(now)
|
||||
got := tr.Online(now)
|
||||
joined := strings.Join(got, ",")
|
||||
if !strings.Contains(joined, "carol@example.com") || !strings.Contains(joined, "bob@example.com") {
|
||||
t.Fatalf("online after truncation = %v, want both users", got)
|
||||
}
|
||||
}
|
||||
|
||||
func appendToFile(path, s string) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(s)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/encoding"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
// ITEM6 B3: per-user traffic accounting for the sing-box runtime.
|
||||
//
|
||||
// §4.4 of the scope offered two shapes: embed sing-box's v2rayapi Go
|
||||
// package, or ship NexusGate's stats-helper binary. This client takes
|
||||
// the first shape without the dependency: sing-box's v2ray_api is a
|
||||
// different protobuf service from xray's, but its messages are just
|
||||
// as small and stable ({patterns} in, {name, value} pairs out), so —
|
||||
// exactly as this build already does for xray in internal/xray/api.go
|
||||
// after dropping the core dependency — the client speaks it at the
|
||||
// protobuf wire level with protowire. Verified against the real
|
||||
// v1.13.21 binary in the B3 end-to-end test; the helper binary remains
|
||||
// the documented fallback if the wire shape ever moves.
|
||||
//
|
||||
// Counter names are identical to xray's
|
||||
// (inbound>>>tag>>>traffic>>>uplink, user>>>name>>>traffic>>>downlink),
|
||||
// and the master-side write path keys users by the same name (=email),
|
||||
// so only this agent-side query implementation changed.
|
||||
//
|
||||
// Epoch discipline (NexusGate): counters belong to one core process
|
||||
// instance. A snapshot is only meaningful when the core's epoch is
|
||||
// unchanged across the query — a sample straddling a restart mixes
|
||||
// two instances' counters and is discarded whole. When the epoch has
|
||||
// flipped since the committed baseline, the new instance's counters
|
||||
// started at zero, so they count IN FULL as fresh deltas (the panel
|
||||
// side's ApplyDelta rule, and the same outcome as the xray path's
|
||||
// "counter below baseline counts from zero"). Together with
|
||||
// flush-before-change in the activation path — which reports the
|
||||
// old instance's tail before it dies — a core restart neither
|
||||
// double-reports nor loses the new instance's traffic. The one
|
||||
// conservative exception: the very first snapshot an agent ever
|
||||
// takes is baseline-only (it may be reading counters that predate
|
||||
// the agent), exactly like the xray path's first poll.
|
||||
|
||||
const singboxStatsServiceQuery = "/v2ray.core.app.stats.command.StatsService/QueryStats"
|
||||
|
||||
// errSingboxEpochChanged aborts a snapshot whose core instance moved
|
||||
// mid-query (or is unknown); the next poll retries against the new
|
||||
// instance. It is a routine event around config changes, not a fault.
|
||||
var errSingboxEpochChanged = errors.New("sing-box restarted during stats query")
|
||||
|
||||
var (
|
||||
singboxTrafficRegex = regexp.MustCompile(`(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
|
||||
singboxClientTrafficRegex = regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
|
||||
)
|
||||
|
||||
// sbRawBytes / sbRawCodec mirror the xray package's raw protobuf
|
||||
// pass-through codec (the standard "proto" content subtype).
|
||||
type sbRawBytes []byte
|
||||
|
||||
type sbRawCodec struct{}
|
||||
|
||||
func (sbRawCodec) Marshal(v any) ([]byte, error) {
|
||||
b, ok := v.(sbRawBytes)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("sing-box stats: raw codec cannot marshal %T", v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (sbRawCodec) Unmarshal(data []byte, v any) error {
|
||||
p, ok := v.(*sbRawBytes)
|
||||
if !ok {
|
||||
return fmt.Errorf("sing-box stats: raw codec cannot unmarshal into %T", v)
|
||||
}
|
||||
*p = append((*p)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sbRawCodec) Name() string { return "proto" }
|
||||
|
||||
var _ encoding.Codec = sbRawCodec{}
|
||||
|
||||
// singboxStatEntry is one counter from a QueryStats response
|
||||
// (v2ray.core.app.stats.command.Stat: name = 1, value = 2).
|
||||
type singboxStatEntry struct {
|
||||
Name string
|
||||
Value int64
|
||||
}
|
||||
|
||||
// encodeSingboxQuery builds the QueryStatsRequest wire form:
|
||||
// patterns is field 3 (repeated string). Asking for the three counter
|
||||
// families keeps the response free of anything else the core counts.
|
||||
func encodeSingboxQuery() []byte {
|
||||
var b []byte
|
||||
for _, pattern := range []string{"inbound>>>", "outbound>>>", "user>>>"} {
|
||||
b = protowire.AppendTag(b, 3, protowire.BytesType)
|
||||
b = protowire.AppendString(b, pattern)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// parseSingboxStatsResponse decodes a QueryStatsResponse
|
||||
// (field 1: repeated Stat).
|
||||
func parseSingboxStatsResponse(b []byte) ([]singboxStatEntry, error) {
|
||||
var out []singboxStatEntry
|
||||
for len(b) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(b)
|
||||
if n < 0 {
|
||||
return nil, protowire.ParseError(n)
|
||||
}
|
||||
b = b[n:]
|
||||
if num == 1 && typ == protowire.BytesType {
|
||||
v, n := protowire.ConsumeBytes(b)
|
||||
if n < 0 {
|
||||
return nil, protowire.ParseError(n)
|
||||
}
|
||||
st, err := parseSingboxStat(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
b = b[n:]
|
||||
continue
|
||||
}
|
||||
n = protowire.ConsumeFieldValue(num, typ, b)
|
||||
if n < 0 {
|
||||
return nil, protowire.ParseError(n)
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseSingboxStat(b []byte) (singboxStatEntry, error) {
|
||||
var st singboxStatEntry
|
||||
for len(b) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(b)
|
||||
if n < 0 {
|
||||
return st, protowire.ParseError(n)
|
||||
}
|
||||
b = b[n:]
|
||||
switch {
|
||||
case num == 1 && typ == protowire.BytesType:
|
||||
v, n := protowire.ConsumeBytes(b)
|
||||
if n < 0 {
|
||||
return st, protowire.ParseError(n)
|
||||
}
|
||||
st.Name = string(v)
|
||||
b = b[n:]
|
||||
case num == 2 && typ == protowire.VarintType:
|
||||
v, n := protowire.ConsumeVarint(b)
|
||||
if n < 0 {
|
||||
return st, protowire.ParseError(n)
|
||||
}
|
||||
st.Value = int64(v)
|
||||
b = b[n:]
|
||||
default:
|
||||
n := protowire.ConsumeFieldValue(num, typ, b)
|
||||
if n < 0 {
|
||||
return st, protowire.ParseError(n)
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// singboxStatsClient is the sing-box counterpart of xray.XrayAPI for
|
||||
// the agent reporting path: same snapshot/commit contract (snapshots
|
||||
// never advance the baseline; only an acknowledged report commits),
|
||||
// plus the epoch guard described above. Not safe for concurrent use;
|
||||
// the agent serializes it under sbMu.
|
||||
type singboxStatsClient struct {
|
||||
addr string
|
||||
epochFunc func() string
|
||||
|
||||
conn *grpc.ClientConn
|
||||
|
||||
committed map[string]int64
|
||||
committedReady bool
|
||||
committedEpoch string
|
||||
pendingEpoch string
|
||||
|
||||
// queryFn replaces the gRPC query in tests.
|
||||
queryFn func(ctx context.Context) ([]singboxStatEntry, error)
|
||||
}
|
||||
|
||||
func newSingboxStatsClient(epochFunc func() string) *singboxStatsClient {
|
||||
return &singboxStatsClient{epochFunc: epochFunc}
|
||||
}
|
||||
|
||||
// SetAddr points the client at the v2ray_api listen address of the
|
||||
// currently applied config. A changed address drops the connection.
|
||||
func (c *singboxStatsClient) SetAddr(addr string) {
|
||||
if addr == c.addr {
|
||||
return
|
||||
}
|
||||
c.addr = addr
|
||||
c.closeConn()
|
||||
}
|
||||
|
||||
func (c *singboxStatsClient) closeConn() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *singboxStatsClient) queryStats(ctx context.Context) ([]singboxStatEntry, error) {
|
||||
if c.queryFn != nil {
|
||||
return c.queryFn(ctx)
|
||||
}
|
||||
if c.addr == "" {
|
||||
return nil, errors.New("sing-box stats address unknown (config carries no v2ray_api listen)")
|
||||
}
|
||||
if c.conn == nil {
|
||||
conn, err := grpc.NewClient(c.addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect sing-box stats API: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
}
|
||||
var resp sbRawBytes
|
||||
if err := c.conn.Invoke(ctx, singboxStatsServiceQuery, sbRawBytes(encodeSingboxQuery()), &resp, grpc.ForceCodec(sbRawCodec{})); err != nil {
|
||||
c.closeConn()
|
||||
return nil, err
|
||||
}
|
||||
return parseSingboxStatsResponse(resp)
|
||||
}
|
||||
|
||||
// GetTrafficSnapshot reads the core counters and computes deltas
|
||||
// against the committed baseline WITHOUT advancing it, under the
|
||||
// epoch rules: an epoch that moves mid-query fails the round; a
|
||||
// flipped epoch counts the new instance's counters in full; the
|
||||
// agent's first-ever snapshot is baseline-only.
|
||||
func (c *singboxStatsClient) GetTrafficSnapshot() (*xray.TrafficSnapshot, error) {
|
||||
epochBefore := c.epochFunc()
|
||||
if epochBefore == "" {
|
||||
return nil, errSingboxEpochChanged
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
stats, err := c.queryStats(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Debug("Failed to query sing-box stats:", err)
|
||||
return nil, err
|
||||
}
|
||||
if epochAfter := c.epochFunc(); epochAfter != epochBefore {
|
||||
return nil, errSingboxEpochChanged
|
||||
}
|
||||
c.pendingEpoch = epochBefore
|
||||
|
||||
snap := &xray.TrafficSnapshot{Values: make(map[string]int64, len(stats))}
|
||||
sameEpoch := c.committedReady && c.committedEpoch == epochBefore
|
||||
tagTrafficMap := make(map[string]*xray.Traffic)
|
||||
emailTrafficMap := make(map[string]*xray.ClientTraffic)
|
||||
|
||||
for _, stat := range stats {
|
||||
snap.Values[stat.Name] = stat.Value
|
||||
if !c.committedReady {
|
||||
continue
|
||||
}
|
||||
var value int64
|
||||
if sameEpoch {
|
||||
lastValue, ok := c.committed[stat.Name]
|
||||
if !ok || stat.Value < lastValue {
|
||||
lastValue = 0
|
||||
}
|
||||
value = stat.Value - lastValue
|
||||
} else {
|
||||
// New core instance: its counters started at zero, so
|
||||
// everything they show is traffic since the restart.
|
||||
value = stat.Value
|
||||
}
|
||||
if matches := singboxTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 {
|
||||
processSingboxTraffic(matches, value, tagTrafficMap)
|
||||
} else if matches := singboxClientTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 3 {
|
||||
processSingboxClientTraffic(matches, value, emailTrafficMap)
|
||||
}
|
||||
}
|
||||
|
||||
snap.Traffics = mapToSliceXrayTraffic(tagTrafficMap)
|
||||
snap.ClientTraffics = mapToSliceXrayClientTraffic(emailTrafficMap)
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// CommitTrafficSnapshot marks a snapshot as acknowledged by the
|
||||
// master: its Values and epoch become the committed baseline.
|
||||
func (c *singboxStatsClient) CommitTrafficSnapshot(s *xray.TrafficSnapshot) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
c.committed = s.BaselineValues()
|
||||
c.committedReady = true
|
||||
c.committedEpoch = c.pendingEpoch
|
||||
}
|
||||
|
||||
// RestoreTrafficBaseline installs a committed baseline recovered from
|
||||
// disk together with the epoch it was taken under. A baseline from a
|
||||
// different core instance is dropped and the first snapshot rebases
|
||||
// baseline-only: unlike a live epoch flip the agent observes itself,
|
||||
// an agent restart leaves a blind gap — the running core's counters
|
||||
// may include traffic the previous agent incarnation already
|
||||
// reported, so counting them in full could double-report.
|
||||
func (c *singboxStatsClient) RestoreTrafficBaseline(values map[string]int64, epoch string) {
|
||||
if len(values) == 0 || epoch == "" || epoch != c.epochFunc() {
|
||||
return
|
||||
}
|
||||
c.committed = make(map[string]int64, len(values))
|
||||
for k, v := range values {
|
||||
c.committed[k] = v
|
||||
}
|
||||
c.committedReady = true
|
||||
c.committedEpoch = epoch
|
||||
}
|
||||
|
||||
// GetOnlineUsers is unused for sing-box (the traffic cycle merges the
|
||||
// log tracker's view with the snapshot's active users itself); it
|
||||
// exists so the client satisfies the same shape as the xray one.
|
||||
func (c *singboxStatsClient) GetOnlineUsers() ([]xray.OnlineUser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func processSingboxTraffic(matches []string, value int64, trafficMap map[string]*xray.Traffic) {
|
||||
isInbound := matches[1] == "inbound"
|
||||
tag := matches[2]
|
||||
isDown := matches[3] == "downlink"
|
||||
if tag == "api" {
|
||||
return
|
||||
}
|
||||
traffic, ok := trafficMap[tag]
|
||||
if !ok {
|
||||
traffic = &xray.Traffic{IsInbound: isInbound, IsOutbound: !isInbound, Tag: tag}
|
||||
trafficMap[tag] = traffic
|
||||
}
|
||||
if isDown {
|
||||
traffic.Down = value
|
||||
} else {
|
||||
traffic.Up = value
|
||||
}
|
||||
}
|
||||
|
||||
func processSingboxClientTraffic(matches []string, value int64, clientTrafficMap map[string]*xray.ClientTraffic) {
|
||||
email := matches[1]
|
||||
isDown := matches[2] == "downlink"
|
||||
traffic, ok := clientTrafficMap[email]
|
||||
if !ok {
|
||||
traffic = &xray.ClientTraffic{Email: email}
|
||||
clientTrafficMap[email] = traffic
|
||||
}
|
||||
if isDown {
|
||||
traffic.Down = value
|
||||
} else {
|
||||
traffic.Up = value
|
||||
}
|
||||
}
|
||||
|
||||
func mapToSliceXrayTraffic(m map[string]*xray.Traffic) []*xray.Traffic {
|
||||
result := make([]*xray.Traffic, 0, len(m))
|
||||
for _, v := range m {
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapToSliceXrayClientTraffic(m map[string]*xray.ClientTraffic) []*xray.ClientTraffic {
|
||||
result := make([]*xray.ClientTraffic, 0, len(m))
|
||||
for _, v := range m {
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
// buildStatsResponse hand-encodes a QueryStatsResponse carrying the
|
||||
// given counters, the same wire shape the real core returns.
|
||||
func buildStatsResponse(counters map[string]int64) []byte {
|
||||
var out []byte
|
||||
for name, value := range counters {
|
||||
var stat []byte
|
||||
stat = protowire.AppendTag(stat, 1, protowire.BytesType)
|
||||
stat = protowire.AppendString(stat, name)
|
||||
stat = protowire.AppendTag(stat, 2, protowire.VarintType)
|
||||
stat = protowire.AppendVarint(stat, uint64(value))
|
||||
out = protowire.AppendTag(out, 1, protowire.BytesType)
|
||||
out = protowire.AppendBytes(out, stat)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestParseSingboxStatsResponse(t *testing.T) {
|
||||
raw := buildStatsResponse(map[string]int64{
|
||||
"inbound>>>ss22>>>traffic>>>uplink": 78,
|
||||
"user>>>bob@example.com>>>traffic>>>downlink": 573,
|
||||
})
|
||||
entries, err := parseSingboxStatsResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %d, want 2", len(entries))
|
||||
}
|
||||
got := map[string]int64{}
|
||||
for _, e := range entries {
|
||||
got[e.Name] = e.Value
|
||||
}
|
||||
if got["inbound>>>ss22>>>traffic>>>uplink"] != 78 || got["user>>>bob@example.com>>>traffic>>>downlink"] != 573 {
|
||||
t.Fatalf("counters = %v", got)
|
||||
}
|
||||
if _, err := parseSingboxStatsResponse([]byte{0xff, 0xff}); err == nil {
|
||||
t.Fatal("garbage input must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSingboxQuery(t *testing.T) {
|
||||
b := encodeSingboxQuery()
|
||||
var patterns []string
|
||||
for len(b) > 0 {
|
||||
num, typ, n := protowire.ConsumeTag(b)
|
||||
if n < 0 || num != 3 || typ != protowire.BytesType {
|
||||
t.Fatalf("unexpected field num=%d typ=%d", num, typ)
|
||||
}
|
||||
b = b[n:]
|
||||
v, n := protowire.ConsumeString(b)
|
||||
if n < 0 {
|
||||
t.Fatal("bad string field")
|
||||
}
|
||||
patterns = append(patterns, v)
|
||||
b = b[n:]
|
||||
}
|
||||
if len(patterns) != 3 {
|
||||
t.Fatalf("patterns = %v", patterns)
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedStats drives the tracker with scripted counters and a
|
||||
// scripted epoch, following the fakeStatsClient pattern of the xray
|
||||
// path: the delta math contract is what's under test here; the wire
|
||||
// itself is covered by the parse test and the real-binary E2E.
|
||||
type scriptedStats struct {
|
||||
counters map[string]int64
|
||||
epoch string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *scriptedStats) client() *singboxStatsClient {
|
||||
c := newSingboxStatsClient(func() string { return s.epoch })
|
||||
c.queryFn = func(context.Context) ([]singboxStatEntry, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
out := make([]singboxStatEntry, 0, len(s.counters))
|
||||
for name, value := range s.counters {
|
||||
out = append(out, singboxStatEntry{Name: name, Value: value})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
const (
|
||||
sbUserUp = "user>>>bob@example.com>>>traffic>>>uplink"
|
||||
sbUserDown = "user>>>bob@example.com>>>traffic>>>downlink"
|
||||
sbInUp = "inbound>>>ss22>>>traffic>>>uplink"
|
||||
)
|
||||
|
||||
func snapUserDown(snap *xray.TrafficSnapshot, email string) int64 {
|
||||
for _, ct := range snap.ClientTraffics {
|
||||
if ct.Email == email {
|
||||
return ct.Down
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestSingboxSnapshotBaselineThenDeltas(t *testing.T) {
|
||||
s := &scriptedStats{epoch: "sing-box:1:100", counters: map[string]int64{sbUserDown: 500, sbInUp: 40}}
|
||||
c := s.client()
|
||||
|
||||
snap, err := c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 1: %v", err)
|
||||
}
|
||||
if len(snap.ClientTraffics) != 0 || len(snap.Traffics) != 0 {
|
||||
t.Fatal("first snapshot of an epoch must be baseline-only")
|
||||
}
|
||||
c.CommitTrafficSnapshot(snap)
|
||||
|
||||
s.counters[sbUserDown] = 900
|
||||
s.counters[sbInUp] = 55
|
||||
snap, err = c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 2: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 400 {
|
||||
t.Fatalf("user delta = %d, want 400", got)
|
||||
}
|
||||
c.CommitTrafficSnapshot(snap)
|
||||
|
||||
// No movement: zero deltas are still shaped but empty of content.
|
||||
snap, err = c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 3: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 0 {
|
||||
t.Fatalf("idle delta = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxSnapshotEpochChangeCountsInFull(t *testing.T) {
|
||||
s := &scriptedStats{epoch: "sing-box:1:100", counters: map[string]int64{sbUserDown: 500}}
|
||||
c := s.client()
|
||||
snap, err := c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 1: %v", err)
|
||||
}
|
||||
c.CommitTrafficSnapshot(snap)
|
||||
|
||||
// Core restarted: counters reset and grow again. The new
|
||||
// instance's counters started at zero, so they count in full
|
||||
// (NexusGate's ApplyDelta rule) — never as a diff against the
|
||||
// dead instance's baseline, and never dropped.
|
||||
s.epoch = "sing-box:2:200"
|
||||
s.counters[sbUserDown] = 120
|
||||
snap, err = c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot after restart: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 120 {
|
||||
t.Fatalf("post-restart delta = %d, want 120 (counted in full)", got)
|
||||
}
|
||||
c.CommitTrafficSnapshot(snap)
|
||||
|
||||
s.counters[sbUserDown] = 150
|
||||
snap, err = c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 3: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 30 {
|
||||
t.Fatalf("post-rebase delta = %d, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxSnapshotEpochUnstableFails(t *testing.T) {
|
||||
epochs := []string{"sing-box:1:100", "sing-box:2:200"}
|
||||
calls := 0
|
||||
c := newSingboxStatsClient(func() string {
|
||||
e := epochs[min(calls, len(epochs)-1)]
|
||||
calls++
|
||||
return e
|
||||
})
|
||||
c.queryFn = func(context.Context) ([]singboxStatEntry, error) {
|
||||
return []singboxStatEntry{{Name: sbUserDown, Value: 10}}, nil
|
||||
}
|
||||
if _, err := c.GetTrafficSnapshot(); !errors.Is(err, errSingboxEpochChanged) {
|
||||
t.Fatalf("err = %v, want errSingboxEpochChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxSnapshotCounterResetCountsFromZero(t *testing.T) {
|
||||
s := &scriptedStats{epoch: "sing-box:1:100", counters: map[string]int64{sbUserDown: 500, sbUserUp: 10}}
|
||||
c := s.client()
|
||||
snap, err := c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 1: %v", err)
|
||||
}
|
||||
c.CommitTrafficSnapshot(snap)
|
||||
|
||||
// Same epoch but a counter moved backwards (core reset its stats
|
||||
// without a process restart): counts from zero, like xray.
|
||||
s.counters[sbUserDown] = 60
|
||||
s.counters[sbUserUp] = 4 // unseen-before direction appears too
|
||||
snap, err = c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 2: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 60 {
|
||||
t.Fatalf("reset counter delta = %d, want 60", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingboxRestoreBaselineEpochGate(t *testing.T) {
|
||||
s := &scriptedStats{epoch: "sing-box:7:700", counters: map[string]int64{sbUserDown: 800}}
|
||||
c := s.client()
|
||||
|
||||
// Baseline from a dead instance: ignored, snapshot rebases.
|
||||
c.RestoreTrafficBaseline(map[string]int64{sbUserDown: 300}, "sing-box:6:600")
|
||||
snap, err := c.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if len(snap.ClientTraffics) != 0 {
|
||||
t.Fatal("stale-epoch baseline must not produce deltas")
|
||||
}
|
||||
|
||||
// Baseline from the live instance: deltas continue from it.
|
||||
c2 := s.client()
|
||||
c2.RestoreTrafficBaseline(map[string]int64{sbUserDown: 300}, "sing-box:7:700")
|
||||
snap, err = c2.GetTrafficSnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot 2: %v", err)
|
||||
}
|
||||
if got := snapUserDown(snap, "bob@example.com"); got != 500 {
|
||||
t.Fatalf("restored delta = %d, want 500", got)
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,18 @@ import (
|
||||
// every heartbeat (P13). Pointer fields are omitted when their collector
|
||||
// failed, so the master keeps the last stored value instead of a zero.
|
||||
type telemetryPayload struct {
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
XrayVersion string `json:"xrayVersion,omitempty"`
|
||||
UptimeSeconds *uint64 `json:"uptimeSeconds,omitempty"`
|
||||
LatencyMs *int64 `json:"latencyMs,omitempty"`
|
||||
CpuPercent *float64 `json:"cpuPercent,omitempty"`
|
||||
MemPercent *float64 `json:"memPercent,omitempty"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
XrayVersion string `json:"xrayVersion,omitempty"`
|
||||
// SingboxVersion rides along since ITEM6 B3. Masters before the
|
||||
// B4/B5 field rename ignore the unknown key; the xray-named
|
||||
// fields stay authoritative until then.
|
||||
SingboxVersion string `json:"singboxVersion,omitempty"`
|
||||
UptimeSeconds *uint64 `json:"uptimeSeconds,omitempty"`
|
||||
LatencyMs *int64 `json:"latencyMs,omitempty"`
|
||||
CpuPercent *float64 `json:"cpuPercent,omitempty"`
|
||||
MemPercent *float64 `json:"memPercent,omitempty"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
}
|
||||
|
||||
// collectTelemetry samples every source independently; a failing source
|
||||
@@ -41,6 +45,9 @@ func (a *Agent) collectTelemetry() *telemetryPayload {
|
||||
if v, ok := a.cachedXrayVersion(); ok {
|
||||
t.XrayVersion = v
|
||||
}
|
||||
if v, ok := a.cachedSingboxVersion(); ok {
|
||||
t.SingboxVersion = v
|
||||
}
|
||||
if up, err := host.Uptime(); err == nil {
|
||||
t.UptimeSeconds = &up
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user