InboundFormModal.tsx 37 KB

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