3x-ui/frontend/src/components/FinalMaskForm.tsx
MHSanaei 9d2a4f217e
feat(inbound-form): salamander auto-seed for Hysteria + modernize random buttons
Picking Hysteria from the protocol select used to leave finalmask.udp
empty, so the listener went out without obfs unless the admin added
the salamander wrapper by hand. Hook into onValuesChange so switching
to Hysteria seeds finalmask.udp with
{type: 'salamander', settings: {password: <random>}} alongside the
hysteriaSettings / tlsSettings reset already happening there.

Also modernise the SyncOutlined-in-label "random" affordances on
Shadowsocks password, WireGuard secret key (server + per-peer), and
Reality target / SNI / shortIds into proper icon buttons inside a
Space.Compact next to the field. The old pattern dropped a tiny
clickable icon into the form-item label, which was easy to miss and
inconsistent with the other action buttons in the modal.
2026-05-27 13:43:21 +02:00

766 lines
26 KiB
XML

import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives';
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
// paths under `name`; the parent modal owns the Form instance.
//
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
// with the Form.List's own `name`. So Form.Items inside the render
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
// Form.Lists also use relative names. Using absolute paths here would
// double up the prefix and silently route reads/writes to the wrong
// storage path.
export interface FinalMaskFormProps {
name: NamePath;
network: string;
protocol: string;
form: FormInstance;
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
function asPath(name: NamePath): (string | number)[] {
return Array.isArray(name) ? [...name] : [name];
}
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'fragment':
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
case 'sudoku':
return {
password: '', ascii: '', customTable: '', customTables: '',
paddingMin: 0, paddingMax: 0,
};
case 'header-custom':
return { clients: [], servers: [] };
default:
return {};
}
}
function defaultUdpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'salamander':
case 'mkcp-aes128gcm':
return { password: '' };
case 'header-dns':
return { domain: '' };
case 'xdns':
return { domains: [] };
case 'xicmp':
return { ip: '0.0.0.0', id: 0 };
case 'header-custom':
return { client: [], server: [] };
case 'noise':
return { reset: 0, noise: [] };
default:
return {};
}
}
function defaultClientServerItem(): Record<string, unknown> {
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function defaultUdpClientServerItem(): Record<string, unknown> {
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function defaultNoiseItem(): Record<string, unknown> {
return {
rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20',
};
}
function defaultQuicParams(): Record<string, unknown> {
return {
congestion: 'bbr',
debug: false,
maxIdleTimeout: 30,
keepAlivePeriod: 10,
disablePathMTUDiscovery: false,
maxIncomingStreams: 1024,
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnectionReceiveWindow: 20971520,
maxConnectionReceiveWindow: 20971520,
};
}
function defaultUdpHop(): Record<string, unknown> {
return { ports: '20000-50000', interval: '5-10' };
}
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
const base = asPath(name);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
const showTcp = TCP_NETWORKS.includes(network);
const showUdp = isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null;
if (!showTcp && !showUdp && !showQuic) return null;
return (
<>
{showTcp && <TcpMasksList base={base} form={form} />}
{showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
{showQuic && (
<>
<Form.Item label="QUIC Params">
<Switch
checked={hasQuicParams}
onChange={(v) => {
form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
}}
/>
</Form.Item>
{hasQuicParams && <QuicParamsForm base={[...base, 'quicParams']} form={form} />}
</>
)}
</>
);
}
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
return (
<Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="TCP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
/>
</Form.Item>
{fields.map((field, mIdx) => (
<TcpMaskItem
key={field.key}
fieldName={field.name}
displayIndex={mIdx + 1}
form={form}
listPath={[...base, 'tcp']}
onRemove={() => remove(field.name)}
/>
))}
</>
)}
</Form.List>
);
}
function TcpMaskItem({
fieldName, displayIndex, form, listPath, onRemove,
}: {
fieldName: number;
displayIndex: number;
form: FormInstance;
listPath: (string | number)[];
onRemove: () => void;
}) {
// Absolute path for setFieldValue side effects (resetting settings on
// type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context.
const absolutePath = [...listPath, fieldName];
return (
<div>
<Divider style={{ margin: 0 }}>
TCP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
options={[
{ value: 'fragment', label: 'Fragment' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'sudoku', label: 'Sudoku' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => {
const a = getDeep(prev, [...absolutePath, 'type']);
const b = getDeep(curr, [...absolutePath, 'type']);
return a !== b;
}}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'fragment') {
return (
<>
<Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
<Select
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
/>
</Form.Item>
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
<Input />
</Form.Item>
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input />
</Form.Item>
<Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
<Input />
</Form.Item>
</>
);
}
if (type === 'sudoku') {
return (
<>
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Padding Max" name={[fieldName, 'settings', 'paddingMax']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
if (type === 'header-custom') {
return (
<HeaderCustomGroups
tcpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
return null;
}}
</Form.Item>
</div>
);
}
// Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates.
function getDeep(obj: unknown, path: (string | number)[]): unknown {
let cur: unknown = obj;
for (const key of path) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string | number, unknown>)[key];
}
return cur;
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
tcpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[tcpFieldName, 'settings', groupKey]}>
{(groups, { add: addGroup, remove: removeGroup }) => (
<>
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => addGroup([defaultClientServerItem()])}
/>
</Form.Item>
{groups.map((group, gi) => (
<div key={group.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
</Divider>
<Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => (
<>
<Form.Item label="Items">
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => addItem(defaultClientServerItem())}
/>
</Form.Item>
{items.map((item) => (
<ItemEditor
key={item.key}
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
delayMode="number"
onRemove={() => removeItem(item.name)}
/>
))}
</>
)}
</Form.List>
</div>
))}
</>
)}
</Form.List>
))}
</>
);
}
function UdpMasksList({
base, form, isHysteria, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) {
return (
<Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="UDP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
add({ type: def, settings: defaultUdpMaskSettings(def) });
}}
/>
</Form.Item>
{fields.map((field, mIdx) => (
<UdpMaskItem
key={field.key}
fieldName={field.name}
displayIndex={mIdx + 1}
form={form}
listPath={[...base, 'udp']}
isHysteria={isHysteria}
network={network}
onRemove={() => remove(field.name)}
/>
))}
</>
)}
</Form.List>
);
}
function UdpMaskItem({
fieldName, displayIndex, form, listPath, isHysteria, network, onRemove,
}: {
fieldName: number;
displayIndex: number;
form: FormInstance;
listPath: (string | number)[];
isHysteria: boolean;
network: string;
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
const onTypeChange = (v: string) => {
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
if (network === 'kcp') {
const kcpMtuPath = [...listPath.slice(0, -1), 'kcpSettings', 'mtu'];
form.setFieldValue(kcpMtuPath, v === 'xdns' ? 900 : 1350);
}
};
const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
: [
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
{ value: 'header-dns', label: 'Header DNS' },
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
{ value: 'header-srtp', label: 'Header SRTP' },
{ value: 'header-utp', label: 'Header uTP' },
{ value: 'header-wechat', label: 'Header WeChat Video' },
{ value: 'header-wireguard', label: 'Header WireGuard' },
{ value: 'mkcp-original', label: 'mKCP Original' },
{ value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
];
return (
<div>
<Divider style={{ margin: 0 }}>
UDP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select onChange={onTypeChange} options={options} />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'mkcp-aes128gcm' || type === 'salamander') {
return (
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
);
}
if (type === 'header-dns') {
return (
<Form.Item label="Domain" name={[fieldName, 'settings', 'domain']}>
<Input placeholder="e.g., www.example.com" />
</Form.Item>
);
}
if (type === 'xdns') {
return (
<Form.Item label="Domains" name={[fieldName, 'settings', 'domains']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
);
}
if (type === 'xicmp') {
return (
<>
<Form.Item label="IP" name={[fieldName, 'settings', 'ip']}>
<Input placeholder="0.0.0.0" />
</Form.Item>
<Form.Item label="ID" name={[fieldName, 'settings', 'id']}>
<InputNumber min={0} />
</Form.Item>
</>
);
}
if (type === 'header-custom') {
return (
<UdpHeaderCustom
udpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
if (type === 'noise') {
return (
<NoiseItems
udpFieldName={fieldName}
form={form}
absoluteSettingsPath={[...absolutePath, 'settings']}
/>
);
}
return null;
}}
</Form.Item>
</div>
);
}
function UdpHeaderCustom({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
{(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => (
<>
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add(defaultUdpClientServerItem())}
/>
</Form.Item>
{items.map((item, ci) => (
<div key={item.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider>
<ItemEditor
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]}
onRemove={() => remove(item.name)}
/>
</div>
))}
</>
)}
</Form.List>
))}
</>
);
}
function NoiseItems({
udpFieldName, form, absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
return (
<>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
<InputNumber min={0} />
</Form.Item>
<Form.List name={[udpFieldName, 'settings', 'noise']}>
{(items, { add, remove }) => (
<>
<Form.Item label="Noise">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => add(defaultNoiseItem())}
/>
</Form.Item>
{items.map((item, ni) => (
<div key={item.key}>
<Divider style={{ margin: 0 }}>
Noise {ni + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
</Divider>
<ItemEditor
fieldName={item.name}
form={form}
absoluteItemPath={[...absoluteSettingsPath, 'noise', item.name]}
delayMode="string"
onRemove={() => remove(item.name)}
/>
</div>
))}
</>
)}
</Form.List>
</>
);
}
function ItemEditor({
fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
}: {
fieldName: number;
form: FormInstance;
absoluteItemPath: (string | number)[];
delayMode?: 'number' | 'string';
onRemove?: () => void;
}) {
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
};
return (
<>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
{ value: 'str', label: 'String' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
]}
/>
</Form.Item>
{delayMode === 'number' && (
<Form.Item label="Delay (ms)" name={[fieldName, 'delay']}>
<InputNumber min={0} />
</Form.Item>
)}
{delayMode === 'string' && (
<Form.Item label="Delay" name={[fieldName, 'delay']}>
<Input placeholder="10-20" />
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
>
{({ getFieldValue }) => {
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
if (type === 'array') {
return (
<>
<Form.Item label="Rand" name={[fieldName, 'rand']}>
{delayMode === 'string' ? (
<Input placeholder="0 or 1-8192" />
) : (
<InputNumber min={0} />
)}
</Form.Item>
<Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
<Input placeholder="0-255" />
</Form.Item>
</>
);
}
if (type === 'base64') {
return (
<Form.Item label="Packet">
<Space.Compact block>
<Form.Item name={[fieldName, 'packet']} noStyle>
<Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/>
</Space.Compact>
</Form.Item>
);
}
return (
<Form.Item label="Packet" name={[fieldName, 'packet']}>
<Input placeholder="binary data" />
</Form.Item>
);
}}
</Form.Item>
</>
);
}
function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as Record<string, unknown> | undefined;
const hasUdpHop = udpHop != null;
return (
<>
<Form.Item label="Congestion" name={[...base, 'congestion']}>
<Select
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
{ value: 'brutal', label: 'Brutal' },
{ value: 'force-brutal', label: 'Force Brutal' },
]}
/>
</Form.Item>
{congestion === 'bbr' && (
<Form.Item label="BBR Profile" name={[...base, 'bbrProfile']}>
<Select
allowClear
placeholder="standard"
options={[
{ value: 'conservative', label: 'Conservative' },
{ value: 'standard', label: 'Standard' },
{ value: 'aggressive', label: 'Aggressive' },
]}
/>
</Form.Item>
)}
<Form.Item label="Debug" name={[...base, 'debug']} valuePropName="checked">
<Switch />
</Form.Item>
{(congestion === 'brutal' || congestion === 'force-brutal') && (
<>
<Form.Item label="Brutal Up" name={[...base, 'brutalUp']}>
<Input placeholder="e.g. 60 mbps" />
</Form.Item>
<Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
<Input placeholder="e.g. 100 mbps" />
</Form.Item>
</>
)}
<Form.Item label="UDP Hop">
<Switch
checked={hasUdpHop}
onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}}
/>
</Form.Item>
{hasUdpHop && (
<>
<Form.Item label="Hop Ports" name={[...base, 'udpHop', 'ports']}>
<Input placeholder="e.g. 20000-50000" />
</Form.Item>
<Form.Item label="Hop Interval (s)" name={[...base, 'udpHop', 'interval']}>
<Input placeholder="e.g. 5-10" />
</Form.Item>
</>
)}
<Form.Item label="Max Idle Timeout (s)" name={[...base, 'maxIdleTimeout']}>
<InputNumber min={4} max={120} />
</Form.Item>
<Form.Item label="Keep Alive Period (s)" name={[...base, 'keepAlivePeriod']}>
<InputNumber min={2} max={60} />
</Form.Item>
<Form.Item label="Disable Path MTU Dis" name={[...base, 'disablePathMTUDiscovery']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
<InputNumber min={8} placeholder="1024 = default" />
</Form.Item>
<Form.Item label="Init Stream Window" name={[...base, 'initStreamReceiveWindow']}>
<InputNumber min={16384} placeholder="8388608 = default" />
</Form.Item>
<Form.Item label="Max Stream Window" name={[...base, 'maxStreamReceiveWindow']}>
<InputNumber min={16384} placeholder="8388608 = default" />
</Form.Item>
<Form.Item label="Init Conn Window" name={[...base, 'initConnectionReceiveWindow']}>
<InputNumber min={16384} placeholder="20971520 = default" />
</Form.Item>
<Form.Item label="Max Conn Window" name={[...base, 'maxConnectionReceiveWindow']}>
<InputNumber min={16384} placeholder="20971520 = default" />
</Form.Item>
</>
);
}