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
532 lines
18 KiB
Go
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)
|
|
}
|
|
}
|