Browse Source

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.
MHSanaei 1 day ago
parent
commit
c7d89ceac2

+ 3 - 1
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
 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
 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
 ## FinalMask — late-layer obfuscation
 
 

+ 3 - 1
docs/content/docs/fa/config/transports.mdx

@@ -136,7 +136,9 @@ XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای
 
 
 **آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
 **آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front
 است که API ذخیره‌ساز از طریق آن وصل می‌شود؛ نام TLS همچنان میزبان API است. XDRIVE لایهٔ
 است که API ذخیره‌ساز از طریق آن وصل می‌شود؛ نام TLS همچنان میزبان API است. XDRIVE لایهٔ
-TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است.
+TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است. در اوت‌باند،
+امنیت **TLS** همین اتصال HTTPS را شکل می‌دهد: API ذخیره‌ساز همان SNI، اثرانگشت uTLS و
+ALPN آن را می‌بیند.
 
 
 ## FinalMask — مبهم‌سازی لایه پایانی
 ## FinalMask — مبهم‌سازی لایه پایانی
 
 

+ 2 - 0
docs/content/docs/ru/config/transports.mdx

@@ -139,6 +139,8 @@ XDRIVE передаёт поток файлами в папке, доступн
 Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
 Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт,
 через который идёт подключение к API хранилища; в TLS по-прежнему указывается хост API.
 через который идёт подключение к API хранилища; в TLS по-прежнему указывается хост API.
 У XDRIVE нет собственного уровня TLS/REALITY — его трафик это HTTPS самого API хранилища.
 У XDRIVE нет собственного уровня TLS/REALITY — его трафик это HTTPS самого API хранилища.
+На исходящем безопасность **TLS** задаёт это HTTPS-подключение: API хранилища видит его
+SNI, отпечаток uTLS и ALPN.
 
 
 ## FinalMask — обфускация на позднем уровне
 ## FinalMask — обфускация на позднем уровне
 
 

+ 2 - 1
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)。 |
 | 调优参数       | 内核默认值     | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 |
 
 
 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
-XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。
+XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。在出站上,**TLS**
+安全设置决定这条 HTTPS 连接:存储 API 看到的就是其中的 SNI、uTLS 指纹和 ALPN。
 
 
 ## FinalMask — 末层混淆
 ## FinalMask — 末层混淆
 
 

+ 2 - 2
frontend/package.json

@@ -29,8 +29,8 @@
   },
   },
   "lint-staged": {
   "lint-staged": {
     "src/**/*.{ts,tsx}": [
     "src/**/*.{ts,tsx}": [
-      "oxfmt --no-error-on-unmatched-pattern",
-      "oxlint --fix --no-error-on-unmatched-pattern"
+      "oxfmt",
+      "oxlint --fix"
     ]
     ]
   },
   },
   "dependencies": {
   "dependencies": {

+ 9 - 0
frontend/src/lib/xray/protocol-capabilities.ts

@@ -43,6 +43,15 @@ export function canEnableTls(values: CapabilityProtocolSlice): boolean {
   return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
   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 {
 export function canEnableReality(values: CapabilityProtocolSlice): boolean {
   if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
   if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
   return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
   return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');

+ 2 - 2
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -14,7 +14,7 @@ import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms
 import {
 import {
   canEnableReality,
   canEnableReality,
   canEnableStream,
   canEnableStream,
-  canEnableTls,
+  canEnableOutboundTls,
   canEnableTlsFlow,
   canEnableTlsFlow,
 } from '@/lib/xray/protocol-capabilities';
 } from '@/lib/xray/protocol-capabilities';
 
 
@@ -111,7 +111,7 @@ export default function OutboundFormModal({
     | undefined;
     | undefined;
 
 
   const streamAllowed = canEnableStream({ protocol });
   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 realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
   const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
   const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
 
 

+ 2 - 2
frontend/src/pages/xray/outbounds/outbound-form-helpers.ts

@@ -1,5 +1,5 @@
 import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
 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 type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
 import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
 
 
@@ -112,7 +112,7 @@ export function applyNetworkChange(
   if (next === 'masque') return masqueStreamSlice();
   if (next === 'masque') return masqueStreamSlice();
   const stream = prevStream ?? {};
   const stream = prevStream ?? {};
   const currentSecurity = (stream.security as string) ?? 'none';
   const currentSecurity = (stream.security as string) ?? 'none';
-  const stillTls = canEnableTls({
+  const stillTls = canEnableOutboundTls({
     protocol,
     protocol,
     streamSettings: { network: next, security: currentSecurity },
     streamSettings: { network: next, security: currentSecurity },
   });
   });

+ 74 - 0
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) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>
+          <OutboundFormModal
+            open={open}
+            outbound={outbound}
+            existingTags={[]}
+            onClose={() => {}}
+            onConfirm={onConfirm}
+          />
+        </ThemeProvider>
+      </QueryClientProvider>
+    );
+    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<string, unknown> };
+    expect(payload.streamSettings).toMatchObject({
+      network: 'xdrive',
+      security: 'tls',
+      tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' },
+    });
+  });
+});
+
 describe('OutboundFormModal MASQUE', () => {
 describe('OutboundFormModal MASQUE', () => {
   // xray-core refuses user/pass next to warp, and builds the WARP client certificate
   // 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.
   // from privateKey; a save that kept stale credentials or dropped a key never connects.