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

+ 3 - 1
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 — مبهم‌سازی لایه پایانی
 

+ 2 - 0
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 — обфускация на позднем уровне
 

+ 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)。 |
 
 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。
-XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。
+XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 HTTPS。在出站上,**TLS**
+安全设置决定这条 HTTPS 连接:存储 API 看到的就是其中的 SNI、uTLS 指纹和 ALPN。
 
 ## FinalMask — 末层混淆
 

+ 2 - 2
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": {

+ 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 ?? '');
 }
 
+// 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 ?? '');

+ 2 - 2
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 } });
 

+ 2 - 2
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 },
   });

+ 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', () => {
   // 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.