Display L-UI; panel binary/process l-ui, agent lui-agent. Dirs /usr/local/l-ui, /etc/l-ui, /var/log/l-ui; DB /etc/l-ui/l-ui.db (name file drives it via GetName), probe.db beside it, cert /etc/l-ui/cert, bin /usr/local/l-ui/bin. Services l-ui.service / lui-agent.service, OpenRC lui-agent, l-ui.sh, cmd/lui-agent. Env prefix XUI_ -> LUI_ (code reads new prefix only). Release assets l-ui-linux-* / lui-agent-linux-*; Gitea repo rogue/l-ui is a placeholder pending user confirmation (see selfupdate.go note). Go module path github.com/mhsanaei/3x-ui/v3 intentionally kept. Versions unchanged: panel V2.6.0 / agent A2.5.0.
210 lines
6.5 KiB
Go
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("LUI_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)
|
|
}
|
|
}
|