DnsTab.tsx 18 KB

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