fix(xray): keep XDRIVE storage and Vision flow within what xray-core runs

xray-core opens an XDRIVE stream's storage only when the inbound starts
listening, so a Google Drive inbound saved with the form's defaults (no
folder, no secrets), a local one without a folder, or a template one
whose template xray cannot use stopped xray and every other inbound with
it. validateXDriveStorage applies the core's storage rules at save, on
inbound add/update and on the xray template's inbounds and outbounds:
the service must be local, Google Drive or template; local and Google
Drive need a remoteFolder; Google Drive needs its three non-empty
secrets; a template must pass newTemplateStorage's checks (put, get,
list and delete urls, and a list namesRegex with one capture group).

A VLESS client kept its xtls-rprx-vision flow when its inbound moved to
a transport that cannot carry it, and GetXrayConfig only honoured
DisableFlow, so the server demanded Vision there and refused every
client. GetXrayConfig and the outbound form now apply the Vision gate,
and that gate now follows the core in the backend, the subscription
service and the frontend: TLS/REALITY on raw TCP, or VLESS encryption on
any transport (xray runs Vision inside encryption.CommonConn whatever
the transport, not only on XHTTP). The outbound modal passes the VLESS
encryption into the gate and gates Mux on the effective flow.
This commit is contained in:
MHSanaei
2026-10-12 00:49:11 +02:00
parent 8fe7be943f
commit c5e1e54c75
22 changed files with 439 additions and 72 deletions
+2 -4
View File
@@ -503,10 +503,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
params.set('security', 'none');
}
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
// vlessFlowAllowed and the form's flow-field gating so panel link, share
// link and subscription agree.
// Same gate as the backend's vlessFlowAllowed and the form's flow field, so the
// panel link, share link and subscription agree.
if (
flow.length > 0 &&
canEnableTlsFlow({
+19 -2
View File
@@ -1,6 +1,7 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
import { canEnableTlsFlow } from '@/lib/xray/protocol-capabilities';
import {
dropEmptyFinalMask,
normalizeStreamSettingsForWire,
@@ -902,10 +903,23 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
}
// The flow select only hides off a Vision-capable transport; its stale value must not reach xray.
export function effectiveVlessFlow(
settings: Pick<VlessOutboundFormSettings, 'flow' | 'encryption'>,
stream: { network?: string; security?: string } | undefined,
): string {
const allowed = canEnableTlsFlow({
protocol: 'vless',
settings: { encryption: settings.encryption },
streamSettings: { network: stream?.network, security: stream?.security },
});
return allowed ? settings.flow : '';
}
function muxAllowed(values: OutboundFormValues): boolean {
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
const flow =
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
values.protocol === 'vless' ? effectiveVlessFlow(values.settings, values.streamSettings) : '';
if (flow) return false;
const network =
values.streamSettings && 'network' in values.streamSettings
@@ -924,7 +938,10 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
settings = vmessToWire(values.settings);
break;
case 'vless':
settings = vlessToWire(values.settings);
settings = vlessToWire({
...values.settings,
flow: effectiveVlessFlow(values.settings, values.streamSettings),
});
break;
case 'trojan':
settings = trojanToWire(values.settings);
@@ -74,10 +74,8 @@ export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
// Classic XTLS Vision: raw TCP carried over TLS or REALITY.
if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true;
// vlessenc carries Vision over XHTTP without transport TLS.
if (network === 'xhttp' && hasVlessEncryption(values.settings)) return true;
return false;
// xray-core runs Vision inside VLESS encryption (encryption.CommonConn) on any transport.
return hasVlessEncryption(values.settings);
}
export function canEnableStream(values: { protocol: string }): boolean {
@@ -104,7 +104,11 @@ export default function OutboundFormModal({
'') as string;
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
'none') as string;
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
const storedFlow = (useWatch({ control: methods.control, name: 'settings.flow' }) ??
'') as string;
const encryption = useWatch({ control: methods.control, name: 'settings.encryption' }) as
| string
| undefined;
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
| string
@@ -113,7 +117,12 @@ export default function OutboundFormModal({
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({
protocol,
settings: { encryption },
streamSettings: { network, security },
});
const flow = tlsFlowAllowed ? storedFlow : '';
/*
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
@@ -527,7 +536,7 @@ export default function OutboundFormModal({
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). */}
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
{flow === 'xtls-rprx-vision' && (
<>
<FormField
label={t('pages.xray.outboundForm.visionTestpre')}
@@ -590,7 +599,7 @@ export default function OutboundFormModal({
)}
/>
<MuxForm protocol={protocol} network={network} />
<MuxForm protocol={protocol} network={network} flow={flow} />
</>
),
},
@@ -9,12 +9,12 @@ import { isMuxAllowed } from '../outbound-form-helpers';
interface MuxFormProps {
protocol: string;
network: string;
flow?: string;
}
export default function MuxForm({ protocol, network }: MuxFormProps) {
export default function MuxForm({ protocol, network, flow = '' }: MuxFormProps) {
const { t } = useTranslation();
const { control } = useFormContext();
const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
if (!isMuxAllowed(protocol, flow, network)) return null;
return (
+12 -3
View File
@@ -226,12 +226,21 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
streamSettings: { network: 'xhttp', security: 'none' },
}),
).toBe(false);
// vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
// xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included.
for (const network of ['tcp', 'ws', 'grpc', 'xdrive']) {
expect(
canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: enc },
streamSettings: { network, security: 'none' },
}),
).toBe(true);
}
expect(
canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: enc },
streamSettings: { network: 'tcp', security: 'none' },
settings: { encryption: 'none' },
streamSettings: { network: 'ws', security: 'tls' },
}),
).toBe(false);
});
@@ -58,6 +58,7 @@ describe('outbound-form-adapter: round-trip', () => {
flow: 'xtls-rprx-vision',
encryption: 'none',
},
streamSettings: { network: 'tcp', security: 'reality' },
};
const form = rawOutboundToFormValues(wire);
expect(form.protocol).toBe('vless');
@@ -119,6 +120,64 @@ describe('outbound-form-adapter: round-trip', () => {
expect(settings.reverse).toMatchObject({ tag: 'rev-1' });
});
// The flow select hides off TCP+TLS/REALITY but keeps its value; xray then fails every dial.
it('vless drops a Vision flow its transport cannot carry, and lets mux back on', () => {
const outbound = (streamSettings: Record<string, unknown>) =>
formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'vless',
settings: {
address: 'srv',
port: 443,
id: '11111111-2222-4333-8444-555555555555',
flow: 'xtls-rprx-vision',
encryption: 'none',
testpre: 5,
},
streamSettings,
mux: { enabled: true, concurrency: 8 },
}),
);
const xdrive = outbound({
network: 'xdrive',
security: 'none',
xdriveSettings: { service: 'local', remoteFolder: '/tmp/shared' },
});
expect(xdrive.settings).toMatchObject({ flow: '' });
expect(xdrive.settings).not.toHaveProperty('testpre');
expect(xdrive.mux).toMatchObject({ enabled: true });
const ws = outbound({ network: 'ws', security: 'tls', wsSettings: { path: '/' } });
expect(ws.settings).toMatchObject({ flow: '' });
const reality = outbound({
network: 'tcp',
security: 'reality',
realitySettings: { serverName: 'a.example', publicKey: 'k', fingerprint: 'chrome' },
});
expect(reality.settings).toMatchObject({ flow: 'xtls-rprx-vision', testpre: 5 });
expect(reality).not.toHaveProperty('mux');
});
// VLESS encryption carries Vision on any transport; stripping it gets the server's "client flow is empty".
it('vless keeps a Vision flow over VLESS encryption on RAW without TLS', () => {
const wire = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'vless',
settings: {
address: 'srv',
port: 443,
id: '11111111-2222-4333-8444-555555555555',
flow: 'xtls-rprx-vision',
encryption: 'mlkem768x25519plus.native.0rtt.cbEXq-_hfkxDUex6HqmSaemEELvnFWc8qQ1MxlNDHnE',
},
streamSettings: { network: 'tcp', security: 'none' },
}),
);
expect(wire.settings).toMatchObject({ flow: 'xtls-rprx-vision' });
});
it('vless does not emit testpre/testseed unless flow is vision', () => {
const wire = {
protocol: 'vless',
@@ -543,6 +602,7 @@ describe('outbound-form-adapter: round-trip', () => {
flow: 'xtls-rprx-vision',
encryption: 'none',
},
streamSettings: { network: 'tcp', security: 'reality' },
mux: { enabled: true },
}),
);
@@ -310,6 +310,60 @@ describe('OutboundFormModal', () => {
// 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('gates Flow and Mux on the flow the transport can actually carry', async () => {
const vless = (encryption: string, streamSettings: Record<string, unknown>) => ({
protocol: 'vless',
tag: 'flow-out',
settings: {
address: 'example.com',
port: 443,
id: 'c9f0c2d0-0000-4000-8000-000000000000',
flow: 'xtls-rprx-vision',
encryption,
},
streamSettings,
});
const openModal = async (outbound: Record<string, unknown>) => {
const queryClient = makeTestQueryClient();
const tree = (open: boolean) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={() => {}}
/>
</ThemeProvider>
</QueryClientProvider>
);
const view = render(tree(false));
view.rerender(tree(true));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
return view;
};
const ws = await openModal(
vless('none', { network: 'ws', security: 'tls', wsSettings: { path: '/' } }),
);
expect(formItemByLabel('Transmission')?.textContent).toContain('WebSocket');
expect(formItemByLabel('Flow')).toBeUndefined();
expect(formItemByLabel('Mux')).toBeDefined();
ws.unmount();
await openModal(
vless('mlkem768x25519plus.native.0rtt.key', {
network: 'xhttp',
security: 'none',
xhttpSettings: { path: '/', mode: 'auto' },
}),
);
expect(formItemByLabel('Flow')).toBeDefined();
});
it('saves an XDRIVE stream picked in the transmission selector', async () => {
const onConfirm = vi.fn();
const queryClient = makeTestQueryClient();