InboundFormModal.tsx 34 KB

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