InboundFormModal.tsx 38 KB

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