mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user