diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index ee6cb6c18..26c56f42d 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/frontend/src/test/stream-wire-normalize.test.ts @@ -66,7 +66,7 @@ describe('normalizeXhttpForWire stream-one', () => { expect(out).not.toHaveProperty('scMaxEachPostBytes'); }); - it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => { + it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => { const out = normalizeXhttpForWire({ path: '/app', mode: 'auto', diff --git a/internal/database/model/model.go b/internal/database/model/model.go index c36b3ce9b..8ace15beb 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -225,6 +225,49 @@ func jsonStringFieldFromRaw(r json.RawMessage) string { return string(trimmed) } +// StripInboundXhttpClientFields removes xHTTP knobs that belong on the +// client dialer and subscription share-link extras only. xray-core's XHTTP +// inbound listener does not consume them; the panel still stores them on +// the inbound row so buildXhttpExtra can push defaults to clients. +func StripInboundXhttpClientFields(streamSettings string) (string, bool) { + if streamSettings == "" { + return streamSettings, false + } + var stream map[string]any + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return streamSettings, false + } + if stream["network"] != "xhttp" { + return streamSettings, false + } + xhttp, ok := stream["xhttpSettings"].(map[string]any) + if !ok || len(xhttp) == 0 { + return streamSettings, false + } + clientOnly := []string{ + "xmux", + "downloadSettings", + "scMinPostsIntervalMs", + "uplinkChunkSize", + "noGRPCHeader", + } + changed := false + for _, key := range clientOnly { + if _, has := xhttp[key]; has { + delete(xhttp, key) + changed = true + } + } + if !changed { + return streamSettings, false + } + out, err := json.MarshalIndent(stream, "", " ") + if err != nil { + return streamSettings, false + } + return string(out), true +} + // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { listen := i.Listen @@ -248,12 +291,16 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { settings = stripped } } + streamSettings := i.StreamSettings + if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok { + streamSettings = stripped + } return &xray.InboundConfig{ Listen: json_util.RawMessage(listen), Port: i.Port, Protocol: protocol, Settings: json_util.RawMessage(settings), - StreamSettings: json_util.RawMessage(i.StreamSettings), + StreamSettings: json_util.RawMessage(streamSettings), Tag: i.Tag, Sniffing: json_util.RawMessage(i.Sniffing), } diff --git a/internal/database/model/model_test.go b/internal/database/model/model_test.go index abdaf3c68..516e17adc 100644 --- a/internal/database/model/model_test.go +++ b/internal/database/model/model_test.go @@ -188,3 +188,85 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) { }) } } + +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") + } +}