Prechádzať zdrojové kódy

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Rouzbeh† 1 deň pred
rodič
commit
c7a76e9626

+ 1 - 1
frontend/public/openapi.json

@@ -2185,7 +2185,7 @@
         "tags": [
           "Inbounds"
         ],
-        "summary": "Lightweight picker projection of the authenticated user’s 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 user’s 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
frontend/src/lib/xray/protocol-capabilities.ts

@@ -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
frontend/src/pages/api-docs/endpoints.ts

@@ -122,7 +122,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/inbounds/options',
-        summary: 'Lightweight picker projection of the authenticated user’s 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 user’s 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,
       },

+ 4 - 0
frontend/src/pages/inbounds/info/helpers.ts

@@ -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 - 0
frontend/src/test/inbound-from-db.test.ts

@@ -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 - 0
internal/sub/service.go

@@ -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
internal/web/service/client_crud.go

@@ -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

+ 16 - 8
internal/web/service/client_flow_isolation_test.go

@@ -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: "[email protected]", Flow: vision}, ib)
 			if got.Flow != tc.wantFlow {
 				t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow)

+ 1 - 1
internal/web/service/inbound.go

@@ -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
internal/web/service/inbound_clients.go

@@ -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
internal/web/service/inbound_protocol.go

@@ -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
 	}

+ 90 - 0
internal/web/service/inbound_protocol_test.go

@@ -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")
+	}
+}