V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
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

- 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).
This commit is contained in:
Rogue
2026-10-23 07:53:11 +00:00
parent c2f1840535
commit 29de9761c8
67 changed files with 3081 additions and 832 deletions
+111 -3
View File
@@ -113,6 +113,17 @@ type Agent struct {
sbVerFor string
sbVerMod time.Time
// Core fetch backoff (V2.0.1 ①, see singbox_fetch.go).
sbFetchMu sync.Mutex
sbFetchFailedAt time.Time
// Fast-report state (V2.0.1 probe batch): the cadence and
// counted-interface pin delivered on the config poll. A zero
// fastInterval means the master never offered one (pre-V2.0.1
// master) and metrics keep riding the 60 s heartbeat.
fastIntervalSec atomic.Int64
fastUnsupported atomic.Bool
// Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat.
probes *probeRunner
@@ -278,10 +289,18 @@ func (a *Agent) Run() error {
trafficTicker := time.NewTicker(defaultTrafficInterval)
defer trafficTicker.Stop()
// Fast-report timer (V2.0.1): armed by the config poll once the
// master offers a cadence; disarmed while the master is older
// (no cadence offered) or answered 404 (endpoint unknown).
fastTimer := time.NewTimer(time.Hour)
fastTimer.Stop()
defer fastTimer.Stop()
// Initial config fetch
if err := a.fetchAndApplyConfig(); err != nil {
logger.Warningf("Initial config fetch failed: %v", err)
}
a.armFastTimer(fastTimer)
for {
select {
@@ -298,14 +317,69 @@ func (a *Agent) Run() error {
if err := a.fetchAndApplyConfig(); err != nil {
logger.Warningf("Config fetch failed: %v", err)
}
a.armFastTimer(fastTimer)
case <-trafficTicker.C:
if err := a.collectAndReportTraffic(); err != nil {
logger.Warningf("Traffic report failed: %v", err)
}
case <-fastTimer.C:
a.sendFastReport()
a.armFastTimer(fastTimer)
}
}
}
// armFastTimer (re)schedules the fast report from the current
// master-provided cadence. Not offered (0) or unsupported (404
// seen) leaves the timer stopped: metrics then ride the heartbeat,
// the pre-V2.0.1 arrangement.
func (a *Agent) armFastTimer(t *time.Timer) {
sec := a.fastIntervalSec.Load()
if sec <= 0 || a.fastUnsupported.Load() {
t.Stop()
return
}
t.Reset(time.Duration(sec) * time.Second)
}
// fastReportActive reports whether runtime metrics currently ride
// the fast channel (and so must stay off the heartbeat).
func (a *Agent) fastReportActive() bool {
return a.fastIntervalSec.Load() > 0 && !a.fastUnsupported.Load()
}
// sendFastReport posts one metrics-only report (V2.0.1). Failures
// are quiet by design — the stream is high-cadence and the next
// tick retries; a 404 marks the endpoint unsupported (pre-V2.0.1
// master) and metrics stay on the heartbeat from then on.
func (a *Agent) sendFastReport() {
m := collectMetrics()
if m == nil {
return
}
body, err := json.Marshal(map[string]any{"probeMetrics": m})
if err != nil {
return
}
url := a.config.MasterURL + "/panel/api/agent/probe-report"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+a.config.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode == http.StatusNotFound {
a.fastUnsupported.Store(true)
logger.Warning("master does not serve the probe fast-report endpoint; metrics stay on the heartbeat")
}
}
// Stop stops the agent
func (a *Agent) Stop() {
if a.probes != nil {
@@ -337,8 +411,16 @@ func (a *Agent) sendHeartbeat() error {
// costs nothing and heals a master that missed the register-time
// copy; metrics are sampled fresh each heartbeat. Old masters
// ignore both fields.
"probeFacts": collectFacts(),
"probeMetrics": collectMetrics(),
"probeFacts": collectFacts(),
}
// V2.0.1: runtime metrics ride the fast report whenever the
// master offered a cadence and the endpoint answered; the
// heartbeat then carries only the heavy, slow-moving content
// (facts, telemetry, probe results, update status). Without a
// fast channel the metrics stay here — a pre-V2.0.1 master must
// keep receiving them exactly as before.
if !a.fastReportActive() {
payload["probeMetrics"] = collectMetrics()
}
if rep := a.currentUpdateReport(); rep != nil {
payload["updateStatus"] = rep
@@ -417,11 +499,31 @@ func (a *Agent) fetchAndApplyConfig() error {
// current set, while a present-but-empty list means "run no
// probes" and clears it.
ProbeTasks *[]ProbeTask `json:"probe_tasks"`
// V2.0.1 probe batch: fast-report cadence + counted-interface
// pin. Pointers for the same reason — an old master omits
// both and the agent keeps its current behaviour (no fast
// report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"`
ProbeStatIface *string `json:"probe_stat_iface"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.ProbeFastInterval != nil {
sec := *result.ProbeFastInterval
if sec < 1 {
sec = 1
}
if sec > 60 {
sec = 60
}
a.fastIntervalSec.Store(sec)
}
if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface)
}
// One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged.
a.maybeHandleUpdate(result.AgentUpdate)
@@ -438,7 +540,13 @@ func (a *Agent) fetchAndApplyConfig() error {
// core), and never touch the xray path again — a later poll that
// somehow lacks the sing-box keys leaves both cores as they are
// rather than resurrecting xray.
if result.SingboxConfigHash != "" {
//
// V2.0.1 ① gate: the switch needs the sing-box core on disk. A
// node whose core is missing gets one fetch attempt (throttled);
// while it stays missing the switch is NOT latched and the xray
// path below keeps the node current — nothing is retired for a
// runtime that cannot start yet.
if result.SingboxConfigHash != "" && a.singboxCoreReady() {
a.sbSwitched = true
if result.SingboxConfigHash != a.lastSingboxHash {
if err := a.switchApplySingbox(result.SingboxConfig); err != nil {
+151
View File
@@ -0,0 +1,151 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// V2.0.1 probe batch, agent side: the config poll's cadence + pin
// are adopted; while the fast channel is active the heartbeat stops
// carrying metrics (they ride the fast report instead); a master
// without the endpoint (404) sends the agent back to heartbeat
// metrics permanently.
type fastMaster struct {
mu sync.Mutex
heartbeat []byte
fastReport []byte
fastStatus int
configBody string
reportStatus int
}
func (m *fastMaster) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.mu.Lock()
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(m.configBody))
case strings.HasSuffix(r.URL.Path, "/probe-report"):
m.fastReport = body
st := m.fastStatus
m.mu.Unlock()
if st != 0 && st != http.StatusOK {
w.WriteHeader(st)
return
}
_, _ = w.Write([]byte(`{"success":true}`))
case strings.HasSuffix(r.URL.Path, "/heartbeat"):
m.heartbeat = body
m.mu.Unlock()
_, _ = w.Write([]byte(`{"success":true}`))
default:
m.mu.Unlock()
http.NotFound(w, r)
}
})
}
func TestFastReportSplitAndFallback(t *testing.T) {
SetStatIfacePin("")
t.Cleanup(func() { SetStatIfacePin("") })
master := &fastMaster{configBody: `{"probe_fast_interval":5,"probe_stat_iface":"eth7"}`}
srv := httptest.NewServer(master.handler())
defer srv.Close()
a := &Agent{
config: &Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"},
httpClient: http.DefaultClient,
probes: newProbeRunner(),
}
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if got := a.fastIntervalSec.Load(); got != 5 {
t.Fatalf("fast interval = %d, want 5 from the poll", got)
}
if got := statIfacePinValue(); got != "eth7" {
t.Fatalf("stat iface pin = %q, want eth7", got)
}
if !a.fastReportActive() {
t.Fatal("fast report should be active after the poll offered a cadence")
}
// The fast report carries the metrics…
a.sendFastReport()
master.mu.Lock()
fast := master.fastReport
master.mu.Unlock()
var fastBody map[string]json.RawMessage
if err := json.Unmarshal(fast, &fastBody); err != nil || fastBody["probeMetrics"] == nil {
t.Fatalf("fast report = %s (err %v), want a probeMetrics block", fast, err)
}
// …and the heartbeat no longer does.
if err := a.sendHeartbeat(); err != nil {
t.Fatalf("sendHeartbeat: %v", err)
}
master.mu.Lock()
hb := master.heartbeat
master.mu.Unlock()
var hbBody map[string]json.RawMessage
if err := json.Unmarshal(hb, &hbBody); err != nil {
t.Fatalf("heartbeat body: %v", err)
}
if hbBody["probeMetrics"] != nil {
t.Error("heartbeat still carries probeMetrics while the fast channel is active")
}
if hbBody["probeFacts"] == nil || hbBody["telemetry"] == nil {
t.Error("heartbeat lost its heavy content (facts/telemetry)")
}
// A 404 from the fast endpoint (old master) disables the fast
// channel and restores heartbeat metrics.
master.mu.Lock()
master.fastStatus = http.StatusNotFound
master.mu.Unlock()
a.sendFastReport()
if a.fastReportActive() {
t.Fatal("fast report still active after a 404")
}
if err := a.sendHeartbeat(); err != nil {
t.Fatalf("sendHeartbeat after fallback: %v", err)
}
master.mu.Lock()
hb = master.heartbeat
master.mu.Unlock()
hbBody = map[string]json.RawMessage{}
if err := json.Unmarshal(hb, &hbBody); err != nil {
t.Fatalf("heartbeat body after fallback: %v", err)
}
if hbBody["probeMetrics"] == nil {
t.Error("heartbeat did not resume carrying probeMetrics after the 404 fallback")
}
}
// The counted-interface pin: strict single-interface selection, and
// a pin naming a vanished interface counts nothing (so the epoch
// changes and the master re-baselines instead of booking garbage).
func TestPinnedInterfaces(t *testing.T) {
all := []netCounters{
{name: "eth0", rx: 100, tx: 200},
{name: "ens3", rx: 300, tx: 400},
}
got := pinnedInterfaces(all, "ens3")
if len(got) != 1 || got[0].name != "ens3" || got[0].rx != 300 {
t.Errorf("pinnedInterfaces = %+v, want only ens3", got)
}
if got := pinnedInterfaces(all, "gone0"); len(got) != 0 {
t.Errorf("pin for a vanished interface = %+v, want empty", got)
}
}
+36 -1
View File
@@ -24,6 +24,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -115,6 +116,36 @@ func sampleFacts() *ProbeFacts {
}
}
// statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set
// selection below. The pin bypasses the three-layer filter on
// purpose: the operator named this exact device, including when it
// is one the filter would skip (a tunnel the node actually bills
// on). A pinned name absent from /proc/net/dev counts nothing —
// the boot epoch changes with the counted set, so the master
// re-baselines instead of booking a phantom delta.
var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin.
func SetStatIfacePin(name string) { statIfacePin.Store(name) }
func statIfacePinValue() string {
if v := statIfacePin.Load(); v != nil {
return v.(string)
}
return ""
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
return []netCounters{c}
}
}
return nil
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
@@ -123,7 +154,11 @@ func collectMetrics() *ProbeMetrics {
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
counted := countedInterfaces(parseNetDev(readProcFile("net/dev")), sysNetRoot)
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
+23
View File
@@ -193,6 +193,28 @@ func (p *singboxProcess) epoch() string {
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 {
@@ -214,6 +236,7 @@ func (p *singboxProcess) start(binPath, configPath, logPath string) error {
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 {
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// V2.0.1 ⑧: the sing-box core process is launched with
// GOMEMLIMIT=32MiB and GOGC=50 in its environment — on the first
// start and on every re-apply, since all launches go through
// singboxProcess.start. The agent's own env is not modified, and
// values the agent was itself started with are replaced (never
// duplicated) for the child.
func countEnv(env []string, want string) int {
n := 0
for _, kv := range env {
if kv == want {
n++
}
}
return n
}
func TestSingboxCoreEnv(t *testing.T) {
t.Setenv("GOMEMLIMIT", "999MiB")
t.Setenv("GOGC", "off")
env := singboxCoreEnv()
if n := countEnv(env, "GOMEMLIMIT=32MiB"); n != 1 {
t.Errorf("GOMEMLIMIT=32MiB appears %d times, want exactly 1 (%v)", n, env)
}
if n := countEnv(env, "GOGC=50"); n != 1 {
t.Errorf("GOGC=50 appears %d times, want exactly 1", n)
}
for _, kv := range env {
if kv == "GOMEMLIMIT=999MiB" || kv == "GOGC=off" {
t.Errorf("agent's own %q leaked into the core env", kv)
}
}
// The agent process env itself is untouched.
if got := os.Getenv("GOMEMLIMIT"); got != "999MiB" {
t.Errorf("agent env GOMEMLIMIT = %q, want the untouched 999MiB", got)
}
}
// TestSingboxProcessStartCarriesCoreEnv starts a stand-in core
// through the real launch path and reads back the environment the
// child actually received (the script dumps its own env on start).
func TestSingboxProcessStartCarriesCoreEnv(t *testing.T) {
dir := t.TempDir()
dump := filepath.Join(dir, "child-env.txt")
binPath := filepath.Join(dir, "sing-box")
script := "#!/bin/sh\nenv > '" + dump + "'\nexec sleep 3600\n"
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake core: %v", err)
}
cfgPath := filepath.Join(dir, "cfg.json")
if err := os.WriteFile(cfgPath, []byte("{}"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("GOMEMLIMIT", "999MiB")
p := &singboxProcess{}
if err := p.start(binPath, cfgPath, filepath.Join(dir, "sing-box.log")); err != nil {
t.Fatalf("start: %v", err)
}
defer p.stop()
var childEnv []string
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if raw, err := os.ReadFile(dump); err == nil && len(raw) > 0 {
childEnv = strings.Split(strings.TrimSpace(string(raw)), "\n")
break
}
time.Sleep(50 * time.Millisecond)
}
if childEnv == nil {
t.Fatal("fake core never dumped its environment")
}
if n := countEnv(childEnv, "GOMEMLIMIT=32MiB"); n != 1 {
t.Errorf("child env GOMEMLIMIT=32MiB x%d, want 1 (%v)", n, childEnv)
}
if n := countEnv(childEnv, "GOGC=50"); n != 1 {
t.Errorf("child env GOGC=50 x%d, want 1", n)
}
if n := countEnv(childEnv, "GOMEMLIMIT=999MiB"); n != 0 {
t.Errorf("child inherited the agent's GOMEMLIMIT=999MiB (%v)", childEnv)
}
}
+162
View File
@@ -0,0 +1,162 @@
package agent
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// ITEM V2.0.1 ①: agents upgraded through the panel's one-click
// update swap only their own binary — a node installed before the
// V2 switchover can hold no sing-box core at all, and applying the
// master's sing-box config there would strand it (the switch retires
// xray first). Before any switch is attempted, a missing core is
// fetched from the same Gitea release install-agent.sh installs
// from (the NexusGate repo's dedicated sing-box-v1.13.21 release),
// self-checked, and only then does the switch proceed. Any failure
// leaves the node exactly as it was: xray keeps running, the fetch
// retries on a later poll (throttled), and the failure is visible in
// the agent log and — through the still-empty singboxVersion — on
// the master's node list.
// singboxCoreVersion is the pinned self-built core release, the
// same pin install-agent.sh carries (SINGBOX_VER).
const singboxCoreVersion = "1.13.21"
// singboxCoreBaseURL is the release download base. A package
// variable so tests can point it at a local server.
var singboxCoreBaseURL = "https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v" + singboxCoreVersion
// singboxFetchBackoff suppresses immediate re-downloads after a
// failed fetch: a node whose route to the release host is broken
// must not hammer it every config poll.
const singboxFetchBackoff = 10 * time.Minute
// singboxCoreAssetName is the release asset for this agent's
// architecture (the release publishes linux amd64/arm64 only, the
// same set install-agent.sh supports).
func singboxCoreAssetName() (string, error) {
switch runtime.GOARCH {
case "amd64", "arm64":
return "nexusgate-sing-box-linux-" + runtime.GOARCH, nil
}
return "", fmt.Errorf("no sing-box core asset for architecture %s", runtime.GOARCH)
}
// singboxCoreReady reports whether the node can run sing-box: the
// core is on disk, or a fetch just installed it. Callers on the
// config path use it as the switch gate.
func (a *Agent) singboxCoreReady() bool {
if singboxBinaryPath() != "" {
return true
}
_ = a.fetchSingboxCoreThrottled()
return singboxBinaryPath() != ""
}
// fetchSingboxCoreThrottled runs one fetch attempt unless the last
// failure is still inside the backoff window. A failed attempt is
// logged once (here, not per poll) and stays visible on the master
// through the agent's still-empty singboxVersion telemetry.
func (a *Agent) fetchSingboxCoreThrottled() error {
a.sbFetchMu.Lock()
defer a.sbFetchMu.Unlock()
if !a.sbFetchFailedAt.IsZero() && time.Since(a.sbFetchFailedAt) < singboxFetchBackoff {
return fmt.Errorf("sing-box core fetch in backoff after failure at %s", a.sbFetchFailedAt.Format(time.RFC3339))
}
if err := a.fetchSingboxCore(); err != nil {
a.sbFetchFailedAt = time.Now()
logger.Warningf("sing-box core fetch failed, staying on the current runtime (retry after backoff): %v", err)
return err
}
a.sbFetchFailedAt = time.Time{}
return nil
}
// fetchSingboxCore downloads the pinned core into the bin folder,
// self-checks it (a runnable `version`, mirroring the install
// script's closing self-check), and only then moves it into place
// and writes the install-time version record the telemetry reads.
func (a *Agent) fetchSingboxCore() error {
asset, err := singboxCoreAssetName()
if err != nil {
return err
}
binDir := config.GetBinFolderPath()
if err := os.MkdirAll(binDir, 0o755); err != nil {
return fmt.Errorf("create bin folder: %w", err)
}
dest := filepath.Join(binDir, singboxBinaryName())
tmp := dest + ".download"
_ = os.Remove(tmp)
url := strings.TrimSuffix(singboxCoreBaseURL, "/") + "/" + asset
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
var transport http.RoundTripper
if a.httpClient != nil {
transport = a.httpClient.Transport
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download sing-box core: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download sing-box core: status %d", resp.StatusCode)
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("create sing-box core download: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("download sing-box core: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("download sing-box core: %w", err)
}
// Self-check before anything is moved into place: the binary
// must actually run on this node (right architecture, not a
// truncated or error-page download).
vctx, vcancel := context.WithTimeout(context.Background(), 10*time.Second)
out, verr := exec.CommandContext(vctx, tmp, "version").CombinedOutput()
vcancel()
if verr != nil {
_ = os.Remove(tmp)
return fmt.Errorf("sing-box core self-check failed: %v (%s)", verr, strings.TrimSpace(string(out)))
}
if len(strings.TrimSpace(string(out))) == 0 {
_ = os.Remove(tmp)
return fmt.Errorf("sing-box core self-check failed: version produced no output")
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("install sing-box core: %w", err)
}
// The version record is what telemetry reports for the self-built
// core (its `version` output carries no stamp) — same convention
// as install-agent.sh.
_ = os.WriteFile(filepath.Join(binDir, singboxVersionRecord), []byte(singboxCoreVersion+"\n"), 0o644)
logger.Infof("sing-box core %s fetched from the release and self-checked; ready to switch", singboxCoreVersion)
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package agent
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// V2.0.1 ①: a node missing its sing-box core fetches the pinned
// release build, self-checks it, and only then reports ready. A
// failed fetch installs nothing and backs off instead of hammering
// the release host on every config poll.
// fakeFetchedCore mimics the self-built release binary: it runs,
// and its `version` carries no stamp ("unknown") — the shape that
// makes telemetry fall back to the install-time version record.
const fakeFetchedCore = "#!/bin/sh\necho 'sing-box version unknown'\n"
func fakeCoreServer(t *testing.T, hits *atomic.Int32, status int) *httptest.Server {
t.Helper()
asset, err := singboxCoreAssetName()
if err != nil {
t.Skipf("no core asset for this arch: %v", err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if status != http.StatusOK {
w.WriteHeader(status)
return
}
if strings.HasSuffix(r.URL.Path, asset) {
_, _ = w.Write([]byte(fakeFetchedCore))
return
}
http.NotFound(w, r)
}))
}
func useCoreBase(t *testing.T, url string) {
t.Helper()
orig := singboxCoreBaseURL
singboxCoreBaseURL = url
t.Cleanup(func() { singboxCoreBaseURL = orig })
}
func TestFetchSingboxCoreInstallsAndSelfChecks(t *testing.T) {
binDir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
srv := fakeCoreServer(t, &hits, http.StatusOK)
defer srv.Close()
useCoreBase(t, srv.URL)
a := &Agent{httpClient: http.DefaultClient}
if !a.singboxCoreReady() {
t.Fatal("core not ready after fetch")
}
dest := filepath.Join(binDir, singboxBinaryName())
info, err := os.Stat(dest)
if err != nil {
t.Fatalf("core not installed: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Errorf("installed core is not executable: %v", info.Mode())
}
if got, ok := a.cachedSingboxVersion(); !ok || got != singboxCoreVersion {
t.Errorf("reported core version = %q (ok %v), want %q (from the version record)", got, ok, singboxCoreVersion)
}
if config.GetBinFolderPath() != binDir {
t.Fatalf("bin folder override not in effect")
}
// Second call: binary present, no further download.
if !a.singboxCoreReady() || hits.Load() != 1 {
t.Errorf("ready check re-downloaded: hits = %d", hits.Load())
}
}
func TestFetchSingboxCoreFailureKeepsNothingAndBacksOff(t *testing.T) {
binDir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
srv := fakeCoreServer(t, &hits, http.StatusBadGateway)
defer srv.Close()
useCoreBase(t, srv.URL)
a := &Agent{httpClient: http.DefaultClient}
if a.singboxCoreReady() {
t.Fatal("core reported ready after a failed fetch")
}
if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) {
t.Errorf("failed fetch left a core binary behind: %v", err)
}
// Backoff: an immediate retry must not hit the server again.
if err := a.fetchSingboxCoreThrottled(); err == nil {
t.Fatal("throttled retry returned no error")
}
if hits.Load() != 1 {
t.Errorf("backoff violated: server hits = %d, want 1", hits.Load())
}
// And the switch gate stays closed: ready remains false.
if a.singboxCoreReady() {
t.Error("core became ready during backoff")
}
}
func TestFetchSingboxCoreRejectsBrokenDownload(t *testing.T) {
binDir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("this is not a binary at all"))
}))
defer srv.Close()
useCoreBase(t, srv.URL)
a := &Agent{httpClient: http.DefaultClient}
if err := a.fetchSingboxCore(); err == nil {
t.Fatal("broken download passed the self-check")
}
if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) {
t.Errorf("broken download was installed: %v", err)
}
}
+59
View File
@@ -0,0 +1,59 @@
package agent
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// V2.0.1 ① integration: the master serves a sing-box config but the
// node has no core and the fetch fails — the switch must NOT latch
// (xray stays the runtime and the next poll retries), because the
// B5 switch retires xray the moment it engages.
func TestSwitchGateStaysOpenWhenCoreFetchFails(t *testing.T) {
binDir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusBadGateway)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
master := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/config") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"config_hash":"x1","singbox_config_hash":"sb1",` +
`"singbox_config":{"inbounds":[]}}`))
return
}
http.NotFound(w, r)
}))
defer master.Close()
a := &Agent{
config: &Config{MasterURL: master.URL, Token: "tok", NodeName: "n"},
httpClient: http.DefaultClient,
probes: newProbeRunner(),
}
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.sbSwitched {
t.Fatal("switch latched although the sing-box core could not be fetched")
}
if hits.Load() != 1 {
t.Errorf("core fetch attempts = %d, want exactly 1", hits.Load())
}
// A second poll inside the backoff window must not re-download.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second fetchAndApplyConfig: %v", err)
}
if hits.Load() != 1 {
t.Errorf("backoff violated on the config path: hits = %d", hits.Load())
}
if a.sbSwitched {
t.Fatal("switch latched during backoff")
}
}