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:
Muse
2026-10-22 22:48:12 +00:00
parent 741cd905ff
commit 1762e54d5f
10 changed files with 2510 additions and 13 deletions
+178 -5
View File
@@ -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)
+468
View File
@@ -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
}
+211
View File
@@ -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)
}
}
}
+684
View File
@@ -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")
}
}
+182
View File
@@ -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
}
+124
View File
@@ -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
}
+380
View File
@@ -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
}
+244
View File
@@ -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)
}
}
+15 -8
View File
@@ -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
}