import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { Button, Card, Checkbox, Divider, Empty, Form, Input, InputNumber, Modal, Radio, Select, Space, Switch, Tabs, Tooltip, Typography, message, } from 'antd'; import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, } from '@ant-design/icons'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { rawInboundToFormValues, formValuesToWirePayload, pruneEmpty, normalizeSniffing, normalizeClients, dropLegacyOptionalEmpties, } 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, Address_Port_Strategy, DOMAIN_STRATEGY_OPTION, Protocols, SNIFFING_OPTION, TCP_CONGESTION_OPTION, TLS_CIPHER_OPTION, TLS_VERSION_OPTION, USAGE_OPTION, UTLS_FINGERPRINT, } from '@/schemas/primitives'; import { HappyEyeballsSchema, SockoptStreamSettingsSchema, } from '@/schemas/protocols/stream/sockopt'; import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; 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/DateTimePicker'; import FinalMaskForm from '@/components/FinalMaskForm'; import HeaderMapEditor from '@/components/HeaderMapEditor'; import InputAddon from '@/components/InputAddon'; import JsonEditor from '@/components/JsonEditor'; import './InboundFormModal.css'; import type { FormInstance } from 'antd'; import type { NamePath } from 'antd/es/form/interface'; const { TextArea } = Input; import { coerceInboundJsonField, 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, wrapKey, minHeight, maxHeight, }: { form: FormInstance; path: NamePath; // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so // the JSON the user sees matches the wire shape's slice envelope (e.g. // `{ "settings": { ... } }`). Edits unwrap the outer key before writing // back to the form. Mirrors the legacy modal's wrappedConfigValue. wrapKey?: string; minHeight?: string; maxHeight?: string; }) { const serialize = (value: unknown): string => { const inner = value ?? {}; return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); }; // preserve: true so useWatch returns the full subtree from the form // store — without it, useWatch goes through getFieldsValue() which // filters out unregistered fields. Slices like `settings` would lose // their `clients` / `fallbacks` sub-trees because those aren't bound // to any Form.Item. const watched = Form.useWatch(path, { form, preserve: true }); const lastEmitRef = useRef(''); const [text, setText] = useState(() => { const initial = serialize(form.getFieldValue(path)); lastEmitRef.current = initial; return initial; }); useEffect(() => { const formStr = serialize(watched); if (formStr === lastEmitRef.current) return; setText(formStr); lastEmitRef.current = formStr; // eslint-disable-next-line react-hooks/exhaustive-deps }, [watched, wrapKey]); return ( { setText(next); try { const parsed = JSON.parse(next); const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record)[wrapKey] ?? {} : parsed; form.setFieldValue(path, toWrite); lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2); } catch { // invalid JSON; keep buffer, don't push to form } }} /> ); } // The "All" editor shows the full inbound JSON in one editor: top-level // connection fields plus the three nested sub-objects (settings, // streamSettings, sniffing). Edits round-trip back to the form's slices, // mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity // works the same way as AdvancedSliceEditor: useWatch on the slices we // care about, lastEmitRef as the "we wrote this" guard. function AdvancedAllEditor({ form, streamEnabled, }: { form: FormInstance; streamEnabled: boolean; }) { // preserve: true — default useWatch returns only registered fields, so // sub-trees we never bound (settings.clients/fallbacks, sniffing // defaults, etc.) wouldn't show up. preserve switches the read to // getFieldsValue(true) which returns the full form store. const wListen = Form.useWatch('listen', { form, preserve: true }); const wPort = Form.useWatch('port', { form, preserve: true }); const wProtocol = Form.useWatch('protocol', { form, preserve: true }); const wTag = Form.useWatch('tag', { form, preserve: true }); const wSettings = Form.useWatch('settings', { form, preserve: true }); const wSniffing = Form.useWatch('sniffing', { form, preserve: true }); const wStream = Form.useWatch('streamSettings', { form, preserve: true }); const serialize = () => { // Apply the same prune/normalize as the wire payload so the JSON // shown here is what the panel actually POSTs (no empty defaults, // disabled sniffing as { enabled: false }, finalmask dropped when // there are no masks). const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { settingsView.clients = normalizeClients(wProtocol, settingsView.clients); } const streamView = streamEnabled ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) : undefined; dropLegacyOptionalEmpties(settingsView, streamView); const out: Record = { listen: wListen ?? '', port: wPort ?? 0, protocol: wProtocol ?? '', tag: wTag ?? '', settings: settingsView, sniffing: normalizeSniffing(wSniffing as Parameters[0]), }; if (streamView) out.streamSettings = streamView; return JSON.stringify(out, null, 2); }; const lastEmitRef = useRef(''); const [text, setText] = useState(() => { const initial = serialize(); lastEmitRef.current = initial; return initial; }); useEffect(() => { const formStr = serialize(); if (formStr === lastEmitRef.current) return; setText(formStr); lastEmitRef.current = formStr; // eslint-disable-next-line react-hooks/exhaustive-deps }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]); return ( { setText(next); let parsed: Record; try { parsed = JSON.parse(next) as Record; } catch { return; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen); if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) { form.setFieldValue('port', parsed.port); } if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol); if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag); if (parsed.settings && typeof parsed.settings === 'object') { form.setFieldValue('settings', parsed.settings); } if (parsed.sniffing && typeof parsed.sniffing === 'object') { form.setFieldValue('sniffing', parsed.sniffing); } if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { form.setFieldValue('streamSettings', parsed.streamSettings); } lastEmitRef.current = next; }} /> ); } 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 fallbackKeyRef = useRef(0); const [fallbacks, setFallbacks] = useState([]); 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 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; }; // Derive a fallback row's SNI / ALPN / Path / xver from a child // inbound's streamSettings — what the legacy panel auto-filled when an // operator wired a fallback target. SNI/ALPN come straight off the // child's TLS block; path depends on the child's transport (ws/grpc // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of // their own). xver stays 0 unless the child explicitly opts in via // PROXY-protocol sockopt. const deriveFallbackDefaults = (childId: number): Partial => { const child = (dbInbounds || []).find((ib) => ib.id === childId); if (!child) return {}; const stream = coerceInboundJsonField(child.streamSettings); const tls = (stream.tlsSettings as Record | undefined) ?? {}; const network = typeof stream.network === 'string' ? stream.network : ''; const sni = typeof tls.serverName === 'string' ? tls.serverName : ''; const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : []; const alpn = alpnArr.filter((v) => typeof v === 'string').join(','); let path = ''; if (network === 'ws') { const ws = (stream.wsSettings as Record | undefined) ?? {}; if (typeof ws.path === 'string') path = ws.path; } else if (network === 'grpc') { const grpc = (stream.grpcSettings as Record | undefined) ?? {}; if (typeof grpc.serviceName === 'string') path = grpc.serviceName; } else if (network === 'httpupgrade') { const hu = (stream.httpupgradeSettings as Record | undefined) ?? {}; if (typeof hu.path === 'string') path = hu.path; } else if (network === 'xhttp') { const xh = (stream.xhttpSettings as Record | undefined) ?? {}; if (typeof xh.path === 'string') path = xh.path; } return { name: sni, alpn, path, xver: 0 }; }; const addFallback = () => { setFallbacks((prev) => [...prev, { rowKey: `fb-${++fallbackKeyRef.current}`, childId: null, name: '', alpn: '', path: '', xver: 0, }]); }; const updateFallback = (rowKey: string, patch: Partial) => { setFallbacks((prev) => prev.map((r) => { if (r.rowKey !== rowKey) return r; // When the picker selects a new child inbound and the row hasn't // been hand-edited yet (sni/alpn/path all blank, xver = 0), pull // the SNI/ALPN/Path defaults off that child. Operators who // intentionally typed values keep them — we only fill the empties. if (typeof patch.childId === 'number' && patch.childId !== r.childId) { const isPristine = !r.name && !r.alpn && !r.path && r.xver === 0; if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) }; } return { ...r, ...patch }; })); }; const removeFallback = (idx: number) => { setFallbacks((prev) => prev.filter((_, i) => i !== idx)); }; // Move a fallback row up/down by swapping adjacent indices. The order // is persisted via the fallback row's sortOrder (rebuilt by index on // save), so reordering survives reloads. const moveFallback = (idx: number, direction: -1 | 1) => { setFallbacks((prev) => { const target = idx + direction; if (target < 0 || target >= prev.length) return prev; const next = prev.slice(); [next[idx], next[target]] = [next[target], next[idx]]; return next; }); }; // One-shot: add a fresh fallback row for every eligible inbound (i.e. // every option in fallbackChildOptions) that is not already wired up. // Convenient for operators who want catch-all routing to every host // they manage on the panel. const addAllFallbacks = () => { setFallbacks((prev) => { const alreadyHave = new Set(prev.map((r) => r.childId)); const additions = fallbackChildOptions .filter((opt) => !alreadyHave.has(opt.value)) .map((opt) => { const derived = deriveFallbackDefaults(opt.value); return { rowKey: `fb-${++fallbackKeyRef.current}`, childId: opt.value, name: derived.name ?? '', alpn: derived.alpn ?? '', path: derived.path ?? '', xver: derived.xver ?? 0, }; }); if (additions.length === 0) return prev; return [...prev, ...additions]; }); }; 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 generateRandomPinHash = () => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); let binary = ''; for (const b of bytes) binary += String.fromCharCode(b); const hash = btoa(binary); const current = (form.getFieldValue( ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], ) as string[] | undefined) ?? []; form.setFieldValue( ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], [...current, hash], ); }; const setCertFromPanel = async (certName: number) => { setSaving(true); try { const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); if (msg?.success) { const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; if (!obj.webCertFile && !obj.webKeyFile) { messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); return; } form.setFieldValue( ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], obj.webCertFile ?? '', ); form.setFieldValue( ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], obj.webKeyFile ?? '', ); } } finally { setSaving(false); } }; const clearCertFiles = (certName: number) => { form.setFieldValue( ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], '', ); form.setFieldValue( ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], '', ); }; const onSecurityChange = async (next: string) => { const current = (form.getFieldValue('streamSettings') as Record) ?? {}; const cleaned: Record = { ...current, security: next }; delete cleaned.tlsSettings; delete cleaned.realitySettings; if (next === 'tls') { const tls = TlsStreamSettingsSchema.parse({}) as Record; tls.certificates = [{ useFile: true, certificateFile: '', keyFile: '', certificate: [], key: [], oneTimeLoading: false, usage: 'encipherment', buildChain: false, }]; cleaned.tlsSettings = tls; } if (next === 'reality') { const reality = RealityStreamSettingsSchema.parse({}) as Record; const tgt = getRandomRealityTarget() as { target: string; sni: string }; reality.target = tgt.target; reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean); reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean); cleaned.realitySettings = reality; } form.setFieldValue('streamSettings', cleaned); if (next === 'reality') { 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); } } catch { // best-effort: leave keypair fields empty if server call fails } } }; 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 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) => { 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 issue = parsed.error.issues[0]; const path = Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join('.') : ''; const baseMsg = issue?.message ?? 'somethingWentWrong'; const display = path ? `${path}: ${baseMsg}` : baseMsg; messageApi.error(t(baseMsg, { defaultValue: display })); console.error('[InboundFormModal] schema validation failed', { path: issue?.path, message: issue?.message, values, }); 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); }} /> ); }} ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()), }} style={{ width: '100%' }} onChange={(v) => updateFallback(record.rowKey, { childId: v })} /> SNI updateFallback(record.rowKey, { name: e.target.value })} /> ALPN updateFallback(record.rowKey, { alpn: e.target.value })} /> Path updateFallback(record.rowKey, { path: e.target.value })} /> xver updateFallback(record.rowKey, { xver: Number(v) || 0 })} /> ))} ); const protocolTab = ( <> {protocol === Protocols.WIREGUARD && ( <> {fields.map((field, idx) => (
{t('pages.inbounds.info.peerNumber', { n: idx + 1 })} {fields.length > 1 && ( {ipFields.map((ipField) => ( {ipFields.length > 1 && ( )} ))} )}
))} )} )} {protocol === Protocols.TUN && ( <> {(fields, { add, remove }) => ( {fields.map((field, j) => ( ))} )} {(fields, { add, remove }) => ( {fields.map((field, j) => ( ))} )} {(fields, { add, remove }) => ( {t('pages.inbounds.info.autoSystemRoutes')} } > {fields.map((field, j) => ( ))} )} {t('pages.inbounds.form.autoOutboundsInterface')} } > )} {protocol === Protocols.TUNNEL && ( <> ))}
)} )} {protocol === Protocols.HTTP && ( )} {protocol === Protocols.MIXED && ( <> )} )} )} {protocol === Protocols.SHADOWSOCKS && ( <> {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })} {network === 'tcp' && (security === 'tls' || security === 'reality') && ( Array.isArray(v) ? v.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0) : [] } extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise." > )} {/* 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 && ( <> {() => { const m = form.getFieldValue([ 'streamSettings', 'hysteriaSettings', 'masquerade', ]); return ( form.setFieldValue( ['streamSettings', 'hysteriaSettings', 'masquerade'], checked ? { type: '', dir: '', url: '', rewriteHost: false, insecure: false, content: '', headers: {}, statusCode: 0, } : undefined, ) } /> ); }} {() => { const m = form.getFieldValue([ 'streamSettings', 'hysteriaSettings', 'masquerade', ]) as { type?: string } | undefined; if (!m) return null; return ( <> )} {m.type === 'file' && ( )} {m.type === 'string' && ( <> )} ); }} )} {network === 'tcp' && ( <> prev.streamSettings?.tcpSettings?.header?.type !== curr.streamSettings?.tcpSettings?.header?.type } > {({ getFieldValue, setFieldValue }) => { const headerType = getFieldValue( ['streamSettings', 'tcpSettings', 'header', 'type'], ) as string | undefined; return ( { setFieldValue( ['streamSettings', 'tcpSettings', 'header'], v ? { type: 'http', request: { version: '1.1', method: 'GET', path: ['/'], headers: {}, }, response: { version: '1.1', status: '200', reason: 'OK', headers: {}, }, } : { type: 'none' }, ); }} /> ); }} {/* Per Xray docs (transports/raw.html#httpheaderobject), the `request` object is honored only by outbound proxies; the inbound listener reads `response`. Showing Host / Path / Method / Version / request-headers on the inbound side was a regression from this modal's earlier iteration — those inputs wrote to the wire but xray-core ignored them. The inbound modal now only exposes the response side. */} 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 ( <> ({ value: Array.isArray(v) ? v.join(',') : v })} getValueFromEvent={(e) => { const raw = (e?.target?.value ?? '') as string; const parts = raw.split(',').map((s) => s.trim()).filter(Boolean); return parts.length > 0 ? parts : ['/']; }} > ); }} )} {network === 'ws' && ( <> )} {network === 'grpc' && ( <> )} {network === 'xhttp' && ( <> )} {xhttpMode === 'stream-up' && ( )} )} )} )} {xhttpMode === 'packet-up' && ( <> )} )} )} {network === 'httpupgrade' && ( <> )} {network === 'kcp' && ( <> )} { const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0); }} > {({ getFieldValue }) => { const arr = getFieldValue(['streamSettings', 'externalProxy']); const on = Array.isArray(arr) && arr.length > 0; return ( <> {on && ( {(fields, { add, remove }) => ( <> {fields.map((field) => (
remove(field.name)}> 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 ( ({ value: a, label: a, }))} /> ); }}
))}
)}
)} ); }}
{ const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt; const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt; return !!a !== !!b; }} > {({ getFieldValue }) => { const sock = getFieldValue(['streamSettings', 'sockopt']); const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; return ( <> {on && ( <> ({ value: c, label: c }))} /> ({ value: v, label: v }))} /> {({ getFieldValue, setFieldValue }) => { const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); const hasHe = he != null; return ( <> { setFieldValue( ['streamSettings', 'sockopt', 'happyEyeballs'], v ? HappyEyeballsSchema.parse({}) : undefined, ); }} /> {hasHe && ( <> )} ); }} {(fields, { add, remove }) => ( <> {fields.map((field) => ( ))} )} )} ); }} ); 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} ); }} prev.streamSettings?.security !== curr.streamSettings?.security } > {({ getFieldValue }) => { const sec = getFieldValue(['streamSettings', 'security']); if (sec !== 'tls') return null; return ( <> ({ value: v, label: v }))} /> ({ value: fp, label: fp })), ]} /> ) : ( <> typeof v === 'string' ? v.split('\n') : v} getValueProps={(v) => ({ value: Array.isArray(v) ? v.join('\n') : v, })} >