diff --git a/frontend/src/lib/xray/inbound-clone.ts b/frontend/src/lib/xray/inbound-clone.ts new file mode 100644 index 000000000..512b4bc40 --- /dev/null +++ b/frontend/src/lib/xray/inbound-clone.ts @@ -0,0 +1,66 @@ +import { RandomUtil } from '@/utils'; +import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; +import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; + +/* + * Payload for POST /panel/api/inbounds/add reproducing `dbInbound` as a + * staged copy: fresh port, empty client list (emails are unique panel-wide + * and UUIDs must not repeat across nodes), disabled, no tag (the backend + * regenerates one with the correct per-node prefix), cleared listen (listen + * addresses are node-local). `nodeId === null` targets the local panel; the + * field is omitted from the wire payload then, matching the add-form adapter. + */ +export function buildClonePayload(dbInbound: DBInbound, port: number, nodeId: number | null) { + let clonedSettings: string; + try { + const raw = { ...coerceInboundJsonField(dbInbound.settings) }; + raw.clients = []; + clonedSettings = JSON.stringify(raw); + } catch { + const fallback = createDefaultInboundSettings(dbInbound.protocol); + clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}'; + } + const streamSettingsString = typeof dbInbound.streamSettings === 'string' + ? dbInbound.streamSettings + : JSON.stringify(dbInbound.streamSettings ?? {}); + const sniffingString = typeof dbInbound.sniffing === 'string' + ? dbInbound.sniffing + : JSON.stringify(dbInbound.sniffing ?? {}); + return { + up: 0, + down: 0, + total: 0, + remark: `${dbInbound.remark} (clone)`, + enable: false, + expiryTime: 0, + listen: '', + port, + protocol: dbInbound.protocol, + settings: clonedSettings, + streamSettings: streamSettingsString, + sniffing: sniffingString, + shareAddrStrategy: dbInbound.shareAddrStrategy, + shareAddr: dbInbound.shareAddr, + ...(nodeId != null ? { nodeId } : {}), + }; +} + +/* + * Random clone port in the add-form's range, avoiding ports already bound on + * the target node (client-side pre-check; the backend's node-scoped conflict + * check stays the final arbiter). A few random tries cover the common sparse + * case; a target so dense that those all miss falls back to a deterministic + * scan so a free port is always found when one exists. + */ +export function pickClonePort(used: Set | undefined): number { + let port = RandomUtil.randomInteger(10000, 60000); + if (!used) return port; + for (let attempts = 0; attempts < 20 && used.has(port); attempts++) { + port = RandomUtil.randomInteger(10000, 60000); + } + if (used.has(port)) { + for (port = 10000; port <= 60000 && used.has(port); port++) { /* dense-range scan */ } + if (port > 60000) port = RandomUtil.randomInteger(10000, 60000); + } + return port; +} diff --git a/frontend/src/lib/xray/node-protocols.ts b/frontend/src/lib/xray/node-protocols.ts new file mode 100644 index 000000000..a7adbc8d6 --- /dev/null +++ b/frontend/src/lib/xray/node-protocols.ts @@ -0,0 +1,16 @@ +import { Protocols } from '@/schemas/primitives'; + +/* + * Protocols whose inbounds can live on a sub-node (the "Deploy To" set). + * Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only. + * Shared by the inbound form's Deploy To selector and the clone dialog's + * target picker so the two surfaces can never drift apart. + */ +export const NODE_ELIGIBLE_PROTOCOLS: Readonly> = { + [Protocols.VLESS]: true, + [Protocols.VMESS]: true, + [Protocols.TROJAN]: true, + [Protocols.SHADOWSOCKS]: true, + [Protocols.HYSTERIA]: true, + [Protocols.WIREGUARD]: true, +}; diff --git a/frontend/src/pages/inbounds/CloneInboundModal.tsx b/frontend/src/pages/inbounds/CloneInboundModal.tsx new file mode 100644 index 000000000..e83aa96ed --- /dev/null +++ b/frontend/src/pages/inbounds/CloneInboundModal.tsx @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Select, Typography, message } from 'antd'; + +import { HttpUtil } from '@/utils'; +import { SelectAllClearButtons } from '@/components/form'; +import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import type { DBInbound } from '@/models/dbinbound'; + +// 0 is the "local panel" sentinel (inbounds without a nodeId) — the same +// convention as the clients page node filter (#4997). +const LOCAL_PANEL = 0; + +interface CloneInboundModalProps { + open: boolean; + dbInbound: DBInbound | null; + nodes: NodeRecord[]; + portsInUse: Map>; + onClose: () => void; + onCloned: () => void | Promise; +} + +export default function CloneInboundModal({ + open, + dbInbound, + nodes, + portsInUse, + onClose, + onCloned, +}: CloneInboundModalProps) { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + const [targets, setTargets] = useState([LOCAL_PANEL]); + const [submitting, setSubmitting] = useState(false); + + const targetOptions = useMemo(() => [ + { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false }, + ...(nodes || []).filter((n) => n.enable).map((n) => ({ + value: n.id, + // Only online nodes are deployable targets: nodes report `unknown` + // until their first heartbeat, and the backend refuses any status + // other than online. + label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`, + disabled: n.status !== 'online', + })), + ], [nodes, t]); + + // "Select all" must not pick targets the user can't pick manually — + // offline nodes are disabled options in the dropdown. + const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]); + + // Reset the selection when the dialog OPENS: pre-select the source + // inbound's own node when it is a selectable target, otherwise the local + // panel (the only destination the clone action had before this picker). + // Deps are deliberately `[open]` only — `nodes` gets a new identity on every + // background refetch (heartbeats bump latency/status), and keying the reset + // on it would clobber the user's selection mid-dialog. + useEffect(() => { + if (!open || !dbInbound) return; + const src = dbInbound.nodeId ?? LOCAL_PANEL; + const srcNode = (nodes || []).find((n) => n.id === src); + const selectable = !!srcNode && !!srcNode.enable && srcNode.status === 'online'; + setTargets([selectable ? src : LOCAL_PANEL]); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [open]); + + async function submit() { + if (!dbInbound || targets.length === 0) return; + setSubmitting(true); + try { + // Sequential posts keep per-target results in selection order; every + // target gets its own fresh port because ports are only node-scoped. + const results: { ok: boolean; reason: string }[] = []; + for (const target of targets) { + const msg = await HttpUtil.post( + '/panel/api/inbounds/add', + buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target), + { silent: true }, + ); + results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') }); + } + const okCount = results.filter((r) => r.ok).length; + const failed = results.length - okCount; + if (failed === 0) { + messageApi.success(okCount === 1 + ? t('pages.inbounds.toasts.inboundCreateSuccess') + : t('pages.inbounds.toasts.clonedMany', { count: okCount })); + } else { + const firstError = results.find((r) => !r.ok)?.reason ?? ''; + const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed }); + messageApi.warning(firstError ? `${base} — ${firstError}` : base); + } + if (okCount > 0) await onCloned(); + onClose(); + } finally { + setSubmitting(false); + } + } + + return ( + <> + {messageContextHolder} + + + {t('pages.inbounds.cloneConfirmContent')} + + +