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},