From 62d27d54d59b077751b57e57f5ebe986c352feab Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:56:26 +0100 Subject: [PATCH] feat(xray): support the XDRIVE s3 storage service xray-core 26.10.31 (#7231) added an "s3" XDRIVE service that carries the stream through S3-compatible storage: secrets are AccessKeyID, SecretAccessKey and an optional SessionToken, an "s3" object names the endpoint, region, bucket and pathStyle, and remoteFolder becomes the object prefix. The schema and the shared XDRIVE form offer the service with a field for every S3 key, so an outbound or inbound keeps its storage on save. The save path refused "s3" as an unknown service; it now runs the vendored loader, whose ValidateS3Config would otherwise only fire when the listener starts and stop every inbound. A node refuses a pushed S3 storage like a pushed template one, since its endpoint is any URL the node would dial, including its own panel. Labels in all 14 locales; docs updated in en/fa/ru/zh. --- docs/content/docs/en/config/transports.mdx | 7 ++- docs/content/docs/fa/config/transports.mdx | 5 +- docs/content/docs/ru/config/transports.mdx | 5 +- docs/content/docs/zh/config/transports.mdx | 5 +- .../lib/xray/forms/transport/XDriveForm.tsx | 57 ++++++++++++++++- .../src/schemas/protocols/stream/xdrive.ts | 14 ++++- .../src/test/outbound-form-modal.test.tsx | 63 +++++++++++++++++++ frontend/src/test/stream.test.ts | 11 ++++ internal/web/service/inbound.go | 25 +++++--- internal/web/service/inbound_push_guard.go | 2 +- internal/web/service/inbound_tls_test.go | 4 ++ internal/web/service/inbound_xdrive_test.go | 12 ++++ internal/web/translation/ar-EG.json | 8 +++ internal/web/translation/en-US.json | 8 +++ internal/web/translation/es-ES.json | 8 +++ internal/web/translation/fa-IR.json | 8 +++ internal/web/translation/id-ID.json | 8 +++ internal/web/translation/ja-JP.json | 8 +++ internal/web/translation/pt-BR.json | 8 +++ internal/web/translation/ru-RU.json | 8 +++ internal/web/translation/sk-SK.json | 8 +++ internal/web/translation/tr-TR.json | 8 +++ internal/web/translation/uk-UA.json | 8 +++ internal/web/translation/vi-VN.json | 8 +++ internal/web/translation/zh-CN.json | 8 +++ internal/web/translation/zh-TW.json | 8 +++ internal/xray/api.go | 11 ++++ 27 files changed, 309 insertions(+), 24 deletions(-) diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 399bb1044..59c5bc08f 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -23,7 +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). | +| **XDRIVE** | `xdriveSettings` | Tunnels the stream through files in shared cloud storage (Google Drive, S3, a local folder or any HTTP storage API). | | **MASQUE** | `masqueSettings` | CONNECT-IP over HTTP/3 or HTTP/2 — only for the **MASQUE** protocol. See [MASQUE](/docs/config/masque). | @@ -132,10 +132,11 @@ enabled. The panel offers XDRIVE only for VLESS, refuses to save an inbound with | Field | Default | Meaning | | -------------- | -------------- | ----------------------------------------------------------------------- | -| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) or `template` (any HTTP storage API). The old `Google Drive` spelling is still read. | +| `service` | `google-drive` | `google-drive`, `local` (a folder on disk) `template` (any HTTP storage API) or `s3` (S3-compatible storage). The old `Google Drive` spelling is still read. | | `remoteFolder` | — | Folder that holds the session files. | -| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. | +| `secrets` | — | Google Drive: ClientID, ClientSecret, RefreshToken — exactly three, in that order. Template: values read as `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey and an optional SessionToken. | | `template` | — | Template service only: the storage API's `auth`, `put`, `get`, `list` and `delete` operations. | +| `s3` | — | S3 service only: `endpoint` (an http(s) URL), `region`, `bucket` and `pathStyle` (default `true`; `false` puts the bucket in the host name). `remoteFolder` is the object prefix. | | 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 diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index 8d74b7681..9066c9604 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -133,10 +133,11 @@ inbound به مقدار `decryption` نیاز دارد و outbound به `encrypt | فیلد | پیش‌فرض | معنا | | -------------- | -------------- | ---- | -| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک) یا `template` (هر API ذخیره‌سازی HTTP). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. | +| `service` | `google-drive` | `google-drive`، `local` (پوشه‌ای روی دیسک)، `template` (هر API ذخیره‌سازی HTTP) یا `s3` (ذخیره‌ساز سازگار با S3). نگارش قدیمی `Google Drive` همچنان خوانده می‌شود. | | `remoteFolder` | — | پوشه‌ای که فایل‌های نشست در آن قرار می‌گیرند. | -| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. | +| `secrets` | — | Google Drive: دقیقاً سه مقدار ClientID، ClientSecret و RefreshToken به همین ترتیب. Template: مقادیری که با `{secret0}`، `{secret1}`، … خوانده می‌شوند. S3: ‏AccessKeyID، SecretAccessKey و در صورت نیاز SessionToken. | | `template` | — | فقط برای سرویس template: عملیات `auth`، `put`، `get`، `list` و `delete` در API ذخیره‌ساز. | +| `s3` | — | فقط برای سرویس s3: `endpoint` (یک URL از نوع http(s))، `region`، `bucket` و `pathStyle` (پیش‌فرض `true`؛ با `false` نام باکت در نام میزبان می‌آید). `remoteFolder` پیشوند اشیاء است. | | تنظیمات دقیق | پیش‌فرض هسته | `segmentBytes` (512 KiB)، `flushIntervalMs` (20)، `pollIntervalMs`/`maxPollIntervalMs` (50/500)، `eagerWindowMs` (2000)، `holeTimeoutMs` (30000)، `sessionTtlSeconds` (300)، `concurrency` (8). | **آدرس/پورت Front** اختیاری (کلیدهای `address`/`port` در سطح stream) یک domain front diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index ee44e7df3..04b13642d 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -135,10 +135,11 @@ Mux. Панель предлагает XDRIVE только для VLESS, не с | Поле | По умолчанию | Значение | | -------------- | -------------- | -------- | -| `service` | `google-drive` | `google-drive`, `local` (папка на диске) или `template` (любой HTTP API хранилища). Старое написание `Google Drive` по-прежнему читается. | +| `service` | `google-drive` | `google-drive`, `local` (папка на диске), `template` (любой HTTP API хранилища) или `s3` (S3-совместимое хранилище). Старое написание `Google Drive` по-прежнему читается. | | `remoteFolder` | — | Папка с файлами сессий. | -| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. | +| `secrets` | — | Google Drive: ровно три значения — ClientID, ClientSecret, RefreshToken, в этом порядке. Template: значения, доступные как `{secret0}`, `{secret1}`, …. S3: AccessKeyID, SecretAccessKey и при необходимости SessionToken. | | `template` | — | Только для template: операции `auth`, `put`, `get`, `list` и `delete` API хранилища. | +| `s3` | — | Только для s3: `endpoint` (URL http(s)), `region`, `bucket` и `pathStyle` (по умолчанию `true`; `false` переносит бакет в имя хоста). `remoteFolder` — префикс объектов. | | тонкая настройка | значения ядра | `segmentBytes` (512 KiB), `flushIntervalMs` (20), `pollIntervalMs`/`maxPollIntervalMs` (50/500), `eagerWindowMs` (2000), `holeTimeoutMs` (30000), `sessionTtlSeconds` (300), `concurrency` (8). | Необязательные **адрес/порт фронтинга** (`address`/`port` на уровне stream) — домен-фронт, diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index e6e6562a2..b9634185a 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -124,10 +124,11 @@ XDRIVE 把数据流作为文件存放在双方都能访问的文件夹中:服 | 字段 | 默认值 | 含义 | | -------------- | -------------- | ---- | -| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)或 `template`(任意 HTTP 存储 API)。旧写法 `Google Drive` 仍可读取。 | +| `service` | `google-drive` | `google-drive`、`local`(磁盘上的文件夹)、`template`(任意 HTTP 存储 API)或 `s3`(S3 兼容存储)。旧写法 `Google Drive` 仍可读取。 | | `remoteFolder` | — | 存放会话文件的文件夹。 | -| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。 | +| `secrets` | — | Google Drive:恰好三项,依次为 ClientID、ClientSecret、RefreshToken。Template:以 `{secret0}`、`{secret1}`… 引用的值。S3:AccessKeyID、SecretAccessKey,以及可选的 SessionToken。 | | `template` | — | 仅用于 template 服务:存储 API 的 `auth`、`put`、`get`、`list` 和 `delete` 操作。 | +| `s3` | — | 仅用于 s3 服务:`endpoint`(http(s) URL)、`region`、`bucket` 和 `pathStyle`(默认 `true`;为 `false` 时存储桶名放在主机名中)。`remoteFolder` 为对象前缀。 | | 调优参数 | 内核默认值 | `segmentBytes`(512 KiB)、`flushIntervalMs`(20)、`pollIntervalMs`/`maxPollIntervalMs`(50/500)、`eagerWindowMs`(2000)、`holeTimeoutMs`(30000)、`sessionTtlSeconds`(300)、`concurrency`(8)。 | 可选的**前置地址/端口**(stream 级的 `address`/`port`)是连接存储 API 时使用的域前置;TLS 中仍使用 API 主机名。 diff --git a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx index 20b84cdab..dc4020543 100644 --- a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx +++ b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Input, InputNumber, Select, Typography } from 'antd'; +import { Input, InputNumber, Select, Switch, Typography } from 'antd'; import { useFormContext, useWatch } from 'react-hook-form'; import { JsonEditor } from '@/components/form'; @@ -13,7 +13,7 @@ const SERVICE_LABEL_KEYS: Partial> = { local: 'pages.inbounds.form.xdrive.serviceLocal', template: 'pages.inbounds.form.xdrive.serviceTemplate', }; -const SERVICE_NAMES: Partial> = { 'google-drive': 'Google Drive' }; +const SERVICE_NAMES: Partial> = { 'google-drive': 'Google Drive', s3: 'S3' }; // Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an // empty field keeps meaning "use the core's value". @@ -29,6 +29,11 @@ const TUNING_FIELDS = [ ] as const; const GOOGLE_DRIVE_SECRETS = ['clientId', 'clientSecret', 'refreshToken'] as const; +const S3_SECRETS = [ + ['accessKeyId', true], + ['secretAccessKey', true], + ['sessionToken', false], +] as const; function TemplateEditor({ value, @@ -90,7 +95,11 @@ export default function XDriveForm() { name={[...BASE, 'remoteFolder']} required > - + {service === 'google-drive' && GOOGLE_DRIVE_SECRETS.map((key, index) => ( @@ -103,6 +112,48 @@ export default function XDriveForm() { {key === 'clientId' ? : } ))} + {service === 's3' && ( + <> + {S3_SECRETS.map(([key, required], index) => ( + + {key === 'accessKeyId' ? : } + + ))} + + + + + + + + + + value !== false }} + > + + + + )} {service === 'template' && ( <> ; // xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias. @@ -22,10 +22,19 @@ export function liftLegacyXDriveService(stream: Record): void { const MillisSchema = z.number().int().min(0); +// S3-compatible storage (xray-core 26.10.31, #7231); an unset pathStyle means path-style addressing. +export const XDriveS3Schema = z.object({ + endpoint: z.string().optional(), + region: z.string().optional(), + bucket: z.string().optional(), + pathStyle: z.boolean().optional(), +}); + export const XDriveStreamSettingsSchema = z.object({ service: XDriveServiceInputSchema.default('google-drive'), remoteFolder: z.string().default(''), - // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken. + // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken; S3 takes + // AccessKeyID, SecretAccessKey and an optional SessionToken. secrets: z.array(z.string()).default([]), segmentBytes: z.number().int().min(0).optional(), flushIntervalMs: MillisSchema.optional(), @@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({ eagerWindowMs: MillisSchema.optional(), holeTimeoutMs: MillisSchema.optional(), template: z.record(z.string(), z.unknown()).optional(), + s3: XDriveS3Schema.optional(), // Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no // defaults are filled in here. A JSON subscription hands an inbound's value to clients. xmux: z.record(z.string(), z.unknown()).optional(), diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 29e19fed6..cb8d4bbd1 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -563,6 +563,69 @@ describe('OutboundFormModal XDRIVE service name', () => { }); }); +// xray-core 26.10.31 (#7231): every S3 key needs a registered field or the editor drops it on save. +describe('OutboundFormModal XDRIVE S3 storage', () => { + it('opens and saves a stored S3 outbound unchanged', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const xdriveSettings = { + service: 's3', + remoteFolder: 'xdrive', + secrets: ['AKID', 'SECRET', 'TOKEN'], + s3: { + endpoint: 'https://storage.yandexcloud.net', + region: 'ru-central1', + bucket: 'example-bucket', + pathStyle: false, + }, + }; + const outbound = { + protocol: 'vless', + tag: 's3-out', + settings: { + address: 'example.com', + port: 443, + id: 'c9f0c2d0-0000-4000-8000-000000000000', + encryption: 'mlkem768x25519plus.native.0rtt.key', + }, + mux: { enabled: true, concurrency: 8 }, + streamSettings: { network: 'xdrive', security: 'none', xdriveSettings }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect((screen.getByLabelText('Bucket') as HTMLInputElement).value).toBe('example-bucket'); + expect((screen.getByLabelText('Access key ID') as HTMLInputElement).value).toBe('AKID'); + 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.xdriveSettings).toMatchObject(xdriveSettings); + }); +}); + // 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', () => { diff --git a/frontend/src/test/stream.test.ts b/frontend/src/test/stream.test.ts index 5be9db518..c61b4efd0 100644 --- a/frontend/src/test/stream.test.ts +++ b/frontend/src/test/stream.test.ts @@ -56,6 +56,17 @@ describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => { expect(parsed.xdriveSettings.service).toBe('google-drive'); }); + // xray-core 26.10.31 (#7231): the "s3" service reads its storage from an "s3" object. + it('keeps the XDRIVE s3 service and its storage', () => { + const s3 = { endpoint: 'https://s3.example', region: 'r', bucket: 'b', pathStyle: false }; + const parsed = NetworkSettingsSchema.parse({ + network: 'xdrive', + xdriveSettings: { service: 's3', remoteFolder: 'x', secrets: ['AKID', 'SECRET'], s3 }, + }) as { xdriveSettings: { service: string; s3?: unknown } }; + expect(parsed.xdriveSettings.service).toBe('s3'); + expect(parsed.xdriveSettings.s3).toEqual(s3); + }); + it('keeps the XDRIVE and MASQUE xmux', () => { const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' }; const xdrive = NetworkSettingsSchema.parse({ diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 447bee557..faf799701 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1028,18 +1028,21 @@ const ( // 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"` + Network string `json:"network"` + XDriveSettings json.RawMessage `json:"xdriveSettings"` } if json.Unmarshal([]byte(streamSettings), &stream) != nil || stream.Network != "xdrive" { return nil } - xd := stream.XDriveSettings + var xd struct { + Service string `json:"service"` + RemoteFolder string `json:"remoteFolder"` + Secrets []string `json:"secrets"` + Template json.RawMessage `json:"template"` + } + if len(stream.XDriveSettings) > 0 && json.Unmarshal(stream.XDriveSettings, &xd) != nil { + return common.NewError("XDRIVE settings are not a valid object.") + } hasFolder := strings.TrimSpace(xd.RemoteFolder) != "" switch xd.Service { case "local": @@ -1058,8 +1061,12 @@ func validateXDriveStorage(streamSettings string) error { return common.NewError(`XDRIVE service "template" needs a "template" object describing the storage API.`) } return validateXDriveTemplate(xd.Template) + case "s3": + if err := xray.ValidateXDriveS3(stream.XDriveSettings); err != nil { + return common.NewError("XDRIVE ", err) + } default: - return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, google-drive or template.", xd.Service)) + return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, google-drive, template or s3.", xd.Service)) } return nil } diff --git a/internal/web/service/inbound_push_guard.go b/internal/web/service/inbound_push_guard.go index 2e6f898f7..16dc185ab 100644 --- a/internal/web/service/inbound_push_guard.go +++ b/internal/web/service/inbound_push_guard.go @@ -150,7 +150,7 @@ func hostReaches(inbound *model.Inbound) ([]pushReach, error) { object("hysteria masquerade", "hysteriasettings", "masquerade") case leaf.endsWith("masterkeylog") && lower != "" && lower != "none": add("masterKeyLog", leaf) - case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template"): + case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template" || lower == "s3"): object("xdrive service", "xdrivesettings") case leaf.endsWith("echconfiglist") && strings.Contains(lower, "://"): add("echConfigList", leaf) diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 978a73d34..6dc8f9300 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -252,6 +252,10 @@ func TestAddInboundRefusesPushedFieldsThatReachThisHost(t *testing.T) { t.Errorf("%s: refused: %v", name, err) } } + s3 := xdriveInbound(44404, `{"service":"s3","remoteFolder":"x","secrets":["AKID","SECRET"],"s3":{"endpoint":"http://127.0.0.1:2053","region":"r","bucket":"b"}}`) + if _, _, err := push.AddInbound(s3); err == nil || !strings.Contains(err.Error(), "may not set xdrive service") { + t.Fatalf("pushed S3 storage on this host's own port = %v, want refused by the push guard", err) + } if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{Enable: true, Listen: "env:XUI_DB_DSN", Port: 44403, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err == nil || !strings.Contains(err.Error(), "env:") { t.Fatalf("env: listen = %v, want refused for every caller", err) } diff --git a/internal/web/service/inbound_xdrive_test.go b/internal/web/service/inbound_xdrive_test.go index a580315ca..60f7116c8 100644 --- a/internal/web/service/inbound_xdrive_test.go +++ b/internal/web/service/inbound_xdrive_test.go @@ -140,6 +140,10 @@ func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) { {"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"}, + {"s3 without an s3 object", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"]}`, `service "s3" needs an "s3" object`}, + {"s3 endpoint that is not http", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"ftp://s3.example","region":"r","bucket":"b"}}`, "S3 endpoint must be an http(s) URL"}, + {"s3 with one secret", `{"service":"s3","remoteFolder":"f","secrets":["key"],"s3":{"endpoint":"https://s3.example","region":"r","bucket":"b"}}`, "S3 needs AccessKeyID, SecretAccessKey"}, + {"s3 virtual-hosted on an IP endpoint", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"https://10.0.0.1","region":"r","bucket":"b","pathStyle":false}}`, "requires a DNS endpoint"}, {"unknown service", `{"service":"Dropbox","remoteFolder":"f"}`, `unsupported XDRIVE service "Dropbox"`}, } for i, tc := range tests { @@ -161,6 +165,14 @@ func TestAddInboundAcceptsACompleteXDriveTemplate(t *testing.T) { } } +func TestAddInboundAcceptsACompleteXDriveS3Storage(t *testing.T) { + setupBulkDB(t) + s3 := `{"service":"s3","remoteFolder":"xdrive","secrets":["AKID","SECRET"],"s3":{"endpoint":"https://storage.yandexcloud.net","region":"ru-central1","bucket":"example-bucket"}}` + if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18481, s3)); err != nil { + t.Fatalf("AddInbound of an S3 storage xray-core can open: %v", err) + } +} + func TestSavePathsRefuseXDriveWithoutStorage(t *testing.T) { const want = `XDRIVE needs a "remoteFolder"` const empty = `{"service":"local","remoteFolder":""}` diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index c89343738..47f90a788 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "معرّف مفتاح الوصول", + "secretAccessKey": "مفتاح الوصول السري", + "sessionToken": "رمز الجلسة", + "s3Endpoint": "نقطة النهاية", + "s3Region": "المنطقة", + "s3Bucket": "الحاوية (Bucket)", + "s3PathStyle": "عنونة بنمط المسار", + "s3PathStyleDesc": "مفعّل: اسم الحاوية بيتحط في مسار الرابط. مقفول: بيتحط في اسم المضيف، وده محتاج endpoint باسم DNS.", "secrets": "الأسرار", "secretsDesc": "قيم يقرؤها القالب باسم {secret0} و{secret1} …", "template": "قالب التخزين", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6578a9292..27f14cf38 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -738,6 +738,14 @@ "clientId": "Client ID", "clientSecret": "Client secret", "refreshToken": "Refresh token", + "accessKeyId": "Access key ID", + "secretAccessKey": "Secret access key", + "sessionToken": "Session token", + "s3Endpoint": "Endpoint", + "s3Region": "Region", + "s3Bucket": "Bucket", + "s3PathStyle": "Path-style addressing", + "s3PathStyleDesc": "On: the bucket goes in the URL path. Off: in the host name, which needs a DNS endpoint.", "secrets": "Secrets", "secretsDesc": "Values the template reads as {secret0}, {secret1}, …", "template": "Storage template", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 6fb602d31..a933ce3ae 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID de clave de acceso", + "secretAccessKey": "Clave de acceso secreta", + "sessionToken": "Token de sesión", + "s3Endpoint": "Endpoint", + "s3Region": "Región", + "s3Bucket": "Bucket", + "s3PathStyle": "Direccionamiento por ruta", + "s3PathStyleDesc": "Activado: el bucket va en la ruta de la URL. Desactivado: en el nombre de host, lo que requiere un endpoint DNS.", "secrets": "Secretos", "secretsDesc": "Valores que la plantilla lee como {secret0}, {secret1}, …", "template": "Plantilla de almacenamiento", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index f01c53540..a04d621d7 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "شناسه کلید دسترسی", + "secretAccessKey": "کلید دسترسی محرمانه", + "sessionToken": "توکن نشست", + "s3Endpoint": "نقطه پایانی (Endpoint)", + "s3Region": "منطقه (Region)", + "s3Bucket": "باکت (Bucket)", + "s3PathStyle": "آدرس‌دهی مسیرمحور", + "s3PathStyleDesc": "روشن: نام باکت در مسیر URL قرار می‌گیرد. خاموش: در نام میزبان، که به یک endpoint دامنه‌ای نیاز دارد.", "secrets": "مقادیر محرمانه", "secretsDesc": "مقادیری که قالب با {secret0}، {secret1}، … می‌خواند", "template": "قالب ذخیره‌سازی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index f0b8ac109..45d730e1d 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID kunci akses", + "secretAccessKey": "Kunci akses rahasia", + "sessionToken": "Token sesi", + "s3Endpoint": "Endpoint", + "s3Region": "Region", + "s3Bucket": "Bucket", + "s3PathStyle": "Pengalamatan gaya path", + "s3PathStyleDesc": "Aktif: bucket ditaruh di path URL. Nonaktif: di nama host, yang membutuhkan endpoint DNS.", "secrets": "Rahasia", "secretsDesc": "Nilai yang dibaca template sebagai {secret0}, {secret1}, …", "template": "Template penyimpanan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 0bfeeb5be..28f018b7e 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -737,6 +737,14 @@ "clientId": "クライアント ID", "clientSecret": "クライアントシークレット", "refreshToken": "リフレッシュトークン", + "accessKeyId": "アクセスキー ID", + "secretAccessKey": "シークレットアクセスキー", + "sessionToken": "セッショントークン", + "s3Endpoint": "エンドポイント", + "s3Region": "リージョン", + "s3Bucket": "バケット", + "s3PathStyle": "パス形式のアドレス指定", + "s3PathStyleDesc": "オン:バケットを URL のパスに入れます。オフ:ホスト名に入れます(DNS 名のエンドポイントが必要)。", "secrets": "シークレット", "secretsDesc": "テンプレートで {secret0}、{secret1}、… として参照される値", "template": "ストレージテンプレート", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 01ff63735..58296ab40 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID da chave de acesso", + "secretAccessKey": "Chave de acesso secreta", + "sessionToken": "Token de sessão", + "s3Endpoint": "Endpoint", + "s3Region": "Região", + "s3Bucket": "Bucket", + "s3PathStyle": "Endereçamento por caminho", + "s3PathStyleDesc": "Ligado: o bucket vai no caminho da URL. Desligado: no nome do host, o que exige um endpoint DNS.", "secrets": "Segredos", "secretsDesc": "Valores que o template lê como {secret0}, {secret1}, …", "template": "Template de armazenamento", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index eba1e20e4..c0ff714ca 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -738,6 +738,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID ключа доступа", + "secretAccessKey": "Секретный ключ доступа", + "sessionToken": "Токен сессии", + "s3Endpoint": "Endpoint", + "s3Region": "Регион", + "s3Bucket": "Бакет", + "s3PathStyle": "Адресация через путь", + "s3PathStyleDesc": "Вкл.: бакет указывается в пути URL. Выкл.: в имени хоста, для чего нужен DNS-endpoint.", "secrets": "Секреты", "secretsDesc": "Значения, которые шаблон читает как {secret0}, {secret1}, …", "template": "Шаблон хранилища", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 95449f105..19b9a472a 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -738,6 +738,14 @@ "clientId": "ID klienta", "clientSecret": "Tajný kľúč klienta", "refreshToken": "Obnovovací token", + "accessKeyId": "ID prístupového kľúča", + "secretAccessKey": "Tajný prístupový kľúč", + "sessionToken": "Token relácie", + "s3Endpoint": "Endpoint", + "s3Region": "Región", + "s3Bucket": "Bucket", + "s3PathStyle": "Adresovanie cez cestu", + "s3PathStyleDesc": "Zapnuté: bucket je v ceste URL. Vypnuté: v názve hostiteľa, čo vyžaduje DNS endpoint.", "secrets": "Tajné hodnoty", "secretsDesc": "Hodnoty, ktoré šablóna číta ako {secret0}, {secret1}, …", "template": "Šablóna úložiska", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index adc69e513..56500bc01 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "Erişim anahtarı kimliği", + "secretAccessKey": "Gizli erişim anahtarı", + "sessionToken": "Oturum belirteci", + "s3Endpoint": "Uç nokta", + "s3Region": "Bölge", + "s3Bucket": "Bucket", + "s3PathStyle": "Yol tarzı adresleme", + "s3PathStyleDesc": "Açık: bucket URL yoluna yazılır. Kapalı: ana bilgisayar adına yazılır, bu da DNS uç noktası gerektirir.", "secrets": "Gizli değerler", "secretsDesc": "Şablonun {secret0}, {secret1}, … olarak okuduğu değerler", "template": "Depolama şablonu", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index cc824b81d..15c1e011b 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID ключа доступу", + "secretAccessKey": "Секретний ключ доступу", + "sessionToken": "Токен сесії", + "s3Endpoint": "Endpoint", + "s3Region": "Регіон", + "s3Bucket": "Бакет", + "s3PathStyle": "Адресація через шлях", + "s3PathStyleDesc": "Увімк.: бакет вказується в шляху URL. Вимк.: в імені хоста, для чого потрібен DNS-endpoint.", "secrets": "Секрети", "secretsDesc": "Значення, які шаблон читає як {secret0}, {secret1}, …", "template": "Шаблон сховища", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 25e17db14..18aa3db53 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID khóa truy cập", + "secretAccessKey": "Khóa truy cập bí mật", + "sessionToken": "Token phiên", + "s3Endpoint": "Endpoint", + "s3Region": "Vùng", + "s3Bucket": "Bucket", + "s3PathStyle": "Định địa chỉ kiểu đường dẫn", + "s3PathStyleDesc": "Bật: bucket nằm trong đường dẫn URL. Tắt: nằm trong tên máy chủ, cần endpoint dạng DNS.", "secrets": "Giá trị bí mật", "secretsDesc": "Các giá trị template đọc dưới dạng {secret0}, {secret1}, …", "template": "Template lưu trữ", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 2684cda56..9d760b324 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -737,6 +737,14 @@ "clientId": "客户端 ID", "clientSecret": "客户端密钥", "refreshToken": "刷新令牌", + "accessKeyId": "访问密钥 ID", + "secretAccessKey": "秘密访问密钥", + "sessionToken": "会话令牌", + "s3Endpoint": "端点", + "s3Region": "区域", + "s3Bucket": "存储桶", + "s3PathStyle": "路径风格寻址", + "s3PathStyleDesc": "开启:存储桶名放在 URL 路径中。关闭:放在主机名中,需要使用域名端点。", "secrets": "密钥", "secretsDesc": "模板中以 {secret0}、{secret1}… 引用的值", "template": "存储模板", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 0bcc9bea3..71e993a22 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -737,6 +737,14 @@ "clientId": "用戶端 ID", "clientSecret": "用戶端密鑰", "refreshToken": "重新整理權杖", + "accessKeyId": "存取金鑰 ID", + "secretAccessKey": "秘密存取金鑰", + "sessionToken": "工作階段權杖", + "s3Endpoint": "端點", + "s3Region": "區域", + "s3Bucket": "儲存貯體", + "s3PathStyle": "路徑樣式定址", + "s3PathStyleDesc": "開啟:儲存貯體名稱放在 URL 路徑中。關閉:放在主機名稱中,需要使用網域端點。", "secrets": "密鑰", "secretsDesc": "範本中以 {secret0}、{secret1}… 引用的值", "template": "儲存範本", diff --git a/internal/xray/api.go b/internal/xray/api.go index a03aac962..81555fdc7 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -237,6 +237,17 @@ func ValidateTunWfpBlockLeak(values []string) error { return err } +// ValidateXDriveS3 builds an "s3" xdriveSettings object through the vendored loader, which runs +// the endpoint, bucket and secret checks xray-core 26.10.31 (#7231) applies to the S3 backend. +func ValidateXDriveS3(xdriveSettings []byte) error { + c := new(conf.XDriveConfig) + if err := json.Unmarshal(xdriveSettings, c); err != nil { + return err + } + _, err := c.Build() + return err +} + // validateXhttpDownloadSettings refuses what conf.Build() lets through but the core's XHTTP // dialer panics on at every dial: a downloadSettings block without an address or not on xhttp. func validateXhttpDownloadSettings(settings *serial.TypedMessage) error {