feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade - клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers - скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase - история изменений (config_revisions, секция ipsec) и restore только managed-объектов - привязка IPsec-клиентов к пользователям приложения по Common Name - страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
@@ -0,0 +1,737 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, 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 { 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 { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
createIpsecUser,
|
||||
deleteIpsecServer,
|
||||
deleteIpsecUser,
|
||||
exportIpsecUserCert,
|
||||
initIpsecServer,
|
||||
listIpsec,
|
||||
patchIpsecUser,
|
||||
restoreIpsecRevision,
|
||||
} from "@/shared/api/ipsec"
|
||||
import type {
|
||||
IpsecCertBundle,
|
||||
IpsecClientDto,
|
||||
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 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 }
|
||||
|
||||
const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
||||
{
|
||||
serverId: "srv2",
|
||||
serverName: "mt-spb-edge-01",
|
||||
initialized: 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: { 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 },
|
||||
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", certificateName: "ipsec-user-alice", 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", certificateName: "ipsec-user-bob", 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", 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 [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
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
|
||||
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !historyServerId) return
|
||||
setHistoryLoading(true)
|
||||
try {
|
||||
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
||||
backendUrl,
|
||||
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||
)
|
||||
setRevisions(res.revisions)
|
||||
} catch (err) {
|
||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||
setRevisions([])
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}, [isLive, historyServerId, backendUrl])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !historyServerId) return
|
||||
setHistoryRestoring(true)
|
||||
try {
|
||||
await restoreIpsecRevision(backendUrl, id, historyServerId)
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadLive()
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
toast.error("Не удалось откатить", { description: String(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.initialized).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 serverOptions = displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
}))
|
||||
|
||||
/** Подсказка свободного IP из пула выбранного сервера. */
|
||||
const freeIpHint = useMemo(() => {
|
||||
const summary = scopedSummaries.find((s) => s.initialized && 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()
|
||||
} catch (e) {
|
||||
toast.error("Ошибка инициализации", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async (form: IpsecUserFormState) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await createIpsecUser(backendUrl, {
|
||||
serverId: form.serverId,
|
||||
name: form.name.trim(),
|
||||
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)
|
||||
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) => {
|
||||
setCertClient(client)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||
const bundle = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const reexportCert = async (passphrase: string) => {
|
||||
if (!certClient) return
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const bundle = 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
|
||||
setBusy(true)
|
||||
try {
|
||||
if (pendingDelete.kind === "user") {
|
||||
await deleteIpsecUser(backendUrl, pendingDelete.client.serverId, pendingDelete.client.rosId)
|
||||
toast.success(`Клиент «${pendingDelete.client.name}» удалён`)
|
||||
} else {
|
||||
await deleteIpsecServer(backendUrl, pendingDelete.summary.serverId)
|
||||
toast.success(`IKEv2-сервер на ${pendingDelete.summary.serverName} удалён`)
|
||||
}
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</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}
|
||||
busy={busy}
|
||||
defaultServerId={sheetServerId}
|
||||
editing={editing}
|
||||
freeIpHint={freeIpHint}
|
||||
onSubmit={editing ? handleEditUser : handleCreateUser}
|
||||
/>
|
||||
|
||||
<IpsecInitSheet
|
||||
open={initOpen}
|
||||
onOpenChange={setInitOpen}
|
||||
servers={serverOptions}
|
||||
busy={busy}
|
||||
defaultServerId={sheetServerId}
|
||||
onSubmit={handleInit}
|
||||
/>
|
||||
|
||||
<IpsecCertSheet
|
||||
open={certOpen}
|
||||
onOpenChange={setCertOpen}
|
||||
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>
|
||||
{pendingDelete?.kind === "server"
|
||||
? `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
|
||||
: `Удалить клиента «${pendingDelete?.kind === "user" ? pendingDelete.client.name : ""}»?`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.kind === "server"
|
||||
? "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
|
||||
: "Удалит identity, персональный mode-config и клиентский сертификат на роутере."}
|
||||
</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={setHistoryOpen}
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={(id) => void restoreRevision(id)}
|
||||
title="История конфигураций IPsec"
|
||||
itemLabel="конфигурация"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user