| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912 |
- import { useEffect, useRef, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import dayjs from 'dayjs';
- import {
- Form,
- Input,
- InputNumber,
- Modal,
- Radio,
- Select,
- Switch,
- Tabs,
- Tooltip,
- message,
- } from 'antd';
- 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 { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
- import {
- canEnableReality,
- canEnableStream,
- canEnableTls,
- isSS2022,
- } from '@/lib/xray/protocol-capabilities';
- import {
- InboundFormBaseSchema,
- InboundFormSchema,
- type InboundFormValues,
- } from '@/schemas/forms/inbound-form';
- import { antdRule } from '@/utils/zodForm';
- import { Protocols } from '@/schemas/primitives';
- import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
- import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
- import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
- import { SniffingSchema } from '@/schemas/primitives/sniffing';
- import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
- import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
- import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
- import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
- import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
- import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
- import { DateTimePicker } from '@/components/form';
- import { FinalMaskForm } from '@/lib/xray/forms/transport';
- import './InboundFormModal.css';
- import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
- import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
- import {
- HttpFields,
- HysteriaFields,
- MixedFields,
- ShadowsocksFields,
- TunFields,
- TunnelFields,
- VlessFields,
- WireguardFields,
- } from './protocols';
- import {
- ExternalProxyForm,
- GrpcForm,
- HttpUpgradeForm,
- KcpForm,
- RawForm,
- SockoptForm,
- WsForm,
- XhttpForm,
- } from './transport';
- import { RealityForm, TlsForm } from './security';
- import { useSecurityActions } from './useSecurityActions';
- import { useInboundFallbacks } from './useInboundFallbacks';
- import FallbacksCard from './FallbacksCard';
- import SniffingTab from './SniffingTab';
- import type { DBInbound } from '@/models/dbinbound';
- import type { NodeRecord } from '@/api/queries/useNodesQuery';
- 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',
- tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }),
- },
- sniffing: SniffingSchema.parse({}),
- 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 {
- fallbacks,
- fallbackChildOptions,
- loadFallbacks,
- saveFallbacks,
- addFallback,
- updateFallback,
- removeFallback,
- moveFallback,
- addAllFallbacks,
- } = useInboundFallbacks(dbInbound, dbInbounds);
- 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 wPort = Form.useWatch('port', form);
- const wListen = (Form.useWatch('listen', form) ?? '') as string;
- const isUdsListen = wListen.startsWith('/');
- const wNodeId = Form.useWatch('nodeId', form) ?? null;
- const wTag = Form.useWatch('tag', form) ?? '';
- const wSsNetwork = Form.useWatch(['settings', 'network'], form);
- const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
- const autoTagRef = useRef(true);
- const lastWrittenTagRef = useRef('');
- const currentTagInput = (): InboundTagInput => ({
- port: typeof wPort === 'number' ? wPort : 0,
- nodeId: typeof wNodeId === 'number' ? wNodeId : null,
- protocol,
- streamSettings: { network },
- settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
- });
- const isFallbackHost =
- (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
- && network === 'tcp'
- && (security === 'tls' || security === 'reality');
- const {
- genRealityKeypair,
- clearRealityKeypair,
- genMldsa65,
- clearMldsa65,
- randomizeRealityTarget,
- randomizeShortIds,
- getNewEchCert,
- clearEchCert,
- generateRandomPinHash,
- setCertFromPanel,
- clearCertFiles,
- onSecurityChange,
- } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
- 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: [],
- pinnedPeerCertSha256: [],
- }]);
- } 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);
- const initialTag = (initial.tag ?? '') as string;
- autoTagRef.current = isAutoInboundTag(initialTag, {
- port: initial.port ?? 0,
- nodeId: initial.nodeId ?? null,
- protocol: initial.protocol,
- streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
- settings: (initial.settings ?? {}) as Record<string, unknown>,
- });
- lastWrittenTagRef.current = initialTag;
- if (
- mode === 'edit'
- && dbInbound
- && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
- ) {
- loadFallbacks(dbInbound.id);
- } else {
- loadFallbacks(null);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, mode, dbInbound, form]);
- useEffect(() => {
- if (!open) return;
- if (wTag === lastWrittenTagRef.current) return;
- autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, wTag]);
- useEffect(() => {
- if (!open || !autoTagRef.current) return;
- const next = composeInboundTag(currentTagInput());
- if (next !== (form.getFieldValue('tag') ?? '')) {
- lastWrittenTagRef.current = next;
- form.setFieldValue('tag', next);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
- // 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);
- }
- // Hysteria uses its dedicated transport — force the network branch
- // so the stream tab renders the hysteria sub-form, not the leftover
- // tcpSettings from the previous protocol. When leaving hysteria,
- // snap back to TCP so the standard network selector has a valid
- // starting point.
- if (next === Protocols.HYSTERIA) {
- const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
- tls.certificates = [{
- useFile: true,
- certificateFile: '',
- keyFile: '',
- certificate: [],
- key: [],
- oneTimeLoading: false,
- usage: 'encipherment',
- buildChain: false,
- }];
- form.setFieldValue('streamSettings', {
- network: 'hysteria',
- security: 'tls',
- hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
- tlsSettings: tls,
- // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
- // it with salamander + a random password so the listener boots
- // with a usable default. Re-selecting Hysteria from another
- // protocol re-runs this and refreshes the password — that's
- // intentional, the form was already being reset.
- finalmask: {
- tcp: [],
- udp: [{
- type: 'salamander',
- settings: { password: RandomUtil.randomLowerAndNum(16) },
- }],
- },
- });
- } else {
- const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
- if (current?.network === 'hysteria') {
- form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
- }
- }
- }
- };
- const submit = async () => {
- try {
- await form.validateFields();
- } catch {
- return;
- }
- // Why getFieldsValue(true) instead of the validateFields return value:
- // rc-component/form's validateFields filters its output by REGISTERED
- // name paths. settings.clients and settings.fallbacks have no Form.Item
- // bound to them (clients are managed via the standalone Client modal,
- // not inside this inbound modal) — so validateFields would drop them
- // and the update wire payload would silently delete every client on
- // every save. getFieldsValue(true) returns the entire form store and
- // keeps those sub-trees intact.
- const values = form.getFieldsValue(true) as InboundFormValues;
- const parsed = InboundFormSchema.safeParse(values);
- if (!parsed.success) {
- const issues = parsed.error.issues;
- messageApi.error(formatInboundValidation(issues, values, t));
- console.error(
- '[InboundFormModal] schema validation failed:',
- issues.map((issue) => formatInboundIssue(issue, values, t)),
- );
- 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="tag" hidden noStyle><Input /></Form.Item>
- <Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
- <Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
- <Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
- <Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
- <Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
- <Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
- <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
- options={selectableNodes.map((n) => ({
- value: n.id,
- label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
- disabled: n.status === 'offline',
- }))}
- />
- </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')}
- extra={t('pages.inbounds.form.listenHelp')}
- >
- <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={isUdsListen ? 0 : 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
- options={TRAFFIC_RESETS.map((r) => ({
- value: r,
- label: t(`pages.inbounds.periodicTrafficReset.${r}`),
- }))}
- />
- </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 = (
- <FallbacksCard
- fallbacks={fallbacks}
- fallbackChildOptions={fallbackChildOptions}
- addFallback={addFallback}
- updateFallback={updateFallback}
- removeFallback={removeFallback}
- moveFallback={moveFallback}
- addAllFallbacks={addAllFallbacks}
- />
- );
- const protocolTab = (
- <>
- {protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
- {protocol === Protocols.TUN && <TunFields />}
- {protocol === Protocols.TUNNEL && <TunnelFields />}
- {protocol === Protocols.HTTP && <HttpFields />}
- {protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
- {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
- {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
- {isFallbackHost && fallbacksCard}
- </>
- );
- // Switching `network` swaps which per-network key (tcpSettings,
- // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
- // network's blob and seed the new one with the schema defaults so the
- // Form.Items inside it have valid initial values (KCP needs MTU=1350
- // etc., not empty strings).
- // Seed each network's settings blob with its Zod schema defaults so
- // every Form.Item inside the network sub-form has a defined starting
- // value. XHTTP in particular has ~20 fields (sessionPlacement,
- // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
- // is the literal "" sentinel meaning "let xray-core pick its
- // default". Without seeding "", the Form.Item reads `undefined` and
- // the Select shows blank instead of the "Default (path)" option.
- const newStreamSlice = (n: string): Record<string, unknown> => {
- switch (n) {
- case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
- case 'kcp': return KcpStreamSettingsSchema.parse({});
- case 'ws': return WsStreamSettingsSchema.parse({});
- case 'grpc': return GrpcStreamSettingsSchema.parse({});
- case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
- case 'xhttp': return XHttpStreamSettingsSchema.parse({});
- default: return {};
- }
- };
- 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`] = newStreamSlice(next);
- // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
- // `mkcp-legacy` so the inbound boots with a sensible default
- // instead of unobfuscated mKCP traffic. The user can still edit or
- // clear the mask via the FinalMask section.
- if (next === 'kcp') {
- const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
- const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
- const hasMkcp = udp.some((m) => {
- const entry = m as { type?: string };
- return entry?.type === 'mkcp-legacy';
- });
- if (!hasMkcp) {
- cleaned.finalmask = {
- ...fm,
- udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
- };
- }
- }
- form.setFieldValue('streamSettings', cleaned);
- };
- const streamTab = (
- <>
- {protocol !== Protocols.HYSTERIA && (
- <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
- <Select
- style={{ width: '75%' }}
- onChange={onNetworkChange}
- options={[
- { value: 'tcp', label: 'RAW' },
- { value: 'kcp', label: 'mKCP' },
- { value: 'ws', label: 'WebSocket' },
- { value: 'grpc', label: 'gRPC' },
- { value: 'httpupgrade', label: 'HTTPUpgrade' },
- { value: 'xhttp', label: 'XHTTP' },
- ]}
- />
- </Form.Item>
- )}
- {/* Inbound Hysteria stream sub-form. The transport for hysteria
- isn't user-selectable (always 'hysteria'), so the network
- dropdown is hidden above. Fields here mirror the legacy
- HysteriaStreamSettings inbound class: version is locked to 2,
- auth + udpIdleTimeout are required, masquerade is an optional
- sub-object that lets xray-core disguise the listener as an
- HTTP server when probed. */}
- {protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
- {network === 'tcp' && <RawForm />}
- {network === 'ws' && <WsForm />}
- {network === 'grpc' && <GrpcForm />}
- {network === 'xhttp' && <XhttpForm form={form} />}
- {network === 'httpupgrade' && <HttpUpgradeForm />}
- {network === 'kcp' && <KcpForm />}
- <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
- <SockoptForm toggleSockopt={toggleSockopt} />
- <FinalMaskForm
- name={['streamSettings', 'finalmask']}
- network={network as string}
- protocol={protocol}
- form={form}
- />
- </>
- );
- const securityTab = (
- <>
- <Form.Item name={['streamSettings', 'security']} hidden noStyle>
- <Input />
- </Form.Item>
- <Form.Item label={t('pages.inbounds.securityTab')}>
- <Form.Item
- noStyle
- shouldUpdate={(prev, curr) =>
- prev.streamSettings?.security !== curr.streamSettings?.security
- || prev.streamSettings?.network !== curr.streamSettings?.network
- || prev.protocol !== curr.protocol
- }
- >
- {({ getFieldValue }) => {
- const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
- const net = getFieldValue(['streamSettings', 'network']) ?? '';
- const proto = getFieldValue('protocol') ?? '';
- const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
- const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
- const tlsOnly = proto === Protocols.HYSTERIA;
- return (
- <Radio.Group
- value={sec}
- buttonStyle="solid"
- disabled={!tlsOk}
- onChange={(e) => onSecurityChange(e.target.value)}
- >
- {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
- <Radio.Button value="tls">TLS</Radio.Button>
- {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
- </Radio.Group>
- );
- }}
- </Form.Item>
- </Form.Item>
- {security === 'tls' && (
- <TlsForm
- saving={saving}
- setCertFromPanel={setCertFromPanel}
- clearCertFiles={clearCertFiles}
- generateRandomPinHash={generateRandomPinHash}
- getNewEchCert={getNewEchCert}
- clearEchCert={clearEchCert}
- />
- )}
- {security === 'reality' && (
- <RealityForm
- saving={saving}
- randomizeRealityTarget={randomizeRealityTarget}
- randomizeShortIds={randomizeShortIds}
- genRealityKeypair={genRealityKeypair}
- clearRealityKeypair={clearRealityKeypair}
- genMldsa65={genMldsa65}
- clearMldsa65={clearMldsa65}
- />
- )}
- </>
- );
- const advancedTab = (
- <div className="advanced-shell">
- <div className="advanced-panel">
- <div className="advanced-panel__header">
- <div>
- <div className="advanced-panel__title">{t('pages.inbounds.advanced.title')}</div>
- <div className="advanced-panel__subtitle">{t('pages.inbounds.advanced.subtitle')}</div>
- </div>
- </div>
- <Tabs
- className="advanced-inner-tabs"
- items={[
- {
- key: 'all',
- label: t('pages.inbounds.advanced.all'),
- children: (
- <>
- <div className="advanced-editor-meta">
- {t('pages.inbounds.advanced.allHelp')}
- </div>
- <AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
- </>
- ),
- },
- {
- key: 'settings',
- label: t('pages.inbounds.advanced.settings'),
- children: (
- <>
- <div className="advanced-editor-meta">
- {t('pages.inbounds.advanced.settingsHelp')}{' '}
- <code>{'{ settings: { ... } }'}</code>.
- </div>
- <AdvancedSliceEditor
- form={form}
- path="settings"
- wrapKey="settings"
- minHeight="320px"
- maxHeight="540px"
- />
- </>
- ),
- },
- ...(streamEnabled
- ? [{
- key: 'stream',
- label: t('pages.inbounds.advanced.stream'),
- children: (
- <>
- <div className="advanced-editor-meta">
- {t('pages.inbounds.advanced.streamHelp')}{' '}
- <code>{'{ streamSettings: { ... } }'}</code>.
- </div>
- <AdvancedSliceEditor
- form={form}
- path="streamSettings"
- wrapKey="streamSettings"
- minHeight="320px"
- maxHeight="540px"
- />
- </>
- ),
- }]
- : []),
- {
- key: 'sniffing',
- label: t('pages.inbounds.advanced.sniffing'),
- children: (
- <>
- <div className="advanced-editor-meta">
- {t('pages.inbounds.advanced.sniffingHelp')}{' '}
- <code>{'{ sniffing: { ... } }'}</code>.
- </div>
- <AdvancedSliceEditor
- form={form}
- path="sniffing"
- wrapKey="sniffing"
- minHeight="240px"
- maxHeight="420px"
- />
- </>
- ),
- },
- ]}
- />
- </div>
- </div>
- );
- const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
- 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={[
- // forceRender on every tab so all Form.Items register at modal
- // open, not lazily on first visit. Without it, AntD's items API
- // lazy-mounts inactive tabs — their fields don't register, so
- // Form.useWatch on a parent path (e.g. 'sniffing') returns the
- // partial-view {} until the user touches the tab and the
- // inner Form.Item for `sniffing.enabled` registers.
- { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
- ...(([
- 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, forceRender: true }]
- : []),
- ...(streamEnabled
- ? [
- { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
- { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
- ]
- : []),
- { key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
- { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
- ]} />
- </Form>
- </Modal>
- </>
- );
- }
|