NordModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
  4. import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
  5. import { HttpUtil } from '@/utils';
  6. import './NordModal.css';
  7. interface NordModalProps {
  8. open: boolean;
  9. templateSettings: { outbounds?: { tag?: string }[] } | null;
  10. onClose: () => void;
  11. onAddOutbound: (outbound: Record<string, unknown>) => void;
  12. onResetOutbound: (payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag: string }) => void;
  13. onRemoveOutbound: (index: number) => void;
  14. onRemoveRoutingRules: (payload: { prefix: string }) => void;
  15. }
  16. interface NordData {
  17. token?: string;
  18. private_key?: string;
  19. }
  20. interface Country {
  21. id: number;
  22. name: string;
  23. code: string;
  24. }
  25. interface City {
  26. id: number;
  27. name: string;
  28. }
  29. interface NordServer {
  30. id: number;
  31. name: string;
  32. hostname: string;
  33. station: string;
  34. load: number;
  35. technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
  36. location_ids?: number[];
  37. cityId?: number | null;
  38. cityName?: string;
  39. }
  40. function loadColor(load: number): string {
  41. if (load < 30) return 'green';
  42. if (load < 70) return 'orange';
  43. return 'red';
  44. }
  45. export default function NordModal({
  46. open,
  47. templateSettings,
  48. onClose,
  49. onAddOutbound,
  50. onResetOutbound,
  51. onRemoveOutbound,
  52. onRemoveRoutingRules,
  53. }: NordModalProps) {
  54. const { t } = useTranslation();
  55. const [messageApi, messageContextHolder] = message.useMessage();
  56. const [loading, setLoading] = useState(false);
  57. const [nordData, setNordData] = useState<NordData | null>(null);
  58. const [token, setToken] = useState('');
  59. const [manualKey, setManualKey] = useState('');
  60. const [countries, setCountries] = useState<Country[]>([]);
  61. const [cities, setCities] = useState<City[]>([]);
  62. const [servers, setServers] = useState<NordServer[]>([]);
  63. const [countryId, setCountryId] = useState<number | null>(null);
  64. const [cityId, setCityId] = useState<number | null>(null);
  65. const [serverId, setServerId] = useState<number | null>(null);
  66. const nordOutboundIndex = useMemo(() => {
  67. const list = templateSettings?.outbounds;
  68. if (!list) return -1;
  69. return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
  70. }, [templateSettings?.outbounds]);
  71. const filteredServers = useMemo(() => {
  72. if (!cityId) return servers;
  73. return servers.filter((s) => s.cityId === cityId);
  74. }, [cityId, servers]);
  75. useEffect(() => {
  76. setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
  77. }, [filteredServers]);
  78. const fetchCountries = useCallback(async () => {
  79. const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
  80. if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
  81. }, []);
  82. const fetchData = useCallback(async () => {
  83. setLoading(true);
  84. try {
  85. const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
  86. if (msg?.success) {
  87. const next = msg.obj ? JSON.parse(msg.obj) : null;
  88. setNordData(next);
  89. if (next) await fetchCountries();
  90. }
  91. } finally {
  92. setLoading(false);
  93. }
  94. }, [fetchCountries]);
  95. useEffect(() => {
  96. if (open) fetchData();
  97. }, [open, fetchData]);
  98. async function login() {
  99. setLoading(true);
  100. try {
  101. const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
  102. if (msg?.success && msg.obj) {
  103. setNordData(JSON.parse(msg.obj));
  104. await fetchCountries();
  105. }
  106. } finally {
  107. setLoading(false);
  108. }
  109. }
  110. async function saveKey() {
  111. setLoading(true);
  112. try {
  113. const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
  114. if (msg?.success && msg.obj) {
  115. setNordData(JSON.parse(msg.obj));
  116. await fetchCountries();
  117. }
  118. } finally {
  119. setLoading(false);
  120. }
  121. }
  122. async function logout() {
  123. setLoading(true);
  124. try {
  125. const msg = await HttpUtil.post('/panel/api/xray/nord/del');
  126. if (msg?.success) {
  127. onRemoveOutbound(nordOutboundIndex);
  128. onRemoveRoutingRules({ prefix: 'nord-' });
  129. setNordData(null);
  130. setToken('');
  131. setManualKey('');
  132. setCountries([]);
  133. setCities([]);
  134. setServers([]);
  135. setCountryId(null);
  136. setCityId(null);
  137. setServerId(null);
  138. }
  139. } finally {
  140. setLoading(false);
  141. }
  142. }
  143. async function fetchServers(newCountryId: number) {
  144. setCountryId(newCountryId);
  145. setLoading(true);
  146. setServers([]);
  147. setCities([]);
  148. setServerId(null);
  149. setCityId(null);
  150. try {
  151. const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
  152. if (!msg?.success || !msg.obj) return;
  153. const data = JSON.parse(msg.obj);
  154. const locations = data.locations || [];
  155. const locToCity: Record<number, City> = {};
  156. const citiesMap = new Map<number, City>();
  157. for (const loc of locations) {
  158. if (loc.country?.city) {
  159. citiesMap.set(loc.country.city.id, loc.country.city);
  160. locToCity[loc.id] = loc.country.city;
  161. }
  162. }
  163. setCities(Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
  164. const next: NordServer[] = (data.servers || [])
  165. .map((s: NordServer) => {
  166. const firstLocId = (s.location_ids || [])[0];
  167. const city = firstLocId != null ? locToCity[firstLocId] : null;
  168. return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
  169. })
  170. .sort((a: NordServer, b: NordServer) => a.load - b.load);
  171. setServers(next);
  172. if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
  173. } finally {
  174. setLoading(false);
  175. }
  176. }
  177. function buildNordOutbound(): Record<string, unknown> | null {
  178. const server = servers.find((s) => s.id === serverId);
  179. if (!server) return null;
  180. const tech = server.technologies?.find((tt) => tt.id === 35);
  181. const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
  182. if (!publicKey) {
  183. messageApi.error(t('pages.xray.nord.noPublicKey'));
  184. return null;
  185. }
  186. return {
  187. tag: `nord-${server.hostname}`,
  188. protocol: 'wireguard',
  189. settings: {
  190. secretKey: nordData?.private_key,
  191. address: ['10.5.0.2/32'],
  192. peers: [{ publicKey, endpoint: `${server.station}:51820` }],
  193. noKernelTun: false,
  194. },
  195. };
  196. }
  197. function addOutbound() {
  198. const ob = buildNordOutbound();
  199. if (!ob) return;
  200. onAddOutbound(ob);
  201. messageApi.success(t('pages.xray.nord.outboundAdded'));
  202. onClose();
  203. }
  204. function resetOutbound() {
  205. if (nordOutboundIndex === -1) return;
  206. const ob = buildNordOutbound();
  207. if (!ob) return;
  208. const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
  209. onResetOutbound({
  210. index: nordOutboundIndex,
  211. outbound: ob,
  212. oldTag,
  213. newTag: ob.tag as string,
  214. });
  215. messageApi.success(t('pages.xray.nord.outboundUpdated'));
  216. onClose();
  217. }
  218. return (
  219. <>
  220. {messageContextHolder}
  221. <Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
  222. {nordData == null ? (
  223. <Tabs
  224. defaultActiveKey="token"
  225. items={[
  226. {
  227. key: 'token',
  228. label: t('pages.xray.nord.accessToken'),
  229. children: (
  230. <Form
  231. colon={false}
  232. labelCol={{ md: { span: 6 } }}
  233. wrapperCol={{ md: { span: 18 } }}
  234. className="mt-20"
  235. >
  236. <Form.Item label={t('pages.xray.nord.accessToken')}>
  237. <Input
  238. value={token}
  239. placeholder={t('pages.xray.nord.accessToken')}
  240. onChange={(e) => setToken(e.target.value)}
  241. />
  242. <Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
  243. {t('login')}
  244. </Button>
  245. </Form.Item>
  246. </Form>
  247. ),
  248. },
  249. {
  250. key: 'key',
  251. label: t('pages.xray.nord.privateKey'),
  252. children: (
  253. <Form
  254. colon={false}
  255. labelCol={{ md: { span: 6 } }}
  256. wrapperCol={{ md: { span: 18 } }}
  257. className="mt-20"
  258. >
  259. <Form.Item label={t('pages.xray.nord.privateKey')}>
  260. <Input
  261. value={manualKey}
  262. placeholder={t('pages.xray.nord.privateKey')}
  263. onChange={(e) => setManualKey(e.target.value)}
  264. />
  265. <Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
  266. {t('save')}
  267. </Button>
  268. </Form.Item>
  269. </Form>
  270. ),
  271. },
  272. ]}
  273. />
  274. ) : (
  275. <>
  276. <table className="nord-data-table">
  277. <tbody>
  278. {nordData.token && (
  279. <tr className="row-odd">
  280. <td>{t('pages.xray.nord.accessToken')}</td>
  281. <td>{nordData.token}</td>
  282. </tr>
  283. )}
  284. <tr>
  285. <td>{t('pages.xray.nord.privateKey')}</td>
  286. <td>{nordData.private_key}</td>
  287. </tr>
  288. </tbody>
  289. </table>
  290. <Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
  291. {t('logout')}
  292. </Button>
  293. <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
  294. <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
  295. <Form.Item label={t('pages.xray.outbound.country')}>
  296. <Select
  297. value={countryId ?? undefined}
  298. showSearch={{ optionFilterProp: 'label' }}
  299. onChange={(v) => fetchServers(v)}
  300. options={countries.map((c) => ({
  301. value: c.id,
  302. label: `${c.name} (${c.code})`,
  303. }))}
  304. />
  305. </Form.Item>
  306. {cities.length > 0 && (
  307. <Form.Item label={t('pages.xray.outbound.city')}>
  308. <Select
  309. value={cityId}
  310. showSearch={{ optionFilterProp: 'label' }}
  311. onChange={setCityId}
  312. options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
  313. />
  314. </Form.Item>
  315. )}
  316. {filteredServers.length > 0 && (
  317. <Form.Item label={t('pages.xray.outbound.server')}>
  318. <Select
  319. value={serverId}
  320. showSearch={{ optionFilterProp: 'label' }}
  321. onChange={setServerId}
  322. options={filteredServers.map((s) => ({
  323. value: s.id,
  324. label: `${s.cityName} ${s.name} ${s.hostname}`,
  325. children: (
  326. <span className="server-row">
  327. <span className="server-name">
  328. {s.cityName} - {s.name}
  329. </span>
  330. <Tag color={loadColor(s.load)} className="server-load-tag">
  331. {s.load}%
  332. </Tag>
  333. </span>
  334. ),
  335. }))}
  336. />
  337. </Form.Item>
  338. )}
  339. </Form>
  340. <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
  341. {nordOutboundIndex >= 0 ? (
  342. <>
  343. <Tag color="green">{t('enabled')}</Tag>
  344. <Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
  345. {t('reset')}
  346. </Button>
  347. </>
  348. ) : (
  349. <>
  350. <Tag color="orange">{t('disabled')}</Tag>
  351. <Button
  352. type="primary"
  353. className="ml-8"
  354. disabled={!serverId}
  355. loading={loading}
  356. onClick={addOutbound}
  357. >
  358. {t('pages.xray.warp.addOutbound')}
  359. </Button>
  360. </>
  361. )}
  362. </>
  363. )}
  364. </Modal>
  365. </>
  366. );
  367. }