fix: enable XTLS vision flow for VLESS+XHTTP+vlessenc in UI and share links (#5157) (#5185)

* fix: enable XTLS vision flow for VLESS+XHTTP+vlessenc in UI and share links (#5157)

* fix: enable xtls-rprx-vision flow for VLESS XHTTP with vlessenc encryption (#5157)

The flow selector was hidden and the vless:// link omitted flow= because:
1. The backend gate (inboundCanEnableTlsFlow) only accepted tcp+tls/reality.
2. The PR #5185 frontend check used `encryption === 'vlessenc'`, which never
   matches — the stored value is a generated ML-KEM dotted string, not the CLI
   subcommand name.

Fix: extend inboundCanEnableTlsFlow to also return true for XHTTP when a
non-none vlessenc encryption/decryption value is present. Update all three
call-sites (inbound.go TlsFlowCapable field, client_crud.go clientWithInboundFlow,
inbound_clients.go copy-flow path) and the sub/service.go link generator.
Scope is XHTTP-only: TCP without tls/reality is intentionally excluded.

Add inbound_protocol_test.go covering the new and existing gate combinations,
extend client_flow_isolation_test.go with xhttp+vlessenc cases, and add
frontend tests for canEnableTlsFlow with real ML-KEM key values.

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-06-11 12:04:02 +02:00
committed by GitHub
parent eee652c4a5
commit c7a76e9626
12 changed files with 239 additions and 31 deletions
+19
View File
@@ -445,6 +445,20 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return buildVmessLink(obj)
}
// vlessEncryptionEnabled reports whether the VLESS inbound settings enable
// VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
// fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
// "none" or empty means off. The value is never the literal "vlessenc" — that is
// the `xray vlessenc` CLI subcommand name, not a stored value.
func vlessEncryptionEnabled(settings map[string]any) bool {
for _, key := range []string{"encryption", "decryption"} {
if v, ok := settings[key].(string); ok && v != "" && v != "none" {
return true
}
}
return false
}
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.VLESS {
return ""
@@ -484,6 +498,11 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
}
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
}
}
externalProxies, _ := stream["externalProxy"].([]any)
+1 -1
View File
@@ -146,7 +146,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
}
func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) {
if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
c.Flow = ""
}
return c
@@ -10,23 +10,31 @@ import (
func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
const vision = "xtls-rprx-vision"
const enc = `{"encryption":"mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y"}`
cases := []struct {
name string
protocol model.Protocol
streamSettings string
settings string
wantFlow string
}{
{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, vision},
{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, vision},
{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, ""},
{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, ""},
{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, ""},
{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, ""},
{"empty stream clears flow", model.VLESS, "", ""},
{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, "", vision},
{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, "", vision},
{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, "", ""},
{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, "", ""},
{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, "", ""},
{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, "", ""},
{"empty stream clears flow", model.VLESS, "", "", ""},
// vlessenc (ML-KEM) keeps Vision flow without transport TLS only on XHTTP.
// TCP without tls/reality clears it even with vlessenc set.
{"vless tcp vlessenc clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, enc, ""},
{"vless xhttp vlessenc keeps flow", model.VLESS, `{"network":"xhttp","security":"none"}`, enc, vision},
{"vless xhttp no encryption clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, `{"encryption":"none"}`, ""},
{"vless xhttp empty settings clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings}
ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings, Settings: tc.settings}
got := clientWithInboundFlow(model.Client{Email: "x@example.com", Flow: vision}, ib)
if got.Flow != tc.wantFlow {
t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow)
+1 -1
View File
@@ -196,7 +196,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Tag: r.Tag,
Protocol: r.Protocol,
Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
})
}
+1 -1
View File
@@ -210,7 +210,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
case model.VLESS:
target.ID = s.generateRandomCredential(targetProtocol)
if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings) {
inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) {
target.Flow = flow
}
case model.Trojan, model.Shadowsocks:
+52 -11
View File
@@ -22,9 +22,14 @@ func inboundShadowsocksMethod(protocol, settings string) string {
return s.Method
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
// XTLS Vision is only valid for VLESS on TCP with tls or reality.
func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
// inboundCanEnableTlsFlow mirrors canEnableTlsFlow() from the frontend
// (frontend/src/lib/xray/protocol-capabilities.ts). XTLS Vision is valid for
// VLESS on TCP with tls or reality (classic), and on XHTTP when VLESS encryption
// (vlessenc / ML-KEM) is enabled — there the post-quantum, VLESS-level
// encryption stands in for the transport TLS that Vision relies on. settings is
// the inbound's raw settings JSON, which carries the encryption value
// (streamSettings does not).
func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
if protocol != string(model.VLESS) {
return false
}
@@ -38,15 +43,51 @@ func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
switch stream.Network {
case "tcp":
return stream.Security == "tls" || stream.Security == "reality"
case "xhttp":
return vlessEncryptionEnabled(settings)
default:
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
// vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level
// encryption (vlessenc / ML-KEM) configured. When enabled these fields hold a
// generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>"); "none"
// or empty means off. The value is never the literal "vlessenc" — that is the
// name of the `xray vlessenc` CLI subcommand, not a stored value.
//
// Both fields are checked: decryption is the authoritative server-side value
// xray-core reads, while encryption is stored by the panel for link generation.
// The ML-KEM/X25519 buttons set both, but accepting either keeps the gate
// working for inbounds configured via the API or raw JSON.
func vlessEncryptionEnabled(settings string) bool {
if settings == "" {
return false
}
var s struct {
Encryption string `json:"encryption"`
Decryption string `json:"decryption"`
}
if err := json.Unmarshal([]byte(settings), &s); err != nil {
return false
}
return vlessEncValueSet(s.Encryption) || vlessEncValueSet(s.Decryption)
}
// vlessEncValueSet reports whether a VLESS encryption/decryption field holds a
// real (generated) value rather than the "none"/empty sentinel.
func vlessEncValueSet(v string) bool {
return v != "" && v != "none"
}
// inboundCanHostFallbacks gates the settings.fallbacks injection.
// Xray only honors fallbacks on VLESS and Trojan inbounds carried over
// TCP transport with TLS or Reality security.
// TCP transport with TLS or Reality security. This is intentionally stricter
// than inboundCanEnableTlsFlow (which also accepts XHTTP+vlessenc): fallbacks
// are a raw-TCP-only feature.
func inboundCanHostFallbacks(ib *model.Inbound) bool {
if ib == nil {
return false
@@ -54,13 +95,13 @@ func inboundCanHostFallbacks(ib *model.Inbound) bool {
if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan {
return false
}
return inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) ||
(ib.Protocol == model.Trojan && trojanStreamSupportsFallbacks(ib.StreamSettings))
return streamSupportsFallbacks(ib.StreamSettings)
}
// trojanStreamSupportsFallbacks mirrors the Trojan side of the same gate
// (Trojan reuses XTLS-Vision capable streams: tcp + tls or reality).
func trojanStreamSupportsFallbacks(streamSettings string) bool {
// streamSupportsFallbacks reports whether the stream is raw TCP carried over
// TLS or REALITY — the only transport Xray honors inbound fallbacks on (and the
// classic requirement for XTLS Vision before vlessenc).
func streamSupportsFallbacks(streamSettings string) bool {
if streamSettings == "" {
return false
}
@@ -0,0 +1,90 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// A representative vlessenc/ML-KEM encryption value as produced by `xray
// vlessenc` — a dotted string, never the literal "vlessenc".
const vlessEncValue = "mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y"
func TestInboundCanEnableTlsFlow(t *testing.T) {
cases := []struct {
name string
protocol string
streamSettings string
settings string
want bool
}{
{"vless tcp tls", string(model.VLESS), `{"network":"tcp","security":"tls"}`, "", true},
{"vless tcp reality", string(model.VLESS), `{"network":"tcp","security":"reality"}`, "", true},
{"vless tcp none no enc", string(model.VLESS), `{"network":"tcp","security":"none"}`, "", false},
{"vless ws tls", string(model.VLESS), `{"network":"ws","security":"tls"}`, "", false},
{"vless grpc reality", string(model.VLESS), `{"network":"grpc","security":"reality"}`, "", false},
{"vmess tcp tls", string(model.VMESS), `{"network":"tcp","security":"tls"}`, "", false},
{"empty stream", string(model.VLESS), "", "", false},
// vlessenc is gated to XHTTP only. TCP without tls/reality is NOT
// Vision-capable even with vlessenc set — the combination only works on
// XHTTP in practice.
{"vless tcp vlessenc not capable", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, false},
// ws is a framed transport — vlessenc never enables Vision there.
{"vless ws vlessenc still off", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, false},
// XHTTP + VLESS encryption (the #5157 case).
{"vless xhttp vlessenc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
{"vless xhttp encryption none", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"none"}`, false},
{"vless xhttp no settings", string(model.VLESS), `{"network":"xhttp","security":"none"}`, "", false},
// Regression for PR #5185: the gate is "any non-none encryption", NOT an
// equality check against the literal "vlessenc" (which the buggy PR used
// and which never matches a real, generated encryption value). An x25519
// auth value must enable it just like the ML-KEM value above.
{"vless xhttp x25519 enc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"native.0rtt.121s-180s.xRMUYYjQctqYO1pSyffM-w"}`, true},
// Server-side configs (API/JSON) may carry only decryption; that alone
// must also enable the flow gate.
{"vless xhttp decryption only", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"decryption":"` + vlessEncValue + `","encryption":"none"}`, true},
// XHTTP without encryption stays off even with tls (Vision over XHTTP is
// gated on vlessenc, not transport security).
{"vless xhttp tls no encryption", string(model.VLESS), `{"network":"xhttp","security":"tls"}`, `{"encryption":"none"}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := inboundCanEnableTlsFlow(tc.protocol, tc.streamSettings, tc.settings)
if got != tc.want {
t.Errorf("inboundCanEnableTlsFlow(%q, %q, %q) = %v, want %v",
tc.protocol, tc.streamSettings, tc.settings, got, tc.want)
}
})
}
}
// Fallbacks must remain raw-TCP-only and must NOT follow the broadened flow gate
// onto XHTTP+vlessenc.
func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
cases := []struct {
name string
protocol model.Protocol
streamSettings string
settings string
want bool
}{
{"vless tcp tls", model.VLESS, `{"network":"tcp","security":"tls"}`, "", true},
{"trojan tcp reality", model.Trojan, `{"network":"tcp","security":"reality"}`, "", true},
{"vless xhttp vlessenc not fallback-capable", model.VLESS, `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, false},
{"vmess tcp tls not fallback-capable", model.VMESS, `{"network":"tcp","security":"tls"}`, "", false},
{"nil-ish empty stream", model.VLESS, "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings, Settings: tc.settings}
if got := inboundCanHostFallbacks(ib); got != tc.want {
t.Errorf("inboundCanHostFallbacks = %v, want %v", got, tc.want)
}
})
}
if inboundCanHostFallbacks(nil) {
t.Errorf("inboundCanHostFallbacks(nil) = true, want false")
}
}