DnsTab.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
  4. import {
  5. DatabaseOutlined,
  6. DeleteOutlined,
  7. ExperimentOutlined,
  8. MenuOutlined,
  9. PlusOutlined,
  10. ProfileOutlined,
  11. SettingOutlined,
  12. } from '@ant-design/icons';
  13. import { onNumber } from '@/utils/onNumber';
  14. import { SettingListItem } from '@/components/ui';
  15. import { useMediaQuery } from '@/hooks/useMediaQuery';
  16. import { catTabLabel } from '@/pages/settings/catTabLabel';
  17. import DnsServerModal from './DnsServerModal';
  18. import type { DnsServerValue } from './DnsServerModal';
  19. import DnsPresetsModal from './DnsPresetsModal';
  20. import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
  21. import './DnsTab.css';
  22. import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
  23. import type { DnsConfig, HostRow, FakednsRow } from './types';
  24. import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
  25. interface DnsTabProps {
  26. templateSettings: XraySettingsValue | null;
  27. setTemplateSettings: SetTemplate;
  28. }
  29. export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
  30. const { t } = useTranslation();
  31. const { isMobile } = useMediaQuery();
  32. const [modal, modalContextHolder] = Modal.useModal();
  33. const [hostsList, setHostsList] = useState<HostRow[]>([]);
  34. const [serverModalOpen, setServerModalOpen] = useState(false);
  35. const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
  36. const [editingIndex, setEditingIndex] = useState<number | null>(null);
  37. const [presetsModalOpen, setPresetsModalOpen] = useState(false);
  38. const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
  39. const dnsEnabled = !!dns;
  40. const sourceHosts = dns?.hosts;
  41. const incomingHosts = JSON.stringify(sourceHosts ?? {});
  42. const lastWrittenHostsRef = useRef<string | null>(null);
  43. useEffect(() => {
  44. if (!dnsEnabled) {
  45. lastWrittenHostsRef.current = '{}';
  46. setHostsList([]);
  47. return;
  48. }
  49. if (incomingHosts === lastWrittenHostsRef.current) return;
  50. lastWrittenHostsRef.current = incomingHosts;
  51. setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
  52. domain,
  53. values: Array.isArray(values) ? [...values] : [String(values)],
  54. })));
  55. }, [dnsEnabled, incomingHosts, sourceHosts]);
  56. const mutate = useCallback(
  57. (mutator: (next: XraySettingsValue) => void) => {
  58. setTemplateSettings((prev) => {
  59. if (!prev) return prev;
  60. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  61. mutator(clone);
  62. return clone;
  63. });
  64. },
  65. [setTemplateSettings],
  66. );
  67. function toggleDNS(enabled: boolean) {
  68. mutate((next) => {
  69. if (enabled) {
  70. (next as { dns?: DnsConfig }).dns = {
  71. tag: 'dns_inbound',
  72. queryStrategy: 'UseIP',
  73. disableCache: false,
  74. disableFallback: false,
  75. disableFallbackIfMatch: false,
  76. useSystemHosts: false,
  77. enableParallelQuery: false,
  78. serveStale: false,
  79. serveExpiredTTL: 0,
  80. hosts: {},
  81. servers: [],
  82. };
  83. next.fakedns = null;
  84. } else {
  85. delete next.dns;
  86. delete next.fakedns;
  87. }
  88. });
  89. }
  90. function syncHosts(next: HostRow[]) {
  91. const obj: Record<string, string | string[]> = {};
  92. for (const row of next) {
  93. if (!row.domain) continue;
  94. const vals = (row.values || []).filter(Boolean);
  95. if (vals.length === 0) continue;
  96. obj[row.domain] = vals.length === 1 ? vals[0] : vals;
  97. }
  98. lastWrittenHostsRef.current = JSON.stringify(obj);
  99. setHostsList(next);
  100. mutate((tt) => {
  101. if (!tt.dns) return;
  102. if (Object.keys(obj).length > 0) {
  103. (tt.dns as DnsConfig).hosts = obj;
  104. } else if ('hosts' in (tt.dns as DnsConfig)) {
  105. delete (tt.dns as DnsConfig).hosts;
  106. }
  107. });
  108. }
  109. function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
  110. mutate((tt) => {
  111. if (!tt.dns) return;
  112. if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
  113. delete (tt.dns as Record<string, unknown>)[key as string];
  114. } else {
  115. (tt.dns as Record<string, unknown>)[key as string] = value;
  116. }
  117. });
  118. }
  119. const dnsServers = useMemo(() => {
  120. const list = dns?.servers || [];
  121. return list.map((server, idx) => ({ key: idx, server }));
  122. }, [dns?.servers]);
  123. // Stable callbacks: the column definitions in useDnsServerColumns are
  124. // memoized, so they must be able to depend on these (see issue #5155)
  125. const openEditServer = useCallback(
  126. (idx: number) => {
  127. setEditingServer((dns?.servers || [])[idx] || null);
  128. setEditingIndex(idx);
  129. setServerModalOpen(true);
  130. },
  131. [dns?.servers],
  132. );
  133. const deleteServer = useCallback(
  134. (idx: number) => {
  135. mutate((tt) => {
  136. const cfg = tt.dns as DnsConfig | undefined;
  137. if (cfg?.servers) cfg.servers.splice(idx, 1);
  138. });
  139. },
  140. [mutate],
  141. );
  142. const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
  143. function openAddServer() {
  144. setEditingServer(null);
  145. setEditingIndex(null);
  146. setServerModalOpen(true);
  147. }
  148. function onServerConfirm(value: DnsServerValue) {
  149. mutate((tt) => {
  150. if (!tt.dns) return;
  151. const cfg = tt.dns as DnsConfig;
  152. if (!Array.isArray(cfg.servers)) cfg.servers = [];
  153. if (editingIndex == null) cfg.servers.push(value);
  154. else cfg.servers[editingIndex] = value;
  155. });
  156. setServerModalOpen(false);
  157. }
  158. function clearAllServers() {
  159. modal.confirm({
  160. title: t('pages.xray.dns.clearAllTitle'),
  161. content: t('pages.xray.dns.clearAllConfirm'),
  162. okText: t('delete'),
  163. okButtonProps: { danger: true },
  164. cancelText: t('cancel'),
  165. onOk: () => mutate((tt) => {
  166. if (tt.dns) (tt.dns as DnsConfig).servers = [];
  167. }),
  168. });
  169. }
  170. function onPresetInstall(servers: string[]) {
  171. mutate((tt) => {
  172. if (tt.dns) (tt.dns as DnsConfig).servers = servers;
  173. });
  174. setPresetsModalOpen(false);
  175. }
  176. const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
  177. const list = Array.isArray(templateSettings?.fakedns)
  178. ? (templateSettings?.fakedns as FakednsRow[])
  179. : [];
  180. return list.map((entry, idx) => ({ key: idx, ...entry }));
  181. }, [templateSettings?.fakedns]);
  182. const deleteFakedns = useCallback(
  183. (idx: number) => {
  184. mutate((tt) => {
  185. const list = tt.fakedns as FakednsRow[] | undefined;
  186. if (!list) return;
  187. list.splice(idx, 1);
  188. if (list.length === 0) tt.fakedns = null;
  189. });
  190. },
  191. [mutate],
  192. );
  193. const updateFakednsField = useCallback(
  194. (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
  195. mutate((tt) => {
  196. const list = tt.fakedns as FakednsRow[] | undefined;
  197. if (!list?.[idx]) return;
  198. (list[idx] as unknown as Record<string, unknown>)[field] = value;
  199. });
  200. },
  201. [mutate],
  202. );
  203. const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
  204. function addFakedns() {
  205. mutate((tt) => {
  206. if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
  207. (tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
  208. });
  209. }
  210. const items = useMemo(() => {
  211. const out = [
  212. {
  213. key: '1',
  214. label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
  215. children: (
  216. <>
  217. <SettingListItem
  218. paddings="small"
  219. title={t('pages.xray.dns.enable')}
  220. description={t('pages.xray.dns.enableDesc')}
  221. control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
  222. />
  223. {dnsEnabled && (
  224. <>
  225. <Alert
  226. type="warning"
  227. showIcon
  228. title={t('pages.xray.dns.dnsLeakWarning')}
  229. style={{ marginBottom: 12 }}
  230. />
  231. <SettingListItem
  232. paddings="small"
  233. title={t('pages.xray.dns.tag')}
  234. description={t('pages.xray.dns.tagDesc')}
  235. control={
  236. <Input
  237. value={dns?.tag ?? 'dns_inbound'}
  238. onChange={(e) => setDnsField('tag', e.target.value)}
  239. />
  240. }
  241. />
  242. <SettingListItem
  243. paddings="small"
  244. title={t('pages.xray.dns.clientIp')}
  245. description={t('pages.xray.dns.clientIpDesc')}
  246. control={
  247. <Input
  248. value={dns?.clientIp ?? ''}
  249. onChange={(e) => setDnsField('clientIp', e.target.value, true)}
  250. />
  251. }
  252. />
  253. <SettingListItem
  254. paddings="small"
  255. title={t('pages.xray.dns.strategy')}
  256. description={t('pages.xray.dns.strategyDesc')}
  257. control={
  258. <Select
  259. value={dns?.queryStrategy ?? 'UseIP'}
  260. style={{ width: '100%' }}
  261. options={STRATEGIES.map((s) => ({ value: s, label: s }))}
  262. onChange={(v) => setDnsField('queryStrategy', v)}
  263. />
  264. }
  265. />
  266. {(
  267. [
  268. ['disableCache', 'pages.xray.dns.disableCache', 'pages.xray.dns.disableCacheDesc'],
  269. ['disableFallback', 'pages.xray.dns.disableFallback', 'pages.xray.dns.disableFallbackDesc'],
  270. ['disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatch', 'pages.xray.dns.disableFallbackIfMatchDesc'],
  271. ['enableParallelQuery', 'pages.xray.dns.enableParallelQuery', 'pages.xray.dns.enableParallelQueryDesc'],
  272. ['useSystemHosts', 'pages.xray.dns.useSystemHosts', 'pages.xray.dns.useSystemHostsDesc'],
  273. ['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
  274. ] as const
  275. ).map(([field, titleKey, descKey]) => (
  276. <SettingListItem
  277. key={field}
  278. paddings="small"
  279. title={t(titleKey)}
  280. description={t(descKey)}
  281. control={
  282. <Switch
  283. checked={!!dns?.[field]}
  284. onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
  285. />
  286. }
  287. />
  288. ))}
  289. <SettingListItem
  290. paddings="small"
  291. title={t('pages.xray.dns.serveExpiredTTL')}
  292. description={t('pages.xray.dns.serveExpiredTTLDesc')}
  293. control={
  294. <InputNumber
  295. value={dns?.serveExpiredTTL ?? 0}
  296. min={0}
  297. step={60}
  298. style={{ width: '100%' }}
  299. onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
  300. />
  301. }
  302. />
  303. </>
  304. )}
  305. </>
  306. ),
  307. },
  308. ];
  309. if (dnsEnabled) {
  310. out.push({
  311. key: 'hosts',
  312. label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
  313. children: hostsList.length === 0 ? (
  314. <Empty description={t('pages.xray.dns.hostsEmpty')}>
  315. <Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
  316. {t('pages.xray.dns.hostsAdd')}
  317. </Button>
  318. </Empty>
  319. ) : (
  320. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  321. <Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
  322. {t('pages.xray.dns.hostsAdd')}
  323. </Button>
  324. {hostsList.map((row, idx) => (
  325. <div key={`h${idx}`} className="hosts-row">
  326. <Input
  327. value={row.domain}
  328. aria-label={t('pages.xray.dns.hostsDomain')}
  329. placeholder={t('pages.xray.dns.hostsDomain')}
  330. style={{ flex: '1 1 220px' }}
  331. onChange={(e) => {
  332. const next = hostsList.map((r, i) => (i === idx ? { ...r, domain: e.target.value } : r));
  333. syncHosts(next);
  334. }}
  335. />
  336. <Select
  337. mode="tags"
  338. value={row.values}
  339. aria-label={t('pages.xray.dns.hostsValues')}
  340. placeholder={t('pages.xray.dns.hostsValues')}
  341. style={{ flex: '2 1 320px' }}
  342. tokenSeparators={[',', ' ']}
  343. onChange={(values) => {
  344. const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
  345. syncHosts(next);
  346. }}
  347. />
  348. <Button danger aria-label={t('delete')} icon={<DeleteOutlined />} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} />
  349. </div>
  350. ))}
  351. </Space>
  352. ),
  353. });
  354. out.push({
  355. key: '2',
  356. label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
  357. children: dnsServers.length === 0 ? (
  358. <Empty description={t('emptyDnsDesc')}>
  359. <Space>
  360. <Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
  361. {t('pages.xray.dns.add')}
  362. </Button>
  363. <Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
  364. {t('pages.xray.dns.usePreset')}
  365. </Button>
  366. </Space>
  367. </Empty>
  368. ) : (
  369. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  370. <Space wrap>
  371. <Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
  372. {t('pages.xray.dns.add')}
  373. </Button>
  374. <Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
  375. {t('pages.xray.dns.usePreset')}
  376. </Button>
  377. <Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
  378. {t('pages.xray.dns.clearAll')}
  379. </Button>
  380. </Space>
  381. <Table
  382. columns={dnsColumns}
  383. dataSource={dnsServers}
  384. rowKey={(r) => r.key}
  385. pagination={false}
  386. size="small"
  387. bordered
  388. />
  389. </Space>
  390. ),
  391. });
  392. out.push({
  393. key: '3',
  394. label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
  395. children: fakeDnsList.length === 0 ? (
  396. <Empty description={t('emptyFakeDnsDesc')}>
  397. <Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
  398. {t('pages.xray.fakedns.add')}
  399. </Button>
  400. </Empty>
  401. ) : (
  402. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  403. <Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
  404. {t('pages.xray.fakedns.add')}
  405. </Button>
  406. <Table
  407. columns={fakednsColumns}
  408. dataSource={fakeDnsList}
  409. rowKey={(r) => r.key}
  410. pagination={false}
  411. size="small"
  412. bordered
  413. />
  414. </Space>
  415. ),
  416. });
  417. }
  418. return out;
  419. // eslint-disable-next-line react-hooks/exhaustive-deps
  420. }, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
  421. return (
  422. <>
  423. {modalContextHolder}
  424. <Tabs defaultActiveKey="1" items={items} />
  425. <DnsServerModal
  426. open={serverModalOpen}
  427. server={editingServer}
  428. isEdit={editingIndex != null}
  429. onClose={() => setServerModalOpen(false)}
  430. onConfirm={onServerConfirm}
  431. />
  432. <DnsPresetsModal
  433. open={presetsModalOpen}
  434. onClose={() => setPresetsModalOpen(false)}
  435. onInstall={onPresetInstall}
  436. />
  437. </>
  438. );
  439. }