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