Files
3x-ui-agent/internal/agent/singbox_switch_test.go
T
Muse 4a532e8c5a B5 switchover: sing-box is the only runtime payload
- master getConfig serves singbox_config only; xray payload retired
  (build failure is a hard 500, agent keeps its current state)
- agent: singbox_config arrival is the switch signal — flush xray
  tail traffic, stop xray, activate sing-box; activation failure
  restarts xray from the last payload and retries next poll; a
  legacy master (no sing-box keys) keeps the xray path untouched
- sing-box log rotation: 8 MiB copy-truncate with one .1 kept,
  checked on the traffic cycle
- install-agent.sh: xray/geo downloads and checks removed, agent
  binary now from the V2.0.0 release, self-check is sing-box
  binary + check on the live config
- display version default 2.0.0 (master V2.0.0 / agent A2.0.0)
2026-10-23 01:24:43 +00:00

153 lines
4.3 KiB
Go

package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// ITEM6 B5 switchover tests: the poll-level switch sequence. The
// fake xray binary stands in for the legacy core so the flush →
// stop → activate → (rollback) ordering can be pinned without a
// real core; real-core behavior is covered by the env-gated E2E.
const fakeXrayScript = `#!/bin/sh
case "$1" in
-version|version)
echo "Xray 26.6.27 (fake)"
exit 0
;;
esac
exec sleep 3600
`
func setupFakeXray(t *testing.T, binDir string) {
t.Helper()
path := filepath.Join(binDir, xray.GetBinaryName())
if err := os.WriteFile(path, []byte(fakeXrayScript), 0o755); err != nil {
t.Fatal(err)
}
}
// startLegacyXray puts the agent into the pre-switchover state: a
// running xray core whose payload is remembered for rollback.
func startLegacyXray(t *testing.T, a *Agent) {
t.Helper()
cfg := json.RawMessage(`{"inbounds":[],"outbounds":[]}`)
if err := a.applyXrayConfig(cfg); err != nil {
t.Fatalf("apply legacy xray config: %v", err)
}
a.lastXrayConfig = append(json.RawMessage(nil), cfg...)
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
t.Fatal("fake xray core not running")
}
}
func TestSwitchoverRetiresXrayAndRunsSingbox(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
a := newApplyTestAgent()
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.switchApplySingbox([]byte(sbCfgA)); err != nil {
t.Fatalf("switch apply: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired by the switchover")
}
if !a.singboxRunning() {
t.Fatal("sing-box must be running after the switchover")
}
if got := appliedConfig(t); got != sbCfgA {
t.Fatalf("applied config = %q, want cfgA", got)
}
}
func TestSwitchoverFailureRestartsXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
a := newApplyTestAgent()
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
bad := `{"log":{"level":"info"},"inbounds":[{"type":"BADSCHEMA"}]}`
if err := a.switchApplySingbox([]byte(bad)); err == nil {
t.Fatal("switch to a check-failing config must fail")
}
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
t.Fatal("xray must be restarted from the last payload after a failed switchover")
}
if a.singboxRunning() {
t.Fatal("sing-box must not be running after a failed switchover")
}
}
func TestFetchAndApplySwitchoverIgnoresLegacyAfterSwitch(t *testing.T) {
setupFakeSingbox(t)
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")
_ = json.NewEncoder(w).Encode(map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
a := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"})
t.Cleanup(a.stopSingbox)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetch+apply: %v", err)
}
if !a.sbSwitched {
t.Fatal("agent must record the switchover once the master serves sing-box")
}
if !a.singboxRunning() {
t.Fatal("sing-box must be running after the switchover poll")
}
if a.lastSingboxHash != hex.EncodeToString(sum[:]) {
t.Fatalf("lastSingboxHash = %q, want the served hash", a.lastSingboxHash)
}
}
func TestRotateSingboxLogIfOversize(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "singbox.log")
small := []byte("one line\n")
if err := os.WriteFile(path, small, 0o640); err != nil {
t.Fatal(err)
}
rotateSingboxLogIfOversize(path)
if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) {
t.Fatal("a small log must not be rotated")
}
big := make([]byte, singboxLogMaxBytes+123)
for i := range big {
big[i] = 'x'
}
if err := os.WriteFile(path, big, 0o640); err != nil {
t.Fatal(err)
}
rotateSingboxLogIfOversize(path)
fi, err := os.Stat(path)
if err != nil || fi.Size() != 0 {
t.Fatalf("live log after rotation: size=%v err=%v, want truncated to 0", fi, err)
}
prev, err := os.Stat(path + ".1")
if err != nil || prev.Size() != int64(len(big)) {
t.Fatalf("previous log: size=%v err=%v, want %d", prev, err, len(big))
}
}