FinalMaskForm.tsx 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553
  1. import { useEffect, useRef } from 'react';
  2. import {
  3. AutoComplete,
  4. Button,
  5. Divider,
  6. Form,
  7. Input,
  8. InputNumber,
  9. Select,
  10. Space,
  11. Switch,
  12. } from 'antd';
  13. import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
  14. import { useTranslation } from 'react-i18next';
  15. import type { FormInstance } from 'antd/es/form';
  16. import type { NamePath } from 'antd/es/form/interface';
  17. import { RandomUtil } from '@/utils';
  18. import { activateOnKey } from '@/utils/a11y';
  19. import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
  20. const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({
  21. value,
  22. label: value,
  23. }));
  24. export interface FinalMaskFormProps {
  25. name: NamePath;
  26. network: string;
  27. protocol: string;
  28. form: FormInstance;
  29. // When true, all sections (TCP / UDP / QUIC) are shown regardless of
  30. // network/protocol. Used by the global sub-JSON finalmask editor where
  31. // the masks apply to every stream rather than one specific transport.
  32. showAll?: boolean;
  33. }
  34. const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
  35. const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
  36. // Xray-core caps the Gecko output packet size at its internal buffer (2048)
  37. // and needs 1 <= min <= max; mirror those bounds so the panel rejects what
  38. // core would reject at runtime (salamander/conn.go).
  39. const GECKO_MIN_PACKET_SIZE = 1;
  40. const GECKO_MAX_PACKET_SIZE = 2048;
  41. export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null {
  42. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  43. const match = /^(\d+)-(\d+)$/.exec(str);
  44. if (!match) return null;
  45. const min = Number(match[1]);
  46. const max = Number(match[2]);
  47. if (
  48. !Number.isSafeInteger(min) ||
  49. !Number.isSafeInteger(max) ||
  50. min < GECKO_MIN_PACKET_SIZE ||
  51. max < min ||
  52. max > GECKO_MAX_PACKET_SIZE
  53. ) {
  54. return null;
  55. }
  56. return { min, max };
  57. }
  58. function formatGeckoPacketSize(min: number, max: number): string {
  59. return `${min}-${max}`;
  60. }
  61. function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } {
  62. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  63. const [minRaw = '', maxRaw = ''] = str.split('-', 2);
  64. const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null;
  65. const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null;
  66. return { min, max };
  67. }
  68. function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
  69. if (parseGeckoPacketSize(value)) return Promise.resolve();
  70. return Promise.reject(
  71. new Error(
  72. `Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
  73. ),
  74. );
  75. }
  76. function asPath(name: NamePath): (string | number)[] {
  77. return Array.isArray(name) ? [...name] : [name];
  78. }
  79. function defaultTcpMaskSettings(type: string): Record<string, unknown> {
  80. switch (type) {
  81. case 'fragment':
  82. // `lengths`/`delays` are per-segment range arrays (xray-core #6334);
  83. // a single length entry reproduces the legacy single-range behavior.
  84. return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
  85. case 'sudoku':
  86. return {
  87. password: '',
  88. ascii: '',
  89. customTable: '',
  90. customTables: [],
  91. paddingMin: 0,
  92. paddingMax: 0,
  93. };
  94. case 'header-custom':
  95. return { clients: [], servers: [] };
  96. case 'xmc':
  97. return {
  98. hostname: '',
  99. profiles: [defaultXmcProfile()],
  100. password: RandomUtil.randomLowerAndNum(16),
  101. };
  102. default:
  103. return {};
  104. }
  105. }
  106. function defaultXmcProfile(): Record<string, unknown> {
  107. return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
  108. }
  109. // xray-core #6487 replaced the xmc mask's `usernames` string list with
  110. // `profiles` objects carrying a Mojang-signed session profile, and dropped the
  111. // "default to Dream" fallback so at least one complete profile is now
  112. // mandatory. The signature can only come from Mojang's session server, so a
  113. // legacy username cannot be upgraded automatically — carry it into a profile
  114. // stub instead, which keeps the operator's player names visible and leaves the
  115. // per-field validators pointing at exactly what still has to be filled in.
  116. export function migrateXmcSettings(settings: Record<string, unknown>): {
  117. next: Record<string, unknown>;
  118. changed: boolean;
  119. } {
  120. const out: Record<string, unknown> = { ...settings };
  121. let changed = false;
  122. if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
  123. out.profiles = out.usernames
  124. .filter((name): name is string => typeof name === 'string' && name.trim() !== '')
  125. .map((name) => ({ ...defaultXmcProfile(), username: name }));
  126. changed = true;
  127. }
  128. if ('usernames' in out) {
  129. delete out.usernames;
  130. changed = true;
  131. }
  132. if (!Array.isArray(out.profiles)) {
  133. out.profiles = [];
  134. changed = true;
  135. }
  136. return { next: out, changed };
  137. }
  138. // xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
  139. // with `lengths`/`delays` arrays (the singular keys remain in core only as a
  140. // fallback). Lift any legacy singular value into a one-element array so the
  141. // list UI shows it, and drop the singular key so we never emit both.
  142. function migrateFragmentSettings(settings: Record<string, unknown>): {
  143. next: Record<string, unknown>;
  144. changed: boolean;
  145. } {
  146. const out: Record<string, unknown> = { ...settings };
  147. let changed = false;
  148. if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
  149. out.lengths = [out.length];
  150. changed = true;
  151. }
  152. if ('length' in out) {
  153. delete out.length;
  154. changed = true;
  155. }
  156. if (!Array.isArray(out.delays) && typeof out.delay === 'string' && out.delay.trim() !== '') {
  157. out.delays = [out.delay];
  158. changed = true;
  159. }
  160. if ('delay' in out) {
  161. delete out.delay;
  162. changed = true;
  163. }
  164. return { next: out, changed };
  165. }
  166. function defaultUdpMaskSettings(type: string): Record<string, unknown> {
  167. switch (type) {
  168. case 'salamander':
  169. return { password: '' };
  170. case 'mkcp-legacy':
  171. return { header: '', value: '' };
  172. case 'xdns':
  173. return { domains: [] };
  174. case 'xicmp':
  175. return { dgram: false, ips: [] };
  176. case 'realm':
  177. return { url: '', stunServers: [] };
  178. case 'header-custom':
  179. return { client: [], server: [] };
  180. case 'noise':
  181. return { reset: 0, noise: [] };
  182. default:
  183. return {};
  184. }
  185. }
  186. function defaultClientServerItem(): Record<string, unknown> {
  187. return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
  188. }
  189. function defaultUdpClientServerItem(): Record<string, unknown> {
  190. return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
  191. }
  192. function defaultNoiseItem(): Record<string, unknown> {
  193. return {
  194. rand: '1-8192',
  195. randRange: '0-255',
  196. type: 'array',
  197. packet: [],
  198. delay: '10-20',
  199. };
  200. }
  201. function defaultQuicParams(): Record<string, unknown> {
  202. return {
  203. congestion: 'bbr',
  204. debug: false,
  205. maxIdleTimeout: 30,
  206. keepAlivePeriod: 10,
  207. disablePathMTUDiscovery: false,
  208. maxIncomingStreams: 1024,
  209. initStreamReceiveWindow: 8388608,
  210. maxStreamReceiveWindow: 8388608,
  211. initConnectionReceiveWindow: 20971520,
  212. maxConnectionReceiveWindow: 20971520,
  213. };
  214. }
  215. function defaultUdpHop(): Record<string, unknown> {
  216. return { ports: '20000-50000', interval: '5-10' };
  217. }
  218. export default function FinalMaskForm({
  219. name,
  220. network,
  221. protocol,
  222. form,
  223. showAll = false,
  224. }: FinalMaskFormProps) {
  225. const base = asPath(name);
  226. // Migrate legacy TCP mask shapes once on mount so configs saved before
  227. // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
  228. const migratedRef = useRef(false);
  229. useEffect(() => {
  230. if (migratedRef.current) return;
  231. migratedRef.current = true;
  232. const tcp = form.getFieldValue([...base, 'tcp']);
  233. if (!Array.isArray(tcp)) return;
  234. let anyChanged = false;
  235. const next = tcp.map((mask) => {
  236. if (!mask || typeof mask !== 'object') return mask;
  237. const m = mask as Record<string, unknown>;
  238. if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
  239. if (!m.settings || typeof m.settings !== 'object') return mask;
  240. const settings = m.settings as Record<string, unknown>;
  241. const { next: migrated, changed } =
  242. m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings);
  243. if (!changed) return mask;
  244. anyChanged = true;
  245. return { ...m, settings: migrated };
  246. });
  247. if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
  248. // eslint-disable-next-line react-hooks/exhaustive-deps
  249. }, []);
  250. const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
  251. // Wireguard carries no user-selectable transport (always a UDP listener/
  252. // dialer), so only the UDP mask section applies — TCP masks would never
  253. // wrap anything even though the leftover network value may be 'tcp'.
  254. const isWireguard = protocol === 'wireguard';
  255. const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network));
  256. const showUdp = showAll || isHysteria || isWireguard || network === 'kcp';
  257. const showQuic = showAll || isHysteria || network === 'xhttp';
  258. const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
  259. const hasQuicParams = quicParams != null;
  260. if (!showTcp && !showUdp && !showQuic) return null;
  261. return (
  262. <>
  263. {showTcp && <TcpMasksList base={base} form={form} />}
  264. {showUdp && (
  265. <UdpMasksList
  266. base={base}
  267. form={form}
  268. isHysteria={isHysteria}
  269. isWireguard={isWireguard}
  270. network={network}
  271. />
  272. )}
  273. {showQuic && (
  274. <>
  275. <Form.Item label="QUIC Params">
  276. <Switch
  277. checked={hasQuicParams}
  278. onChange={(v) => {
  279. form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
  280. }}
  281. />
  282. </Form.Item>
  283. {hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
  284. </>
  285. )}
  286. </>
  287. );
  288. }
  289. function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
  290. const { t } = useTranslation();
  291. return (
  292. <Form.List name={[...base, 'tcp']}>
  293. {(fields, { add, remove }) => (
  294. <>
  295. <Form.Item label="TCP Masks">
  296. <Button
  297. type="primary"
  298. size="small"
  299. icon={<PlusOutlined />}
  300. aria-label={t('add')}
  301. onClick={() =>
  302. add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
  303. }
  304. />
  305. </Form.Item>
  306. {fields.map((field, mIdx) => (
  307. <TcpMaskItem
  308. key={field.key}
  309. fieldName={field.name}
  310. displayIndex={mIdx + 1}
  311. form={form}
  312. listPath={[...base, 'tcp']}
  313. onRemove={() => remove(field.name)}
  314. />
  315. ))}
  316. </>
  317. )}
  318. </Form.List>
  319. );
  320. }
  321. function TcpMaskItem({
  322. fieldName,
  323. displayIndex,
  324. form,
  325. listPath,
  326. onRemove,
  327. }: {
  328. fieldName: number;
  329. displayIndex: number;
  330. form: FormInstance;
  331. listPath: (string | number)[];
  332. onRemove: () => void;
  333. }) {
  334. // Absolute path for setFieldValue side effects (resetting settings on
  335. // type change). All Form.Item `name=` use RELATIVE paths within the
  336. // outer Form.List context.
  337. const absolutePath = [...listPath, fieldName];
  338. const { t } = useTranslation();
  339. return (
  340. <div>
  341. <Divider style={{ margin: 0 }}>
  342. TCP Mask {displayIndex}
  343. <DeleteOutlined
  344. className="danger-icon"
  345. role="button"
  346. tabIndex={0}
  347. aria-label={t('remove')}
  348. onClick={onRemove}
  349. onKeyDown={activateOnKey(onRemove)}
  350. />
  351. </Divider>
  352. <Form.Item label="Type" name={[fieldName, 'type']}>
  353. <Select
  354. onChange={(v) =>
  355. form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
  356. }
  357. options={[
  358. { value: 'fragment', label: 'Fragment' },
  359. { value: 'header-custom', label: 'Header Custom' },
  360. { value: 'sudoku', label: 'Sudoku' },
  361. { value: 'xmc', label: 'XMC (Minecraft)' },
  362. ]}
  363. />
  364. </Form.Item>
  365. <Form.Item
  366. noStyle
  367. shouldUpdate={(prev, curr) => {
  368. const a = getDeep(prev, [...absolutePath, 'type']);
  369. const b = getDeep(curr, [...absolutePath, 'type']);
  370. return a !== b;
  371. }}
  372. >
  373. {({ getFieldValue }) => {
  374. const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
  375. if (type === 'fragment') {
  376. return (
  377. <>
  378. <Form.Item
  379. label="Packets"
  380. name={[fieldName, 'settings', 'packets']}
  381. rules={[{ validator: validateFragmentPackets }]}
  382. >
  383. <AutoComplete
  384. options={[
  385. { value: 'tlshello', label: 'tlshello' },
  386. { value: '1-3', label: '1-3' },
  387. { value: '1-5', label: '1-5' },
  388. ]}
  389. placeholder="tlshello or n-m, e.g. 1-3"
  390. />
  391. </Form.Item>
  392. <FragmentRangeList
  393. listName={[fieldName, 'settings', 'lengths']}
  394. label="Lengths"
  395. placeholder="e.g. 100-200"
  396. minItems={1}
  397. validator={validateFragmentLength}
  398. />
  399. <FragmentRangeList
  400. listName={[fieldName, 'settings', 'delays']}
  401. label="Delays"
  402. placeholder="e.g. 10-20 or 0"
  403. validator={validateFragmentDelayEntry}
  404. />
  405. <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
  406. <Input />
  407. </Form.Item>
  408. </>
  409. );
  410. }
  411. if (type === 'sudoku') {
  412. return (
  413. <>
  414. <Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
  415. <Input />
  416. </Form.Item>
  417. <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}>
  418. <Input />
  419. </Form.Item>
  420. <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}>
  421. <Input />
  422. </Form.Item>
  423. <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
  424. <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
  425. </Form.Item>
  426. <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
  427. <InputNumber min={0} />
  428. </Form.Item>
  429. <Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
  430. <InputNumber min={0} />
  431. </Form.Item>
  432. </>
  433. );
  434. }
  435. if (type === 'header-custom') {
  436. return (
  437. <HeaderCustomGroups
  438. tcpFieldName={fieldName}
  439. form={form}
  440. absoluteSettingsPath={[...absolutePath, 'settings']}
  441. />
  442. );
  443. }
  444. if (type === 'xmc') {
  445. return (
  446. <>
  447. <Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
  448. <Input placeholder="Server address mimicked in the handshake" />
  449. </Form.Item>
  450. <XmcProfilesList tcpFieldName={fieldName} />
  451. <Form.Item label="Password" required>
  452. <Space.Compact block>
  453. <Form.Item
  454. name={[fieldName, 'settings', 'password']}
  455. noStyle
  456. rules={[{ required: true, message: 'Password is required' }]}
  457. >
  458. <Input
  459. placeholder="Obfuscation password"
  460. style={{ width: 'calc(100% - 32px)' }}
  461. />
  462. </Form.Item>
  463. <Button
  464. icon={<ReloadOutlined />}
  465. aria-label={t('regenerate')}
  466. onClick={() =>
  467. form.setFieldValue(
  468. [...absolutePath, 'settings', 'password'],
  469. RandomUtil.randomLowerAndNum(16),
  470. )
  471. }
  472. />
  473. </Space.Compact>
  474. </Form.Item>
  475. </>
  476. );
  477. }
  478. return null;
  479. }}
  480. </Form.Item>
  481. </div>
  482. );
  483. }
  484. // xray's fragment `packets` accepts "tlshello" or an arbitrary packet-number
  485. // range like "1-3" (#5075 — presets only covered the common cases).
  486. function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void> {
  487. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  488. if (str.length === 0 || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
  489. return Promise.resolve();
  490. }
  491. return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
  492. }
  493. function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
  494. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  495. if (str.length === 0) {
  496. return Promise.reject(
  497. new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'),
  498. );
  499. }
  500. const min = Number(str.split('-')[0]);
  501. if (!Number.isFinite(min) || min <= 0) {
  502. return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
  503. }
  504. return Promise.resolve();
  505. }
  506. // A delay segment is a millisecond value or range; 0 is allowed (no delay),
  507. // but an empty row would serialize as "" and break xray's Int32Range parse,
  508. // so require a value and let the user remove the row instead.
  509. function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<void> {
  510. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  511. if (str.length === 0) {
  512. return Promise.reject(
  513. new Error("Delay is required — remove the row if you don't want a delay"),
  514. );
  515. }
  516. if (!/^\d+(?:-\d+)?$/.test(str)) {
  517. return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
  518. }
  519. return Promise.resolve();
  520. }
  521. // Per-segment range list for a fragment mask's `lengths`/`delays` (xray-core
  522. // #6334): an editable list of dash-range strings. xray applies entry N to
  523. // fragment segment N, clamping to the last entry. `minItems` keeps at least
  524. // one length row so the config never collapses to an empty (rejected) list.
  525. function FragmentRangeList({
  526. listName,
  527. label,
  528. placeholder,
  529. validator,
  530. minItems = 0,
  531. }: {
  532. listName: (string | number)[];
  533. label: string;
  534. placeholder: string;
  535. validator?: (rule: unknown, value: unknown) => Promise<void>;
  536. minItems?: number;
  537. }) {
  538. const { t } = useTranslation();
  539. return (
  540. <Form.List name={listName}>
  541. {(fields, { add, remove }) => (
  542. <>
  543. <Form.Item label={label}>
  544. <Button
  545. type="primary"
  546. size="small"
  547. icon={<PlusOutlined />}
  548. aria-label={t('add')}
  549. onClick={() => add('')}
  550. />
  551. </Form.Item>
  552. {fields.map((field, idx) => (
  553. <Form.Item
  554. key={field.key}
  555. label={`#${idx + 1}`}
  556. name={field.name}
  557. rules={validator ? [{ validator }] : undefined}
  558. >
  559. <Input
  560. placeholder={placeholder}
  561. suffix={
  562. fields.length > minItems ? (
  563. <DeleteOutlined
  564. className="danger-icon"
  565. role="button"
  566. tabIndex={0}
  567. aria-label={t('remove')}
  568. onClick={() => remove(field.name)}
  569. onKeyDown={activateOnKey(() => remove(field.name))}
  570. />
  571. ) : null
  572. }
  573. />
  574. </Form.Item>
  575. ))}
  576. </>
  577. )}
  578. </Form.List>
  579. );
  580. }
  581. // randRange bytes must sit in 0-255 — xray rejects the whole config with
  582. // "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
  583. // xray reorders them).
  584. function validateRandRange(_rule: unknown, value: unknown): Promise<void> {
  585. const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
  586. if (str.length === 0) return Promise.resolve();
  587. const m = /^(\d{1,3})(?:-(\d{1,3}))?$/.exec(str);
  588. if (!m) return Promise.reject(new Error('Use a byte value or range like 0-255'));
  589. const from = Number(m[1]);
  590. const to = m[2] !== undefined ? Number(m[2]) : from;
  591. if (from > 255 || to > 255) {
  592. return Promise.reject(new Error('randRange bytes must be within 0-255'));
  593. }
  594. return Promise.resolve();
  595. }
  596. function getDeep(obj: unknown, path: (string | number)[]): unknown {
  597. let cur: unknown = obj;
  598. for (const key of path) {
  599. if (cur == null || typeof cur !== 'object') return undefined;
  600. cur = (cur as Record<string | number, unknown>)[key];
  601. }
  602. return cur;
  603. }
  604. // Mojang hands the profile UUID back undashed from the session server and
  605. // dashed from most other endpoints; xray-core parses either, so accept both
  606. // rather than forcing the operator to reformat what they pasted.
  607. const XMC_UUID_PATTERN =
  608. /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
  609. const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
  610. function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
  611. if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
  612. return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
  613. }
  614. function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
  615. if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
  616. return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
  617. }
  618. // Each mask needs at least one fully signed profile since xray-core #6487 —
  619. // an empty or partial list makes the core reject the whole config, so the
  620. // panel blocks the save here rather than letting the backend drop the mask.
  621. function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
  622. const { t } = useTranslation();
  623. return (
  624. <Form.List name={[tcpFieldName, 'settings', 'profiles']}>
  625. {(profiles, { add, remove }) => (
  626. <>
  627. <Form.Item
  628. label="Profiles"
  629. extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
  630. >
  631. <Button
  632. type="primary"
  633. size="small"
  634. icon={<PlusOutlined />}
  635. aria-label={t('add')}
  636. onClick={() => add(defaultXmcProfile())}
  637. />
  638. </Form.Item>
  639. {profiles.map((profile, idx) => (
  640. <div key={profile.key}>
  641. <Divider style={{ margin: 0 }}>
  642. Profile {idx + 1}
  643. <DeleteOutlined
  644. className="danger-icon"
  645. role="button"
  646. tabIndex={0}
  647. aria-label={t('remove')}
  648. onClick={() => remove(profile.name)}
  649. onKeyDown={activateOnKey(() => remove(profile.name))}
  650. />
  651. </Divider>
  652. <Form.Item
  653. label="Username"
  654. name={[profile.name, 'username']}
  655. rules={[{ validator: validateXmcUsername }]}
  656. >
  657. <Input placeholder="Notch" />
  658. </Form.Item>
  659. <Form.Item
  660. label="UUID"
  661. name={[profile.name, 'uuid']}
  662. rules={[{ validator: validateXmcUuid }]}
  663. >
  664. <Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
  665. </Form.Item>
  666. <Form.Item
  667. label="Textures Value"
  668. name={[profile.name, 'texturesValue']}
  669. rules={[{ required: true, message: 'Textures value is required' }]}
  670. >
  671. <Input.TextArea
  672. autoSize={{ minRows: 2, maxRows: 4 }}
  673. placeholder="Base64 value from the session profile"
  674. />
  675. </Form.Item>
  676. <Form.Item
  677. label="Textures Signature"
  678. name={[profile.name, 'texturesSignature']}
  679. rules={[{ required: true, message: 'Textures signature is required' }]}
  680. >
  681. <Input.TextArea
  682. autoSize={{ minRows: 2, maxRows: 4 }}
  683. placeholder="Base64 signature from the session profile"
  684. />
  685. </Form.Item>
  686. </div>
  687. ))}
  688. </>
  689. )}
  690. </Form.List>
  691. );
  692. }
  693. function HeaderCustomGroups({
  694. tcpFieldName,
  695. form,
  696. absoluteSettingsPath,
  697. }: {
  698. tcpFieldName: number;
  699. form: FormInstance;
  700. absoluteSettingsPath: (string | number)[];
  701. }) {
  702. const { t } = useTranslation();
  703. return (
  704. <>
  705. {(['clients', 'servers'] as const).map((groupKey) => (
  706. <Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
  707. {(groups, { add: addGroup, remove: removeGroup }) => (
  708. <>
  709. <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
  710. <Button
  711. type="primary"
  712. size="small"
  713. icon={<PlusOutlined />}
  714. aria-label={t('add')}
  715. onClick={() => addGroup([defaultClientServerItem()])}
  716. />
  717. </Form.Item>
  718. {groups.map((group, gi) => (
  719. <div key={group.key}>
  720. <Divider style={{ margin: 0 }}>
  721. {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
  722. <DeleteOutlined
  723. className="danger-icon"
  724. role="button"
  725. tabIndex={0}
  726. aria-label={t('remove')}
  727. onClick={() => removeGroup(group.name)}
  728. onKeyDown={activateOnKey(() => removeGroup(group.name))}
  729. />
  730. </Divider>
  731. <Form.List name={[group.name]}>
  732. {(items, { add: addItem, remove: removeItem }) => (
  733. <>
  734. <Form.Item label="Items">
  735. <Button
  736. size="small"
  737. icon={<PlusOutlined />}
  738. aria-label={t('add')}
  739. onClick={() => addItem(defaultClientServerItem())}
  740. />
  741. </Form.Item>
  742. {items.map((item) => (
  743. <ItemEditor
  744. key={item.key}
  745. fieldName={item.name}
  746. form={form}
  747. absoluteItemPath={[
  748. ...absoluteSettingsPath,
  749. groupKey,
  750. group.name,
  751. item.name,
  752. ]}
  753. delayMode="number"
  754. onRemove={() => removeItem(item.name)}
  755. />
  756. ))}
  757. </>
  758. )}
  759. </Form.List>
  760. </div>
  761. ))}
  762. </>
  763. )}
  764. </Form.List>
  765. ))}
  766. </>
  767. );
  768. }
  769. function UdpMasksList({
  770. base,
  771. form,
  772. isHysteria,
  773. isWireguard,
  774. network,
  775. }: {
  776. base: (string | number)[];
  777. form: FormInstance;
  778. isHysteria: boolean;
  779. isWireguard: boolean;
  780. network: string;
  781. }) {
  782. const { t } = useTranslation();
  783. return (
  784. <Form.List name={[...base, 'udp']}>
  785. {(fields, { add, remove }) => (
  786. <>
  787. <Form.Item label="UDP Masks">
  788. <Button
  789. type="primary"
  790. size="small"
  791. icon={<PlusOutlined />}
  792. aria-label={t('add')}
  793. onClick={() => {
  794. const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
  795. add({ type: def, settings: defaultUdpMaskSettings(def) });
  796. }}
  797. />
  798. </Form.Item>
  799. {fields.map((field, mIdx) => (
  800. <UdpMaskItem
  801. key={field.key}
  802. fieldName={field.name}
  803. displayIndex={mIdx + 1}
  804. form={form}
  805. listPath={[...base, 'udp']}
  806. isHysteria={isHysteria}
  807. isWireguard={isWireguard}
  808. network={network}
  809. onRemove={() => remove(field.name)}
  810. />
  811. ))}
  812. </>
  813. )}
  814. </Form.List>
  815. );
  816. }
  817. function UdpMaskItem({
  818. fieldName,
  819. displayIndex,
  820. form,
  821. listPath,
  822. isHysteria,
  823. isWireguard,
  824. network,
  825. onRemove,
  826. }: {
  827. fieldName: number;
  828. displayIndex: number;
  829. form: FormInstance;
  830. listPath: (string | number)[];
  831. isHysteria: boolean;
  832. isWireguard: boolean;
  833. network: string;
  834. onRemove: () => void;
  835. }) {
  836. const absolutePath = [...listPath, fieldName];
  837. const { t } = useTranslation();
  838. const onTypeChange = (v: string) => {
  839. form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
  840. if (network === 'kcp') {
  841. const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu'];
  842. form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350);
  843. }
  844. };
  845. const options = isHysteria
  846. ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
  847. : [
  848. // Salamander is the mask xray-core's own wireguard finalmask example
  849. // uses; it stays hysteria-only elsewhere to keep legacy parity.
  850. ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
  851. { value: 'mkcp-legacy', label: 'mKCP Legacy' },
  852. { value: 'xdns', label: 'xDNS' },
  853. { value: 'xicmp', label: 'xICMP' },
  854. { value: 'realm', label: 'Realm' },
  855. { value: 'header-custom', label: 'Header Custom' },
  856. { value: 'noise', label: 'Noise' },
  857. ];
  858. return (
  859. <div>
  860. <Divider style={{ margin: 0 }}>
  861. UDP Mask {displayIndex}
  862. <DeleteOutlined
  863. className="danger-icon"
  864. role="button"
  865. tabIndex={0}
  866. aria-label={t('remove')}
  867. onClick={onRemove}
  868. onKeyDown={activateOnKey(onRemove)}
  869. />
  870. </Divider>
  871. <Form.Item label="Type" name={[fieldName, 'type']}>
  872. <Select onChange={onTypeChange} options={options} />
  873. </Form.Item>
  874. <Form.Item
  875. noStyle
  876. shouldUpdate={(prev, curr) =>
  877. getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])
  878. }
  879. >
  880. {({ getFieldValue }) => {
  881. const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
  882. if (type === 'salamander') {
  883. return (
  884. <SalamanderUdpMaskSettings
  885. fieldName={fieldName}
  886. form={form}
  887. absolutePath={absolutePath}
  888. />
  889. );
  890. }
  891. if (type === 'mkcp-legacy') {
  892. return (
  893. <>
  894. <Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
  895. <Select
  896. options={[
  897. { value: '', label: 'Original / AES-128-GCM' },
  898. { value: 'dns', label: 'DNS' },
  899. { value: 'dtls', label: 'DTLS 1.2' },
  900. { value: 'srtp', label: 'SRTP' },
  901. { value: 'utp', label: 'uTP' },
  902. { value: 'wechat', label: 'WeChat Video' },
  903. { value: 'wireguard', label: 'WireGuard' },
  904. ]}
  905. />
  906. </Form.Item>
  907. <Form.Item label="Value" name={[fieldName, 'settings', 'value']}>
  908. <Input placeholder="password (AES-128-GCM) or domain (DNS header)" />
  909. </Form.Item>
  910. </>
  911. );
  912. }
  913. if (type === 'xdns') {
  914. return (
  915. <Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
  916. <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
  917. </Form.Item>
  918. );
  919. }
  920. if (type === 'xicmp') {
  921. return (
  922. <>
  923. <Form.Item
  924. label="Dgram"
  925. name={[fieldName, 'settings', 'dgram']}
  926. valuePropName="checked"
  927. >
  928. <Switch />
  929. </Form.Item>
  930. <Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
  931. <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
  932. </Form.Item>
  933. </>
  934. );
  935. }
  936. if (type === 'realm') {
  937. return (
  938. <>
  939. <Form.Item label="URL" name={[fieldName, 'settings', 'url']}>
  940. <Input placeholder="realm://token@host:port/id" />
  941. </Form.Item>
  942. <Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
  943. <Select
  944. mode="tags"
  945. style={{ width: '100%' }}
  946. tokenSeparators={[',']}
  947. placeholder="host:port"
  948. />
  949. </Form.Item>
  950. <Form.Item label="IP Mode" name={[fieldName, 'settings', 'ipMode']}>
  951. <Select
  952. allowClear
  953. placeholder="dual"
  954. options={[
  955. { value: 'dual', label: 'Dual' },
  956. { value: 'v4', label: 'IPv4' },
  957. { value: 'v6', label: 'IPv6' },
  958. ]}
  959. />
  960. </Form.Item>
  961. <Form.Item
  962. label="Port Mapping (UPnP / NAT-PMP)"
  963. name={[fieldName, 'settings', 'portMapping', 'enabled']}
  964. valuePropName="checked"
  965. >
  966. <Switch />
  967. </Form.Item>
  968. <Form.Item
  969. label="Mapping Timeout (s)"
  970. name={[fieldName, 'settings', 'portMapping', 'timeout']}
  971. >
  972. <InputNumber min={0} placeholder="10 = default" />
  973. </Form.Item>
  974. <Form.Item
  975. label="Mapping Lifetime (s)"
  976. name={[fieldName, 'settings', 'portMapping', 'lifetime']}
  977. >
  978. <InputNumber min={0} placeholder="600 = default" />
  979. </Form.Item>
  980. <Divider plain style={{ margin: '8px 0' }}>
  981. TLS (optional)
  982. </Divider>
  983. <Form.Item
  984. label="Server Name"
  985. name={[fieldName, 'settings', 'tlsConfig', 'serverName']}
  986. >
  987. <Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
  988. </Form.Item>
  989. <Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
  990. <Select
  991. mode="multiple"
  992. style={{ width: '100%' }}
  993. options={[
  994. { value: 'h3', label: 'h3' },
  995. { value: 'h2', label: 'h2' },
  996. { value: 'http/1.1', label: 'http/1.1' },
  997. ]}
  998. />
  999. </Form.Item>
  1000. <Form.Item
  1001. label="Fingerprint"
  1002. name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}
  1003. >
  1004. <Select allowClear style={{ width: '100%' }} options={UTLS_FINGERPRINT_OPTIONS} />
  1005. </Form.Item>
  1006. <Form.Item
  1007. label="Allow Insecure"
  1008. name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
  1009. valuePropName="checked"
  1010. >
  1011. <Switch />
  1012. </Form.Item>
  1013. </>
  1014. );
  1015. }
  1016. if (type === 'header-custom') {
  1017. return (
  1018. <UdpHeaderCustom
  1019. udpFieldName={fieldName}
  1020. form={form}
  1021. absoluteSettingsPath={[...absolutePath, 'settings']}
  1022. />
  1023. );
  1024. }
  1025. if (type === 'noise') {
  1026. return (
  1027. <NoiseItems
  1028. udpFieldName={fieldName}
  1029. form={form}
  1030. absoluteSettingsPath={[...absolutePath, 'settings']}
  1031. />
  1032. );
  1033. }
  1034. return null;
  1035. }}
  1036. </Form.Item>
  1037. </div>
  1038. );
  1039. }
  1040. function SalamanderUdpMaskSettings({
  1041. fieldName,
  1042. form,
  1043. absolutePath,
  1044. }: {
  1045. fieldName: number;
  1046. form: FormInstance;
  1047. absolutePath: (string | number)[];
  1048. }) {
  1049. const { t } = useTranslation();
  1050. const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
  1051. const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
  1052. const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
  1053. return (
  1054. <>
  1055. <Form.Item
  1056. label="Mode"
  1057. extra={
  1058. mode === 'gecko'
  1059. ? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
  1060. : 'Scrambles each packet into random-looking bytes.'
  1061. }
  1062. >
  1063. <Select
  1064. value={mode}
  1065. onChange={(next) => {
  1066. if (next === 'gecko') {
  1067. const current = form.getFieldValue(packetSizePath);
  1068. form.setFieldValue(
  1069. packetSizePath,
  1070. parseGeckoPacketSize(current)
  1071. ? current
  1072. : formatGeckoPacketSize(
  1073. DEFAULT_GECKO_PACKET_SIZE.min,
  1074. DEFAULT_GECKO_PACKET_SIZE.max,
  1075. ),
  1076. );
  1077. } else {
  1078. form.setFieldValue(packetSizePath, undefined);
  1079. }
  1080. }}
  1081. options={[
  1082. { value: 'salamander', label: 'Salamander' },
  1083. { value: 'gecko', label: 'Gecko experimental' },
  1084. ]}
  1085. />
  1086. </Form.Item>
  1087. <Form.Item label="Password">
  1088. <Space.Compact block>
  1089. <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
  1090. <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
  1091. </Form.Item>
  1092. <Button
  1093. icon={<ReloadOutlined />}
  1094. aria-label={t('regenerate')}
  1095. onClick={() =>
  1096. form.setFieldValue(
  1097. [...absolutePath, 'settings', 'password'],
  1098. RandomUtil.randomLowerAndNum(16),
  1099. )
  1100. }
  1101. />
  1102. </Space.Compact>
  1103. </Form.Item>
  1104. {mode === 'gecko' && (
  1105. <Form.Item
  1106. label="Packet size"
  1107. name={[fieldName, 'settings', 'packetSize']}
  1108. rules={[{ validator: validateGeckoPacketSize }]}
  1109. extra="Serialized as a string range, for example 512-1200."
  1110. >
  1111. <GeckoPacketSizeInput />
  1112. </Form.Item>
  1113. )}
  1114. </>
  1115. );
  1116. }
  1117. function GeckoPacketSizeInput({
  1118. value,
  1119. onChange,
  1120. }: {
  1121. value?: string;
  1122. onChange?: (value: string) => void;
  1123. }) {
  1124. const { min, max } = splitGeckoPacketSize(value);
  1125. return (
  1126. <Space.Compact block>
  1127. <InputNumber
  1128. prefix="Min"
  1129. min={GECKO_MIN_PACKET_SIZE}
  1130. max={GECKO_MAX_PACKET_SIZE}
  1131. precision={0}
  1132. value={min}
  1133. placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
  1134. onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
  1135. style={{ width: '50%' }}
  1136. />
  1137. <InputNumber
  1138. prefix="Max"
  1139. min={GECKO_MIN_PACKET_SIZE}
  1140. max={GECKO_MAX_PACKET_SIZE}
  1141. precision={0}
  1142. value={max}
  1143. placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
  1144. onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
  1145. style={{ width: '50%' }}
  1146. />
  1147. </Space.Compact>
  1148. );
  1149. }
  1150. function UdpHeaderCustom({
  1151. udpFieldName,
  1152. form,
  1153. absoluteSettingsPath,
  1154. }: {
  1155. udpFieldName: number;
  1156. form: FormInstance;
  1157. absoluteSettingsPath: (string | number)[];
  1158. }) {
  1159. const { t } = useTranslation();
  1160. return (
  1161. <>
  1162. {(['client', 'server'] as const).map((groupKey) => (
  1163. <Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
  1164. {(items, { add, remove }) => (
  1165. <>
  1166. <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
  1167. <Button
  1168. type="primary"
  1169. size="small"
  1170. icon={<PlusOutlined />}
  1171. aria-label={t('add')}
  1172. onClick={() => add(defaultUdpClientServerItem())}
  1173. />
  1174. </Form.Item>
  1175. {items.map((item, ci) => (
  1176. <div key={item.key}>
  1177. <Divider style={{ margin: 0 }}>
  1178. {groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
  1179. <DeleteOutlined
  1180. className="danger-icon"
  1181. role="button"
  1182. tabIndex={0}
  1183. aria-label={t('remove')}
  1184. onClick={() => remove(item.name)}
  1185. onKeyDown={activateOnKey(() => remove(item.name))}
  1186. />
  1187. </Divider>
  1188. <ItemEditor
  1189. fieldName={item.name}
  1190. form={form}
  1191. absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
  1192. onRemove={() => remove(item.name)}
  1193. />
  1194. </div>
  1195. ))}
  1196. </>
  1197. )}
  1198. </Form.List>
  1199. ))}
  1200. </>
  1201. );
  1202. }
  1203. function NoiseItems({
  1204. udpFieldName,
  1205. form,
  1206. absoluteSettingsPath,
  1207. }: {
  1208. udpFieldName: number;
  1209. form: FormInstance;
  1210. absoluteSettingsPath: (string | number)[];
  1211. }) {
  1212. const { t } = useTranslation();
  1213. return (
  1214. <>
  1215. <Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
  1216. <InputNumber min={0} />
  1217. </Form.Item>
  1218. <Form.List name={[udpFieldName, 'settings', 'noise']}>
  1219. {(items, { add, remove }) => (
  1220. <>
  1221. <Form.Item label="Noise">
  1222. <Button
  1223. type="primary"
  1224. size="small"
  1225. icon={<PlusOutlined />}
  1226. aria-label={t('add')}
  1227. onClick={() => add(defaultNoiseItem())}
  1228. />
  1229. </Form.Item>
  1230. {items.map((item, ni) => (
  1231. <div key={item.key}>
  1232. <Divider style={{ margin: 0 }}>
  1233. Noise {ni + 1}
  1234. <DeleteOutlined
  1235. className="danger-icon"
  1236. role="button"
  1237. tabIndex={0}
  1238. aria-label={t('remove')}
  1239. onClick={() => remove(item.name)}
  1240. onKeyDown={activateOnKey(() => remove(item.name))}
  1241. />
  1242. </Divider>
  1243. <ItemEditor
  1244. fieldName={item.name}
  1245. form={form}
  1246. absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
  1247. delayMode="string"
  1248. onRemove={() => remove(item.name)}
  1249. />
  1250. </div>
  1251. ))}
  1252. </>
  1253. )}
  1254. </Form.List>
  1255. </>
  1256. );
  1257. }
  1258. function ItemEditor({
  1259. fieldName,
  1260. form,
  1261. absoluteItemPath,
  1262. delayMode,
  1263. onRemove: _onRemove,
  1264. }: {
  1265. fieldName: number;
  1266. form: FormInstance;
  1267. absoluteItemPath: (string | number)[];
  1268. delayMode?: 'number' | 'string';
  1269. onRemove?: () => void;
  1270. }) {
  1271. const { t } = useTranslation();
  1272. /**
  1273. * Switching to `array` clears the packet instead of emptying it to `[]`:
  1274. * that branch is rand-driven, and xray-core counts even an empty array as a
  1275. * packet, rejecting an item that carries both a packet and a rand. That
  1276. * error fails the whole config, so one such item keeps every inbound offline.
  1277. */
  1278. const onTypeChange = (v: string) => {
  1279. if (v === 'base64') {
  1280. form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
  1281. } else if (v === 'array') {
  1282. form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
  1283. form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
  1284. } else {
  1285. form.setFieldValue([...absoluteItemPath, 'packet'], '');
  1286. }
  1287. };
  1288. return (
  1289. <>
  1290. <Form.Item label="Type" name={[fieldName, 'type']}>
  1291. <Select
  1292. onChange={onTypeChange}
  1293. options={[
  1294. { value: 'array', label: 'Array' },
  1295. { value: 'str', label: 'String' },
  1296. { value: 'hex', label: 'Hex' },
  1297. { value: 'base64', label: 'Base64' },
  1298. ]}
  1299. />
  1300. </Form.Item>
  1301. {delayMode === 'number' && (
  1302. <Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
  1303. <InputNumber min={0} />
  1304. </Form.Item>
  1305. )}
  1306. {delayMode === 'string' && (
  1307. <Form.Item label="Delay" name={[fieldName, 'delay']}>
  1308. <Input placeholder="10-20" />
  1309. </Form.Item>
  1310. )}
  1311. <Form.Item
  1312. noStyle
  1313. shouldUpdate={(prev, curr) =>
  1314. getDeep(prev, [...absoluteItemPath, 'type']) !==
  1315. getDeep(curr, [...absoluteItemPath, 'type'])
  1316. }
  1317. >
  1318. {({ getFieldValue }) => {
  1319. const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
  1320. if (type === 'array') {
  1321. return (
  1322. <>
  1323. <Form.Item label="Rand" name={[fieldName, 'rand']}>
  1324. {delayMode === 'string' ? (
  1325. <Input placeholder="0 or 1-8192" />
  1326. ) : (
  1327. <InputNumber min={0} />
  1328. )}
  1329. </Form.Item>
  1330. {/* Cleared must become undefined, not '': xray parses an
  1331. explicit "" as the range 0-0 (all-zero fill bytes), while
  1332. an omitted randRange falls back to the 0-255 default. */}
  1333. <Form.Item
  1334. label="Rand Range"
  1335. name={[fieldName, 'randRange']}
  1336. normalize={(v) => (v === '' ? undefined : v)}
  1337. rules={[{ validator: validateRandRange }]}
  1338. >
  1339. <Input placeholder="0-255" />
  1340. </Form.Item>
  1341. </>
  1342. );
  1343. }
  1344. if (type === 'base64') {
  1345. return (
  1346. <Form.Item label="Packet">
  1347. <Space.Compact block>
  1348. <Form.Item name={[fieldName, 'packet']} noStyle>
  1349. <Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
  1350. </Form.Item>
  1351. <Button
  1352. icon={<ReloadOutlined />}
  1353. aria-label={t('regenerate')}
  1354. onClick={() =>
  1355. form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())
  1356. }
  1357. />
  1358. </Space.Compact>
  1359. </Form.Item>
  1360. );
  1361. }
  1362. return (
  1363. <Form.Item label="Packet" name={[fieldName, 'packet']}>
  1364. <Input placeholder="binary data" />
  1365. </Form.Item>
  1366. );
  1367. }}
  1368. </Form.Item>
  1369. </>
  1370. );
  1371. }
  1372. function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
  1373. const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
  1374. const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
  1375. | Record<string, unknown>
  1376. | undefined;
  1377. const hasUdpHop = udpHop != null;
  1378. return (
  1379. <>
  1380. <Form.Item label="Congestion" name={[...base, 'congestion']}>
  1381. <Select
  1382. options={[
  1383. { value: 'reno', label: 'Reno' },
  1384. { value: 'bbr', label: 'BBR' },
  1385. { value: 'brutal', label: 'Brutal' },
  1386. { value: 'force-brutal', label: 'Force Brutal' },
  1387. ]}
  1388. />
  1389. </Form.Item>
  1390. {congestion === 'bbr' && (
  1391. <Form.Item label="BBR Profile" name={[...base, 'bbrProfile']}>
  1392. <Select
  1393. allowClear
  1394. placeholder="standard"
  1395. options={[
  1396. { value: 'conservative', label: 'Conservative' },
  1397. { value: 'standard', label: 'Standard' },
  1398. { value: 'aggressive', label: 'Aggressive' },
  1399. ]}
  1400. />
  1401. </Form.Item>
  1402. )}
  1403. <Form.Item label="Debug" name={[...base, 'debug']} valuePropName="checked">
  1404. <Switch />
  1405. </Form.Item>
  1406. {(congestion === 'brutal' || congestion === 'force-brutal') && (
  1407. <>
  1408. <Form.Item label="Brutal Up" name={[...base, 'brutalUp']}>
  1409. <Input placeholder="e.g. 60 mbps" />
  1410. </Form.Item>
  1411. <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
  1412. <Input placeholder="e.g. 100 mbps" />
  1413. </Form.Item>
  1414. <Form.Item
  1415. label="Brutal Disable Loss Comp"
  1416. name={[...base, 'brutalDisableLossCompensation']}
  1417. valuePropName="checked"
  1418. >
  1419. <Switch />
  1420. </Form.Item>
  1421. </>
  1422. )}
  1423. <Form.Item label="UDP Hop">
  1424. <Switch
  1425. checked={hasUdpHop}
  1426. onChange={(v) => {
  1427. form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
  1428. }}
  1429. />
  1430. </Form.Item>
  1431. {hasUdpHop && (
  1432. <>
  1433. <Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
  1434. <Input placeholder="e.g. 20000-50000" />
  1435. </Form.Item>
  1436. <Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
  1437. <Input placeholder="e.g. 5-10" />
  1438. </Form.Item>
  1439. </>
  1440. )}
  1441. <Form.Item label="Max Idle Timeout (s)" name={[...base, 'maxIdleTimeout']}>
  1442. <InputNumber min={4} max={120} />
  1443. </Form.Item>
  1444. <Form.Item label="Keep Alive Period (s)" name={[...base, 'keepAlivePeriod']}>
  1445. <InputNumber min={2} max={60} />
  1446. </Form.Item>
  1447. <Form.Item
  1448. label="Disable Path MTU Dis"
  1449. name={[...base, 'disablePathMTUDiscovery']}
  1450. valuePropName="checked"
  1451. >
  1452. <Switch />
  1453. </Form.Item>
  1454. <Form.Item
  1455. label="Disable Chrome Parrot"
  1456. name={[...base, 'disableChromeParrot']}
  1457. valuePropName="checked"
  1458. >
  1459. <Switch />
  1460. </Form.Item>
  1461. <Form.Item label="Disable GSO" name={[...base, 'disableGSO']} valuePropName="checked">
  1462. <Switch />
  1463. </Form.Item>
  1464. <Form.Item
  1465. label="Disable Stateless Reset"
  1466. name={[...base, 'disableStatelessReset']}
  1467. valuePropName="checked"
  1468. >
  1469. <Switch />
  1470. </Form.Item>
  1471. <Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
  1472. <InputNumber min={8} placeholder="1024 = default" />
  1473. </Form.Item>
  1474. <Form.Item label="Init Stream Window" name={[...base, 'initStreamReceiveWindow']}>
  1475. <InputNumber min={16384} placeholder="8388608 = default" />
  1476. </Form.Item>
  1477. <Form.Item label="Max Stream Window" name={[...base, 'maxStreamReceiveWindow']}>
  1478. <InputNumber min={16384} placeholder="8388608 = default" />
  1479. </Form.Item>
  1480. <Form.Item label="Init Conn Window" name={[...base, 'initConnectionReceiveWindow']}>
  1481. <InputNumber min={16384} placeholder="20971520 = default" />
  1482. </Form.Item>
  1483. <Form.Item label="Max Conn Window" name={[...base, 'maxConnectionReceiveWindow']}>
  1484. <InputNumber min={16384} placeholder="20971520 = default" />
  1485. </Form.Item>
  1486. </>
  1487. );
  1488. }