Files
MikrotikManager/app/(main)/ipsec/page.tsx
T

1003 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { can } from "@/lib/auth"
import { z } from "zod"
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header"
import { servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { IpsecUsersGrid } from "@/components/ipsec/ipsec-users-grid"
import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid"
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet"
import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet"
import { Button } from "@/components/ui/button"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { Checkbox } from "@/components/ui/checkbox"
import {
createIpsecUser,
deleteIpsecCert,
deleteIpsecPeer,
deleteIpsecServer,
deleteIpsecUser,
exportIpsecCertByName,
exportIpsecUserCert,
initIpsecServer,
listIpsec,
patchIpsecPeer,
patchIpsecUser,
restoreIpsecRevision,
} from "@/shared/api/ipsec"
import type {
IpsecCertBundle,
IpsecCertInfoDto,
IpsecClientDto,
IpsecPeerDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
ALL_SERVERS_ID,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
import { toast } from "sonner"
import { findFreePoolIp } from "@/lib/ipsec-client"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react"
type IpsecWorkspaceTab = "clients" | "server" | "cli"
type IpsecFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "user"; client: IpsecClientDto }
| { kind: "server"; summary: IpsecServerSummaryDto }
| { kind: "peer"; serverId: string; peer: IpsecPeerDto }
| { kind: "cert"; serverId: string; cert: IpsecCertInfoDto }
const MOCK_IPSEC_PEER: IpsecPeerDto = {
id: "srv2:pp1",
rosId: "*PP1",
serverId: "srv2",
serverName: "mt-spb-edge-01",
name: "ipsec-vpn",
address: "0.0.0.0/0",
exchangeMode: "ike2",
passive: true,
certificate: "ipsec-server",
profile: "ipsec-vpn",
disabled: false,
managed: true,
}
const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
{
serverId: "srv2",
serverName: "mt-spb-edge-01",
initialized: true,
ike2Ready: true,
serverEndpoint: "vpn.example.com",
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
peer: MOCK_IPSEC_PEER,
peers: [MOCK_IPSEC_PEER],
caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true },
serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true },
natRuleManaged: true,
clientsTotal: 3,
clientsOnline: 1,
certs: [],
},
]
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
{
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice",
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
},
{
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
},
{
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
},
]
interface BackendServer {
id: number
name: string
host: string
country: string
type?: Server["type"]
enabled: boolean
status?: Server["status"] | null
latency?: number | null
asn?: string
}
type InventoryServer = Server & { monitoringUnknown?: boolean }
function mapBackendServer(s: BackendServer): InventoryServer {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "offline",
monitoringUnknown: s.status == null,
latency: s.latency ?? null,
sessions: 0,
}
}
const ipsecInventorySchema = z.array(z.object({
id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(),
country: z.string().optional().default("UN"),
type: z.enum(["jump-host", "exit-node", "home-router"]).optional(),
status: z.enum(["online", "offline", "degraded"]).nullable().optional(),
latency: z.number().nullable().optional(), asn: z.string().optional(),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWorkspaceTab }: { selectedServerId: string; setSelectedServerId: (id: string) => void; workspaceTab: IpsecWorkspaceTab; setWorkspaceTab: (tab: IpsecWorkspaceTab) => void }) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
const [clients, setClients] = useState<IpsecClientDto[]>([])
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [failures, setFailures] = useState<IpsecFailure[]>([])
const [loading, setLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [inventoryError, setInventoryError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
const mounted = useRef(true)
const operationLock = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const readEpoch = useRef(0)
useEffect(() => () => { readEpoch.current += 1 }, [])
const [busy, setBusy] = useState(false)
const [certBusy, setCertBusy] = useState(false)
const [certError, setCertError] = useState<string | null>(null)
const certEpoch = useRef(0)
const certPending = useRef(false)
useEffect(() => () => { certEpoch.current += 1 }, [])
const [search, setSearch] = useState("")
const [initOpen, setInitOpen] = useState(false)
const [initTargetId, setInitTargetId] = useState<string | undefined>()
const [userOpen, setUserOpen] = useState(false)
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
const [certOpen, setCertOpen] = useState(false)
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null)
const [peerOpen, setPeerOpen] = useState(false)
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [removeClientCertificate, setRemoveClientCertificate] = useState(true)
const deleteLock = useRef(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [forceAllowed, setForceAllowed] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const historyEpoch = useRef(0)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async (afterOperation = false) => {
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
const epoch = ++readEpoch.current
setLoading(true)
const [ipsecResult, inventoryResult] = await Promise.allSettled([
listIpsec(backendUrl), requestJson<unknown>(backendUrl, "/api/servers"),
])
if (epoch !== readEpoch.current) return
if (inventoryResult.status === "fulfilled") {
const parsed = ipsecInventorySchema.safeParse(inventoryResult.value)
if (parsed.success) {
setLiveServers(parsed.data.filter(s => s.enabled).map(mapBackendServer))
setInventoryError(null)
} else setInventoryError("Некорректный список устройств")
} else setInventoryError(inventoryResult.reason instanceof Error ? inventoryResult.reason.message : "Не удалось загрузить устройства")
if (ipsecResult.status === "fulfilled") {
const ipsec = ipsecResult.value
const failedIds = new Set((ipsec.failures ?? []).map(f => f.serverId))
setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))])
setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))])
setFailures(ipsec.failures ?? [])
const at = new Date().toISOString()
setResponseReceipt(at)
const successfulIds = new Set([...ipsec.servers.map(s => s.serverId), ...ipsec.clients.map(c => c.serverId)])
setReadReceipt(previous => ({ ...previous, ...Object.fromEntries([...successfulIds].map(id => [id, at])) }))
setHasLoaded(true)
setDataError(null)
} else {
setDataError(ipsecResult.reason instanceof Error ? ipsecResult.reason.message : "Ошибка загрузки IPsec")
}
setLoading(false)
}, [isLive, backendUrl])
useEffect(() => {
if (isLive) queueMicrotask(() => { void loadLive() })
}, [isLive, loadLive])
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
const effectiveServerId = selectedServerId
const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId)
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId || !mounted.current) return
const epoch = ++historyEpoch.current
setHistoryLoading(true)
setHistoryError(null)
try {
const res = await requestJson<unknown>(
backendUrl,
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
if (epoch !== historyEpoch.current) return
setRevisions(parseConfigRevisions(res, historyServerId, "ipsec"))
} catch (err) {
if (epoch !== historyEpoch.current) return
setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю")
} finally {
if (epoch === historyEpoch.current) setHistoryLoading(false)
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = async (id: string) => {
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
if (!isLive || !historyServerId) return
beginOperation(historyServerId)
setHistoryRestoring(true)
try {
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
if (!mounted.current) return
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
confirmOperation(historyServerId)
toast.success("Версия применена на роутер")
await loadLive(true)
await loadRevisions()
} catch (err) {
throw err
} finally {
endOperation()
if (mounted.current) setHistoryRestoring(false)
}
}
const failedServerIds = new Set(failures.map(f => f.serverId))
const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
const canManage = (serverId: string) => isLive && can("mm:network:write") && hasLoaded && !dataError && !inventoryError &&
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
const assertDeviceReady = (serverId: string) => {
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
}
const confirmOperation = (serverId: string) => setWriteReceipt({ serverId, at: new Date().toISOString() })
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
const scopeReceipt = effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]
const scopeWriteReceipt = writeReceipt && (effectiveServerId === ALL_SERVERS_ID || writeReceipt.serverId === effectiveServerId) ? writeReceipt : null
const beginOperation = (serverId: string) => {
assertDeviceReady(serverId)
operationLock.current = true
readEpoch.current += 1
setLoading(false)
setBusy(true)
}
const endOperation = () => {
operationLock.current = false
if (mounted.current) setBusy(false)
}
const hasReadyDevice = !missingSelectedServer && displayServers.some(s => (effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId) && canManage(s.id))
const scopedClients = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
return displayClients.filter((c) => c.serverId === effectiveServerId)
}, [displayClients, effectiveServerId])
const scopedSummaries = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displaySummaries
return displaySummaries.filter((s) => s.serverId === effectiveServerId)
}, [displaySummaries, effectiveServerId])
const filteredClients = useMemo(() => {
if (!search) return scopedClients
const q = search.toLowerCase()
return scopedClients.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.serverName.toLowerCase().includes(q) ||
(c.commonName ?? "").toLowerCase().includes(q) ||
(c.staticIp ?? "").includes(q),
)
}, [scopedClients, search])
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
const clientsOnline = scopedClients.filter((c) => c.online).length
const clientsAmbiguous = scopedClients.filter(c => c.sessionAmbiguous).length
const caOk = scopedSummaries.some((s) => s.caCert)
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const peerOptions = useMemo(
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
[scopedSummaries],
)
const serverOptions = displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
}))
/** Подсказка свободного IP из пула выбранного сервера. */
const freeIpHint = useMemo(() => {
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
if (!summary?.pool) return undefined
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
}, [scopedSummaries, scopedClients])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const c of displayClients) {
counts.set(c.serverId, (counts.get(c.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
meta: isLive && (s as InventoryServer).monitoringUnknown ? `${counts.get(s.id) ?? 0} · Статус неизвестен` : undefined,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, displayClients, isLive])
const handleInit = async (form: IpsecInitFormState) => {
beginOperation(form.serverId)
try {
await initIpsecServer(backendUrl, {
serverId: form.serverId,
serverEndpoint: form.serverEndpoint.trim(),
poolCidr: form.poolCidr.trim(),
dns: form.dns.trim() || undefined,
caDaysValid: 3650,
serverDaysValid: 3650,
clientDaysValid: 1825,
createNatRule: form.createNatRule,
})
if (!mounted.current) return
confirmOperation(form.serverId)
toast.success("IKEv2-сервер инициализирован")
setInitOpen(false)
await loadLive(true)
} finally {
endOperation()
}
}
const handleCreateUser = async (form: IpsecUserFormState) => {
beginOperation(form.serverId)
try {
const res = await createIpsecUser(backendUrl, {
serverId: form.serverId,
name: form.name.trim(),
peerName: form.peerName.trim() || undefined,
authMethod: form.authMethod,
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
passphrase: form.passphrase.trim() || undefined,
})
if (!mounted.current) return
confirmOperation(form.serverId)
toast.success(`Клиент «${form.name.trim()}» создан`)
setUserOpen(false)
setEditing(null)
await loadLive(true)
if (!mounted.current) return
if (res.bundle) {
certEpoch.current += 1
certPending.current = false
setCertBusy(false)
setCertError(null)
setCertBundle(res.bundle)
setCertClient(res.client ?? null)
setCertByName(null)
setCertOpen(true)
}
} finally {
endOperation()
}
}
const handleEditUser = async (form: IpsecUserFormState) => {
if (!editing) return
beginOperation(editing.serverId)
try {
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
})
if (!mounted.current) return
confirmOperation(editing.serverId)
toast.success("Клиент обновлён")
setUserOpen(false)
setEditing(null)
await loadLive(true)
} finally {
endOperation()
}
}
const loadCert = async (serverId: string, request: () => Promise<IpsecCertBundle>) => {
if (certPending.current) return
certPending.current = true
const epoch = ++certEpoch.current
setCertBusy(true)
setCertError(null)
try {
assertDeviceReady(serverId)
const bundle = await request()
if (epoch === certEpoch.current) setCertBundle(bundle)
} catch (error) {
if (epoch === certEpoch.current) setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата")
} finally {
if (epoch === certEpoch.current) { certPending.current = false; setCertBusy(false) }
}
}
const openCert = async (client: IpsecClientDto) => {
const byName = client.kind === "cert" && Boolean(client.certName)
certEpoch.current += 1
certPending.current = false
setCertClient(byName ? null : client)
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
setCertBundle(null)
setCertOpen(true)
const passphrase = crypto.randomUUID()
await loadCert(client.serverId, () => byName
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
}
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
certEpoch.current += 1
certPending.current = false
setCertByName({ serverId, name: cert.name })
setCertClient(null)
setCertBundle(null)
setCertOpen(true)
await loadCert(serverId, () => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
}
const reexportCert = async (passphrase: string) => {
if (!certClient && !certByName) return
await loadCert(certByName?.serverId ?? certClient!.serverId, () => certByName
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
}
const deletingCertificateIdentity = pendingDelete?.kind === "user" && pendingDelete.client.kind === "cert" && pendingDelete.client.rosId.startsWith("identity:")
const deletingSharedCertificate = deletingCertificateIdentity && pendingDelete?.kind === "user" && Boolean(pendingDelete.client.certName) && displayClients.some(c => c.serverId === pendingDelete.client.serverId && c.certName === pendingDelete.client.certName && c.rosId !== pendingDelete.client.rosId)
const shouldRemoveClientCertificate = !deletingCertificateIdentity || (removeClientCertificate && !deletingSharedCertificate)
const confirmDelete = async (force = false) => {
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
const target = pendingDelete
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
beginOperation(serverId)
deleteLock.current = true
setDeleteError(null)
try {
if (target.kind === "user") {
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId, { removeCertificate: shouldRemoveClientCertificate })
} else if (target.kind === "server") {
await deleteIpsecServer(backendUrl, target.summary.serverId)
} else if (target.kind === "peer") {
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force })
} else {
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
}
if (!mounted.current) return
confirmOperation(serverId)
toast.success("Удалено")
setPendingDelete(null)
setForceAllowed(false)
await loadLive(true)
} catch (error) {
if (!mounted.current) return
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить")
setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert"))
} finally {
deleteLock.current = false
endOperation()
}
}
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
setEditingPeer({ serverId, peer })
setPeerOpen(true)
}
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
beginOperation(editingPeer.serverId)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
address: form.address.trim() || undefined,
exchangeMode: form.exchangeMode.trim() || undefined,
passive: form.passive,
profile: form.profile.trim() || undefined,
disabled: form.disabled,
})
if (!mounted.current) return
confirmOperation(editingPeer.serverId)
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive(true)
} finally {
endOperation()
}
}
const deleteDialogTitle = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
case "user": return `Удалить клиента «${pendingDelete.client.name}»?`
case "peer": return `Удалить peer «${pendingDelete.peer.name}»?`
case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?`
}
})()
const deleteDialogDescription = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server":
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
case "user":
if (pendingDelete.client.kind === "psk") return "Удалит выбранную PSK identity и неиспользуемый персональный mode-config приложения. Сертификаты останутся на роутере."
if (deletingCertificateIdentity && !shouldRemoveClientCertificate) return "Удалит выбранную identity и неиспользуемый персональный mode-config приложения. Сертификат останется на роутере; другие identity сохранятся."
return "Удалит identity, неиспользуемый персональный mode-config приложения и прямо привязанный клиентский сертификат на роутере."
case "peer":
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
case "cert":
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
}
})()
return (
<>
<ServerRailLayout
items={railItems}
preserveSelection
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayClients.length}
loading={isLive && loading && displayServers.length === 0}
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading || busy || historyRestoring}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading || busy || historyRestoring || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
)}
{isLive ? (
<Button size="sm" disabled={!hasReadyDevice} onClick={() => setUserOpen(true)}>
<PlusIcon className="size-4" />
Новый клиент
</Button>
) : null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
{missingSelectedServer && <Alert variant="destructive" role="alert"><AlertTitle>Устройство из ссылки не найдено</AlertTitle><AlertDescription>Выберите доступный сервер. Данные других устройств не подставляются в этот срез.</AlertDescription></Alert>}
<KpiStatGrid
aria-label="Сводка IPsec"
items={[
{
id: "servers",
label: "Серверов IKEv2",
value: scopeUnknown ? "—" : serversWithIpsec,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "clients",
label: "Клиентов",
value: scopeUnknown ? "—" : scopedClients.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Онлайн",
value: scopeUnknown || clientsAmbiguous > 0 ? "—" : clientsOnline,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ca",
label: "CA",
value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет",
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{isLive && <p className="text-xs text-muted-foreground">Онлайн означает установленную IKE-сессию. Передачу данных проверяйте Ping.</p>}
{isLive && <div role="group" aria-label="Свежесть IPsec" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span data-source-time="read">{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ IPsec получен в приложении: " : "Устройство опрошено, ответ получен в приложении: "}
{scopeReceipt ? <time dateTime={scopeReceipt} title="Клиентское время получения ответа; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно"}
</span>
{scopeWriteReceipt && <span data-source-time="write">Изменение подтверждено API: <time dateTime={scopeWriteReceipt.at} title={`Устройство ${scopeWriteReceipt.serverId}; подтверждение API не заменяет опрос`}>{formatReceipt(scopeWriteReceipt.at)}</time></span>}
</div>}
{clientsAmbiguous > 0 && <Alert>
<AlertCircleIcon />
<AlertTitle>Статус части клиентов не определён</AlertTitle>
<AlertDescription>У {clientsAmbiguous} клиентов совпадает идентификатор сессии. RouterOS не сообщает, какому из них принадлежит подключение. Проверьте Remote ID.</AlertDescription>
</Alert>}
{isLive && (dataError || inventoryError) ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
<AlertDescription>
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
<Button size="sm" variant="outline" disabled={loading || busy || historyRestoring} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
</AlertDescription>
</Alert>
) : null}
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена.
</AlertDescription>
</Alert>
) : null}
{!isLive ? (
<Alert variant="info">
<InfoIcon />
<AlertTitle>Mock-режим</AlertTitle>
<AlertDescription>
Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik.
</AlertDescription>
</Alert>
) : null}
<Tabs
value={workspaceTab}
onValueChange={(v) => setWorkspaceTab(v as IpsecWorkspaceTab)}
className="gap-3"
>
<TabsList variant="line">
<TabsTrigger value="clients" className="gap-1.5">
<UsersIcon className="size-3.5" />
Клиенты
</TabsTrigger>
<TabsTrigger value="server" className="gap-1.5">
<ShieldCheckIcon className="size-3.5" />
Сервер
</TabsTrigger>
<TabsTrigger value="cli" className="gap-1.5">
<CodeXmlIcon className="size-3.5" />
CLI
</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени / CN / IP…"
actions={
isLive ? (
<Button size="sm" disabled={!hasReadyDevice} onClick={() => { setEditing(null); setUserOpen(true) }}>
<PlusIcon className="size-4" />
Клиент
</Button>
) : undefined
}
/>
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
clients={filteredClients}
canManage={canManage}
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
compactServer={compactServer}
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
onDelete={isLive ? (row) => { setRemoveClientCertificate(true); setPendingDelete({ kind: "user", client: row }) } : undefined}
/>}
</DataPageCard>
</TabsContent>
<TabsContent value="server" className="mt-0 outline-none">
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
servers={scopedSummaries}
canManage={canManage}
onInit={isLive ? (s) => { setInitTargetId(s.serverId); setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
onEditPeer={isLive ? openEditPeer : undefined}
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
/>}
</TabsContent>
<TabsContent value="cli" className="mt-0 outline-none">
<OpsPanel title="RouterOS 7 · /ip ipsec — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
{[
{
title: "CA и серверный серт.",
lines: [
"/certificate add name=ipsec-ca \\",
" common-name=\"MM IPsec CA\" \\",
" key-size=4096 \\",
" key-usage=key-cert-sign,crl-sign",
"/certificate sign ipsec-ca",
"/certificate add name=ipsec-server \\",
" common-name=vpn.example.com \\",
" subject-alt-name=DNS:vpn.example.com",
"/certificate sign ipsec-server ca=ipsec-ca",
],
},
{
title: "Peer + identity",
lines: [
"/ip ipsec peer add \\",
" name=ipsec-vpn address=0.0.0.0/0 \\",
" exchange-mode=ike2 passive=yes profile=ipsec-vpn",
"/ip ipsec identity add \\",
" peer=ipsec-vpn auth-method=digital-signature \\",
" certificate=ipsec-server \\",
" generate-policy=port-strict \\",
" mode-config=ipsec-vpn",
],
},
{
title: "Клиент: серт и .p12",
lines: [
"/certificate add name=ipsec-user-alice \\",
" common-name=alice key-size=2048 \\",
" key-usage=digital-signature,\\",
" key-encipherment,tls-client",
"/certificate sign ipsec-user-alice \\",
" ca=ipsec-ca",
"/certificate export-certificate \\",
" ipsec-user-alice type=pkcs12 \\",
" export-passphrase=*****",
"",
"# Онлайн-клиенты:",
"/ip ipsec active-peers print",
],
},
].map((b) => (
<div key={b.title}>
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
{b.title}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</TabsContent>
</Tabs>
</div>
</ServerRailLayout>
<IpsecUserSheet
open={userOpen}
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
servers={serverOptions.filter(s => canManage(s.id) || busy)}
peers={peerOptions}
busy={busy}
defaultServerId={sheetServerId}
editing={editing}
freeIpHint={freeIpHint}
onSubmit={editing ? handleEditUser : handleCreateUser}
/>
<IpsecPeerSheet
open={peerOpen}
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
busy={busy}
editing={editingPeer?.peer ?? null}
onSubmit={handleEditPeer}
/>
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
servers={serverOptions.filter(s => canManage(s.id) || busy)}
busy={busy}
defaultServerId={initTargetId ?? sheetServerId}
onSubmit={handleInit}
/>
<IpsecCertSheet
open={certOpen}
onOpenChange={(v) => {
if (!v) { certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null) }
setCertOpen(v)
}}
error={certError}
bundle={certBundle}
busy={certBusy}
onReexport={reexportCert}
/>
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
{deletingCertificateIdentity && (
<div className="space-y-2 text-left text-sm">
<label htmlFor="ipsec-delete-certificate" className="flex items-center gap-2">
<Checkbox id="ipsec-delete-certificate" checked={shouldRemoveClientCertificate} disabled={busy || deletingSharedCertificate} onCheckedChange={setRemoveClientCertificate} />
Удалить также клиентский сертификат
</label>
{deletingSharedCertificate && <p className="text-muted-foreground">Сертификат используется другими identity и будет сохранён.</p>}
</div>
)}
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
{forceAllowed && <Button variant="destructive" disabled={busy} onClick={() => void confirmDelete(true)}>Удалить принудительно</Button>}
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={(e) => { e.preventDefault(); void confirmDelete() }}
>
<Trash2Icon className="size-4" />
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => void loadRevisions()}
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
restoreDisabled={!can("mm:network:write") || !historyServerId || !canManage(historyServerId)}
onRestore={restoreRevision}
title="История конфигураций IPsec"
itemLabel="конфигурация"
/>
</>
)
}
function IpsecRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const rawTab = params.get("tab")
const workspaceTab: IpsecWorkspaceTab = rawTab === "server" || rawTab === "cli" ? rawTab : "clients"
const navigate = (key: string, value: string, defaultValue: string) => {
const next = new URLSearchParams(params.toString())
if (value === defaultValue) next.delete(key)
else next.set(key, value)
window.history.pushState(null, "", "/ipsec" + (next.size ? "?" + next.toString() : ""))
}
return <IpsecPage key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} setSelectedServerId={id => navigate("server", id, ALL_SERVERS_ID)} workspaceTab={workspaceTab} setWorkspaceTab={tab => navigate("tab", tab, "clients")} />
}
export default function IpsecSource() {
return <Suspense fallback={<div role="status">Загрузка IPsec…</div>}><IpsecRoute /></Suspense>
}