Files
3x-ui-agent/internal/web/service/singbox_golden_test.go
T
Muse c610ab88ea B5 rehearsal fix: sing-box stats registration needs all three lists
The B5 full-stack rehearsal caught a silent accounting killer:
with the production switch set (inbound on, outbound off) the
generated v2ray_api stats block omitted the outbounds list, and
sing-box then registers NO counters at all — QueryStats answered
zero entries while real traffic flowed (A/B against the real core:
adding the list produced full user/inbound/outbound counters).

- builder: whenever the stats block is generated, emit inbounds,
  users and outbounds together; the gates now only decide whether
  the block exists
- master: enforce the inbound category gate at recording time in
  the agent traffic report (the whole-network accounting switch
  semantics: off period is never counted); onlines stay ungated
- golden gate test updated to the corrected contract
2026-10-23 01:57:52 +00:00

532 lines
18 KiB
Go

package service
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/singbox"
)
// TestGoldenGenerationFromModel is the B2 generation-side golden
// run: for every B0 fixture, the fragment the builder places into the
// assembled sing-box config (stored fragment re-projected against
// the identity layer) must deep-equal the B0 expected fragment, and
// fragment-less cases (blocked / external-runtime) must not appear
// in the config at all.
func TestGoldenGenerationFromModel(t *testing.T) {
root := filepath.Join("..", "..", "singbox", "testdata", "golden")
dirs, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read golden dir: %v", err)
}
ran := 0
for _, d := range dirs {
if !d.IsDir() {
continue
}
name := d.Name()
t.Run(name, func(t *testing.T) {
dir := filepath.Join(root, name)
row, convIn, fixtureClient := loadGenerationFixture(t, dir)
res, err := singbox.ConvertInbound(convIn)
if err != nil {
t.Fatalf("ConvertInbound: %v", err)
}
exp := loadExpectedSingbox(t, filepath.Join(dir, "expected-singbox.json"))
var wantFrag any
key := "inbounds"
if res.Endpoint {
key = "endpoints"
}
if arr, ok := exp[key].([]any); ok && len(arr) > 0 {
wantFrag = arr[0]
}
var items []*singboxInboundItem
if res.Fragment != nil && row.Protocol != model.MTProto && row.Protocol != model.AmneziaWG {
fragRaw, err := res.MarshalFragment()
if err != nil {
t.Fatalf("MarshalFragment: %v", err)
}
fragment := map[string]any{}
if err := json.Unmarshal([]byte(fragRaw), &fragment); err != nil {
t.Fatalf("unmarshal fragment: %v", err)
}
presRaw, err := res.MarshalPreserved()
if err != nil {
t.Fatalf("MarshalPreserved: %v", err)
}
preserved := singbox.Preserved{}
if presRaw != "" {
if err := json.Unmarshal([]byte(presRaw), &preserved); err != nil {
t.Fatalf("unmarshal preserved: %v", err)
}
}
row.SingboxPreserved = presRaw
refreshFragmentUsers(row, fragment, fixtureClient, fixtureClient)
items = append(items, &singboxInboundItem{row: row, fragment: fragment, preserved: preserved})
}
cfg := assembleSingboxConfig(assembleSingboxOptions{
items: items,
switches: trafficStatsSwitches{InboundUplink: true, InboundDownlink: true},
})
var gotFrag any
for _, k := range []string{"inbounds", "endpoints"} {
if arr, ok := cfg[k].([]any); ok {
for _, e := range arr {
if em, ok := e.(map[string]any); ok && em["tag"] == resFragmentTag(res) {
gotFrag = em
}
}
}
}
if !reflect.DeepEqual(normalizeJSONValue(gotFrag), normalizeJSONValue(wantFrag)) {
t.Errorf("generated fragment mismatch:\n got: %s\nwant: %s",
marshalIndentValue(gotFrag), marshalIndentValue(wantFrag))
}
})
ran++
}
if ran != 26 {
t.Fatalf("golden cases ran = %d, want 26", ran)
}
}
func resFragmentTag(res singbox.Result) string {
if res.Fragment == nil {
return ""
}
tag, _ := res.Fragment["tag"].(string)
return tag
}
func normalizeJSONValue(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return v
}
var out any
if err := json.Unmarshal(b, &out); err != nil {
return v
}
return out
}
func marshalIndentValue(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func loadExpectedSingbox(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read expected: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse expected: %v", err)
}
return m
}
// loadGenerationFixture builds the inbound row, the converter input
// and the identity client list for one B0 fixture. The assembly
// mirrors the converter's own golden fixture loader (the
// settings.clients mirror stands in for the identity layer).
func loadGenerationFixture(t *testing.T, dir string) (*model.Inbound, singbox.Inbound, []model.Client) {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "input.json"))
if err != nil {
t.Fatalf("read input: %v", err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatalf("parse input: %v", err)
}
ibRaw, err := json.Marshal(root["inbound"])
if err != nil {
t.Fatalf("marshal inbound: %v", err)
}
var row model.Inbound
if err := json.Unmarshal(ibRaw, &row); err != nil {
t.Fatalf("unmarshal inbound: %v", err)
}
ib, _ := root["inbound"].(map[string]any)
obj := func(m map[string]any, key string) map[string]any {
if o, ok := m[key].(map[string]any); ok {
return o
}
return nil
}
str := func(m map[string]any, key string) string {
if s, ok := m[key].(string); ok {
return s
}
return ""
}
num := func(m map[string]any, key string) int {
if f, ok := m[key].(float64); ok {
return int(f)
}
return 0
}
arr := func(m map[string]any, key string) []any {
if a, ok := m[key].([]any); ok {
return a
}
return nil
}
raw := func(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal raw: %v", err)
}
return b
}
settings := obj(ib, "settings")
in := singbox.Inbound{
Listen: str(ib, "listen"),
Port: num(ib, "port"),
Protocol: str(ib, "protocol"),
Settings: raw(ib["settings"]),
StreamSettings: raw(ib["streamSettings"]),
Sniffing: raw(ib["sniffing"]),
}
for _, e := range arr(settings, "fallbacks") {
in.Fallbacks = append(in.Fallbacks, raw(e))
}
for _, e := range arr(settings, "clients") {
cm, _ := e.(map[string]any)
if cm == nil {
continue
}
cl := singbox.Client{
Email: str(cm, "email"),
UUID: str(cm, "id"),
Password: str(cm, "password"),
Auth: str(cm, "auth"),
Flow: str(cm, "flow"),
Security: str(cm, "security"),
AlterID: num(cm, "alterId"),
PublicKey: str(cm, "publicKey"),
PreSharedKey: str(cm, "preSharedKey"),
KeepAlive: num(cm, "keepAlive"),
Secret: str(cm, "secret"),
AdTag: str(cm, "adTag"),
}
for _, ip := range arr(cm, "allowedIPs") {
if s, ok := ip.(string); ok {
cl.AllowedIPs = append(cl.AllowedIPs, s)
}
}
if rev, ok := cm["reverse"]; ok && rev != nil {
cl.Reverse = raw(rev)
}
in.Clients = append(in.Clients, cl)
}
// The identity list is the settings.clients mirror (B0 built the
// fixtures from a state where mirror == identity; the top-level
// "client" object is just the link-test client and may be only
// one of several).
var clients []model.Client
for _, e := range arr(settings, "clients") {
rawClient, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal client: %v", err)
}
var mc model.Client
if err := json.Unmarshal(rawClient, &mc); err != nil {
t.Fatalf("unmarshal client: %v", err)
}
clients = append(clients, mc)
}
return &row, in, clients
}
// TestSingboxStatsGates pins the trafficStatsSwitches → v2ray_api
// stats wiring: the block is generated when at least one category
// gate is on (both direction switches), and all three name lists
// are always present together — sing-box registers no counters at
// all unless inbounds, users and outbounds are all listed (real-
// core verified in the ITEM6 B5 rehearsal). The per-category
// counting policy is enforced at recording time on the master,
// not by omission here. With all four switches off there is no
// experimental block at all.
func TestSingboxStatsGates(t *testing.T) {
mkItems := func() []*singboxInboundItem {
return []*singboxInboundItem{
{row: &model.Inbound{Protocol: model.VLESS}, fragment: map[string]any{
"type": "vless", "tag": "inbound-443",
"users": []any{map[string]any{"name": "a@example.com", "uuid": "u1"}},
}},
{row: &model.Inbound{Protocol: model.Mixed}, fragment: map[string]any{
"type": "mixed", "tag": "inbound-1080",
"users": []any{map[string]any{"username": "b@example.com", "password": "p"}},
}},
}
}
statsOf := func(cfg map[string]any) map[string]any {
exp, _ := cfg["experimental"].(map[string]any)
if exp == nil {
return nil
}
api, _ := exp["v2ray_api"].(map[string]any)
stats, _ := api["stats"].(map[string]any)
return stats
}
cfg := assembleSingboxConfig(assembleSingboxOptions{items: mkItems(),
switches: trafficStatsSwitches{InboundUplink: true, InboundDownlink: true}})
stats := statsOf(cfg)
if stats == nil {
t.Fatalf("inbound-on: no stats block")
}
if got := stats["inbounds"]; !reflect.DeepEqual(got, []string{"inbound-443", "inbound-1080"}) {
// stats lists are []string only when non-empty interface slices are built;
// compare via JSON for shape stability.
if !reflect.DeepEqual(normalizeJSONValue(got), normalizeJSONValue([]string{"inbound-443", "inbound-1080"})) {
t.Errorf("inbounds list = %v", got)
}
}
if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbounds list must always accompany the stats block (sing-box registers no counters without it)")
}
usersJSON := marshalIndentValue(stats["users"])
if !strings.Contains(usersJSON, "a@example.com") || !strings.Contains(usersJSON, "b@example.com") {
t.Errorf("users list missing attribution keys: %s", usersJSON)
}
cfg = assembleSingboxConfig(assembleSingboxOptions{items: mkItems(),
switches: trafficStatsSwitches{InboundUplink: true, OutboundUplink: true, OutboundDownlink: true}})
stats = statsOf(cfg)
if stats == nil {
t.Fatalf("outbound-on: no stats block")
}
if _, ok := stats["inbounds"]; !ok {
t.Errorf("inbound half-off: inbounds list must still accompany the block (registration requirement; recording gate enforces the policy)")
}
if _, ok := stats["users"]; !ok {
t.Errorf("inbound half-off: users list must still accompany the block (registration requirement)")
}
if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbound-on: outbounds list missing")
}
cfg = assembleSingboxConfig(assembleSingboxOptions{items: mkItems(), switches: trafficStatsSwitches{}})
if _, ok := cfg["experimental"]; ok {
t.Errorf("all-off: experimental block generated anyway")
}
}
// TestSingboxForwardingPair pins the §5.5 forwarding compile shape.
func TestSingboxForwardingPair(t *testing.T) {
outbound, rule := singboxForwardingPair(7, "inbound-443", &resolvedLanding{
address: "10.0.0.9", port: 1080, protocol: "socks", username: "u", password: "p",
})
wantOut := map[string]any{"type": "socks", "tag": "fwd-7", "server": "10.0.0.9",
"server_port": 1080, "username": "u", "password": "p"}
if !reflect.DeepEqual(normalizeJSONValue(outbound), normalizeJSONValue(wantOut)) {
t.Errorf("outbound = %s", marshalIndentValue(outbound))
}
wantRule := map[string]any{"inbound": []string{"inbound-443"}, "action": "route", "outbound": "fwd-7"}
if !reflect.DeepEqual(normalizeJSONValue(rule), normalizeJSONValue(wantRule)) {
t.Errorf("rule = %s", marshalIndentValue(rule))
}
outbound, _ = singboxForwardingPair(8, "inbound-8443", &resolvedLanding{
address: "example.com", port: 8080, protocol: "http",
})
if _, ok := outbound["username"]; ok {
t.Errorf("noauth landing gained a username: %v", outbound)
}
}
// TestSingboxAssemblyOrder pins the route rule order (forwarding,
// agent rules, private reject), the final outbound, the egress
// override replacement and forwarding outbound dedup.
func TestSingboxAssemblyOrder(t *testing.T) {
fwdOut, fwdRule := singboxForwardingPair(3, "inbound-443", &resolvedLanding{
address: "10.0.0.1", port: 1080, protocol: "socks",
})
cfg := assembleSingboxConfig(assembleSingboxOptions{
items: []*singboxInboundItem{
{row: &model.Inbound{Protocol: model.VLESS}, fragment: map[string]any{"type": "vless", "tag": "inbound-443"}},
},
switches: trafficStatsSwitches{},
outboundsJSON: `[{"type":"socks","tag":"proxy","server":"1.2.3.4","server_port":1080}]`,
routingJSON: `[{"outbound":"proxy","domain_suffix":["example.com"]}]`,
fwdOutbounds: []any{fwdOut},
fwdRules: []any{fwdRule},
})
route, _ := cfg["route"].(map[string]any)
if route["final"] != "direct" {
t.Errorf("final = %v", route["final"])
}
rules, _ := route["rules"].([]any)
if len(rules) != 3 {
t.Fatalf("rules len = %d, want 3 (fwd, agent, reject)", len(rules))
}
if r0, _ := rules[0].(map[string]any); r0["outbound"] != "fwd-3" {
t.Errorf("rules[0] = %v, want forwarding rule first", rules[0])
}
if r1, _ := rules[1].(map[string]any); r1["outbound"] != "proxy" {
t.Errorf("rules[1] = %v, want agent rule second", rules[1])
}
if r2, _ := rules[2].(map[string]any); r2["action"] != "reject" {
t.Errorf("rules[2] = %v, want private reject last", rules[2])
}
outbounds, _ := cfg["outbounds"].([]any)
if len(outbounds) != 2 {
t.Fatalf("outbounds len = %d, want 2 (override replaced defaults, fwd appended)", len(outbounds))
}
if o0, _ := outbounds[0].(map[string]any); o0["tag"] != "proxy" {
t.Errorf("outbounds[0] = %v, want override entry", outbounds[0])
}
if o1, _ := outbounds[1].(map[string]any); o1["tag"] != "fwd-3" {
t.Errorf("outbounds[1] = %v, want forwarding outbound", outbounds[1])
}
}
// TestRefreshSocksUsersViaFragment pins the mixed/socks refresh: a
// stored fixed account survives, a stored entry for a now-detached
// client drops, a disabled client's entry drops (it is attached but
// not in the enabled list), and a fresh client is appended.
func TestRefreshSocksUsersViaFragment(t *testing.T) {
row := &model.Inbound{
Protocol: model.Mixed,
SingboxPreserved: `{"panel":{"socksAuth":"password","socksAccounts":[{"username":"fixed","password":"fx"}]}}`,
}
fragment := map[string]any{
"type": "mixed", "tag": "inbound-1080",
"users": []any{
map[string]any{"username": "fixed", "password": "fx"},
map[string]any{"username": "gone@example.com", "password": "old"},
map[string]any{"username": "off@example.com", "password": "old2"},
map[string]any{"username": "on@example.com", "password": "stale"},
},
}
// gone@example.com exists only in the stored fragment (detached
// since migration); off@ is attached but disabled; on@ is active.
attached := []model.Client{
{Email: "off@example.com", Password: "x", Enable: false},
{Email: "on@example.com", Password: "fresh", Enable: true},
}
enabled := filterEnabledClients(&model.Inbound{}, attached)
refreshFragmentUsers(row, fragment, attached, enabled)
users, _ := fragment["users"].([]any)
got := marshalIndentValue(users)
for _, want := range []string{`"fixed"`, `"on@example.com"`, `"fresh"`} {
if !strings.Contains(got, want) {
t.Errorf("users missing %s:\n%s", want, got)
}
}
for _, unwanted := range []string{"gone@example.com", "off@example.com", "stale", "old"} {
if strings.Contains(got, unwanted) {
t.Errorf("users should not contain %s:\n%s", unwanted, got)
}
}
// noauth: identity clients must not be projected at all.
rowNoauth := &model.Inbound{
Protocol: model.Mixed,
SingboxPreserved: `{"panel":{"socksAuth":"noauth"}}`,
}
fragNoauth := map[string]any{"type": "mixed", "tag": "inbound-1081"}
refreshFragmentUsers(rowNoauth, fragNoauth, attached, enabled)
if _, ok := fragNoauth["users"]; ok {
t.Errorf("noauth inbound gained users: %v", fragNoauth["users"])
}
// Fallback for rows preserved before the socksAccounts record:
// stored entries owned by attached clients are refreshed, the
// rest are kept as presumed fixed accounts (the documented
// provenance heuristic).
rowLegacy := &model.Inbound{
Protocol: model.Mixed,
SingboxPreserved: `{"panel":{"socksAuth":"password"}}`,
}
fragLegacy := map[string]any{
"type": "mixed", "tag": "inbound-1082",
"users": []any{
map[string]any{"username": "fixed", "password": "fx"},
map[string]any{"username": "on@example.com", "password": "stale"},
},
}
refreshFragmentUsers(rowLegacy, fragLegacy, attached, enabled)
legacyUsers := marshalIndentValue(fragLegacy["users"])
if !strings.Contains(legacyUsers, `"fixed"`) || !strings.Contains(legacyUsers, `"fresh"`) {
t.Errorf("legacy fallback refresh wrong:\n%s", legacyUsers)
}
if strings.Contains(legacyUsers, "stale") {
t.Errorf("legacy fallback kept a stale client password:\n%s", legacyUsers)
}
}
// TestPreserveVMessAlterIDs pins the alter_id carry-over: the
// identity layer has no alterId column, so a non-zero stored value
// wins over the fresh projection's zero.
func TestPreserveVMessAlterIDs(t *testing.T) {
fragment := map[string]any{"users": []any{
map[string]any{"name": "a@example.com", "uuid": "u1", "alter_id": float64(4)},
map[string]any{"name": "b@example.com", "uuid": "u2", "alter_id": float64(0)},
}}
row := &model.Inbound{Protocol: model.VMESS}
refreshFragmentUsers(row, fragment, nil, []model.Client{
{Email: "a@example.com", ID: "u1"},
{Email: "b@example.com", ID: "u2"},
})
users, _ := fragment["users"].([]any)
if len(users) != 2 {
t.Fatalf("users = %v", users)
}
u0, _ := users[0].(map[string]any)
if u0["alter_id"] != 4 {
t.Errorf("alter_id = %v, want stored 4", u0["alter_id"])
}
}
// TestAssembleTunDNS pins the B2 tun dns landing: preserved
// settings.dns entries become config-level udp dns servers.
func TestAssembleTunDNS(t *testing.T) {
var preserved singbox.Preserved
raw := `{"unmapped":[{"concept":"settings.dns","input":["1.1.1.1","8.8.8.8"],"reason":"x","pending":"B2"}]}`
if err := json.Unmarshal([]byte(raw), &preserved); err != nil {
t.Fatalf("unmarshal preserved: %v", err)
}
cfg := assembleSingboxConfig(assembleSingboxOptions{
items: []*singboxInboundItem{
{row: &model.Inbound{Protocol: model.Protocol("tun")},
fragment: map[string]any{"type": "tun", "tag": "inbound-0"},
preserved: preserved},
},
switches: trafficStatsSwitches{},
})
dns, _ := cfg["dns"].(map[string]any)
servers, _ := dns["servers"].([]any)
if len(servers) != 2 {
t.Fatalf("dns servers = %v", dns)
}
s0, _ := servers[0].(map[string]any)
if s0["type"] != "udp" || s0["server"] != "1.1.1.1" {
t.Errorf("server[0] = %v", s0)
}
}