feat(warp): register WARP over MASQUE from the WARP modal

xray-core's masque transport reaches Cloudflare WARP with an enrolled
ECDSA P-256 key, the endpoint's public key and the assigned tunnel
addresses, which only a WARP enrollment hands out.

A new regMasque action on /panel/api/xray/warp/:action registers a
separate WARP device and enrolls a secp256r1 key for the masque tunnel
(the API revision and client version the WARP apps enroll MASQUE keys
with). Enrolling replaces a device's WireGuard key, so it never touches
the stored WireGuard registration. It returns the PEM key pair, the
addresses and the endpoint, and the WARP modal turns them into a
warp-masque outbound (HTTP/3, Cloudflare's MASQUE SNI), or refreshes
the one already there.
This commit is contained in:
MHSanaei
2026-10-11 10:31:01 +02:00
parent b04039c2bd
commit 04cccef311
25 changed files with 477 additions and 16 deletions
+1 -1
View File
@@ -2138,7 +2138,7 @@ export const sections: readonly Section[] = [
name: 'action',
in: 'path',
type: 'string',
desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). regMasque — register a separate device enrolled for MASQUE and return its warp key pair, addresses and endpoint. changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
},
{
name: 'privateKey',
@@ -93,6 +93,31 @@ export function buildWarpOutbound(
};
}
interface WarpMasqueRegistration {
privateKey: string;
publicKey: string;
address: string[];
endpoint: string;
}
// Cloudflare serves WARP over MASQUE on HTTP/3 behind this SNI; the enrolled key and
// endpoint key replace user/pass, which xray-core refuses next to a warp block.
export function buildWarpMasqueOutbound(reg: WarpMasqueRegistration): Record<string, unknown> {
return {
tag: 'warp-masque',
protocol: 'masque',
settings: { address: reg.endpoint, port: 443 },
streamSettings: {
network: 'masque',
security: 'tls',
tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] },
masqueSettings: {
warp: { privateKey: reg.privateKey, publicKey: reg.publicKey, address: reg.address },
},
},
};
}
export function mergeWarpRotation(
existing: Record<string, unknown> | undefined,
data: WarpData | null,
@@ -310,6 +335,21 @@ export default function WarpModal({
}
}
async function addMasqueOutbound() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/regMasque');
if (!msg?.success || !msg.obj) return;
const outbound = buildWarpMasqueOutbound(JSON.parse(msg.obj) as WarpMasqueRegistration);
const index = templateSettings?.outbounds?.findIndex((o) => o?.tag === 'warp-masque') ?? -1;
if (index >= 0) onResetOutbound({ index, outbound });
else onAddOutbound(outbound);
onClose();
} finally {
setLoading(false);
}
}
function addOutbound() {
if (!stagedOutbound) {
messageApi.warning(t('pages.xray.warp.fetchFirst'));
@@ -544,6 +584,18 @@ export default function WarpModal({
)}
</>
)}
<Divider className="my-10">MASQUE</Divider>
<Alert type="info" showIcon title={t('pages.xray.warp.masqueDesc')} />
<Button
type="primary"
className="mt-8"
loading={loading}
icon={<PlusOutlined />}
onClick={addMasqueOutbound}
>
{t('pages.xray.warp.addMasqueOutbound')}
</Button>
</FormProvider>
</Modal>
</>
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import WarpModal from '@/pages/xray/overrides/WarpModal';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
const registration = {
privateKey: '-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n',
publicKey: '-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----\n',
address: ['172.16.0.2', '2606:4700:110:8a36::2'],
endpoint: '162.159.198.1',
};
function mockApi() {
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/warp/regMasque') {
return new Msg(true, '', JSON.stringify(registration));
}
if (url === '/panel/api/setting/all') return new Msg(true, '', {});
return new Msg(true, '', '');
});
}
async function clickAddMasque() {
const button = await screen.findByRole('button', { name: /Add WARP over MASQUE outbound/ });
// The modal loads its WARP data on open; a loading button swallows the click.
await waitFor(() => expect(button.className).not.toContain('ant-btn-loading'));
await act(async () => {
fireEvent.click(button);
});
}
afterEach(() => {
vi.restoreAllMocks();
});
// WARP over MASQUE dials Cloudflare's MASQUE endpoint with the enrolled key, its SNI
// and HTTP/3; an outbound missing any of them fails the handshake at runtime.
describe('WarpModal WARP over MASQUE', () => {
const expected = {
tag: 'warp-masque',
protocol: 'masque',
settings: { address: '162.159.198.1', port: 443 },
streamSettings: {
network: 'masque',
security: 'tls',
tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] },
masqueSettings: {
warp: {
privateKey: registration.privateKey,
publicKey: registration.publicKey,
address: registration.address,
},
},
},
};
it('adds a MASQUE outbound built from a fresh enrollment', async () => {
mockApi();
const onAddOutbound = vi.fn();
renderWithProviders(
<WarpModal
open
templateSettings={{ outbounds: [{ tag: 'direct' }] }}
onClose={() => {}}
onAddOutbound={onAddOutbound}
onResetOutbound={() => {}}
onRemoveOutbound={() => {}}
/>,
);
await clickAddMasque();
await waitFor(() => expect(onAddOutbound).toHaveBeenCalledWith(expected));
});
it('replaces an existing warp-masque outbound instead of adding a second one', async () => {
mockApi();
const onAddOutbound = vi.fn();
const onResetOutbound = vi.fn();
renderWithProviders(
<WarpModal
open
templateSettings={{ outbounds: [{ tag: 'direct' }, { tag: 'warp-masque' }] }}
onClose={() => {}}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={() => {}}
/>,
);
await clickAddMasque();
await waitFor(() =>
expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }),
);
expect(onAddOutbound).not.toHaveBeenCalled();
});
});