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
+1 -1
View File
@@ -2185,7 +2185,7 @@
"tags": [
"Inbounds"
],
"summary": "Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.",
"summary": "Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.",
"operationId": "get_panel_api_inbounds_options",
"responses": {
"200": {
+24 -7
View File
@@ -16,16 +16,16 @@ const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
export interface CapabilityProtocolSlice {
protocol: string;
settings?: { encryption?: string; decryption?: string };
streamSettings?: { network?: string; security?: string };
}
export interface CapabilityVlessSlice extends CapabilityProtocolSlice {
settings?: { clients?: { flow?: string }[] };
settings?: { encryption?: string; decryption?: string; clients?: { flow?: string }[] };
}
export interface CapabilityShadowsocksSlice {
protocol: string;
settings?: { method?: string };
export interface CapabilityShadowsocksSlice extends CapabilityProtocolSlice {
settings?: { encryption?: string; method?: string };
}
export function canEnableTls(values: CapabilityProtocolSlice): boolean {
@@ -39,11 +39,28 @@ export function canEnableReality(values: CapabilityProtocolSlice): boolean {
return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
}
// VLESS encryption (vlessenc / ML-KEM) is on when encryption or decryption holds
// a generated value (e.g. "mlkem768x25519plus.native.0rtt.<key>") rather than
// the "none"/"" sentinel. The value is never the literal "vlessenc" (that is the
// `xray vlessenc` subcommand). decryption is the server-side value; encryption is
// stored for link generation — either being set means it is on.
function hasVlessEncryption(settings: CapabilityProtocolSlice['settings']): boolean {
const isSet = (v?: string) => v != null && v !== '' && v !== 'none';
return isSet(settings?.encryption) || isSet(settings?.decryption);
}
export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
if (values.protocol !== 'vless') return false;
const network = values.streamSettings?.network;
const security = values.streamSettings?.security;
if (security !== 'tls' && security !== 'reality') return false;
if (values.streamSettings?.network !== 'tcp') return false;
return values.protocol === 'vless';
// Classic XTLS Vision: raw TCP carried over TLS or REALITY.
if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true;
// vlessenc carries Vision over XHTTP without transport TLS.
if (network === 'xhttp' && hasVlessEncryption(values.settings)) return true;
return false;
}
export function canEnableStream(values: { protocol: string }): boolean {
+1 -1
View File
@@ -122,7 +122,7 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/inbounds/options',
summary: 'Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
summary: 'Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
responseSchema: 'InboundOption',
responseSchemaArray: true,
},
@@ -121,6 +121,10 @@ export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
}),
isVlessTlsFlow: canEnableTlsFlow({
protocol: dbInbound.protocol,
settings: {
encryption: settings.encryption as string | undefined,
decryption: settings.decryption as string | undefined,
},
streamSettings: { network, security },
}),
host: readNetworkHost(stream, network),
+29
View File
@@ -180,6 +180,35 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
streamSettings: { network: 'tcp', security: 'tls' },
})).toBe(false);
});
it('canEnableTlsFlow allows vless + xhttp when vlessenc encryption is set', () => {
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
const dec = 'mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E';
// XHTTP + a real (generated) encryption value → Vision flow allowed.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { encryption: enc },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(true);
// decryption alone (server-side value) is enough on XHTTP.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: 'none' },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(true);
// No encryption → stays gated off.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { encryption: 'none' },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(false);
// vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: enc },
streamSettings: { network: 'tcp', security: 'none' },
})).toBe(false);
});
});
describe('getInboundClients with schema-shaped inbound', () => {
+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")
}
}