mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
d01d9867e4
* 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>
257 lines
9.1 KiB
Go
257 lines
9.1 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
|
for _, raw := range svc.defaultOutbounds {
|
|
var outbound map[string]any
|
|
if err := json.Unmarshal(raw, &outbound); err != nil {
|
|
continue
|
|
}
|
|
if outbound["tag"] == "direct_out" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("failed to unmarshal outbound: %v", err)
|
|
}
|
|
settings, _ := parsed["settings"].(map[string]any)
|
|
if settings == nil {
|
|
t.Fatal("outbound has no settings")
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
|
svc := NewSubJsonService("", "", finalMask, nil)
|
|
|
|
if hasDirectOutOutbound(svc) {
|
|
t.Fatal("direct_out outbound must never be emitted")
|
|
}
|
|
|
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
|
if _, ok := stream["sockopt"]; ok {
|
|
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
|
}
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
if finalmask == nil {
|
|
t.Fatal("streamSettings is missing finalmask")
|
|
}
|
|
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 1 {
|
|
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
|
}
|
|
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
|
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
|
}
|
|
|
|
udp, _ := finalmask["udp"].([]any)
|
|
if len(udp) != 1 {
|
|
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
|
}
|
|
|
|
quic, _ := finalmask["quicParams"].(map[string]any)
|
|
if quic == nil || quic["congestion"] != "bbr" {
|
|
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
|
svc := NewSubJsonService("", "", finalMask, nil)
|
|
|
|
stream := svc.streamData(`{
|
|
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
|
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
|
}`)
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 2 {
|
|
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
|
}
|
|
a, _ := tcp[0].(map[string]any)
|
|
b, _ := tcp[1].(map[string]any)
|
|
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
|
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", nil)
|
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
|
if _, ok := stream["finalmask"]; ok {
|
|
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
|
}
|
|
if _, ok := stream["sockopt"]; ok {
|
|
t.Fatal("legacy direct_out sockopt must never be set")
|
|
}
|
|
}
|
|
|
|
// xray-core parses tlsSettings.pinnedPeerCertSha256 as a comma-separated string;
|
|
// the JSON subscription must emit that form, not an array, or v2ray clients fail
|
|
// to import the config (#5401).
|
|
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", nil)
|
|
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`)
|
|
|
|
tls, _ := stream["tlsSettings"].(map[string]any)
|
|
if tls == nil {
|
|
t.Fatalf("tlsSettings missing: %#v", stream)
|
|
}
|
|
if got := tls["pinnedPeerCertSha256"]; got != "aa11,bb22" {
|
|
t.Fatalf("pinnedPeerCertSha256 = %#v, want comma-separated string \"aa11,bb22\"", got)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
|
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, ""))
|
|
if _, ok := settings["vnext"]; ok {
|
|
t.Fatal("vless outbound must not use vnext")
|
|
}
|
|
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
|
|
t.Fatalf("flat vless settings wrong: %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
|
client := model.Client{ID: "uuid-2"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
|
|
if _, ok := settings["vnext"]; ok {
|
|
t.Fatal("vmess outbound must not use vnext")
|
|
}
|
|
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
|
|
t.Fatalf("flat vmess settings wrong: %#v", settings)
|
|
}
|
|
}
|
|
|
|
// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
|
|
// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
|
|
// works on very recent xray-core.
|
|
func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
|
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
|
client := model.Client{Password: "p4ss"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
|
|
server := firstServer(settings)
|
|
if server == nil {
|
|
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
|
|
}
|
|
if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
|
|
t.Fatalf("trojan server entry wrong: %#v", server)
|
|
}
|
|
if _, ok := server["method"]; ok {
|
|
t.Fatalf("trojan must not carry method: %#v", server)
|
|
}
|
|
|
|
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
|
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
|
|
ssServer := firstServer(ssSettings)
|
|
if ssServer == nil {
|
|
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
|
|
}
|
|
if ssServer["method"] != "aes-256-gcm" {
|
|
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)
|
|
}
|
|
}
|