- 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>
269 lines
7.9 KiB
Go
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
|
|
}
|