mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-13 01:14:59 +03:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
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<string, unknown>;
|
||||
onChange?: (next: Record<string, unknown> | undefined) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState(() => (value ? JSON.stringify(value, null, 2) : ''));
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<JsonEditor
|
||||
value={text}
|
||||
minHeight="160px"
|
||||
onChange={(next) => {
|
||||
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<string, unknown>);
|
||||
} catch {
|
||||
setInvalid(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{invalid && (
|
||||
<Typography.Text type="danger">
|
||||
{t('pages.inbounds.form.xdrive.templateInvalid')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
|
||||
<Select options={serviceOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.remoteFolder')}
|
||||
name={[...BASE, 'remoteFolder']}
|
||||
required
|
||||
>
|
||||
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
|
||||
</FormField>
|
||||
{service === 'Google Drive' &&
|
||||
GOOGLE_DRIVE_SECRETS.map((key, index) => (
|
||||
<FormField
|
||||
key={key}
|
||||
label={t(`pages.inbounds.form.xdrive.${key}`)}
|
||||
name={[...BASE, 'secrets', index]}
|
||||
required
|
||||
>
|
||||
{key === 'clientId' ? <Input /> : <Input.Password />}
|
||||
</FormField>
|
||||
))}
|
||||
{service === 'template' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.secrets')}
|
||||
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
|
||||
name={[...BASE, 'secrets']}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} open={false} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.template')}
|
||||
name={[...BASE, 'template']}
|
||||
required
|
||||
>
|
||||
<TemplateEditor />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{service !== 'local' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.front')}
|
||||
tooltip={t('pages.inbounds.form.xdrive.frontDesc')}
|
||||
name={['streamSettings', 'address']}
|
||||
transform={{ output: (raw) => String(raw ?? '').trim() || undefined }}
|
||||
>
|
||||
<Input placeholder="www.googleapis.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.frontPort')}
|
||||
name={['streamSettings', 'port']}
|
||||
>
|
||||
<InputNumber min={1} max={65535} placeholder="443" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{TUNING_FIELDS.map(([key, label, placeholder]) => (
|
||||
<FormField key={key} label={t(`pages.inbounds.form.xdrive.${label}`)} name={[...BASE, key]}>
|
||||
<InputNumber min={0} placeholder={String(placeholder)} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>) ?? {};
|
||||
const cleaned: Record<string, unknown> = { ...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<string, unknown> | 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' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -941,6 +956,8 @@ export default function InboundFormModal({
|
||||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
||||
|
||||
{network === 'kcp' && <KcpForm />}
|
||||
|
||||
{network === 'xdrive' && <XDriveForm />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
|
||||
|
||||
{network === 'hysteria' && <HysteriaForm />}
|
||||
|
||||
{network === 'xdrive' && <XDriveForm />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
udpIdleTimeout: 60,
|
||||
},
|
||||
};
|
||||
case 'xdrive':
|
||||
return { network: 'xdrive', xdriveSettings: XDriveStreamSettingsSchema.parse({}) };
|
||||
default:
|
||||
return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
|
||||
}
|
||||
|
||||
@@ -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<typeof NetworkSchema>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof XDriveServiceSchema>;
|
||||
|
||||
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<typeof XDriveStreamSettingsSchema>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> | 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) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<OutboundFormModal
|
||||
open={open}
|
||||
outbound={outbound}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(tree(false));
|
||||
rerender(tree(true));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
chooseOptionByLabel('Transmission', 'XDRIVE');
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
for (const [label, value] of [
|
||||
['Remote folder', 'xray-tunnel'],
|
||||
['Client ID', 'id.apps.googleusercontent.com'],
|
||||
['Client secret', 'client-secret'],
|
||||
['Refresh token', 'refresh-token'],
|
||||
]) {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
}
|
||||
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
|
||||
await act(async () => {
|
||||
fireEvent.click(ok);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record<string, unknown> };
|
||||
expect(payload.streamSettings).toMatchObject({
|
||||
network: 'xdrive',
|
||||
xdriveSettings: {
|
||||
service: 'Google Drive',
|
||||
remoteFolder: 'xray-tunnel',
|
||||
secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user