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

903 lines
35 KiB
TypeScript

"use client"
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,
}
}
export default function IpsecPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
const [clients, setClients] = useState<IpsecClientDto[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<IpsecFailure[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [certBusy, setCertBusy] = useState(false)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
const [search, setSearch] = useState("")
const [initOpen, setInitOpen] = useState(false)
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 [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 () => {
if (!isLive) return
setLoading(true)
try {
const [ipsec, servers] = await Promise.all([
listIpsec(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setSummaries(ipsec.servers)
setClients(ipsec.clients)
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(ipsec.failures ?? [])
if (ipsec.failures?.length) {
toast.warning(
`Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec")
setSummaries([])
setClients([])
setFailures([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setSummaries([])
setClients([])
setLiveServers([])
setFailures([])
})
return
}
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) 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 = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
throw err
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
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<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: 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) => {
setBusy(true)
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,
})
toast.success("IKEv2-сервер инициализирован")
setInitOpen(false)
await loadLive()
} finally {
setBusy(false)
}
}
const handleCreateUser = async (form: IpsecUserFormState) => {
setBusy(true)
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,
})
toast.success(`Клиент «${form.name.trim()}» создан`)
setUserOpen(false)
setEditing(null)
await loadLive()
if (res.bundle) {
setCertBundle(res.bundle)
setCertClient(res.client ?? null)
setCertByName(null)
setCertOpen(true)
}
} catch (e) {
toast.error("Ошибка создания клиента", {
description: e instanceof Error ? e.message : String(e),
})
} finally {
setBusy(false)
}
}
const handleEditUser = async (form: IpsecUserFormState) => {
if (!editing) return
setBusy(true)
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,
})
toast.success("Клиент обновлён")
setUserOpen(false)
setEditing(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
const openCert = async (client: IpsecClientDto) => {
const byName = client.kind === "cert" && Boolean(client.certName)
setCertClient(byName ? null : client)
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
setCertBundle(null)
setCertOpen(true)
setCertBusy(true)
try {
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
const bundle = byName
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта сертификата", {
description: e instanceof Error ? e.message : String(e),
})
setCertOpen(false)
} finally {
setCertBusy(false)
}
}
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
setCertByName({ serverId, name: cert.name })
setCertClient(null)
setCertBundle(null)
setCertOpen(true)
setCertBusy(true)
try {
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта сертификата", {
description: e instanceof Error ? e.message : String(e),
})
setCertOpen(false)
} finally {
setCertBusy(false)
}
}
const reexportCert = async (passphrase: string) => {
if (!certClient && !certByName) return
setCertBusy(true)
try {
const bundle = certByName
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
} finally {
setCertBusy(false)
}
}
const confirmDelete = async () => {
if (!pendingDelete) return
const target = pendingDelete
setBusy(true)
try {
if (target.kind === "user") {
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
toast.success(`Клиент «${target.client.name}» удалён`)
} else if (target.kind === "server") {
await deleteIpsecServer(backendUrl, target.summary.serverId)
toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`)
} else if (target.kind === "peer") {
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId)
toast.success(`Peer «${target.peer.name}» удалён`)
} else {
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
toast.success(`Сертификат «${target.cert.name}» удалён`)
}
await loadLive()
} catch (e) {
const forceDelete = async (fn: () => Promise<unknown>) => {
try {
await fn()
toast.success("Удалено")
await loadLive()
} catch (err) {
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
}
}
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
toast.error("На peer ссылаются identity", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
},
})
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
toast.error("Сертификат используется", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
},
})
} else {
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
}
} finally {
setBusy(false)
setPendingDelete(null)
}
}
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
setEditingPeer({ serverId, peer })
setPeerOpen(true)
}
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
setBusy(true)
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,
})
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
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 (
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayClients.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
)}
{isLive ? (
<Button size="sm" onClick={() => setUserOpen(true)}>
<PlusIcon className="size-4" />
Новый клиент
</Button>
) : null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка IPsec"
items={[
{
id: "servers",
label: "Серверов IKEv2",
value: serversWithIpsec,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "clients",
label: "Клиентов",
value: scopedClients.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Онлайн",
value: clientsOnline,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ca",
label: "CA",
value: caOk ? "ок" : "—",
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => f.serverName ?? f.serverId).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" onClick={() => { setEditing(null); setUserOpen(true) }}>
<PlusIcon className="size-4" />
Клиент
</Button>
) : undefined
}
/>
<IpsecUsersGrid
clients={filteredClients}
compactServer={compactServer}
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="server" className="mt-0 outline-none">
<IpsecServerGrid
servers={scopedSummaries}
onInit={isLive ? (s) => { 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 \\",
" 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) => (
<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}
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}
certificates={certNames}
onSubmit={handleEditPeer}
/>
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
servers={serverOptions}
busy={busy}
defaultServerId={sheetServerId}
onSubmit={handleInit}
/>
<IpsecCertSheet
open={certOpen}
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
bundle={certBundle}
busy={certBusy}
onReexport={reexportCert}
/>
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
<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}
onRestore={restoreRevision}
title="История конфигураций IPsec"
itemLabel="конфигурация"
/>
</>
)
}