Files
3x-ui/internal/web/service/finalmask_rand_packet_test.go
T
Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it
The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
2026-07-28 14:43:55 +02:00

161 lines
5.5 KiB
Go

package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
t.Helper()
return map[string]any{
"network": "tcp",
"finalmask": map[string]any{
"udp": []any{map[string]any{
"type": "noise",
"settings": map[string]any{
"reset": "60",
"noise": []any{item},
},
}},
},
}
}
func noiseItem(t *testing.T, stream map[string]any) map[string]any {
t.Helper()
finalmask, _ := stream["finalmask"].(map[string]any)
udp, _ := finalmask["udp"].([]any)
mask, _ := udp[0].(map[string]any)
settings, _ := mask["settings"].(map[string]any)
noise, _ := settings["noise"].([]any)
item, _ := noise[0].(map[string]any)
return item
}
// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
// editor writing packet:[] alongside a rand. xray-core counts the empty array
// as a packet and refuses the config, which keeps every inbound offline.
func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
stream := streamWithNoiseItem(t, map[string]any{
"type": "array",
"rand": "1-8192",
"packet": []any{},
"delay": "5",
})
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
t.Fatalf("cleared = %d, want 1", cleared)
}
item := noiseItem(t, stream)
if _, present := item["packet"]; present {
t.Fatalf("packet survived: %#v", item)
}
if item["rand"] != "1-8192" || item["delay"] != "5" {
t.Fatalf("healing changed the mask: %#v", item)
}
}
func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
tests := []struct {
name string
item map[string]any
}{
{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stream := streamWithNoiseItem(t, tt.item)
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want the item left alone", cleared)
}
if _, present := noiseItem(t, stream)["packet"]; !present {
t.Fatal("packet was dropped")
}
})
}
}
// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
// sit two arrays deep and are subject to the same exclusive-kind rule.
func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
stream := map[string]any{
"finalmask": map[string]any{
"tcp": []any{map[string]any{
"type": "header-custom",
"settings": map[string]any{
"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
},
}},
},
}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
t.Fatalf("cleared = %d, want 2", cleared)
}
}
func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
stream := map[string]any{"network": "tcp"}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want 0", cleared)
}
}
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
// refuses outright must build once the panel has healed them.
func TestHealedConfigsBuildInXray(t *testing.T) {
t.Run("hysteria v1 row", func(t *testing.T) {
in := model.Inbound{
Protocol: model.Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
raw, err := json.Marshal(in.GenXrayInboundConfig())
if err != nil {
t.Fatalf("marshal generated inbound: %v", err)
}
var healed map[string]any
if err := json.Unmarshal(raw, &healed); err != nil {
t.Fatalf("decode generated inbound: %v", err)
}
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
var unhealed map[string]any
if err := json.Unmarshal([]byte(`{
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
t.Fatalf("decode raw inbound: %v", err)
}
if err := buildGoldenInbound(t, unhealed); err == nil {
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
}
})
t.Run("noise item with an empty packet", func(t *testing.T) {
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
stream := streamWithNoiseItem(t, item)
inbound := map[string]any{
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
"streamSettings": stream,
}
if err := buildGoldenInbound(t, inbound); err == nil {
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
}
dropEmptyRandPackets(stream["finalmask"])
inbound["streamSettings"] = stream
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
})
}