OutboundFormModal.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Form,
  5. Input,
  6. InputNumber,
  7. Modal,
  8. Radio,
  9. Select,
  10. Space,
  11. Switch,
  12. Tabs,
  13. message,
  14. } from 'antd';
  15. import { FinalMaskForm } from '@/lib/xray/forms/transport';
  16. import { JsonEditor } from '@/components/form';
  17. import { Wireguard } from '@/utils';
  18. import {
  19. XMUX_DEFAULTS,
  20. formValuesToWirePayload,
  21. rawOutboundToFormValues,
  22. } from '@/lib/xray/outbound-form-adapter';
  23. import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
  24. import {
  25. OutboundFormBaseSchema,
  26. type OutboundFormValues,
  27. } from '@/schemas/forms/outbound-form';
  28. import { SNIFFING_OPTION } from '@/schemas/primitives';
  29. import {
  30. canEnableReality,
  31. canEnableStream,
  32. canEnableTls,
  33. canEnableTlsFlow,
  34. } from '@/lib/xray/protocol-capabilities';
  35. import { antdRule } from '@/utils/zodForm';
  36. import {
  37. FLOW_OPTIONS,
  38. HYSTERIA_NETWORK_OPTION,
  39. NETWORK_OPTIONS,
  40. PROTOCOL_OPTIONS,
  41. SERVER_PROTOCOLS,
  42. } from './outbound-form-constants';
  43. import {
  44. applyNetworkChange,
  45. buildAddModeValues,
  46. hysteriaStreamSlice,
  47. newStreamSlice,
  48. } from './outbound-form-helpers';
  49. import {
  50. BlackholeFields,
  51. DnsFields,
  52. FreedomFields,
  53. HttpFields,
  54. LoopbackFields,
  55. ServerTarget,
  56. ShadowsocksFields,
  57. SocksFields,
  58. TrojanFields,
  59. VlessFields,
  60. VmessFields,
  61. WireguardFields,
  62. } from './protocols';
  63. import {
  64. GrpcForm,
  65. HttpUpgradeForm,
  66. HysteriaForm,
  67. KcpForm,
  68. MuxForm,
  69. RawForm,
  70. SockoptForm,
  71. WsForm,
  72. XhttpForm,
  73. } from './transport';
  74. import { RealityForm, TlsForm } from './security';
  75. import './OutboundFormModal.css';
  76. // Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
  77. // file so the build stays green section-by-section. The atomic swap at
  78. // the end of the rewrite replaces the legacy file in one commit
  79. // (per Core Decision 7 in the migration spec).
  80. interface OutboundFormModalProps {
  81. open: boolean;
  82. outbound: Record<string, unknown> | null;
  83. existingTags: string[];
  84. onClose: () => void;
  85. onConfirm: (outbound: Record<string, unknown>) => void;
  86. }
  87. export default function OutboundFormModal({
  88. open,
  89. outbound: outboundProp,
  90. existingTags,
  91. onClose,
  92. onConfirm,
  93. }: OutboundFormModalProps) {
  94. const { t } = useTranslation();
  95. const [messageApi, messageContextHolder] = message.useMessage();
  96. const [form] = Form.useForm<OutboundFormValues>();
  97. const [activeKey, setActiveKey] = useState('1');
  98. const [jsonText, setJsonText] = useState('');
  99. const [jsonDirty, setJsonDirty] = useState(false);
  100. const [linkInput, setLinkInput] = useState('');
  101. // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
  102. // hysteria2:// / wireguard://) and replace form state with the result.
  103. // The current tag is preserved when the parsed link doesn't carry one.
  104. function importLink() {
  105. const link = linkInput.trim();
  106. if (!link) return;
  107. const parsed = parseOutboundLink(link);
  108. if (!parsed) {
  109. messageApi.error('Wrong Link!');
  110. return;
  111. }
  112. const currentTag = form.getFieldValue('tag') as string | undefined;
  113. if (!parsed.tag && currentTag) parsed.tag = currentTag;
  114. const next = rawOutboundToFormValues(parsed);
  115. form.resetFields();
  116. form.setFieldsValue(next);
  117. setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
  118. setJsonDirty(false);
  119. setLinkInput('');
  120. messageApi.success('Link imported successfully');
  121. switchTab('1');
  122. }
  123. const isEdit = outboundProp != null;
  124. const title = isEdit
  125. ? `${t('edit')} ${t('pages.xray.Outbounds')}`
  126. : `+ ${t('pages.xray.Outbounds')}`;
  127. const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
  128. useEffect(() => {
  129. if (!open) return;
  130. const initial = outboundProp
  131. ? rawOutboundToFormValues(outboundProp)
  132. : buildAddModeValues();
  133. form.resetFields();
  134. form.setFieldsValue(initial);
  135. setActiveKey('1');
  136. setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
  137. setJsonDirty(false);
  138. }, [open, outboundProp, form]);
  139. const tag = Form.useWatch('tag', form) ?? '';
  140. const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
  141. const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
  142. const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
  143. const streamAllowed = canEnableStream({ protocol });
  144. const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
  145. const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
  146. const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
  147. useEffect(() => {
  148. if (!streamAllowed) return;
  149. if (network) return;
  150. form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
  151. // eslint-disable-next-line react-hooks/exhaustive-deps
  152. }, [streamAllowed, network]);
  153. useEffect(() => {
  154. if (protocol !== 'hysteria') return;
  155. if (network === 'hysteria' && security === 'tls') return;
  156. const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
  157. const slice = hysteriaStreamSlice();
  158. if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
  159. if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
  160. form.setFieldValue('streamSettings', slice);
  161. }, [protocol, network, security, form]);
  162. const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
  163. useEffect(() => {
  164. if (protocol !== 'wireguard') return;
  165. const sk = (wgSecretKey ?? '').trim();
  166. if (!sk) {
  167. form.setFieldValue(['settings', 'pubKey'], '');
  168. return;
  169. }
  170. try {
  171. const { publicKey } = Wireguard.generateKeypair(sk);
  172. form.setFieldValue(['settings', 'pubKey'], publicKey);
  173. } catch {
  174. form.setFieldValue(['settings', 'pubKey'], '');
  175. }
  176. // eslint-disable-next-line react-hooks/exhaustive-deps
  177. }, [protocol, wgSecretKey]);
  178. function onValuesChange(changed: Partial<OutboundFormValues>) {
  179. if ('protocol' in changed && changed.protocol) {
  180. const next = rawOutboundToFormValues({ protocol: changed.protocol });
  181. form.setFieldValue('settings', next.settings);
  182. if (changed.protocol === 'hysteria') {
  183. form.setFieldValue('streamSettings', hysteriaStreamSlice());
  184. } else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
  185. form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
  186. }
  187. }
  188. }
  189. function onSecurityChange(next: string) {
  190. const stream = form.getFieldValue('streamSettings') ?? {};
  191. const cleaned = { ...stream } as Record<string, unknown>;
  192. delete cleaned.tlsSettings;
  193. delete cleaned.realitySettings;
  194. if (next === 'tls') {
  195. cleaned.tlsSettings = {
  196. serverName: '',
  197. alpn: [],
  198. fingerprint: '',
  199. echConfigList: '',
  200. verifyPeerCertByName: '',
  201. pinnedPeerCertSha256: '',
  202. };
  203. } else if (next === 'reality') {
  204. cleaned.realitySettings = {
  205. publicKey: '',
  206. fingerprint: 'chrome',
  207. serverName: '',
  208. shortId: '',
  209. spiderX: '',
  210. mldsa65Verify: '',
  211. };
  212. }
  213. cleaned.security = next;
  214. form.setFieldValue('streamSettings', cleaned);
  215. }
  216. // Network change cascade: swap the per-network sub-key (tcpSettings,
  217. // wsSettings, etc.) so the DU branch matches. Preserve security if
  218. // the new network supports it, otherwise force back to 'none'.
  219. function onNetworkChange(next: string) {
  220. const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
  221. form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
  222. }
  223. function onXmuxToggle(checked: boolean) {
  224. if (!checked) return;
  225. const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
  226. const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
  227. if (hasValues) return;
  228. form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
  229. }
  230. const duplicateTag = useMemo(() => {
  231. const myTag = tag.trim();
  232. if (!myTag) return false;
  233. if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
  234. return (existingTags || []).includes(myTag);
  235. }, [tag, existingTags, isEdit, outboundProp]);
  236. // Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
  237. // any edits into form state. When entering JSON tab, snapshot current
  238. // form values so the user sees the live shape.
  239. function applyJsonToForm(): boolean {
  240. if (!jsonDirty) return true;
  241. const raw = jsonText.trim();
  242. if (!raw) return true;
  243. let parsed: Record<string, unknown>;
  244. try {
  245. parsed = JSON.parse(raw) as Record<string, unknown>;
  246. } catch (e) {
  247. messageApi.error(`JSON: ${(e as Error).message}`);
  248. return false;
  249. }
  250. const next = rawOutboundToFormValues(parsed);
  251. form.resetFields();
  252. form.setFieldsValue(next);
  253. setJsonDirty(false);
  254. return true;
  255. }
  256. function switchTab(key: string) {
  257. if (typeof document !== 'undefined') {
  258. (document.activeElement as HTMLElement | null)?.blur?.();
  259. }
  260. setActiveKey(key);
  261. }
  262. function onTabChange(key: string) {
  263. if (key === '2') {
  264. const values = form.getFieldsValue(true) as OutboundFormValues;
  265. setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
  266. setJsonDirty(false);
  267. switchTab(key);
  268. return;
  269. }
  270. if (key === '1' && activeKey === '2') {
  271. if (!applyJsonToForm()) return;
  272. }
  273. switchTab(key);
  274. }
  275. async function onOk() {
  276. let values: OutboundFormValues;
  277. if (activeKey === '2') {
  278. const raw = jsonText.trim();
  279. if (!raw) return;
  280. let parsed: Record<string, unknown>;
  281. try {
  282. parsed = JSON.parse(raw) as Record<string, unknown>;
  283. } catch (e) {
  284. messageApi.error(`JSON: ${(e as Error).message}`);
  285. return;
  286. }
  287. values = rawOutboundToFormValues(parsed);
  288. form.resetFields();
  289. form.setFieldsValue(values);
  290. setJsonDirty(false);
  291. } else {
  292. try {
  293. await form.validateFields();
  294. } catch {
  295. return;
  296. }
  297. values = form.getFieldsValue(true) as OutboundFormValues;
  298. }
  299. const tagValue = (values.tag ?? '').trim();
  300. if (!tagValue) {
  301. messageApi.error(t('pages.xray.outboundForm.tagRequired'));
  302. return;
  303. }
  304. const isDuplicateTag = (existingTags || []).includes(tagValue)
  305. && !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
  306. if (isDuplicateTag) {
  307. messageApi.error('Tag already used by another outbound');
  308. return;
  309. }
  310. onConfirm(formValuesToWirePayload(values));
  311. }
  312. return (
  313. <>
  314. {messageContextHolder}
  315. <Modal
  316. open={open}
  317. title={title}
  318. okText={okText}
  319. cancelText={t('close')}
  320. mask={{ closable: false }}
  321. width={780}
  322. onOk={onOk}
  323. onCancel={onClose}
  324. destroyOnHidden
  325. >
  326. <Form
  327. form={form}
  328. colon={false}
  329. labelCol={{ md: { span: 8 } }}
  330. wrapperCol={{ md: { span: 14 } }}
  331. onValuesChange={onValuesChange}
  332. >
  333. <Tabs
  334. activeKey={activeKey}
  335. onChange={onTabChange}
  336. items={[
  337. {
  338. key: '1',
  339. label: t('pages.xray.basicTemplate'),
  340. children: (
  341. <>
  342. <Form.Item
  343. label={t('protocol')}
  344. name="protocol"
  345. rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
  346. >
  347. <Select options={PROTOCOL_OPTIONS} />
  348. </Form.Item>
  349. <Form.Item
  350. label={t('pages.xray.outbound.tag')}
  351. name="tag"
  352. validateStatus={duplicateTag ? 'warning' : undefined}
  353. help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
  354. rules={[
  355. { required: true, message: t('pages.xray.outboundForm.tagRequired') },
  356. ]}
  357. >
  358. <Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
  359. </Form.Item>
  360. <Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
  361. <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
  362. </Form.Item>
  363. {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
  364. {protocol === 'vmess' && <VmessFields />}
  365. {protocol === 'vless' && <VlessFields />}
  366. {protocol === 'trojan' && <TrojanFields />}
  367. {protocol === 'shadowsocks' && <ShadowsocksFields />}
  368. {protocol === 'http' && <HttpFields />}
  369. {protocol === 'socks' && <SocksFields />}
  370. {protocol === 'loopback' && <LoopbackFields />}
  371. {protocol === 'blackhole' && <BlackholeFields />}
  372. {protocol === 'dns' && <DnsFields />}
  373. {protocol === 'freedom' && <FreedomFields form={form} />}
  374. {protocol === 'vless' && (
  375. <Form.Item shouldUpdate noStyle>
  376. {() => {
  377. const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
  378. if (!reverseTag) return null;
  379. const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
  380. enabled?: boolean;
  381. };
  382. return (
  383. <>
  384. <Form.Item
  385. label={t('pages.xray.outboundForm.reverseSniffing')}
  386. name={['settings', 'reverseSniffing', 'enabled']}
  387. valuePropName="checked"
  388. >
  389. <Switch />
  390. </Form.Item>
  391. {sniff.enabled && (
  392. <>
  393. <Form.Item
  394. wrapperCol={{ md: { span: 14, offset: 8 } }}
  395. name={['settings', 'reverseSniffing', 'destOverride']}
  396. >
  397. <Select
  398. mode="multiple"
  399. className="sniffing-options"
  400. options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
  401. value: v,
  402. label: k,
  403. }))}
  404. />
  405. </Form.Item>
  406. <Form.Item
  407. label={t('pages.inbounds.sniffingMetadataOnly')}
  408. name={['settings', 'reverseSniffing', 'metadataOnly']}
  409. valuePropName="checked"
  410. >
  411. <Switch />
  412. </Form.Item>
  413. <Form.Item
  414. label={t('pages.inbounds.sniffingRouteOnly')}
  415. name={['settings', 'reverseSniffing', 'routeOnly']}
  416. valuePropName="checked"
  417. >
  418. <Switch />
  419. </Form.Item>
  420. <Form.Item
  421. label={t('pages.inbounds.sniffingIpsExcluded')}
  422. name={['settings', 'reverseSniffing', 'ipsExcluded']}
  423. >
  424. <Select
  425. mode="tags"
  426. tokenSeparators={[',']}
  427. placeholder="IP/CIDR/geoip:*"
  428. />
  429. </Form.Item>
  430. <Form.Item
  431. label={t('pages.inbounds.sniffingDomainsExcluded')}
  432. name={['settings', 'reverseSniffing', 'domainsExcluded']}
  433. >
  434. <Select
  435. mode="tags"
  436. tokenSeparators={[',']}
  437. placeholder="domain:*"
  438. />
  439. </Form.Item>
  440. </>
  441. )}
  442. </>
  443. );
  444. }}
  445. </Form.Item>
  446. )}
  447. {protocol === 'wireguard' && <WireguardFields form={form} />}
  448. {streamAllowed && network && (
  449. <>
  450. <Form.Item
  451. label={t('transmission')}
  452. name={['streamSettings', 'network']}
  453. >
  454. <Select
  455. value={network}
  456. onChange={onNetworkChange}
  457. options={
  458. protocol === 'hysteria'
  459. ? [HYSTERIA_NETWORK_OPTION]
  460. : NETWORK_OPTIONS
  461. }
  462. />
  463. </Form.Item>
  464. {network === 'tcp' && <RawForm form={form} />}
  465. {network === 'kcp' && <KcpForm />}
  466. {network === 'ws' && <WsForm />}
  467. {network === 'grpc' && <GrpcForm />}
  468. {network === 'httpupgrade' && <HttpUpgradeForm />}
  469. {network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
  470. {network === 'hysteria' && <HysteriaForm form={form} />}
  471. </>
  472. )}
  473. {tlsFlowAllowed && (
  474. <Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
  475. <Select
  476. allowClear
  477. placeholder={t('none')}
  478. options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
  479. />
  480. </Form.Item>
  481. )}
  482. {/* Vision seed knobs only meaningful for the exact
  483. xtls-rprx-vision flow, on TCP+(tls|reality). The
  484. legacy class gated this on `canEnableVisionSeed()`
  485. — same condition encoded inline here. */}
  486. <Form.Item shouldUpdate noStyle>
  487. {() => {
  488. const flow =
  489. (form.getFieldValue(['settings', 'flow']) ?? '') as string;
  490. if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
  491. return (
  492. <>
  493. <Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
  494. <InputNumber min={0} style={{ width: '100%' }} />
  495. </Form.Item>
  496. <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
  497. <Space.Compact block>
  498. {[900, 500, 900, 256].map((def, i) => (
  499. <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
  500. <InputNumber min={1} style={{ width: '25%' }} />
  501. </Form.Item>
  502. ))}
  503. </Space.Compact>
  504. </Form.Item>
  505. </>
  506. );
  507. }}
  508. </Form.Item>
  509. {streamAllowed && network && (
  510. <Form.Item label={t('security')}>
  511. <Radio.Group
  512. value={security}
  513. buttonStyle="solid"
  514. onChange={(e) => onSecurityChange(e.target.value as string)}
  515. >
  516. {network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
  517. {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
  518. {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
  519. </Radio.Group>
  520. </Form.Item>
  521. )}
  522. {security === 'tls' && tlsAllowed && <TlsForm />}
  523. {security === 'reality' && realityAllowed && <RealityForm />}
  524. {((streamAllowed && network) || !streamAllowed) && (
  525. <SockoptForm form={form} outboundTags={existingTags} />
  526. )}
  527. <FinalMaskForm
  528. name={['streamSettings', 'finalmask']}
  529. network={network}
  530. protocol={protocol}
  531. form={form}
  532. />
  533. <MuxForm form={form} protocol={protocol} network={network} />
  534. </>
  535. ),
  536. },
  537. {
  538. key: '2',
  539. label: 'JSON',
  540. children: (
  541. <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
  542. <Input.Search
  543. value={linkInput}
  544. placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
  545. enterButton="Import"
  546. onChange={(e) => setLinkInput(e.target.value)}
  547. onSearch={importLink}
  548. />
  549. <JsonEditor
  550. value={jsonText}
  551. onChange={(next) => {
  552. setJsonText(next);
  553. setJsonDirty(true);
  554. }}
  555. minHeight="360px"
  556. maxHeight="600px"
  557. />
  558. </Space>
  559. ),
  560. },
  561. ]}
  562. />
  563. </Form>
  564. </Modal>
  565. </>
  566. );
  567. }