InboundFormModal.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import dayjs from 'dayjs';
  4. import {
  5. Form,
  6. Input,
  7. InputNumber,
  8. Modal,
  9. Radio,
  10. Select,
  11. Switch,
  12. Tabs,
  13. Tooltip,
  14. message,
  15. } from 'antd';
  16. import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
  17. import {
  18. rawInboundToFormValues,
  19. formValuesToWirePayload,
  20. } from '@/lib/xray/inbound-form-adapter';
  21. import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
  22. import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
  23. import {
  24. canEnableReality,
  25. canEnableStream,
  26. canEnableTls,
  27. isSS2022,
  28. } from '@/lib/xray/protocol-capabilities';
  29. import {
  30. InboundFormBaseSchema,
  31. InboundFormSchema,
  32. type InboundFormValues,
  33. } from '@/schemas/forms/inbound-form';
  34. import { antdRule } from '@/utils/zodForm';
  35. import { Protocols } from '@/schemas/primitives';
  36. import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
  37. import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
  38. import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
  39. import { SniffingSchema } from '@/schemas/primitives/sniffing';
  40. import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
  41. import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
  42. import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
  43. import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
  44. import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
  45. import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
  46. import { DateTimePicker } from '@/components/form';
  47. import { FinalMaskForm } from '@/lib/xray/forms/transport';
  48. import './InboundFormModal.css';
  49. import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
  50. import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
  51. import {
  52. HttpFields,
  53. HysteriaFields,
  54. MixedFields,
  55. ShadowsocksFields,
  56. TunFields,
  57. TunnelFields,
  58. VlessFields,
  59. WireguardFields,
  60. } from './protocols';
  61. import {
  62. ExternalProxyForm,
  63. GrpcForm,
  64. HttpUpgradeForm,
  65. KcpForm,
  66. RawForm,
  67. SockoptForm,
  68. WsForm,
  69. XhttpForm,
  70. } from './transport';
  71. import { RealityForm, TlsForm } from './security';
  72. import { useSecurityActions } from './useSecurityActions';
  73. import { useInboundFallbacks } from './useInboundFallbacks';
  74. import FallbacksCard from './FallbacksCard';
  75. import SniffingTab from './SniffingTab';
  76. import type { DBInbound } from '@/models/dbinbound';
  77. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  78. const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
  79. const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
  80. const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
  81. Protocols.VLESS,
  82. Protocols.VMESS,
  83. Protocols.TROJAN,
  84. Protocols.SHADOWSOCKS,
  85. Protocols.HYSTERIA,
  86. Protocols.WIREGUARD,
  87. ]);
  88. interface InboundFormModalProps {
  89. open: boolean;
  90. onClose: () => void;
  91. onSaved: () => void;
  92. mode: 'add' | 'edit';
  93. dbInbound: DBInbound | null;
  94. dbInbounds: DBInbound[];
  95. availableNodes?: NodeRecord[];
  96. }
  97. function buildAddModeValues(): InboundFormValues {
  98. const settings = createDefaultInboundSettings('vless') ?? undefined;
  99. return rawInboundToFormValues({
  100. protocol: 'vless',
  101. settings,
  102. streamSettings: {
  103. network: 'tcp',
  104. security: 'none',
  105. tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }),
  106. },
  107. sniffing: SniffingSchema.parse({}),
  108. port: RandomUtil.randomInteger(10000, 60000),
  109. listen: '',
  110. tag: '',
  111. enable: true,
  112. trafficReset: 'never',
  113. });
  114. }
  115. export default function InboundFormModal({
  116. open,
  117. onClose,
  118. onSaved,
  119. mode,
  120. dbInbound,
  121. dbInbounds,
  122. availableNodes,
  123. }: InboundFormModalProps) {
  124. const { t } = useTranslation();
  125. const [messageApi, messageContextHolder] = message.useMessage();
  126. const [form] = Form.useForm<InboundFormValues>();
  127. const [saving, setSaving] = useState(false);
  128. const {
  129. fallbacks,
  130. fallbackChildOptions,
  131. loadFallbacks,
  132. saveFallbacks,
  133. addFallback,
  134. updateFallback,
  135. removeFallback,
  136. moveFallback,
  137. addAllFallbacks,
  138. } = useInboundFallbacks(dbInbound, dbInbounds);
  139. const selectableNodes = (availableNodes || []).filter((n) => n.enable);
  140. const protocol = (Form.useWatch('protocol', form) ?? '') as string;
  141. const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
  142. const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
  143. const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
  144. const ssMethod = Form.useWatch(['settings', 'method'], form);
  145. const isSSWith2022 = isSS2022({
  146. protocol,
  147. settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
  148. });
  149. const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
  150. const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
  151. const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
  152. const streamEnabled = canEnableStream({ protocol });
  153. const wPort = Form.useWatch('port', form);
  154. const wListen = (Form.useWatch('listen', form) ?? '') as string;
  155. const isUdsListen = wListen.startsWith('/');
  156. const wNodeId = Form.useWatch('nodeId', form) ?? null;
  157. const wTag = Form.useWatch('tag', form) ?? '';
  158. const wSsNetwork = Form.useWatch(['settings', 'network'], form);
  159. const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
  160. const autoTagRef = useRef(true);
  161. const lastWrittenTagRef = useRef('');
  162. const currentTagInput = (): InboundTagInput => ({
  163. port: typeof wPort === 'number' ? wPort : 0,
  164. nodeId: typeof wNodeId === 'number' ? wNodeId : null,
  165. protocol,
  166. streamSettings: { network },
  167. settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
  168. });
  169. const isFallbackHost =
  170. (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
  171. && network === 'tcp'
  172. && (security === 'tls' || security === 'reality');
  173. const {
  174. genRealityKeypair,
  175. clearRealityKeypair,
  176. genMldsa65,
  177. clearMldsa65,
  178. randomizeRealityTarget,
  179. randomizeShortIds,
  180. getNewEchCert,
  181. clearEchCert,
  182. generateRandomPinHash,
  183. setCertFromPanel,
  184. clearCertFiles,
  185. onSecurityChange,
  186. } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
  187. const toggleExternalProxy = (on: boolean) => {
  188. if (on) {
  189. const port = (form.getFieldValue('port') as number) ?? 443;
  190. form.setFieldValue(['streamSettings', 'externalProxy'], [{
  191. forceTls: 'same',
  192. dest: typeof window !== 'undefined' ? window.location.hostname : '',
  193. port,
  194. remark: '',
  195. sni: '',
  196. fingerprint: '',
  197. alpn: [],
  198. pinnedPeerCertSha256: [],
  199. }]);
  200. } else {
  201. form.setFieldValue(['streamSettings', 'externalProxy'], []);
  202. }
  203. };
  204. const toggleSockopt = (on: boolean) => {
  205. if (on) {
  206. form.setFieldValue(
  207. ['streamSettings', 'sockopt'],
  208. SockoptStreamSettingsSchema.parse({}),
  209. );
  210. } else {
  211. form.setFieldValue(['streamSettings', 'sockopt'], undefined);
  212. }
  213. };
  214. const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
  215. const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
  216. ? Wireguard.generateKeypair(wgSecretKey).publicKey
  217. : '';
  218. const regenInboundWg = () => {
  219. const kp = Wireguard.generateKeypair();
  220. form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
  221. };
  222. const regenWgPeerKeypair = (peerName: number) => {
  223. const kp = Wireguard.generateKeypair();
  224. form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
  225. form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
  226. };
  227. const matchesVlessAuth = (
  228. block: { id?: string; label?: string } | undefined | null,
  229. authId: string,
  230. ) => {
  231. if (block?.id === authId) return true;
  232. const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
  233. if (authId === 'mlkem768') return label.includes('mlkem768');
  234. if (authId === 'x25519') return label.includes('x25519');
  235. return false;
  236. };
  237. const getNewVlessEnc = async (authId: string) => {
  238. if (!authId) return;
  239. setSaving(true);
  240. try {
  241. const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
  242. if (!msg?.success) return;
  243. const obj = msg.obj as {
  244. auths?: { decryption: string; encryption: string; label?: string; id?: string }[];
  245. };
  246. const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
  247. if (!block) return;
  248. form.setFieldValue(['settings', 'decryption'], block.decryption);
  249. form.setFieldValue(['settings', 'encryption'], block.encryption);
  250. } finally {
  251. setSaving(false);
  252. }
  253. };
  254. const clearVlessEnc = () => {
  255. form.setFieldValue(['settings', 'decryption'], 'none');
  256. form.setFieldValue(['settings', 'encryption'], 'none');
  257. };
  258. const selectedVlessAuth = (() => {
  259. const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
  260. if (!enc || enc === 'none') return 'None';
  261. const parts = enc.split('.').filter(Boolean);
  262. const authKey = parts[parts.length - 1] || '';
  263. if (!authKey) return t('pages.inbounds.vlessAuthCustom');
  264. return authKey.length > 300
  265. ? t('pages.inbounds.vlessAuthMlkem768')
  266. : t('pages.inbounds.vlessAuthX25519');
  267. })();
  268. useEffect(() => {
  269. if (!open) return;
  270. const initial = mode === 'edit' && dbInbound
  271. ? rawInboundToFormValues(dbInbound)
  272. : buildAddModeValues();
  273. form.resetFields();
  274. form.setFieldsValue(initial);
  275. const initialTag = (initial.tag ?? '') as string;
  276. autoTagRef.current = isAutoInboundTag(initialTag, {
  277. port: initial.port ?? 0,
  278. nodeId: initial.nodeId ?? null,
  279. protocol: initial.protocol,
  280. streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
  281. settings: (initial.settings ?? {}) as Record<string, unknown>,
  282. });
  283. lastWrittenTagRef.current = initialTag;
  284. if (
  285. mode === 'edit'
  286. && dbInbound
  287. && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
  288. ) {
  289. loadFallbacks(dbInbound.id);
  290. } else {
  291. loadFallbacks(null);
  292. }
  293. // eslint-disable-next-line react-hooks/exhaustive-deps
  294. }, [open, mode, dbInbound, form]);
  295. useEffect(() => {
  296. if (!open) return;
  297. if (wTag === lastWrittenTagRef.current) return;
  298. autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
  299. // eslint-disable-next-line react-hooks/exhaustive-deps
  300. }, [open, wTag]);
  301. useEffect(() => {
  302. if (!open || !autoTagRef.current) return;
  303. const next = composeInboundTag(currentTagInput());
  304. if (next !== (form.getFieldValue('tag') ?? '')) {
  305. lastWrittenTagRef.current = next;
  306. form.setFieldValue('tag', next);
  307. }
  308. // eslint-disable-next-line react-hooks/exhaustive-deps
  309. }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
  310. // Why: protocol picker reset cascades through the form — clearing the
  311. // settings DU branch and dropping a nodeId that no longer applies. The
  312. // legacy modal did this imperatively in onProtocolChange; here we hook
  313. // into AntD's onValuesChange and let setFieldValue keep the rest of
  314. // the form state intact.
  315. const onValuesChange = (changed: Partial<InboundFormValues>) => {
  316. if (mode === 'edit') return;
  317. if ('protocol' in changed && typeof changed.protocol === 'string') {
  318. const next = changed.protocol;
  319. const settings = createDefaultInboundSettings(next) ?? undefined;
  320. form.setFieldValue('settings', settings);
  321. if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
  322. form.setFieldValue('nodeId', null);
  323. }
  324. // Hysteria uses its dedicated transport — force the network branch
  325. // so the stream tab renders the hysteria sub-form, not the leftover
  326. // tcpSettings from the previous protocol. When leaving hysteria,
  327. // snap back to TCP so the standard network selector has a valid
  328. // starting point.
  329. if (next === Protocols.HYSTERIA) {
  330. const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
  331. tls.certificates = [{
  332. useFile: true,
  333. certificateFile: '',
  334. keyFile: '',
  335. certificate: [],
  336. key: [],
  337. oneTimeLoading: false,
  338. usage: 'encipherment',
  339. buildChain: false,
  340. }];
  341. form.setFieldValue('streamSettings', {
  342. network: 'hysteria',
  343. security: 'tls',
  344. hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
  345. tlsSettings: tls,
  346. // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
  347. // it with salamander + a random password so the listener boots
  348. // with a usable default. Re-selecting Hysteria from another
  349. // protocol re-runs this and refreshes the password — that's
  350. // intentional, the form was already being reset.
  351. finalmask: {
  352. tcp: [],
  353. udp: [{
  354. type: 'salamander',
  355. settings: { password: RandomUtil.randomLowerAndNum(16) },
  356. }],
  357. },
  358. });
  359. } else {
  360. const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
  361. if (current?.network === 'hysteria') {
  362. form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
  363. }
  364. }
  365. }
  366. };
  367. const submit = async () => {
  368. try {
  369. await form.validateFields();
  370. } catch {
  371. return;
  372. }
  373. // Why getFieldsValue(true) instead of the validateFields return value:
  374. // rc-component/form's validateFields filters its output by REGISTERED
  375. // name paths. settings.clients and settings.fallbacks have no Form.Item
  376. // bound to them (clients are managed via the standalone Client modal,
  377. // not inside this inbound modal) — so validateFields would drop them
  378. // and the update wire payload would silently delete every client on
  379. // every save. getFieldsValue(true) returns the entire form store and
  380. // keeps those sub-trees intact.
  381. const values = form.getFieldsValue(true) as InboundFormValues;
  382. const parsed = InboundFormSchema.safeParse(values);
  383. if (!parsed.success) {
  384. const issues = parsed.error.issues;
  385. messageApi.error(formatInboundValidation(issues, values, t));
  386. console.error(
  387. '[InboundFormModal] schema validation failed:',
  388. issues.map((issue) => formatInboundIssue(issue, values, t)),
  389. );
  390. return;
  391. }
  392. setSaving(true);
  393. try {
  394. const payload = formValuesToWirePayload(parsed.data);
  395. const url = mode === 'edit' && dbInbound
  396. ? `/panel/api/inbounds/update/${dbInbound.id}`
  397. : '/panel/api/inbounds/add';
  398. const msg = await HttpUtil.post(url, payload);
  399. if (msg?.success) {
  400. if (isFallbackHost) {
  401. const obj = msg.obj as { id?: number; Id?: number } | null;
  402. const masterId = mode === 'edit'
  403. ? dbInbound!.id
  404. : (obj?.id ?? obj?.Id ?? 0);
  405. if (masterId) await saveFallbacks(masterId);
  406. }
  407. onSaved();
  408. onClose();
  409. }
  410. } finally {
  411. setSaving(false);
  412. }
  413. };
  414. const title = mode === 'edit'
  415. ? t('pages.inbounds.modifyInbound')
  416. : t('pages.inbounds.addInbound');
  417. const okText = mode === 'edit'
  418. ? t('pages.clients.submitEdit')
  419. : t('create');
  420. const basicTab = (
  421. <>
  422. <Form.Item name="tag" hidden noStyle><Input /></Form.Item>
  423. <Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
  424. <Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
  425. <Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
  426. <Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
  427. <Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
  428. <Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
  429. <Form.Item name="enable" label={t('enable')} valuePropName="checked">
  430. <Switch />
  431. </Form.Item>
  432. <Form.Item name="remark" label={t('pages.inbounds.remark')}>
  433. <Input />
  434. </Form.Item>
  435. {selectableNodes.length > 0 && isNodeEligible && (
  436. <Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
  437. <Select
  438. disabled={mode === 'edit'}
  439. placeholder={t('pages.inbounds.localPanel')}
  440. allowClear
  441. options={selectableNodes.map((n) => ({
  442. value: n.id,
  443. label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
  444. disabled: n.status === 'offline',
  445. }))}
  446. />
  447. </Form.Item>
  448. )}
  449. <Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
  450. <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
  451. </Form.Item>
  452. <Form.Item
  453. name="listen"
  454. label={t('pages.inbounds.address')}
  455. extra={t('pages.inbounds.form.listenHelp')}
  456. >
  457. <Input placeholder={t('pages.inbounds.monitorDesc')} />
  458. </Form.Item>
  459. <Form.Item
  460. name="port"
  461. label={t('pages.inbounds.port')}
  462. rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
  463. >
  464. <InputNumber min={isUdsListen ? 0 : 1} max={65535} />
  465. </Form.Item>
  466. <Form.Item
  467. label={
  468. <Tooltip title={t('pages.inbounds.meansNoLimit')}>
  469. {t('pages.inbounds.totalFlow')}
  470. </Tooltip>
  471. }
  472. >
  473. <Form.Item
  474. noStyle
  475. shouldUpdate={(prev, curr) => prev.total !== curr.total}
  476. >
  477. {({ getFieldValue, setFieldValue }) => {
  478. const totalBytes = (getFieldValue('total') as number) ?? 0;
  479. const totalGB = totalBytes
  480. ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
  481. : 0;
  482. return (
  483. <InputNumber
  484. value={totalGB}
  485. min={0}
  486. step={1}
  487. onChange={(v) => {
  488. const bytes = NumberFormatter.toFixed(
  489. (Number(v) || 0) * SizeFormatter.ONE_GB,
  490. 0,
  491. );
  492. setFieldValue('total', bytes);
  493. }}
  494. />
  495. );
  496. }}
  497. </Form.Item>
  498. </Form.Item>
  499. <Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
  500. <Select
  501. options={TRAFFIC_RESETS.map((r) => ({
  502. value: r,
  503. label: t(`pages.inbounds.periodicTrafficReset.${r}`),
  504. }))}
  505. />
  506. </Form.Item>
  507. <Form.Item
  508. label={
  509. <Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
  510. {t('pages.inbounds.expireDate')}
  511. </Tooltip>
  512. }
  513. >
  514. <Form.Item
  515. noStyle
  516. shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
  517. >
  518. {({ getFieldValue, setFieldValue }) => {
  519. const expiry = (getFieldValue('expiryTime') as number) ?? 0;
  520. return (
  521. <DateTimePicker
  522. value={expiry > 0 ? dayjs(expiry) : null}
  523. onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
  524. />
  525. );
  526. }}
  527. </Form.Item>
  528. </Form.Item>
  529. </>
  530. );
  531. const fallbacksCard = (
  532. <FallbacksCard
  533. fallbacks={fallbacks}
  534. fallbackChildOptions={fallbackChildOptions}
  535. addFallback={addFallback}
  536. updateFallback={updateFallback}
  537. removeFallback={removeFallback}
  538. moveFallback={moveFallback}
  539. addAllFallbacks={addAllFallbacks}
  540. />
  541. );
  542. const protocolTab = (
  543. <>
  544. {protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
  545. {protocol === Protocols.TUN && <TunFields />}
  546. {protocol === Protocols.TUNNEL && <TunnelFields />}
  547. {protocol === Protocols.HTTP && <HttpFields />}
  548. {protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
  549. {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
  550. {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
  551. {isFallbackHost && fallbacksCard}
  552. </>
  553. );
  554. // Switching `network` swaps which per-network key (tcpSettings,
  555. // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
  556. // network's blob and seed the new one with the schema defaults so the
  557. // Form.Items inside it have valid initial values (KCP needs MTU=1350
  558. // etc., not empty strings).
  559. // Seed each network's settings blob with its Zod schema defaults so
  560. // every Form.Item inside the network sub-form has a defined starting
  561. // value. XHTTP in particular has ~20 fields (sessionPlacement,
  562. // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
  563. // is the literal "" sentinel meaning "let xray-core pick its
  564. // default". Without seeding "", the Form.Item reads `undefined` and
  565. // the Select shows blank instead of the "Default (path)" option.
  566. const newStreamSlice = (n: string): Record<string, unknown> => {
  567. switch (n) {
  568. case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
  569. case 'kcp': return KcpStreamSettingsSchema.parse({});
  570. case 'ws': return WsStreamSettingsSchema.parse({});
  571. case 'grpc': return GrpcStreamSettingsSchema.parse({});
  572. case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
  573. case 'xhttp': return XHttpStreamSettingsSchema.parse({});
  574. default: return {};
  575. }
  576. };
  577. const onNetworkChange = (next: string) => {
  578. const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
  579. const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
  580. const cleaned: Record<string, unknown> = { ...current, network: next };
  581. for (const k of ALL) {
  582. if (k !== `${next}Settings`) delete cleaned[k];
  583. }
  584. cleaned[`${next}Settings`] = newStreamSlice(next);
  585. // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
  586. // `mkcp-legacy` so the inbound boots with a sensible default
  587. // instead of unobfuscated mKCP traffic. The user can still edit or
  588. // clear the mask via the FinalMask section.
  589. if (next === 'kcp') {
  590. const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
  591. const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
  592. const hasMkcp = udp.some((m) => {
  593. const entry = m as { type?: string };
  594. return entry?.type === 'mkcp-legacy';
  595. });
  596. if (!hasMkcp) {
  597. cleaned.finalmask = {
  598. ...fm,
  599. udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
  600. };
  601. }
  602. }
  603. form.setFieldValue('streamSettings', cleaned);
  604. };
  605. const streamTab = (
  606. <>
  607. {protocol !== Protocols.HYSTERIA && (
  608. <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
  609. <Select
  610. style={{ width: '75%' }}
  611. onChange={onNetworkChange}
  612. options={[
  613. { value: 'tcp', label: 'RAW' },
  614. { value: 'kcp', label: 'mKCP' },
  615. { value: 'ws', label: 'WebSocket' },
  616. { value: 'grpc', label: 'gRPC' },
  617. { value: 'httpupgrade', label: 'HTTPUpgrade' },
  618. { value: 'xhttp', label: 'XHTTP' },
  619. ]}
  620. />
  621. </Form.Item>
  622. )}
  623. {/* Inbound Hysteria stream sub-form. The transport for hysteria
  624. isn't user-selectable (always 'hysteria'), so the network
  625. dropdown is hidden above. Fields here mirror the legacy
  626. HysteriaStreamSettings inbound class: version is locked to 2,
  627. auth + udpIdleTimeout are required, masquerade is an optional
  628. sub-object that lets xray-core disguise the listener as an
  629. HTTP server when probed. */}
  630. {protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
  631. {network === 'tcp' && <RawForm />}
  632. {network === 'ws' && <WsForm />}
  633. {network === 'grpc' && <GrpcForm />}
  634. {network === 'xhttp' && <XhttpForm form={form} />}
  635. {network === 'httpupgrade' && <HttpUpgradeForm />}
  636. {network === 'kcp' && <KcpForm />}
  637. <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
  638. <SockoptForm toggleSockopt={toggleSockopt} />
  639. <FinalMaskForm
  640. name={['streamSettings', 'finalmask']}
  641. network={network as string}
  642. protocol={protocol}
  643. form={form}
  644. />
  645. </>
  646. );
  647. const securityTab = (
  648. <>
  649. <Form.Item name={['streamSettings', 'security']} hidden noStyle>
  650. <Input />
  651. </Form.Item>
  652. <Form.Item label={t('pages.inbounds.securityTab')}>
  653. <Form.Item
  654. noStyle
  655. shouldUpdate={(prev, curr) =>
  656. prev.streamSettings?.security !== curr.streamSettings?.security
  657. || prev.streamSettings?.network !== curr.streamSettings?.network
  658. || prev.protocol !== curr.protocol
  659. }
  660. >
  661. {({ getFieldValue }) => {
  662. const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
  663. const net = getFieldValue(['streamSettings', 'network']) ?? '';
  664. const proto = getFieldValue('protocol') ?? '';
  665. const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
  666. const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
  667. const tlsOnly = proto === Protocols.HYSTERIA;
  668. return (
  669. <Radio.Group
  670. value={sec}
  671. buttonStyle="solid"
  672. disabled={!tlsOk}
  673. onChange={(e) => onSecurityChange(e.target.value)}
  674. >
  675. {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
  676. <Radio.Button value="tls">TLS</Radio.Button>
  677. {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
  678. </Radio.Group>
  679. );
  680. }}
  681. </Form.Item>
  682. </Form.Item>
  683. {security === 'tls' && (
  684. <TlsForm
  685. saving={saving}
  686. setCertFromPanel={setCertFromPanel}
  687. clearCertFiles={clearCertFiles}
  688. generateRandomPinHash={generateRandomPinHash}
  689. getNewEchCert={getNewEchCert}
  690. clearEchCert={clearEchCert}
  691. />
  692. )}
  693. {security === 'reality' && (
  694. <RealityForm
  695. saving={saving}
  696. randomizeRealityTarget={randomizeRealityTarget}
  697. randomizeShortIds={randomizeShortIds}
  698. genRealityKeypair={genRealityKeypair}
  699. clearRealityKeypair={clearRealityKeypair}
  700. genMldsa65={genMldsa65}
  701. clearMldsa65={clearMldsa65}
  702. />
  703. )}
  704. </>
  705. );
  706. const advancedTab = (
  707. <div className="advanced-shell">
  708. <div className="advanced-panel">
  709. <div className="advanced-panel__header">
  710. <div>
  711. <div className="advanced-panel__title">{t('pages.inbounds.advanced.title')}</div>
  712. <div className="advanced-panel__subtitle">{t('pages.inbounds.advanced.subtitle')}</div>
  713. </div>
  714. </div>
  715. <Tabs
  716. className="advanced-inner-tabs"
  717. items={[
  718. {
  719. key: 'all',
  720. label: t('pages.inbounds.advanced.all'),
  721. children: (
  722. <>
  723. <div className="advanced-editor-meta">
  724. {t('pages.inbounds.advanced.allHelp')}
  725. </div>
  726. <AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
  727. </>
  728. ),
  729. },
  730. {
  731. key: 'settings',
  732. label: t('pages.inbounds.advanced.settings'),
  733. children: (
  734. <>
  735. <div className="advanced-editor-meta">
  736. {t('pages.inbounds.advanced.settingsHelp')}{' '}
  737. <code>{'{ settings: { ... } }'}</code>.
  738. </div>
  739. <AdvancedSliceEditor
  740. form={form}
  741. path="settings"
  742. wrapKey="settings"
  743. minHeight="320px"
  744. maxHeight="540px"
  745. />
  746. </>
  747. ),
  748. },
  749. ...(streamEnabled
  750. ? [{
  751. key: 'stream',
  752. label: t('pages.inbounds.advanced.stream'),
  753. children: (
  754. <>
  755. <div className="advanced-editor-meta">
  756. {t('pages.inbounds.advanced.streamHelp')}{' '}
  757. <code>{'{ streamSettings: { ... } }'}</code>.
  758. </div>
  759. <AdvancedSliceEditor
  760. form={form}
  761. path="streamSettings"
  762. wrapKey="streamSettings"
  763. minHeight="320px"
  764. maxHeight="540px"
  765. />
  766. </>
  767. ),
  768. }]
  769. : []),
  770. {
  771. key: 'sniffing',
  772. label: t('pages.inbounds.advanced.sniffing'),
  773. children: (
  774. <>
  775. <div className="advanced-editor-meta">
  776. {t('pages.inbounds.advanced.sniffingHelp')}{' '}
  777. <code>{'{ sniffing: { ... } }'}</code>.
  778. </div>
  779. <AdvancedSliceEditor
  780. form={form}
  781. path="sniffing"
  782. wrapKey="sniffing"
  783. minHeight="240px"
  784. maxHeight="420px"
  785. />
  786. </>
  787. ),
  788. },
  789. ]}
  790. />
  791. </div>
  792. </div>
  793. );
  794. const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
  795. return (
  796. <>
  797. {messageContextHolder}
  798. <Modal
  799. open={open}
  800. title={title}
  801. okText={okText}
  802. cancelText={t('close')}
  803. confirmLoading={saving}
  804. mask={{ closable: false }}
  805. width={780}
  806. onOk={submit}
  807. onCancel={onClose}
  808. destroyOnHidden
  809. >
  810. <Form
  811. form={form}
  812. colon={false}
  813. labelCol={{ sm: { span: 8 } }}
  814. wrapperCol={{ sm: { span: 14 } }}
  815. onValuesChange={onValuesChange}
  816. >
  817. <Tabs items={[
  818. // forceRender on every tab so all Form.Items register at modal
  819. // open, not lazily on first visit. Without it, AntD's items API
  820. // lazy-mounts inactive tabs — their fields don't register, so
  821. // Form.useWatch on a parent path (e.g. 'sniffing') returns the
  822. // partial-view {} until the user touches the tab and the
  823. // inner Form.Item for `sniffing.enabled` registers.
  824. { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
  825. ...(([
  826. Protocols.VLESS,
  827. Protocols.SHADOWSOCKS,
  828. Protocols.HTTP,
  829. Protocols.MIXED,
  830. Protocols.TUNNEL,
  831. Protocols.TUN,
  832. Protocols.WIREGUARD,
  833. ] as string[]).includes(protocol) || isFallbackHost
  834. ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
  835. : []),
  836. ...(streamEnabled
  837. ? [
  838. { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
  839. { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
  840. ]
  841. : []),
  842. { key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
  843. { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
  844. ]} />
  845. </Form>
  846. </Modal>
  847. </>
  848. );
  849. }