Files
3x-ui-agent/internal/agent/singbox.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

542 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"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→B5: the agent's sing-box runtime. The master delivers a
// complete sing-box configuration on the config poll
// (singbox_config + singbox_config_hash, B2). Since the B5
// switchover that payload is the node's only runtime config: the
// agent treats its arrival as the switch signal, retires the legacy
// xray core (see switchApplySingbox in agent.go) and runs sing-box
// alone. A legacy master serves no sing-box keys, and the agent
// then keeps the xray path exactly as before — the dual capability
// is what makes the agents-first rollout order safe.
//
// Activation borrows NexusGate's discipline (agent-go activateSingBox):
// 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")
}
// singboxLogMaxBytes bounds the managed core's log on disk (ITEM6
// B5 rotation policy). The online tracker only ever tails the file,
// so beyond a few MB it is pure weight on small node volumes — but
// the log is also the online record, so the cap is generous rather
// than tight. Rotation keeps exactly one previous file
// (singbox.log.1), bounding the pair at ~2× the cap.
const singboxLogMaxBytes = 8 << 20 // 8 MiB
// rotateSingboxLogIfOversize copy-truncates the sing-box log once it
// passes the cap: the content is copied to singbox.log.1, then the
// live file is truncated in place. Copy-truncate (rather than
// rename) because the running core holds the file open — its fd
// stays valid and keeps appending; the online tracker already
// treats a size drop as a cursor reset, so the tail survives the
// rotation. Called on the sing-box traffic cycle; failures only log
// — log hygiene must never disturb the runtime.
func rotateSingboxLogIfOversize(path string) {
fi, err := os.Stat(path)
if err != nil || fi.Size() <= singboxLogMaxBytes {
return
}
src, err := os.Open(path)
if err != nil {
logger.Warningf("sing-box log rotation: open %s: %v", path, err)
return
}
dst, err := os.OpenFile(path+".1", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
_ = src.Close()
logger.Warningf("sing-box log rotation: create %s.1: %v", path, err)
return
}
_, copyErr := io.Copy(dst, src)
closeErr := dst.Close()
_ = src.Close()
if copyErr != nil || closeErr != nil {
logger.Warningf("sing-box log rotation: copy %s: %v / %v", path, copyErr, closeErr)
return
}
if err := os.Truncate(path, 0); err != nil {
logger.Warningf("sing-box log rotation: truncate %s: %v", path, err)
return
}
logger.Infof("sing-box log rotated at %d bytes (previous kept as %s.1)", fi.Size(), path)
}
// singboxProcess manages one sing-box child process: lifecycle only,
// 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())
}
// singboxCoreEnv is the environment the sing-box core process runs
// with (V2.0.1 ⑧): a 32 MiB Go memory limit and GOGC=50, so the
// core's heap is collected back instead of only growing — the
// untuned core on HW-SG idles around 30 MB RSS where the tuned
// NexusGate nodes sit under 10 MB. Every core launch goes through
// singboxProcess.start, so the first start and every re-apply /
// restart inherit this. Only the child is tuned: the agent's own
// runtime settings are untouched, and values the agent itself was
// started with are replaced, not duplicated (a duplicate key's
// effective value would depend on the reader).
func singboxCoreEnv() []string {
env := os.Environ()
out := make([]string, 0, len(env)+2)
for _, kv := range env {
if strings.HasPrefix(kv, "GOMEMLIMIT=") || strings.HasPrefix(kv, "GOGC=") {
continue
}
out = append(out, kv)
}
return append(out, "GOMEMLIMIT=32MiB", "GOGC=50")
}
// 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.Env = singboxCoreEnv()
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
}