Forráskód Böngészése

fix(sub): deliver vision flow for VLESS+XHTTP+REALITY in share links and Clash (#5232)

The vlessenc fix (#5185) enabled flow on XHTTP only in the security=none
branch of genVlessLink, and the Clash builder still gated flow on
network==tcp. With XHTTP+REALITY+vlessenc the panel accepts and stores
the flow (inboundCanEnableTlsFlow passes), but subscriptions dropped it,
so clients received configs without xtls-rprx-vision.

Add vlessFlowAllowed mirroring inboundCanEnableTlsFlow — tcp with
tls/reality, or xhttp with vlessenc regardless of security layer — and
use it in both the vless:// link generator and the Clash proxy builder.
MHSanaei 19 órája
szülő
commit
3c68b039f6

+ 4 - 3
internal/sub/clash_service.go

@@ -208,11 +208,12 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
 	case model.VLESS:
 		proxy["type"] = "vless"
 		proxy["uuid"] = client.ID
-		if client.Flow != "" && network == "tcp" {
-			proxy["flow"] = client.Flow
-		}
 		var inboundSettings map[string]any
 		json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		streamSecurity, _ := stream["security"].(string)
+		if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
+			proxy["flow"] = client.Flow
+		}
 		if encryption, ok := inboundSettings["encryption"].(string); ok {
 			encryption = strings.TrimSpace(encryption)
 			if encryption != "" && encryption != "none" {

+ 57 - 0
internal/sub/clash_service_test.go

@@ -148,6 +148,63 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
 	}
 }
 
+func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	encryption := "mlkem768x25519plus.native.0rtt.client"
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.1",
+		Port:     443,
+		Protocol: model.VLESS,
+		Remark:   "pq-flow",
+		Settings: `{"encryption":"` + encryption + `"}`,
+	}
+	client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
+	stream := map[string]any{
+		"network": "xhttp",
+		"xhttpSettings": map[string]any{
+			"path": "/",
+			"mode": "auto",
+		},
+		"security": "reality",
+		"realitySettings": map[string]any{
+			"publicKey":  "pub",
+			"serverName": "example.com",
+			"shortId":    "abcd",
+		},
+	}
+
+	proxy := svc.buildProxy(inbound, client, stream, "")
+
+	if proxy["flow"] != "xtls-rprx-vision" {
+		t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
+	}
+}
+
+func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.1",
+		Port:     443,
+		Protocol: model.VLESS,
+		Remark:   "plain-flow",
+		Settings: `{"encryption":"none"}`,
+	}
+	client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
+	stream := map[string]any{
+		"network":  "tcp",
+		"security": "none",
+		"tcpSettings": map[string]any{
+			"header": map[string]any{"type": "none"},
+		},
+	}
+
+	proxy := svc.buildProxy(inbound, client, stream, "")
+
+	if _, ok := proxy["flow"]; ok {
+		t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
+	}
+}
+
 func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
 	svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
 	inbound := &model.Inbound{

+ 20 - 11
internal/sub/service.go

@@ -484,6 +484,23 @@ func vlessEncryptionEnabled(settings map[string]any) bool {
 	return false
 }
 
+// vlessFlowAllowed reports whether a client's XTLS Vision flow belongs in
+// generated links/configs. Mirrors inboundCanEnableTlsFlow in
+// internal/web/service: Vision runs on TCP with tls/reality (classic), and on
+// XHTTP whenever VLESS encryption (vlessenc / ML-KEM) is enabled — there the
+// VLESS-level encryption stands in for the transport TLS that Vision relies
+// on, regardless of the stream security layer (so XHTTP+REALITY+vlessenc
+// keeps its flow too).
+func vlessFlowAllowed(network, security string, settings map[string]any) bool {
+	switch network {
+	case "tcp":
+		return security == "tls" || security == "reality"
+	case "xhttp":
+		return vlessEncryptionEnabled(settings)
+	}
+	return false
+}
+
 func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	if inbound.Protocol != model.VLESS {
 		return ""
@@ -513,21 +530,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	switch security {
 	case "tls":
 		applyShareTLSParams(stream, params)
-		if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
-			params["flow"] = clients[clientIndex].Flow
-		}
 	case "reality":
 		applyShareRealityParams(stream, params)
-		if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
-			params["flow"] = clients[clientIndex].Flow
-		}
 	default:
 		params["security"] = "none"
-		// VLESS encryption (vlessenc / ML-KEM) carries XTLS Vision over XHTTP
-		// without transport TLS.
-		if streamNetwork == "xhttp" && len(clients[clientIndex].Flow) > 0 && vlessEncryptionEnabled(settings) {
-			params["flow"] = clients[clientIndex].Flow
-		}
+	}
+	if len(clients[clientIndex].Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
+		params["flow"] = clients[clientIndex].Flow
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)

+ 100 - 0
internal/sub/service_flow_test.go

@@ -0,0 +1,100 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Issue #5232: a vision flow set on a VLESS+XHTTP+REALITY (vlessenc) client
+// must survive into subscription output, not just the inbound JSON.
+
+const testMlkemEncryption = "mlkem768x25519plus.native.0rtt.dGVzdC1rZXk"
+
+func TestVlessFlowAllowed(t *testing.T) {
+	enc := map[string]any{"encryption": testMlkemEncryption}
+	noEnc := map[string]any{"encryption": "none"}
+
+	tests := []struct {
+		name     string
+		network  string
+		security string
+		settings map[string]any
+		want     bool
+	}{
+		{"tcp tls", "tcp", "tls", noEnc, true},
+		{"tcp reality", "tcp", "reality", noEnc, true},
+		{"tcp none", "tcp", "none", noEnc, false},
+		{"tcp none vlessenc", "tcp", "none", enc, false},
+		{"xhttp none vlessenc", "xhttp", "none", enc, true},
+		{"xhttp reality vlessenc (#5232)", "xhttp", "reality", enc, true},
+		{"xhttp tls vlessenc", "xhttp", "tls", enc, true},
+		{"xhttp reality no vlessenc", "xhttp", "reality", noEnc, false},
+		{"ws tls", "ws", "tls", noEnc, false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := vlessFlowAllowed(tc.network, tc.security, tc.settings); got != tc.want {
+				t.Fatalf("vlessFlowAllowed(%q, %q, %v) = %v, want %v", tc.network, tc.security, tc.settings, got, tc.want)
+			}
+		})
+	}
+}
+
+func flowTestInbound(streamSettings, encryption string) *model.Inbound {
+	return &model.Inbound{
+		Listen:   "203.0.113.1",
+		Port:     443,
+		Protocol: model.VLESS,
+		Remark:   "flowtest",
+		Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user","flow":"xtls-rprx-vision"}],` +
+			`"decryption":"` + encryption + `","encryption":"` + encryption + `"}`,
+		StreamSettings: streamSettings,
+	}
+}
+
+const xhttpRealityStream = `{
+	"network": "xhttp",
+	"security": "reality",
+	"xhttpSettings": {"path": "/", "mode": "auto"},
+	"realitySettings": {
+		"serverNames": ["example.com"],
+		"shortIds": ["abcd"],
+		"settings": {"publicKey": "pub", "fingerprint": "chrome"}
+	}
+}`
+
+func TestGenVlessLink_FlowXhttpRealityVlessenc(t *testing.T) {
+	s := &SubService{remarkModel: "-ieo"}
+	link := s.genVlessLink(flowTestInbound(xhttpRealityStream, testMlkemEncryption), "user")
+	if !strings.Contains(link, "flow=xtls-rprx-vision") {
+		t.Fatalf("xhttp+reality+vlessenc link must carry the vision flow (#5232), got %q", link)
+	}
+}
+
+func TestGenVlessLink_NoFlowXhttpRealityWithoutVlessenc(t *testing.T) {
+	s := &SubService{remarkModel: "-ieo"}
+	link := s.genVlessLink(flowTestInbound(xhttpRealityStream, "none"), "user")
+	if strings.Contains(link, "flow=") {
+		t.Fatalf("xhttp+reality without vlessenc must not carry a flow, got %q", link)
+	}
+}
+
+func TestGenVlessLink_FlowTcpRealityStillWorks(t *testing.T) {
+	stream := `{
+		"network": "tcp",
+		"security": "reality",
+		"tcpSettings": {"header": {"type": "none"}},
+		"realitySettings": {
+			"serverNames": ["example.com"],
+			"shortIds": ["abcd"],
+			"settings": {"publicKey": "pub", "fingerprint": "chrome"}
+		}
+	}`
+	s := &SubService{remarkModel: "-ieo"}
+	link := s.genVlessLink(flowTestInbound(stream, "none"), "user")
+	if !strings.Contains(link, "flow=xtls-rprx-vision") {
+		t.Fatalf("tcp+reality link must keep the vision flow, got %q", link)
+	}
+}