From 7365d8d8fbc7dba879fbf3d0810122fdbb63b970 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 12 Sep 2026 21:04:49 +0700 Subject: [PATCH] feat(ipsec): enhance IPsec management with peer and certificate handling - Added support for managing IPsec peers, including editing and deleting functionality. - Introduced a new UI component for displaying peers and their details within the IPsec server grid. - Updated the IPsec user creation form to allow binding identities to specific peers. - Enhanced backend routes and services to handle peer patching and deletion requests. - Improved data structures to accommodate multiple peers and certificates for each server. - Added tests to ensure proper functionality of new peer management features. --- ...ss_fca5e2e2-1ed7-4e6c-a22c-4fe44786d13a.md | 46 ++++ app/(main)/ipsec/page.tsx | 165 ++++++++++-- backend/src/routes/ipsec.ts | 152 +++++++++-- backend/src/services/ipsec-config.test.ts | 26 ++ backend/src/services/ipsec-config.ts | 29 ++ backend/src/services/ipsec-live.ts | 41 +-- backend/src/services/ipsec-ros.ts | 8 + components/ipsec/ipsec-peer-sheet.tsx | 149 +++++++++++ components/ipsec/ipsec-server-grid.tsx | 253 ++++++++++++++---- components/ipsec/ipsec-user-sheet.tsx | 25 ++ components/ipsec/ipsec-users-grid.tsx | 3 + packages/contracts/src/ipsec.ts | 22 ++ shared/api/ipsec.ts | 46 ++++ страна_раскрывает_сервисы_424c424b.plan.md | 94 +++++++ 14 files changed, 953 insertions(+), 106 deletions(-) create mode 100644 .zcode/plans/plan-sess_fca5e2e2-1ed7-4e6c-a22c-4fe44786d13a.md create mode 100644 components/ipsec/ipsec-peer-sheet.tsx create mode 100644 страна_раскрывает_сервисы_424c424b.plan.md diff --git a/.zcode/plans/plan-sess_fca5e2e2-1ed7-4e6c-a22c-4fe44786d13a.md b/.zcode/plans/plan-sess_fca5e2e2-1ed7-4e6c-a22c-4fe44786d13a.md new file mode 100644 index 0000000..ddc5aa6 --- /dev/null +++ b/.zcode/plans/plan-sess_fca5e2e2-1ed7-4e6c-a22c-4fe44786d13a.md @@ -0,0 +1,46 @@ +# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard + +## Архитектура (как у WireGuard) + +Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers). + +## Модель RouterOS (что создаём/читаем) + +Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам». + +Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca= → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`. + +Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере). + +## Бэкенд + +1. **`mikrotik.ts`**: `downloadFile(name): Promise` — бинарно-безопасный GET `/rest/file/` (с fallback `flash/`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`. +2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-`, `ipsec-user-`). +3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download. +4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило. +5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара. +6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет). +7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`. +8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту. + +## Контракт + +`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`. + +## Фронт + +`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима. + +## Тесты и проверка + +- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts` — `planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`. +- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок. +- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз). + +## Риски (проверить на живом роутере при внедрении) + +- REST-скачивание файла `GET /rest/file/` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей. +- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать. +- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с. + +Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`. \ No newline at end of file diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index e87a2ae..4a80ef4 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -11,6 +11,7 @@ import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid" import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet" import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet" import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet" +import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet" import { Button } from "@/components/ui/button" import { Alert, @@ -32,20 +33,25 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import { useDataSource } from "@/lib/data-source" -import { requestJson } from "@/shared/api/http-client" +import { ApiClientError, requestJson } from "@/shared/api/http-client" import { createIpsecUser, + deleteIpsecCert, + deleteIpsecPeer, deleteIpsecServer, deleteIpsecUser, exportIpsecUserCert, initIpsecServer, listIpsec, + patchIpsecPeer, patchIpsecUser, restoreIpsecRevision, } from "@/shared/api/ipsec" import type { IpsecCertBundle, + IpsecCertInfoDto, IpsecClientDto, + IpsecPeerDto, IpsecServerSummaryDto, } from "@mmapp/contracts/ipsec" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" @@ -68,6 +74,23 @@ type IpsecFailure = { serverId: string; serverName?: string; error: string } type PendingDelete = | { kind: "user"; client: IpsecClientDto } | { kind: "server"; summary: IpsecServerSummaryDto } + | { kind: "peer"; serverId: string; peer: IpsecPeerDto } + | { kind: "cert"; serverId: string; cert: IpsecCertInfoDto } + +const MOCK_IPSEC_PEER: IpsecPeerDto = { + id: "srv2:pp1", + rosId: "*PP1", + serverId: "srv2", + serverName: "mt-spb-edge-01", + name: "ipsec-vpn", + address: "0.0.0.0/0", + exchangeMode: "ike2", + passive: true, + certificate: "ipsec-server", + profile: "ipsec-vpn", + disabled: false, + managed: true, +} const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [ { @@ -77,7 +100,8 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [ 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 }, + peer: MOCK_IPSEC_PEER, + peers: [MOCK_IPSEC_PEER], caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true }, serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true }, natRuleManaged: true, @@ -157,6 +181,8 @@ export default function IpsecPage() { const [certOpen, setCertOpen] = useState(false) const [certBundle, setCertBundle] = useState(null) const [certClient, setCertClient] = useState(null) + const [peerOpen, setPeerOpen] = useState(false) + const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) @@ -270,11 +296,19 @@ export default function IpsecPage() { ) }, [scopedClients, search]) - const serversWithIpsec = scopedSummaries.filter((s) => s.initialized).length + const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length const clientsOnline = scopedClients.filter((c) => c.online).length const caOk = scopedSummaries.some((s) => s.caCert) const compactServer = effectiveServerId !== ALL_SERVERS_ID const sheetServerId = compactServer ? effectiveServerId : undefined + const certNames = useMemo( + () => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(), + [scopedSummaries], + ) + const peerOptions = useMemo( + () => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))), + [scopedSummaries], + ) const serverOptions = displayServers.map((s) => ({ id: s.id, @@ -340,6 +374,7 @@ export default function IpsecPage() { const res = await createIpsecUser(backendUrl, { serverId: form.serverId, name: form.name.trim(), + peerName: form.peerName.trim() || undefined, authMethod: form.authMethod, psk: form.authMethod === "pre-shared-key" ? form.psk : undefined, remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined, @@ -418,24 +453,111 @@ export default function IpsecPage() { const confirmDelete = async () => { if (!pendingDelete) return + const target = pendingDelete setBusy(true) try { - if (pendingDelete.kind === "user") { - await deleteIpsecUser(backendUrl, pendingDelete.client.serverId, pendingDelete.client.rosId) - toast.success(`Клиент «${pendingDelete.client.name}» удалён`) + if (target.kind === "user") { + await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId) + toast.success(`Клиент «${target.client.name}» удалён`) + } else if (target.kind === "server") { + await deleteIpsecServer(backendUrl, target.summary.serverId) + toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`) + } else if (target.kind === "peer") { + await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId) + toast.success(`Peer «${target.peer.name}» удалён`) } else { - await deleteIpsecServer(backendUrl, pendingDelete.summary.serverId) - toast.success(`IKEv2-сервер на ${pendingDelete.summary.serverName} удалён`) + await deleteIpsecCert(backendUrl, target.serverId, target.cert.name) + toast.success(`Сертификат «${target.cert.name}» удалён`) } await loadLive() } catch (e) { - toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) }) + const forceDelete = async (fn: () => Promise) => { + try { + await fn() + toast.success("Удалено") + await loadLive() + } catch (err) { + toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) }) + } + } + if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") { + toast.error("На peer ссылаются identity", { + description: e.message, + action: { + label: "Удалить принудительно", + onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) }, + }, + }) + } else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") { + toast.error("Сертификат используется", { + description: e.message, + action: { + label: "Удалить принудительно", + onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) }, + }, + }) + } else { + toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) }) + } } finally { setBusy(false) setPendingDelete(null) } } + const openEditPeer = (serverId: string, peer: IpsecPeerDto) => { + setEditingPeer({ serverId, peer }) + setPeerOpen(true) + } + + const handleEditPeer = async (form: IpsecPeerFormState) => { + if (!editingPeer) return + setBusy(true) + try { + await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, { + name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined, + address: form.address.trim() || undefined, + exchangeMode: form.exchangeMode.trim() || undefined, + passive: form.passive, + certificate: form.certificate.trim() || undefined, + profile: form.profile.trim() || undefined, + disabled: form.disabled, + }) + toast.success("Peer обновлён") + setPeerOpen(false) + setEditingPeer(null) + await loadLive() + } catch (e) { + toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) }) + } finally { + setBusy(false) + } + } + + const deleteDialogTitle = (() => { + if (!pendingDelete) return "" + switch (pendingDelete.kind) { + case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?` + case "user": return `Удалить клиента «${pendingDelete.client.name}»?` + case "peer": return `Удалить peer «${pendingDelete.peer.name}»?` + case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?` + } + })() + + const deleteDialogDescription = (() => { + if (!pendingDelete) return "" + switch (pendingDelete.kind) { + case "server": + return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)." + case "user": + return "Удалит identity, персональный mode-config и клиентский сертификат на роутере." + case "peer": + return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление." + case "cert": + return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление." + } + })() + return ( <> { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined} + onEditPeer={isLive ? openEditPeer : undefined} + onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} + onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined} /> @@ -667,6 +792,7 @@ export default function IpsecPage() { open={userOpen} onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }} servers={serverOptions} + peers={peerOptions} busy={busy} defaultServerId={sheetServerId} editing={editing} @@ -674,6 +800,15 @@ export default function IpsecPage() { onSubmit={editing ? handleEditUser : handleCreateUser} /> + { setPeerOpen(v); if (!v) setEditingPeer(null) }} + busy={busy} + editing={editingPeer?.peer ?? null} + certificates={certNames} + onSubmit={handleEditPeer} + /> + - - {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 и клиентский сертификат на роутере."} - + {deleteDialogTitle} + {deleteDialogDescription} Отмена diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index bdc82ec..ffa83a8 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -1,8 +1,10 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyReply } from "fastify" import { + ipsecCertDeleteRequestSchema, ipsecCertExportRequestSchema, ipsecInitRequestSchema, + ipsecPeerPatchSchema, ipsecUserCreateRequestSchema, ipsecUserPatchSchema, type IpsecCertBundle, @@ -17,6 +19,7 @@ import { buildSswanConfig, clientCertName, findFreePoolIp, + identityDisplayName, ipsecManagedComment, ipsecUserComment, isIpsecManagedComment, @@ -37,6 +40,8 @@ import { putIdentity, patchIdentity, deleteIdentity, + patchPeer, + deletePeer, listByPath, } from "../services/ipsec-ros.js" import { @@ -240,22 +245,33 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { 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 peer = (body.peerName + ? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim()) + : undefined) + ?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME) + ?? state.peers[0] + if (!peer) { + return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" }) + } const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) + ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign")) const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) - if (!peer || !sharedMc || !caCert || !serverCert) { - return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" }) + ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) + if (body.authMethod === "certificate" && (!caCert || !serverCert)) { + return reply.status(400).send({ + error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK", + }) } const peerName = (peer.name ?? "").trim() - const serverEndpoint = String(serverCert["common-name"] ?? "").trim() - const dns = sharedMc["static-dns"]?.trim() || undefined + const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host + 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() + 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}` }) @@ -271,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (body.authMethod === "certificate") { const issued = await issueClientCertificate(client, { userName: body.name, - caCertName: IPSEC_CA_CERT, + caCertName: String(caCert!.name ?? IPSEC_CA_CERT), daysValid: body.daysValid ?? 1825, }) if (issued.existed) { @@ -285,7 +301,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { modeConfig, comment: ipsecUserComment(body.name), authMethod: body.authMethod, - certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined, + certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined, remoteCertificate: certName, secret: body.psk, remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined, @@ -328,26 +344,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { 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 managedIdentity = isIpsecManagedComment(identity.comment) const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? "" const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + const sharedMcName = (sharedMc?.name ?? "").trim() if (d.name && d.name !== oldName) { + // managed: user=; существующий RouterOS identity: обычный comment await patchIdentity(client, identity[".id"]!, { - comment: ipsecUserComment(d.name), + comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), }) } if (d.staticIp !== undefined) { + const currentMc = (identity["mode-config"] ?? "").trim() 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) + if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName }) + const personal = currentMc && currentMc !== sharedMcName ? currentMc : "" + if (personal) 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()), + state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc), ) if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) const name = userModeConfigName(d.name || oldName) @@ -381,15 +401,18 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { 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 managed = isIpsecManagedComment(identity.comment) const userName = parseIpsecUserComment(identity.comment) ?? "" + const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim() const personal = (identity["mode-config"] ?? "").trim() + const remoteCert = (identity["remote-certificate"] ?? "").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) + if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal) + if (removeCertificate) { + // только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем + if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined) + else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined) } await recordIpsec(server, "apply") return reply.send({ ok: true }) @@ -398,6 +421,99 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { } }) + app.patch("/ipsec/peers/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const parsed = ipsecPeerPatchSchema.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 peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId)) + if (!peer) return reply.status(404).send({ error: "Peer не найден" }) + const body: Record = {} + if (d.name !== undefined) body.name = d.name + if (d.address !== undefined) body.address = d.address + if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode + if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no" + if (d.certificate !== undefined) body.certificate = d.certificate + if (d.profile !== undefined) body.profile = d.profile + if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no" + if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body) + await recordIpsec(server, "apply") + return reply.send({ ok: true }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.delete("/ipsec/peers/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const q = req.query as { force?: string } + 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 peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId)) + if (!peer) return reply.status(404).send({ error: "Peer не найден" }) + const peerName = (peer.name ?? "").trim() + const linked = state.identities + .filter((i) => (i.peer ?? "").trim() === peerName) + .map((i) => identityDisplayName(i, state.certs)) + if (linked.length > 0 && q.force !== "true") { + return reply.status(409).send({ + error: `На peer «${peerName}» ссылаются identity: ${linked.join(", ")}. Удаление разорвёт их.`, + identities: linked, + }) + } + await deletePeer(client, rosIdParam(rosId)) + await recordIpsec(server, "apply") + return reply.send({ ok: true }) + } catch (e) { + return errReply(reply, e) + } + }) + + app.delete("/ipsec/certs/:serverId", async (req, reply) => { + const { serverId } = req.params as { serverId: string } + const parsed = ipsecCertDeleteRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const q = req.query as { force?: string } + 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 name = parsed.data.name + const cert = state.certs.find((c) => String(c.name ?? "").trim() === name) + if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` }) + const usedBy = [ + ...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`), + ...state.identities + .filter((i) => (i.certificate ?? "").trim() === name || (i["remote-certificate"] ?? "").trim() === name) + .map((i) => `identity ${identityDisplayName(i, state.certs)}`), + ] + if (usedBy.length > 0 && q.force !== "true") { + return reply.status(409).send({ + error: `Сертификат «${name}» используется: ${usedBy.join(", ")}.`, + usedBy, + }) + } + await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`) + 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 }) diff --git a/backend/src/services/ipsec-config.test.ts b/backend/src/services/ipsec-config.test.ts index 2e11e68..7648cba 100644 --- a/backend/src/services/ipsec-config.test.ts +++ b/backend/src/services/ipsec-config.test.ts @@ -5,6 +5,7 @@ import { buildSswanConfig, clientCertName, findFreePoolIp, + identityDisplayName, ipsecManagedComment, ipsecSlug, ipsecUserComment, @@ -52,6 +53,31 @@ import { assert.equal(findFreePoolIp("мусор", []), null) } +{ + // managed: user= из comment + assert.equal(identityDisplayName({ comment: ipsecUserComment("alice"), ".id": "*1" }, []), "alice") + // существующий RouterOS identity: сырой comment + assert.equal(identityDisplayName({ comment: "home office", ".id": "*2" }, []), "home office") + // remote-id + assert.equal(identityDisplayName({ "remote-id": "client@example.com", ".id": "*3" }, []), "client@example.com") + // CN сертификата по remote-certificate + assert.equal( + identityDisplayName( + { "remote-certificate": "ipsec-user-bob", ".id": "*4" }, + [{ name: "ipsec-user-bob", "common-name": "bob" }], + ), + "bob", + ) + // fallback: peer#shortId + assert.equal( + identityDisplayName({ peer: "ikev2-srv", ".id": "*AB12CD34" }, []), + "ikev2-srv#AB12CD", + ) + // fallback: rosId + assert.equal(identityDisplayName({ ".id": "*FF" }, []), "FF") + assert.equal(identityDisplayName({}, []), "identity") +} + { const sswan = buildSswanConfig({ name: "IKEv2 vpn.example.com", diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index 2491fd6..c7aceda 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -24,6 +24,35 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin return m?.[1] ?? null } +type IdentityLike = { + ".id"?: string + comment?: string + "remote-id"?: string + "remote-certificate"?: string + peer?: string +} + +type CertLike = { name?: string; "common-name"?: string } + +/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */ +export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string { + const comment = (i.comment ?? "").trim() + const managedName = parseIpsecUserComment(comment) + if (managedName) return managedName + if (comment) return comment + const remoteId = (i["remote-id"] ?? "").trim() + if (remoteId) return remoteId + const remoteCert = (i["remote-certificate"] ?? "").trim() + if (remoteCert) { + const cn = String(certs.find((c) => String(c.name ?? "").trim() === remoteCert)?.["common-name"] ?? "").trim() + return cn || remoteCert + } + const rosId = String(i[".id"] ?? "").replace(/^\*/, "") + const peer = (i.peer ?? "").trim() + if (peer) return `${peer}#${rosId.slice(0, 6) || "identity"}` + return rosId || "identity" +} + // ── naming-конвенции управляемых объектов RouterOS ────────────────────────── export const IPSEC_CA_CERT = "ipsec-ca" diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index 6c12ca2..d644e81 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -16,8 +16,8 @@ import { IPSEC_COMMON_NAME, IPSEC_SERVER_CERT, clientCertName, + identityDisplayName, isIpsecManagedComment, - parseIpsecUserComment, } from "./ipsec-config.js" import { canonicalIpsecSnapshot, @@ -111,6 +111,8 @@ 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 isServer = keyUsage.includes("tls-server") + const isClient = keyUsage.includes("tls-client") const isUser = name.startsWith("ipsec-user-") return { name, @@ -120,7 +122,7 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto { 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", + role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other", managed: isCa && name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || isUser @@ -202,14 +204,9 @@ export async function fetchIpsecState(server: ServerRow): Promise() - 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) { @@ -218,10 +215,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { } return state.identities - .filter((i) => isIpsecManagedComment(i.comment)) .map((i): IpsecClientDto => { const comment = i.comment ?? "" - const name = parseIpsecUserComment(comment) ?? (comment || "client") + const managed = isIpsecManagedComment(comment) + const name = identityDisplayName(i, state.certs) const psk = (i["auth-method"] ?? "") === "pre-shared-key" const certName = (i["remote-certificate"] ?? "").trim() const cn = certName @@ -229,8 +226,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { : "" 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 + const staticIp = mc + ? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined) : undefined const active = (cn ? activeByRemote.get(cn) : undefined) ?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined) @@ -252,7 +249,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { activeSince: active?.established || undefined, disabled: asBool(i.disabled), comment: comment || undefined, - managed: true, + managed, } }) .sort((a, b) => a.name.localeCompare(b.name)) @@ -262,6 +259,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt const server = state.server const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment)) ?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) + /** Для отображения: managed peer, иначе первый существующий peer роутера. */ + const primaryPeer = managedPeer ?? state.peers[0] const sharedMc = state.modeConfigs.find( (m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")), ) @@ -270,7 +269,9 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt ?? state.pools.find((p) => isIpsecManagedComment(p.comment)) : undefined const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) + ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign")) const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) + ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat") return { serverId: String(server.id), @@ -278,7 +279,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt serverCountry: server.country ?? undefined, initialized: Boolean(managedPeer && caCert && serverCert), serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined, - peer: managedPeer ? mapPeer(server, managedPeer) : undefined, + peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined, + peers: state.peers.map((p) => mapPeer(server, p)), pool: pool ? mapPool(server, pool) : undefined, sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined, caCert: caCert ? mapCertificate(caCert) : undefined, @@ -286,15 +288,14 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt 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), + certs: state.certs.map(mapCertificate), } } +/** + * Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`): + * restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты. + */ export async function captureIpsecSnapshot(server: ServerRow): Promise { const state = await fetchIpsecState(server) return canonicalIpsecSnapshot({ diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts index 1c327f9..2d1141c 100644 --- a/backend/src/services/ipsec-ros.ts +++ b/backend/src/services/ipsec-ros.ts @@ -206,6 +206,14 @@ export async function deleteIdentity(client: MikrotikClient, rosId: string): Pro await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`) } +export async function patchPeer(client: MikrotikClient, rosId: string, body: Record): Promise { + await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body) +} + +export async function deletePeer(client: MikrotikClient, rosId: string): Promise { + await client.delete(`/ip/ipsec/peer/${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 })) diff --git a/components/ipsec/ipsec-peer-sheet.tsx b/components/ipsec/ipsec-peer-sheet.tsx new file mode 100644 index 0000000..736cc8f --- /dev/null +++ b/components/ipsec/ipsec-peer-sheet.tsx @@ -0,0 +1,149 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { IpsecPeerDto } 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 IpsecPeerFormState = { + name: string + address: string + exchangeMode: string + passive: boolean + certificate: string + profile: string + disabled: boolean +} + +export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({ + name: "", + address: "0.0.0.0/0", + exchangeMode: "ike2", + passive: true, + certificate: "", + profile: "", + disabled: false, +}) + +const EXCHANGE_MODES = ["ike2", "main", "aggressive", "base"] + +/** Редактирование существующего peer (сервера) RouterOS. */ +function IpsecPeerSheet({ + open, + onOpenChange, + busy, + editing, + certificates, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + busy?: boolean + editing: IpsecPeerDto | null + certificates: string[] + onSubmit: (form: IpsecPeerFormState) => void | Promise +}) { + const [form, setForm] = useState(defaultIpsecPeerForm) + const set = (k: K, v: IpsecPeerFormState[K]) => + setForm((f) => ({ ...f, [k]: v })) + + useEffect(() => { + if (!open || !editing) return + queueMicrotask(() => setForm({ + name: editing.name, + address: editing.address ?? "", + exchangeMode: editing.exchangeMode ?? "ike2", + passive: editing.passive ?? false, + certificate: editing.certificate ?? "", + profile: editing.profile ?? "", + disabled: editing.disabled, + })) + }, [open, editing]) + + const canSubmit = useMemo(() => form.name.trim().length > 0, [form.name]) + + return ( + + + + {editing ? `Peer «${editing.name}»` : "Peer"} + + {editing?.managed + ? "Managed peer: изменения применяются напрямую на роутере" + : "Существующий peer RouterOS: изменения применяются напрямую"} + + + +
+
+ Основные + + set("name", e.target.value)} /> + + + set("address", e.target.value)} /> + + + + + + set("certificate", e.target.value)} + /> + + {certificates.map((c) => + + + set("profile", e.target.value)} /> + +
+ +
+ Состояние +
+

Passive

+ set("passive", v)} /> +
+
+

Отключён

+ set("disabled", v)} /> +
+
+
+ + + }> + Отмена + + + +
+
+ ) +} + +export { IpsecPeerSheet } diff --git a/components/ipsec/ipsec-server-grid.tsx b/components/ipsec/ipsec-server-grid.tsx index b264417..1b928d3 100644 --- a/components/ipsec/ipsec-server-grid.tsx +++ b/components/ipsec/ipsec-server-grid.tsx @@ -1,12 +1,14 @@ "use client" -import type { IpsecServerSummaryDto } from "@mmapp/contracts/ipsec" +import type { IpsecCertInfoDto, IpsecPeerDto, IpsecServerSummaryDto } from "@mmapp/contracts/ipsec" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { GlobeIcon, + KeyRoundIcon, NetworkIcon, + PencilIcon, ShieldCheckIcon, Trash2Icon, } from "lucide-react" @@ -22,70 +24,223 @@ function Row({ label, value, mono = true }: { label: string; value: string; mono ) } +function OriginBadge({ managed }: { managed: boolean }) { + return managed ? null : ( + RouterOS + ) +} + +function PeerRow({ + peer, + onEdit, + onDelete, +}: { + peer: IpsecPeerDto + onEdit?: (peer: IpsecPeerDto) => void + onDelete?: (peer: IpsecPeerDto) => void +}) { + const summary = [ + peer.exchangeMode, + peer.address, + peer.passive ? "passive" : undefined, + peer.certificate, + peer.profile, + ].filter(Boolean).join(" · ") + return ( +
+
+
+ {peer.name} + + {peer.disabled ? выкл : null} +
+

{summary || "—"}

+
+
+ {onEdit ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
+
+ ) +} + +function CertRow({ + cert, + onDelete, +}: { + cert: IpsecCertInfoDto + onDelete?: (cert: IpsecCertInfoDto) => void +}) { + const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий" + return ( +
+
+
+ + {cert.name} + {roleLabel} + + {cert.trusted === false ? не trusted : null} +
+

+ {[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"} +

+
+ {onDelete ? ( + + ) : null} +
+ ) +} + export interface IpsecServerGridProps { servers: IpsecServerSummaryDto[] onInit?: (row: IpsecServerSummaryDto) => void onRemove?: (row: IpsecServerSummaryDto) => void + onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void + onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void + onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void } -function IpsecServerGrid({ servers, onInit, onRemove }: IpsecServerGridProps) { +function IpsecServerGrid({ + servers, + onInit, + onRemove, + onEditPeer, + onDeletePeer, + onDeleteCert, +}: IpsecServerGridProps) { return (
- {servers.map((s) => ( -
-
-
-

{s.serverName}

-

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

+ {servers.map((s) => { + const certs = s.certs ?? [] + return ( +
+
+
+

{s.serverName}

+

+ {s.serverEndpoint ?? "endpoint не определён"} +

+
+ {s.peers.length > 0 ? ( + + + {s.peers.length} peer + + ) : ( + нет peer + )}
- {s.initialized ? ( - - - IKEv2 - - ) : ( - нет - )} -
-
- - - - - - -
+
+

+ Peer / серверы +

+ {s.peers.length > 0 ? ( +
+ {s.peers.map((p) => ( + onEditPeer(s.serverId, peer) : undefined} + onDelete={onDeletePeer ? (peer) => onDeletePeer(s.serverId, peer) : undefined} + /> + ))} +
+ ) : ( +

Peers не найдены

+ )} +
-
- - {s.initialized && onRemove ? ( +
+

+ Сертификаты +

+ {certs.length > 0 ? ( +
+ {certs.map((c) => ( + onDeleteCert(s.serverId, cert) : undefined} + /> + ))} +
+ ) : ( +

Сертификаты не найдены

+ )} +
+ +
+ + + + +
+ +
- ) : null} + {s.initialized && onRemove ? ( + + ) : null} +
-
- ))} + ) + })} {servers.length === 0 ? (
diff --git a/components/ipsec/ipsec-user-sheet.tsx b/components/ipsec/ipsec-user-sheet.tsx index bd90ea7..b92fecd 100644 --- a/components/ipsec/ipsec-user-sheet.tsx +++ b/components/ipsec/ipsec-user-sheet.tsx @@ -15,6 +15,7 @@ export type IpsecAuthKind = "certificate" | "pre-shared-key" export type IpsecUserFormState = { serverId: string name: string + peerName: string authMethod: IpsecAuthKind psk: string remoteId: string @@ -26,6 +27,7 @@ export type IpsecUserFormState = { export const defaultIpsecUserForm = (): IpsecUserFormState => ({ serverId: "", name: "", + peerName: "", authMethod: "certificate", psk: "", remoteId: "", @@ -35,11 +37,13 @@ export const defaultIpsecUserForm = (): IpsecUserFormState => ({ }) type ServerOption = { id: string; name: string; host: string } +export type PeerOption = { serverId: string; name: string; managed: boolean } function IpsecUserSheet({ open, onOpenChange, servers, + peers, busy, defaultServerId, editing, @@ -49,6 +53,8 @@ function IpsecUserSheet({ open: boolean onOpenChange: (v: boolean) => void servers: ServerOption[] + /** Доступные peers (серверы) для привязки identity при создании. */ + peers?: PeerOption[] busy?: boolean defaultServerId?: string /** Режим редактирования: сервер и метод аутентификации не меняются. */ @@ -67,6 +73,7 @@ function IpsecUserSheet({ queueMicrotask(() => setForm({ serverId: edit.serverId, name: edit.name, + peerName: edit.peerName ?? "", authMethod: edit.authMethod, psk: "", remoteId: edit.remoteId ?? "", @@ -118,6 +125,24 @@ function IpsecUserSheet({ ) : null} + {!editing && (peers ?? []).some((p) => p.serverId === form.serverId) ? ( + + + + ) : null} выкл ) : null} + {!c.managed ? ( + RouterOS + ) : null}
) }, diff --git a/packages/contracts/src/ipsec.ts b/packages/contracts/src/ipsec.ts index 39088cf..46b8b21 100644 --- a/packages/contracts/src/ipsec.ts +++ b/packages/contracts/src/ipsec.ts @@ -88,7 +88,10 @@ export const ipsecServerSummaryDtoSchema = z.object({ /** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */ initialized: z.boolean(), serverEndpoint: z.string().optional(), + /** Primary managed peer (обратная совместимость); для полного списка — `peers`. */ peer: ipsecPeerDtoSchema.optional(), + /** Все peers роутера (managed + существующие). */ + peers: z.array(ipsecPeerDtoSchema).default([]), pool: ipsecPoolDtoSchema.optional(), sharedModeConfig: ipsecModeConfigDtoSchema.optional(), caCert: ipsecCertInfoDtoSchema.optional(), @@ -96,9 +99,24 @@ export const ipsecServerSummaryDtoSchema = z.object({ natRuleManaged: z.boolean(), clientsTotal: z.number().int().nonnegative(), clientsOnline: z.number().int().nonnegative(), + /** Все сертификаты роутера (managed + существующие). */ certs: z.array(ipsecCertInfoDtoSchema).optional(), }) +export const ipsecPeerPatchSchema = z.object({ + name: z.string().min(1).max(64).optional(), + address: z.string().optional(), + exchangeMode: z.string().optional(), + passive: z.boolean().optional(), + certificate: z.string().optional(), + profile: z.string().optional(), + disabled: z.boolean().optional(), +}) + +export const ipsecCertDeleteRequestSchema = z.object({ + name: z.string().min(1), +}) + export const ipsecListResponseSchema = z.object({ servers: z.array(ipsecServerSummaryDtoSchema), clients: z.array(ipsecClientDtoSchema), @@ -131,6 +149,8 @@ export const ipsecUserCreateRequestSchema = z.object({ serverId: z.union([z.string(), z.number()]), /** Отображаемое имя клиента; из него slug для серта/CN. */ name: z.string().min(1).max(64), + /** Peer (сервер), к которому привязать identity; без — primary managed peer выбранного сервера. */ + peerName: z.string().optional(), authMethod: ipsecAuthMethodSchema.default("certificate"), psk: z.string().min(8).optional(), remoteId: z.string().optional(), @@ -178,6 +198,8 @@ export const ipsecUserCreatedSchema = z.object({ export type IpsecAuthMethod = z.infer export type IpsecClientDto = z.infer export type IpsecPeerDto = z.infer +export type IpsecPeerPatch = z.infer +export type IpsecCertDeleteRequest = z.infer export type IpsecModeConfigDto = z.infer export type IpsecPoolDto = z.infer export type IpsecCertInfoDto = z.infer diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts index 38a7463..e671d12 100644 --- a/shared/api/ipsec.ts +++ b/shared/api/ipsec.ts @@ -3,6 +3,7 @@ import type { IpsecCertBundle, IpsecInitRequest, IpsecListResponse, + IpsecPeerPatch, IpsecServerSummaryDto, IpsecUserCreateRequest, IpsecUserCreated, @@ -106,3 +107,48 @@ export async function restoreIpsecRevision( body: JSON.stringify({ serverId }), }) } + +export async function patchIpsecPeer( + baseUrl: string, + serverId: string, + peerId: string, + patch: IpsecPeerPatch, +): Promise<{ ok: boolean }> { + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}`, + { method: "PATCH", body: JSON.stringify(patch) }, + ) +} + +export async function deleteIpsecPeer( + baseUrl: string, + serverId: string, + peerId: string, + opts?: { force?: boolean }, +): Promise<{ ok: boolean }> { + const q = new URLSearchParams() + if (opts?.force) q.set("force", "true") + const suffix = q.size > 0 ? `?${q.toString()}` : "" + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}${suffix}`, + { method: "DELETE" }, + ) +} + +export async function deleteIpsecCert( + baseUrl: string, + serverId: string, + name: string, + opts?: { force?: boolean }, +): Promise<{ ok: boolean }> { + const q = new URLSearchParams() + if (opts?.force) q.set("force", "true") + const suffix = q.size > 0 ? `?${q.toString()}` : "" + return requestJson<{ ok: boolean }>( + baseUrl, + `/api/ipsec/certs/${encodeURIComponent(serverId)}${suffix}`, + { method: "DELETE", body: JSON.stringify({ name }) }, + ) +} diff --git a/страна_раскрывает_сервисы_424c424b.plan.md b/страна_раскрывает_сервисы_424c424b.plan.md new file mode 100644 index 0000000..5ebf14d --- /dev/null +++ b/страна_раскрывает_сервисы_424c424b.plan.md @@ -0,0 +1,94 @@ +--- +name: Страна раскрывает сервисы +overview: Клик по стране на карте оставляет остальные страны на холсте и раскрывает справа столбец сервисов этой страны (доля от трафика страны). Данные — вложенная агрегация brand×ISO в том же hops-проходе. +todos: + - id: contract-nested + content: countryServiceGroups + mapCountryServiceNodeId в контракте/брендах + status: done + - id: backend-nested + content: Агрегация brand×country в hops, рёбра страна→сервис + status: done + - id: backend-nested-tests + content: Тесты групп US/NL, id cc:us|svc:*, fromId=страна + status: done + - id: ui-expand + content: "Клик по стране: сдвиг колонки, столбец сервисов, панель, mock" + status: done +isProject: false +--- + +# Раскрытие сервисов страны на карте + +Эталон холста — [`app/(main)/network-map/page.tsx`](c:/Users/shats/Dev/MikrotikManager-3/app/(main)/network-map/page.tsx) ([Frame](https://reui.io/docs/components/base/frame) не меняем: SVG, не ops-list). UX: **клик по стране → справа узлы сервисов, остальные страны остаются**. + +```mermaid +flowchart LR + dest[internetPeer] --> brand[mapInternetBrand] + dest --> geo[ISO country] + brand --> nested["group cc:us"] + geo --> nested + nested --> col["столбец сервисов"] + click[клик по стране] --> col +``` + +Сейчас hops считает бренд и страну **независимо** в одном цикле по `dstAcc` ([`traffic-flow-map-hops.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.ts) ~567–583). Связки «Google внутри US» в API нет — без неё UI может только гадать. + +## Поведение на холсте + +- Режим **Страны**, слой «Назначения» включён: как сейчас (EN → страны). +- **Клик по стране**: она selected; справа появляется **второй столбец** сервисов этой страны; остальные страны **остаются**, но приглушаются (`opacity` как у `isVis`). +- Рёбра раскрытия: **страна → сервис** (пунктир, те же подписи скорости). EN → страна для выбранной страны подсвечивается. +- Повторный клик по той же стране / клик по пустому холсту / смена `destMode` → свернуть столбец. +- Клик по другой стране → свернуть предыдущий набор и раскрыть новый. +- Клик по сервису: selected = сервис; столбец остаётся; панель — «Сервис в {страна}». + +Раскладка в [`lib/network-map-layout.ts`](c:/Users/shats/Dev/MikrotikManager-3/lib/network-map-layout.ts): сейчас одна колонка `x = W - MARGIN - SERVICE_COL_W/2`. При раскрытии: + +- страны сдвигаются влево на `SERVICE_COL_W` (чтобы не налезать на Канаду/Вьетнам с скрина); +- сервисы занимают прежний правый `x`, **по вертикали вокруг Y выбранной страны** (новый хелпер `placeCountryServiceNodes(ids, parentPos)`), не общий центр EN. + +Доля на узле сервиса = **bytes / bytes страны** (не доля окна): иначе у DE 2% все сервисы будут «0%». В панели дополнительно можно показать долю окна. + +Cap вложенного слоя: тот же `pickMapServices`, но `shareBase` = байты страны, cap **8**, min узлов **4**. «Прочее» внутри страны — `svc:other`. + +## Контракт + +В [`packages/contracts/src/traffic-flow.ts`](c:/Users/shats/Dev/MikrotikManager-3/packages/contracts/src/traffic-flow.ts) optional: + +```ts +countryServiceGroups: z.array(z.object({ + countryId: z.string(), // cc:us + services: z.array(flowMapServiceDtoSchema), + edges: z.array(flowMapServiceEdgeDtoSchema), // fromId = countryId + paths: z.array(flowMapServicePathDtoSchema), // serviceId = nested id +})).optional() +``` + +id сервиса во вложении: `cc:us|svc:google` (`mapCountryServiceNodeId`), чтобы Google в US и NL не смешивались в одном payload. Группы только для стран, прошедших `pickMapServices` стран. + +## Бэкенд + +В том же цикле `dstAcc`: после `svcId` + `country.id` копить `nestedTotals/edges/paths` с ключом `${country.id}|${svcId}`. Рёбра вложенного слоя: `fromId = country.id` (не EN). Пути: как сервисные, `serviceId` = nested id (enId остаётся для подсветки HR→JH→EN). + +`finalize` по каждой стране из `countriesOut.nodes`. + +## Фронт + +- Состояние `expandedCountryId` (не в sessionStorage). +- `visibleNested = groups.find(g => g.countryId === expandedCountryId)`. +- `ServiceNode` для вложенных: `destMode="services"` (бренд), подпись label, доля от страны. +- Панель страны: текущие KPI + короткий список топ-сервисов (клик = тот же expand/select). +- Панель сервиса: бренд + «в {countryName}», доля страны, пути `visibleNested.paths` с `serviceId`. +- Mock: `MOCK_COUNTRY_SERVICE_GROUPS` для `cc:us` (Google / Cloudflare / AWS). + +## Тесты + +[`traffic-flow-map-hops.test.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.test.ts): ripe US → группа `cc:us` с брендом 8.8.8.8; кейс US+NL — сервисы не пересекают `countryId`; nested `edges.fromId` = `cc:us`, не `svc:*`. + +Хелпер id — рядом с `mapCountryNodeId` в brands-тесте. + +## Вне скоупа + +- Одновременное раскрытие двух стран. +- Географическая карта мира. +- Отдельный порог NetFlow для вложенных сервисов (тот же %, но база = страна).