1
0

WarpModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Alert, Button, Collapse, Divider, Form, Input, message, Modal, Tag } from 'antd';
  4. import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
  5. import { FormProvider, useForm, useWatch } from 'react-hook-form';
  6. import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
  7. import { FormField } from '@/components/form/rhf';
  8. import './WarpModal.css';
  9. interface WarpModalProps {
  10. open: boolean;
  11. templateSettings: { outbounds?: { tag?: string }[] } | null;
  12. onClose: () => void;
  13. onAddOutbound: (outbound: Record<string, unknown>) => void;
  14. onResetOutbound: (payload: { index: number; outbound: Record<string, unknown> }) => void;
  15. onRemoveOutbound: (tag: string) => void;
  16. }
  17. interface WarpData {
  18. access_token?: string;
  19. device_id?: string;
  20. license_key?: string;
  21. private_key?: string;
  22. client_id?: string;
  23. }
  24. interface WarpConfig {
  25. name?: string;
  26. model?: string;
  27. enabled?: boolean;
  28. config?: {
  29. client_id?: string;
  30. interface?: { addresses?: { v4?: string; v6?: string } };
  31. peers?: { public_key?: string; endpoint?: { host?: string } }[];
  32. };
  33. account?: {
  34. account_type?: string;
  35. role?: string;
  36. premium_data?: number;
  37. quota?: number;
  38. usage?: number;
  39. };
  40. }
  41. interface WarpFormValues {
  42. warpPlus: string;
  43. updateInterval: number;
  44. }
  45. const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
  46. function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
  47. const out: string[] = [];
  48. if (addrs.v4) out.push(`${addrs.v4}/32`);
  49. if (addrs.v6) out.push(`${addrs.v6}/128`);
  50. return out;
  51. }
  52. function reservedFor(clientId?: string): number[] {
  53. if (!clientId) return [];
  54. const decoded = atob(clientId);
  55. const out: number[] = [];
  56. for (let i = 0; i < decoded.length; i += 1) out.push(decoded.charCodeAt(i));
  57. return out;
  58. }
  59. export function mergeWarpRotation(
  60. existing: Record<string, unknown> | undefined,
  61. data: WarpData | null,
  62. config: WarpConfig | null,
  63. ): Record<string, unknown> | null {
  64. const cfg = config?.config;
  65. const peer = cfg?.peers?.[0];
  66. if (!cfg || !peer) return null;
  67. const base: Record<string, unknown> =
  68. existing && typeof existing === 'object'
  69. ? { ...existing }
  70. : { tag: 'warp', protocol: 'wireguard' };
  71. const prevSettings =
  72. base.settings && typeof base.settings === 'object'
  73. ? { ...(base.settings as Record<string, unknown>) }
  74. : {};
  75. const prevPeers = Array.isArray(prevSettings.peers)
  76. ? [...(prevSettings.peers as Record<string, unknown>[])]
  77. : [];
  78. const prevFirstPeer =
  79. prevPeers[0] && typeof prevPeers[0] === 'object'
  80. ? { ...(prevPeers[0] as Record<string, unknown>) }
  81. : {};
  82. prevFirstPeer.publicKey = peer.public_key;
  83. prevFirstPeer.endpoint = peer.endpoint?.host;
  84. prevPeers[0] = prevFirstPeer;
  85. prevSettings.secretKey = data?.private_key;
  86. prevSettings.address = addressesFor(cfg.interface?.addresses || {});
  87. prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id);
  88. prevSettings.peers = prevPeers;
  89. base.settings = prevSettings;
  90. base.tag = 'warp';
  91. base.protocol = 'wireguard';
  92. return base;
  93. }
  94. export default function WarpModal({
  95. open,
  96. templateSettings,
  97. onClose,
  98. onAddOutbound,
  99. onResetOutbound,
  100. onRemoveOutbound,
  101. }: WarpModalProps) {
  102. const { t } = useTranslation();
  103. const [messageApi, messageContextHolder] = message.useMessage();
  104. const [loading, setLoading] = useState(false);
  105. const [warpData, setWarpData] = useState<WarpData | null>(null);
  106. const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
  107. const [licenseError, setLicenseError] = useState('');
  108. const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
  109. const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
  110. const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
  111. const warpOutboundIndex = useMemo(() => {
  112. const list = templateSettings?.outbounds;
  113. if (!list) return -1;
  114. return list.findIndex((o) => o?.tag === 'warp');
  115. }, [templateSettings?.outbounds]);
  116. const collectConfig = useCallback(
  117. (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
  118. const cfg = config?.config;
  119. if (!cfg?.peers?.length) return null;
  120. const peer = cfg.peers[0];
  121. const outbound: Record<string, unknown> = {
  122. tag: 'warp',
  123. protocol: 'wireguard',
  124. settings: {
  125. mtu: 1420,
  126. secretKey: data?.private_key,
  127. address: addressesFor(cfg.interface?.addresses || {}),
  128. reserved: reservedFor(cfg.client_id ?? data?.client_id),
  129. // Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
  130. // record for engage.cloudflareclient.com, and a host with
  131. // half-configured IPv6 then blackholes the handshake with no error
  132. // logged (#5205).
  133. domainStrategy: 'ForceIPv4v6',
  134. peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
  135. // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
  136. // fails silently on many VPS setups, and it is a different data path
  137. // than the panel's connectivity test (which always probes with
  138. // noKernelTun=true), so "test ok" and "traffic flows" can disagree.
  139. noKernelTun: true,
  140. },
  141. };
  142. setStagedOutbound(outbound);
  143. return outbound;
  144. },
  145. [],
  146. );
  147. const fetchData = useCallback(async () => {
  148. setLoading(true);
  149. try {
  150. const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
  151. if (msg?.success) {
  152. const raw = msg.obj;
  153. setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
  154. }
  155. const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
  156. if (settingMsg?.success && settingMsg.obj) {
  157. methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
  158. }
  159. } finally {
  160. setLoading(false);
  161. }
  162. }, [methods]);
  163. const [wasOpen, setWasOpen] = useState(false);
  164. if (open !== wasOpen) {
  165. setWasOpen(open);
  166. if (open) {
  167. setWarpConfig(null);
  168. setStagedOutbound(null);
  169. setLicenseError('');
  170. }
  171. }
  172. useEffect(() => {
  173. if (!open) return;
  174. let cancelled = false;
  175. void (async () => {
  176. await fetchData();
  177. if (cancelled) return;
  178. })();
  179. return () => {
  180. cancelled = true;
  181. };
  182. }, [open, fetchData]);
  183. async function register() {
  184. setLoading(true);
  185. try {
  186. const keys = Wireguard.generateKeypair();
  187. const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
  188. if (msg?.success && msg.obj) {
  189. const resp = JSON.parse(msg.obj);
  190. setWarpData(resp.data);
  191. setWarpConfig(resp.config);
  192. collectConfig(resp.data, resp.config);
  193. }
  194. } finally {
  195. setLoading(false);
  196. }
  197. }
  198. async function getConfig() {
  199. setLoading(true);
  200. try {
  201. const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
  202. if (msg?.success && msg.obj) {
  203. const parsed = JSON.parse(msg.obj);
  204. setWarpConfig(parsed);
  205. collectConfig(warpData, parsed);
  206. }
  207. } finally {
  208. setLoading(false);
  209. }
  210. }
  211. async function changeIp() {
  212. setLoading(true);
  213. try {
  214. const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
  215. if (msg?.success && msg.obj) {
  216. const parsed = JSON.parse(msg.obj);
  217. setWarpData(parsed.data);
  218. setWarpConfig(parsed.config);
  219. collectConfig(parsed.data, parsed.config);
  220. if (warpOutboundIndex >= 0) {
  221. const existing = templateSettings?.outbounds?.[warpOutboundIndex] as
  222. | Record<string, unknown>
  223. | undefined;
  224. const merged = mergeWarpRotation(existing, parsed.data, parsed.config);
  225. if (merged) {
  226. onResetOutbound({ index: warpOutboundIndex, outbound: merged });
  227. }
  228. }
  229. if (parsed.warning) {
  230. messageApi.warning(parsed.warning);
  231. }
  232. messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
  233. }
  234. } finally {
  235. setLoading(false);
  236. }
  237. }
  238. async function saveInterval() {
  239. setLoading(true);
  240. try {
  241. const msg = await HttpUtil.post('/panel/api/xray/warp/interval', {
  242. interval: methods.getValues('updateInterval'),
  243. });
  244. if (msg?.success) {
  245. messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
  246. }
  247. } finally {
  248. setLoading(false);
  249. }
  250. }
  251. async function updateLicense() {
  252. const licenseValue = methods.getValues('warpPlus');
  253. if (licenseValue.length < 26) return;
  254. setLoading(true);
  255. setLicenseError('');
  256. try {
  257. const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', {
  258. license: licenseValue,
  259. });
  260. if (msg?.success && msg.obj) {
  261. setWarpData(JSON.parse(msg.obj));
  262. setWarpConfig(null);
  263. methods.setValue('warpPlus', '');
  264. } else {
  265. setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
  266. }
  267. } finally {
  268. setLoading(false);
  269. }
  270. }
  271. async function delConfig() {
  272. setLoading(true);
  273. try {
  274. const msg = await HttpUtil.post('/panel/api/xray/warp/del');
  275. if (msg?.success) {
  276. setWarpData(null);
  277. setWarpConfig(null);
  278. setStagedOutbound(null);
  279. onRemoveOutbound('warp');
  280. onClose();
  281. }
  282. } finally {
  283. setLoading(false);
  284. }
  285. }
  286. function addOutbound() {
  287. if (!stagedOutbound) {
  288. messageApi.warning(t('pages.xray.warp.fetchFirst'));
  289. return;
  290. }
  291. onAddOutbound(stagedOutbound);
  292. onClose();
  293. }
  294. function resetOutbound() {
  295. if (!stagedOutbound) return;
  296. onResetOutbound({ index: warpOutboundIndex, outbound: stagedOutbound });
  297. onClose();
  298. }
  299. const hasWarp = !ObjectUtil.isEmpty(warpData);
  300. const hasConfig = !ObjectUtil.isEmpty(warpConfig);
  301. return (
  302. <>
  303. {messageContextHolder}
  304. <Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
  305. <FormProvider {...methods}>
  306. {!hasWarp ? (
  307. <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
  308. {t('pages.xray.warp.createAccount')}
  309. </Button>
  310. ) : (
  311. <>
  312. <table className="warp-data-table">
  313. <tbody>
  314. <tr className="row-odd">
  315. <td>{t('pages.xray.warp.accessToken')}</td>
  316. <td>{warpData?.access_token}</td>
  317. </tr>
  318. <tr>
  319. <td>{t('pages.xray.warp.deviceId')}</td>
  320. <td>{warpData?.device_id}</td>
  321. </tr>
  322. <tr className="row-odd">
  323. <td>{t('pages.xray.warp.licenseKey')}</td>
  324. <td>{warpData?.license_key}</td>
  325. </tr>
  326. <tr>
  327. <td>{t('pages.xray.warp.privateKey')}</td>
  328. <td>{warpData?.private_key}</td>
  329. </tr>
  330. </tbody>
  331. </table>
  332. <Button
  333. loading={loading}
  334. type="primary"
  335. danger
  336. className="mt-8"
  337. icon={<DeleteOutlined />}
  338. onClick={delConfig}
  339. >
  340. {t('pages.xray.warp.deleteAccount')}
  341. </Button>
  342. <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
  343. <Collapse
  344. className="my-10"
  345. items={[
  346. {
  347. key: '1',
  348. label: t('pages.xray.warp.licenseKeyLabel'),
  349. children: (
  350. <Form
  351. colon={false}
  352. labelCol={{ md: { span: 6 } }}
  353. wrapperCol={{ md: { span: 14 } }}
  354. >
  355. <FormField
  356. name="warpPlus"
  357. label={t('pages.xray.warp.key')}
  358. onAfterChange={() => setLicenseError('')}
  359. >
  360. <Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
  361. </FormField>
  362. <div className="license-actions mt-8">
  363. <Button
  364. type="primary"
  365. disabled={warpPlusValue.length < 26}
  366. loading={loading}
  367. onClick={updateLicense}
  368. >
  369. {t('update')}
  370. </Button>
  371. {licenseError && (
  372. <Alert
  373. title={licenseError}
  374. type="error"
  375. showIcon
  376. className="license-error"
  377. />
  378. )}
  379. </div>
  380. </Form>
  381. ),
  382. },
  383. {
  384. key: '2',
  385. label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
  386. children: (
  387. <Form
  388. colon={false}
  389. labelCol={{ md: { span: 8 } }}
  390. wrapperCol={{ md: { span: 12 } }}
  391. >
  392. <FormField
  393. name="updateInterval"
  394. label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
  395. tooltip={t(
  396. 'pages.xray.warp.intervalDesc',
  397. '0 to disable. Changes IP address automatically.',
  398. )}
  399. transform={{ output: (v) => Number(v) }}
  400. >
  401. <Input type="number" min={0} />
  402. </FormField>
  403. <Button
  404. className="mt-8"
  405. type="primary"
  406. loading={loading}
  407. onClick={saveInterval}
  408. >
  409. {t('save', 'Save')}
  410. </Button>
  411. </Form>
  412. ),
  413. },
  414. ]}
  415. />
  416. <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
  417. <div className="my-8">
  418. <Button
  419. loading={loading}
  420. type="primary"
  421. icon={<SyncOutlined />}
  422. onClick={getConfig}
  423. >
  424. {t('refresh')}
  425. </Button>
  426. <Button
  427. loading={loading}
  428. type="primary"
  429. className="ml-8"
  430. icon={<SyncOutlined />}
  431. onClick={changeIp}
  432. >
  433. {t('pages.xray.warp.changeIp', 'Change IP')}
  434. </Button>
  435. </div>
  436. {hasConfig && (
  437. <>
  438. <table className="warp-data-table">
  439. <tbody>
  440. <tr className="row-odd">
  441. <td>{t('pages.xray.warp.deviceName')}</td>
  442. <td>{warpConfig?.name}</td>
  443. </tr>
  444. <tr>
  445. <td>{t('pages.xray.warp.deviceModel')}</td>
  446. <td>{warpConfig?.model}</td>
  447. </tr>
  448. <tr className="row-odd">
  449. <td>{t('pages.xray.warp.deviceEnabled')}</td>
  450. <td>{String(warpConfig?.enabled)}</td>
  451. </tr>
  452. {warpConfig?.account && (
  453. <>
  454. <tr>
  455. <td>{t('pages.xray.warp.accountType')}</td>
  456. <td>{warpConfig.account.account_type}</td>
  457. </tr>
  458. <tr className="row-odd">
  459. <td>{t('pages.xray.warp.role')}</td>
  460. <td>{warpConfig.account.role}</td>
  461. </tr>
  462. <tr>
  463. <td>{t('pages.xray.warp.warpPlusData')}</td>
  464. <td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
  465. </tr>
  466. <tr className="row-odd">
  467. <td>{t('pages.xray.warp.quota')}</td>
  468. <td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
  469. </tr>
  470. {warpConfig.account.usage != null && (
  471. <tr>
  472. <td>{t('pages.xray.warp.usage')}</td>
  473. <td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
  474. </tr>
  475. )}
  476. </>
  477. )}
  478. </tbody>
  479. </table>
  480. <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
  481. {warpOutboundIndex >= 0 ? (
  482. <>
  483. <Tag color="green">{t('enabled')}</Tag>
  484. <Button
  485. type="primary"
  486. danger
  487. loading={loading}
  488. className="ml-8"
  489. onClick={resetOutbound}
  490. >
  491. {t('reset')}
  492. </Button>
  493. </>
  494. ) : (
  495. <>
  496. <Tag color="orange">{t('disabled')}</Tag>
  497. <Button
  498. type="primary"
  499. loading={loading}
  500. className="ml-8"
  501. icon={<PlusOutlined />}
  502. onClick={addOutbound}
  503. >
  504. {t('pages.xray.warp.addOutbound')}
  505. </Button>
  506. </>
  507. )}
  508. </>
  509. )}
  510. </>
  511. )}
  512. </FormProvider>
  513. </Modal>
  514. </>
  515. );
  516. }