From c7d89ceac2277673944579e9a634858d1659679c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 15:44:17 +0200 Subject: [PATCH] feat(outbounds): TLS for XDRIVE outbounds xray-core 26.10.10 dials an XDRIVE outbound's storage API with the stream's tlsSettings: their SNI, uTLS fingerprint and ALPN are what the API sees, which is the point of fronting it. canEnableTls kept XDRIVE out of the TLS-capable networks, so the security picker never offered TLS and switching a TLS outbound to XDRIVE silently reset it to none. canEnableOutboundTls admits a TLS-eligible protocol on xdrive and backs both the picker and the network-change cascade. Inbounds keep the plain predicate: their tlsSettings carry server certificates, not a client hello. Also restores the plain oxfmt / oxlint --fix lint-staged commands, reverting da6872a3: that hook change was not wanted. --- docs/content/docs/en/config/transports.mdx | 4 +- docs/content/docs/fa/config/transports.mdx | 4 +- docs/content/docs/ru/config/transports.mdx | 2 + docs/content/docs/zh/config/transports.mdx | 3 +- frontend/package.json | 4 +- .../src/lib/xray/protocol-capabilities.ts | 9 +++ .../xray/outbounds/OutboundFormModal.tsx | 4 +- .../xray/outbounds/outbound-form-helpers.ts | 4 +- .../src/test/outbound-form-modal.test.tsx | 74 +++++++++++++++++++ 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index dbf45a460..fdcd21293 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -135,7 +135,9 @@ client; XDRIVE inbounds get no share link or Clash entry. The optional **Front address/port** (the stream-level `address`/`port`) is a domain front the storage API is dialed through; TLS still names the API host. XDRIVE has -no TLS/REALITY layer of its own — its traffic is the storage API's HTTPS. +no TLS/REALITY layer of its own — its traffic is the storage API's HTTPS. On an +outbound, **TLS** security shapes that HTTPS connection: its SNI, uTLS fingerprint +and ALPN are what the storage API sees. ## FinalMask — late-layer obfuscation diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index 0e02eb1b8..79cac589d 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -136,7 +136,9 @@ XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای **آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front است که API ذخیره‌ساز از طریق آن وصل می‌شود؛ نام TLS همچنان میزبان API است. XDRIVE لایهٔ -TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است. +TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است. در اوت‌باند، +امنیت **TLS** همین اتصال HTTPS را شکل می‌دهد: API ذخیره‌ساز همان SNI، اثرانگشت uTLS و +ALPN آن را می‌بیند. ## FinalMask — مبهم‌سازی لایه پایانی diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index c7b5ad51f..7b517138b 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -139,6 +139,8 @@ XDRIVE передаёт поток файлами в папке, доступн Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт, через который идёт подключение к API хранилища; в TLS по-прежнему указывается хост API. У XDRIVE нет собственного уровня TLS/REALITY — его трафик это HTTPS самого API хранилища. +На исходящем безопасность **TLS** задаёт это HTTPS-подключение: API хранилища видит его +SNI, отпечаток uTLS и ALPN. ## FinalMask — обфускация на позднем уровне diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index 3e04286ec..953ffe559 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -128,7 +128,8 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服 | 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 | 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。 -XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。 +XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。在出站上,**TLS** +安全设置决定这条 HTTPS 连接:存储 API 看到的就是其中的 SNI、uTLS 指纹和 ALPN。 ## FinalMask — 末层混淆 diff --git a/frontend/package.json b/frontend/package.json index 861bc6f2e..abb3c5c1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,8 +29,8 @@ }, "lint-staged": { "src/**/*.{ts,tsx}": [ - "oxfmt --no-error-on-unmatched-pattern", - "oxlint --fix --no-error-on-unmatched-pattern" + "oxfmt", + "oxlint --fix" ] }, "dependencies": { diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index be6d76ce6..68d086010 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -43,6 +43,15 @@ export function canEnableTls(values: CapabilityProtocolSlice): boolean { return TLS_NETWORKS.includes(values.streamSettings?.network ?? ''); } +// An XDRIVE outbound's tlsSettings shape only its HTTPS calls to the storage API (SNI, uTLS +// fingerprint); xray-core 26.10.10 reads them on the dial side, never on a listener. +export function canEnableOutboundTls(values: CapabilityProtocolSlice): boolean { + if (canEnableTls(values)) return true; + return ( + TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol) && values.streamSettings?.network === 'xdrive' + ); +} + export function canEnableReality(values: CapabilityProtocolSlice): boolean { if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false; return REALITY_NETWORKS.includes(values.streamSettings?.network ?? ''); diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index d02f75c05..0ab97ca0e 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -14,7 +14,7 @@ import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms import { canEnableReality, canEnableStream, - canEnableTls, + canEnableOutboundTls, canEnableTlsFlow, } from '@/lib/xray/protocol-capabilities'; @@ -111,7 +111,7 @@ export default function OutboundFormModal({ | undefined; const streamAllowed = canEnableStream({ protocol }); - const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } }); + const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } }); const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } }); const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } }); diff --git a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts index 5f3b1f2fa..396c5b415 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts @@ -1,5 +1,5 @@ import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter'; -import { canEnableReality, canEnableTls } from '@/lib/xray/protocol-capabilities'; +import { canEnableOutboundTls, canEnableReality } from '@/lib/xray/protocol-capabilities'; import type { OutboundFormValues } from '@/schemas/forms/outbound-form'; import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive'; @@ -112,7 +112,7 @@ export function applyNetworkChange( if (next === 'masque') return masqueStreamSlice(); const stream = prevStream ?? {}; const currentSecurity = (stream.security as string) ?? 'none'; - const stillTls = canEnableTls({ + const stillTls = canEnableOutboundTls({ protocol, streamSettings: { network: next, security: currentSecurity }, }); diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index a2c62427b..249d7f3fc 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -379,6 +379,80 @@ describe('OutboundFormModal', () => { }); }); +// xray-core 26.10.10 dials the XDRIVE storage API with the outbound's tlsSettings (SNI, +// uTLS fingerprint), so switching a TLS outbound to XDRIVE must keep them. +describe('OutboundFormModal XDRIVE TLS', () => { + it('keeps TLS when a TLS outbound moves to XDRIVE', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'drive-out', + settings: { + vnext: [ + { + address: 'www.google.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'tcp', + security: 'tls', + tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + chooseOptionByLabel('Transmission', 'XDRIVE'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect((screen.getByRole('radio', { name: 'TLS' }) as HTMLInputElement).checked).toBe(true); + for (const [label, value] of [ + ['Remote folder', 'xray-tunnel'], + ['Client ID', 'id.apps.googleusercontent.com'], + ['Client secret', 'client-secret'], + ['Refresh token', 'refresh-token'], + ]) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + } + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + expect(payload.streamSettings).toMatchObject({ + network: 'xdrive', + security: 'tls', + tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' }, + }); + }); +}); + describe('OutboundFormModal MASQUE', () => { // xray-core refuses user/pass next to warp, and builds the WARP client certificate // from privateKey; a save that kept stale credentials or dropped a key never connects.