Files
l-ui/internal/web/controller/agent_singbox_config_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

126 lines
4.6 KiB
Go

package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// ITEM6 B2→B5: the agent config endpoint serves the sing-box shape.
// Since the B5 switchover it is the ONLY runtime payload — the xray
// keys (config_hash / xray_config) are retired and must not reappear;
// the singbox keys carry the node's fragments re-projected against
// the identity layer, plus a hash of the exact bytes served.
const sbToken = "singbox-config-token-00000000000000000001"
func TestConfigCarriesSingboxShape(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "sb-node", Address: "10.0.9.4"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
agent := &model.AgentNode{
TokenHash: hashAgentToken(sbToken),
TokenPrefix: sbToken[:12],
Name: "sb-node",
NodeID: &node.Id,
AgentVersion: "A1.0.0",
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
inbound := &model.Inbound{
Enable: true,
Port: 443,
Protocol: model.VLESS,
Tag: "inbound-443",
NodeID: &node.Id,
Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`,
StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` +
`{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` +
`"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
SingboxConfig: `{"type":"vless","tag":"inbound-443","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` +
`"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` +
`"short_id":["ab12cd34"]}},"users":[{"name":"stale@example.com","uuid":"stale"}]}`,
SingboxPreserved: `{"panel":{"streamNetwork":"tcp","streamSecurity":"reality",` +
`"realityClientSettings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
client := &model.ClientRecord{
Email: "live@example.com",
UUID: "11111111-2222-4333-8444-555555555555",
Enable: true,
}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}).Error; err != nil {
t.Fatalf("seed link: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
req.Header.Set("Authorization", "Bearer "+sbToken)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
}
var resp struct {
ConfigHash string `json:"config_hash"`
XrayConfig json.RawMessage `json:"xray_config"`
SingboxConfig json.RawMessage `json:"singbox_config"`
SingboxConfigHash string `json:"singbox_config_hash"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("config response not JSON: %v", err)
}
if resp.ConfigHash != "" || len(resp.XrayConfig) != 0 {
t.Fatalf("xray payload must be retired since B5: hash=%q len=%d", resp.ConfigHash, len(resp.XrayConfig))
}
if len(resp.SingboxConfig) == 0 || resp.SingboxConfigHash == "" {
t.Fatalf("singbox payload missing: len=%d hash=%q", len(resp.SingboxConfig), resp.SingboxConfigHash)
}
var cfg map[string]any
if err := json.Unmarshal(resp.SingboxConfig, &cfg); err != nil {
t.Fatalf("singbox config not an object: %v", err)
}
inbounds, _ := cfg["inbounds"].([]any)
if len(inbounds) != 1 {
t.Fatalf("singbox inbounds = %v", cfg["inbounds"])
}
frag, _ := inbounds[0].(map[string]any)
users, _ := frag["users"].([]any)
if len(users) != 1 {
t.Fatalf("fragment users = %v (identity re-projection failed)", frag["users"])
}
user, _ := users[0].(map[string]any)
if user["name"] != "live@example.com" || user["uuid"] != "11111111-2222-4333-8444-555555555555" {
t.Fatalf("fragment user = %v, want the live identity client (stale stored user replaced)", user)
}
exp, _ := cfg["experimental"].(map[string]any)
if exp == nil {
t.Fatalf("experimental stats block missing under factory-default switches")
}
}