From 7755d77340b81a728c024349989d9c8b4db90658 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 12 Sep 2026 20:20:56 +0700 Subject: [PATCH] =?UTF-8?q?feat(ipsec):=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20IKEv2/IPsec=20VPN=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - мастер инициализации сервера: 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 --- app/(main)/ipsec/page.tsx | 737 ++++++++++++++++++ backend/src/db/schema.ts | 4 +- backend/src/index.ts | 2 + backend/src/lib/permissions.ts | 2 + backend/src/modules/users/iface-type.ts | 3 +- backend/src/modules/users/peer-bind.ts | 8 +- .../modules/users/service/users-service.ts | 38 +- backend/src/routes/ipsec.ts | 498 ++++++++++++ backend/src/routes/sidebar-counts.ts | 5 +- backend/src/services/config-revisions.ts | 1 + backend/src/services/entity-snapshots.test.ts | 52 ++ backend/src/services/entity-snapshots.ts | 313 +++++++- backend/src/services/ipsec-ca.ts | 121 +++ backend/src/services/ipsec-config.test.ts | 87 +++ backend/src/services/ipsec-config.ts | 158 ++++ backend/src/services/ipsec-live.ts | 513 ++++++++++++ backend/src/services/ipsec-ros.ts | 214 +++++ backend/src/services/mikrotik.ts | 137 ++++ components/app-sidebar.tsx | 4 + components/command-palette.tsx | 3 +- .../data-grids/users-expanded-detail.tsx | 4 +- components/ipsec/ipsec-cert-sheet.tsx | 181 +++++ components/ipsec/ipsec-init-sheet.tsx | 162 ++++ components/ipsec/ipsec-server-grid.tsx | 99 +++ components/ipsec/ipsec-user-sheet.tsx | 226 ++++++ components/ipsec/ipsec-users-grid.tsx | 245 ++++++ components/users/user-sheet.tsx | 3 +- lib/ipsec-client.ts | 33 + lib/sidebar-badges.ts | 5 + lib/users.ts | 3 +- packages/contracts/package.json | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipsec.ts | 191 +++++ packages/contracts/src/users.ts | 2 +- shared/api/ipsec.ts | 108 +++ 35 files changed, 4154 insertions(+), 13 deletions(-) create mode 100644 app/(main)/ipsec/page.tsx create mode 100644 backend/src/routes/ipsec.ts create mode 100644 backend/src/services/ipsec-ca.ts create mode 100644 backend/src/services/ipsec-config.test.ts create mode 100644 backend/src/services/ipsec-config.ts create mode 100644 backend/src/services/ipsec-live.ts create mode 100644 backend/src/services/ipsec-ros.ts create mode 100644 components/ipsec/ipsec-cert-sheet.tsx create mode 100644 components/ipsec/ipsec-init-sheet.tsx create mode 100644 components/ipsec/ipsec-server-grid.tsx create mode 100644 components/ipsec/ipsec-user-sheet.tsx create mode 100644 components/ipsec/ipsec-users-grid.tsx create mode 100644 lib/ipsec-client.ts create mode 100644 packages/contracts/src/ipsec.ts create mode 100644 shared/api/ipsec.ts diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx new file mode 100644 index 0000000..e87a2ae --- /dev/null +++ b/app/(main)/ipsec/page.tsx @@ -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([]) + const [clients, setClients] = useState([]) + const [liveServers, setLiveServers] = useState([]) + const [failures, setFailures] = useState([]) + 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("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 [pendingDelete, setPendingDelete] = useState(null) + const [historyOpen, setHistoryOpen] = useState(false) + const [revisions, setRevisions] = useState([]) + 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(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(() => { + 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) => { + 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 ( + <> + + + {isLive && ( + + )} + {isLive && ( + + )} + {isLive ? ( + + ) : null} + + } + /> + } + > +
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "clients", + label: "Клиентов", + value: scopedClients.length, + icon: , + iconClassName: "text-info", + }, + { + id: "online", + label: "Онлайн", + value: clientsOnline, + icon: , + iconClassName: "text-success", + }, + { + id: "ca", + label: "CA", + value: caOk ? "ок" : "—", + icon: , + iconClassName: "text-primary", + }, + ]} + /> + + {failures.length > 0 ? ( + + + Не удалось опросить часть роутеров + + {failures.map((f) => f.serverName ?? f.serverId).join(", ")} + + + ) : null} + + {!isLive ? ( + + + Mock-режим + + Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik. + + + ) : null} + + setWorkspaceTab(v as IpsecWorkspaceTab)} + className="gap-3" + > + + + + Клиенты + + + + Сервер + + + + CLI + + + + + + { setEditing(null); setUserOpen(true) }}> + + Клиент + + ) : undefined + } + /> + void openCert(row) : undefined} + onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined} + onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined} + /> + + + + + { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} + onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : 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} + busy={busy} + defaultServerId={sheetServerId} + editing={editing} + freeIpHint={freeIpHint} + onSubmit={editing ? handleEditUser : handleCreateUser} + /> + + + + + + { if (!v) setPendingDelete(null) }}> + + + + + + + {pendingDelete?.kind === "server" + ? `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?` + : `Удалить клиента «${pendingDelete?.kind === "user" ? pendingDelete.client.name : ""}»?`} + + + {pendingDelete?.kind === "server" + ? "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)." + : "Удалит identity, персональный mode-config и клиентский сертификат на роутере."} + + + + Отмена + { e.preventDefault(); void confirmDelete() }} + > + + Удалить + + + + + + void restoreRevision(id)} + title="История конфигураций IPsec" + itemLabel="конфигурация" + /> + + ) +} diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c7493ea..acc0201 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -96,7 +96,7 @@ export const filterRules = pgTable("filter_rules", { export const configRevisions = pgTable("config_revisions", { id: text("id").primaryKey(), serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }), - section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "gre"] }).notNull(), + section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "ipsec", "gre"] }).notNull(), source: text("source", { enum: ["apply", "rollback", "observed", "copy"] }).notNull(), fingerprint: text("fingerprint").notNull(), payload: jsonb("payload").$type().notNull().default(sql`'[]'::jsonb`), @@ -716,7 +716,7 @@ export const userInterfaceBindings = pgTable("user_interface_bindings", { userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }), serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }), interfaceName: text("interface_name").notNull(), - interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] }) + interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "ipsec", "other"] }) .notNull().default("other"), peerPublicKey: text("peer_public_key").notNull().default(""), peerName: text("peer_name").notNull().default(""), diff --git a/backend/src/index.ts b/backend/src/index.ts index 7b037f3..9316d05 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,6 +29,7 @@ import certificatesRoutes from "./routes/certificates.js" import systemDatabaseRoutes from "./routes/system-database.js" import eventsRoutes from "./routes/events.js" import wireguardRoutes from "./routes/wireguard.js" +import ipsecRoutes from "./routes/ipsec.js" import vxlanRoutes from "./routes/vxlan.js" import containersRoutes from "./routes/containers.js" import firewallRoutes from "./routes/firewall.js" @@ -137,6 +138,7 @@ export async function buildApp(opts?: { await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" }) await app.register(wireguardRoutes, { prefix: "/api" }) + await app.register(ipsecRoutes, { prefix: "/api" }) await app.register(vxlanRoutes, { prefix: "/api" }) await app.register(containersRoutes, { prefix: "/api" }) await app.register(firewallRoutes, { prefix: "/api" }) diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index e1513d1..a87ee71 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -155,6 +155,7 @@ const RULES: Rule[] = [ p.startsWith("/api/internet-path") || p.startsWith("/api/exec") || p.startsWith("/api/wireguard") || + p.startsWith("/api/ipsec") || p.startsWith("/api/firewall") || p.startsWith("/api/gre"), permission: "mm:network:read", @@ -169,6 +170,7 @@ const RULES: Rule[] = [ p.startsWith("/api/internet-path") || p.startsWith("/api/exec") || p.startsWith("/api/wireguard") || + p.startsWith("/api/ipsec") || p.startsWith("/api/firewall") || p.startsWith("/api/gre"), permission: "mm:network:write", diff --git a/backend/src/modules/users/iface-type.ts b/backend/src/modules/users/iface-type.ts index 0c70921..dc05e29 100644 --- a/backend/src/modules/users/iface-type.ts +++ b/backend/src/modules/users/iface-type.ts @@ -1,4 +1,4 @@ -export type InterfaceType = "ether" | "gre" | "wg" | "other" +export type InterfaceType = "ether" | "gre" | "wg" | "ipsec" | "other" export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType { const t = String(raw ?? "").trim().toLowerCase() @@ -10,6 +10,7 @@ export function mapRosInterfaceType(raw: string | undefined | null, name?: strin const n = String(name ?? "").trim().toLowerCase() if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre" if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg" + if (n.startsWith("ipsec")) return "ipsec" if (n.startsWith("ether") || n.startsWith("sfp")) return "ether" return "other" } diff --git a/backend/src/modules/users/peer-bind.ts b/backend/src/modules/users/peer-bind.ts index 535c4b3..b33849e 100644 --- a/backend/src/modules/users/peer-bind.ts +++ b/backend/src/modules/users/peer-bind.ts @@ -28,7 +28,7 @@ export function peerDisplayName(opts: { return truncPeerKey(opts.publicKey) } -/** Ether/GRE — пустой ключ. WG — обязательный public-key. */ +/** Ether/GRE — пустой ключ. WG — обязательный public-key. IPsec — CN сертификата клиента. */ export function normalizeBindingPeer( type: InterfaceType, peerPublicKey: string | undefined, @@ -40,5 +40,11 @@ export function normalizeBindingPeer( } return key } + if (type === "ipsec") { + if (!key) { + throw new PeerBindError("Для IPsec укажите клиента (CN сертификата)", 400) + } + return key + } return "" } diff --git a/backend/src/modules/users/service/users-service.ts b/backend/src/modules/users/service/users-service.ts index cb25f81..bbbce82 100644 --- a/backend/src/modules/users/service/users-service.ts +++ b/backend/src/modules/users/service/users-service.ts @@ -43,6 +43,7 @@ import { peerDisplayName, } from "../peer-bind.js" import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js" +import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js" export class UsersServiceError extends Error { constructor( @@ -305,7 +306,10 @@ export async function listInterfaceCatalog(serverId: number): Promise { + // IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата). + const ipsecLive = await listIpsecClientsForCatalog(serverId) + + const entries: CatalogInterface[] = ifaces.map((iface) => { const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? "")) const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined const base: CatalogInterface = { @@ -335,7 +339,37 @@ export async function listInterfaceCatalog(serverId: number): Promise a.name.localeCompare(b.name)) + }) + + if (ipsecLive.clients.length > 0) { + const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? "")) + const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined + entries.push({ + name: "ipsec-vpn", + type: "ipsec", + running: true, + disabled: false, + boundUserId: ifaceBind?.userId ?? null, + boundUserLogin: owner?.login ?? null, + peersError: ipsecLive.error, + peers: ipsecLive.clients.map((c) => { + const cn = c.commonName ?? c.name + const bind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && b.peerPublicKey === cn) + const peerOwner = bind ? usersById.get(bind.userId) : undefined + return { + publicKey: cn, + name: c.name, + comment: c.comment ?? "", + allowedIps: c.staticIp ? [c.staticIp] : [], + latestHandshake: c.activeSince, + boundUserId: bind?.userId ?? null, + boundUserLogin: peerOwner?.login ?? null, + } + }), + }) + } + + return entries.sort((a, b) => a.name.localeCompare(b.name)) } export { parseRawInterfaces, mapRosInterfaceType } diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts new file mode 100644 index 0000000..bdc82ec --- /dev/null +++ b/backend/src/routes/ipsec.ts @@ -0,0 +1,498 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import type { FastifyReply } from "fastify" +import { + ipsecCertExportRequestSchema, + ipsecInitRequestSchema, + ipsecUserCreateRequestSchema, + ipsecUserPatchSchema, + type IpsecCertBundle, + type IpsecClientDto, +} from "@mmapp/contracts/ipsec" +import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" +import { + IPSEC_CA_CERT, + IPSEC_COMMON_NAME, + IPSEC_SERVER_CERT, + buildClientInstructions, + buildSswanConfig, + clientCertName, + findFreePoolIp, + ipsecManagedComment, + ipsecUserComment, + isIpsecManagedComment, + parseIpsecUserComment, + poolRangesFromCidr, + userModeConfigName, +} from "../services/ipsec-config.js" +import { + ensureIpsecPeer, + ensureIpsecPool, + ensureIpsecProfile, + ensureIpsecProposal, + ensureNatRule, + ensurePolicyTemplate, + ensureSharedModeConfig, + putUserModeConfig, + deleteUserModeConfig, + putIdentity, + patchIdentity, + deleteIdentity, + listByPath, +} from "../services/ipsec-ros.js" +import { + ensureCaCertificate, + ensureServerCertificate, + exportClientP12, + findCertificate, + issueClientCertificate, +} from "../services/ipsec-ca.js" +import { + captureIpsecSnapshot, + fetchIpsecRestoreState, + fetchIpsecState, + getEnabledIpsecServerById, + listIpsec, + mapClients, + mapServerSummary, +} from "../services/ipsec-live.js" +import { + captureAndAppendRevision, + listRevisions, + loadRevisionForRestore, + type ConfigRevisionSource, +} from "../services/config-revisions.js" +import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js" +import { executeRosOps } from "../services/ros-ops.js" +import { parseDbServerId } from "../utils/server-id.js" +import { randomBytes } from "node:crypto" +import { z } from "zod" + +const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/ + +function generatePassphrase(): string { + return randomBytes(9).toString("base64url") +} + +function serverIdParam(v: string): string { + return decodeURIComponent(v) +} + +function rosIdParam(v: string): string { + return decodeURIComponent(v) +} + +function errReply(reply: FastifyReply, e: unknown) { + const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) +} + +async function recordIpsec( + server: NonNullable>>, + source: ConfigRevisionSource, +) { + await captureAndAppendRevision({ + serverId: server.id, + section: "ipsec", + source, + capture: () => captureIpsecSnapshot(server), + }) +} + +/** Существующие статические IP клиентов (персональные mode-config). */ +function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "address-prefix"?: string }>): string[] { + return modeConfigs + .filter((m) => (m.name ?? "").startsWith("mc-ipsec-")) + .map((m) => String(m.address ?? m["address-prefix"] ?? "").replace(/\/\d+$/, "").trim()) + .filter(Boolean) +} + +async function buildCertBundle( + client: MikrotikClient, + args: { userName: string; serverEndpoint: string; passphrase: string; dns?: string }, +): Promise { + const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase) + const p12B64 = content.toString("base64") + const certName = clientCertName(args.userName) + return { + user: args.userName, + serverEndpoint: args.serverEndpoint, + filename: fileName, + contentB64: p12B64, + mime: "application/x-pkcs12", + passphrase: args.passphrase, + sswanFilename: `${certName}.sswan`, + sswanContent: buildSswanConfig({ + name: `IKEv2 ${args.serverEndpoint}`, + serverEndpoint: args.serverEndpoint, + serverId: args.serverEndpoint, + p12B64, + }), + instructions: buildClientInstructions({ + userName: args.userName, + serverEndpoint: args.serverEndpoint, + p12Filename: fileName, + passphrase: args.passphrase, + dns: args.dns, + }), + } +} + +const RevisionIdParamSchema = z.object({ id: z.string().min(1) }) + +const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/ipsec", async (req, reply) => { + const q = req.query as { serverId?: string } + const result = await listIpsec({ serverId: q.serverId }) + const sid = parseDbServerId(q.serverId) + if (sid !== null) { + const server = await getEnabledIpsecServerById(sid) + if (server) await recordIpsec(server, "observed") + } + return reply.send(result) + }) + + app.post("/ipsec/server/init", async (req, reply) => { + const parsed = ipsecInitRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const ranges = poolRangesFromCidr(body.poolCidr) + if (!ranges) { + return reply.status(400).send({ error: `Некорректная подсеть пула: ${body.poolCidr}` }) + } + const server = await getEnabledIpsecServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const caCert = await ensureCaCertificate(client, body.caDaysValid) + const serverCert = await ensureServerCertificate(client, { + serverEndpoint: body.serverEndpoint, + caCertName: caCert, + daysValid: body.serverDaysValid, + }) + const comment = ipsecManagedComment("IKEv2 road-warrior") + await ensureIpsecProfile(client, IPSEC_COMMON_NAME, comment) + await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment) + await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment) + await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment) + await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment) + await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment) + if (body.createNatRule) { + await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN")) + } + await recordIpsec(server, "apply") + const state = await fetchIpsecState(server) + return reply.status(201).send(mapServerSummary(state, mapClients(state))) + } catch (e) { + return errReply(reply, e) + } + }) + + app.delete("/ipsec/server/:serverId", async (req, reply) => { + const { serverId } = req.params as { serverId: string } + const q = req.query as { removeCertificates?: string } + const removeCertificates = q.removeCertificates !== "false" + const server = await getEnabledIpsecServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) { + const rows = await listByPath(client, path) + for (const row of rows) { + if (!row[".id"]) continue + if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) { + await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined) + } + } + } + const nat = await listByPath(client, "/ip/firewall/nat") + for (const row of nat) { + if (row[".id"] && isIpsecManagedComment(row.comment)) { + await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined) + } + } + // profile/proposal не критично оставлять; сертификаты — по флагу + if (removeCertificates) { + const certs = await client.getCertificates() + for (const c of certs) { + const name = String(c.name ?? "") + if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) { + await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined) + } + } + } + await recordIpsec(server, "apply") + return reply.send({ ok: true }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.post("/ipsec/users", async (req, reply) => { + const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = await getEnabledIpsecServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const state = await fetchIpsecState(server) + const peer = state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME) + const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) + const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) + if (!peer || !sharedMc || !caCert || !serverCert) { + return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" }) + } + const peerName = (peer.name ?? "").trim() + const serverEndpoint = String(serverCert["common-name"] ?? "").trim() + const dns = sharedMc["static-dns"]?.trim() || undefined + + if (body.authMethod === "pre-shared-key" && !body.psk) { + return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" }) + } + + let modeConfig = (sharedMc.name ?? "").trim() + if (body.staticIp) { + const ip = body.staticIp.trim() + if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + const taken = takenStaticIps(state.modeConfigs) + if (taken.includes(ip)) { + return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) + } + modeConfig = userModeConfigName(body.name) + await putUserModeConfig(client, modeConfig, ip, ipsecManagedComment(`клиент ${body.name.trim()}`)) + } + + let certName: string | undefined + if (body.authMethod === "certificate") { + const issued = await issueClientCertificate(client, { + userName: body.name, + caCertName: IPSEC_CA_CERT, + daysValid: body.daysValid ?? 1825, + }) + if (issued.existed) { + return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` }) + } + certName = issued.certName + } + + await putIdentity(client, { + peerName, + modeConfig, + comment: ipsecUserComment(body.name), + authMethod: body.authMethod, + certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined, + remoteCertificate: certName, + secret: body.psk, + remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined, + }) + + let bundle: IpsecCertBundle | undefined + if (body.authMethod === "certificate" && certName) { + const passphrase = body.passphrase?.trim() || generatePassphrase() + bundle = await buildCertBundle(client, { + userName: body.name, + serverEndpoint, + passphrase, + dns, + }) + } + + await recordIpsec(server, "apply") + const fresh = await fetchIpsecState(server) + const clients = mapClients(fresh) + const created: IpsecClientDto | undefined = clients.find( + (c) => c.name === body.name.trim(), + ) ?? clients.find((c) => c.certificateName === certName) + return reply.status(201).send({ client: created ?? null, bundle }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.patch("/ipsec/users/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const parsed = ipsecUserPatchSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const d = parsed.data + const server = await getEnabledIpsecServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const state = await fetchIpsecState(server) + const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) + if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) + const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? "" + const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + + if (d.name && d.name !== oldName) { + await patchIdentity(client, identity[".id"]!, { + comment: ipsecUserComment(d.name), + }) + } + + if (d.staticIp !== undefined) { + if (d.staticIp == null) { + // вернуть выдачу из пула + if (sharedMc) await patchIdentity(client, identity[".id"]!, { "mode-config": (sharedMc.name ?? "").trim() }) + const personal = (identity["mode-config"] ?? "").trim() + if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal) + } else { + const ip = d.staticIp.trim() + if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + const others = takenStaticIps( + state.modeConfigs.filter((m) => (m.name ?? "").trim() !== (identity["mode-config"] ?? "").trim()), + ) + if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) + const name = userModeConfigName(d.name || oldName) + await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`)) + await patchIdentity(client, identity[".id"]!, { "mode-config": name }) + } + } + + const patch: Record = {} + if (d.psk) patch.secret = d.psk + if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId + if (d.disabled === true) patch.disabled = "yes" + if (d.disabled === false) patch.disabled = "no" + if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch) + + await recordIpsec(server, "apply") + return reply.send({ ok: true }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.delete("/ipsec/users/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const q = req.query as { removeCertificate?: string } + const removeCertificate = q.removeCertificate !== "false" + const server = await getEnabledIpsecServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const state = await fetchIpsecState(server) + const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) + if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) + const userName = parseIpsecUserComment(identity.comment) ?? "" + const personal = (identity["mode-config"] ?? "").trim() + + await deleteIdentity(client, identity[".id"]!) + if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal) + if (removeCertificate && identity["remote-certificate"]) { + await client.removeCertificate(identity["remote-certificate"]).catch(() => undefined) + } else if (removeCertificate && userName) { + await client.removeCertificate(clientCertName(userName)).catch(() => undefined) + } + await recordIpsec(server, "apply") + return reply.send({ ok: true }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId }) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = await getEnabledIpsecServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const state = await fetchIpsecState(server) + const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) + if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) + const userName = parseIpsecUserComment(identity.comment) ?? "" + if (!userName) return reply.status(400).send({ error: "Не managed-клиент" }) + const certName = identity["remote-certificate"]?.trim() || clientCertName(userName) + if (!(await findCertificate(client, certName))) { + return reply.status(404).send({ error: `Сертификат ${certName} не найден на роутере` }) + } + const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) + const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host + const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + const dns = sharedMc?.["static-dns"]?.trim() || undefined + // export работает по имени сертификата (certName), не по userName + const { fileName, content } = await client.exportCertificatePkcs12({ + name: certName, + passphrase: body.passphrase, + }) + const p12B64 = content.toString("base64") + const bundle: IpsecCertBundle = { + user: userName, + serverEndpoint, + filename: fileName, + contentB64: p12B64, + mime: "application/x-pkcs12", + passphrase: body.passphrase, + sswanFilename: `${certName}.sswan`, + sswanContent: buildSswanConfig({ + name: `IKEv2 ${serverEndpoint}`, + serverEndpoint, + serverId: serverEndpoint, + p12B64, + }), + instructions: buildClientInstructions({ + userName, + serverEndpoint, + p12Filename: fileName, + passphrase: body.passphrase, + dns, + }), + } + return reply.send(bundle) + } catch (e) { + return errReply(reply, e) + } + }) + + app.get("/ipsec/revisions", async (req, reply) => { + const q = req.query as { serverId?: string | number } + const serverId = parseDbServerId(q.serverId) + if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) + const revisions = await listRevisions(serverId, "ipsec") + return reply.send({ revisions }) + }) + + app.post("/ipsec/revisions/:id/restore", { + schema: { params: RevisionIdParamSchema }, + }, async (req, reply) => { + const { id } = req.params + const body = req.body as { serverId?: string | number } | undefined + const loaded = await loadRevisionForRestore({ + id, + section: "ipsec", + requestedServerId: parseDbServerId(body?.serverId), + }) + if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error }) + try { + const desired = parseIpsecSnapshot(loaded.row.payload) + const state = await fetchIpsecRestoreState(loaded.server) + const ops = planIpsecRestore(desired, { + peers: state.peers, + identities: state.identities, + modeConfigs: state.modeConfigs, + pools: state.pools, + nat: state.nat, + }) + await executeRosOps(state.client, ops) + await recordIpsec(loaded.server, "rollback") + const result = await listIpsec({ serverId: String(loaded.server.id) }) + return reply.send({ ok: true, ...result }) + } catch (e) { + return errReply(reply, e) + } + }) +} + +export default ipsecRoutes diff --git a/backend/src/routes/sidebar-counts.ts b/backend/src/routes/sidebar-counts.ts index d629be1..2d7de74 100644 --- a/backend/src/routes/sidebar-counts.ts +++ b/backend/src/routes/sidebar-counts.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { count } from "drizzle-orm" import { listCertificatesFromServers } from "../services/certificates-service.js" import { countWireGuardInterfaces } from "../services/wireguard-live.js" +import { countIpsecClients } from "../services/ipsec-live.js" import { countVxlanTunnels } from "../services/vxlan-live.js" import { countContainers } from "../services/containers-live.js" import { countBgpSessions } from "../services/bgp-peers-live.js" @@ -27,9 +28,10 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { const uptimeProbesTotal = await tableCount(uptimeProbes) const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes) const recursiveRoutesTotal = await tableCount(recursiveRoutes) - const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([ + const [certRes, wireguardTotal, ipsecTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([ listCertificatesFromServers(), countWireGuardInterfaces().catch(() => 0), + countIpsecClients().catch(() => 0), countBgpSessions().catch(() => 0), countVxlanTunnels().catch(() => 0), countContainers().catch(() => 0), @@ -46,6 +48,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { recursiveRoutes: recursiveRoutesTotal, certificates: certificatesTotal, wireguard: wireguardTotal, + ipsec: ipsecTotal, users: usersTotal, bgpSessions: bgpTotal, vxlan: vxlanTotal, diff --git a/backend/src/services/config-revisions.ts b/backend/src/services/config-revisions.ts index 61c5f92..814af8d 100644 --- a/backend/src/services/config-revisions.ts +++ b/backend/src/services/config-revisions.ts @@ -10,6 +10,7 @@ export const CONFIG_SECTIONS = [ "recursive-routes", "firewall", "wireguard", + "ipsec", "gre", ] as const diff --git a/backend/src/services/entity-snapshots.test.ts b/backend/src/services/entity-snapshots.test.ts index beca97f..4a2a672 100644 --- a/backend/src/services/entity-snapshots.test.ts +++ b/backend/src/services/entity-snapshots.test.ts @@ -2,13 +2,16 @@ import assert from "node:assert/strict" import { canonicalFirewallSnapshot, canonicalGreSnapshot, + canonicalIpsecSnapshot, canonicalWireguardSnapshot, opsPaths, opsTouchOnly, + parseIpsecSnapshot, planFirewallRestore, planGreCreate, planGreDelete, planGreRestore, + planIpsecRestore, planWireguardRestore, } from "./entity-snapshots.js" import { fingerprintPayload, revisionItemCount } from "./config-revisions.js" @@ -161,4 +164,53 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js" assert.notEqual(p1, p3) } +{ + const desired = canonicalIpsecSnapshot({ + peers: [{ name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", comment: "MikrotikManager:ipsec", disabled: false }], + identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }], + modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }], + pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }], + nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }], + }) + // round-trip через payload ревизии + const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired))) + assert.equal(fingerprintPayload(restored), fingerprintPayload(desired)) + assert.equal(restored.identities.length, 1) + assert.equal(restored.identities[0]?.comment, "MikrotikManager:ipsec user=alice") + + const ops = planIpsecRestore(restored, { + peers: [ + { ...restored.peers[0]!, rosId: "*P1" }, + { name: "site-to-site", address: "203.0.113.7", exchangeMode: "ike2", passive: false, certificate: "", profile: "default", comment: "", disabled: false, rosId: "*P2" }, + ], + identities: [ + { ...restored.identities[0]!, secret: "(hidden)", rosId: "*I1" }, + { + peerName: "site-to-site", authMethod: "pre-shared-key", certificate: "", remoteCertificate: "", matchBy: "", + secret: "(hidden)", remoteId: "peer-b", modeConfig: "", generatePolicy: "", comment: "не managed", + disabled: false, rosId: "*I2", + }, + ], + modeConfigs: [ + { ...restored.modeConfigs[0]!, rosId: "*M1" }, + { name: "mc-ipsec-ghost", addressPool: "", address: "10.77.0.9", staticDns: "", comment: "MikrotikManager:ipsec клиент ghost", rosId: "*M2" }, + ], + pools: [{ ...restored.pools[0]!, rosId: "*PL1" }], + nat: [{ ...restored.nat[0]!, rosId: "*N1" }], + }) + + // лишние managed-объекты удаляются, чужие (site-to-site / «не managed») не трогаем + assert.ok(!ops.some((op) => op.path.includes("*P2")), "чужой peer не тронут") + assert.ok(!ops.some((op) => op.path.includes("*I2")), "чужая identity не тронута") + assert.ok(ops.some((op) => op.op === "delete" && op.path === "/ip/ipsec/mode-config/*M2"), "персональный mc лишнего клиента удалён") + const patchOp = ops.find((op) => op.op === "patch" && op.path === "/ip/ipsec/identity/*I1") + assert.ok(patchOp, "identity желаемого клиента патчится") + assert.ok(patchOp?.body.secret === undefined, "секрет (hidden) не перезаписываем") + assert.equal( + opsTouchOnly(ops, ["/ip/ipsec", "/ip/pool", "/ip/firewall/nat"]), + true, + "restore не выходит за пределы ipsec-объектов", + ) +} + console.log("entity-snapshots.test.ts: ok") diff --git a/backend/src/services/entity-snapshots.ts b/backend/src/services/entity-snapshots.ts index dab26e9..d3d724e 100644 --- a/backend/src/services/entity-snapshots.ts +++ b/backend/src/services/entity-snapshots.ts @@ -1,4 +1,5 @@ -/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE. */ +/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */ +import { isIpsecManagedComment } from "./ipsec-config.js" export type FirewallFamily = "ip" | "ip6" export type FirewallTable = "filter" | "nat" | "mangle" | "raw" @@ -161,6 +162,94 @@ export interface GreLiveAddr { address: string } +// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ──────────── + +export interface IpsecSnapshotPeer { + name: string + address: string + exchangeMode: string + passive: boolean + certificate: string + profile: string + comment: string + disabled: boolean +} + +export interface IpsecSnapshotIdentity { + peerName: string + authMethod: string + certificate: string + remoteCertificate: string + matchBy: string + secret: string + remoteId: string + modeConfig: string + generatePolicy: string + comment: string + disabled: boolean +} + +export interface IpsecSnapshotModeConfig { + name: string + addressPool: string + address: string + staticDns: string + comment: string +} + +export interface IpsecSnapshotPool { + name: string + ranges: string + comment: string +} + +export interface IpsecSnapshotPolicy { + srcAddress: string + dstAddress: string + proposal: string + comment: string +} + +export interface IpsecSnapshotNat { + chain: string + action: string + srcAddress: string + comment: string +} + +export interface IpsecSnapshot { + peers: IpsecSnapshotPeer[] + identities: IpsecSnapshotIdentity[] + modeConfigs: IpsecSnapshotModeConfig[] + pools: IpsecSnapshotPool[] + policies: IpsecSnapshotPolicy[] + nat: IpsecSnapshotNat[] +} + +export interface IpsecLivePeer extends IpsecSnapshotPeer { + rosId: string +} + +export interface IpsecLiveIdentity extends IpsecSnapshotIdentity { + rosId: string +} + +export interface IpsecLiveModeConfig extends IpsecSnapshotModeConfig { + rosId: string +} + +export interface IpsecLivePool extends IpsecSnapshotPool { + rosId: string +} + +export interface IpsecLivePolicy extends IpsecSnapshotPolicy { + rosId: string +} + +export interface IpsecLiveNat extends IpsecSnapshotNat { + rosId: string +} + function str(v: unknown): string { return String(v ?? "").trim() } @@ -508,6 +597,228 @@ export function planWireguardRestore( return ops } +// ── IPsec: canonical / parse / plan ───────────────────────────────────────── + +function canonicalIpsecPeerRaw(p: Partial): IpsecSnapshotPeer { + return { + name: str(p.name), + address: str(p.address), + exchangeMode: str(p.exchangeMode), + passive: Boolean(p.passive), + certificate: str(p.certificate), + profile: str(p.profile), + comment: str(p.comment), + disabled: Boolean(p.disabled), + } +} + +function canonicalIpsecIdentityRaw(i: Partial): IpsecSnapshotIdentity { + return { + peerName: str(i.peerName), + authMethod: str(i.authMethod), + certificate: str(i.certificate), + remoteCertificate: str(i.remoteCertificate), + matchBy: str(i.matchBy), + secret: str(i.secret), + remoteId: str(i.remoteId), + modeConfig: str(i.modeConfig), + generatePolicy: str(i.generatePolicy), + comment: str(i.comment), + disabled: Boolean(i.disabled), + } +} + +function canonicalIpsecModeConfigRaw(m: Partial): IpsecSnapshotModeConfig { + return { + name: str(m.name), + addressPool: str(m.addressPool), + address: str(m.address), + staticDns: str(m.staticDns), + comment: str(m.comment), + } +} + +export function canonicalIpsecSnapshot(input: { + peers?: Array> + identities?: Array> + modeConfigs?: Array> + pools?: Array> + policies?: Array> + nat?: Array> +}): IpsecSnapshot { + return { + peers: (input.peers ?? []).map(canonicalIpsecPeerRaw).filter((p) => p.name).sort((a, b) => a.name.localeCompare(b.name)), + identities: (input.identities ?? []) + .map(canonicalIpsecIdentityRaw) + .filter((i) => i.comment) + .sort((a, b) => a.comment.localeCompare(b.comment)), + modeConfigs: (input.modeConfigs ?? []) + .map(canonicalIpsecModeConfigRaw) + .filter((m) => m.name) + .sort((a, b) => a.name.localeCompare(b.name)), + pools: (input.pools ?? []) + .map((p) => ({ name: str(p.name), ranges: str(p.ranges), comment: str(p.comment) })) + .filter((p) => p.name) + .sort((a, b) => a.name.localeCompare(b.name)), + policies: (input.policies ?? []) + .map((p) => ({ srcAddress: str(p.srcAddress), dstAddress: str(p.dstAddress), proposal: str(p.proposal), comment: str(p.comment) })) + .filter((p) => p.dstAddress) + .sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)), + nat: (input.nat ?? []) + .map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) })) + .filter((n) => n.comment) + .sort((a, b) => a.comment.localeCompare(b.comment)), + } +} + +export function parseIpsecSnapshot(payload: unknown): IpsecSnapshot { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return canonicalIpsecSnapshot({}) + } + const o = payload as Record + return canonicalIpsecSnapshot({ + peers: Array.isArray(o.peers) ? o.peers as Array> : [], + identities: Array.isArray(o.identities) ? o.identities as Array> : [], + modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array> : [], + pools: Array.isArray(o.pools) ? o.pools as Array> : [], + policies: Array.isArray(o.policies) ? o.policies as Array> : [], + nat: Array.isArray(o.nat) ? o.nat as Array> : [], + }) +} + +function ipsecPeerBody(p: IpsecSnapshotPeer): Record { + return compactBody({ + name: p.name, + address: p.address, + "exchange-mode": p.exchangeMode, + passive: rosYesNo(p.passive), + certificate: p.certificate, + "send-cert": "always", + profile: p.profile, + comment: p.comment, + disabled: rosYesNo(p.disabled), + }) +} + +function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record { + return compactBody({ + peer: i.peerName, + "auth-method": i.authMethod, + certificate: i.certificate, + "remote-certificate": i.remoteCertificate, + "match-by": i.matchBy, + secret: isHiddenSecret(i.secret) ? undefined : i.secret, + "remote-id": i.remoteId, + "mode-config": i.modeConfig, + "generate-policy": i.generatePolicy, + comment: i.comment, + disabled: rosYesNo(i.disabled), + }) +} + +function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record { + return compactBody({ + name: m.name, + "address-pool": m.addressPool, + address: m.address, + "static-dns": m.staticDns, + comment: m.comment, + }) +} + +function ipsecPoolBody(p: IpsecSnapshotPool): Record { + return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment }) +} + +function ipsecNatBody(n: IpsecSnapshotNat): Record { + return compactBody({ + chain: n.chain, + action: n.action, + "src-address": n.srcAddress, + comment: n.comment, + }) +} + +/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем; + * чужие (не ipsec-managed) live-объекты не трогаем даже если их передали). */ +export function planIpsecRestore( + desiredInput: IpsecSnapshot, + current: { + peers: IpsecLivePeer[] + identities: IpsecLiveIdentity[] + modeConfigs: IpsecLiveModeConfig[] + pools: IpsecLivePool[] + nat: IpsecLiveNat[] + }, +): RosWriteOp[] { + const desired = canonicalIpsecSnapshot(desiredInput) + const ops: RosWriteOp[] = [] + const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment)) + const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment)) + const managedModeConfigs = current.modeConfigs.filter((m) => isIpsecManagedComment(m.comment)) + const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment)) + const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment)) + + for (const identity of managedIdentities) { + if (!desired.identities.some((i) => i.comment === identity.comment)) { + ops.push({ op: "delete", path: `/ip/ipsec/identity/${identity.rosId}` }) + } + } + for (const mc of managedModeConfigs) { + if (!desired.modeConfigs.some((m) => m.name === mc.name)) { + ops.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` }) + } + } + for (const peer of managedPeers) { + if (!desired.peers.some((p) => p.name === peer.name)) { + ops.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` }) + } + } + for (const pool of managedPools) { + if (!desired.pools.some((p) => p.name === pool.name)) { + ops.push({ op: "delete", path: `/ip/pool/${pool.rosId}` }) + } + } + for (const rule of managedNat) { + if (!desired.nat.some((n) => n.comment === rule.comment)) { + ops.push({ op: "delete", path: `/ip/firewall/nat/${rule.rosId}` }) + } + } + + for (const want of desired.peers) { + const live = managedPeers.find((p) => p.name === want.name) + const body = ipsecPeerBody(want) + if (!live) ops.push({ op: "put", path: "/ip/ipsec/peer", body }) + else ops.push({ op: "patch", path: `/ip/ipsec/peer/${live.rosId}`, body }) + } + for (const want of desired.modeConfigs) { + const live = managedModeConfigs.find((m) => m.name === want.name) + const body = ipsecModeConfigBody(want) + if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body }) + else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body }) + } + for (const want of desired.pools) { + const live = managedPools.find((p) => p.name === want.name) + const body = ipsecPoolBody(want) + if (!live) ops.push({ op: "put", path: "/ip/pool", body }) + else ops.push({ op: "patch", path: `/ip/pool/${live.rosId}`, body }) + } + for (const want of desired.identities) { + const live = managedIdentities.find((i) => i.comment === want.comment) + const body = ipsecIdentityBody(want) + if (!live) ops.push({ op: "put", path: "/ip/ipsec/identity", body }) + else ops.push({ op: "patch", path: `/ip/ipsec/identity/${live.rosId}`, body }) + } + for (const want of desired.nat) { + const live = managedNat.find((n) => n.comment === want.comment) + const body = ipsecNatBody(want) + if (!live) ops.push({ op: "put", path: "/ip/firewall/nat", body }) + else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body }) + } + + return ops +} + export function canonicalGreSnapshot(input: { tunnels?: Array> }): GreSnapshot { diff --git a/backend/src/services/ipsec-ca.ts b/backend/src/services/ipsec-ca.ts new file mode 100644 index 0000000..e475927 --- /dev/null +++ b/backend/src/services/ipsec-ca.ts @@ -0,0 +1,121 @@ +import type { MikrotikClient } from "./mikrotik.js" +import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js" + +type RosCertRow = Record + +const SIGN_POLL_TIMEOUT_MS = 90_000 +const SIGN_POLL_INTERVAL_MS = 1_000 + +function isIp(value: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim()) +} + +export async function findCertificate(client: MikrotikClient, name: string): Promise { + const certs = await client.getCertificates() + return certs.find((c) => String(c.name ?? "").trim() === name) +} + +/** Сертификат подписан: у него заполнен invalid-after. */ +export async function isCertificateSigned(client: MikrotikClient, name: string): Promise { + const cert = await findCertificate(client, name) + return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "") +} + +/** sign в RouterOS не мгновенный: ждём появления invalid-after. */ +export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (await isCertificateSigned(client, name)) return + await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS)) + } + throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`) +} + +/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */ +export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise { + const existing = await findCertificate(client, IPSEC_CA_CERT) + if (!existing) { + await client.addCertificate({ + name: IPSEC_CA_CERT, + "common-name": "MikrotikManager IPsec CA", + "key-size": "4096", + "key-usage": "key-cert-sign,crl-sign", + "days-valid": String(daysValid), + comment: ipsecManagedComment("CA"), + }) + } + if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) { + await client.signCertificate({ name: IPSEC_CA_CERT, daysValid }) + await waitCertificateSigned(client, IPSEC_CA_CERT) + } + return IPSEC_CA_CERT +} + +/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */ +export async function ensureServerCertificate( + client: MikrotikClient, + args: { serverEndpoint: string; caCertName: string; daysValid: number }, +): Promise { + const endpoint = args.serverEndpoint.trim() + const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}` + const existing = await findCertificate(client, IPSEC_SERVER_CERT) + if (!existing) { + await client.addCertificate({ + name: IPSEC_SERVER_CERT, + "common-name": endpoint, + "subject-alt-name": san, + "key-size": "2048", + "key-usage": "digital-signature,key-encipherment,tls-server", + "days-valid": String(args.daysValid), + comment: ipsecManagedComment("server"), + }) + } + if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) { + await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid }) + await waitCertificateSigned(client, IPSEC_SERVER_CERT) + } + return IPSEC_SERVER_CERT +} + +export interface IssuedClientCert { + certName: string + commonName: string + /** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */ + existed: boolean +} + +/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */ +export async function issueClientCertificate( + client: MikrotikClient, + args: { userName: string; caCertName: string; daysValid: number }, +): Promise { + const certName = clientCertName(args.userName) + const existing = await findCertificate(client, certName) + if (existing) { + return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true } + } + await client.addCertificate({ + name: certName, + "common-name": args.userName.trim(), + "key-size": "2048", + "key-usage": "digital-signature,key-encipherment,tls-client", + "days-valid": String(args.daysValid), + comment: ipsecManagedComment(`client ${args.userName.trim()}`), + }) + await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid }) + await waitCertificateSigned(client, certName) + return { certName, commonName: args.userName.trim(), existed: false } +} + +/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */ +export async function exportClientP12( + client: MikrotikClient, + userName: string, + passphrase: string, +): Promise<{ fileName: string; content: Buffer; certName: string }> { + const certName = clientCertName(userName) + const cert = await findCertificate(client, certName) + if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`) + const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase }) + return { fileName, content, certName } +} diff --git a/backend/src/services/ipsec-config.test.ts b/backend/src/services/ipsec-config.test.ts new file mode 100644 index 0000000..2e11e68 --- /dev/null +++ b/backend/src/services/ipsec-config.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict" +import { + IPSEC_MARKER, + buildClientInstructions, + buildSswanConfig, + clientCertName, + findFreePoolIp, + ipsecManagedComment, + ipsecSlug, + ipsecUserComment, + isIpsecManagedComment, + parseIpsecUserComment, + poolRangesFromCidr, + userModeConfigName, +} from "./ipsec-config.js" + +{ + assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper") + assert.equal(ipsecSlug("Иван"), "") + assert.equal(clientCertName("Alice Cooper"), "ipsec-user-alice-cooper") + assert.equal(userModeConfigName("Bob"), "mc-ipsec-bob") +} + +{ + assert.ok(isIpsecManagedComment(ipsecManagedComment("IKEv2 road-warrior"))) + assert.ok(isIpsecManagedComment(`${IPSEC_MARKER} user=alice`)) + assert.ok(!isIpsecManagedComment("какой-то чужой комментарий")) + assert.ok(!isIpsecManagedComment(undefined)) + assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`) + assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice") + assert.equal(parseIpsecUserComment("не managed"), null) + assert.equal(parseIpsecUserComment(null), null) +} + +{ + assert.equal(poolRangesFromCidr("10.77.0.0/24"), "10.77.0.2-10.77.0.254") + assert.equal(poolRangesFromCidr("10.77.0.1/24"), "10.77.0.2-10.77.0.254") + assert.equal(poolRangesFromCidr("192.168.7.0/28"), "192.168.7.2-192.168.7.14") + assert.equal(poolRangesFromCidr("10.77.0.0/31"), null, "/31 — нет адресов клиентам") + assert.equal(poolRangesFromCidr("10.77.0.0"), null) + assert.equal(poolRangesFromCidr("не-сидр"), null) +} + +{ + const range = "10.77.0.2-10.77.0.10" + assert.equal(findFreePoolIp(range, []), "10.77.0.2") + assert.equal(findFreePoolIp(range, ["10.77.0.2"]), "10.77.0.3") + assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3/32", "10.77.0.4"]), "10.77.0.5") + assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3", "10.77.0.4", "10.77.0.5", "10.77.0.6", "10.77.0.7", "10.77.0.8", "10.77.0.9", "10.77.0.10"]), null) + // одиночный адрес без диапазона + assert.equal(findFreePoolIp("10.77.0.7", []), "10.77.0.7") + assert.equal(findFreePoolIp("мусор", []), null) +} + +{ + const sswan = buildSswanConfig({ + name: "IKEv2 vpn.example.com", + serverEndpoint: "vpn.example.com", + serverId: "vpn.example.com", + p12B64: "cDEy", + uuid: "fix-me", + }) + const parsed = JSON.parse(sswan) as Record + assert.equal(parsed.version, 1) + assert.equal(parsed.type, "ikev2-cert") + const remote = parsed.remote as Record + const local = parsed.local as Record + assert.equal(remote.addr, "vpn.example.com") + assert.equal(remote.id, "vpn.example.com") + assert.equal(local.p12, "cDEy") +} + +{ + const text = buildClientInstructions({ + userName: "alice", + serverEndpoint: "vpn.example.com", + p12Filename: "cert_export_ipsec-user-alice.p12", + passphrase: "s3cret", + dns: "10.77.0.1", + }) + assert.ok(text.includes("alice")) + assert.ok(text.includes("vpn.example.com")) + assert.ok(text.includes("s3cret")) + assert.ok(text.includes("strongSwan")) +} + +console.log("ipsec-config.test.ts: ok") diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts new file mode 100644 index 0000000..2491fd6 --- /dev/null +++ b/backend/src/services/ipsec-config.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto" +import { PRODUCT_NAME } from "../managed-markers.js" + +// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ───────── + +export const IPSEC_MARKER = `${PRODUCT_NAME}:ipsec` + +export function ipsecManagedComment(label = ""): string { + return label ? `${IPSEC_MARKER} ${label}` : IPSEC_MARKER +} + +export function isIpsecManagedComment(comment: string | undefined | null): boolean { + return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER) +} + +/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */ +export function ipsecUserComment(name: string): string { + return `${IPSEC_MARKER} user=${name.trim()}` +} + +export function parseIpsecUserComment(comment: string | undefined | null): string | null { + if (!comment) return null + const m = /user=([^\s]+)/.exec(comment.trim()) + return m?.[1] ?? null +} + +// ── naming-конвенции управляемых объектов RouterOS ────────────────────────── + +export const IPSEC_CA_CERT = "ipsec-ca" +export const IPSEC_SERVER_CERT = "ipsec-server" +/** Общее имя peer / profile / proposal / пула / общего mode-config. */ +export const IPSEC_COMMON_NAME = "ipsec-vpn" + +export function ipsecSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function clientCertName(userName: string): string { + return `ipsec-user-${ipsecSlug(userName) || "client"}` +} + +export function userModeConfigName(userName: string): string { + return `mc-ipsec-${ipsecSlug(userName) || "client"}` +} + +// ── пул адресов клиентов ──────────────────────────────────────────────────── + +function ipToInt(b: Array): number { + return (((b[0]! << 24) | (b[1]! << 16) | (b[2]! << 8) | b[3]!) >>> 0) +} + +function intToIp(n: number): string { + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".") +} + +function parseIpv4(s: string): number | null { + const parts = s.trim().split(".") + if (parts.length !== 4) return null + const octets = parts.map((p) => Number(p)) + if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null + return ipToInt(octets) +} + +/** «10.77.0.0/24» → pool ranges «10.77.0.2-10.77.0.254» (шлюзы .1 и broadcast не раздаём). */ +export function poolRangesFromCidr(cidr: string): string | null { + const m = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(cidr.trim()) + if (!m) return null + const base = parseIpv4(m[1]!) + const prefix = Number(m[2]) + if (base == null || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return null + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0 + const network = (base & mask) >>> 0 + const size = 2 ** (32 - prefix) + const from = network + 2 + const to = network + size - 2 + if (to <= from) return null + return `${intToIp(from)}-${intToIp(to)}` +} + +export interface PoolIp { + ip: string + n: number +} + +/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e», берём первый диапазон из списка), исключая занятые. */ +export function findFreePoolIp(range: string, taken: Iterable): string | null { + const takenSet = new Set( + Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()), + ) + const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? "" + const [fromRaw, toRaw] = first.split("-") + const from = parseIpv4(fromRaw ?? "") + const to = parseIpv4(toRaw ?? fromRaw ?? "") + if (from == null) return null + const last = to ?? from + if (last < from) return null + const cap = Math.min(last, from + 65_534) + for (let n = from; n <= cap; n++) { + const ip = intToIp(n) + if (!takenSet.has(ip)) return ip + } + return null +} + +// ── клиентские конфиги ────────────────────────────────────────────────────── + +/** strongSwan (Android/iOS) .sswan-профиль с встроенным .p12. */ +export function buildSswanConfig(args: { + name: string + serverEndpoint: string + serverId: string + p12B64: string + uuid?: string +}): string { + return JSON.stringify( + { + version: 1, + uuid: args.uuid ?? randomUUID(), + name: args.name, + type: "ikev2-cert", + remote: { addr: args.serverEndpoint, id: args.serverId }, + local: { p12: args.p12B64 }, + "ike-proposal": "AES256-SHA256-MODP2048", + "esp-proposal": "AES256-SHA256-MODP2048", + }, + null, + 2, + ) +} + +/** Короткая текстовая инструкция по подключению (Windows/macOS/iOS/Android). */ +export function buildClientInstructions(args: { + userName: string + serverEndpoint: string + p12Filename: string + passphrase?: string + dns?: string +}): string { + const pass = args.passphrase ? `Пароль архива (при импорте): ${args.passphrase}\n` : "" + return [ + `IKEv2/IPsec VPN — клиент «${args.userName}»`, + `Сервер: ${args.serverEndpoint}${args.dns ? ` (DNS: ${args.dns})` : ""}`, + "", + `1. Скачайте и импортируйте сертификат ${args.p12Filename}.`, + pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.", + "2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»", + " (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).", + "3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),", + " аутентификация — сертификат; удалённый ID = CN серверного сертификата.", + "4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).", + "", + "Адрес выдаётся автоматически при подключении (mode-config).", + ].join("\n") +} diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts new file mode 100644 index 0000000..6c12ca2 --- /dev/null +++ b/backend/src/services/ipsec-live.ts @@ -0,0 +1,513 @@ +import { eq } from "drizzle-orm" +import { db } from "../db/index.js" +import { servers } from "../db/schema.js" +import { MikrotikClient } from "./mikrotik.js" +import type { + IpsecCertInfoDto, + IpsecClientDto, + IpsecListResponse, + IpsecModeConfigDto, + IpsecPeerDto, + IpsecPoolDto, + IpsecServerSummaryDto, +} from "@mmapp/contracts/ipsec" +import { + IPSEC_CA_CERT, + IPSEC_COMMON_NAME, + IPSEC_SERVER_CERT, + clientCertName, + isIpsecManagedComment, + parseIpsecUserComment, +} from "./ipsec-config.js" +import { + canonicalIpsecSnapshot, + type IpsecLiveIdentity, + type IpsecLiveModeConfig, + type IpsecLiveNat, + type IpsecLivePeer, + type IpsecLivePool, + type IpsecSnapshot, +} from "./entity-snapshots.js" + +type ServerRow = typeof servers.$inferSelect + +export interface RosIpsecPeer { + ".id"?: string + name?: string + address?: string + "exchange-mode"?: string + passive?: string + certificate?: string + profile?: string + disabled?: string + comment?: string +} + +export interface RosIpsecIdentity { + ".id"?: string + peer?: string + "auth-method"?: string + certificate?: string + "remote-certificate"?: string + "match-by"?: string + secret?: string + "remote-id"?: string + "mode-config"?: string + "generate-policy"?: string + disabled?: string + comment?: string +} + +export interface RosIpsecModeConfig { + ".id"?: string + name?: string + "address-pool"?: string + "address-prefix"?: string + address?: string + "split-dns"?: string + "static-dns"?: string + comment?: string +} + +export interface RosIpsecPool { + ".id"?: string + name?: string + ranges?: string + comment?: string +} + +export interface RosIpsecPolicy { + ".id"?: string + "src-address"?: string + "dst-address"?: string + proposal?: string + template?: string + comment?: string +} + +export interface RosFirewallNat { + ".id"?: string + chain?: string + action?: string + "src-address"?: string + comment?: string +} + +export interface RosIpsecActivePeer { + ".id"?: string + address?: string + "remote-id"?: string + identity?: string + established?: string +} + +type RosCertRow = Record + +function asBool(v: string | undefined): boolean { + return v === "true" || v === "yes" +} + +export function mapCertificate(c: RosCertRow): IpsecCertInfoDto { + const name = String(c.name ?? "") + const keyUsage = String(c["key-usage"] ?? "") + const isCa = keyUsage.includes("key-cert-sign") + const isUser = name.startsWith("ipsec-user-") + return { + name, + commonName: c["common-name"] || undefined, + keySize: c["key-size"] || undefined, + fingerprint: c.fingerprint || undefined, + expiresAt: c["invalid-after"] || undefined, + trusted: asBool(c.trusted), + hasPrivateKey: asBool(c["private-key"]), + role: isCa ? "ca" : name === IPSEC_SERVER_CERT ? "server" : isUser ? "client" : "other", + managed: isCa && name === IPSEC_CA_CERT + || name === IPSEC_SERVER_CERT + || isUser + || isIpsecManagedComment(c.comment), + } +} + +function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto { + return { + id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`, + rosId: String(p[".id"] ?? p.name ?? "peer"), + serverId: String(server.id), + serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), + name: (p.name ?? "").trim(), + address: p.address || undefined, + exchangeMode: p["exchange-mode"] || undefined, + passive: asBool(p.passive), + certificate: p.certificate || undefined, + profile: p.profile || undefined, + disabled: asBool(p.disabled), + comment: p.comment || undefined, + managed: isIpsecManagedComment(p.comment), + } +} + +function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto { + return { + id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`, + rosId: String(m[".id"] ?? m.name ?? "mc"), + serverId: String(server.id), + name: (m.name ?? "").trim(), + addressPool: m["address-pool"] || m["address-prefix"] || undefined, + address: m.address || undefined, + splitDns: m["split-dns"] || undefined, + staticDns: m["static-dns"] || undefined, + comment: m.comment || undefined, + managed: isIpsecManagedComment(m.comment), + } +} + +function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto { + return { + id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`, + rosId: String(p[".id"] ?? p.name ?? "pool"), + serverId: String(server.id), + name: (p.name ?? "").trim(), + ranges: (p.ranges ?? "").trim(), + comment: p.comment || undefined, + managed: isIpsecManagedComment(p.comment), + } +} + +export interface IpsecServerState { + server: ServerRow + client: MikrotikClient + peers: RosIpsecPeer[] + identities: RosIpsecIdentity[] + modeConfigs: RosIpsecModeConfig[] + pools: RosIpsecPool[] + policies: RosIpsecPolicy[] + nat: RosFirewallNat[] + active: RosIpsecActivePeer[] + certs: RosCertRow[] +} + +export async function fetchIpsecState(server: ServerRow): Promise { + const client = MikrotikClient.fromServer(server) + const empty = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) + const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([ + client.get("/ip/ipsec/peer").then((v) => empty(v)).catch(() => [] as RosIpsecPeer[]), + client.get("/ip/ipsec/identity").then((v) => empty(v)).catch(() => [] as RosIpsecIdentity[]), + client.get("/ip/ipsec/mode-config").then((v) => empty(v)).catch(() => [] as RosIpsecModeConfig[]), + client.get("/ip/pool").then((v) => empty(v)).catch(() => [] as RosIpsecPool[]), + client.get("/ip/ipsec/policy").then((v) => empty(v)).catch(() => [] as RosIpsecPolicy[]), + client.get("/ip/firewall/nat").then((v) => empty(v)).catch(() => [] as RosFirewallNat[]), + client.get("/ip/ipsec/active-peers").then((v) => empty(v)).catch(() => [] as RosIpsecActivePeer[]), + client.getCertificates().catch(() => [] as RosCertRow[]), + ]) + return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs } +} + +/** Клиенты = managed-идентичности (созданы менеджером). */ +export function mapClients(state: IpsecServerState): IpsecClientDto[] { + const server = state.server + const certByCommonName = new Map() + for (const c of state.certs) { + const cn = String(c["common-name"] ?? "").trim() + if (cn) certByCommonName.set(cn, c) + } + const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m])) + const activeByRemote = new Map() + for (const a of state.active) { + const rid = String(a["remote-id"] ?? "").trim() + if (rid) activeByRemote.set(rid, a) + } + + return state.identities + .filter((i) => isIpsecManagedComment(i.comment)) + .map((i): IpsecClientDto => { + const comment = i.comment ?? "" + const name = parseIpsecUserComment(comment) ?? (comment || "client") + const psk = (i["auth-method"] ?? "") === "pre-shared-key" + const certName = (i["remote-certificate"] ?? "").trim() + const cn = certName + ? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "") + : "" + const mcName = (i["mode-config"] ?? "").trim() + const mc = mcByName.get(mcName) + const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-") + ? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined + : undefined + const active = (cn ? activeByRemote.get(cn) : undefined) + ?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined) + return { + id: `${server.id}:${String(i[".id"] ?? "identity")}`, + rosId: String(i[".id"] ?? "identity"), + serverId: String(server.id), + serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), + name, + authMethod: psk ? "pre-shared-key" : "certificate", + certificateName: certName || undefined, + commonName: cn || undefined, + remoteId: (i["remote-id"] ?? "").trim() || undefined, + staticIp, + modeConfigName: mcName || undefined, + peerName: (i.peer ?? "").trim() || undefined, + online: Boolean(active), + activeAddress: active?.address || undefined, + activeSince: active?.established || undefined, + disabled: asBool(i.disabled), + comment: comment || undefined, + managed: true, + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto { + const server = state.server + const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment)) + ?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) + const sharedMc = state.modeConfigs.find( + (m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")), + ) + const pool = sharedMc + ? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim()) + ?? state.pools.find((p) => isIpsecManagedComment(p.comment)) + : undefined + const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) + const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) + const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat") + return { + serverId: String(server.id), + serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), + serverCountry: server.country ?? undefined, + initialized: Boolean(managedPeer && caCert && serverCert), + serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined, + peer: managedPeer ? mapPeer(server, managedPeer) : undefined, + pool: pool ? mapPool(server, pool) : undefined, + sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined, + caCert: caCert ? mapCertificate(caCert) : undefined, + serverCert: serverCert ? mapCertificate(serverCert) : undefined, + natRuleManaged, + clientsTotal: clients.length, + clientsOnline: clients.filter((c) => c.online).length, + certs: state.certs + .filter((c) => { + const n = String(c.name ?? "") + return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-") + }) + .map(mapCertificate), + } +} + +export async function captureIpsecSnapshot(server: ServerRow): Promise { + const state = await fetchIpsecState(server) + return canonicalIpsecSnapshot({ + peers: state.peers + .filter((p) => isIpsecManagedComment(p.comment)) + .map((p) => ({ + name: (p.name ?? "").trim(), + address: (p.address ?? "").trim(), + exchangeMode: (p["exchange-mode"] ?? "").trim(), + passive: asBool(p.passive), + certificate: (p.certificate ?? "").trim(), + profile: (p.profile ?? "").trim(), + comment: (p.comment ?? "").trim(), + disabled: asBool(p.disabled), + })), + identities: state.identities + .filter((i) => isIpsecManagedComment(i.comment)) + .map((i) => ({ + peerName: (i.peer ?? "").trim(), + authMethod: (i["auth-method"] ?? "").trim(), + certificate: (i.certificate ?? "").trim(), + remoteCertificate: (i["remote-certificate"] ?? "").trim(), + matchBy: (i["match-by"] ?? "").trim(), + secret: (i.secret ?? "").trim(), + remoteId: (i["remote-id"] ?? "").trim(), + modeConfig: (i["mode-config"] ?? "").trim(), + generatePolicy: (i["generate-policy"] ?? "").trim(), + comment: (i.comment ?? "").trim(), + disabled: asBool(i.disabled), + })), + modeConfigs: state.modeConfigs + .filter((m) => isIpsecManagedComment(m.comment)) + .map((m) => ({ + name: (m.name ?? "").trim(), + addressPool: (m["address-pool"] ?? "").trim(), + address: (m.address ?? m["address-prefix"] ?? "").trim(), + staticDns: (m["static-dns"] ?? "").trim(), + comment: (m.comment ?? "").trim(), + })), + pools: state.pools + .filter((p) => isIpsecManagedComment(p.comment)) + .map((p) => ({ + name: (p.name ?? "").trim(), + ranges: (p.ranges ?? "").trim(), + comment: (p.comment ?? "").trim(), + })), + policies: state.policies + .filter((p) => isIpsecManagedComment(p.comment)) + .map((p) => ({ + srcAddress: (p["src-address"] ?? "").trim(), + dstAddress: (p["dst-address"] ?? "").trim(), + proposal: (p.proposal ?? "").trim(), + comment: (p.comment ?? "").trim(), + })), + nat: state.nat + .filter((n) => isIpsecManagedComment(n.comment)) + .map((n) => ({ + chain: (n.chain ?? "").trim(), + action: (n.action ?? "").trim(), + srcAddress: (n["src-address"] ?? "").trim(), + comment: (n.comment ?? "").trim(), + })), + }) +} + +export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ + client: MikrotikClient + peers: IpsecLivePeer[] + identities: IpsecLiveIdentity[] + modeConfigs: IpsecLiveModeConfig[] + pools: IpsecLivePool[] + nat: IpsecLiveNat[] + snapshot: IpsecSnapshot +}> { + const state = await fetchIpsecState(server) + return { + client: state.client, + peers: state.peers + .filter((p) => isIpsecManagedComment(p.comment)) + .map((p) => ({ + rosId: String(p[".id"] ?? ""), + name: (p.name ?? "").trim(), + address: (p.address ?? "").trim(), + exchangeMode: (p["exchange-mode"] ?? "").trim(), + passive: asBool(p.passive), + certificate: (p.certificate ?? "").trim(), + profile: (p.profile ?? "").trim(), + comment: (p.comment ?? "").trim(), + disabled: asBool(p.disabled), + })), + identities: state.identities + .filter((i) => isIpsecManagedComment(i.comment)) + .map((i) => ({ + rosId: String(i[".id"] ?? ""), + peerName: (i.peer ?? "").trim(), + authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const, + certificate: (i.certificate ?? "").trim(), + remoteCertificate: (i["remote-certificate"] ?? "").trim(), + matchBy: (i["match-by"] ?? "").trim(), + secret: (i.secret ?? "").trim(), + remoteId: (i["remote-id"] ?? "").trim(), + modeConfig: (i["mode-config"] ?? "").trim(), + generatePolicy: (i["generate-policy"] ?? "").trim(), + comment: (i.comment ?? "").trim(), + disabled: asBool(i.disabled), + })), + modeConfigs: state.modeConfigs + .filter((m) => isIpsecManagedComment(m.comment)) + .map((m) => ({ + rosId: String(m[".id"] ?? ""), + name: (m.name ?? "").trim(), + addressPool: (m["address-pool"] ?? "").trim(), + address: (m.address ?? m["address-prefix"] ?? "").trim(), + staticDns: (m["static-dns"] ?? "").trim(), + comment: (m.comment ?? "").trim(), + })), + pools: state.pools + .filter((p) => isIpsecManagedComment(p.comment)) + .map((p) => ({ + rosId: String(p[".id"] ?? ""), + name: (p.name ?? "").trim(), + ranges: (p.ranges ?? "").trim(), + comment: (p.comment ?? "").trim(), + })), + nat: state.nat + .filter((n) => isIpsecManagedComment(n.comment)) + .map((n) => ({ + rosId: String(n[".id"] ?? ""), + chain: (n.chain ?? "").trim(), + action: (n.action ?? "").trim(), + srcAddress: (n["src-address"] ?? "").trim(), + comment: (n.comment ?? "").trim(), + })), + snapshot: await captureIpsecSnapshot(server), + } +} + +export type IpsecListResult = IpsecListResponse + +export async function listIpsec(opts?: { serverId?: string }): Promise { + let serverRows: ServerRow[] + if (opts?.serverId) { + const id = Number.parseInt(String(opts.serverId), 10) + if (!Number.isFinite(id)) { + return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] } + } + const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] + serverRows = row ? [row] : [] + } else { + serverRows = await db.select().from(servers).where(eq(servers.enabled, true)) + } + + const failures: IpsecListResult["failures"] = [] + const summaries: IpsecServerSummaryDto[] = [] + const clients: IpsecClientDto[] = [] + await Promise.all( + serverRows.map(async (server) => { + try { + const state = await fetchIpsecState(server) + const serverClients = mapClients(state) + summaries.push(mapServerSummary(state, serverClients)) + clients.push(...serverClients) + } catch (e) { + failures.push({ + serverId: String(server.id), + serverName: server.name ?? undefined, + error: e instanceof Error ? e.message : String(e), + }) + } + }), + ) + summaries.sort((a, b) => a.serverName.localeCompare(b.serverName)) + clients.sort((a, b) => a.name.localeCompare(b.name)) + return { servers: summaries, clients, failures } +} + +export async function countIpsecClients(): Promise { + try { + const result = await Promise.race([ + listIpsec(), + new Promise((resolve) => setTimeout(() => resolve(null), 8_000)), + ]) + if (!result) return 0 + return result.clients.length + } catch { + return 0 + } +} + +export async function getEnabledIpsecServerById(serverId: string | number) { + const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10) + if (!Number.isFinite(id)) return null + return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null +} + +/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */ +export async function listIpsecClientsForCatalog(serverId: number): Promise<{ + clients: IpsecClientDto[] + error?: string +}> { + const row = await getEnabledIpsecServerById(serverId) + if (!row) return { clients: [], error: "Сервер не найден" } + try { + const state = await Promise.race([ + fetchIpsecState(row), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Таймаут RouterOS")), 5_000) + }), + ]) + return { clients: mapClients(state) } + } catch (e) { + return { clients: [], error: e instanceof Error ? e.message : String(e) } + } +} + +export { clientCertName } diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts new file mode 100644 index 0000000..1c327f9 --- /dev/null +++ b/backend/src/services/ipsec-ros.ts @@ -0,0 +1,214 @@ +import type { MikrotikClient } from "./mikrotik.js" +import { ipsecManagedComment } from "./ipsec-config.js" + +export function toRosBody(obj: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (v == null) continue + const s = String(v) + if (s === "") continue + out[k] = s + } + return out +} + +type RosRow = Record + +async function listByPath(client: MikrotikClient, path: string): Promise { + const raw = await client.get(path).catch(() => []) + return Array.isArray(raw) ? (raw as RosRow[]) : [] +} + +/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */ +export async function putOrPatchByName( + client: MikrotikClient, + path: string, + name: string, + body: Record, +): Promise<"created" | "patched"> { + const rows = await listByPath(client, path) + const existing = rows.find((r) => String(r.name ?? "").trim() === name) + if (existing?.[".id"]) { + await client.patch(`${path}/${encodeURIComponent(existing[".id"])}`, body) + return "patched" + } + await client.put(path, body) + return "created" +} + +async function findRosId(client: MikrotikClient, path: string, name: string): Promise { + const rows = await listByPath(client, path) + return rows.find((r) => String(r.name ?? "").trim() === name)?.[".id"] ?? null +} + +// ── инициализация IKEv2-сервера ───────────────────────────────────────────── + +export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise { + await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({ + name, + "hash-algorithm": "sha256", + "enc-algorithm": "aes-256,aes-192,aes-128", + "dh-group": "modp2048,modp1536,modp1024", + "nat-traversal": "yes", + comment, + })) +} + +export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise { + await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({ + name, + "auth-algorithms": "sha256,sha1", + "enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc", + "pfs-group": "modp2048", + comment, + })) +} + +export async function ensureIpsecPool(client: MikrotikClient, name: string, ranges: string, comment: string): Promise { + await putOrPatchByName(client, "/ip/pool", name, toRosBody({ name, ranges, comment })) +} + +/** Общий mode-config: адрес клиентам из пула. */ +export async function ensureSharedModeConfig( + client: MikrotikClient, + name: string, + poolName: string, + dns: string | undefined, + comment: string, +): Promise { + await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ + name, + "address-pool": poolName, + "static-dns": dns, + comment, + })) +} + +export async function ensurePolicyTemplate( + client: MikrotikClient, + dstCidr: string, + proposalName: string, + comment: string, +): Promise { + const rows = await listByPath(client, "/ip/ipsec/policy") + const managed = rows.find((r) => (r.comment ?? "").trim() === comment && (r.template === "true" || r.template === "yes")) + if (managed?.[".id"]) { + await client.patch(`/ip/ipsec/policy/${encodeURIComponent(managed[".id"])}`, toRosBody({ + "src-address": "0.0.0.0/0", + "dst-address": dstCidr, + proposal: proposalName, + comment, + })) + return + } + await client.put("/ip/ipsec/policy", toRosBody({ + "src-address": "0.0.0.0/0", + "dst-address": dstCidr, + proposal: proposalName, + template: "yes", + comment, + })) +} + +export async function ensureIpsecPeer( + client: MikrotikClient, + name: string, + serverCertName: string, + profileName: string, + comment: string, +): Promise { + await putOrPatchByName(client, "/ip/ipsec/peer", name, toRosBody({ + name, + address: "0.0.0.0/0", + "exchange-mode": "ike2", + passive: "yes", + certificate: serverCertName, + "send-cert": "always", + profile: profileName, + comment, + })) +} + +/** Managed srcnat masquerade: интернет клиентам VPN. */ +export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise { + const rows = await listByPath(client, "/ip/firewall/nat") + const existing = rows.find( + (r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat", + ) + if (existing?.[".id"]) return + await client.put("/ip/firewall/nat", toRosBody({ + chain: "srcnat", + action: "masquerade", + "src-address": poolCidr, + "out-interface-list": "WAN", + comment, + })) +} + +// ── клиенты ───────────────────────────────────────────────────────────────── + +export async function putUserModeConfig( + client: MikrotikClient, + name: string, + staticIp: string, + comment: string, +): Promise { + await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ + name, + address: staticIp, + comment, + })) +} + +export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise { + const id = await findRosId(client, "/ip/ipsec/mode-config", name) + if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`) +} + +export interface IdentityFields { + peerName: string + modeConfig: string + comment: string + authMethod: "certificate" | "pre-shared-key" + /** cert: серверный сертификат (router представляет его клиенту). */ + certificate?: string + /** cert: строгий мэтч конкретного клиента по его сертификату. */ + remoteCertificate?: string + /** psk. */ + secret?: string + remoteId?: string +} + +export function identityRosBody(f: IdentityFields): Record { + return toRosBody({ + peer: f.peerName, + "auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key", + certificate: f.certificate, + "remote-certificate": f.remoteCertificate, + "match-by": f.authMethod === "certificate" ? "certificate" : undefined, + secret: f.secret, + "remote-id": f.remoteId, + "mode-config": f.modeConfig, + "generate-policy": "port-strict", + comment: f.comment, + }) +} + +export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise { + await client.put("/ip/ipsec/identity", identityRosBody(fields)) +} + +export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record): Promise { + await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body) +} + +export async function deleteIdentity(client: MikrotikClient, rosId: string): Promise { + await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`) +} + +/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */ +export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise { + await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig })) +} + +export { listByPath, findRosId, ipsecManagedComment } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 271ef9c..dc8a793 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -267,6 +267,57 @@ function rosDelete( }) } +/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */ +function rosDownload( + params: MikrotikConnectParams, + path: string, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const basePath = params.apiPath ?? "/rest" + const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") + + const options: https.RequestOptions = { + hostname: params.host, + port: params.port, + path: basePath + path, + method: "GET", + headers: { Authorization: authHeader }, + rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, + } + + const lib = params.useSsl ? https : http + + const req = lib.request(options, (res) => { + const chunks: Buffer[] = [] + res.on("data", (chunk: Buffer) => { chunks.push(chunk) }) + res.on("end", () => { + const buf = Buffer.concat(chunks) + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200))) + return + } + const contentType = String(res.headers["content-type"] ?? "") + if (contentType.includes("application/json")) { + reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`)) + return + } + resolve(buf) + }) + }) + + const timer = setTimeout(() => { + req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) + }, timeoutMs) + req.on("close", () => clearTimeout(timer)) + req.on("error", (err) => { + clearTimeout(timer) + reject(err) + }) + req.end() + }) +} + function rosPatch( params: MikrotikConnectParams, path: string, @@ -621,6 +672,92 @@ export class MikrotikClient { } } + /** Скачивание содержимого файла RouterOS (GET /rest/file/, бинарно). */ + async downloadFile(fileName: string, timeoutMs = 30_000): Promise { + const normalized = routerFileBasename(fileName) + const candidates = [normalized, `flash/${normalized}`] + let lastError: unknown + for (const name of candidates) { + try { + return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error + ? lastError + : new Error(`Не удалось скачать файл ${normalized} с RouterOS`) + } + + /** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */ + async addCertificate(body: Record, timeoutMs = 30_000): Promise { + return this.post("/certificate/add", body, timeoutMs) + } + + /** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */ + async signCertificate(params: { + name: string + ca?: string + daysValid?: number + }, timeoutMs = 120_000): Promise { + const body: Record = { name: params.name } + if (params.ca) body.ca = params.ca + if (params.daysValid != null) body["days-valid"] = String(params.daysValid) + try { + return await this.post("/certificate/sign", body, timeoutMs) + } catch (e) { + // Некоторые версии REST принимают цель подписи только как .id. + const certs = await this.getCertificates() + const row = certs.find((c) => String(c.name ?? "") === params.name) + const id = row?.[".id"] + if (!id) throw e + return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs) + } + } + + /** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */ + async exportCertificate(params: { + name: string + type: "pkcs12" | "pem" + passphrase?: string + }, timeoutMs = 60_000): Promise { + const body: Record = { name: params.name, type: params.type } + if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim() + let raw: unknown + try { + raw = await this.post("/certificate/export-certificate", body, timeoutMs) + } catch (e) { + const certs = await this.getCertificates() + const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"] + if (!id) throw e + raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs) + } + void raw + // RouterOS создаёт cert_export_.p12 либо .p12 — ищем по списку файлов. + const ext = params.type === "pkcs12" ? "p12" : "crt" + const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`] + const files = await this.listFiles() + const hit = files.find((f) => wanted.includes(f.name)) + ?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name)) + if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`) + return hit.name + } + + /** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */ + async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> { + const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase }) + const content = await this.downloadFile(fileName) + return { fileName, content } + } + + async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise { + const certs = await this.getCertificates() + const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId) + const id = row?.[".id"] + if (!id) return + await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs) + } + private async patchIpService(serviceName: string, body: Record): Promise { const pathByName = `/ip/service/${encodeURIComponent(serviceName)}` try { diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index d54ef56..ff7caa3 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -37,6 +37,7 @@ import { BoxIcon, BadgeCheckIcon, UsersIcon, + LockIcon, } from "lucide-react" import { useDataSource } from "@/lib/data-source" import { useEvoBGP } from "@/lib/evobgp-context" @@ -78,6 +79,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [ { title: "Рекурсивные маршруты", url: "/recursive-routes", icon: }, { title: "Firewall", url: "/firewall", icon: }, { title: "WireGuard", url: "/wireguard", icon: }, + { title: "IPsec / IKEv2", url: "/ipsec", icon: }, { title: "GRE-туннели", url: "/gre", icon: }, { title: "VXLAN", url: "/vxlan", icon: }, { title: "Контейнеры", url: "/containers", icon: }, @@ -110,6 +112,7 @@ type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number certificates?: number wireguard?: number + ipsec?: number users?: number bgpSessions?: number vxlan?: number @@ -179,6 +182,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0) if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0) if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0) + if (url === "/ipsec") return formatSidebarBadgeCount(liveCounts.ipsec ?? 0) if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0) if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0) if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0) diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 69d5c76..2c8ee54 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -8,7 +8,7 @@ import { GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon, ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon, HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon, - TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, + TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon, } from "lucide-react" // ─── Command item definition ────────────────────────────────────────────────── @@ -42,6 +42,7 @@ const ALL_ITEMS: CommandItem[] = [ { id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: , keywords: ["recursive","route","static","маршруты"] }, { id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: , keywords: ["rules","правила","брандмауэр","acl"] }, { id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: , keywords: ["vpn","tunnel","туннель","wg"] }, + { id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: , keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] }, { id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: , keywords: ["gre","ipsec","tunnel","туннель"] }, { id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: , keywords: ["overlay","vni","vtep","l2"] }, { id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: , keywords: ["docker","container","образ","image"] }, diff --git a/components/data-grids/users-expanded-detail.tsx b/components/data-grids/users-expanded-detail.tsx index 9c910aa..7e0ab19 100644 --- a/components/data-grids/users-expanded-detail.tsx +++ b/components/data-grids/users-expanded-detail.tsx @@ -12,12 +12,13 @@ import { type AppUser, type InterfaceType, } from "@/lib/users" -import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react" +import { CableIcon, KeyRoundIcon, LockIcon, NetworkIcon, ShieldIcon } from "lucide-react" const TYPE_VARIANT: Record = { ether: "outline", gre: "info-light", wg: "success-light", + ipsec: "info-light", other: "secondary", } @@ -25,6 +26,7 @@ const TYPE_ICON: Record void + bundle: IpsecCertBundle | null + busy?: boolean + /** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */ + onReexport?: (passphrase: string) => void | Promise +}) { + const [passphrase, setPassphrase] = useState("") + + useEffect(() => { + if (!open) return + const initial = bundle?.passphrase ?? "" + queueMicrotask(() => setPassphrase(initial)) + }, [open, bundle]) + + const canDownload = useMemo(() => Boolean(bundle && passphrase.trim().length >= 4), [bundle, passphrase]) + + return ( + + + + Сертификат клиента {bundle ? `«${bundle.user}»` : ""} + + .p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS) + + + +
+ {!bundle ? ( +

+ Бандл сертификата пуст — перезапустите экспорт с новой парольной фразой. +

+ ) : ( + <> +
+ Пароль архива .p12 + +
+ setPassphrase(e.target.value)} + /> + +
+
+ {bundle.serverEndpoint ? ( +

+ Сервер: {bundle.serverEndpoint} +

+ ) : null} +
+ +
+ Файлы + + {bundle.sswanContent && bundle.sswanFilename ? ( + + ) : null} + {bundle.instructions ? ( + + ) : null} +
+ + {bundle.instructions ? ( +
+                  {bundle.instructions}
+                
+ ) : null} + + )} +
+ + + }> + Закрыть + + +
+
+ ) +} + +export { IpsecCertSheet, downloadText, downloadB64 } diff --git a/components/ipsec/ipsec-init-sheet.tsx b/components/ipsec/ipsec-init-sheet.tsx new file mode 100644 index 0000000..efd8fc4 --- /dev/null +++ b/components/ipsec/ipsec-init-sheet.tsx @@ -0,0 +1,162 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, + SheetDescription, SheetFooter, SheetClose, +} from "@/components/ui/sheet" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import { InfoIcon } from "lucide-react" + +export type IpsecInitFormState = { + serverId: string + serverEndpoint: string + poolCidr: string + dns: string + createNatRule: boolean +} + +export const defaultIpsecInitForm = (): IpsecInitFormState => ({ + serverId: "", + serverEndpoint: "", + poolCidr: "10.77.0.0/24", + dns: "", + createNatRule: true, +}) + +type ServerOption = { id: string; name: string; host: string } + +function IpsecInitSheet({ + open, + onOpenChange, + servers, + busy, + defaultServerId, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + servers: ServerOption[] + busy?: boolean + defaultServerId?: string + onSubmit: (form: IpsecInitFormState) => void | Promise +}) { + const [form, setForm] = useState(defaultIpsecInitForm) + const set = (k: K, v: IpsecInitFormState[K]) => + setForm((f) => ({ ...f, [k]: v })) + + useEffect(() => { + if (!open) return + const server = servers.find((s) => s.id === (defaultServerId ?? "")) + queueMicrotask(() => setForm({ + ...defaultIpsecInitForm(), + serverId: defaultServerId ?? "", + serverEndpoint: server?.host ?? "", + })) + }, [open, defaultServerId, servers]) + + const canSubmit = useMemo(() => { + return Boolean( + form.serverId && + form.serverEndpoint.trim() && + /^(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[\w.-]+\.[a-z]{2,})$/i.test(form.serverEndpoint.trim()) && + /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()), + ) + }, [form]) + + return ( + + + + Инициализация IKEv2-сервера + + CA + серверный сертификат, peer (ike2/passive), пул адресов и mode-config + + + +
+ + + Выпуск CA и сертификатов занимает до минуты + + Повторный запуск безопасен: существующие managed-объекты обновятся, сертификаты + не перевыпускаются. + + + +
+ Основные + + + + + set("serverEndpoint", e.target.value)} + /> + + + set("poolCidr", e.target.value)} + /> + + + set("dns", e.target.value)} + /> + +
+
+

Интернет клиентам (NAT)

+

+ Managed srcnat masquerade для подсети клиентов (out-interface-list WAN) +

+
+ set("createNatRule", v)} /> +
+
+
+ + + }> + Отмена + + + +
+
+ ) +} + +export { IpsecInitSheet } diff --git a/components/ipsec/ipsec-server-grid.tsx b/components/ipsec/ipsec-server-grid.tsx new file mode 100644 index 0000000..b264417 --- /dev/null +++ b/components/ipsec/ipsec-server-grid.tsx @@ -0,0 +1,99 @@ +"use client" + +import type { IpsecServerSummaryDto } from "@mmapp/contracts/ipsec" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { + GlobeIcon, + NetworkIcon, + ShieldCheckIcon, + Trash2Icon, +} from "lucide-react" + +function Row({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value || "—"} + +
+ ) +} + +export interface IpsecServerGridProps { + servers: IpsecServerSummaryDto[] + onInit?: (row: IpsecServerSummaryDto) => void + onRemove?: (row: IpsecServerSummaryDto) => void +} + +function IpsecServerGrid({ servers, onInit, onRemove }: IpsecServerGridProps) { + return ( +
+ {servers.map((s) => ( +
+
+
+

{s.serverName}

+

+ {s.serverEndpoint ?? "не инициализирован"} +

+
+ {s.initialized ? ( + + + IKEv2 + + ) : ( + нет + )} +
+ +
+ + + + + + +
+ +
+ + {s.initialized && onRemove ? ( + + ) : null} +
+
+ ))} + {servers.length === 0 ? ( +
+ + Серверы не найдены +
+ ) : null} +
+ ) +} + +export { IpsecServerGrid } diff --git a/components/ipsec/ipsec-user-sheet.tsx b/components/ipsec/ipsec-user-sheet.tsx new file mode 100644 index 0000000..bd90ea7 --- /dev/null +++ b/components/ipsec/ipsec-user-sheet.tsx @@ -0,0 +1,226 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { IpsecClientDto } from "@mmapp/contracts/ipsec" +import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, + SheetDescription, SheetFooter, SheetClose, +} from "@/components/ui/sheet" + +export type IpsecAuthKind = "certificate" | "pre-shared-key" + +export type IpsecUserFormState = { + serverId: string + name: string + authMethod: IpsecAuthKind + psk: string + remoteId: string + useStaticIp: boolean + staticIp: string + passphrase: string +} + +export const defaultIpsecUserForm = (): IpsecUserFormState => ({ + serverId: "", + name: "", + authMethod: "certificate", + psk: "", + remoteId: "", + useStaticIp: false, + staticIp: "", + passphrase: "", +}) + +type ServerOption = { id: string; name: string; host: string } + +function IpsecUserSheet({ + open, + onOpenChange, + servers, + busy, + defaultServerId, + editing, + freeIpHint, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + servers: ServerOption[] + busy?: boolean + defaultServerId?: string + /** Режим редактирования: сервер и метод аутентификации не меняются. */ + editing?: IpsecClientDto | null + freeIpHint?: string + onSubmit: (form: IpsecUserFormState) => void | Promise +}) { + const [form, setForm] = useState(defaultIpsecUserForm) + const set = (k: K, v: IpsecUserFormState[K]) => + setForm((f) => ({ ...f, [k]: v })) + + useEffect(() => { + if (!open) return + if (editing) { + const edit = editing + queueMicrotask(() => setForm({ + serverId: edit.serverId, + name: edit.name, + authMethod: edit.authMethod, + psk: "", + remoteId: edit.remoteId ?? "", + useStaticIp: Boolean(edit.staticIp), + staticIp: edit.staticIp ?? "", + passphrase: "", + })) + return + } + queueMicrotask(() => setForm({ ...defaultIpsecUserForm(), serverId: defaultServerId ?? "" })) + }, [open, defaultServerId, editing]) + + const canSubmit = useMemo(() => { + if (!editing && !form.serverId) return false + if (!form.name.trim()) return false + if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false + if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false + return true + }, [form, editing]) + + return ( + + + + {editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"} + + {editing + ? "Имя, статический IP и secret (для PSK)" + : "Identity на роутере; для сертификата — выпуск .p12 для авторизации"} + + + +
+
+ Основные + {!editing ? ( + + + + ) : null} + + set("name", e.target.value)} + /> + + {!editing ? ( + +
+ + +
+
+ ) : null} + {form.authMethod === "pre-shared-key" ? ( + <> + + set("psk", e.target.value)} + /> + + {!editing ? ( + + set("remoteId", e.target.value)} + /> + + ) : null} + + ) : ( + !editing ? ( + + set("passphrase", e.target.value)} + /> + + ) : null + )} +
+ +
+ IP-адрес +
+
+

Статический IP

+

+ {freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"} +

+
+ set("useStaticIp", v)} /> +
+ {form.useStaticIp && ( + + set("staticIp", e.target.value)} + /> + + )} +
+
+ + + }> + Отмена + + + +
+
+ ) +} + +export { IpsecUserSheet } diff --git a/components/ipsec/ipsec-users-grid.tsx b/components/ipsec/ipsec-users-grid.tsx new file mode 100644 index 0000000..65276cb --- /dev/null +++ b/components/ipsec/ipsec-users-grid.tsx @@ -0,0 +1,245 @@ +"use client" + +import { useMemo, type ReactNode } from "react" +import { + type ColumnDef, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import type { IpsecClientDto } from "@mmapp/contracts/ipsec" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { + DATA_GRID_CELL_PAD, + DATA_GRID_CELL_PAD_FIRST, + DATA_GRID_CELL_PAD_LAST, +} from "@/components/data-grids/shared/data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +import { EmptyState } from "@/components/empty-state" +import { + BadgeCheckIcon, + KeyRoundIcon, + Trash2Icon, + UsersIcon, + WifiIcon, +} from "lucide-react" + +export interface IpsecUsersGridProps { + clients: IpsecClientDto[] + compactServer?: boolean + emptyAction?: ReactNode + onDownloadCert?: (row: IpsecClientDto) => void + onEdit?: (row: IpsecClientDto) => void + onDelete?: (row: IpsecClientDto) => void +} + +function IpsecUsersGrid({ + clients, + compactServer = false, + emptyAction, + onDownloadCert, + onEdit, + onDelete, +}: IpsecUsersGridProps) { + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const c = row.original + return ( +
+ + + {c.disabled ? ( + выкл + ) : null} +
+ ) + }, + meta: { + headerTitle: "Клиент", + headerClassName: DATA_GRID_CELL_PAD_FIRST, + cellClassName: DATA_GRID_CELL_PAD_FIRST, + }, + }, + { + id: "auth", + accessorKey: "authMethod", + header: ({ column }) => , + cell: ({ row }) => { + const cert = row.original.authMethod === "certificate" + return ( +
+ {cert ? ( + + ) : ( + + )} + {cert ? "Сертификат" : "PSK"} +
+ ) + }, + meta: { + headerTitle: "Аутентификация", + headerClassName: DATA_GRID_CELL_PAD, + cellClassName: DATA_GRID_CELL_PAD, + }, + }, + ] + + if (!compactServer) { + cols.push({ + id: "server", + accessorKey: "serverName", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.serverName} + + ), + meta: { + headerTitle: "Сервер", + headerClassName: DATA_GRID_CELL_PAD, + cellClassName: DATA_GRID_CELL_PAD, + }, + }) + } + + cols.push( + { + id: "ip", + accessorFn: (row) => row.staticIp ?? "", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.staticIp ?? "из пула"} + + ), + meta: { + headerTitle: "IP", + headerClassName: DATA_GRID_CELL_PAD, + cellClassName: DATA_GRID_CELL_PAD, + }, + }, + { + id: "online", + accessorKey: "online", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + + {row.original.online ? row.original.activeAddress ?? "онлайн" : "офлайн"} + +
+ ), + meta: { + headerTitle: "Статус", + headerClassName: DATA_GRID_CELL_PAD, + cellClassName: DATA_GRID_CELL_PAD, + }, + }, + { + id: "actions", + header: () => Действия, + enableSorting: false, + size: 96, + cell: ({ row }) => { + const c = row.original + return ( +
+ {c.authMethod === "certificate" && onDownloadCert ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
+ ) + }, + meta: { + headerClassName: DATA_GRID_CELL_PAD_LAST, + cellClassName: DATA_GRID_CELL_PAD_LAST, + }, + }, + ) + + return cols + }, [compactServer, onDownloadCert, onEdit, onDelete]) + + const table = useReactTable({ + data: clients, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + }) + + if (clients.length === 0) { + return ( + } + title="Нет клиентов IKEv2" + description="Создайте клиента — сертификат и .p12 выпустятся автоматически" + action={emptyAction} + className="border-0 py-16" + /> + ) + } + + return ( + + ) +} + +export { IpsecUsersGrid } diff --git a/components/users/user-sheet.tsx b/components/users/user-sheet.tsx index 12cae0d..9f07cb8 100644 --- a/components/users/user-sheet.tsx +++ b/components/users/user-sheet.tsx @@ -55,13 +55,14 @@ import { } from "@/lib/users" import { CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, - NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon, + LockIcon, NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon, } from "lucide-react" const IFACE_TILE: Record = { ether: { icon: CableIcon, className: "text-muted-foreground" }, gre: { icon: NetworkIcon, className: "text-info" }, wg: { icon: ShieldIcon, className: "text-success" }, + ipsec: { icon: LockIcon, className: "text-info" }, other: { icon: CableIcon, className: "text-muted-foreground" }, } diff --git a/lib/ipsec-client.ts b/lib/ipsec-client.ts new file mode 100644 index 0000000..8c96bbd --- /dev/null +++ b/lib/ipsec-client.ts @@ -0,0 +1,33 @@ +/** Клиентские хелперы IPsec (зеркало чистых функций бэкенда). */ + +function parseIpv4(s: string): number | null { + const parts = s.trim().split(".") + if (parts.length !== 4) return null + const octets = parts.map((p) => Number(p)) + if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null + return (((octets[0]! << 24) | (octets[1]! << 16) | (octets[2]! << 8) | octets[3]!) >>> 0) +} + +function intToIp(n: number): string { + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".") +} + +/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e»), исключая занятые. */ +export function findFreePoolIp(range: string, taken: Iterable): string | null { + const takenSet = new Set( + Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()), + ) + const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? "" + const [fromRaw, toRaw] = first.split("-") + const from = parseIpv4(fromRaw ?? "") + const to = parseIpv4(toRaw ?? fromRaw ?? "") + if (from == null) return null + const last = to ?? from + if (last < from) return null + const cap = Math.min(last, from + 65_534) + for (let n = from; n <= cap; n++) { + const ip = intToIp(n) + if (!takenSet.has(ip)) return ip + } + return null +} diff --git a/lib/sidebar-badges.ts b/lib/sidebar-badges.ts index 9d0fe72..306235f 100644 --- a/lib/sidebar-badges.ts +++ b/lib/sidebar-badges.ts @@ -24,6 +24,9 @@ export function formatSidebarBadgeCount(n: number): string { return `${s}к` } +/** Мок-клиенты IKEv2 для демо-режима (см. MOCK_IPSEC_CLIENTS на странице /ipsec). */ +export const MOCK_IPSEC_CLIENTS_COUNT = 3 + function mockWireGuardIfacesCount(): number { let n = 0 for (const s of servers) n += s.wireGuardIfaces?.length ?? 0 @@ -41,6 +44,7 @@ export function mockSidebarBadgesByUrl(): Record { "/users": formatSidebarBadgeCount(INIT_USERS.length), "/filters": formatSidebarBadgeCount(filters.length), "/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()), + "/ipsec": formatSidebarBadgeCount(MOCK_IPSEC_CLIENTS_COUNT), "/gre": formatSidebarBadgeCount(greTunnels.length), "/vxlan": formatSidebarBadgeCount(vxlanTunnels.length), "/containers": formatSidebarBadgeCount(routerContainers.length), @@ -58,6 +62,7 @@ export interface SidebarCountsDto { recursiveRoutes: number certificates?: number wireguard?: number + ipsec?: number users?: number bgpSessions?: number vxlan?: number diff --git a/lib/users.ts b/lib/users.ts index ef60171..f938582 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -79,10 +79,11 @@ export const IFACE_TYPE_LABEL: Record = { ether: "Ethernet", gre: "GRE", wg: "WireGuard", + ipsec: "IPsec", other: "Прочие", } -export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "other"] +export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "ipsec", "other"] export const ROLE_LABEL: Record = { admin: "Администратор", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 7253066..103177d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -38,6 +38,10 @@ "types": "./dist/wireguard.d.ts", "default": "./dist/wireguard.js" }, + "./ipsec": { + "types": "./dist/ipsec.d.ts", + "default": "./dist/ipsec.js" + }, "./users": { "types": "./dist/users.d.ts", "default": "./dist/users.js" diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9f0df50..0ad3376 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from "./events.js" export * from "./certificates.js" export * from "./backups.js" export * from "./wireguard.js" +export * from "./ipsec.js" export * from "./users.js" export * from "./traffic-flow.js" export * from "./geoip.js" diff --git a/packages/contracts/src/ipsec.ts b/packages/contracts/src/ipsec.ts new file mode 100644 index 0000000..39088cf --- /dev/null +++ b/packages/contracts/src/ipsec.ts @@ -0,0 +1,191 @@ +import { z } from "zod" + +export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"]) + +/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */ +export const ipsecClientDtoSchema = z.object({ + id: z.string().min(1), + rosId: z.string().min(1), + serverId: z.string().min(1), + serverName: z.string(), + /** Управляемое имя клиента (из managed-комментария identity). */ + name: z.string(), + authMethod: ipsecAuthMethodSchema, + certificateName: z.string().optional(), + commonName: z.string().optional(), + remoteId: z.string().optional(), + /** Статический IP клиента (персональный mode-config), undefined — из общего пула. */ + staticIp: z.string().optional(), + modeConfigName: z.string().optional(), + peerName: z.string().optional(), + online: z.boolean(), + activeAddress: z.string().optional(), + activeSince: z.string().optional(), + disabled: z.boolean(), + comment: z.string().optional(), + /** Создан менеджером (managed-комментарий). */ + managed: z.boolean(), +}) + +/** Слушатель IKEv2 — /ip/ipsec/peer (passive). */ +export const ipsecPeerDtoSchema = z.object({ + id: z.string().min(1), + rosId: z.string().min(1), + serverId: z.string().min(1), + serverName: z.string(), + name: z.string(), + address: z.string().optional(), + exchangeMode: z.string().optional(), + passive: z.boolean().optional(), + certificate: z.string().optional(), + profile: z.string().optional(), + disabled: z.boolean(), + comment: z.string().optional(), + managed: z.boolean(), +}) + +export const ipsecModeConfigDtoSchema = z.object({ + id: z.string().min(1), + rosId: z.string().min(1), + serverId: z.string().min(1), + name: z.string(), + /** Общий пул (shared) или персональный статический адрес (per-user). */ + addressPool: z.string().optional(), + address: z.string().optional(), + splitDns: z.string().optional(), + staticDns: z.string().optional(), + comment: z.string().optional(), + managed: z.boolean(), +}) + +export const ipsecPoolDtoSchema = z.object({ + id: z.string().min(1), + rosId: z.string().min(1), + serverId: z.string().min(1), + name: z.string(), + ranges: z.string(), + comment: z.string().optional(), + managed: z.boolean(), +}) + +export const ipsecCertInfoDtoSchema = z.object({ + name: z.string(), + commonName: z.string().optional(), + keySize: z.string().optional(), + fingerprint: z.string().optional(), + expiresAt: z.string().optional(), + trusted: z.boolean().optional(), + hasPrivateKey: z.boolean().optional(), + role: z.enum(["ca", "server", "client", "other"]).optional(), + managed: z.boolean(), +}) + +/** Сводка IKEv2-сервера на одном роутере. */ +export const ipsecServerSummaryDtoSchema = z.object({ + serverId: z.string().min(1), + serverName: z.string(), + serverCountry: z.string().optional(), + /** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */ + initialized: z.boolean(), + serverEndpoint: z.string().optional(), + peer: ipsecPeerDtoSchema.optional(), + pool: ipsecPoolDtoSchema.optional(), + sharedModeConfig: ipsecModeConfigDtoSchema.optional(), + caCert: ipsecCertInfoDtoSchema.optional(), + serverCert: ipsecCertInfoDtoSchema.optional(), + natRuleManaged: z.boolean(), + clientsTotal: z.number().int().nonnegative(), + clientsOnline: z.number().int().nonnegative(), + certs: z.array(ipsecCertInfoDtoSchema).optional(), +}) + +export const ipsecListResponseSchema = z.object({ + servers: z.array(ipsecServerSummaryDtoSchema), + clients: z.array(ipsecClientDtoSchema), + failures: z + .array( + z.object({ + serverId: z.string(), + serverName: z.string().optional(), + error: z.string(), + }), + ) + .optional(), +}) + +export const ipsecInitRequestSchema = z.object({ + serverId: z.union([z.string(), z.number()]), + /** Домен или IP для CN/SAN серверного сертификата (к нему подключаются клиенты). */ + serverEndpoint: z.string().min(1), + /** Подсеть пула клиентов; из неё pool ranges .2–.254. */ + poolCidr: z.string().regex(/^[0-9a-fA-F.:/]+$/).default("10.77.0.0/24"), + dns: z.string().optional(), + caDaysValid: z.number().int().positive().default(3650), + serverDaysValid: z.number().int().positive().default(3650), + clientDaysValid: z.number().int().positive().default(1825), + /** Managed srcnat masquerade, чтобы у клиентов был интернет. */ + createNatRule: z.boolean().default(true), +}) + +export const ipsecUserCreateRequestSchema = z.object({ + serverId: z.union([z.string(), z.number()]), + /** Отображаемое имя клиента; из него slug для серта/CN. */ + name: z.string().min(1).max(64), + authMethod: ipsecAuthMethodSchema.default("certificate"), + psk: z.string().min(8).optional(), + remoteId: z.string().optional(), + /** Конкретный IP клиента; без — выдаётся из пула. */ + staticIp: z.string().optional(), + /** Пароль на экспортируемый .p12. */ + passphrase: z.string().min(4).optional(), + daysValid: z.number().int().positive().optional(), +}) + +export const ipsecUserPatchSchema = z.object({ + name: z.string().min(1).max(64).optional(), + /** null — вернуть выдачу из пула. */ + staticIp: z.string().nullable().optional(), + psk: z.string().min(8).optional(), + remoteId: z.string().optional(), + disabled: z.boolean().optional(), +}) + +export const ipsecCertExportRequestSchema = z.object({ + serverId: z.union([z.string(), z.number()]), + clientId: z.string().min(1), + passphrase: z.string().min(4), +}) + +/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */ +export const ipsecCertBundleSchema = z.object({ + user: z.string(), + serverEndpoint: z.string().optional(), + filename: z.string(), + contentB64: z.string(), + mime: z.string().default("application/x-pkcs12"), + passphrase: z.string().optional(), + sswanFilename: z.string().optional(), + sswanContent: z.string().optional(), + instructions: z.string().optional(), +}) + +export const ipsecUserCreatedSchema = z.object({ + client: ipsecClientDtoSchema, + /** Одноразовый бандл сертификата (для cert-клиентов). */ + bundle: ipsecCertBundleSchema.optional(), +}) + +export type IpsecAuthMethod = z.infer +export type IpsecClientDto = z.infer +export type IpsecPeerDto = z.infer +export type IpsecModeConfigDto = z.infer +export type IpsecPoolDto = z.infer +export type IpsecCertInfoDto = z.infer +export type IpsecServerSummaryDto = z.infer +export type IpsecListResponse = z.infer +export type IpsecInitRequest = z.infer +export type IpsecUserCreateRequest = z.infer +export type IpsecUserPatch = z.infer +export type IpsecCertExportRequest = z.infer +export type IpsecCertBundle = z.infer +export type IpsecUserCreated = z.infer diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 57b99b5..97951c5 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -2,7 +2,7 @@ import { z } from "zod" export const appUserRoleSchema = z.enum(["admin", "operator", "viewer"]) export const permLevelSchema = z.enum(["none", "read", "write"]) -export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "other"]) +export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "ipsec", "other"]) export const sectionPermSchema = z.object({ section: z.string().min(1), diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts new file mode 100644 index 0000000..38a7463 --- /dev/null +++ b/shared/api/ipsec.ts @@ -0,0 +1,108 @@ +import { requestJson } from "@/shared/api/http-client" +import type { + IpsecCertBundle, + IpsecInitRequest, + IpsecListResponse, + IpsecServerSummaryDto, + IpsecUserCreateRequest, + IpsecUserCreated, + IpsecUserPatch, +} from "@mmapp/contracts/ipsec" + +export async function listIpsec( + baseUrl: string, + opts?: { serverId?: string }, +): Promise { + const q = new URLSearchParams() + if (opts?.serverId) q.set("serverId", opts.serverId) + const suffix = q.size > 0 ? `?${q.toString()}` : "" + return requestJson(baseUrl, `/api/ipsec${suffix}`, { method: "GET" }) +} + +export async function initIpsecServer( + baseUrl: string, + body: IpsecInitRequest, +): Promise { + return requestJson(baseUrl, "/api/ipsec/server/init", { + method: "POST", + body: JSON.stringify(body), + }) +} + +export async function deleteIpsecServer( + baseUrl: string, + serverId: string, + opts?: { removeCertificates?: boolean }, +): Promise<{ ok: boolean }> { + const q = new URLSearchParams() + if (opts?.removeCertificates === false) q.set("removeCertificates", "false") + const suffix = q.size > 0 ? `?${q.toString()}` : "" + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/server/${encodeURIComponent(serverId)}${suffix}`, + { method: "DELETE" }, + ) +} + +export async function createIpsecUser( + baseUrl: string, + body: IpsecUserCreateRequest, +): Promise { + return requestJson(baseUrl, "/api/ipsec/users", { + method: "POST", + body: JSON.stringify(body), + }) +} + +export async function patchIpsecUser( + baseUrl: string, + serverId: string, + clientId: string, + patch: IpsecUserPatch, +): Promise<{ ok: boolean }> { + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}`, + { method: "PATCH", body: JSON.stringify(patch) }, + ) +} + +export async function deleteIpsecUser( + baseUrl: string, + serverId: string, + clientId: string, + opts?: { removeCertificate?: boolean }, +): Promise<{ ok: boolean }> { + const q = new URLSearchParams() + if (opts?.removeCertificate === false) q.set("removeCertificate", "false") + const suffix = q.size > 0 ? `?${q.toString()}` : "" + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}${suffix}`, + { method: "DELETE" }, + ) +} + +export async function exportIpsecUserCert( + baseUrl: string, + serverId: string, + clientId: string, + passphrase: string, +): Promise { + return requestJson( + baseUrl, + `/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`, + { method: "POST", body: JSON.stringify({ passphrase }) }, + ) +} + +export async function restoreIpsecRevision( + baseUrl: string, + revisionId: string, + serverId: string, +): Promise<{ ok: boolean }> { + return requestJson<{ ok: boolean }>(baseUrl, `/api/ipsec/revisions/${encodeURIComponent(revisionId)}/restore`, { + method: "POST", + body: JSON.stringify({ serverId }), + }) +}