fix(sub): preserve non-default scMinPostsIntervalMs and use per-inbound xmux in JSON subscriptions (#5393)

* 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 <w3struk@gmail.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
w3struk
2026-06-20 03:57:47 +05:00
committed by GitHub
parent da9ecf6f4d
commit d01d9867e4
8 changed files with 264 additions and 33 deletions
@@ -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;
}
@@ -40,7 +40,29 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
}))}
/>
</Form.Item>
{xhttpMode === 'packet-up' && (
{(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
<>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
label={t('pages.inbounds.form.maxUploadSize')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
label={t('pages.inbounds.form.maxBufferedUpload')}
>
<InputNumber />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
label={t('pages.xray.outboundForm.minUploadInterval')}
>
<Input placeholder="e.g. 50-150" />
</Form.Item>
</>
)}
{xhttpMode === 'stream-up' && (
<>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
@@ -49,21 +71,13 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
<InputNumber />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
label={t('pages.inbounds.form.maxUploadSize')}
name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
label={t('pages.inbounds.form.streamUpServer')}
>
<Input />
</Form.Item>
</>
)}
{xhttpMode === 'stream-up' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
label={t('pages.inbounds.form.streamUpServer')}
>
<Input />
</Form.Item>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
label={t('pages.inbounds.form.serverMaxHeaderBytes')}
@@ -212,14 +212,14 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
if (mode !== 'packet-up') return null;
if (mode !== 'packet-up' && mode !== 'auto') return null;
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.minUploadInterval')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
>
<Input placeholder="30" />
<Input placeholder="e.g. 50-150" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
@@ -51,9 +51,12 @@ export const XHttpStreamSettingsSchema = z.object({
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
// Outbound-only fields. Server (inbound) listener ignores these. The
// panel embeds them in share-link `extra` blobs so the same xhttp
// config can roundtrip on both sides.
// Client-side fields stored on inbound for subscription propagation.
// The server listener ignores them at runtime, but the panel embeds
// them in share-link `extra` blobs so the same xhttp config can
// round-trip on both sides.
// - scMinPostsIntervalMs: preserved when non-default (stripped at '' or '30')
// - uplinkChunkSize & noGRPCHeader: outbound-only; stripped from inbound wire
scMinPostsIntervalMs: z.string().default(''),
uplinkChunkSize: z.number().int().min(0).default(0),
noGRPCHeader: z.boolean().default(false),
@@ -53,6 +53,28 @@ describe('normalizeXhttpForWire stream-one', () => {
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<string, unknown>;
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<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
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<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMinPostsIntervalMs');
});
});
describe('freedom outbound sockopt wire payload', () => {
+23 -13
View File
@@ -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
+81
View File
@@ -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)
}
}
+3 -3
View File
@@ -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},