"use client" import { z } from "zod" import { useCallback, useEffect, useMemo, useRef, useState } from "react" 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 { 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"] latency?: number | null asn?: string } function mapBackendServer(s: BackendServer): Server { 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 ?? "online", 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"]).optional(), latency: z.number().nullable().optional(), asn: z.string().optional(), })).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) function IpsecPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [summaries, setSummaries] = useState([]) const [clients, setClients] = useState([]) const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(false) const [dataError, setDataError] = useState(null) const [inventoryError, setInventoryError] = useState(null) const [hasLoaded, setHasLoaded] = useState(false) 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(null) const certEpoch = useRef(0) const certPending = useRef(false) useEffect(() => () => { certEpoch.current += 1 }, []) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("clients") const [search, setSearch] = useState("") const [initOpen, setInitOpen] = useState(false) const [userOpen, setUserOpen] = useState(false) const [editing, setEditing] = useState(null) const [certOpen, setCertOpen] = useState(false) const [certBundle, setCertBundle] = useState(null) const [certClient, setCertClient] = useState(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(null) const deleteLock = useRef(false) const [deleteError, setDeleteError] = useState(null) const [forceAllowed, setForceAllowed] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) const [historyError, setHistoryError] = useState(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(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 ?? []) 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 === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) ? selectedServerId : ALL_SERVERS_ID 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( 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 (!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("Сервер не подтвердил восстановление") 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 = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) const canManage = (serverId: string) => isLive && 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 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 = displayServers.some(s => 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 caOk = scopedSummaries.some((s) => s.caCert) const compactServer = effectiveServerId !== ALL_SERVERS_ID const sheetServerId = compactServer ? effectiveServerId : undefined const certNames = useMemo( () => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(), [scopedSummaries], ) 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(() => { const counts = new Map() 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: s.status, type: s.type, count: counts.get(s.id) ?? 0, enabled: s.enabled, title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), })) }, [displayServers, displayClients]) 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 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 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 toast.success("Клиент обновлён") setUserOpen(false) setEditing(null) await loadLive(true) } finally { endOperation() } } const loadCert = async (serverId: string, request: () => Promise) => { 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 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) } 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 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, certificate: form.certificate.trim() || undefined, profile: form.profile.trim() || undefined, disabled: form.disabled, }) if (!mounted.current) return 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": return "Удалит identity, персональный mode-config и клиентский сертификат на роутере." case "peer": return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление." case "cert": return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление." } })() return ( <> {isLive && ( )} {isLive && ( )} {isLive ? ( ) : null} } /> } >
, iconClassName: "text-muted-foreground", }, { id: "clients", label: "Клиентов", value: scopeUnknown ? "—" : scopedClients.length, icon: , iconClassName: "text-info", }, { id: "online", label: "Онлайн", value: scopeUnknown ? "—" : clientsOnline, icon: , iconClassName: "text-success", }, { id: "ca", label: "CA", value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет", icon: , iconClassName: "text-primary", }, ]} /> {isLive && (dataError || inventoryError) ? ( {dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"} {dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."} ) : null} {failures.length > 0 ? ( Не удалось опросить часть роутеров {failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена. ) : null} {!isLive ? ( Mock-режим Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik. ) : null} setWorkspaceTab(v as IpsecWorkspaceTab)} className="gap-3" > Клиенты Сервер CLI { setEditing(null); setUserOpen(true) }}> Клиент ) : undefined } /> {isLive && !hasLoaded ?

{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}

: void openCert(row) : undefined} onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined} onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined} />}
{isLive && !hasLoaded ?

Состояние серверов IPsec пока неизвестно

: { 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} />}
{[ { 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 \\", " certificate=ipsec-server send-cert=always", "/ip ipsec identity add \\", " peer=ipsec-vpn auth-method=rsa-key \\", " certificate=ipsec-server \\", " match-by=certificate \\", " 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) => (

{b.title}

                          {b.lines.join("\n")}
                        
))}
{ 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} /> { setPeerOpen(v); if (!v) setEditingPeer(null) }} busy={busy} editing={editingPeer?.peer ?? null} certificates={certNames} onSubmit={handleEditPeer} /> canManage(s.id) || busy)} busy={busy} defaultServerId={sheetServerId} onSubmit={handleInit} /> { 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} /> { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}> {deleteDialogTitle} {deleteDialogDescription} {busy &&

Ожидание результата удаления…

} {deleteError &&

{deleteError}

}
Отмена {forceAllowed && } { e.preventDefault(); void confirmDelete() }} > Удалить
{ if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} loadError={historyError} onRetry={() => void loadRevisions()} revisions={revisions} loading={historyLoading} restoring={historyRestoring} onRestore={restoreRevision} title="История конфигураций IPsec" itemLabel="конфигурация" /> ) } export default function IpsecSource() { const { mode, backendUrl } = useDataSource() return }