fix(xray): keep XDRIVE storage and Vision flow within what xray-core runs

xray-core opens an XDRIVE stream's storage only when the inbound starts
listening, so a Google Drive inbound saved with the form's defaults (no
folder, no secrets), a local one without a folder, or a template one
whose template xray cannot use stopped xray and every other inbound with
it. validateXDriveStorage applies the core's storage rules at save, on
inbound add/update and on the xray template's inbounds and outbounds:
the service must be local, Google Drive or template; local and Google
Drive need a remoteFolder; Google Drive needs its three non-empty
secrets; a template must pass newTemplateStorage's checks (put, get,
list and delete urls, and a list namesRegex with one capture group).

A VLESS client kept its xtls-rprx-vision flow when its inbound moved to
a transport that cannot carry it, and GetXrayConfig only honoured
DisableFlow, so the server demanded Vision there and refused every
client. GetXrayConfig and the outbound form now apply the Vision gate,
and that gate now follows the core in the backend, the subscription
service and the frontend: TLS/REALITY on raw TCP, or VLESS encryption on
any transport (xray runs Vision inside encryption.CommonConn whatever
the transport, not only on XHTTP). The outbound modal passes the VLESS
encryption into the gate and gates Mux on the effective flow.
This commit is contained in:
MHSanaei
2026-10-12 00:49:11 +02:00
parent 8fe7be943f
commit c5e1e54c75
22 changed files with 439 additions and 72 deletions
+5 -13
View File
@@ -1193,21 +1193,13 @@ 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).
// vlessFlowAllowed mirrors inboundCanEnableTlsFlow in internal/web/service: Vision runs over
// TLS/REALITY on raw TCP, or inside VLESS encryption on any transport.
func vlessFlowAllowed(network, security string, settings map[string]any) bool {
switch network {
case "tcp":
return security == "tls" || security == "reality"
case "xhttp":
return vlessEncryptionEnabled(settings)
if network == "tcp" && (security == "tls" || security == "reality") {
return true
}
return false
return vlessEncryptionEnabled(settings)
}
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
+2 -1
View File
@@ -26,7 +26,8 @@ func TestVlessFlowAllowed(t *testing.T) {
{"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},
{"tcp none vlessenc", "tcp", "none", enc, true},
{"ws none vlessenc", "ws", "none", enc, true},
{"xhttp none vlessenc", "xhttp", "none", enc, true},
{"xhttp reality vlessenc (#5232)", "xhttp", "reality", enc, true},
{"xhttp tls vlessenc", "xhttp", "tls", enc, true},
@@ -26,9 +26,8 @@ func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
{"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, ""},
// vlessenc (ML-KEM) carries Vision without transport TLS on any transport.
{"vless tcp vlessenc keeps flow", model.VLESS, `{"network":"tcp","security":"none"}`, enc, vision},
{"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"}`, "", ""},
+78
View File
@@ -1005,6 +1005,78 @@ func validateFinalMaskSalamander(streamSettings string) error {
return maskcompat.CheckSalamanderPasswords(stream["finalmask"])
}
// validateXDriveStorage refuses at save the XDRIVE storage xray-core refuses when an inbound starts
// listening (stopping every inbound) or an outbound dials.
func validateXDriveStorage(streamSettings string) error {
var stream struct {
Network string `json:"network"`
XDriveSettings struct {
Service string `json:"service"`
RemoteFolder string `json:"remoteFolder"`
Secrets []string `json:"secrets"`
Template json.RawMessage `json:"template"`
} `json:"xdriveSettings"`
}
if json.Unmarshal([]byte(streamSettings), &stream) != nil || stream.Network != "xdrive" {
return nil
}
xd := stream.XDriveSettings
hasFolder := strings.TrimSpace(xd.RemoteFolder) != ""
switch xd.Service {
case "local":
if !hasFolder {
return common.NewError(`XDRIVE needs a "remoteFolder": the local folder both ends share.`)
}
case "Google Drive":
if !hasFolder {
return common.NewError(`XDRIVE needs a "remoteFolder": the id of the shared Google Drive folder.`)
}
if len(xd.Secrets) != 3 || slices.ContainsFunc(xd.Secrets, func(s string) bool { return strings.TrimSpace(s) == "" }) {
return common.NewError("Google Drive needs 3 secrets, in order: ClientID, ClientSecret, RefreshToken.")
}
case "template":
if len(xd.Template) == 0 || string(xd.Template) == "null" {
return common.NewError(`XDRIVE service "template" needs a "template" object describing the storage API.`)
}
return validateXDriveTemplate(xd.Template)
default:
return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, Google Drive or template.", xd.Service))
}
return nil
}
// validateXDriveTemplate applies the checks of xray-core's newTemplateStorage
// (transport/internet/xdrive/template.go), which only run once the listener starts.
func validateXDriveTemplate(raw json.RawMessage) error {
type op struct {
URL string `json:"url"`
NamesRegex string `json:"namesRegex"`
}
var tmpl struct {
Put op `json:"put"`
Get op `json:"get"`
Delete op `json:"delete"`
List op `json:"list"`
}
if err := json.Unmarshal(raw, &tmpl); err != nil {
return common.NewError("XDRIVE template is not a valid storage template:", err)
}
if tmpl.Put.URL == "" || tmpl.Get.URL == "" || tmpl.List.URL == "" || tmpl.Delete.URL == "" {
return common.NewError("XDRIVE template needs put, get, list and delete operations, each with a url.")
}
if tmpl.List.NamesRegex == "" {
return common.NewError("XDRIVE template list needs a namesRegex.")
}
names, err := regexp.Compile(tmpl.List.NamesRegex)
if err != nil {
return common.NewError("XDRIVE template namesRegex does not compile:", err)
}
if names.NumSubexp() < 1 {
return common.NewError("XDRIVE template namesRegex needs one capture group for the object name.")
}
return nil
}
// validateFinalMaskJSONSalamander checks a stored whole finalmask: a host's, or the subscription's.
func validateFinalMaskJSONSalamander(finalmask string) error {
var fm map[string]any
@@ -1178,6 +1250,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateXDriveStorage(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil {
return inbound, false, err
}
@@ -1826,6 +1901,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateXDriveStorage(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil {
return inbound, false, err
}
+7 -21
View File
@@ -27,35 +27,21 @@ func inboundShadowsocksMethod(protocol, settings string) string {
return s.Method
}
// 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).
// inboundCanEnableTlsFlow mirrors the frontend's canEnableTlsFlow: xray-core runs Vision over
// TLS/REALITY on raw TCP, or inside VLESS encryption (encryption.CommonConn) on any transport.
func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
if protocol != string(model.VLESS) {
return false
}
if streamSettings == "" {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return false
}
switch stream.Network {
case "tcp":
return stream.Security == "tls" || stream.Security == "reality"
case "xhttp":
return vlessEncryptionEnabled(settings)
default:
return false
if json.Unmarshal([]byte(streamSettings), &stream) == nil && stream.Network == "tcp" &&
(stream.Security == "tls" || stream.Security == "reality") {
return true
}
return vlessEncryptionEnabled(settings)
}
// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. A sidecar
@@ -141,7 +127,7 @@ func vlessEncValueSet(v string) bool {
// inboundCanHostFallbacks gates the settings.fallbacks injection.
// Xray only honors fallbacks on VLESS and Trojan inbounds carried over
// TCP transport with TLS or Reality security. This is intentionally stricter
// than inboundCanEnableTlsFlow (which also accepts XHTTP+vlessenc): fallbacks
// than inboundCanEnableTlsFlow (which also accepts any vlessenc stream): fallbacks
// are a raw-TCP-only feature.
func inboundCanHostFallbacks(ib *model.Inbound) bool {
if ib == nil {
@@ -26,12 +26,11 @@ func TestInboundCanEnableTlsFlow(t *testing.T) {
{"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},
// xray-core runs Vision inside VLESS encryption on any transport (encryption.CommonConn).
{"vless raw vlessenc", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, true},
{"vless ws vlessenc", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
{"vless xdrive vlessenc", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"` + vlessEncValue + `"}`, true},
{"vless xdrive no encryption", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"none"}`, false},
// XHTTP + VLESS encryption (the #5157 case).
{"vless xhttp vlessenc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
@@ -0,0 +1,91 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func xdriveInbound(port int, xdriveSettings string) *model.Inbound {
return &model.Inbound{
Enable: true, Listen: "127.0.0.1", Port: port, Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none"}`,
StreamSettings: `{"network":"xdrive","security":"none","xdriveSettings":` + xdriveSettings + `}`,
}
}
// xray-core opens XDRIVE storage when the listener starts; a storage it refuses there stops every inbound.
func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) {
tests := []struct {
name string
settings string
want string
}{
{"local without folder", `{"service":"local","remoteFolder":""}`, `XDRIVE needs a "remoteFolder"`},
{"google drive without folder", `{"service":"Google Drive","remoteFolder":" ","secrets":["id","secret","token"]}`, `XDRIVE needs a "remoteFolder"`},
{"google drive with two secrets", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","secret"]}`, "Google Drive needs 3 secrets"},
{"google drive with an empty secret", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","","token"]}`, "Google Drive needs 3 secrets"},
{"template without template", `{"service":"template","remoteFolder":"f"}`, `XDRIVE service "template" needs a "template" object`},
{"template that is empty", `{"service":"template","remoteFolder":"f","template":{}}`, "XDRIVE template needs put, get, list and delete operations"},
{"template that is not an object", `{"service":"template","remoteFolder":"f","template":"put"}`, "XDRIVE template is not a valid storage template"},
{"template without a delete op", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"(.+)"}}}`, "XDRIVE template needs put, get, list and delete operations"},
{"template without namesRegex", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u"},"delete":{"url":"u"}}}`, "XDRIVE template list needs a namesRegex"},
{"template namesRegex without a group", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"name"},"delete":{"url":"u"}}}`, "XDRIVE template namesRegex needs one capture group"},
{"unknown service", `{"service":"Dropbox","remoteFolder":"f"}`, `unsupported XDRIVE service "Dropbox"`},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
_, _, err := (&InboundService{}).AddInbound(xdriveInbound(18460+i, tc.settings))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("AddInbound err = %v, want one containing %q", err, tc.want)
}
})
}
}
func TestAddInboundAcceptsACompleteXDriveTemplate(t *testing.T) {
setupBulkDB(t)
tmpl := `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"\"name\":\"([^\"]+)\""},"delete":{"url":"u"}}}`
if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18480, tmpl)); err != nil {
t.Fatalf("AddInbound of a template xray-core can open: %v", err)
}
}
func TestSavePathsRefuseXDriveWithoutStorage(t *testing.T) {
const want = `XDRIVE needs a "remoteFolder"`
const empty = `{"service":"local","remoteFolder":""}`
tests := []struct {
name string
save func(t *testing.T) error
}{
{"update inbound", func(t *testing.T) error {
in, _, err := (&InboundService{}).AddInbound(xdriveInbound(18470, `{"service":"local","remoteFolder":"/tmp/drive"}`))
if err != nil {
t.Fatalf("AddInbound of a complete XDRIVE inbound: %v", err)
}
in.StreamSettings = xdriveInbound(18470, empty).StreamSettings
_, _, err = (&InboundService{}).UpdateInbound(in)
return err
}},
{"xray template inbound", func(t *testing.T) error {
return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"drive","port":18471,"protocol":"vless",
"settings":{"clients":[],"decryption":"none"},"streamSettings":` + xdriveInbound(18471, empty).StreamSettings + `}],
"outbounds":[{"protocol":"freedom","tag":"direct"}]}`)
}},
{"xray template outbound", func(t *testing.T) error {
return (&XraySettingService{}).SaveXraySetting(`{"outbounds":[{"protocol":"freedom","tag":"direct"},
{"protocol":"vless","tag":"drive-out","settings":{"address":"drive.example","port":443,"id":"11111111-2222-4333-8444-555555555555","encryption":"none"},
"streamSettings":` + xdriveInbound(0, empty).StreamSettings + `}]}`)
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
if err := tc.save(t); err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("save err = %v, want one containing %q", err, want)
}
})
}
}
+3 -1
View File
@@ -208,6 +208,8 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
}
settings := map[string]any{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
flowAllowed := !inbound.DisableFlow &&
inboundCanEnableTlsFlow(string(inbound.Protocol), inbound.StreamSettings, inbound.Settings)
var wireguardClientsByEmail map[string]model.Client
if inbound.Protocol == model.WireGuard {
inboundClients, _ := ParseInboundSettingsClients(inbound.Settings)
@@ -245,7 +247,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if flow == "xtls-rprx-vision-udp443" {
flow = "xtls-rprx-vision"
}
if inbound.DisableFlow {
if !flowAllowed {
flow = ""
}
entry := map[string]any{"email": c.Email}
@@ -107,3 +107,71 @@ func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) {
t.Errorf("client id not carried through: %#v", entry)
}
}
// A flow kept from a TCP+REALITY stream makes xray demand Vision on a transport that cannot run it
// without VLESS encryption: "XTLS only supports TLS and REALITY directly".
func TestGetXrayConfig_OmitsVisionFlowOnIneligibleStream(t *testing.T) {
cases := []struct {
name string
stream string
}{
{"xdrive", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`},
{"ws over tls", `{"network":"ws","security":"tls","wsSettings":{"path":"/"}}`},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tag := "stale-flow"
seedVlessInbound(t, tag, 43110+i, []model.Client{
{Email: "vision@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Flow: "xtls-rprx-vision"},
})
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).
Update("stream_settings", tc.stream).Error; err != nil {
t.Fatalf("set stream: %v", err)
}
value, _ := emittedClients(t, tag)
list, ok := value.([]any)
if !ok || len(list) != 1 {
t.Fatalf("settings.clients = %#v, want the one enabled client", value)
}
entry, _ := list[0].(map[string]any)
if flow, present := entry["flow"]; present {
t.Fatalf("flow %q emitted for a %s stream", flow, tc.name)
}
})
}
}
// xray-core runs Vision over TLS/REALITY on RAW, or over VLESS encryption on any transport.
func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) {
const vlessenc = `{"clients":[],"decryption":"mlkem768x25519plus.native.600s.WB3OymHLL_DJ1jdHiqcMC-u84TgvfNFlgbZIJE-NtUQ"}`
cases := []struct {
name string
stream string
settings string
}{
{"tcp reality", `{"network":"tcp","security":"reality","realitySettings":{}}`, `{"clients":[],"decryption":"none"}`},
{"raw vlessenc", `{"network":"tcp","security":"none"}`, vlessenc},
{"ws vlessenc", `{"network":"ws","security":"none","wsSettings":{"path":"/"}}`, vlessenc},
{"xdrive vlessenc", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`, vlessenc},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tag := "vision-flow"
seedVlessInbound(t, tag, 43120+i, []model.Client{
{Email: "vision@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, Flow: "xtls-rprx-vision"},
})
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).
Updates(map[string]any{"stream_settings": tc.stream, "settings": tc.settings}).Error; err != nil {
t.Fatalf("set stream: %v", err)
}
value, _ := emittedClients(t, tag)
list, _ := value.([]any)
if len(list) != 1 {
t.Fatalf("settings.clients = %#v, want one client", value)
}
if entry, _ := list[0].(map[string]any); entry["flow"] != "xtls-rprx-vision" {
t.Fatalf("flow = %#v, want xtls-rprx-vision kept on %s", entry["flow"], tc.name)
}
})
}
}
+6
View File
@@ -57,6 +57,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
if err := validateFinalMaskSalamander(string(inbound.StreamSettings)); err != nil {
return common.NewError("xray template inbound \""+inbound.Tag+"\":", err)
}
if err := validateXDriveStorage(string(inbound.StreamSettings)); err != nil {
return common.NewError("xray template inbound \""+inbound.Tag+"\":", err)
}
}
if len(xrayConfig.OutboundConfigs) > 0 {
var outbounds []json.RawMessage
@@ -90,6 +93,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
if err := validateFinalMaskSalamander(string(probe.StreamSettings)); err != nil {
return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
}
if err := validateXDriveStorage(string(probe.StreamSettings)); err != nil {
return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
}
if err := xray.ValidateOutboundConfig(outbound); err != nil {
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
continue