mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 12:10:56 +00:00
Inbound XMUX and other client-side xHTTP knobs were written into bin/config.json even though xray-core's server listener ignores them. Strip them in GenXrayInboundConfig while leaving the DB row intact so buildXhttpExtra still pushes defaults to clients via share links.
272 lines
8.3 KiB
Go
272 lines
8.3 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
|
|
in := Inbound{
|
|
Id: 7,
|
|
Protocol: VLESS,
|
|
Port: 443,
|
|
Settings: `{"clients":[],"decryption":"none"}`,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
Sniffing: `{"enabled":true}`,
|
|
}
|
|
out, err := json.Marshal(in)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
|
if _, ok := parsed[field].(map[string]any); !ok {
|
|
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
|
|
}
|
|
}
|
|
if strings.Contains(string(out), `"settings":"`) {
|
|
t.Errorf("settings should not be emitted as a JSON string: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
|
|
in := Inbound{Id: 1, Protocol: VLESS}
|
|
out, err := json.Marshal(in)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
|
if parsed[field] != nil {
|
|
t.Errorf("expected %s to be null, got %v", field, parsed[field])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{
|
|
name: "nested objects (modern)",
|
|
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
|
|
},
|
|
{
|
|
name: "JSON-encoded strings (legacy)",
|
|
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var in Inbound
|
|
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
|
|
t.Fatalf("Unmarshal failed: %v", err)
|
|
}
|
|
if !strings.Contains(in.Settings, `"decryption":"none"`) {
|
|
t.Errorf("Settings not normalised: %q", in.Settings)
|
|
}
|
|
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
|
|
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
|
|
}
|
|
if !strings.Contains(in.Sniffing, `"enabled":true`) {
|
|
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
|
|
in := Inbound{Id: 1, Settings: "not json at all"}
|
|
out, err := json.Marshal(in)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
if !strings.Contains(string(out), `"settings":"not json at all"`) {
|
|
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
|
|
}
|
|
}
|
|
|
|
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
|
|
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
|
|
out, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
obj, ok := parsed["reverse"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
|
|
}
|
|
if obj["tag"] != "vless-in" {
|
|
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
|
|
}
|
|
}
|
|
|
|
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
|
|
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
|
|
out, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
if parsed["reverse"] != nil {
|
|
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
|
|
}
|
|
}
|
|
|
|
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
|
|
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var rec ClientRecord
|
|
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
|
|
t.Fatalf("Unmarshal failed: %v", err)
|
|
}
|
|
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
|
|
t.Errorf("Reverse not normalised: %q", rec.Reverse)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
|
|
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
|
out, err := json.Marshal(row)
|
|
if err != nil {
|
|
t.Fatalf("Marshal failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
arr, ok := parsed["ips"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
|
|
}
|
|
if len(arr) != 1 {
|
|
t.Errorf("expected 1 entry, got %d", len(arr))
|
|
}
|
|
}
|
|
|
|
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
|
|
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var row InboundClientIps
|
|
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
|
|
t.Fatalf("Unmarshal failed: %v", err)
|
|
}
|
|
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
|
|
t.Errorf("Ips not normalised: %q", row.Ips)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripInboundXhttpClientFields_RemovesClientOnlyKnobs(t *testing.T) {
|
|
stream := `{
|
|
"network": "xhttp",
|
|
"security": "reality",
|
|
"xhttpSettings": {
|
|
"path": "/app",
|
|
"host": "example.com",
|
|
"mode": "stream-one",
|
|
"xmux": { "maxConcurrency": "16-32" },
|
|
"downloadSettings": { "network": "xhttp" },
|
|
"scMinPostsIntervalMs": "20-40",
|
|
"uplinkChunkSize": 4096,
|
|
"noGRPCHeader": true
|
|
}
|
|
}`
|
|
out, changed := StripInboundXhttpClientFields(stream)
|
|
if !changed {
|
|
t.Fatal("expected client-only xhttp fields to be stripped")
|
|
}
|
|
if strings.Contains(out, `"xmux"`) {
|
|
t.Fatalf("xmux should be removed from xray config stream: %s", out)
|
|
}
|
|
for _, key := range []string{"downloadSettings", "scMinPostsIntervalMs", "uplinkChunkSize", "noGRPCHeader"} {
|
|
if strings.Contains(out, `"`+key+`"`) {
|
|
t.Fatalf("%s should be removed from xray config stream: %s", key, out)
|
|
}
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("invalid JSON: %v", err)
|
|
}
|
|
xhttp := parsed["xhttpSettings"].(map[string]any)
|
|
if xhttp["path"] != "/app" || xhttp["host"] != "example.com" {
|
|
t.Fatalf("server fields must survive: %#v", xhttp)
|
|
}
|
|
}
|
|
|
|
func TestStripInboundXhttpClientFields_UnchangedWithoutClientFields(t *testing.T) {
|
|
stream := `{"network":"xhttp","xhttpSettings":{"path":"/app","mode":"stream-one"}}`
|
|
out, changed := StripInboundXhttpClientFields(stream)
|
|
if changed {
|
|
t.Fatalf("expected no change, got: %s", out)
|
|
}
|
|
if out != stream {
|
|
t.Fatalf("unchanged stream must be returned verbatim")
|
|
}
|
|
}
|
|
|
|
func TestStripInboundXhttpClientFields_NonXhttpPassthrough(t *testing.T) {
|
|
stream := `{"network":"ws","wsSettings":{"path":"/"}}`
|
|
out, changed := StripInboundXhttpClientFields(stream)
|
|
if changed || out != stream {
|
|
t.Fatalf("non-xhttp stream must pass through unchanged, got changed=%v out=%s", changed, out)
|
|
}
|
|
}
|
|
|
|
func TestGenXrayInboundConfig_OmitsInboundXmuxButDbRowUnchanged(t *testing.T) {
|
|
stream := `{
|
|
"network": "xhttp",
|
|
"xhttpSettings": {
|
|
"path": "/app",
|
|
"mode": "stream-one",
|
|
"xmux": { "maxConcurrency": "16-32", "hMaxRequestTimes": "600-900" }
|
|
}
|
|
}`
|
|
in := Inbound{
|
|
Protocol: VLESS,
|
|
Port: 443,
|
|
Listen: "0.0.0.0",
|
|
Tag: "in-xhttp",
|
|
Settings: `{"clients":[],"decryption":"none"}`,
|
|
StreamSettings: stream,
|
|
}
|
|
cfg := in.GenXrayInboundConfig()
|
|
if strings.Contains(string(cfg.StreamSettings), `"xmux"`) {
|
|
t.Fatalf("GenXrayInboundConfig must not emit xmux: %s", cfg.StreamSettings)
|
|
}
|
|
if strings.Contains(in.StreamSettings, `"xmux"`) == false {
|
|
t.Fatal("inbound row streamSettings must still carry xmux for subscriptions")
|
|
}
|
|
}
|