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") } }