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([ 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(); 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, settings: (initial.settings ?? {}) as Record, }); 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) => { 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; 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 = ( <> {selectableNodes.length > 0 && isNodeEligible && ( {t('pages.inbounds.totalFlow')} } > 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 ( { const bytes = NumberFormatter.toFixed( (Number(v) || 0) * SizeFormatter.ONE_GB, 0, ); setFieldValue('total', bytes); }} /> ); }} )} {/* 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 && } {network === 'tcp' && } {network === 'ws' && } {network === 'grpc' && } {network === 'xhttp' && } {network === 'httpupgrade' && } {network === 'kcp' && } ); const securityTab = ( <> 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 ( onSecurityChange(e.target.value)} > {!tlsOnly && {t('none')}} TLS {realityOk && Reality} ); }} {security === 'tls' && ( )} {security === 'reality' && ( )} ); const advancedTab = (
{t('pages.inbounds.advanced.title')}
{t('pages.inbounds.advanced.subtitle')}
{t('pages.inbounds.advanced.allHelp')}
), }, { key: 'settings', label: t('pages.inbounds.advanced.settings'), children: ( <>
{t('pages.inbounds.advanced.settingsHelp')}{' '} {'{ settings: { ... } }'}.
), }, ...(streamEnabled ? [{ key: 'stream', label: t('pages.inbounds.advanced.stream'), children: ( <>
{t('pages.inbounds.advanced.streamHelp')}{' '} {'{ streamSettings: { ... } }'}.
), }] : []), { key: 'sniffing', label: t('pages.inbounds.advanced.sniffing'), children: ( <>
{t('pages.inbounds.advanced.sniffingHelp')}{' '} {'{ sniffing: { ... } }'}.
), }, ]} />
); const sniffingTab = ; return ( <> {messageContextHolder}
); }