From ca5983595b3c71dab9b068ccf93a0e7bc317e94c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 08:21:08 +0200 Subject: [PATCH] feat(xray): add the XDRIVE transport xray-core v26.10.10 tunnels a stream through files in shared cloud storage ("network": "xdrive"): Google Drive, a local folder, or any HTTP storage API described by a template. The panel could not select it, and the stream schema rejected a pasted xdrive stream outright. The inbound and outbound forms now offer XDRIVE with one shared editor: service, folder, the three Google Drive secrets in the order the core reads them, a template JSON editor with its {secretN} values, the optional domain front (the stream-level address/port the storage client dials), and the core's tuning knobs with their defaults as placeholders. Switching an inbound to XDRIVE resets security to none, because the core would otherwise read the inbound's TLS/REALITY as the storage API's client TLS. XDRIVE has no share-link format, and a link carrying the storage secrets would print them into every QR code, so both link generators emit none for it; the JSON subscription already passes the stream through intact, and Clash drops the unknown transport. --- docs/content/docs/en/config/transports.mdx | 22 ++- docs/content/docs/fa/config/transports.mdx | 22 ++- docs/content/docs/ru/config/transports.mdx | 22 ++- docs/content/docs/zh/config/transports.mdx | 19 ++- .../xray/forms/transport/FinalMaskForm.tsx | 2 +- .../lib/xray/forms/transport/XDriveForm.tsx | 148 ++++++++++++++++++ frontend/src/lib/xray/inbound-link.ts | 7 + frontend/src/lib/xray/stream-defaults.ts | 4 + .../pages/inbounds/form/InboundFormModal.tsx | 17 ++ .../xray/outbounds/OutboundFormModal.tsx | 3 + .../xray/outbounds/outbound-form-constants.ts | 1 + .../xray/outbounds/outbound-form-helpers.ts | 3 + .../src/schemas/protocols/stream/index.ts | 12 ++ .../src/schemas/protocols/stream/xdrive.ts | 26 +++ .../test/__snapshots__/stream.test.ts.snap | 25 +++ .../fixtures/stream/xdrive-google-drive.json | 22 +++ frontend/src/test/inbound-form-modal.test.tsx | 44 ++++++ frontend/src/test/inbound-link.test.ts | 22 +++ .../src/test/outbound-form-modal.test.tsx | 89 ++++++++++- internal/sub/service.go | 8 +- internal/sub/xdrive_test.go | 15 ++ internal/web/translation/ar-EG.json | 26 ++- internal/web/translation/en-US.json | 24 +++ internal/web/translation/es-ES.json | 26 ++- internal/web/translation/fa-IR.json | 26 ++- internal/web/translation/id-ID.json | 26 ++- internal/web/translation/ja-JP.json | 26 ++- internal/web/translation/pt-BR.json | 26 ++- internal/web/translation/ru-RU.json | 24 +++ internal/web/translation/tr-TR.json | 26 ++- internal/web/translation/uk-UA.json | 26 ++- internal/web/translation/vi-VN.json | 26 ++- internal/web/translation/zh-CN.json | 26 ++- internal/web/translation/zh-TW.json | 26 ++- 34 files changed, 848 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/xray/forms/transport/XDriveForm.tsx create mode 100644 frontend/src/schemas/protocols/stream/xdrive.ts create mode 100644 frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json create mode 100644 internal/sub/xdrive_test.go diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 77ed33cf6..f5baf7c91 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -1,6 +1,6 @@ --- title: Transports & Security -description: Every transport 3x-ui exposes — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria — with their settings, plus FinalMask obfuscation, sockopt, TLS/REALITY, XTLS-Vision, and VLESS encryption. +description: Every transport 3x-ui exposes — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, XDRIVE — with their settings, plus FinalMask obfuscation, sockopt, TLS/REALITY, XTLS-Vision, and VLESS encryption. icon: Network --- @@ -23,6 +23,7 @@ network writes its own settings key on the wire (`tcpSettings`, `kcpSettings`, | **HTTPUpgrade** | `httpupgradeSettings` | CDN-friendly HTTP/1.1 `Upgrade`; lighter than full WebSocket. | | **XHTTP** | `xhttpSettings` | Modern stream-multiplexed HTTP transport; CDN-friendly and REALITY-capable. | | **Hysteria** | `hysteriaSettings` | QUIC-based transport — only for the **Hysteria2** protocol. | +| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, a local folder or any HTTP storage API). | **WireGuard** and **Tunnel** (dokodemo-door) inbounds expose no transport @@ -116,6 +117,25 @@ Only valid when the protocol is **Hysteria2**. | `udpIdleTimeout` | `60` | Seconds (2–600) before idle UDP sessions are dropped. | | `masquerade` | — | Disguise as an HTTP/3 server: `type` `proxy`/`file`/`string` with `url`/`dir`/`content`, plus `headers` and `statusCode`. | +### XDRIVE — `xdriveSettings` + +XDRIVE carries the stream as files in a folder both ends can reach: the server polls +that folder instead of accepting connections on its port. Both sides must use the +**same** service, folder and secrets, so the JSON subscription ships them to every +client; XDRIVE inbounds get no share link or Clash entry. + +| Field | Default | Meaning | +| -------------- | -------------- | ----------------------------------------------------------------------- | +| `service` | `Google Drive` | `Google Drive`, `local` (a folder on disk) or `template` (any HTTP storage API). | +| `remoteFolder` | — | Folder that holds the session files. | +| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. | +| `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. | +| tuning | core defaults | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). | + +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. + ## FinalMask — late-layer obfuscation **FinalMask** wraps traffic **after** the transport and security layers, so it can diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index c9657a959..2690a269d 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -1,6 +1,6 @@ --- title: انتقال‌ها و امنیت -description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria — به‌همراه تنظیماتشان، و نیز مبهم‌سازی FinalMask، sockopt، TLS/REALITY، XTLS-Vision و رمزنگاری VLESS. +description: هر انتقالی که 3x-ui ارائه می‌دهد — TCP، mKCP، WebSocket، gRPC، HTTPUpgrade، XHTTP، Hysteria، XDRIVE — به‌همراه تنظیماتشان، و نیز مبهم‌سازی FinalMask، sockopt، TLS/REALITY، XTLS-Vision و رمزنگاری VLESS. icon: Network --- @@ -23,6 +23,7 @@ icon: Network | **HTTPUpgrade** | `httpupgradeSettings` | `Upgrade` مربوط به HTTP/1.1 و سازگار با CDN؛ سبک‌تر از WebSocket کامل. | | **XHTTP** | `xhttpSettings` | انتقال HTTP مدرن با مالتی‌پلکس جریانی؛ سازگار با CDN و توانمند برای REALITY. | | **Hysteria** | `hysteriaSettings` | انتقال مبتنی بر QUIC — تنها برای پروتکل **Hysteria2**. | +| **XDRIVE** | `xdriveSettings` | جریان را از طریق فایل‌ها در یک فضای ذخیره‌سازی ابری مشترک (Google Drive، یک پوشهٔ محلی یا هر API ذخیره‌سازی HTTP) تونل می‌کند. | inbound‌های **WireGuard** و **Tunnel** (dokodemo-door) هیچ انتخابگر انتقالی @@ -117,6 +118,25 @@ icon: Network | `udpIdleTimeout` | `60` | ثانیه (2–600) پیش از حذف نشست‌های بی‌کار UDP. | | `masquerade` | — | استتار به‌عنوان یک سرور HTTP/3: `type` با مقدار `proxy`/`file`/`string` و `url`/`dir`/`content`، به‌علاوه `headers` و `statusCode`. | +### XDRIVE — `xdriveSettings` + +XDRIVE جریان را به‌صورت فایل‌هایی در پوشه‌ای که هر دو طرف به آن دسترسی دارند حمل می‌کند: +سرور به‌جای پذیرش اتصال روی پورتش، آن پوشه را بررسی می‌کند. هر دو طرف باید سرویس، پوشه +و مقادیر محرمانهٔ **یکسان** داشته باشند، بنابراین اشتراک JSON آن‌ها را به همهٔ کلاینت‌ها +می‌فرستد؛ inboundهای XDRIVE لینک اشتراک‌گذاری یا ورودی Clash ندارند. + +| فیلد | پیش‌فرض | معنا | +| -------------- | -------------- | ---- | +| `service` | `Google Drive` | `Google Drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). | +| `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. | +| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. | +| `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. | +| تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). | + +**آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front +است که API ذخیره‌ساز از طریق آن وصل می‌شود؛ نام TLS همچنان میزبان API است. XDRIVE لایهٔ +TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS خود API ذخیره‌ساز است. + ## FinalMask — مبهم‌سازی لایه پایانی **FinalMask** ترافیک را **پس از** لایه‌های انتقال و امنیت می‌پیچد، بنابراین می‌تواند diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index 3d67cc9eb..ccdbe2e11 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -1,6 +1,6 @@ --- title: Транспорты и безопасность -description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria — с их настройками, а также обфускация FinalMask, sockopt, TLS/REALITY, XTLS-Vision и шифрование VLESS. +description: Все транспорты, которые предоставляет 3x-ui — TCP, mKCP, WebSocket, gRPC, HTTPUpgrade, XHTTP, Hysteria, XDRIVE — с их настройками, а также обфускация FinalMask, sockopt, TLS/REALITY, XTLS-Vision и шифрование VLESS. icon: Network --- @@ -25,6 +25,7 @@ network записывает свой собственный ключ настр | **HTTPUpgrade** | `httpupgradeSettings` | Дружественный к CDN апгрейд HTTP/1.1 `Upgrade`; легче полноценного WebSocket. | | **XHTTP** | `xhttpSettings` | Современный HTTP-транспорт с мультиплексированием потоков; дружественный к CDN и совместимый с REALITY. | | **Hysteria** | `hysteriaSettings` | Транспорт на базе QUIC — только для протокола **Hysteria2**. | +| **XDRIVE** | `xdriveSettings` | Туннелирует поток через файлы в общем облачном хранилище (Google Drive, локальная папка или любой HTTP API хранилища). | Inbound-соединения **WireGuard** и **Tunnel** (dokodemo-door) не предоставляют @@ -119,6 +120,25 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей | `udpIdleTimeout` | `60` | Секунды (2–600) до сброса простаивающих UDP-сессий. | | `masquerade` | — | Маскировка под HTTP/3-сервер: `type` `proxy`/`file`/`string` с `url`/`dir`/`content`, а также `headers` и `statusCode`. | +### XDRIVE — `xdriveSettings` + +XDRIVE передаёт поток файлами в папке, доступной обеим сторонам: сервер опрашивает эту +папку, а не принимает соединения на своём порту. Обе стороны должны использовать +**одинаковые** сервис, папку и секреты, поэтому JSON-подписка передаёт их каждому +клиенту; для XDRIVE-инбаундов не создаются ссылки и записи Clash. + +| Поле | По умолчанию | Значение | +| -------------- | -------------- | -------- | +| `service` | `Google Drive` | `Google Drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). | +| `remoteFolder` | — | Папка с файлами сессий. | +| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. | +| `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. | +| тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). | + +Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт, +через который идёт подключение к API хранилища; в TLS по-прежнему указывается хост API. +У XDRIVE нет собственного уровня TLS/REALITY — его трафик это HTTPS самого API хранилища. + ## FinalMask — обфускация на позднем уровне **FinalMask** оборачивает трафик **после** уровней транспорта и безопасности, diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index a6887cb87..e87cf5dec 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -1,6 +1,6 @@ --- title: 传输方式与安全层 -description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria——及其设置项,外加 FinalMask 混淆、sockopt、TLS/REALITY、XTLS-Vision 以及 VLESS 加密。 +description: 3x-ui 提供的每一种传输方式——TCP、mKCP、WebSocket、gRPC、HTTPUpgrade、XHTTP、Hysteria、XDRIVE——及其设置项,外加 FinalMask 混淆、sockopt、TLS/REALITY、XTLS-Vision 以及 VLESS 加密。 icon: Network --- @@ -22,6 +22,7 @@ icon: Network | **HTTPUpgrade** | `httpupgradeSettings` | 对 CDN 友好的 HTTP/1.1 `Upgrade`;比完整的 WebSocket 更轻量。 | | **XHTTP** | `xhttpSettings` | 现代的流多路复用 HTTP 传输;对 CDN 友好且支持 REALITY。 | | **Hysteria** | `hysteriaSettings` | 基于 QUIC 的传输——仅用于 **Hysteria2** 协议。 | +| **XDRIVE** | `xdriveSettings` | 通过共享云存储(Google Drive、本地文件夹或任意 HTTP 存储 API)中的文件传输数据流。 | **WireGuard** 和 **Tunnel**(dokodemo-door)入站不提供传输方式选择器——它们的传输流 @@ -112,6 +113,22 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、 | `udpIdleTimeout` | `60` | 空闲 UDP 会话被丢弃前的秒数(2–600)。 | | `masquerade` | — | 伪装成一个 HTTP/3 服务器:`type` 为 `proxy`/`file`/`string`,配合 `url`/`dir`/`content`,外加 `headers` 与 `statusCode`。 | +### XDRIVE — `xdriveSettings` + +XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服务端轮询该文件夹,而不是在端口上接受连接。 +双方必须使用**相同**的服务、文件夹和密钥,因此 JSON 订阅会把它们下发给每个客户端;XDRIVE 入站不生成分享链接或 Clash 条目。 + +| 字段 | 默认值 | 含义 | +| -------------- | -------------- | ---- | +| `service` | `Google Drive` | `Google Drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。 | +| `remoteFolder` | — | 存放会话文件的文件夹。 | +| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 | +| `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 | +| 调优参数 | 内核默认值 | `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。 + ## FinalMask — 末层混淆 **FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的 diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 6efffd7f6..c9a5137da 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -36,7 +36,7 @@ export interface FinalMaskFormProps { showAll?: boolean; } -const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; +const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp', 'xdrive']; const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 }; // Xray-core caps the Gecko output packet size at its internal buffer (2048) // and needs 1 <= min <= max; mirror those bounds so the panel rejects what diff --git a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx new file mode 100644 index 000000000..3b1402f86 --- /dev/null +++ b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input, InputNumber, Select, Typography } from 'antd'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { JsonEditor } from '@/components/form'; +import { FormField } from '@/components/form/rhf'; +import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive'; + +const BASE = ['streamSettings', 'xdriveSettings'] as const; + +const SERVICE_LABEL_KEYS: Partial> = { + local: 'pages.inbounds.form.xdrive.serviceLocal', + template: 'pages.inbounds.form.xdrive.serviceTemplate', +}; + +// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an +// empty field keeps meaning "use the core's value". +const TUNING_FIELDS = [ + ['segmentBytes', 'segmentBytes', 524288], + ['flushIntervalMs', 'flushInterval', 20], + ['pollIntervalMs', 'pollInterval', 50], + ['maxPollIntervalMs', 'maxPollInterval', 500], + ['eagerWindowMs', 'eagerWindow', 2000], + ['holeTimeoutMs', 'holeTimeout', 30000], + ['sessionTtlSeconds', 'sessionTtl', 300], + ['concurrency', 'concurrency', 8], +] as const; + +const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const; + +function TemplateEditor({ + value, + onChange, +}: { + value?: Record; + onChange?: (next: Record | undefined) => void; +}) { + const { t } = useTranslation(); + const [text, setText] = useState(() => (value ? JSON.stringify(value, null, 2) : '')); + const [invalid, setInvalid] = useState(false); + return ( + <> + { + setText(next); + if (next.trim() === '') { + setInvalid(false); + onChange?.(undefined); + return; + } + try { + const parsed: unknown = JSON.parse(next); + const isObject = !!parsed && typeof parsed === 'object' && !Array.isArray(parsed); + setInvalid(!isObject); + if (isObject) onChange?.(parsed as Record); + } catch { + setInvalid(true); + } + }} + /> + {invalid && ( + + {t('pages.inbounds.form.xdrive.templateInvalid')} + + )} + + ); +} + +export default function XDriveForm() { + const { t } = useTranslation(); + const { control } = useFormContext(); + const service = useWatch({ control, name: `${BASE.join('.')}.service` }) as string | undefined; + const serviceOptions = XDriveServiceSchema.options.map((value) => { + const labelKey = SERVICE_LABEL_KEYS[value]; + return { value, label: labelKey ? t(labelKey) : value }; + }); + + return ( + <> + + + + {service === 'Google Drive' && + GOOGLE_DRIVE_SECRETS.map((key, index) => ( + + {key === 'clientId' ? : } + + ))} + {service === 'template' && ( + <> + + + + + + + + )} + {TUNING_FIELDS.map(([key, label, placeholder]) => ( + + + + ))} + + ); +} diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 4fd457f44..25a6365c2 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1449,6 +1449,11 @@ export interface GenLinkInput { externalProxy?: ExternalProxyEntry | null; } +// XDRIVE has no link format; a link carrying its storage secrets would leak them. +function hasNoLinkFormat(inbound: Inbound): boolean { + return inbound.streamSettings?.network === 'xdrive'; +} + // Per-protocol dispatcher matching the legacy `genLink` switch. Returns // '' for protocols that don't have client-based share links (wireguard // goes through genWireguardLinks/Configs separately, http/mixed/tunnel @@ -1463,6 +1468,7 @@ export function genLink(input: GenLinkInput): string { client, externalProxy = null, } = input; + if (hasNoLinkFormat(inbound)) return ''; switch (inbound.protocol) { case 'vmess': return genVmessLink({ @@ -1603,6 +1609,7 @@ export interface GenInboundLinksInput { // other clientless protocols (http, mixed, tunnel). export function genInboundLinks(input: GenInboundLinksInput): string { const { inbound, remark = '', hostOverride = '', fallbackHostname } = input; + if (hasNoLinkFormat(inbound)) return ''; const addr = resolveAddr(inbound, hostOverride, fallbackHostname); const clients = getInboundClients(inbound); if (clients) { diff --git a/frontend/src/lib/xray/stream-defaults.ts b/frontend/src/lib/xray/stream-defaults.ts index b042117a9..9f1dc1da2 100644 --- a/frontend/src/lib/xray/stream-defaults.ts +++ b/frontend/src/lib/xray/stream-defaults.ts @@ -5,6 +5,7 @@ import { KcpStreamSettingsSchema, TcpStreamSettingsSchema, WsStreamSettingsSchema, + XDriveStreamSettingsSchema, XHttpStreamSettingsSchema, } from '@/schemas/protocols/stream'; import { RealityStreamSettingsSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security'; @@ -17,6 +18,7 @@ const NETWORK_KEY_MAP = { httpupgrade: 'httpupgradeSettings', xhttp: 'xhttpSettings', hysteria: 'hysteriaSettings', + xdrive: 'xdriveSettings', } as const; type SchemaWithParse = { safeParse: (v: unknown) => { success: boolean; data?: unknown } }; @@ -44,6 +46,8 @@ function networkSchemaFor(network: string): SchemaWithParse | null { return XHttpStreamSettingsSchema; case 'hysteria': return HysteriaStreamSettingsSchema; + case 'xdrive': + return XDriveStreamSettingsSchema; default: return null; } diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index e5c8f7b55..ae0fa59cb 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -54,8 +54,10 @@ import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws'; import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc'; import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade'; import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp'; +import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive'; import { DateTimePicker } from '@/components/form'; import { FinalMaskField } from '@/lib/xray/forms/fields'; +import XDriveForm from '@/lib/xray/forms/transport/XDriveForm'; import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; @@ -214,6 +216,8 @@ function newStreamSlice(n: string): Record { return HttpUpgradeStreamSettingsSchema.parse({}); case 'xhttp': return XHttpStreamSettingsSchema.parse({}); + case 'xdrive': + return XDriveStreamSettingsSchema.parse({}); default: return {}; } @@ -865,12 +869,22 @@ export default function InboundFormModal({ 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings', + 'xdriveSettings', ]; const current = (getV('streamSettings') as Record) ?? {}; const cleaned: Record = { ...current, network: next }; for (const k of ALL) { if (k !== `${next}Settings`) delete cleaned[k]; } + if (next === 'xdrive') { + /* The core would read the inbound's TLS as the storage API's client TLS. */ + cleaned.security = 'none'; + delete cleaned.tlsSettings; + delete cleaned.realitySettings; + } else { + delete cleaned.address; + delete cleaned.port; + } cleaned[`${next}Settings`] = newStreamSlice(next); if (next === 'kcp') { const fm = (cleaned.finalmask as Record | undefined) ?? {}; @@ -918,6 +932,7 @@ export default function InboundFormModal({ { value: 'grpc', label: 'gRPC' }, { value: 'httpupgrade', label: 'HTTPUpgrade' }, { value: 'xhttp', label: 'XHTTP' }, + { value: 'xdrive', label: 'XDRIVE' }, ]} /> @@ -941,6 +956,8 @@ export default function InboundFormModal({ {network === 'httpupgrade' && } {network === 'kcp' && } + + {network === 'xdrive' && } )} diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index 9ad8e2fd4..c5bea48b9 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -8,6 +8,7 @@ import { JsonEditor } from '@/components/form'; import { Wireguard } from '@/utils'; import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter'; import { parseOutboundLink } from '@/lib/xray/outbound-link-parser'; +import XDriveForm from '@/lib/xray/forms/transport/XDriveForm'; import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp'; import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form'; import { @@ -487,6 +488,8 @@ export default function OutboundFormModal({ {network === 'xhttp' && } {network === 'hysteria' && } + + {network === 'xdrive' && } )} diff --git a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts b/frontend/src/pages/xray/outbounds/outbound-form-constants.ts index be3a85cbe..50ba9eaeb 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-constants.ts +++ b/frontend/src/pages/xray/outbounds/outbound-form-constants.ts @@ -44,6 +44,7 @@ export const NETWORK_OPTIONS: { value: string; label: string }[] = [ { value: 'grpc', label: 'gRPC' }, { value: 'httpupgrade', label: 'HTTPUpgrade' }, { value: 'xhttp', label: 'XHTTP' }, + { value: 'xdrive', label: 'XDRIVE' }, ]; // The hysteria protocol is locked to its own QUIC transport: the selector diff --git a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts index aa25dff02..a4a145e58 100644 --- a/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbound-form-helpers.ts @@ -1,6 +1,7 @@ import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter'; import { canEnableReality, canEnableTls } from '@/lib/xray/protocol-capabilities'; import type { OutboundFormValues } from '@/schemas/forms/outbound-form'; +import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive'; import { MUX_PROTOCOLS } from './outbound-form-constants'; @@ -64,6 +65,8 @@ export function newStreamSlice(network: string): Record { udpIdleTimeout: 60, }, }; + case 'xdrive': + return { network: 'xdrive', xdriveSettings: XDriveStreamSettingsSchema.parse({}) }; default: return { network: 'tcp', tcpSettings: { header: { type: 'none' } } }; } diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts index dd9833775..5a6adeb85 100644 --- a/frontend/src/schemas/protocols/stream/index.ts +++ b/frontend/src/schemas/protocols/stream/index.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { PortSchema } from '@/schemas/primitives'; + import { ExternalProxyEntrySchema } from './external-proxy'; import { FinalMaskStreamSettingsSchema } from './finalmask'; import { GrpcStreamSettingsSchema } from './grpc'; @@ -9,6 +11,7 @@ import { KcpStreamSettingsSchema } from './kcp'; import { SockoptStreamSettingsSchema } from './sockopt'; import { TcpStreamSettingsSchema } from './tcp'; import { WsStreamSettingsSchema } from './ws'; +import { XDriveStreamSettingsSchema } from './xdrive'; import { XHttpStreamSettingsSchema } from './xhttp'; export * from './external-proxy'; @@ -20,6 +23,7 @@ export * from './kcp'; export * from './sockopt'; export * from './tcp'; export * from './ws'; +export * from './xdrive'; export * from './xhttp'; export const NetworkSchema = z.enum([ @@ -30,6 +34,7 @@ export const NetworkSchema = z.enum([ 'httpupgrade', 'xhttp', 'hysteria', + 'xdrive', ]); export type Network = z.infer; @@ -53,6 +58,13 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [ }), z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }), z.object({ network: z.literal('hysteria'), hysteriaSettings: HysteriaStreamSettingsSchema }), + // The stream-level address/port is the domain front XDRIVE dials for its storage API. + z.object({ + network: z.literal('xdrive'), + xdriveSettings: XDriveStreamSettingsSchema, + address: z.string().optional(), + port: PortSchema.optional(), + }), ]); // Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no diff --git a/frontend/src/schemas/protocols/stream/xdrive.ts b/frontend/src/schemas/protocols/stream/xdrive.ts new file mode 100644 index 000000000..f3387b5f6 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/xdrive.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +// XDRIVE tunnels the stream through files in shared cloud storage, so both ends +// carry the same service, folder and secrets. Zero/absent tuning knobs take the +// core's defaults; segmentBytes and concurrency are capped there at 16 MiB / 64. +export const XDriveServiceSchema = z.enum(['Google Drive', 'local', 'template']); +export type XDriveService = z.infer; + +const MillisSchema = z.number().int().min(0); + +export const XDriveStreamSettingsSchema = z.object({ + service: XDriveServiceSchema.default('Google Drive'), + remoteFolder: z.string().default(''), + // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken. + secrets: z.array(z.string()).default([]), + segmentBytes: z.number().int().min(0).optional(), + flushIntervalMs: MillisSchema.optional(), + pollIntervalMs: MillisSchema.optional(), + maxPollIntervalMs: MillisSchema.optional(), + sessionTtlSeconds: z.number().int().min(0).optional(), + concurrency: z.number().int().min(0).optional(), + eagerWindowMs: MillisSchema.optional(), + holeTimeoutMs: MillisSchema.optional(), + template: z.record(z.string(), z.unknown()).optional(), +}); +export type XDriveStreamSettings = z.infer; diff --git a/frontend/src/test/__snapshots__/stream.test.ts.snap b/frontend/src/test/__snapshots__/stream.test.ts.snap index 6b9adcfa2..17bfef5b8 100644 --- a/frontend/src/test/__snapshots__/stream.test.ts.snap +++ b/frontend/src/test/__snapshots__/stream.test.ts.snap @@ -35,6 +35,31 @@ exports[`NetworkSettingsSchema fixtures > parses ws-default byte-stably 1`] = ` } `; +exports[`NetworkSettingsSchema fixtures > parses xdrive-google-drive byte-stably 1`] = ` +{ + "address": "142.250.0.1", + "network": "xdrive", + "port": 443, + "xdriveSettings": { + "concurrency": 8, + "eagerWindowMs": 2000, + "flushIntervalMs": 20, + "holeTimeoutMs": 30000, + "maxPollIntervalMs": 500, + "pollIntervalMs": 50, + "remoteFolder": "xray-tunnel", + "secrets": [ + "000000000000-example.apps.googleusercontent.com", + "GOCSPX-example-client-secret", + "1//example-refresh-token", + ], + "segmentBytes": 524288, + "service": "Google Drive", + "sessionTtlSeconds": 300, + }, +} +`; + exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = ` { "network": "xhttp", diff --git a/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json new file mode 100644 index 000000000..966ce4334 --- /dev/null +++ b/frontend/src/test/golden/fixtures/stream/xdrive-google-drive.json @@ -0,0 +1,22 @@ +{ + "network": "xdrive", + "address": "142.250.0.1", + "port": 443, + "xdriveSettings": { + "service": "Google Drive", + "remoteFolder": "xray-tunnel", + "secrets": [ + "000000000000-example.apps.googleusercontent.com", + "GOCSPX-example-client-secret", + "1//example-refresh-token" + ], + "segmentBytes": 524288, + "flushIntervalMs": 20, + "pollIntervalMs": 50, + "maxPollIntervalMs": 500, + "sessionTtlSeconds": 300, + "concurrency": 8, + "eagerWindowMs": 2000, + "holeTimeoutMs": 30000 + } +} diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 93351dd26..e5a6cf593 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -318,6 +318,50 @@ describe('InboundFormModal', () => { }); }); + // The core reads an XDRIVE inbound's TLS/REALITY as the storage API's client TLS, so + // switching a REALITY inbound to XDRIVE must store security none with its secrets. + it('switches a REALITY inbound to an XDRIVE stream with security none', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + + fireEvent.click(screen.getByRole('tab', { name: 'Stream' })); + const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find( + (el) => + el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission', + ); + const select = transmission?.querySelector('.ant-select') as HTMLElement; + fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select); + const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find( + (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE', + ) as HTMLElement; + fireEvent.click(option); + 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(await screen.findByLabelText(label), { target: { value } }); + } + fireEvent.click(primaryButton()); + + const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/42'; + await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true)); + const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string }; + const stream = JSON.parse(payload.streamSettings) as Record; + expect(stream).toMatchObject({ + network: 'xdrive', + security: 'none', + xdriveSettings: { + service: 'Google Drive', + remoteFolder: 'xray-tunnel', + secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'], + }, + }); + expect(stream.realitySettings).toBeUndefined(); + }); + // Clients and enable change through their own endpoints; the server keeps the // stored ones, so the edit form must neither send nor validate its stale copy. it('edit save neither sends nor validates the clients it loaded', async () => { diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 084765cb6..745bdf7a8 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -127,6 +127,28 @@ describe('genVlessLink', () => { } }); +describe('XDRIVE share links', () => { + // No client reads an xdrive link, and one carrying the storage secrets would print + // them into every QR code; mirrors the Go generator, which emits none either. + it('emits no link for an XDRIVE stream', () => { + const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!; + const inbound = InboundSchema.parse({ + ...raw, + streamSettings: { + network: 'xdrive', + security: 'none', + xdriveSettings: { service: 'local', remoteFolder: '/srv/xdrive' }, + }, + }); + const client = (raw as { settings: { clients: Array<{ id: string; email: string }> } }).settings + .clients[0]; + + const entries = genAllLinks({ inbound, client, fallbackHostname: 'panel.example.test' }); + + expect(entries.map((e) => e.link)).toEqual(['']); + }); +}); + describe('applyVlessRoute', () => { const id = '11111111-2222-4333-8444-555555555555'; it('encodes a single value into the 3rd group and no-ops on invalid input', () => { diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 4f12ef4ba..565aa3d1a 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { act, fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; @@ -24,6 +24,23 @@ function renderModal(outbound: Record | null = null) { ); } +function formItemByLabel(label: string): Element | undefined { + return Array.from(document.querySelectorAll('.ant-form-item')).find( + (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label, + ); +} + +function chooseOptionByLabel(label: string, optionText: string) { + const select = formItemByLabel(label)?.querySelector('.ant-select'); + if (!select) throw new Error(`${label} select not found`); + fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select); + const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find( + (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionText, + ); + if (!option) throw new Error(`Option '${optionText}' not found for ${label}`); + fireEvent.click(option); +} + function toggleSwitch(label: string) { const item = Array.from(document.querySelectorAll('.ant-form-item')).find( (el) => (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === label, @@ -290,4 +307,74 @@ describe('OutboundFormModal', () => { useSystemCA: true, }); }); + + // 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('saves an XDRIVE stream picked in the transmission selector', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'vless', + tag: 'drive-out', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { network: 'tcp', security: 'none' }, + }; + 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)); + }); + 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', + xdriveSettings: { + service: 'Google Drive', + remoteFolder: 'xray-tunnel', + secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'], + }, + }); + }); }); diff --git a/internal/sub/service.go b/internal/sub/service.go index ec5a51818..2867ff2be 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -818,9 +818,13 @@ func mergeStreamFromMaster(childStream, masterStream string) string { // GetLink dispatches to the protocol-specific generator for one (inbound, client) // pair. Returns "" when the inbound's protocol doesn't produce a subscription URL -// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may -// contain multiple `\n`-separated URLs when the inbound has externalProxy set. +// (socks, http, mixed, wireguard, dokodemo, tunnel) or its transport has no link +// format (xdrive). The returned string may contain multiple `\n`-separated URLs +// when the inbound has externalProxy set. func (s *SubService) GetLink(inbound *model.Inbound, email string) string { + if network, _ := unmarshalStreamSettings(inbound.StreamSettings)["network"].(string); network == "xdrive" { + return "" + } switch inbound.Protocol { case "vmess": return s.genVmessLink(inbound, email) diff --git a/internal/sub/xdrive_test.go b/internal/sub/xdrive_test.go new file mode 100644 index 000000000..ca7de7cfe --- /dev/null +++ b/internal/sub/xdrive_test.go @@ -0,0 +1,15 @@ +package sub + +import "testing" + +// XDRIVE has no share-link format: a link would either drop the storage secrets both +// ends need or print them into every QR code, so only the JSON subscription carries it. +func TestGetLink_SkipsXdriveStreams(t *testing.T) { + stream := `{"network":"xdrive","security":"none", + "xdriveSettings":{"service":"Google Drive","remoteFolder":"xray-tunnel","secrets":["id","secret","token"]}}` + inbound := flowTestInbound(stream, "none") + + if link := (&SubService{}).GetLink(inbound, "user"); link != "" { + t.Fatalf("GetLink = %q, want no link for an XDRIVE stream", link) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 123422e6a..920f8f012 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -694,7 +694,31 @@ "bytesPerSecTip": "حد السرعة (بايت/ثانية) المطبّق على مرور الـ fallback بعد العتبة، عشان الفحوصات ماتقدرش تستخدم سيرفرك كنطاق ترددي مجاني للهدف. 0 = بلا حد (بيعطّل الاتجاه ده).", "burstBytesPerSec": "بايت في الثانية للدفقة", "burstBytesPerSecTip": "السماح بدفقات قصيرة فوق المعدل الثابت (حجم token-bucket). لو أقل من بايت في الثانية بيترفع ليطابقه.", - "autoFill": "الملء التلقائي" + "autoFill": "الملء التلقائي", + "xdrive": { + "service": "خدمة التخزين", + "serviceLocal": "مجلد محلي", + "serviceTemplate": "قالب مخصص", + "remoteFolder": "المجلد البعيد", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "الأسرار", + "secretsDesc": "قيم يقرؤها القالب باسم {secret0} و{secret1} …", + "template": "قالب التخزين", + "templateInvalid": "يجب أن يكون كائن JSON", + "front": "عنوان الواجهة (Front)", + "frontDesc": "مضيف أو IP اختياري يُتصل عبره بواجهة API التخزين (domain fronting).", + "frontPort": "منفذ الواجهة (Front)", + "segmentBytes": "حجم المقطع (بايت)", + "flushInterval": "فاصل الإرسال (ms)", + "pollInterval": "فاصل الاستطلاع (ms)", + "maxPollInterval": "أقصى فاصل استطلاع (ms)", + "eagerWindow": "نافذة الإرسال المبكر (ms)", + "holeTimeout": "مهلة المقطع المفقود (ms)", + "sessionTtl": "مدة صلاحية الجلسة (s)", + "concurrency": "التزامن" + } }, "info": { "mode": "الوضع", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 234d57df0..95b723f4f 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -695,6 +695,30 @@ "node": "Node address", "listen": "Inbound listen", "custom": "Custom" + }, + "xdrive": { + "service": "Storage service", + "serviceLocal": "Local folder", + "serviceTemplate": "Custom template", + "remoteFolder": "Remote folder", + "clientId": "Client ID", + "clientSecret": "Client secret", + "refreshToken": "Refresh token", + "secrets": "Secrets", + "secretsDesc": "Values the template reads as {secret0}, {secret1}, …", + "template": "Storage template", + "templateInvalid": "Must be a JSON object", + "front": "Front address", + "frontDesc": "Optional host or IP the storage API is dialed through (domain fronting).", + "frontPort": "Front port", + "segmentBytes": "Segment size (bytes)", + "flushInterval": "Flush interval (ms)", + "pollInterval": "Poll interval (ms)", + "maxPollInterval": "Max poll interval (ms)", + "eagerWindow": "Eager window (ms)", + "holeTimeout": "Missing segment timeout (ms)", + "sessionTtl": "Session TTL (s)", + "concurrency": "Concurrency" } }, "info": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index d2a44b80a..e2cb1a73f 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -694,7 +694,31 @@ "listen": "Dirección de escucha del inbound", "custom": "Personalizada" }, - "autoFill": "Autocompletar" + "autoFill": "Autocompletar", + "xdrive": { + "service": "Servicio de almacenamiento", + "serviceLocal": "Carpeta local", + "serviceTemplate": "Plantilla personalizada", + "remoteFolder": "Carpeta remota", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Secretos", + "secretsDesc": "Valores que la plantilla lee como {secret0}, {secret1}, …", + "template": "Plantilla de almacenamiento", + "templateInvalid": "Debe ser un objeto JSON", + "front": "Dirección de fronting", + "frontDesc": "Host o IP opcional a través del cual se conecta a la API de almacenamiento (domain fronting).", + "frontPort": "Puerto de fronting", + "segmentBytes": "Tamaño de segmento (bytes)", + "flushInterval": "Intervalo de envío (ms)", + "pollInterval": "Intervalo de sondeo (ms)", + "maxPollInterval": "Intervalo máximo de sondeo (ms)", + "eagerWindow": "Ventana anticipada (ms)", + "holeTimeout": "Tiempo de espera de segmento perdido (ms)", + "sessionTtl": "TTL de sesión (s)", + "concurrency": "Concurrencia" + } }, "info": { "mode": "Modo", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 6a021977a..802815ea2 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -694,7 +694,31 @@ "listen": "آدرس شنود ورودی", "custom": "سفارشی" }, - "autoFill": "تکمیل خودکار" + "autoFill": "تکمیل خودکار", + "xdrive": { + "service": "سرویس ذخیره‌سازی", + "serviceLocal": "پوشهٔ محلی", + "serviceTemplate": "قالب سفارشی", + "remoteFolder": "پوشهٔ راه دور", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "مقادیر محرمانه", + "secretsDesc": "مقادیری که قالب با {secret0}، {secret1}، … می‌خواند", + "template": "قالب ذخیره‌سازی", + "templateInvalid": "باید یک شیء JSON باشد", + "front": "آدرس Front", + "frontDesc": "میزبان یا IP اختیاری که API ذخیره‌ساز از طریق آن وصل می‌شود (domain fronting).", + "frontPort": "پورت Front", + "segmentBytes": "اندازهٔ قطعه (بایت)", + "flushInterval": "فاصلهٔ ارسال (میلی‌ثانیه)", + "pollInterval": "فاصلهٔ بررسی (میلی‌ثانیه)", + "maxPollInterval": "حداکثر فاصلهٔ بررسی (میلی‌ثانیه)", + "eagerWindow": "پنجرهٔ پیش‌ارسال (میلی‌ثانیه)", + "holeTimeout": "مهلت قطعهٔ گمشده (میلی‌ثانیه)", + "sessionTtl": "TTL نشست (ثانیه)", + "concurrency": "هم‌زمانی" + } }, "info": { "mode": "حالت", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index c02b70877..81affa685 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -694,7 +694,31 @@ "bytesPerSecTip": "Batas kecepatan (byte/detik) yang diterapkan pada lalu lintas fallback setelah ambang batas, agar probe tidak dapat memakai server Anda sebagai bandwidth gratis menuju target. 0 = tanpa batas (menonaktifkan arah ini).", "burstBytesPerSec": "Byte per detik burst", "burstBytesPerSecTip": "Kelonggaran untuk burst singkat di atas laju tetap (ukuran token-bucket). Jika lebih kecil dari Byte per detik, nilainya dinaikkan agar sama.", - "autoFill": "Isi Otomatis" + "autoFill": "Isi Otomatis", + "xdrive": { + "service": "Layanan penyimpanan", + "serviceLocal": "Folder lokal", + "serviceTemplate": "Template kustom", + "remoteFolder": "Folder jarak jauh", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Rahasia", + "secretsDesc": "Nilai yang dibaca template sebagai {secret0}, {secret1}, …", + "template": "Template penyimpanan", + "templateInvalid": "Harus berupa objek JSON", + "front": "Alamat fronting", + "frontDesc": "Host atau IP opsional untuk menghubungi API penyimpanan (domain fronting).", + "frontPort": "Port fronting", + "segmentBytes": "Ukuran segmen (byte)", + "flushInterval": "Interval pengiriman (ms)", + "pollInterval": "Interval polling (ms)", + "maxPollInterval": "Interval polling maks. (ms)", + "eagerWindow": "Jendela kirim awal (ms)", + "holeTimeout": "Batas waktu segmen hilang (ms)", + "sessionTtl": "TTL sesi (s)", + "concurrency": "Konkurensi" + } }, "info": { "mode": "Mode", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 35318ddf3..6b6354fdc 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -694,7 +694,31 @@ "listen": "インバウンドのリッスンアドレス", "custom": "カスタム" }, - "autoFill": "自動入力" + "autoFill": "自動入力", + "xdrive": { + "service": "ストレージサービス", + "serviceLocal": "ローカルフォルダ", + "serviceTemplate": "カスタムテンプレート", + "remoteFolder": "リモートフォルダ", + "clientId": "クライアント ID", + "clientSecret": "クライアントシークレット", + "refreshToken": "リフレッシュトークン", + "secrets": "シークレット", + "secretsDesc": "テンプレートで {secret0}、{secret1}、… として参照される値", + "template": "ストレージテンプレート", + "templateInvalid": "JSON オブジェクトである必要があります", + "front": "フロンティングアドレス", + "frontDesc": "ストレージ API への接続に使う任意のホストまたは IP(ドメインフロンティング)。", + "frontPort": "フロンティングポート", + "segmentBytes": "セグメントサイズ(バイト)", + "flushInterval": "送出間隔(ms)", + "pollInterval": "ポーリング間隔(ms)", + "maxPollInterval": "最大ポーリング間隔(ms)", + "eagerWindow": "先行送信ウィンドウ(ms)", + "holeTimeout": "欠落セグメントのタイムアウト(ms)", + "sessionTtl": "セッション TTL(秒)", + "concurrency": "同時実行数" + } }, "info": { "mode": "モード", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index d37328b0b..4c5dee2b3 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -694,7 +694,31 @@ "listen": "Endereço de escuta do inbound", "custom": "Personalizada" }, - "autoFill": "Preenchimento automático" + "autoFill": "Preenchimento automático", + "xdrive": { + "service": "Serviço de armazenamento", + "serviceLocal": "Pasta local", + "serviceTemplate": "Template personalizado", + "remoteFolder": "Pasta remota", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Segredos", + "secretsDesc": "Valores que o template lê como {secret0}, {secret1}, …", + "template": "Template de armazenamento", + "templateInvalid": "Deve ser um objeto JSON", + "front": "Endereço de fronting", + "frontDesc": "Host ou IP opcional usado para conectar à API de armazenamento (domain fronting).", + "frontPort": "Porta de fronting", + "segmentBytes": "Tamanho do segmento (bytes)", + "flushInterval": "Intervalo de envio (ms)", + "pollInterval": "Intervalo de polling (ms)", + "maxPollInterval": "Intervalo máximo de polling (ms)", + "eagerWindow": "Janela antecipada (ms)", + "holeTimeout": "Timeout de segmento ausente (ms)", + "sessionTtl": "TTL da sessão (s)", + "concurrency": "Concorrência" + } }, "info": { "mode": "Modo", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 37eaef461..ba859b0c5 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -695,6 +695,30 @@ "node": "Адрес узла", "listen": "Адрес прослушивания inbound", "custom": "Пользовательская" + }, + "xdrive": { + "service": "Сервис хранилища", + "serviceLocal": "Локальная папка", + "serviceTemplate": "Свой шаблон", + "remoteFolder": "Удалённая папка", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Секреты", + "secretsDesc": "Значения, которые шаблон читает как {secret0}, {secret1}, …", + "template": "Шаблон хранилища", + "templateInvalid": "Должен быть объект JSON", + "front": "Адрес фронтинга", + "frontDesc": "Необязательный хост или IP для подключения к API хранилища (domain fronting).", + "frontPort": "Порт фронтинга", + "segmentBytes": "Размер сегмента (байт)", + "flushInterval": "Интервал отправки (мс)", + "pollInterval": "Интервал опроса (мс)", + "maxPollInterval": "Макс. интервал опроса (мс)", + "eagerWindow": "Окно упреждения (мс)", + "holeTimeout": "Тайм-аут пропущенного сегмента (мс)", + "sessionTtl": "TTL сессии (с)", + "concurrency": "Параллелизм" } }, "info": { diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index da96b7962..f80bca6e3 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -694,7 +694,31 @@ "bytesPerSecTip": "Eşik aşıldıktan sonra fallback trafiğine uygulanan hız sınırı (bayt/sn); böylece sondalar sunucunuzu hedefe ücretsiz bant genişliği olarak kullanamaz. 0 = sınır yok (bu yönü devre dışı bırakır).", "burstBytesPerSec": "Saniye Başına Patlama Baytı", "burstBytesPerSecTip": "Sabit hızın üzerindeki kısa patlamalar için pay (token-bucket boyutu). Saniye Başına Bayt değerinden düşükse ona eşitlenecek şekilde yükseltilir.", - "autoFill": "Otomatik Doldur" + "autoFill": "Otomatik Doldur", + "xdrive": { + "service": "Depolama hizmeti", + "serviceLocal": "Yerel klasör", + "serviceTemplate": "Özel şablon", + "remoteFolder": "Uzak klasör", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Gizli değerler", + "secretsDesc": "Şablonun {secret0}, {secret1}, … olarak okuduğu değerler", + "template": "Depolama şablonu", + "templateInvalid": "Bir JSON nesnesi olmalı", + "front": "Fronting adresi", + "frontDesc": "Depolama API'sine bağlanmak için isteğe bağlı ana makine veya IP (domain fronting).", + "frontPort": "Fronting portu", + "segmentBytes": "Segment boyutu (bayt)", + "flushInterval": "Gönderim aralığı (ms)", + "pollInterval": "Yoklama aralığı (ms)", + "maxPollInterval": "Maks. yoklama aralığı (ms)", + "eagerWindow": "Erken gönderim penceresi (ms)", + "holeTimeout": "Eksik segment zaman aşımı (ms)", + "sessionTtl": "Oturum TTL (s)", + "concurrency": "Eşzamanlılık" + } }, "info": { "mode": "Mod", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 0c91f0eba..b1547258d 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -694,7 +694,31 @@ "listen": "Адреса прослуховування inbound", "custom": "Користувацька" }, - "autoFill": "Автозаповнення" + "autoFill": "Автозаповнення", + "xdrive": { + "service": "Сервіс сховища", + "serviceLocal": "Локальна тека", + "serviceTemplate": "Власний шаблон", + "remoteFolder": "Віддалена тека", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Секрети", + "secretsDesc": "Значення, які шаблон читає як {secret0}, {secret1}, …", + "template": "Шаблон сховища", + "templateInvalid": "Має бути об'єкт JSON", + "front": "Адреса фронтингу", + "frontDesc": "Необов'язковий хост або IP для підключення до API сховища (domain fronting).", + "frontPort": "Порт фронтингу", + "segmentBytes": "Розмір сегмента (байт)", + "flushInterval": "Інтервал надсилання (мс)", + "pollInterval": "Інтервал опитування (мс)", + "maxPollInterval": "Макс. інтервал опитування (мс)", + "eagerWindow": "Вікно випередження (мс)", + "holeTimeout": "Тайм-аут пропущеного сегмента (мс)", + "sessionTtl": "TTL сесії (с)", + "concurrency": "Паралельність" + } }, "info": { "mode": "Режим", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 4edbb142e..71ad6af03 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -694,7 +694,31 @@ "listen": "Địa chỉ listen inbound", "custom": "Tùy chỉnh" }, - "autoFill": "Tự động điền" + "autoFill": "Tự động điền", + "xdrive": { + "service": "Dịch vụ lưu trữ", + "serviceLocal": "Thư mục cục bộ", + "serviceTemplate": "Template tùy chỉnh", + "remoteFolder": "Thư mục từ xa", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "refreshToken": "Refresh Token", + "secrets": "Giá trị bí mật", + "secretsDesc": "Các giá trị template đọc dưới dạng {secret0}, {secret1}, …", + "template": "Template lưu trữ", + "templateInvalid": "Phải là một đối tượng JSON", + "front": "Địa chỉ fronting", + "frontDesc": "Host hoặc IP tùy chọn để kết nối tới API lưu trữ (domain fronting).", + "frontPort": "Cổng fronting", + "segmentBytes": "Kích thước phân đoạn (byte)", + "flushInterval": "Khoảng gửi (ms)", + "pollInterval": "Khoảng thăm dò (ms)", + "maxPollInterval": "Khoảng thăm dò tối đa (ms)", + "eagerWindow": "Cửa sổ gửi sớm (ms)", + "holeTimeout": "Thời gian chờ phân đoạn thiếu (ms)", + "sessionTtl": "TTL phiên (s)", + "concurrency": "Số luồng đồng thời" + } }, "info": { "mode": "Chế độ", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6ab6b005f..6a7e54512 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -694,7 +694,31 @@ "custom": "自定义" }, "verifyPeerCertByName": "按名称验证对端证书", - "autoFill": "自动填充" + "autoFill": "自动填充", + "xdrive": { + "service": "存储服务", + "serviceLocal": "本地文件夹", + "serviceTemplate": "自定义模板", + "remoteFolder": "远程文件夹", + "clientId": "客户端 ID", + "clientSecret": "客户端密钥", + "refreshToken": "刷新令牌", + "secrets": "密钥", + "secretsDesc": "模板中以 {secret0}、{secret1}… 引用的值", + "template": "存储模板", + "templateInvalid": "必须是 JSON 对象", + "front": "前置地址", + "frontDesc": "连接存储 API 时使用的可选主机或 IP(域前置)。", + "frontPort": "前置端口", + "segmentBytes": "分段大小(字节)", + "flushInterval": "发送间隔(毫秒)", + "pollInterval": "轮询间隔(毫秒)", + "maxPollInterval": "最大轮询间隔(毫秒)", + "eagerWindow": "预发窗口(毫秒)", + "holeTimeout": "缺失分段超时(毫秒)", + "sessionTtl": "会话 TTL(秒)", + "concurrency": "并发数" + } }, "info": { "mode": "模式", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 6c7fdccd3..1100cb596 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -694,7 +694,31 @@ "listen": "入站監聽地址", "custom": "自訂" }, - "autoFill": "自動填入" + "autoFill": "自動填入", + "xdrive": { + "service": "儲存服務", + "serviceLocal": "本機資料夾", + "serviceTemplate": "自訂範本", + "remoteFolder": "遠端資料夾", + "clientId": "用戶端 ID", + "clientSecret": "用戶端密鑰", + "refreshToken": "重新整理權杖", + "secrets": "密鑰", + "secretsDesc": "範本中以 {secret0}、{secret1}… 引用的值", + "template": "儲存範本", + "templateInvalid": "必須是 JSON 物件", + "front": "前置位址", + "frontDesc": "連線儲存 API 時使用的選用主機或 IP(網域前置)。", + "frontPort": "前置連接埠", + "segmentBytes": "分段大小(位元組)", + "flushInterval": "傳送間隔(毫秒)", + "pollInterval": "輪詢間隔(毫秒)", + "maxPollInterval": "最大輪詢間隔(毫秒)", + "eagerWindow": "預發視窗(毫秒)", + "holeTimeout": "遺失分段逾時(毫秒)", + "sessionTtl": "工作階段 TTL(秒)", + "concurrency": "並行數" + } }, "info": { "mode": "模式",