mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
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.
This commit is contained in:
@@ -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<Record<string, string>> = {
|
||||
local: 'pages.inbounds.form.xdrive.serviceLocal',
|
||||
template: 'pages.inbounds.form.xdrive.serviceTemplate',
|
||||
};
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
|
||||
const SERVICE_NAMES: Partial<Record<string, string>> = { '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
|
||||
>
|
||||
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
|
||||
<Input
|
||||
placeholder={
|
||||
service === 'local' ? '/var/lib/xdrive' : service === 's3' ? 'xdrive' : 'xray-tunnel'
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
{service === 'google-drive' &&
|
||||
GOOGLE_DRIVE_SECRETS.map((key, index) => (
|
||||
@@ -103,6 +112,48 @@ export default function XDriveForm() {
|
||||
{key === 'clientId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
{service === 's3' && (
|
||||
<>
|
||||
{S3_SECRETS.map(([key, required], index) => (
|
||||
<FormField
|
||||
key={key}
|
||||
label={t(`pages.inbounds.form.xdrive.${key}`)}
|
||||
name={[...BASE, 'secrets', index]}
|
||||
required={required}
|
||||
>
|
||||
{key === 'accessKeyId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Endpoint')}
|
||||
name={[...BASE, 's3', 'endpoint']}
|
||||
required
|
||||
>
|
||||
<Input placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Region')}
|
||||
name={[...BASE, 's3', 'region']}
|
||||
>
|
||||
<Input placeholder="us-east-1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3Bucket')}
|
||||
name={[...BASE, 's3', 'bucket']}
|
||||
required
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.s3PathStyle')}
|
||||
tooltip={t('pages.inbounds.form.xdrive.s3PathStyleDesc')}
|
||||
name={[...BASE, 's3', 'pathStyle']}
|
||||
transform={{ input: (value) => value !== false }}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{service === 'template' && (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
// Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes
|
||||
// the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64).
|
||||
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']);
|
||||
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template', 's3']);
|
||||
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
|
||||
|
||||
// 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<string, unknown>): 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(),
|
||||
|
||||
@@ -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) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<OutboundFormModal
|
||||
open={open}
|
||||
outbound={outbound}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(tree(false));
|
||||
rerender(tree(true));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
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<string, unknown> };
|
||||
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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user