- 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)
153 lines
4.3 KiB
Go
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))
|
|
}
|
|
}
|