Files
3x-ui-agent/internal/sub/service_model_prod_test.go
T
MuseandMuse Spark 741cd905ff ITEM6 B2: sing-box generation side (parallel path)
- 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>
2026-10-22 21:42:40 +00:00

269 lines
7.9 KiB
Go

package sub
import (
"encoding/json"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// TestProductionLinksFromModel is the B2 production-form acceptance
// run: over a migrated copy of a production-era backup database
// (25 inbounds), every link the new model generates must equal the
// legacy path's link for the same (inbound, client) — byte for byte,
// except where the LEGACY generator itself is nondeterministic:
// for Reality inbounds with several serverNames/shortIds it picks
// one at random per call, so there the new link must equal the old
// in every other parameter and its sni/sid must be members of the
// configured lists.
//
// Gated on XUI_B2_OLD_DB pointing at the backup .db file. The same
// run also builds the node's sing-box config through the real
// service path and, when a sing-box binary is available
// (XUI_SINGBOX_BIN or the workspace self-build), requires it to pass
// `sing-box check`.
func TestProductionLinksFromModel(t *testing.T) {
src := os.Getenv("XUI_B2_OLD_DB")
if src == "" {
t.Skip("XUI_B2_OLD_DB not set")
}
dir := t.TempDir()
dst := filepath.Join(dir, "prod.db")
copyDBFile(t, src, dst)
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(src + suffix); err == nil {
copyDBFile(t, src+suffix, dst+suffix)
}
}
if err := database.InitDB(dst); err != nil {
t.Fatalf("InitDB over backup copy: %v", err)
}
defer func() { _ = database.CloseDB() }()
db := database.GetDB()
var inbounds []*model.Inbound
if err := db.Model(&model.Inbound{}).Preload("ClientStats").Find(&inbounds).Error; err != nil {
t.Fatalf("load inbounds: %v", err)
}
if len(inbounds) == 0 {
t.Fatalf("no inbounds in backup DB")
}
oldSvc := NewSubService("")
oldSvc.loadNodes()
newSvc := NewSubService("")
newSvc.loadNodes()
clientSvc := &service.ClientService{}
compared, byteEqual, realityNormalized := 0, 0, 0
for _, inbound := range inbounds {
clients, err := clientSvc.ListForInbound(nil, inbound.Id)
if err != nil {
t.Fatalf("ListForInbound %d: %v", inbound.Id, err)
}
for _, c := range clients {
oldLink := oldSvc.GetLink(inbound, c.Email)
newLink := newSvc.GetLinkFromModel(inbound, c.Email)
compared++
if oldLink == newLink {
byteEqual++
continue
}
if linksEqualModuloRealityPick(t, inbound, oldLink, newLink) {
realityNormalized++
continue
}
t.Errorf("inbound %d (%s) client %s:\n old: %s\n new: %s",
inbound.Id, inbound.Protocol, c.Email, oldLink, newLink)
}
}
t.Logf("link comparison: %d compared, %d byte-equal, %d reality-pick-normalized",
compared, byteEqual, realityNormalized)
if compared == 0 {
t.Fatalf("no (inbound, client) pairs compared")
}
// ---- generation side over the same migrated copy -------------
// The backup predates agent nodes (panel-local inbounds), so the
// rows are homed onto a synthetic node in the COPY only, and the
// real service path builds that node's config.
if err := db.Exec("UPDATE inbounds SET node_id = 1 WHERE enable = 1").Error; err != nil {
t.Fatalf("home inbounds onto test node: %v", err)
}
sbSvc := &service.SingboxService{}
cfg, err := sbSvc.GetSingboxConfigForAgent(1, "", "")
if err != nil {
t.Fatalf("GetSingboxConfigForAgent: %v", err)
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
fragCount := 0
userCount := 0
realityKeys := 0
for _, key := range []string{"inbounds", "endpoints"} {
if arr, ok := cfg[key].([]any); ok {
fragCount += len(arr)
for _, e := range arr {
em, _ := e.(map[string]any)
if users, ok := em["users"].([]any); ok {
userCount += len(users)
}
if tls, ok := em["tls"].(map[string]any); ok {
if reality, ok := tls["reality"].(map[string]any); ok {
if pk, _ := reality["private_key"].(string); pk != "" {
realityKeys++
}
}
}
}
}
}
t.Logf("generated config: %d fragments, %d users, %d reality private keys, %d bytes",
fragCount, userCount, realityKeys, len(raw))
if fragCount != 25 {
t.Errorf("fragments = %d, want 25 (production census)", fragCount)
}
// 109, not B1's fragment count of 107: the two mixed inbounds
// each carry one fixed settings account AND one attached
// identity client. The B1 fragments hold only the accounts (the
// migrator resolved mixed clients from the empty settings
// mirror), while the live xray generation path authenticates
// accounts + identity clients — and the link comparison above
// proves those clients' socks links are live today. B2's
// identity refresh reproduces the live path: 105 vless/ss
// clients + 2x(1 account + 1 client) = 109.
if userCount != 109 {
t.Errorf("users = %d, want 109 (105 protocol clients + 2 mixed accounts + 2 mixed clients)", userCount)
}
if realityKeys != 12 {
t.Errorf("reality private keys = %d, want 12", realityKeys)
}
exp, _ := cfg["experimental"].(map[string]any)
if exp == nil {
t.Errorf("experimental block missing (migrated switches are inbound-on)")
}
bin := os.Getenv("XUI_SINGBOX_BIN")
if bin == "" {
bin = filepath.Join(os.Getenv("HOME"), "workspace", "singbox-build", "dist", "sing-box-v2ray")
}
if _, err := os.Stat(bin); err == nil {
cfgPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(cfgPath, raw, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
out, err := exec.Command(bin, "check", "-c", cfgPath).CombinedOutput()
if err != nil {
t.Errorf("sing-box check failed: %v\n%s", err, out)
} else {
t.Logf("sing-box check: OK (%s)", bin)
}
} else {
t.Logf("sing-box binary not found at %s, check skipped", bin)
}
}
func copyDBFile(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
t.Fatalf("write %s: %v", dst, err)
}
}
// linksEqualModuloRealityPick compares two (possibly multi-line,
// externalProxy-fanned) links allowing only the Reality sni/sid
// random picks to differ, and then only within the configured lists.
func linksEqualModuloRealityPick(t *testing.T, inbound *model.Inbound, oldLink, newLink string) bool {
t.Helper()
var stream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
rs, _ := stream["realitySettings"].(map[string]any)
if rs == nil {
return false
}
names := map[string]bool{}
if arr, ok := rs["serverNames"].([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
names[s] = true
}
}
}
sids := map[string]bool{}
if arr, ok := rs["shortIds"].([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
sids[s] = true
}
}
}
if len(names) <= 1 && len(sids) <= 1 {
return false // single-valued lists are deterministic; any diff is real
}
oldLines := strings.Split(oldLink, "\n")
newLines := strings.Split(newLink, "\n")
if len(oldLines) != len(newLines) {
return false
}
sort.Strings(oldLines)
sort.Strings(newLines)
for i := range oldLines {
if !oneLinkEqualModuloRealityPick(oldLines[i], newLines[i], names, sids) {
return false
}
}
return true
}
func oneLinkEqualModuloRealityPick(oldLink, newLink string, names, sids map[string]bool) bool {
ou, err1 := url.Parse(oldLink)
nu, err2 := url.Parse(newLink)
if err1 != nil || err2 != nil {
return false
}
if ou.Scheme != nu.Scheme || ou.Host != nu.Host || ou.Fragment != nu.Fragment ||
ou.User.String() != nu.User.String() {
return false
}
oq, nq := ou.Query(), nu.Query()
keys := map[string]bool{}
for k := range oq {
keys[k] = true
}
for k := range nq {
keys[k] = true
}
for k := range keys {
ov, nv := oq.Get(k), nq.Get(k)
switch k {
case "sid":
if !sids[ov] || !sids[nv] {
return false
}
case "sni":
if !names[ov] || !names[nv] {
return false
}
default:
if ov != nv {
return false
}
}
}
return true
}