import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { QuestionCircleOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, Tooltip, message, } from 'antd'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import type { RealityScanResult } from '@/generated/types'; 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, canEnableSniffing, canEnableStream, canEnableTls, isSS2022, } from '@/lib/xray/protocol-capabilities'; import { InboundDbFieldsSchema, InboundFormBaseSchema, InboundFormSchema, type InboundFormValues, } from '@/schemas/forms/inbound-form'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { Protocols, TRAFFIC_RESETS } from '@/schemas/primitives'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols'; import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption'; 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 { FinalMaskField } from '@/lib/xray/forms/fields'; import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; import { formatInboundIssue, formatInboundValidation } from './formatValidationError'; import { HttpFields, HysteriaFields, MixedFields, MtprotoFields, ShadowsocksFields, TunFields, TunnelFields, VlessFields, WireguardFields, } from './protocols'; import { 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'; /* Render a field label with a hover tooltip icon instead of an `extra` help line below. */ const labelWithHint = (label: string, hint: string) => ( {label} ); const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const; const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/; function isValidShareAddrInput(value: string): boolean { const v = value.trim(); if (v.length === 0) return true; if (v.includes('://') || v.startsWith('//') || /[/?#@]/.test(v)) return false; if (v.startsWith('[')) { if (!v.endsWith(']')) return false; try { new URL(`http://${v}`); return true; } catch { return false; } } if (v.includes(':')) { try { new URL(`http://[${v}]`); return true; } catch { return false; } } return SHARE_ADDR_HOSTNAME_RE.test(v); } interface RhfValidationIssue { path: PropertyKey[]; message: string; } function firstRhfValidationIssue( value: unknown, path: PropertyKey[] = [], ): RhfValidationIssue | null { if (!value || typeof value !== 'object') return null; const record = value as Record; // `type` is what marks a react-hook-form leaf FieldError; anything else is a group. if ('type' in record) { return { path, message: typeof record.message === 'string' ? record.message : '' }; } for (const key of Object.keys(record)) { const issue = firstRhfValidationIssue(record[key], [...path, key]); if (issue) return issue; } return null; } function tabForValidationPath(path: PropertyKey[]): string { if (path[0] === 'settings') return 'protocol'; if (path[0] === 'sniffing') return 'sniffing'; if (path[0] === 'streamSettings') { if ( path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings' ) return 'security'; return 'stream'; } return 'basic'; } interface InboundFormModalProps { open: boolean; onClose: () => void; onSaved: () => void; mode: 'add' | 'edit'; dbInbound: DBInbound | null; dbInbounds: DBInbound[]; availableNodes?: NodeRecord[]; availableNodesFetched?: boolean; } 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', }); } /* * Switching `network` swaps which per-network key (tcpSettings, wsSettings, * grpcSettings, ...) appears on the wire. Seed each network's blob with its * Zod schema defaults so every field inside the network sub-form has a * defined starting value (KCP needs MTU=1350 etc., XHTTP needs the "" * sentinels so the "Default" option shows instead of blank). */ function newStreamSlice(n: string): Record { 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 {}; } } export default function InboundFormModal({ open, onClose, onSaved, mode, dbInbound, dbInbounds, availableNodes, availableNodesFetched = true, }: InboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const methods = useForm({ defaultValues: buildAddModeValues() }); const setV = methods.setValue as unknown as (name: string, value: unknown) => void; const getV = methods.getValues as unknown as (name?: string) => unknown; const control = methods.control; const [saving, setSaving] = useState(false); const [scanning, setScanning] = useState(false); const [scanResult, setScanResult] = useState(null); const [activeTab, setActiveTab] = useState('basic'); const { fallbacks, fallbackChildOptions, loadFallbacks, saveFallbacks, addFallback, updateFallback, removeFallback, moveFallback, addAllFallbacks, } = useInboundFallbacks(dbInbound, dbInbounds); const selectableNodes = (availableNodes || []).filter((n) => n.enable); const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string; const isNodeEligible = !!NODE_ELIGIBLE_PROTOCOLS[protocol]; /* * The `node` share-address strategy only means something when the inbound can * actually live on a node — otherwise the node address it would resolve to is * always empty. Offer it only then; `listen`/`custom` work for local inbounds. */ const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible; const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? ''; const ssMethod = useWatch({ control, name: 'settings.method' }); const isSSWith2022 = isSS2022({ protocol, settings: typeof ssMethod === 'string' ? { method: ssMethod } : {}, }); const mixedUdpOn = (useWatch({ control, name: 'settings.udp' }) ?? false) as boolean; const network = (useWatch({ control, name: 'streamSettings.network' }) ?? '') as string; const security = (useWatch({ control, name: 'streamSettings.security' }) ?? 'none') as string; const streamEnabled = canEnableStream({ protocol }); const sniffingSupported = canEnableSniffing({ protocol }); /* * Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no * user-selectable transport — their stream tab is just sockopt, which is all * Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its * own dedicated transport form. For all of these the RAW/mKCP/WS/... network * picker and the per-network sub-forms are hidden. */ const hasSelectableTransport = protocol !== Protocols.HYSTERIA && protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL; const wPort = useWatch({ control, name: 'port' }); const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string; const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@'); const wNodeId = useWatch({ control, name: 'nodeId' }) ?? null; const shareAddrStrategy = useWatch({ control, name: 'shareAddrStrategy' }) ?? 'node'; const wTag = (useWatch({ control, name: 'tag' }) ?? '') as string; const wSsNetwork = useWatch({ control, name: 'settings.network' }); const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' }); const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0; const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0; const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never'; 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, scanRealityTarget, scanRealityCandidates, applyRealityScanResult, randomizeShortIds, randomizeSpiderX, getNewEchCert, clearEchCert, pinFromCert, pinFromRemote, setCertFromPanel, clearCertFiles, onSecurityChange, } = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning }); const toggleSockopt = (on: boolean) => { if (on) { setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({})); } else { setV('streamSettings.sockopt', undefined); } }; const wgSecretKey = useWatch({ control, name: 'settings.secretKey' }); const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0 ? Wireguard.generateKeypair(wgSecretKey).publicKey : ''; const regenInboundWg = () => { const kp = Wireguard.generateKeypair(); setV('settings.secretKey', kp.privateKey); }; 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') && !label.includes('xorpub') && !label.includes('random'); if (authId === 'x25519') return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random'); if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub'); if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random'); if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub'); if (authId === 'x25519_random') return label.includes('x25519') && label.includes('random'); 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; setV('settings.decryption', block.decryption); setV('settings.encryption', block.encryption); } finally { setSaving(false); } }; const clearVlessEnc = () => { setV('settings.decryption', 'none'); setV('settings.encryption', 'none'); }; const vlessAuthKind = vlessEncryptionAuthKind( typeof vlessEncryption === 'string' ? vlessEncryption : '', ); const selectedVlessAuth = (() => { const enc = typeof vlessEncryption === 'string' ? vlessEncryption : ''; if (!enc || enc === 'none') return 'None'; if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom'); return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]); })(); useEffect(() => { if (!open) return; const initial = mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues(); methods.reset(initial); setScanResult(null); setActiveTab('basic'); 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, methods]); 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 !== ((getV('tag') as string | undefined) ?? '')) { lastWrittenTagRef.current = next; setV('tag', next); } /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]); /* * Keep the strategy value inside the visible option set: when `node` isn't * offered (no node, or a protocol that can't deploy to one) fall back to * `listen`, which yields the same link for a local inbound. Mirrors how the * protocol reset drops a nodeId that no longer applies. * Only downgrade once the inputs this decision depends on are settled, so a * persisted `node` strategy is never clobbered by transient mount state (#5375). */ useEffect(() => { if (!open) return; if (!availableNodesFetched || !protocol) return; const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined; if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') { setV('shareAddrStrategy', 'listen'); } /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]); /* * Protocol picker reset cascades through the form — clearing the settings DU * branch and dropping a nodeId that no longer applies. Only a real user * change (type === 'change') triggers it; programmatic setValue (advanced * JSON edits, open reset) must not, matching the legacy onValuesChange. */ useEffect(() => { if (mode === 'edit') return; /* eslint-disable-next-line react-hooks/incompatible-library */ const sub = methods.watch((_value, { name, type }) => { if (name !== 'protocol' || type !== 'change') return; const next = getV('protocol') as string; const settings = createDefaultInboundSettings(next) ?? undefined; setV('settings', settings); if (!NODE_ELIGIBLE_PROTOCOLS[next]) { setV('nodeId', null); } if (next !== Protocols.VLESS) { setV('disableFlow', false); } if (next === Protocols.HYSTERIA) { setV('streamSettings', { network: 'hysteria', security: 'tls', hysteriaSettings: HysteriaStreamSettingsSchema.parse({}), tlsSettings: createHysteriaTlsSettingsWithDefaultCert(), finalmask: { tcp: [], udp: [{ type: 'salamander', settings: { password: RandomUtil.randomLowerAndNum(16) }, }], }, }); } else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) { setV('streamSettings', { security: 'none' }); } else { const current = getV('streamSettings') as { network?: string } | undefined; if (current?.network === 'hysteria' || !current?.network) { setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} }); } } }); return () => sub.unsubscribe(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [mode, methods]); const saveValues = async () => { /* * getValues() returns the entire form store, including settings.clients and * settings.fallbacks which have no bound field (clients are managed via the * standalone Client modal, not this inbound modal). With shouldUnregister * false those pass-through sub-trees survive from the reset object, so the * update wire payload never silently drops every client on save. */ const values = methods.getValues() 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); } }; /* * Field errors render inline, but every tab is force-rendered, so an error on * a hidden tab looks like a dead Save button — jump to it and say what broke. */ const submit = methods.handleSubmit(saveValues, (errors) => { const issue = firstRhfValidationIssue(errors); if (!issue) return; setActiveTab(tabForValidationPath(issue.path)); messageApi.error(formatInboundIssue(issue, methods.getValues(), t)); }); 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 && ( )} {protocol === Protocols.VLESS && ( )} {t('pages.inbounds.totalFlow')} } > { const bytes = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0); setV('total', bytes); }} /> )} {/* Inbound Hysteria stream sub-form. The transport for hysteria isn't user-selectable (always 'hysteria'), so the network dropdown is hidden above. */} {protocol === Protocols.HYSTERIA && } {hasSelectableTransport && ( <> {network === 'tcp' && } {network === 'ws' && } {network === 'grpc' && } {network === 'xhttp' && } {network === 'httpupgrade' && } {network === 'kcp' && } )} {/* The legacy externalProxy section is replaced by the Hosts page; the field is still parsed/rendered for backward compatibility but is no longer editable here. */} {/* Transport masks don't apply to tunnel (a transparent forwarder), so its stream tab is just sockopt + TProxy. */} {protocol !== Protocols.TUNNEL && ( ( )} /> )} ); const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } }); const realityOk = canEnableReality({ protocol, streamSettings: { network, security } }); const tlsOnly = protocol === Protocols.HYSTERIA; const securityTab = ( <> 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: { ... } }'}.
), }] : []), ...(sniffingSupported ? [{ key: 'sniffing', label: t('pages.inbounds.advanced.sniffing'), children: ( <>
{t('pages.inbounds.advanced.sniffingHelp')}{' '} {'{ sniffing: { ... } }'}.
), }] : []), ]} />
); const sniffingTab = ; return ( <> {messageContextHolder}
); }