diff --git a/frontend/src/pages/inbounds/form/FallbacksCard.tsx b/frontend/src/pages/inbounds/form/FallbacksCard.tsx new file mode 100644 index 00000000..41f0e823 --- /dev/null +++ b/frontend/src/pages/inbounds/form/FallbacksCard.tsx @@ -0,0 +1,123 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Card, Empty, Input, InputNumber, Select, Space } from 'antd'; +import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; + +import { InputAddon } from '@/components/ui'; +import type { FallbackRow } from '@/schemas/forms/inbound-form'; + +interface FallbacksCardProps { + fallbacks: FallbackRow[]; + fallbackChildOptions: { label: string; value: number }[]; + addFallback: () => void; + updateFallback: (rowKey: string, patch: Partial) => void; + removeFallback: (idx: number) => void; + moveFallback: (idx: number, direction: -1 | 1) => void; + addAllFallbacks: () => void; +} + +export default function FallbacksCard({ + fallbacks, + fallbackChildOptions, + addFallback, + updateFallback, + removeFallback, + moveFallback, + addAllFallbacks, +}: FallbacksCardProps) { + const { t } = useTranslation(); + return ( + + {fallbacks.length === 0 && ( + + )} + {fallbacks.map((record, idx) => ( +
+ + updateFallback(record.rowKey, { name: e.target.value })} + /> + ALPN + updateFallback(record.rowKey, { alpn: e.target.value })} + /> + Path + updateFallback(record.rowKey, { path: e.target.value })} + /> + Dest + updateFallback(record.rowKey, { dest: e.target.value })} + /> + xver + updateFallback(record.rowKey, { xver: Number(v) || 0 })} + /> + +
+ ))} + + + + +
+ ); +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 018c1970..5161d5f2 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -1,29 +1,18 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { - Button, - Card, - Checkbox, - Empty, Form, Input, InputNumber, Modal, Radio, Select, - Space, Switch, Tabs, Tooltip, message, } from 'antd'; -import { - ArrowDownOutlined, - ArrowUpOutlined, - DeleteOutlined, - PlusOutlined, -} from '@ant-design/icons'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { @@ -37,22 +26,16 @@ import { canEnableTls, isSS2022, } from '@/lib/xray/protocol-capabilities'; -import { getRandomRealityTarget } from '@/models/reality-targets'; import { InboundFormBaseSchema, InboundFormSchema, - type FallbackRow, type InboundFormValues, } from '@/schemas/forms/inbound-form'; import { antdRule } from '@/utils/zodForm'; -import { - Protocols, - SNIFFING_OPTION, -} from '@/schemas/primitives'; +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 { 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'; @@ -62,7 +45,6 @@ import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/http import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp'; import { DateTimePicker } from '@/components/form'; import { FinalMaskForm } from '@/lib/xray/forms/transport'; -import { InputAddon } from '@/components/ui'; import './InboundFormModal.css'; import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; @@ -87,15 +69,14 @@ import { 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 { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; +import type { DBInbound } from '@/models/dbinbound'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; -// Pattern A rewrite of InboundFormModal. Built as a sibling file so the -// build stays green while the rewrite progresses section by section. -// InboundsPage continues to render the old InboundFormModal.tsx until the -// atomic swap at the end (Core Decision 7). - const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; @@ -150,8 +131,17 @@ export default function InboundFormModal({ const [messageApi, messageContextHolder] = message.useMessage(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); - const fallbackKeyRef = useRef(0); - const [fallbacks, setFallbacks] = useState([]); + 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; @@ -172,333 +162,20 @@ export default function InboundFormModal({ && 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; - dest?: string; - xver?: number; - }[]) - .map((r) => ({ - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: r.childId, - name: r.name || '', - alpn: r.alpn || '', - path: r.path || '', - dest: r.dest || '', - 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, - dest: c.dest, - 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: '', - dest: '', - 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/dest 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.dest && 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 ?? '', - dest: '', - 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 { + genRealityKeypair, + clearRealityKeypair, + genMldsa65, + clearMldsa65, + randomizeRealityTarget, + randomizeShortIds, + getNewEchCert, + clearEchCert, + generateRandomPinHash, + setCertFromPanel, + clearCertFiles, + onSecurityChange, + } = useSecurityActions({ form, setSaving, messageApi }); const toggleExternalProxy = (on: boolean) => { if (on) { @@ -602,9 +279,10 @@ export default function InboundFormModal({ ) { loadFallbacks(dbInbound.id); } else { - setFallbacks([]); + loadFallbacks(null); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, mode, dbInbound, form]); // Why: protocol picker reset cascades through the form — clearing the @@ -845,99 +523,15 @@ export default function InboundFormModal({ ); const fallbacksCard = ( - - {fallbacks.length === 0 && ( - - )} - {fallbacks.map((record, idx) => ( -
- - updateFallback(record.rowKey, { name: e.target.value })} - /> - ALPN - updateFallback(record.rowKey, { alpn: e.target.value })} - /> - Path - updateFallback(record.rowKey, { path: e.target.value })} - /> - Dest - updateFallback(record.rowKey, { dest: e.target.value })} - /> - xver - updateFallback(record.rowKey, { xver: Number(v) || 0 })} - /> - -
- ))} - - - - -
+ ); const protocolTab = ( @@ -1215,65 +809,7 @@ export default function InboundFormModal({ ); - const sniffingTab = ( - <> - - - - - {sniffingEnabled && ( - <> - - - {Object.entries(SNIFFING_OPTION).map(([key, value]) => ( - {key} - ))} - - - - - - - - - - - - - - - - )} - - ); + const sniffingTab = ; return ( <> diff --git a/frontend/src/pages/inbounds/form/SniffingTab.tsx b/frontend/src/pages/inbounds/form/SniffingTab.tsx new file mode 100644 index 00000000..dea59452 --- /dev/null +++ b/frontend/src/pages/inbounds/form/SniffingTab.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; +import { Checkbox, Form, Select, Switch } from 'antd'; + +import { SNIFFING_OPTION } from '@/schemas/primitives'; + +export default function SniffingTab({ sniffingEnabled }: { sniffingEnabled: boolean }) { + const { t } = useTranslation(); + return ( + <> + + + + + {sniffingEnabled && ( + <> + + + {Object.entries(SNIFFING_OPTION).map(([key, value]) => ( + {key} + ))} + + + + + + + + + + + + + + + + )} + + ); +} diff --git a/frontend/src/pages/inbounds/form/useInboundFallbacks.ts b/frontend/src/pages/inbounds/form/useInboundFallbacks.ts new file mode 100644 index 00000000..578af2ca --- /dev/null +++ b/frontend/src/pages/inbounds/form/useInboundFallbacks.ts @@ -0,0 +1,187 @@ +import { useRef, useState } from 'react'; + +import { HttpUtil } from '@/utils'; +import type { FallbackRow } from '@/schemas/forms/inbound-form'; +import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; + +// Fallback rows for VLESS/Trojan TLS inbounds: state + the load/save/derive +// and add/update/remove/move handlers, plus the eligible-child option list. +// Lifted out of InboundFormModal so the modal body stays focused on layout. +export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) { + const fallbackKeyRef = useRef(0); + const [fallbacks, setFallbacks] = useState([]); + + 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; + dest?: string; + xver?: number; + }[]) + .map((r) => ({ + rowKey: `fb-${++fallbackKeyRef.current}`, + childId: r.childId, + name: r.name || '', + alpn: r.alpn || '', + path: r.path || '', + dest: r.dest || '', + 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, + dest: c.dest, + 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: '', + dest: '', + 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/dest 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.dest && 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 ?? '', + dest: '', + xver: derived.xver ?? 0, + }; + }); + if (additions.length === 0) return prev; + return [...prev, ...additions]; + }); + }; + + return { + fallbacks, + fallbackChildOptions, + loadFallbacks, + saveFallbacks, + addFallback, + updateFallback, + removeFallback, + moveFallback, + addAllFallbacks, + }; +} diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts new file mode 100644 index 00000000..f9012494 --- /dev/null +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -0,0 +1,205 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { FormInstance } from 'antd'; +import type { MessageInstance } from 'antd/es/message/interface'; + +import { HttpUtil, RandomUtil } from '@/utils'; +import { getRandomRealityTarget } from '@/models/reality-targets'; +import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; +import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; +import type { InboundFormValues } from '@/schemas/forms/inbound-form'; + +interface UseSecurityActionsArgs { + form: FormInstance; + setSaving: Dispatch>; + messageApi: MessageInstance; +} + +// Server-side TLS / Reality key + certificate generation handlers for the +// inbound modal's security tab. Each talks to a /panel server endpoint and +// writes the result back into the form. Lifted out of InboundFormModal so +// the modal body stays focused on orchestration. +export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityActionsArgs) { + const { t } = useTranslation(); + + 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 + } + } + }; + + return { + genRealityKeypair, + clearRealityKeypair, + genMldsa65, + clearMldsa65, + randomizeRealityTarget, + randomizeShortIds, + getNewEchCert, + clearEchCert, + generateRandomPinHash, + setCertFromPanel, + clearCertFiles, + onSecurityChange, + }; +}