feat(ipsec): enhance IPsec management with peer and certificate handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s

- 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.
This commit is contained in:
Denozordec
2026-09-12 21:04:49 +07:00
parent 7755d77340
commit 7365d8d8fb
14 changed files with 953 additions and 106 deletions
@@ -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=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` 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<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
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/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
+146 -19
View File
@@ -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<IpsecCertBundle | null>(null)
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
const [peerOpen, setPeerOpen] = useState(false)
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
@@ -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<unknown>) => {
try {
await fn()
toast.success("Удалено")
await loadLive()
} catch (err) {
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
}
}
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
toast.error("На peer ссылаются identity", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
},
})
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
toast.error("Сертификат используется", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
},
})
} else {
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
}
} finally {
setBusy(false)
setPendingDelete(null)
}
}
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
setEditingPeer({ serverId, peer })
setPeerOpen(true)
}
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
setBusy(true)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
address: form.address.trim() || undefined,
exchangeMode: form.exchangeMode.trim() || undefined,
passive: form.passive,
certificate: form.certificate.trim() || undefined,
profile: form.profile.trim() || undefined,
disabled: form.disabled,
})
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
const deleteDialogTitle = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
case "user": return `Удалить клиента «${pendingDelete.client.name}»?`
case "peer": return `Удалить peer «${pendingDelete.peer.name}»?`
case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?`
}
})()
const deleteDialogDescription = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server":
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
case "user":
return "Удалит identity, персональный mode-config и клиентский сертификат на роутере."
case "peer":
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
case "cert":
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
}
})()
return (
<>
<ServerRailLayout
@@ -593,6 +715,9 @@ export default function IpsecPage() {
servers={scopedSummaries}
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
onEditPeer={isLive ? openEditPeer : undefined}
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
/>
</TabsContent>
@@ -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}
/>
<IpsecPeerSheet
open={peerOpen}
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
busy={busy}
editing={editingPeer?.peer ?? null}
certificates={certNames}
onSubmit={handleEditPeer}
/>
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
@@ -697,16 +832,8 @@ export default function IpsecPage() {
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>
{pendingDelete?.kind === "server"
? `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
: `Удалить клиента «${pendingDelete?.kind === "user" ? pendingDelete.client.name : ""}»?`}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "server"
? "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
: "Удалит identity, персональный mode-config и клиентский сертификат на роутере."}
</AlertDialogDescription>
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
+134 -18
View File
@@ -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=<name>; существующий 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<string, string> = {}
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 })
+26
View File
@@ -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",
+29
View File
@@ -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"
+21 -20
View File
@@ -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<IpsecServerSta
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = managed-идентичности (созданы менеджером). */
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const certByCommonName = new Map<string, RosCertRow>()
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<string, RosIpsecActivePeer>()
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<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
+8
View File
@@ -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<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
}
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
}
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
+149
View File
@@ -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<void>
}) {
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
const set = <K extends keyof IpsecPeerFormState>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{editing ? `Peer «${editing.name}»` : "Peer"}</SheetTitle>
<SheetDescription>
{editing?.managed
? "Managed peer: изменения применяются напрямую на роутере"
: "Существующий peer RouterOS: изменения применяются напрямую"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Имя" required>
<Input value={form.name} onChange={(e) => set("name", e.target.value)} />
</FormField>
<FormField label="Адрес" hint="Обычно 0.0.0.0/0 для road-warrior">
<Input className="font-mono" value={form.address} onChange={(e) => set("address", e.target.value)} />
</FormField>
<FormField label="Exchange mode">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.exchangeMode}
onChange={(e) => set("exchangeMode", e.target.value)}
>
{EXCHANGE_MODES.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</FormField>
<FormField label="Сертификат" hint="Серверный сертификат роутера">
<Input
className="font-mono"
list="ipsec-peer-certs"
placeholder="ipsec-server"
value={form.certificate}
onChange={(e) => set("certificate", e.target.value)}
/>
<datalist id="ipsec-peer-certs">
{certificates.map((c) => <option key={c} value={c} />)}
</datalist>
</FormField>
<FormField label="Profile" hint="Пусто — не менять">
<Input className="font-mono" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Состояние</SectionTitle>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Passive</p>
<FormToggle checked={form.passive} onChange={(v) => set("passive", v)} />
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Отключён</p>
<FormToggle checked={form.disabled} onChange={(v) => set("disabled", v)} />
</div>
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? "Сохранение…" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecPeerSheet }
+204 -49
View File
@@ -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 : (
<Badge variant="outline" className="text-[10px]">RouterOS</Badge>
)
}
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 (
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-medium">{peer.name}</span>
<OriginBadge managed={peer.managed} />
{peer.disabled ? <Badge variant="outline" className="text-[10px]">выкл</Badge> : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground">{summary || "—"}</p>
</div>
<div className="flex shrink-0 gap-0.5">
{onEdit ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
aria-label="Изменить peer"
title="Изменить peer"
onClick={() => onEdit(peer)}
>
<PencilIcon className="size-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 text-destructive"
aria-label="Удалить peer"
title="Удалить peer"
onClick={() => onDelete(peer)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
</div>
)
}
function CertRow({
cert,
onDelete,
}: {
cert: IpsecCertInfoDto
onDelete?: (cert: IpsecCertInfoDto) => void
}) {
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
return (
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">{cert.name}</span>
<Badge variant="outline" className="text-[10px]">{roleLabel}</Badge>
<OriginBadge managed={cert.managed} />
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground">
{[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"}
</p>
</div>
{onDelete ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-destructive"
aria-label="Удалить сертификат"
title="Удалить сертификат"
onClick={() => onDelete(cert)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
}
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 (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => (
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{s.serverName}</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{s.serverEndpoint ?? "не инициализирован"}
</p>
{servers.map((s) => {
const certs = s.certs ?? []
return (
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{s.serverName}</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{s.serverEndpoint ?? "endpoint не определён"}
</p>
</div>
{s.peers.length > 0 ? (
<Badge className="gap-1">
<ShieldCheckIcon className="size-3" />
{s.peers.length} peer
</Badge>
) : (
<Badge variant="outline">нет peer</Badge>
)}
</div>
{s.initialized ? (
<Badge className="gap-1">
<ShieldCheckIcon className="size-3" />
IKEv2
</Badge>
) : (
<Badge variant="outline">нет</Badge>
)}
</div>
<div className="flex flex-col divide-y divide-border/60">
<Row label="Клиенты" value={`${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
<Row label="Peer" value={s.peer ? `${s.peer.name} (${s.peer.exchangeMode ?? "ike2"}${s.peer.passive ? ", passive" : ""})` : "—"} />
<Row label="CA-сертификат" value={s.caCert ? `${s.caCert.commonName ?? s.caCert.name}${s.caCert.expiresAt ? ` · до ${s.caCert.expiresAt}` : ""}` : "—"} />
<Row label="Серверный серт." value={s.serverCert ? `${s.serverCert.commonName ?? s.serverCert.name}${s.serverCert.expiresAt ? ` · до ${s.serverCert.expiresAt}` : ""}` : "—"} />
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Peer / серверы
</p>
{s.peers.length > 0 ? (
<div className="flex flex-col gap-1.5">
{s.peers.map((p) => (
<PeerRow
key={p.rosId}
peer={p}
onEdit={onEditPeer ? (peer) => onEditPeer(s.serverId, peer) : undefined}
onDelete={onDeletePeer ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">Peers не найдены</p>
)}
</div>
<div className="mt-auto flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onInit?.(s)}
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
>
<GlobeIcon className="size-3.5" />
{s.initialized ? "Переинициализировать" : "Инициализировать"}
</Button>
{s.initialized && onRemove ? (
<div className="flex flex-col gap-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Сертификаты
</p>
{certs.length > 0 ? (
<div className="flex flex-col gap-1.5">
{certs.map((c) => (
<CertRow
key={c.name}
cert={c}
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">Сертификаты не найдены</p>
)}
</div>
<div className="mt-auto flex flex-col divide-y divide-border/60">
<Row label="Клиенты" value={`${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
<Row label="Managed IKEv2" value={s.initialized ? "инициализирован" : "не инициализирован"} mono={false} />
</div>
<div className="flex justify-end gap-1.5">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-destructive"
aria-label="Удалить IKEv2-сервер"
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
onClick={() => onRemove(s)}
size="sm"
variant="outline"
onClick={() => onInit?.(s)}
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
>
<Trash2Icon className="size-3.5" />
<GlobeIcon className="size-3.5" />
{s.initialized ? "Переинициализировать" : "Инициализировать"}
</Button>
) : null}
{s.initialized && onRemove ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-destructive"
aria-label="Удалить IKEv2-сервер"
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
onClick={() => onRemove(s)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
</div>
</div>
))}
)
})}
{servers.length === 0 ? (
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<NetworkIcon className="size-4" />
+25
View File
@@ -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({
</select>
</FormField>
) : null}
{!editing && (peers ?? []).some((p) => p.serverId === form.serverId) ? (
<FormField label="Peer (сервер)" hint="К какому peer привязать identity">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.peerName}
onChange={(e) => set("peerName", e.target.value)}
>
<option value="">Автоматически (managed/первый)</option>
{(peers ?? [])
.filter((p) => p.serverId === form.serverId)
.map((p) => (
<option key={`${p.serverId}:${p.name}`} value={p.name}>
{p.name}{p.managed ? "" : " (RouterOS)"}
</option>
))}
</select>
</FormField>
) : null}
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
<Input
placeholder="alice"
+3
View File
@@ -67,6 +67,9 @@ function IpsecUsersGrid({
{c.disabled ? (
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
) : null}
{!c.managed ? (
<Badge variant="outline" className="ml-1 text-[10px]">RouterOS</Badge>
) : null}
</div>
)
},
+22
View File
@@ -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<typeof ipsecAuthMethodSchema>
export type IpsecClientDto = z.infer<typeof ipsecClientDtoSchema>
export type IpsecPeerDto = z.infer<typeof ipsecPeerDtoSchema>
export type IpsecPeerPatch = z.infer<typeof ipsecPeerPatchSchema>
export type IpsecCertDeleteRequest = z.infer<typeof ipsecCertDeleteRequestSchema>
export type IpsecModeConfigDto = z.infer<typeof ipsecModeConfigDtoSchema>
export type IpsecPoolDto = z.infer<typeof ipsecPoolDtoSchema>
export type IpsecCertInfoDto = z.infer<typeof ipsecCertInfoDtoSchema>
+46
View File
@@ -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 }) },
)
}
@@ -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) ~567583). Связки «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 для вложенных сервисов (тот же %, но база = страна).