Files
3x-ui-agent/internal/agent/probe_only_test.go
T
Rogue 5ca22ec340 probe-only node mode: master flag + agent gate + panel switch (V2.1.0 A)
agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
2026-10-23 11:54:35 +00:00

210 lines
6.5 KiB
Go

package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
)
// V2.1.0 probe-only tests: the mode gates both core branches of the
// config poll (no fetch, no start, no switch latch), retires a core
// that was running when the mode turns on, and self-heals back to
// the core when the mode turns off again.
// probeMaster is a fake master whose config response is assembled
// from a switchable probe_only flag plus the usual sing-box payload.
type probeMaster struct {
probeOnly atomic.Bool
trafficHit atomic.Int32
srv *httptest.Server
}
func newProbeMaster(t *testing.T) *probeMaster {
t.Helper()
m := &probeMaster{}
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
"probe_tasks": []any{},
// The real master always sends the flag (only a pre-V2.1.0
// master omits it); the fixture mirrors that.
"probe_only": m.probeOnly.Load(),
}
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
m.trafficHit.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func (m *probeMaster) agent(cfg *Config) *Agent {
if cfg == nil {
cfg = &Config{}
}
cfg.MasterURL = m.srv.URL
cfg.Token = "tok"
cfg.NodeName = "n"
return New(cfg)
}
// The master's flag alone (no local preset) must gate the poll: the
// served sing-box payload is ignored entirely.
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if !a.probeOnlyActive() {
t.Fatal("probe-only must be active from the master flag")
}
if a.sbSwitched {
t.Fatal("switchover latched under probe-only")
}
if a.singboxRunning() {
t.Fatal("sing-box started under probe-only")
}
if a.lastSingboxHash != "" {
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
}
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
}
}
// The local agent.json preset gates even against a master that
// still serves a payload (and predates the flag): with no core
// binary on disk, not even a core fetch may be attempted.
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
binDir := t.TempDir() // deliberately no fake sing-box binary
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
m := newProbeMaster(t) // probe_only absent from its responses
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if hits.Load() != 0 {
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
}
if a.sbSwitched || a.singboxRunning() {
t.Fatal("core engaged under local probe-only preset")
}
}
// Turning the flag on while sing-box is running retires the core
// exactly once; turning it off again reactivates the core from the
// unchanged hash (the poll's not-running path), without the
// bookkeeping having been dirtied in between.
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("initial fetch: %v", err)
}
if !a.singboxRunning() || !a.sbSwitched {
t.Fatal("precondition: sing-box must be running after the normal poll")
}
hashBefore := a.lastSingboxHash
m.probeOnly.Store(true)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("probe-only fetch: %v", err)
}
if a.singboxRunning() {
t.Fatal("sing-box must be retired when probe-only turns on")
}
if !a.sbSwitched {
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
}
if a.lastSingboxHash != hashBefore {
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
}
// A second probe-only poll is a no-op, not an error.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second probe-only fetch: %v", err)
}
m.probeOnly.Store(false)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("heal-back fetch: %v", err)
}
if !a.singboxRunning() {
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
}
}
// Same retirement for a legacy xray core that predates the
// switchover: probe-only stops it and does not start sing-box.
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired under probe-only")
}
if a.singboxRunning() || a.sbSwitched {
t.Fatal("sing-box must not engage under probe-only")
}
}
// The traffic cycle is an explicit no-op under probe-only: even
// with a core process somehow alive, nothing is collected or posted.
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
// Put a core under the agent directly (bypassing the poll gate)
// to prove the traffic cycle itself refuses to touch it.
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
t.Fatalf("applySingboxConfig: %v", err)
}
if !a.singboxRunning() {
t.Fatal("precondition: fake sing-box must be running")
}
if err := a.collectAndReportTraffic(); err != nil {
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
}
if n := m.trafficHit.Load(); n != 0 {
t.Fatalf("traffic posted %d times under probe-only", n)
}
}