- SingboxService builds the full sing-box config from the new model: stored fragments re-projected against the identity layer (converter projections factored into internal/singbox/project.go, shared by converter and generator), agent singbox_outbounds/routing_rules override, forwarding compiled to sing-box outbounds+route rules, default private-CIDR reject rule (geoip:private replacement), tun dns landing in the config dns block (closes the B1 'B2' pending). - trafficStatsSwitches wiring: experimental.v2ray_api.stats lists are generated per category only when both direction switches are on; all-off yields no experimental block (no backfill, per the switches' documented semantics). - Agent /config endpoint additionally serves singbox_config + singbox_config_hash; xray payload untouched, sing-box build failures log-and-omit. - SubService.GetLinkFromModel regenerates share links from identity + singbox_config/singbox_preserved only (legacy generators reused over rebuilt settings/stream views). Golden fixtures 26/26 byte-equal; production backup DB: 107/107 links equal (55 byte-equal, 52 equal modulo the legacy generator's own random Reality sni/sid pick), generated config passes sing-box check. - Converter preservation amendments (panel-side, for blocked/discarded material link generation still needs): blockedTls for xhttp/kcp, verbatim streamNetwork/streamSecurity, vlessEncryption presence, ssPassword for removed ciphers, wireguardDns, socksAuth/socksAccounts provenance for exact mixed/socks user refresh. Co-Authored-By: Muse Spark <noreply@muse.dev>
126 lines
4.5 KiB
Go
126 lines
4.5 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: the agent config endpoint serves the sing-box shape
|
|
// alongside the xray config (parallel generation path). The xray
|
|
// payload must be untouched; 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 damaged: 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")
|
|
}
|
|
}
|