feat(outbounds): TLS for XDRIVE outbounds

xray-core 26.10.10 dials an XDRIVE outbound's storage API with the
stream's tlsSettings: their SNI, uTLS fingerprint and ALPN are what the
API sees, which is the point of fronting it. canEnableTls kept XDRIVE out
of the TLS-capable networks, so the security picker never offered TLS and
switching a TLS outbound to XDRIVE silently reset it to none.

canEnableOutboundTls admits a TLS-eligible protocol on xdrive and backs
both the picker and the network-change cascade. Inbounds keep the plain
predicate: their tlsSettings carry server certificates, not a client hello.

Also restores the plain oxfmt / oxlint --fix lint-staged commands, reverting
da6872a3: that hook change was not wanted.
This commit is contained in:
MHSanaei
2026-10-11 15:44:17 +02:00
parent ef4bda4e06
commit c7d89ceac2
9 changed files with 99 additions and 9 deletions
@@ -43,6 +43,15 @@ export function canEnableTls(values: CapabilityProtocolSlice): boolean {
return TLS_NETWORKS.includes(values.streamSettings?.network ?? '');
}
// An XDRIVE outbound's tlsSettings shape only its HTTPS calls to the storage API (SNI, uTLS
// fingerprint); xray-core 26.10.10 reads them on the dial side, never on a listener.
export function canEnableOutboundTls(values: CapabilityProtocolSlice): boolean {
if (canEnableTls(values)) return true;
return (
TLS_ELIGIBLE_PROTOCOLS.includes(values.protocol) && values.streamSettings?.network === 'xdrive'
);
}
export function canEnableReality(values: CapabilityProtocolSlice): boolean {
if (!REALITY_ELIGIBLE_PROTOCOLS.includes(values.protocol)) return false;
return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
@@ -14,7 +14,7 @@ import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableOutboundTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
@@ -111,7 +111,7 @@ export default function OutboundFormModal({
| undefined;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
@@ -1,5 +1,5 @@
import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import { canEnableReality, canEnableTls } from '@/lib/xray/protocol-capabilities';
import { canEnableOutboundTls, canEnableReality } from '@/lib/xray/protocol-capabilities';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
@@ -112,7 +112,7 @@ export function applyNetworkChange(
if (next === 'masque') return masqueStreamSlice();
const stream = prevStream ?? {};
const currentSecurity = (stream.security as string) ?? 'none';
const stillTls = canEnableTls({
const stillTls = canEnableOutboundTls({
protocol,
streamSettings: { network: next, security: currentSecurity },
});
@@ -379,6 +379,80 @@ describe('OutboundFormModal', () => {
});
});
// 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', () => {
it('keeps TLS when a TLS outbound moves to XDRIVE', async () => {
const onConfirm = vi.fn();
const queryClient = makeTestQueryClient();
const outbound = {
protocol: 'vless',
tag: 'drive-out',
settings: {
vnext: [
{
address: 'www.google.com',
port: 443,
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
},
],
},
streamSettings: {
network: 'tcp',
security: 'tls',
tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' },
},
};
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));
});
expect((screen.getByRole('radio', { name: 'TLS' }) as HTMLInputElement).checked).toBe(true);
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',
security: 'tls',
tlsSettings: { serverName: 'www.google.com', fingerprint: 'chrome' },
});
});
});
describe('OutboundFormModal MASQUE', () => {
// xray-core refuses user/pass next to warp, and builds the WARP client certificate
// from privateKey; a save that kept stale credentials or dropped a key never connects.