From d01d9867e4bb80529febc8109f5245f91f7e23ae Mon Sep 17 00:00:00 2001 From: w3struk Date: Sat, 20 Jun 2026 03:57:47 +0500 Subject: [PATCH] fix(sub): preserve non-default scMinPostsIntervalMs and use per-inbound xmux in JSON subscriptions (#5393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sub): preserve non-default scMinPostsIntervalMs in inbound wire payload The frontend wire normalizer unconditionally deleted scMinPostsIntervalMs from inbound configs before persisting to the database, so JSON subscriptions could never include it — even when the admin set a non-default value like "50-150". Only strip the xray-core default ("30") or empty values. The literal "30" is a known DPI fingerprint (#5141) and must still be removed, but custom tuning knobs must survive the round-trip so that buildXhttpExtra and the JSON subscription generator can propagate them to clients. Add tests for non-default preservation and empty-value stripping. * fix(sub): use per-inbound xmux instead of global subJsonMux in JSON subscriptions The JSON subscription generator always used the global subJsonMux panel setting for outbound.Mux, even when the inbound carried per-inbound xmux inside xhttpSettings. This meant XHTTP outbounds that configured their own multiplexing via xmux still got the legacy mux.cool block injected — and the inbound's own xmux was silently ignored. Now getConfig() checks whether xmux is present in the inbound's xhttpSettings. When it is, the per-inbound xmux handles multiplexing and the legacy outbound.Mux is suppressed. When xmux is absent, the global subJsonMux is used as before. The mux selection is threaded through genVless, genVnext, genServer, and genHy as an explicit parameter so each protocol handler can decide independently. Add tests: - xmux present → outbound.Mux suppressed, xmux survives streamData() - no xmux → global subJsonMux used as outbound.Mux * feat(ui): add scMinPostsIntervalMs to inbound XHTTP form The inbound XHTTP form was missing scMinPostsIntervalMs, making it impossible for admins to configure this client-only tuning knob through the panel. The field already existed in the Zod schema and outbound form, and the wire normalizer (PR #5393) now preserves non-default values for subscription propagation. Add Form.Item for scMinPostsIntervalMs in the packet-up section of the inbound XHTTP form, after scMaxEachPostBytes. Use the existing translation key and a placeholder that shows the range format without endorsing the DPI-fingerprinted default (30). Update the Zod schema comment to clarify that scMinPostsIntervalMs is now preserved on inbound for subscriptions, while uplinkChunkSize and noGRPCHeader remain outbound-only. Add two integration tests: - Non-default value (50-150) preserved through formValuesToWirePayload - Default value (30) stripped through the full pipeline * fix(ui): show packet-up fields for auto mode in inbound XHTTP form When mode is 'auto', the server accepts all three XHTTP modes including packet-up. The packet-up-specific fields (scMaxBufferedPosts, scMaxEachPostBytes, scMinPostsIntervalMs) are therefore relevant and should be configurable. Change the conditional from 'packet-up' only to 'packet-up || auto' so admins using the default 'auto' mode can configure these fields. * fix(outbound): show scMinPostsIntervalMs for auto mode, update placeholder - Show scMinPostsIntervalMs field when mode is 'auto' in addition to 'packet-up', since auto+TLS resolves to packet-up client-side - Change placeholder from '30' (DPI fingerprint) to 'e.g. 50-150' for consistency with inbound form * fix(inbound): show scMaxEachPostBytes for all modes, gate scMaxBufferedPosts behind packet-up/auto scMaxEachPostBytes is used by xray-core in every mode (both handlePacketUp and handleStreamUp validate it) and must be visible regardless of mode. scMaxBufferedPosts is only used by handlePacketUp, so it remains gated behind the packet-up/auto conditional. Also show scMinPostsIntervalMs for auto mode in outbound form and change placeholder from '30' (DPI fingerprint) to 'e.g. 50-150'. Update snapshot to reflect the new field order. * fix(inbound): correct XHTTP field visibility per xray-core source verification - scMaxEachPostBytes: move behind packet-up/auto gate (server only checks it in handlePacketUp, not handleStreamUp) - scMaxBufferedPosts: show for packet-up, stream-up, and auto (server uses uploadQueue in both handlePacketUp and handleStreamUp) - scStreamUpServerSecs: already correct (stream-up only) Verified against xray-core hub.go and dialer.go source code. --------- Co-authored-by: w3struk Co-authored-by: MHSanaei --- .../src/lib/xray/stream-wire-normalize.ts | 7 +- .../pages/inbounds/form/transport/xhttp.tsx | 36 ++++-- .../pages/xray/outbounds/transport/xhttp.tsx | 4 +- .../src/schemas/protocols/stream/xhttp.ts | 9 +- .../src/test/stream-wire-normalize.test.ts | 118 ++++++++++++++++++ internal/sub/json_service.go | 36 ++++-- internal/sub/json_service_test.go | 81 ++++++++++++ internal/sub/mutation_audit_test.go | 6 +- 8 files changed, 264 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index 446754c6e..b4548c5bc 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/frontend/src/lib/xray/stream-wire-normalize.ts @@ -150,7 +150,12 @@ export function normalizeXhttpForWire( if (side === 'inbound') { if (!enableXmux) delete out.xmux; - delete out.scMinPostsIntervalMs; + // scMinPostsIntervalMs is a client-only tuning knob that subscriptions + // must propagate to clients. Only strip the xray-core default ("30") + // or empty values — the literal "30" is a known DPI fingerprint (#5141). + if (out.scMinPostsIntervalMs === '' || out.scMinPostsIntervalMs === '30') { + delete out.scMinPostsIntervalMs; + } delete out.uplinkChunkSize; } diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx index c7251cb61..f5808469f 100644 --- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx +++ b/frontend/src/pages/inbounds/form/transport/xhttp.tsx @@ -40,7 +40,29 @@ export default function XhttpForm({ form }: { form: FormInstance - {xhttpMode === 'packet-up' && ( + {(xhttpMode === 'packet-up' || xhttpMode === 'auto') && ( + <> + + + + + + + + + + + )} + {xhttpMode === 'stream-up' && ( <> )} - {xhttpMode === 'stream-up' && ( - - - - )} - + { expect(out).not.toHaveProperty('headers'); }); + it('preserves non-default scMinPostsIntervalMs on inbound for subscriptions', () => { + const out = normalizeXhttpForWire({ + path: '/app', + mode: 'packet-up', + scMinPostsIntervalMs: '50-150', + enableXmux: false, + }, 'inbound'); + + expect(out.scMinPostsIntervalMs).toBe('50-150'); + }); + + it('strips empty scMinPostsIntervalMs on inbound', () => { + const out = normalizeXhttpForWire({ + path: '/app', + mode: 'packet-up', + scMinPostsIntervalMs: '', + enableXmux: false, + }, 'inbound'); + + expect(out).not.toHaveProperty('scMinPostsIntervalMs'); + }); + it('keeps xmux on outbound stream-one', () => { const out = normalizeXhttpForWire({ path: '/app', @@ -340,6 +362,102 @@ describe('inbound formValuesToWirePayload integration', () => { const settings = tls.settings as Record; expect(settings).not.toHaveProperty('fingerprint'); }); + + it('preserves non-default scMinPostsIntervalMs in packet-up inbound wire payload for subscriptions', () => { + const values = { + remark: 't', + enable: true, + port: 443, + listen: '0.0.0.0', + tag: 'in-443', + expiryTime: 0, + sniffing: { enabled: false }, + up: 0, + down: 0, + total: 0, + trafficReset: 'never', + lastTrafficResetTime: 0, + nodeId: null, + protocol: 'vless', + settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' }, + streamSettings: { + network: 'xhttp', + security: 'reality', + realitySettings: { + target: 'play.google.com:443', + privateKey: 'priv', + serverNames: ['play.google.com'], + shortIds: ['44003d86dc1e'], + settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' }, + }, + xhttpSettings: { + path: '/app', + host: 'play.google.com', + mode: 'packet-up', + scMinPostsIntervalMs: '50-150', + }, + sockopt: {}, + }, + }; + + const parsed = InboundFormSchema.safeParse(values); + expect(parsed.success).toBe(true); + if (!parsed.success) throw parsed.error; + + const payload = formValuesToWirePayload(parsed.data); + const stream = JSON.parse(payload.streamSettings) as Record; + const xhttp = stream.xhttpSettings as Record; + + expect(xhttp.scMinPostsIntervalMs).toBe('50-150'); + }); + + it('strips default scMinPostsIntervalMs=30 from inbound wire payload', () => { + const values = { + remark: 't', + enable: true, + port: 443, + listen: '0.0.0.0', + tag: 'in-443', + expiryTime: 0, + sniffing: { enabled: false }, + up: 0, + down: 0, + total: 0, + trafficReset: 'never', + lastTrafficResetTime: 0, + nodeId: null, + protocol: 'vless', + settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' }, + streamSettings: { + network: 'xhttp', + security: 'reality', + realitySettings: { + target: 'play.google.com:443', + privateKey: 'priv', + serverNames: ['play.google.com'], + shortIds: ['44003d86dc1e'], + settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' }, + }, + xhttpSettings: { + path: '/app', + host: 'play.google.com', + mode: 'packet-up', + scMinPostsIntervalMs: '30', + }, + sockopt: {}, + }, + }; + + const parsed = InboundFormSchema.safeParse(values); + expect(parsed.success).toBe(true); + if (!parsed.success) throw parsed.error; + + const payload = formValuesToWirePayload(parsed.data); + const stream = JSON.parse(payload.streamSettings) as Record; + const xhttp = stream.xhttpSettings as Record; + + expect(xhttp).not.toHaveProperty('scMinPostsIntervalMs'); + }); }); describe('freedom outbound sockopt wire payload', () => { diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 38c3b2dbc..72fb3d1f9 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -150,6 +150,16 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c defaultDest = host } + // Per-inbound xmux takes precedence over the global subJsonMux. + // When xmux is present inside xhttpSettings, XHTTP multiplexing + // is handled by xmux — don't also set the legacy outbound.Mux. + mux := s.mux + if xhttp, ok := stream["xhttpSettings"].(map[string]any); ok { + if _, hasXmux := xhttp["xmux"]; hasXmux { + mux = "" + } + } + externalProxies, ok := stream["externalProxy"].([]any) hasExternalProxy := ok && len(externalProxies) > 0 if !hasExternalProxy { @@ -197,13 +207,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c switch inbound.Protocol { case "vmess": - newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, hostMux)) + newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux))) case "vless": - newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, hostMux)) + newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, jsonMux(mux, hostMux))) case "trojan", "shadowsocks": - newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, hostMux)) + newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux))) case "hysteria": - newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client)) + newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) } newOutbounds = append(newOutbounds, s.defaultOutbounds...) @@ -340,12 +350,12 @@ func jsonMux(global, override string) string { return global } -func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage { +func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{} outbound.Protocol = string(inbound.Protocol) outbound.Tag = "proxy" - if mux := jsonMux(s.mux, muxOverride); mux != "" { + if mux != "" { outbound.Mux = json_util.RawMessage(mux) } outbound.StreamSettings = streamSettings @@ -366,11 +376,11 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut return result } -func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage { +func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{} outbound.Protocol = string(inbound.Protocol) outbound.Tag = "proxy" - if mux := jsonMux(s.mux, muxOverride); mux != "" { + if mux != "" { outbound.Mux = json_util.RawMessage(mux) } outbound.StreamSettings = streamSettings @@ -395,7 +405,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut return result } -func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage { +func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{} serverData := make([]ServerSetting, 1) @@ -422,7 +432,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u outbound.Protocol = string(inbound.Protocol) outbound.Tag = "proxy" - if mux := jsonMux(s.mux, muxOverride); mux != "" { + if mux != "" { outbound.Mux = json_util.RawMessage(mux) } outbound.StreamSettings = streamSettings @@ -448,14 +458,14 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u return result } -func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client) json_util.RawMessage { +func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{} outbound.Protocol = string(inbound.Protocol) outbound.Tag = "proxy" - if s.mux != "" { - outbound.Mux = json_util.RawMessage(s.mux) + if mux != "" { + outbound.Mux = json_util.RawMessage(mux) } var settings, stream map[string]any diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index 36b86bf4c..a2c1e66c2 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -173,3 +173,84 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) { t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer) } } + +func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) { + globalMux := `{"enabled":true,"concurrency":8}` + svc := NewSubJsonService(globalMux, "", "", nil) + + // When xmux is present in xhttpSettings, the per-inbound xmux handles + // multiplexing and the legacy outbound.Mux must NOT be set. + stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}` + parsed := svc.streamData(stream) + + mux := globalMux + if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok { + if _, hasXmux := xhttp["xmux"]; hasXmux { + mux = "" + } + } + + streamSettings, _ := json.Marshal(parsed) + inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} + client := model.Client{ID: "uuid-1"} + + raw := svc.genVless(inbound, streamSettings, client, mux) + var ob map[string]any + if err := json.Unmarshal(raw, &ob); err != nil { + t.Fatalf("unmarshal outbound: %v", err) + } + if _, has := ob["mux"]; has { + t.Fatal("outbound.Mux must NOT be set when per-inbound xmux is present") + } + + // Verify xmux is still inside xhttpSettings in streamSettings. + ss, _ := ob["streamSettings"].(map[string]any) + if ss == nil { + t.Fatal("streamSettings missing from outbound") + } + xhttp, _ := ss["xhttpSettings"].(map[string]any) + if xhttp == nil { + t.Fatal("xhttpSettings missing from streamSettings") + } + xmux, _ := xhttp["xmux"].(map[string]any) + if xmux == nil { + t.Fatal("xmux missing from xhttpSettings — per-inbound xmux must survive streamData()") + } + if xmux["maxConcurrency"] != "16-32" { + t.Fatalf("xmux.maxConcurrency = %v, want 16-32", xmux["maxConcurrency"]) + } +} + +func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) { + globalMux := `{"enabled":true,"concurrency":8}` + svc := NewSubJsonService(globalMux, "", "", nil) + + // When no xmux is present, the global subJsonMux should be used. + stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}` + parsed := svc.streamData(stream) + + mux := globalMux + if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok { + if _, hasXmux := xhttp["xmux"]; hasXmux { + mux = "" + } + } + + streamSettings, _ := json.Marshal(parsed) + inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} + client := model.Client{ID: "uuid-1"} + + raw := svc.genVless(inbound, streamSettings, client, mux) + var ob map[string]any + if err := json.Unmarshal(raw, &ob); err != nil { + t.Fatalf("unmarshal outbound: %v", err) + } + m, has := ob["mux"] + if !has { + t.Fatal("outbound.Mux must be set when global subJsonMux is configured and no per-inbound xmux") + } + mm, _ := m.(map[string]any) + if mm["enabled"] != true || mm["concurrency"] != float64(8) { + t.Fatalf("mux payload wrong: %#v", m) + } +} diff --git a/internal/sub/mutation_audit_test.go b/internal/sub/mutation_audit_test.go index 1dcca428c..1c27abb93 100644 --- a/internal/sub/mutation_audit_test.go +++ b/internal/sub/mutation_audit_test.go @@ -66,9 +66,9 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) { wantMux bool protocol model.Protocol }{ - {"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), true, model.VMESS}, - {"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), true, model.VLESS}, - {"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), true, model.Trojan}, + {"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS}, + {"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS}, + {"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan}, {"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS}, {"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS}, {"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},