diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 399593094..dbc2e12e7 100644 --- a/internal/sub/clash_service.go +++ b/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" { diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 78590e15b..070cfaaf5 100644 --- a/internal/sub/clash_service_test.go +++ b/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{ diff --git a/internal/sub/service.go b/internal/sub/service.go index ff94bbc13..916a52efe 100644 --- a/internal/sub/service.go +++ b/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) diff --git a/internal/sub/service_flow_test.go b/internal/sub/service_flow_test.go new file mode 100644 index 000000000..ee8431ad8 --- /dev/null +++ b/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) + } +}