Files
3x-ui/frontend/src/pages/inbounds/InboundFormModal.tsx
MHSanaei 7442486a58 feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers
Add a single reusable header-map editor that handles the two wire
shapes Xray uses:

- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
  masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
  Each header can repeat (RFC 7230 §3.2.2).

Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.

Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers

XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.
2026-05-26 12:46:54 +02:00

2295 lines
82 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import {
Button,
Card,
Checkbox,
Empty,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Switch,
Tabs,
Tooltip,
Typography,
message,
} from 'antd';
import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import {
rawInboundToFormValues,
formValuesToWirePayload,
} from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import {
canEnableReality,
canEnableStream,
canEnableTls,
isSS2022,
} from '@/lib/xray/protocol-capabilities';
import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks';
import { getRandomRealityTarget } from '@/models/reality-targets';
import {
InboundFormBaseSchema,
InboundFormSchema,
type FallbackRow,
type InboundFormValues,
} from '@/schemas/forms/inbound-form';
import { antdRule } from '@/utils/zodForm';
import {
ALPN_OPTION,
DOMAIN_STRATEGY_OPTION,
Protocols,
SNIFFING_OPTION,
TCP_CONGESTION_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION,
USAGE_OPTION,
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import DateTimePicker from '@/components/DateTimePicker';
import HeaderMapEditor from '@/components/HeaderMapEditor';
import InputAddon from '@/components/InputAddon';
import JsonEditor from '@/components/JsonEditor';
import type { FormInstance } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
const { TextArea } = Input;
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
// build stays green while the rewrite progresses section by section.
// InboundsPage continues to render the old InboundFormModal.tsx until the
// atomic swap at the end (Core Decision 7).
const { Text } = Typography;
// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
// Holds a local text buffer so the user can type freely; on every keystroke
// we try to JSON.parse and forward the result to form state. Invalid JSON
// is held in the buffer until the next valid moment — no panic on partial
// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
// each open a fresh editor instance, so we don't need to re-sync on outer
// form changes.
function AdvancedSliceEditor({
form,
path,
minHeight,
maxHeight,
}: {
form: FormInstance<InboundFormValues>;
path: NamePath;
minHeight?: string;
maxHeight?: string;
}) {
const [text, setText] = useState(() =>
JSON.stringify(form.getFieldValue(path) ?? {}, null, 2),
);
return (
<JsonEditor
value={text}
minHeight={minHeight}
maxHeight={maxHeight}
onChange={(next) => {
setText(next);
try {
form.setFieldValue(path, JSON.parse(next));
} catch {
}
}}
/>
);
}
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
Protocols.VLESS,
Protocols.VMESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
Protocols.WIREGUARD,
]);
interface InboundFormModalProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
mode: 'add' | 'edit';
dbInbound: DBInbound | null;
dbInbounds: DBInbound[];
availableNodes?: NodeRecord[];
}
function buildAddModeValues(): InboundFormValues {
const settings = createDefaultInboundSettings('vless') ?? undefined;
return rawInboundToFormValues({
protocol: 'vless',
settings,
streamSettings: { network: 'tcp', security: 'none' },
sniffing: {},
port: RandomUtil.randomInteger(10000, 60000),
listen: '',
tag: '',
enable: true,
trafficReset: 'never',
});
}
export default function InboundFormModal({
open,
onClose,
onSaved,
mode,
dbInbound,
dbInbounds,
availableNodes,
}: InboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<InboundFormValues>();
const [saving, setSaving] = useState(false);
const fallbackKeyRef = useRef(0);
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
const ssMethod = Form.useWatch(['settings', 'method'], form);
const isSSWith2022 = isSS2022({
protocol,
settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
});
const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
const streamEnabled = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const isFallbackHost =
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
&& network === 'tcp'
&& (security === 'tls' || security === 'reality');
const fallbackChildOptions = (dbInbounds || [])
.filter((ib) => ib.id !== dbInbound?.id)
.map((ib) => ({
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
value: ib.id,
}));
const loadFallbacks = async (masterId: number | null) => {
if (!masterId) {
setFallbacks([]);
return;
}
const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`);
if (!msg?.success || !Array.isArray(msg.obj)) {
setFallbacks([]);
return;
}
setFallbacks(
(msg.obj as { childId: number; name?: string; alpn?: string; path?: string; xver?: number }[])
.map((r) => ({
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: r.childId,
name: r.name || '',
alpn: r.alpn || '',
path: r.path || '',
xver: r.xver || 0,
})),
);
};
const saveFallbacks = async (masterId: number) => {
if (!masterId) return true;
const payload = {
fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({
childId: c.childId,
name: c.name,
alpn: c.alpn,
path: c.path,
xver: Number(c.xver) || 0,
sortOrder: i,
})),
};
const msg = await HttpUtil.post(
`/panel/api/inbounds/${masterId}/fallbacks`,
payload,
{ headers: { 'Content-Type': 'application/json' } },
);
return !!msg?.success;
};
const addFallback = () => {
setFallbacks((prev) => [...prev, {
rowKey: `fb-${++fallbackKeyRef.current}`,
childId: null,
name: '',
alpn: '',
path: '',
xver: 0,
}]);
};
const updateFallback = (rowKey: string, patch: Partial<FallbackRow>) => {
setFallbacks((prev) => prev.map((r) => r.rowKey === rowKey ? { ...r, ...patch } : r));
};
const removeFallback = (idx: number) => {
setFallbacks((prev) => prev.filter((_, i) => i !== idx));
};
const genRealityKeypair = async () => {
setSaving(true);
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
}
} finally {
setSaving(false);
}
};
const clearRealityKeypair = () => {
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
};
const genMldsa65 = async () => {
setSaving(true);
try {
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) {
const obj = msg.obj as { seed: string; verify: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
}
} finally {
setSaving(false);
}
};
const clearMldsa65 = () => {
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
};
const randomizeRealityTarget = () => {
const tgt = getRandomRealityTarget() as { target: string; sni: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
form.setFieldValue(
['streamSettings', 'realitySettings', 'serverNames'],
tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
);
};
const randomizeShortIds = () => {
form.setFieldValue(
['streamSettings', 'realitySettings', 'shortIds'],
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
);
};
const getNewEchCert = async () => {
const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
setSaving(true);
try {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
if (msg?.success) {
const obj = msg.obj as { echServerKeys: string; echConfigList: string };
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
}
} finally {
setSaving(false);
}
};
const clearEchCert = () => {
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
};
const onSecurityChange = (next: string) => {
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, security: next };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') cleaned.tlsSettings = TlsStreamSettingsSchema.parse({});
if (next === 'reality') cleaned.realitySettings = RealityStreamSettingsSchema.parse({});
form.setFieldValue('streamSettings', cleaned);
};
const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
const externalProxyArr = Form.useWatch(['streamSettings', 'externalProxy'], form);
const externalProxyOn = Array.isArray(externalProxyArr) && externalProxyArr.length > 0;
const sockoptValue = Form.useWatch(['streamSettings', 'sockopt'], form);
const sockoptOn = !!sockoptValue && typeof sockoptValue === 'object' && Object.keys(sockoptValue as object).length > 0;
const toggleExternalProxy = (on: boolean) => {
if (on) {
const port = (form.getFieldValue('port') as number) ?? 443;
form.setFieldValue(['streamSettings', 'externalProxy'], [{
forceTls: 'same',
dest: typeof window !== 'undefined' ? window.location.hostname : '',
port,
remark: '',
sni: '',
fingerprint: '',
alpn: [],
}]);
} else {
form.setFieldValue(['streamSettings', 'externalProxy'], []);
}
};
const toggleSockopt = (on: boolean) => {
if (on) {
form.setFieldValue(
['streamSettings', 'sockopt'],
SockoptStreamSettingsSchema.parse({}),
);
} else {
form.setFieldValue(['streamSettings', 'sockopt'], undefined);
}
};
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
: '';
const regenInboundWg = () => {
const kp = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
};
const regenWgPeerKeypair = (peerName: number) => {
const kp = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
};
const matchesVlessAuth = (
block: { id?: string; label?: string } | undefined | null,
authId: string,
) => {
if (block?.id === authId) return true;
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
if (authId === 'mlkem768') return label.includes('mlkem768');
if (authId === 'x25519') return label.includes('x25519');
return false;
};
const getNewVlessEnc = async (authId: string) => {
if (!authId) return;
setSaving(true);
try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return;
const obj = msg.obj as {
auths?: { decryption: string; encryption: string; label?: string; id?: string }[];
};
const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
form.setFieldValue(['settings', 'decryption'], block.decryption);
form.setFieldValue(['settings', 'encryption'], block.encryption);
} finally {
setSaving(false);
}
};
const clearVlessEnc = () => {
form.setFieldValue(['settings', 'decryption'], 'none');
form.setFieldValue(['settings', 'encryption'], 'none');
};
const selectedVlessAuth = (() => {
const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
if (!enc || enc === 'none') return 'None';
const parts = enc.split('.').filter(Boolean);
const authKey = parts[parts.length - 1] || '';
if (!authKey) return t('pages.inbounds.vlessAuthCustom');
return authKey.length > 300
? t('pages.inbounds.vlessAuthMlkem768')
: t('pages.inbounds.vlessAuthX25519');
})();
useEffect(() => {
if (!open) return;
const initial = mode === 'edit' && dbInbound
? rawInboundToFormValues(dbInbound)
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
if (
mode === 'edit'
&& dbInbound
&& (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
) {
loadFallbacks(dbInbound.id);
} else {
setFallbacks([]);
}
}, [open, mode, dbInbound, form]);
// Why: protocol picker reset cascades through the form — clearing the
// settings DU branch and dropping a nodeId that no longer applies. The
// legacy modal did this imperatively in onProtocolChange; here we hook
// into AntD's onValuesChange and let setFieldValue keep the rest of
// the form state intact.
const onValuesChange = (changed: Partial<InboundFormValues>) => {
if (mode === 'edit') return;
if ('protocol' in changed && typeof changed.protocol === 'string') {
const next = changed.protocol;
const settings = createDefaultInboundSettings(next) ?? undefined;
form.setFieldValue('settings', settings);
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
form.setFieldValue('nodeId', null);
}
}
};
const submit = async () => {
let values: InboundFormValues;
try {
values = await form.validateFields();
} catch {
return;
}
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
const issue = parsed.error.issues[0];
messageApi.error(
t(issue?.message ?? 'somethingWentWrong', {
defaultValue: issue?.message ?? 'invalid',
}),
);
return;
}
setSaving(true);
try {
const payload = formValuesToWirePayload(parsed.data);
const url = mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
: '/panel/api/inbounds/add';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
if (isFallbackHost) {
const obj = msg.obj as { id?: number; Id?: number } | null;
const masterId = mode === 'edit'
? dbInbound!.id
: (obj?.id ?? obj?.Id ?? 0);
if (masterId) await saveFallbacks(masterId);
}
onSaved();
onClose();
}
} finally {
setSaving(false);
}
};
const title = mode === 'edit'
? t('pages.inbounds.modifyInbound')
: t('pages.inbounds.addInbound');
const okText = mode === 'edit'
? t('pages.clients.submitEdit')
: t('create');
const basicTab = (
<>
<Form.Item name="enable" label={t('enable')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="remark" label={t('pages.inbounds.remark')}>
<Input />
</Form.Item>
{selectableNodes.length > 0 && isNodeEligible && (
<Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')}
allowClear
>
<Select.Option value={null}>{t('pages.inbounds.localPanel')}</Select.Option>
{selectableNodes.map((n) => (
<Select.Option
key={n.id}
value={n.id}
disabled={n.status === 'offline'}
>
{n.name}{n.status === 'offline' ? ' (offline)' : ''}
</Select.Option>
))}
</Select>
</Form.Item>
)}
<Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</Form.Item>
<Form.Item name="listen" label={t('pages.inbounds.address')}>
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item>
<Form.Item
name="port"
label={t('pages.inbounds.port')}
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.inbounds.meansNoLimit')}>
{t('pages.inbounds.totalFlow')}
</Tooltip>
}
>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.total !== curr.total}
>
{({ getFieldValue, setFieldValue }) => {
const totalBytes = (getFieldValue('total') as number) ?? 0;
const totalGB = totalBytes
? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
: 0;
return (
<InputNumber
value={totalGB}
min={0}
step={1}
onChange={(v) => {
const bytes = NumberFormatter.toFixed(
(Number(v) || 0) * SizeFormatter.ONE_GB,
0,
);
setFieldValue('total', bytes);
}}
/>
);
}}
</Form.Item>
</Form.Item>
<Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select>
{TRAFFIC_RESETS.map((r) => (
<Select.Option key={r} value={r}>
{t(`pages.inbounds.periodicTrafficReset.${r}`)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
{t('pages.inbounds.expireDate')}
</Tooltip>
}
>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
>
{({ getFieldValue, setFieldValue }) => {
const expiry = (getFieldValue('expiryTime') as number) ?? 0;
return (
<DateTimePicker
value={expiry > 0 ? dayjs(expiry) : null}
onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
/>
);
}}
</Form.Item>
</Form.Item>
</>
);
const fallbacksCard = (
<Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
{fallbacks.length === 0 && (
<Empty
description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
styles={{ image: { height: 40 } }}
style={{ margin: '8px 0 12px' }}
/>
)}
{fallbacks.map((record, idx) => (
<div
key={record.rowKey}
style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
>
<Space.Compact block style={{ marginBottom: 6 }}>
<Select
value={record.childId}
options={fallbackChildOptions}
showSearch
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
filterOption={(input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
onChange={(v) => updateFallback(record.rowKey, { childId: v })}
/>
<Button danger onClick={() => removeFallback(idx)}>
<DeleteOutlined />
</Button>
</Space.Compact>
<Space.Compact block>
<InputAddon>SNI</InputAddon>
<Input
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
/>
<InputAddon>ALPN</InputAddon>
<Input
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
/>
<InputAddon>Path</InputAddon>
<Input
placeholder="/"
value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
/>
<InputAddon>xver</InputAddon>
<InputNumber
min={0}
max={2}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
/>
</Space.Compact>
</div>
))}
<Button size="small" onClick={addFallback}>
<PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
</Card>
);
const protocolTab = (
<>
{protocol === Protocols.WIREGUARD && (
<>
<Form.Item
name={['settings', 'secretKey']}
label={
<>
Secret key{' '}
<SyncOutlined className="random-icon" onClick={regenInboundWg} />
</>
}
>
<Input />
</Form.Item>
<Form.Item label="Public key">
<Input value={wgPubKey} disabled />
</Form.Item>
<Form.Item name={['settings', 'mtu']} label="MTU">
<InputNumber />
</Form.Item>
<Form.Item
name={['settings', 'noKernelTun']}
label="No-kernel TUN"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.List name={['settings', 'peers']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="Peers">
<Button
size="small"
onClick={() => add({
publicKey: '',
allowedIPs: [],
})}
>
<PlusOutlined /> Add peer
</Button>
</Form.Item>
{fields.map((field, idx) => (
<div key={field.key} className="wg-peer">
<Form.Item label={`Peer ${idx + 1}`}>
{fields.length > 1 && (
<Button
size="small"
danger
onClick={() => remove(field.name)}
>
<MinusOutlined />
</Button>
)}
</Form.Item>
<Form.Item
name={[field.name, 'privateKey']}
label={
<>
Secret key{' '}
<SyncOutlined
className="random-icon"
onClick={() => regenWgPeerKeypair(field.name)}
/>
</>
}
>
<Input />
</Form.Item>
<Form.Item name={[field.name, 'publicKey']} label="Public key">
<Input />
</Form.Item>
<Form.Item name={[field.name, 'preSharedKey']} label="PSK">
<Input />
</Form.Item>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<Form.Item label="Allowed IPs">
<Button size="small" onClick={() => addIp('')}>
<PlusOutlined />
</Button>
{ipFields.map((ipField) => (
<Space.Compact key={ipField.key} block className="mt-4">
<Form.Item name={ipField.name} noStyle>
<Input />
</Form.Item>
{ipFields.length > 1 && (
<Button size="small" onClick={() => removeIp(ipField.name)}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item name={[field.name, 'keepAlive']} label="Keep-alive">
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
)}
{protocol === Protocols.TUN && (
<>
<Form.Item name={['settings', 'name']} label="Interface name">
<Input placeholder="xray0" />
</Form.Item>
<Form.Item name={['settings', 'mtu']} label="MTU">
<InputNumber min={0} />
</Form.Item>
<Form.List name={['settings', 'gateway']}>
{(fields, { add, remove }) => (
<Form.Item label="Gateway">
<Button size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.key} block className="mt-4">
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.List name={['settings', 'dns']}>
{(fields, { add, remove }) => (
<Form.Item label="DNS">
<Button size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.key} block className="mt-4">
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item name={['settings', 'userLevel']} label="User level">
<InputNumber min={0} />
</Form.Item>
<Form.List name={['settings', 'autoSystemRoutingTable']}>
{(fields, { add, remove }) => (
<Form.Item
label={
<Tooltip title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
Auto system routes
</Tooltip>
}
>
<Button size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.key} block className="mt-4">
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item
name={['settings', 'autoOutboundsInterface']}
label={
<Tooltip title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
Auto outbounds interface
</Tooltip>
}
>
<Input placeholder="auto" />
</Form.Item>
</>
)}
{protocol === Protocols.TUNNEL && (
<>
<Form.Item name={['settings', 'rewriteAddress']} label="Rewrite address">
<Input />
</Form.Item>
<Form.Item name={['settings', 'rewritePort']} label="Rewrite port">
<InputNumber min={0} max={65535} />
</Form.Item>
<Form.Item name={['settings', 'allowedNetwork']} label="Allowed network">
<Select>
<Select.Option value="tcp,udp">TCP, UDP</Select.Option>
<Select.Option value="tcp">TCP</Select.Option>
<Select.Option value="udp">UDP</Select.Option>
</Select>
</Form.Item>
<Form.List name={['settings', 'portMap']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="Port map">
<Button size="small" onClick={() => add({ name: '', value: '' })}>
<PlusOutlined />
</Button>
</Form.Item>
{fields.length > 0 && (
<Form.Item wrapperCol={{ span: 24 }}>
{fields.map((field, idx) => (
<Space.Compact key={field.key} className="mb-8" block>
<InputAddon>{String(idx + 1)}</InputAddon>
<Form.Item name={[field.name, 'name']} noStyle>
<Input placeholder="5555" />
</Form.Item>
<Form.Item name={[field.name, 'value']} noStyle>
<Input placeholder="1.1.1.1:7777" />
</Form.Item>
<Button onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</>
)}
</Form.List>
<Form.Item
name={['settings', 'followRedirect']}
label="Follow redirect"
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{(protocol === Protocols.HTTP || protocol === Protocols.MIXED) && (
<>
<Form.List name={['settings', 'accounts']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="Accounts">
<Button size="small" onClick={() => add({ user: '', pass: '' })}>
<PlusOutlined /> Add
</Button>
</Form.Item>
{fields.length > 0 && (
<Form.Item wrapperCol={{ span: 24 }}>
{fields.map((field, idx) => (
<Space.Compact key={field.key} className="mb-8" block>
<InputAddon>{String(idx + 1)}</InputAddon>
<Form.Item name={[field.name, 'user']} noStyle>
<Input placeholder="Username" />
</Form.Item>
<Form.Item name={[field.name, 'pass']} noStyle>
<Input placeholder="Password" />
</Form.Item>
<Button onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</>
)}
</Form.List>
{protocol === Protocols.HTTP && (
<Form.Item
name={['settings', 'allowTransparent']}
label="Allow transparent"
valuePropName="checked"
>
<Switch />
</Form.Item>
)}
{protocol === Protocols.MIXED && (
<>
<Form.Item name={['settings', 'auth']} label="Auth">
<Select>
<Select.Option value="noauth">noauth</Select.Option>
<Select.Option value="password">password</Select.Option>
</Select>
</Form.Item>
<Form.Item
name={['settings', 'udp']}
label="UDP"
valuePropName="checked"
>
<Switch />
</Form.Item>
{mixedUdpOn && (
<Form.Item name={['settings', 'ip']} label="UDP IP">
<Input />
</Form.Item>
)}
</>
)}
</>
)}
{protocol === Protocols.SHADOWSOCKS && (
<>
<Form.Item name={['settings', 'method']} label="Encryption method">
<Select
onChange={(v) => {
form.setFieldValue(
['settings', 'password'],
RandomUtil.randomShadowsocksPassword(v as string),
);
}}
>
{SSMethodSchema.options.map((m) => (
<Select.Option key={m} value={m}>{m}</Select.Option>
))}
</Select>
</Form.Item>
{isSSWith2022 && (
<Form.Item
name={['settings', 'password']}
label={
<>
Password{' '}
<SyncOutlined
className="random-icon"
onClick={() => {
const method = form.getFieldValue(['settings', 'method']);
form.setFieldValue(
['settings', 'password'],
RandomUtil.randomShadowsocksPassword(method as string),
);
}}
/>
</>
}
>
<Input />
</Form.Item>
)}
<Form.Item name={['settings', 'network']} label="Network">
<Select style={{ width: 120 }}>
<Select.Option value="tcp,udp">TCP, UDP</Select.Option>
<Select.Option value="tcp">TCP</Select.Option>
<Select.Option value="udp">UDP</Select.Option>
</Select>
</Form.Item>
<Form.Item
name={['settings', 'ivCheck']}
label="ivCheck"
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{protocol === Protocols.VLESS && (
<>
<Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
<Input />
</Form.Item>
<Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space size={8} wrap>
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
{t('pages.inbounds.vlessAuthX25519')}
</Button>
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
{t('pages.inbounds.vlessAuthMlkem768')}
</Button>
<Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
</Space>
<Text type="secondary" className="vless-auth-state">
{t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })}
</Text>
</Form.Item>
</>
)}
{isFallbackHost && fallbacksCard}
</>
);
// Switching `network` swaps which per-network key (tcpSettings, wsSettings,
// grpcSettings, ...) appears on the wire. We clear the previously selected
// network's settings blob and seed a default empty object for the new one
// so AntD's Form.Items aren't pointed at undefined nested paths.
const onNetworkChange = (next: string) => {
const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, network: next };
for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k];
}
cleaned[`${next}Settings`] = {};
form.setFieldValue('streamSettings', cleaned);
};
const streamTab = (
<>
{protocol !== Protocols.HYSTERIA && (
<Form.Item label="Transmission">
<Select
value={network}
style={{ width: '75%' }}
onChange={onNetworkChange}
>
<Select.Option value="tcp">TCP (RAW)</Select.Option>
<Select.Option value="kcp">mKCP</Select.Option>
<Select.Option value="ws">WebSocket</Select.Option>
<Select.Option value="grpc">gRPC</Select.Option>
<Select.Option value="httpupgrade">HTTPUpgrade</Select.Option>
<Select.Option value="xhttp">XHTTP</Select.Option>
</Select>
</Form.Item>
)}
{network === 'tcp' && (
<>
<Form.Item
name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
label="Proxy Protocol"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tcpSettings?.header?.type
!== curr.streamSettings?.tcpSettings?.header?.type
}
>
{({ getFieldValue, setFieldValue }) => {
const headerType = getFieldValue(
['streamSettings', 'tcpSettings', 'header', 'type'],
) as string | undefined;
return (
<Switch
checked={headerType === 'http'}
onChange={(v) => {
setFieldValue(
['streamSettings', 'tcpSettings', 'header'],
v
? {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: ['/'],
headers: {},
},
}
: { type: 'none' },
);
}}
/>
);
}}
</Form.Item>
</Form.Item>
{/* Host + path camouflage inputs only render when the Switch
above is on. Both are string[] on the wire; normalize +
getValueProps translate to/from comma-joined input. Mirrors
the symmetric outbound side. */}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tcpSettings?.header?.type
!== curr.streamSettings?.tcpSettings?.header?.type
}
>
{({ getFieldValue }) => {
const headerType = getFieldValue(
['streamSettings', 'tcpSettings', 'header', 'type'],
) as string | undefined;
if (headerType !== 'http') return null;
return (
<>
<Form.Item
label={t('host')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'headers', 'Host',
]}
normalize={(v: unknown) =>
typeof v === 'string'
? v.split(',').map((s) => s.trim()).filter(Boolean)
: Array.isArray(v) ? v : []
}
getValueProps={(v: unknown) => ({
value: Array.isArray(v) ? v.join(',') : '',
})}
>
<Input placeholder="example.com,cdn.example.com" />
</Form.Item>
<Form.Item
label={t('path')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'path',
]}
normalize={(v: unknown) =>
typeof v === 'string'
? v.split(',').map((s) => s.trim()).filter(Boolean)
: Array.isArray(v) ? v : ['/']
}
getValueProps={(v: unknown) => ({
value: Array.isArray(v) ? v.join(',') : '/',
})}
>
<Input placeholder="/,/api,/static" />
</Form.Item>
</>
);
}}
</Form.Item>
</>
)}
{network === 'ws' && (
<>
<Form.Item
name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
label="Proxy Protocol"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
<Input />
</Form.Item>
<Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
label="Heartbeat Period"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label="Headers"
name={['streamSettings', 'wsSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
{network === 'grpc' && (
<>
<Form.Item
name={['streamSettings', 'grpcSettings', 'serviceName']}
label="Service Name"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'grpcSettings', 'authority']}
label="Authority"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'grpcSettings', 'multiMode']}
label="Multi Mode"
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{network === 'xhttp' && (
<>
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
<Input />
</Form.Item>
<Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
<Input />
</Form.Item>
<Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label="Mode">
<Select style={{ width: '50%' }}>
{(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => (
<Select.Option key={m} value={m}>{m}</Select.Option>
))}
</Select>
</Form.Item>
{xhttpMode === 'packet-up' && (
<>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
label="Max Buffered Upload"
>
<InputNumber />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
label="Max Upload Size (Byte)"
>
<Input />
</Form.Item>
</>
)}
{xhttpMode === 'stream-up' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
label="Stream-Up Server"
>
<Input />
</Form.Item>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
label="Server Max Header Bytes"
>
<InputNumber min={0} placeholder="0 (default)" />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
label="Padding Bytes"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label="Uplink HTTP Method"
>
<Select>
<Select.Option value="">Default (POST)</Select.Option>
<Select.Option value="POST">POST</Select.Option>
<Select.Option value="PUT">PUT</Select.Option>
<Select.Option value="GET" disabled={xhttpMode !== 'packet-up'}>
GET (packet-up only)
</Select.Option>
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
label="Padding Obfs Mode"
valuePropName="checked"
>
<Switch />
</Form.Item>
{xhttpObfsMode && (
<>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
label="Padding Key"
>
<Input placeholder="x_padding" />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
label="Padding Header"
>
<Input placeholder="X-Padding" />
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label="Padding Placement"
>
<Select>
<Select.Option value="">Default (queryInHeader)</Select.Option>
<Select.Option value="queryInHeader">queryInHeader</Select.Option>
<Select.Option value="header">header</Select.Option>
<Select.Option value="cookie">cookie</Select.Option>
<Select.Option value="query">query</Select.Option>
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label="Padding Method"
>
<Select>
<Select.Option value="">Default (repeat-x)</Select.Option>
<Select.Option value="repeat-x">repeat-x</Select.Option>
<Select.Option value="tokenish">tokenish</Select.Option>
</Select>
</Form.Item>
</>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
label="Session Placement"
>
<Select>
<Select.Option value="">Default (path)</Select.Option>
<Select.Option value="path">path</Select.Option>
<Select.Option value="header">header</Select.Option>
<Select.Option value="cookie">cookie</Select.Option>
<Select.Option value="query">query</Select.Option>
</Select>
</Form.Item>
{xhttpSessionPlacement && xhttpSessionPlacement !== 'path' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionKey']}
label="Session Key"
>
<Input placeholder="x_session" />
</Form.Item>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label="Sequence Placement"
>
<Select>
<Select.Option value="">Default (path)</Select.Option>
<Select.Option value="path">path</Select.Option>
<Select.Option value="header">header</Select.Option>
<Select.Option value="cookie">cookie</Select.Option>
<Select.Option value="query">query</Select.Option>
</Select>
</Form.Item>
{xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'seqKey']}
label="Sequence Key"
>
<Input placeholder="x_seq" />
</Form.Item>
)}
{xhttpMode === 'packet-up' && (
<>
<Form.Item
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label="Uplink Data Placement"
>
<Select>
<Select.Option value="">Default (body)</Select.Option>
<Select.Option value="body">body</Select.Option>
<Select.Option value="header">header</Select.Option>
<Select.Option value="cookie">cookie</Select.Option>
<Select.Option value="query">query</Select.Option>
</Select>
</Form.Item>
{xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
label="Uplink Data Key"
>
<Input placeholder="x_data" />
</Form.Item>
)}
</>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
label="No SSE Header"
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{network === 'httpupgrade' && (
<>
<Form.Item
name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
label="Proxy Protocol"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'httpupgradeSettings', 'host']}
label={t('host')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'httpupgradeSettings', 'path']}
label={t('path')}
>
<Input />
</Form.Item>
<Form.Item
label="Headers"
name={['streamSettings', 'httpupgradeSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
{network === 'kcp' && (
<>
<Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
<InputNumber min={576} max={1460} />
</Form.Item>
<Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label="TTI (ms)">
<InputNumber min={10} max={100} />
</Form.Item>
<Form.Item name={['streamSettings', 'kcpSettings', 'upCap']} label="Uplink (MB/s)">
<InputNumber min={0} />
</Form.Item>
<Form.Item name={['streamSettings', 'kcpSettings', 'downCap']} label="Downlink (MB/s)">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
label="CWND Multiplier"
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
label="Max Sending Window"
>
<InputNumber min={0} />
</Form.Item>
</>
)}
<Form.Item label="External Proxy">
<Switch checked={externalProxyOn} onChange={toggleExternalProxy} />
</Form.Item>
{externalProxyOn && (
<Form.List name={['streamSettings', 'externalProxy']}>
{(fields, { add, remove }) => (
<>
<Form.Item label=" " colon={false}>
<Button
size="small"
type="primary"
onClick={() => add({
forceTls: 'same',
dest: '',
port: 443,
remark: '',
sni: '',
fingerprint: '',
alpn: [],
})}
>
<PlusOutlined />
</Button>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{fields.map((field) => (
<div key={field.key} style={{ margin: '8px 0' }}>
<Space.Compact block>
<Form.Item name={[field.name, 'forceTls']} noStyle>
<Select style={{ width: '20%' }}>
<Select.Option value="same">{t('pages.inbounds.same')}</Select.Option>
<Select.Option value="none">{t('none')}</Select.Option>
<Select.Option value="tls">TLS</Select.Option>
</Select>
</Form.Item>
<Form.Item name={[field.name, 'dest']} noStyle>
<Input style={{ width: '30%' }} placeholder={t('host')} />
</Form.Item>
<Form.Item name={[field.name, 'port']} noStyle>
<InputNumber style={{ width: '15%' }} min={1} max={65535} />
</Form.Item>
<Form.Item name={[field.name, 'remark']} noStyle>
<Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
}
>
{({ getFieldValue }) => {
const ft = getFieldValue([
'streamSettings', 'externalProxy', field.name, 'forceTls',
]);
if (ft !== 'tls') return null;
return (
<Space.Compact style={{ marginTop: 6 }} block>
<Form.Item name={[field.name, 'sni']} noStyle>
<Input style={{ width: '30%' }} placeholder="SNI (defaults to host)" />
</Form.Item>
<Form.Item name={[field.name, 'fingerprint']} noStyle>
<Select style={{ width: '30%' }} placeholder="Fingerprint">
<Select.Option value="">Default</Select.Option>
{Object.values(UTLS_FINGERPRINT).map((fp) => (
<Select.Option key={fp} value={fp}>{fp}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={[field.name, 'alpn']} noStyle>
<Select mode="multiple" style={{ width: '40%' }} placeholder="ALPN">
{Object.values(ALPN_OPTION).map((a) => (
<Select.Option key={a} value={a}>{a}</Select.Option>
))}
</Select>
</Form.Item>
</Space.Compact>
);
}}
</Form.Item>
</div>
))}
</Form.Item>
</>
)}
</Form.List>
)}
<Form.Item label="Sockopt">
<Switch checked={sockoptOn} onChange={toggleSockopt} />
</Form.Item>
{sockoptOn && (
<>
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label="Route Mark">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
label="TCP Keep Alive Interval"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
label="TCP Keep Alive Idle"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label="TCP Max Seg">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
label="TCP User Timeout"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label="TCP Window Clamp"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
label="Proxy Protocol"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
label="TCP Fast Open"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpMptcp']}
label="Multipath TCP"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'penetrate']}
label="Penetrate"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'V6Only']}
label="V6 Only"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'domainStrategy']}
label="Domain Strategy"
>
<Select style={{ width: '50%' }}>
{Object.values(DOMAIN_STRATEGY_OPTION).map((d) => (
<Select.Option key={d} value={d}>{d}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label="TCP Congestion"
>
<Select style={{ width: '50%' }}>
{Object.values(TCP_CONGESTION_OPTION).map((c) => (
<Select.Option key={c} value={c}>{c}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select style={{ width: '50%' }}>
<Select.Option value="off">Off</Select.Option>
<Select.Option value="redirect">Redirect</Select.Option>
<Select.Option value="tproxy">TProxy</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label="Dialer Proxy">
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'interfaceName']}
label="Interface Name"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label="Trusted X-Forwarded-For"
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']}>
<Select.Option value="CF-Connecting-IP">CF-Connecting-IP</Select.Option>
<Select.Option value="X-Real-IP">X-Real-IP</Select.Option>
<Select.Option value="True-Client-IP">True-Client-IP</Select.Option>
<Select.Option value="X-Client-IP">X-Client-IP</Select.Option>
</Select>
</Form.Item>
</>
)}
</>
);
const securityTab = (
<>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.security !== curr.streamSettings?.security
}
>
{({ getFieldValue }) => {
const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
return (
<Select
value={sec}
disabled={!tlsAllowed}
onChange={onSecurityChange}
style={{ width: 180 }}
>
<Select.Option value="none">none</Select.Option>
<Select.Option value="tls">tls</Select.Option>
{realityAllowed && <Select.Option value="reality">reality</Select.Option>}
</Select>
);
}}
</Form.Item>
</Form.Item>
{security === 'tls' && (
<>
<Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
<Input placeholder="Server Name Indication" />
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label="Cipher Suites">
<Select>
<Select.Option value="">Auto</Select.Option>
{Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => (
<Select.Option key={v} value={v}>{k}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="Min/Max Version">
<Space.Compact block>
<Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select style={{ width: '50%' }}>
{Object.values(TLS_VERSION_OPTION).map((v) => (
<Select.Option key={v} value={v}>{v}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select style={{ width: '50%' }}>
{Object.values(TLS_VERSION_OPTION).map((v) => (
<Select.Option key={v} value={v}>{v}</Select.Option>
))}
</Select>
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select>
<Select.Option value="">None</Select.Option>
{Object.values(UTLS_FINGERPRINT).map((fp) => (
<Select.Option key={fp} value={fp}>{fp}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select mode="multiple" tokenSeparators={[',']} style={{ width: '100%' }}>
{Object.values(ALPN_OPTION).map((a) => (
<Select.Option key={a} value={a}>{a}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
label="Reject Unknown SNI"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
label="Disable System Root"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
label="Session Resumption"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
{(certFields, { add, remove }) => (
<>
<Form.Item label={t('certificate')}>
<Button
type="primary"
size="small"
onClick={() => add({
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
})}
>
<PlusOutlined />
</Button>
</Form.Item>
{certFields.map((certField, idx) => (
<div key={certField.key}>
<Form.Item
name={[certField.name, 'useFile']}
label={`${t('certificate')} ${idx + 1}`}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value={true}>
{t('pages.inbounds.certificatePath')}
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
</Radio.Group>
</Form.Item>
{certFields.length > 1 && (
<Form.Item label=" ">
<Button
size="small"
danger
onClick={() => remove(certField.name)}
>
<MinusOutlined /> Remove
</Button>
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
}
>
{({ getFieldValue }) => {
const useFile = getFieldValue([
'streamSettings', 'tlsSettings', 'certificates',
certField.name, 'useFile',
]);
return useFile ? (
<>
<Form.Item
name={[certField.name, 'certificateFile']}
label={t('pages.inbounds.publicKey')}
>
<Input />
</Form.Item>
<Form.Item
name={[certField.name, 'keyFile']}
label={t('pages.inbounds.privatekey')}
>
<Input />
</Form.Item>
</>
) : (
<>
<Form.Item
name={[certField.name, 'certificate']}
label={t('pages.inbounds.publicKey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
<Form.Item
name={[certField.name, 'key']}
label={t('pages.inbounds.privatekey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
name={[certField.name, 'oneTimeLoading']}
label="One Time Loading"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={[certField.name, 'usage']}
label="Usage Option"
>
<Select style={{ width: '50%' }}>
{Object.values(USAGE_OPTION).map((u) => (
<Select.Option key={u} value={u}>{u}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
}
>
{({ getFieldValue }) => {
const usage = getFieldValue([
'streamSettings', 'tlsSettings', 'certificates',
certField.name, 'usage',
]);
if (usage !== 'issue') return null;
return (
<Form.Item
name={[certField.name, 'buildChain']}
label="Build Chain"
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label="ECH key">
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label="ECH config"
>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
Get New ECH Cert
</Button>
<Button danger onClick={clearEchCert}>Clear</Button>
</Space>
</Form.Item>
</>
)}
{security === 'reality' && (
<>
<Form.Item
name={['streamSettings', 'realitySettings', 'show']}
label="Show"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name={['streamSettings', 'realitySettings', 'xver']} label="Xver">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select>
{Object.values(UTLS_FINGERPRINT).map((fp) => (
<Select.Option key={fp} value={fp}>{fp}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'target']}
label={
<>
Target{' '}
<SyncOutlined className="random-icon" onClick={randomizeRealityTarget} />
</>
}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'serverNames']}
label={
<>
SNI{' '}
<SyncOutlined className="random-icon" onClick={randomizeRealityTarget} />
</>
}
>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxTimediff']}
label="Max Time Diff (ms)"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'minClientVer']}
label="Min Client Ver"
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxClientVer']}
label="Max Client Ver"
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'shortIds']}
label={
<>
Short IDs{' '}
<SyncOutlined className="random-icon" onClick={randomizeShortIds} />
</>
}
>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
label="SpiderX"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
label={t('pages.inbounds.publicKey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'privateKey']}
label={t('pages.inbounds.privatekey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={genRealityKeypair}>
Get New Cert
</Button>
<Button danger onClick={clearRealityKeypair}>Clear</Button>
</Space>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
label="mldsa65 Seed"
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
label="mldsa65 Verify"
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={genMldsa65}>
Get New Seed
</Button>
<Button danger onClick={clearMldsa65}>Clear</Button>
</Space>
</Form.Item>
</>
)}
</>
);
const advancedTab = (
<Tabs
items={[
{
key: 'settings',
label: t('pages.inbounds.advanced.settings'),
children: (
<AdvancedSliceEditor
form={form}
path="settings"
minHeight="320px"
maxHeight="540px"
/>
),
},
...(streamEnabled
? [{
key: 'stream',
label: t('pages.inbounds.advanced.stream'),
children: (
<AdvancedSliceEditor
form={form}
path="streamSettings"
minHeight="320px"
maxHeight="540px"
/>
),
}]
: []),
{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<AdvancedSliceEditor
form={form}
path="sniffing"
minHeight="240px"
maxHeight="420px"
/>
),
},
]}
/>
);
const sniffingTab = (
<>
<Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
<Switch />
</Form.Item>
{sniffingEnabled && (
<>
<Form.Item name={['sniffing', 'destOverride']} wrapperCol={{ span: 24 }}>
<Checkbox.Group>
{Object.entries(SNIFFING_OPTION).map(([key, value]) => (
<Checkbox key={key} value={value}>{key}</Checkbox>
))}
</Checkbox.Group>
</Form.Item>
<Form.Item
name={['sniffing', 'metadataOnly']}
label={t('pages.inbounds.sniffingMetadataOnly')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['sniffing', 'routeOnly']}
label={t('pages.inbounds.sniffingRouteOnly')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['sniffing', 'ipsExcluded']}
label={t('pages.inbounds.sniffingIpsExcluded')}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name={['sniffing', 'domainsExcluded']}
label={t('pages.inbounds.sniffingDomainsExcluded')}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
</>
)}
</>
);
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
confirmLoading={saving}
mask={{ closable: false }}
width={780}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<Form
form={form}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
onValuesChange={onValuesChange}
>
<Tabs items={[
{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab },
...(([
Protocols.VLESS,
Protocols.SHADOWSOCKS,
Protocols.HTTP,
Protocols.MIXED,
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
] as string[]).includes(protocol) || isFallbackHost
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab }]
: []),
...(streamEnabled
? [
{ key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab },
{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab },
]
: []),
{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab },
{ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab },
]} />
</Form>
</Modal>
</>
);
}