import { useEffect, useRef } from 'react'; import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch, } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import type { FormInstance } from 'antd/es/form'; import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { activateOnKey } from '@/utils/a11y'; import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives'; const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value, })); export interface FinalMaskFormProps { name: NamePath; network: string; protocol: string; form: FormInstance; // When true, all sections (TCP / UDP / QUIC) are shown regardless of // network/protocol. Used by the global sub-JSON finalmask editor where // the masks apply to every stream rather than one specific transport. showAll?: boolean; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 }; // Xray-core caps the Gecko output packet size at its internal buffer (2048) // and needs 1 <= min <= max; mirror those bounds so the panel rejects what // core would reject at runtime (salamander/conn.go). const GECKO_MIN_PACKET_SIZE = 1; const GECKO_MAX_PACKET_SIZE = 2048; export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null { const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); const match = /^(\d+)-(\d+)$/.exec(str); if (!match) return null; const min = Number(match[1]); const max = Number(match[2]); if ( !Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE ) { return null; } return { min, max }; } function formatGeckoPacketSize(min: number, max: number): string { return `${min}-${max}`; } function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } { const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); const [minRaw = '', maxRaw = ''] = str.split('-', 2); const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null; const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null; return { min, max }; } function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise { if (parseGeckoPacketSize(value)) return Promise.resolve(); return Promise.reject( new Error( `Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`, ), ); } function asPath(name: NamePath): (string | number)[] { return Array.isArray(name) ? [...name] : [name]; } function defaultTcpMaskSettings(type: string): Record { switch (type) { case 'fragment': // `lengths`/`delays` are per-segment range arrays (xray-core #6334); // a single length entry reproduces the legacy single-range behavior. return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' }; case 'sudoku': return { password: '', ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0, }; case 'header-custom': return { clients: [], servers: [] }; case 'xmc': return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16), }; default: return {}; } } function defaultXmcProfile(): Record { return { username: '', uuid: '', texturesValue: '', texturesSignature: '' }; } // xray-core #6487 replaced the xmc mask's `usernames` string list with // `profiles` objects carrying a Mojang-signed session profile, and dropped the // "default to Dream" fallback so at least one complete profile is now // mandatory. The signature can only come from Mojang's session server, so a // legacy username cannot be upgraded automatically — carry it into a profile // stub instead, which keeps the operator's player names visible and leaves the // per-field validators pointing at exactly what still has to be filled in. export function migrateXmcSettings(settings: Record): { next: Record; changed: boolean; } { const out: Record = { ...settings }; let changed = false; if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) { out.profiles = out.usernames .filter((name): name is string => typeof name === 'string' && name.trim() !== '') .map((name) => ({ ...defaultXmcProfile(), username: name })); changed = true; } if ('usernames' in out) { delete out.usernames; changed = true; } if (!Array.isArray(out.profiles)) { out.profiles = []; changed = true; } return { next: out, changed }; } // xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges // with `lengths`/`delays` arrays (the singular keys remain in core only as a // fallback). Lift any legacy singular value into a one-element array so the // list UI shows it, and drop the singular key so we never emit both. function migrateFragmentSettings(settings: Record): { next: Record; changed: boolean; } { const out: Record = { ...settings }; let changed = false; if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') { out.lengths = [out.length]; changed = true; } if ('length' in out) { delete out.length; changed = true; } if (!Array.isArray(out.delays) && typeof out.delay === 'string' && out.delay.trim() !== '') { out.delays = [out.delay]; changed = true; } if ('delay' in out) { delete out.delay; changed = true; } return { next: out, changed }; } function defaultUdpMaskSettings(type: string): Record { switch (type) { case 'salamander': return { password: '' }; case 'mkcp-legacy': return { header: '', value: '' }; case 'xdns': return { domains: [] }; case 'xicmp': return { dgram: false, ips: [] }; case 'realm': return { url: '', stunServers: [] }; case 'header-custom': return { client: [], server: [] }; case 'noise': return { reset: 0, noise: [] }; default: return {}; } } function defaultClientServerItem(): Record { return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] }; } function defaultUdpClientServerItem(): Record { return { rand: 0, randRange: '0-255', type: 'array', packet: [] }; } function defaultNoiseItem(): Record { return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20', }; } function defaultQuicParams(): Record { return { congestion: 'bbr', debug: false, maxIdleTimeout: 30, keepAlivePeriod: 10, disablePathMTUDiscovery: false, maxIncomingStreams: 1024, initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608, initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520, }; } function defaultUdpHop(): Record { return { ports: '20000-50000', interval: '5-10' }; } export default function FinalMaskForm({ name, network, protocol, form, showAll = false, }: FinalMaskFormProps) { const base = asPath(name); // Migrate legacy TCP mask shapes once on mount so configs saved before // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI. const migratedRef = useRef(false); useEffect(() => { if (migratedRef.current) return; migratedRef.current = true; const tcp = form.getFieldValue([...base, 'tcp']); if (!Array.isArray(tcp)) return; let anyChanged = false; const next = tcp.map((mask) => { if (!mask || typeof mask !== 'object') return mask; const m = mask as Record; if (m.type !== 'fragment' && m.type !== 'xmc') return mask; if (!m.settings || typeof m.settings !== 'object') return mask; const settings = m.settings as Record; const { next: migrated, changed } = m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings); if (!changed) return mask; anyChanged = true; return { ...m, settings: migrated }; }); if (anyChanged) form.setFieldValue([...base, 'tcp'], next); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; // Wireguard carries no user-selectable transport (always a UDP listener/ // dialer), so only the UDP mask section applies — TCP masks would never // wrap anything even though the leftover network value may be 'tcp'. const isWireguard = protocol === 'wireguard'; const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network)); const showUdp = showAll || isHysteria || isWireguard || network === 'kcp'; const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; if (!showTcp && !showUdp && !showQuic) return null; return ( <> {showTcp && } {showUdp && ( )} {showQuic && ( <> { form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined); }} /> {hasQuicParams && } )} ); } function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) { const { t } = useTranslation(); return ( {(fields, { add, remove }) => ( <>