InboundFormModal.tsx 37 KB

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