| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- import { useCallback, useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Alert, Button, Collapse, Divider, Form, Input, message, Modal, Tag } from 'antd';
- import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
- import { FormProvider, useForm, useWatch } from 'react-hook-form';
- import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
- import { FormField } from '@/components/form/rhf';
- import './WarpModal.css';
- interface WarpModalProps {
- open: boolean;
- templateSettings: { outbounds?: { tag?: string }[] } | null;
- onClose: () => void;
- onAddOutbound: (outbound: Record<string, unknown>) => void;
- onResetOutbound: (payload: { index: number; outbound: Record<string, unknown> }) => void;
- onRemoveOutbound: (tag: string) => void;
- }
- interface WarpData {
- access_token?: string;
- device_id?: string;
- license_key?: string;
- private_key?: string;
- client_id?: string;
- }
- interface WarpConfig {
- name?: string;
- model?: string;
- enabled?: boolean;
- config?: {
- client_id?: string;
- interface?: { addresses?: { v4?: string; v6?: string } };
- peers?: { public_key?: string; endpoint?: { host?: string } }[];
- };
- account?: {
- account_type?: string;
- role?: string;
- premium_data?: number;
- quota?: number;
- usage?: number;
- };
- }
- interface WarpFormValues {
- warpPlus: string;
- updateInterval: number;
- }
- const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
- function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
- const out: string[] = [];
- if (addrs.v4) out.push(`${addrs.v4}/32`);
- if (addrs.v6) out.push(`${addrs.v6}/128`);
- return out;
- }
- function reservedFor(clientId?: string): number[] {
- if (!clientId) return [];
- const decoded = atob(clientId);
- const out: number[] = [];
- for (let i = 0; i < decoded.length; i += 1) out.push(decoded.charCodeAt(i));
- return out;
- }
- export function mergeWarpRotation(
- existing: Record<string, unknown> | undefined,
- data: WarpData | null,
- config: WarpConfig | null,
- ): Record<string, unknown> | null {
- const cfg = config?.config;
- const peer = cfg?.peers?.[0];
- if (!cfg || !peer) return null;
- const base: Record<string, unknown> =
- existing && typeof existing === 'object'
- ? { ...existing }
- : { tag: 'warp', protocol: 'wireguard' };
- const prevSettings =
- base.settings && typeof base.settings === 'object'
- ? { ...(base.settings as Record<string, unknown>) }
- : {};
- const prevPeers = Array.isArray(prevSettings.peers)
- ? [...(prevSettings.peers as Record<string, unknown>[])]
- : [];
- const prevFirstPeer =
- prevPeers[0] && typeof prevPeers[0] === 'object'
- ? { ...(prevPeers[0] as Record<string, unknown>) }
- : {};
- prevFirstPeer.publicKey = peer.public_key;
- prevFirstPeer.endpoint = peer.endpoint?.host;
- prevPeers[0] = prevFirstPeer;
- prevSettings.secretKey = data?.private_key;
- prevSettings.address = addressesFor(cfg.interface?.addresses || {});
- prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id);
- prevSettings.peers = prevPeers;
- base.settings = prevSettings;
- base.tag = 'warp';
- base.protocol = 'wireguard';
- return base;
- }
- export default function WarpModal({
- open,
- templateSettings,
- onClose,
- onAddOutbound,
- onResetOutbound,
- onRemoveOutbound,
- }: WarpModalProps) {
- const { t } = useTranslation();
- const [messageApi, messageContextHolder] = message.useMessage();
- const [loading, setLoading] = useState(false);
- const [warpData, setWarpData] = useState<WarpData | null>(null);
- const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
- const [licenseError, setLicenseError] = useState('');
- const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
- const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
- const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
- const warpOutboundIndex = useMemo(() => {
- const list = templateSettings?.outbounds;
- if (!list) return -1;
- return list.findIndex((o) => o?.tag === 'warp');
- }, [templateSettings?.outbounds]);
- const collectConfig = useCallback(
- (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
- const cfg = config?.config;
- if (!cfg?.peers?.length) return null;
- const peer = cfg.peers[0];
- const outbound: Record<string, unknown> = {
- tag: 'warp',
- protocol: 'wireguard',
- settings: {
- mtu: 1420,
- secretKey: data?.private_key,
- address: addressesFor(cfg.interface?.addresses || {}),
- reserved: reservedFor(cfg.client_id ?? data?.client_id),
- // Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
- // record for engage.cloudflareclient.com, and a host with
- // half-configured IPv6 then blackholes the handshake with no error
- // logged (#5205).
- domainStrategy: 'ForceIPv4v6',
- peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
- // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
- // fails silently on many VPS setups, and it is a different data path
- // than the panel's connectivity test (which always probes with
- // noKernelTun=true), so "test ok" and "traffic flows" can disagree.
- noKernelTun: true,
- },
- };
- setStagedOutbound(outbound);
- return outbound;
- },
- [],
- );
- const fetchData = useCallback(async () => {
- setLoading(true);
- try {
- const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
- if (msg?.success) {
- const raw = msg.obj;
- setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
- }
- const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
- if (settingMsg?.success && settingMsg.obj) {
- methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
- }
- } finally {
- setLoading(false);
- }
- }, [methods]);
- const [wasOpen, setWasOpen] = useState(false);
- if (open !== wasOpen) {
- setWasOpen(open);
- if (open) {
- setWarpConfig(null);
- setStagedOutbound(null);
- setLicenseError('');
- }
- }
- useEffect(() => {
- if (!open) return;
- let cancelled = false;
- void (async () => {
- await fetchData();
- if (cancelled) return;
- })();
- return () => {
- cancelled = true;
- };
- }, [open, fetchData]);
- async function register() {
- setLoading(true);
- try {
- const keys = Wireguard.generateKeypair();
- const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
- if (msg?.success && msg.obj) {
- const resp = JSON.parse(msg.obj);
- setWarpData(resp.data);
- setWarpConfig(resp.config);
- collectConfig(resp.data, resp.config);
- }
- } finally {
- setLoading(false);
- }
- }
- async function getConfig() {
- setLoading(true);
- try {
- const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
- if (msg?.success && msg.obj) {
- const parsed = JSON.parse(msg.obj);
- setWarpConfig(parsed);
- collectConfig(warpData, parsed);
- }
- } finally {
- setLoading(false);
- }
- }
- async function changeIp() {
- setLoading(true);
- try {
- const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
- if (msg?.success && msg.obj) {
- const parsed = JSON.parse(msg.obj);
- setWarpData(parsed.data);
- setWarpConfig(parsed.config);
- collectConfig(parsed.data, parsed.config);
- if (warpOutboundIndex >= 0) {
- const existing = templateSettings?.outbounds?.[warpOutboundIndex] as
- | Record<string, unknown>
- | undefined;
- const merged = mergeWarpRotation(existing, parsed.data, parsed.config);
- if (merged) {
- onResetOutbound({ index: warpOutboundIndex, outbound: merged });
- }
- }
- if (parsed.warning) {
- messageApi.warning(parsed.warning);
- }
- messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
- }
- } finally {
- setLoading(false);
- }
- }
- async function saveInterval() {
- setLoading(true);
- try {
- const msg = await HttpUtil.post('/panel/api/xray/warp/interval', {
- interval: methods.getValues('updateInterval'),
- });
- if (msg?.success) {
- messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
- }
- } finally {
- setLoading(false);
- }
- }
- async function updateLicense() {
- const licenseValue = methods.getValues('warpPlus');
- if (licenseValue.length < 26) return;
- setLoading(true);
- setLicenseError('');
- try {
- const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', {
- license: licenseValue,
- });
- if (msg?.success && msg.obj) {
- setWarpData(JSON.parse(msg.obj));
- setWarpConfig(null);
- methods.setValue('warpPlus', '');
- } else {
- setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
- }
- } finally {
- setLoading(false);
- }
- }
- async function delConfig() {
- setLoading(true);
- try {
- const msg = await HttpUtil.post('/panel/api/xray/warp/del');
- if (msg?.success) {
- setWarpData(null);
- setWarpConfig(null);
- setStagedOutbound(null);
- onRemoveOutbound('warp');
- onClose();
- }
- } finally {
- setLoading(false);
- }
- }
- function addOutbound() {
- if (!stagedOutbound) {
- messageApi.warning(t('pages.xray.warp.fetchFirst'));
- return;
- }
- onAddOutbound(stagedOutbound);
- onClose();
- }
- function resetOutbound() {
- if (!stagedOutbound) return;
- onResetOutbound({ index: warpOutboundIndex, outbound: stagedOutbound });
- onClose();
- }
- const hasWarp = !ObjectUtil.isEmpty(warpData);
- const hasConfig = !ObjectUtil.isEmpty(warpConfig);
- return (
- <>
- {messageContextHolder}
- <Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
- <FormProvider {...methods}>
- {!hasWarp ? (
- <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
- {t('pages.xray.warp.createAccount')}
- </Button>
- ) : (
- <>
- <table className="warp-data-table">
- <tbody>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.accessToken')}</td>
- <td>{warpData?.access_token}</td>
- </tr>
- <tr>
- <td>{t('pages.xray.warp.deviceId')}</td>
- <td>{warpData?.device_id}</td>
- </tr>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.licenseKey')}</td>
- <td>{warpData?.license_key}</td>
- </tr>
- <tr>
- <td>{t('pages.xray.warp.privateKey')}</td>
- <td>{warpData?.private_key}</td>
- </tr>
- </tbody>
- </table>
- <Button
- loading={loading}
- type="primary"
- danger
- className="mt-8"
- icon={<DeleteOutlined />}
- onClick={delConfig}
- >
- {t('pages.xray.warp.deleteAccount')}
- </Button>
- <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
- <Collapse
- className="my-10"
- items={[
- {
- key: '1',
- label: t('pages.xray.warp.licenseKeyLabel'),
- children: (
- <Form
- colon={false}
- labelCol={{ md: { span: 6 } }}
- wrapperCol={{ md: { span: 14 } }}
- >
- <FormField
- name="warpPlus"
- label={t('pages.xray.warp.key')}
- onAfterChange={() => setLicenseError('')}
- >
- <Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
- </FormField>
- <div className="license-actions mt-8">
- <Button
- type="primary"
- disabled={warpPlusValue.length < 26}
- loading={loading}
- onClick={updateLicense}
- >
- {t('update')}
- </Button>
- {licenseError && (
- <Alert
- title={licenseError}
- type="error"
- showIcon
- className="license-error"
- />
- )}
- </div>
- </Form>
- ),
- },
- {
- key: '2',
- label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
- children: (
- <Form
- colon={false}
- labelCol={{ md: { span: 8 } }}
- wrapperCol={{ md: { span: 12 } }}
- >
- <FormField
- name="updateInterval"
- label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
- tooltip={t(
- 'pages.xray.warp.intervalDesc',
- '0 to disable. Changes IP address automatically.',
- )}
- transform={{ output: (v) => Number(v) }}
- >
- <Input type="number" min={0} />
- </FormField>
- <Button
- className="mt-8"
- type="primary"
- loading={loading}
- onClick={saveInterval}
- >
- {t('save', 'Save')}
- </Button>
- </Form>
- ),
- },
- ]}
- />
- <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
- <div className="my-8">
- <Button
- loading={loading}
- type="primary"
- icon={<SyncOutlined />}
- onClick={getConfig}
- >
- {t('refresh')}
- </Button>
- <Button
- loading={loading}
- type="primary"
- className="ml-8"
- icon={<SyncOutlined />}
- onClick={changeIp}
- >
- {t('pages.xray.warp.changeIp', 'Change IP')}
- </Button>
- </div>
- {hasConfig && (
- <>
- <table className="warp-data-table">
- <tbody>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.deviceName')}</td>
- <td>{warpConfig?.name}</td>
- </tr>
- <tr>
- <td>{t('pages.xray.warp.deviceModel')}</td>
- <td>{warpConfig?.model}</td>
- </tr>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.deviceEnabled')}</td>
- <td>{String(warpConfig?.enabled)}</td>
- </tr>
- {warpConfig?.account && (
- <>
- <tr>
- <td>{t('pages.xray.warp.accountType')}</td>
- <td>{warpConfig.account.account_type}</td>
- </tr>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.role')}</td>
- <td>{warpConfig.account.role}</td>
- </tr>
- <tr>
- <td>{t('pages.xray.warp.warpPlusData')}</td>
- <td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
- </tr>
- <tr className="row-odd">
- <td>{t('pages.xray.warp.quota')}</td>
- <td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
- </tr>
- {warpConfig.account.usage != null && (
- <tr>
- <td>{t('pages.xray.warp.usage')}</td>
- <td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
- </tr>
- )}
- </>
- )}
- </tbody>
- </table>
- <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
- {warpOutboundIndex >= 0 ? (
- <>
- <Tag color="green">{t('enabled')}</Tag>
- <Button
- type="primary"
- danger
- loading={loading}
- className="ml-8"
- onClick={resetOutbound}
- >
- {t('reset')}
- </Button>
- </>
- ) : (
- <>
- <Tag color="orange">{t('disabled')}</Tag>
- <Button
- type="primary"
- loading={loading}
- className="ml-8"
- icon={<PlusOutlined />}
- onClick={addOutbound}
- >
- {t('pages.xray.warp.addOutbound')}
- </Button>
- </>
- )}
- </>
- )}
- </>
- )}
- </FormProvider>
- </Modal>
- </>
- );
- }
|