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.
This commit is contained in:
@@ -55,6 +55,11 @@ type Config struct {
|
||||
// Empty: main derives it next to the agent config file; the agent
|
||||
// falls back to the bin folder.
|
||||
BaselinePath string `json:"baseline_path,omitempty"`
|
||||
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
|
||||
// fetched, started or stopped for the node's inbounds — only the
|
||||
// probe channels run. The master's per-node flag (delivered on the
|
||||
// config poll) enables the same mode; either source turns it on.
|
||||
ProbeOnly bool `json:"probeOnly,omitempty"`
|
||||
}
|
||||
|
||||
// Agent represents the node agent
|
||||
@@ -124,6 +129,11 @@ type Agent struct {
|
||||
fastIntervalSec atomic.Int64
|
||||
fastUnsupported atomic.Bool
|
||||
|
||||
// Probe-only mode (V2.1.0): the master's per-node flag rides the
|
||||
// config poll; the local config preset is the other source. While
|
||||
// either is on, the poll skips both core branches entirely.
|
||||
masterProbeOnly 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
|
||||
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
// report, automatic interface selection).
|
||||
ProbeFastInterval *int64 `json:"probe_fast_interval"`
|
||||
ProbeStatIface *string `json:"probe_stat_iface"`
|
||||
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
|
||||
// probe keys: a master too old to know the mode omits the field
|
||||
// and the agent keeps its current state.
|
||||
ProbeOnly *bool `json:"probe_only"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
if result.ProbeStatIface != nil {
|
||||
SetStatIfacePin(*result.ProbeStatIface)
|
||||
}
|
||||
if result.ProbeOnly != nil {
|
||||
a.masterProbeOnly.Store(*result.ProbeOnly)
|
||||
}
|
||||
|
||||
// One-click update intents ride this poll (item 20); handle them even
|
||||
// when the config itself is unchanged.
|
||||
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
a.probes.applyTasks(*result.ProbeTasks)
|
||||
}
|
||||
|
||||
// Probe-only mode (V2.1.0): no core config is applied at all. The
|
||||
// master already withholds the payload for flagged nodes, but the
|
||||
// local preset must gate even against a master that still serves
|
||||
// one. A core left running from before the mode was turned on is
|
||||
// retired by the convergence step.
|
||||
if a.probeOnlyActive() {
|
||||
a.retireCoresForProbeOnly()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Switchover (ITEM6 B5): a master that serves a sing-box config
|
||||
// has made sing-box the node's formal runtime. Apply the config
|
||||
// through the switch sequence (which retires the legacy xray
|
||||
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeOnlyActive reports whether probe-only mode is in force from
|
||||
// either source: the local agent.json preset or the master's
|
||||
// per-node flag on the config poll.
|
||||
func (a *Agent) probeOnlyActive() bool {
|
||||
return a.config.ProbeOnly || a.masterProbeOnly.Load()
|
||||
}
|
||||
|
||||
// retireCoresForProbeOnly converges a node that entered probe-only
|
||||
// mode while a core was still running: the core's tail traffic is
|
||||
// flushed best-effort (an explicit operator mode change outranks the
|
||||
// unreported tail, unlike the switchover retire path, which keeps
|
||||
// the core alive on a failed flush) and the core is stopped. Later
|
||||
// polls find nothing running and skip, so the retirement happens
|
||||
// exactly once per entry. The switchover bookkeeping (sbSwitched,
|
||||
// lastSingboxHash) is left untouched on purpose: leaving probe-only
|
||||
// re-serves the same config hash, and the existing "hash current but
|
||||
// core not running" poll path reactivates the core on its own.
|
||||
func (a *Agent) retireCoresForProbeOnly() {
|
||||
if a.xrayProc != nil {
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopXray()
|
||||
logger.Info("probe-only mode: xray core retired")
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
if err := a.collectAndReportSingboxTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopSingbox()
|
||||
logger.Info("probe-only mode: sing-box core retired")
|
||||
}
|
||||
}
|
||||
|
||||
// singboxRunning reports whether the managed sing-box process is
|
||||
// currently alive.
|
||||
func (a *Agent) singboxRunning() bool {
|
||||
@@ -914,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
|
||||
// (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 {
|
||||
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
|
||||
// no-op, so a core retired by the mode can never be mistaken for
|
||||
// a live one by a later tick.
|
||||
if a.probeOnlyActive() {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
firstErr = err
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user