diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index a093d126e..20cf6f981 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -204,7 +204,7 @@ The `xtls-rprx-vision` flow is fast and DPI-resistant. It's available for - the transport is raw **TCP** with **TLS** or **REALITY** security (classic XTLS-Vision), or -- the transport is **XHTTP** with VLESS encryption enabled (see below). +- VLESS encryption is enabled, on any transport (see below). Set the flow on the VLESS **client**, not the inbound. With classic Vision on TCP, the panel can also offer a **Vision seed** once a client uses the flow. @@ -213,7 +213,7 @@ TCP, the panel can also offer a **Vision seed** once a client uses the flow. VLESS supports post-quantum **encryption** (ML-KEM / `mlkem768x25519`), stored in the inbound's `decryption` (server) and clients' `encryption` (for link -generation). When enabled, it unlocks the Vision flow over XHTTP. Generate the +generation). When enabled, it unlocks the Vision flow on any transport. Generate the keys from the panel's VLESS settings. ## Shadowsocks ciphers diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index 3baa706e7..faf5b316d 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -205,7 +205,7 @@ mKCP و Hysteria لایه TLS/REALITY جداگانه‌ای نمی‌گیرند - انتقال، **TCP** خام با امنیت **TLS** یا **REALITY** باشد (XTLS-Vision کلاسیک)، یا -- انتقال، **XHTTP** با رمزنگاری VLESS فعال باشد (به ادامه مراجعه کنید). +- رمزنگاری VLESS فعال باشد، روی هر انتقالی (به ادامه مراجعه کنید). جریان را روی **کلاینت** VLESS تنظیم کنید، نه روی inbound. با Vision کلاسیک روی TCP، پنل می‌تواند پس از آنکه یک کلاینت از جریان استفاده کرد، یک **Vision seed** نیز @@ -215,7 +215,7 @@ TCP، پنل می‌تواند پس از آنکه یک کلاینت از جری ‏VLESS از **رمزنگاری** پساکوانتومی (ML-KEM / `mlkem768x25519`) پشتیبانی می‌کند که در ‏`decryption` مربوط به inbound (سمت سرور) و `encryption` کلاینت‌ها (برای تولید -لینک) ذخیره می‌شود. وقتی فعال باشد، جریان Vision را روی XHTTP باز می‌کند. کلیدها +لینک) ذخیره می‌شود. وقتی فعال باشد، جریان Vision را روی هر انتقالی باز می‌کند. کلیدها را از تنظیمات VLESS در پنل تولید کنید. ## رمزهای Shadowsocks diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index b48899bb7..8b095a597 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -207,7 +207,7 @@ mKCP и Hysteria не используют отдельный уровень TLS - транспорт — это «сырой» **TCP** с безопасностью **TLS** или **REALITY** (классический XTLS-Vision), либо -- транспорт — это **XHTTP** с включённым шифрованием VLESS (см. ниже). +- включено шифрование VLESS, на любом транспорте (см. ниже). Поток задаётся на **клиенте** VLESS, а не на inbound. При классическом Vision поверх TCP панель также может предложить **Vision seed** после того, как клиент @@ -218,7 +218,7 @@ mKCP и Hysteria не используют отдельный уровень TLS VLESS поддерживает постквантовое **шифрование** (ML-KEM / `mlkem768x25519`), которое хранится в поле `decryption` у inbound (сервер) и в поле `encryption` у клиентов (для генерации ссылок). Когда оно включено, становится доступен поток -Vision поверх XHTTP. Сгенерируйте ключи в настройках VLESS на панели. +Vision на любом транспорте. Сгенерируйте ключи в настройках VLESS на панели. ## Шифры Shadowsocks diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index cd284ea3a..eb2d98097 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -185,7 +185,7 @@ mKCP 和 Hysteria 不使用单独的 TLS/REALITY 层——mKCP 以明文运行 `xtls-rprx-vision` 流控既快速又能抵抗 DPI。在以下任一情况下,它都可用于 **VLESS**: - 传输方式为裸 **TCP**,安全层为 **TLS** 或 **REALITY**(经典的 XTLS-Vision),或者 -- 传输方式为 **XHTTP** 且启用了 VLESS 加密(见下文)。 +- 启用了 VLESS 加密,任意传输方式均可(见下文)。 请在 VLESS **客户端**上设置流控,而不是在入站上。在 TCP 上使用经典 Vision 时,一旦某个 客户端使用该流控,面板还可以提供一个 **Vision seed**。 @@ -193,7 +193,7 @@ mKCP 和 Hysteria 不使用单独的 TLS/REALITY 层——mKCP 以明文运行 ## VLESS 加密(ML-KEM) VLESS 支持后量子**加密**(ML-KEM / `mlkem768x25519`),它存储在入站的 `decryption` -(服务器)以及客户端的 `encryption`(用于生成链接)中。启用后,它会解锁在 XHTTP +(服务器)以及客户端的 `encryption`(用于生成链接)中。启用后,它会解锁任意传输方式 之上的 Vision 流控。请从面板的 VLESS 设置中生成密钥。 ## Shadowsocks 加密方式 diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 8b8dc01e6..05681202f 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -503,10 +503,8 @@ export function genVlessLink(input: GenVlessLinkInput): string { params.set('security', 'none'); } - // XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the - // VLESS-level encryption stands in for transport TLS). Mirrors the backend's - // vlessFlowAllowed and the form's flow-field gating so panel link, share - // link and subscription agree. + // Same gate as the backend's vlessFlowAllowed and the form's flow field, so the + // panel link, share link and subscription agree. if ( flow.length > 0 && canEnableTlsFlow({ diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 4c60ebde3..02e99c420 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -1,6 +1,7 @@ import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound'; import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound'; +import { canEnableTlsFlow } from '@/lib/xray/protocol-capabilities'; import { dropEmptyFinalMask, normalizeStreamSettingsForWire, @@ -902,10 +903,23 @@ function stripUiOnlyStreamFields(stream: unknown): Raw { return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw; } +// The flow select only hides off a Vision-capable transport; its stale value must not reach xray. +export function effectiveVlessFlow( + settings: Pick, + stream: { network?: string; security?: string } | undefined, +): string { + const allowed = canEnableTlsFlow({ + protocol: 'vless', + settings: { encryption: settings.encryption }, + streamSettings: { network: stream?.network, security: stream?.security }, + }); + return allowed ? settings.flow : ''; +} + function muxAllowed(values: OutboundFormValues): boolean { if (!MUX_PROTOCOLS.has(values.protocol)) return false; const flow = - values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : ''; + values.protocol === 'vless' ? effectiveVlessFlow(values.settings, values.streamSettings) : ''; if (flow) return false; const network = values.streamSettings && 'network' in values.streamSettings @@ -924,7 +938,10 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun settings = vmessToWire(values.settings); break; case 'vless': - settings = vlessToWire(values.settings); + settings = vlessToWire({ + ...values.settings, + flow: effectiveVlessFlow(values.settings, values.streamSettings), + }); break; case 'trojan': settings = trojanToWire(values.settings); diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index 3d06a89d6..e824efb16 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -74,10 +74,8 @@ export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean { // 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; + // xray-core runs Vision inside VLESS encryption (encryption.CommonConn) on any transport. + return hasVlessEncryption(values.settings); } export function canEnableStream(values: { protocol: string }): boolean { diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index 0ab97ca0e..70b2b05f0 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -104,7 +104,11 @@ export default function OutboundFormModal({ '') as string; const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string; - const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string; + const storedFlow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? + '') as string; + const encryption = useWatch({ control: methods.control, name: 'settings.encryption' }) as + | string + | undefined; const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' }); const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as | string @@ -113,7 +117,12 @@ export default function OutboundFormModal({ const streamAllowed = canEnableStream({ protocol }); const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } }); const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } }); - const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } }); + const tlsFlowAllowed = canEnableTlsFlow({ + protocol, + settings: { encryption }, + streamSettings: { network, security }, + }); + const flow = tlsFlowAllowed ? storedFlow : ''; /* * Parse a share link (vmess:// / vless:// / trojan:// / ss:// / @@ -527,7 +536,7 @@ export default function OutboundFormModal({ {/* Vision seed knobs only meaningful for the exact xtls-rprx-vision flow, on TCP+(tls|reality). */} - {tlsFlowAllowed && flow === 'xtls-rprx-vision' && ( + {flow === 'xtls-rprx-vision' && ( <> - + ), }, diff --git a/frontend/src/pages/xray/outbounds/transport/mux.tsx b/frontend/src/pages/xray/outbounds/transport/mux.tsx index 489bc0df2..690010da6 100644 --- a/frontend/src/pages/xray/outbounds/transport/mux.tsx +++ b/frontend/src/pages/xray/outbounds/transport/mux.tsx @@ -9,12 +9,12 @@ import { isMuxAllowed } from '../outbound-form-helpers'; interface MuxFormProps { protocol: string; network: string; + flow?: string; } -export default function MuxForm({ protocol, network }: MuxFormProps) { +export default function MuxForm({ protocol, network, flow = '' }: MuxFormProps) { const { t } = useTranslation(); const { control } = useFormContext(); - const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string; const muxEnabled = !!useWatch({ control, name: 'mux.enabled' }); if (!isMuxAllowed(protocol, flow, network)) return null; return ( diff --git a/frontend/src/test/inbound-from-db.test.ts b/frontend/src/test/inbound-from-db.test.ts index aa9bf53cd..46da7a8ca 100644 --- a/frontend/src/test/inbound-from-db.test.ts +++ b/frontend/src/test/inbound-from-db.test.ts @@ -226,12 +226,21 @@ describe('protocol-capability helpers with raw coerced shapes', () => { streamSettings: { network: 'xhttp', security: 'none' }, }), ).toBe(false); - // vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable. + // xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included. + for (const network of ['tcp', 'ws', 'grpc', 'xdrive']) { + expect( + canEnableTlsFlow({ + protocol: 'vless', + settings: { decryption: dec, encryption: enc }, + streamSettings: { network, security: 'none' }, + }), + ).toBe(true); + } expect( canEnableTlsFlow({ protocol: 'vless', - settings: { decryption: dec, encryption: enc }, - streamSettings: { network: 'tcp', security: 'none' }, + settings: { encryption: 'none' }, + streamSettings: { network: 'ws', security: 'tls' }, }), ).toBe(false); }); diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index e0ad11165..87239929a 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -58,6 +58,7 @@ describe('outbound-form-adapter: round-trip', () => { flow: 'xtls-rprx-vision', encryption: 'none', }, + streamSettings: { network: 'tcp', security: 'reality' }, }; const form = rawOutboundToFormValues(wire); expect(form.protocol).toBe('vless'); @@ -119,6 +120,64 @@ describe('outbound-form-adapter: round-trip', () => { expect(settings.reverse).toMatchObject({ tag: 'rev-1' }); }); + // The flow select hides off TCP+TLS/REALITY but keeps its value; xray then fails every dial. + it('vless drops a Vision flow its transport cannot carry, and lets mux back on', () => { + const outbound = (streamSettings: Record) => + formValuesToWirePayload( + rawOutboundToFormValues({ + protocol: 'vless', + settings: { + address: 'srv', + port: 443, + id: '11111111-2222-4333-8444-555555555555', + flow: 'xtls-rprx-vision', + encryption: 'none', + testpre: 5, + }, + streamSettings, + mux: { enabled: true, concurrency: 8 }, + }), + ); + + const xdrive = outbound({ + network: 'xdrive', + security: 'none', + xdriveSettings: { service: 'local', remoteFolder: '/tmp/shared' }, + }); + expect(xdrive.settings).toMatchObject({ flow: '' }); + expect(xdrive.settings).not.toHaveProperty('testpre'); + expect(xdrive.mux).toMatchObject({ enabled: true }); + + const ws = outbound({ network: 'ws', security: 'tls', wsSettings: { path: '/' } }); + expect(ws.settings).toMatchObject({ flow: '' }); + + const reality = outbound({ + network: 'tcp', + security: 'reality', + realitySettings: { serverName: 'a.example', publicKey: 'k', fingerprint: 'chrome' }, + }); + expect(reality.settings).toMatchObject({ flow: 'xtls-rprx-vision', testpre: 5 }); + expect(reality).not.toHaveProperty('mux'); + }); + + // VLESS encryption carries Vision on any transport; stripping it gets the server's "client flow is empty". + it('vless keeps a Vision flow over VLESS encryption on RAW without TLS', () => { + const wire = formValuesToWirePayload( + rawOutboundToFormValues({ + protocol: 'vless', + settings: { + address: 'srv', + port: 443, + id: '11111111-2222-4333-8444-555555555555', + flow: 'xtls-rprx-vision', + encryption: 'mlkem768x25519plus.native.0rtt.cbEXq-_hfkxDUex6HqmSaemEELvnFWc8qQ1MxlNDHnE', + }, + streamSettings: { network: 'tcp', security: 'none' }, + }), + ); + expect(wire.settings).toMatchObject({ flow: 'xtls-rprx-vision' }); + }); + it('vless does not emit testpre/testseed unless flow is vision', () => { const wire = { protocol: 'vless', @@ -543,6 +602,7 @@ describe('outbound-form-adapter: round-trip', () => { flow: 'xtls-rprx-vision', encryption: 'none', }, + streamSettings: { network: 'tcp', security: 'reality' }, mux: { enabled: true }, }), ); diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 249d7f3fc..585d545d6 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -310,6 +310,60 @@ describe('OutboundFormModal', () => { // xray-core reads Google Drive's three secrets by position (ClientID, ClientSecret, // RefreshToken); a misordered list fails OAuth only when the tunnel first dials. + it('gates Flow and Mux on the flow the transport can actually carry', async () => { + const vless = (encryption: string, streamSettings: Record) => ({ + protocol: 'vless', + tag: 'flow-out', + settings: { + address: 'example.com', + port: 443, + id: 'c9f0c2d0-0000-4000-8000-000000000000', + flow: 'xtls-rprx-vision', + encryption, + }, + streamSettings, + }); + const openModal = async (outbound: Record) => { + const queryClient = makeTestQueryClient(); + const tree = (open: boolean) => ( + + + {}} + onConfirm={() => {}} + /> + + + ); + const view = render(tree(false)); + view.rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + return view; + }; + + const ws = await openModal( + vless('none', { network: 'ws', security: 'tls', wsSettings: { path: '/' } }), + ); + expect(formItemByLabel('Transmission')?.textContent).toContain('WebSocket'); + expect(formItemByLabel('Flow')).toBeUndefined(); + expect(formItemByLabel('Mux')).toBeDefined(); + ws.unmount(); + + await openModal( + vless('mlkem768x25519plus.native.0rtt.key', { + network: 'xhttp', + security: 'none', + xhttpSettings: { path: '/', mode: 'auto' }, + }), + ); + expect(formItemByLabel('Flow')).toBeDefined(); + }); + it('saves an XDRIVE stream picked in the transmission selector', async () => { const onConfirm = vi.fn(); const queryClient = makeTestQueryClient(); diff --git a/internal/sub/service.go b/internal/sub/service.go index a95d0571b..fb46921df 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -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 { diff --git a/internal/sub/service_flow_test.go b/internal/sub/service_flow_test.go index 030d03fca..696f267dd 100644 --- a/internal/sub/service_flow_test.go +++ b/internal/sub/service_flow_test.go @@ -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}, diff --git a/internal/web/service/client_flow_isolation_test.go b/internal/web/service/client_flow_isolation_test.go index 52b18454e..4ef25afeb 100644 --- a/internal/web/service/client_flow_isolation_test.go +++ b/internal/web/service/client_flow_isolation_test.go @@ -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"}`, "", ""}, diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 8c87ed1ca..d88ab0b1e 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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 } diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index c03ecfaa3..12c1993fa 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -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 { diff --git a/internal/web/service/inbound_protocol_test.go b/internal/web/service/inbound_protocol_test.go index db696db20..2bca4b4d4 100644 --- a/internal/web/service/inbound_protocol_test.go +++ b/internal/web/service/inbound_protocol_test.go @@ -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}, diff --git a/internal/web/service/inbound_xdrive_test.go b/internal/web/service/inbound_xdrive_test.go new file mode 100644 index 000000000..438f7eee5 --- /dev/null +++ b/internal/web/service/inbound_xdrive_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index c8aeb1fa1..d0ebc3cfb 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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} diff --git a/internal/web/service/xray_config_clients_test.go b/internal/web/service/xray_config_clients_test.go index 9ad61a3f1..5b25614a5 100644 --- a/internal/web/service/xray_config_clients_test.go +++ b/internal/web/service/xray_config_clients_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/xray_setting.go b/internal/web/service/xray_setting.go index 51f222a70..eba12c788 100644 --- a/internal/web/service/xray_setting.go +++ b/internal/web/service/xray_setting.go @@ -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