Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3502b9755 | ||
|
|
0e1524c600 | ||
|
|
11ae7593f4 | ||
|
|
3eb75ea0b8 | ||
|
|
7365d8d8fb |
@@ -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): …`.
|
||||||
+187
-28
@@ -11,6 +11,7 @@ import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid"
|
|||||||
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
|
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
|
||||||
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
|
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
|
||||||
import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-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 { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -32,20 +33,26 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
createIpsecUser,
|
createIpsecUser,
|
||||||
|
deleteIpsecCert,
|
||||||
|
deleteIpsecPeer,
|
||||||
deleteIpsecServer,
|
deleteIpsecServer,
|
||||||
deleteIpsecUser,
|
deleteIpsecUser,
|
||||||
|
exportIpsecCertByName,
|
||||||
exportIpsecUserCert,
|
exportIpsecUserCert,
|
||||||
initIpsecServer,
|
initIpsecServer,
|
||||||
listIpsec,
|
listIpsec,
|
||||||
|
patchIpsecPeer,
|
||||||
patchIpsecUser,
|
patchIpsecUser,
|
||||||
restoreIpsecRevision,
|
restoreIpsecRevision,
|
||||||
} from "@/shared/api/ipsec"
|
} from "@/shared/api/ipsec"
|
||||||
import type {
|
import type {
|
||||||
IpsecCertBundle,
|
IpsecCertBundle,
|
||||||
|
IpsecCertInfoDto,
|
||||||
IpsecClientDto,
|
IpsecClientDto,
|
||||||
|
IpsecPeerDto,
|
||||||
IpsecServerSummaryDto,
|
IpsecServerSummaryDto,
|
||||||
} from "@mmapp/contracts/ipsec"
|
} from "@mmapp/contracts/ipsec"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
@@ -68,16 +75,35 @@ type IpsecFailure = { serverId: string; serverName?: string; error: string }
|
|||||||
type PendingDelete =
|
type PendingDelete =
|
||||||
| { kind: "user"; client: IpsecClientDto }
|
| { kind: "user"; client: IpsecClientDto }
|
||||||
| { kind: "server"; summary: IpsecServerSummaryDto }
|
| { 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[] = [
|
const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
||||||
{
|
{
|
||||||
serverId: "srv2",
|
serverId: "srv2",
|
||||||
serverName: "mt-spb-edge-01",
|
serverName: "mt-spb-edge-01",
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
ike2Ready: true,
|
||||||
serverEndpoint: "vpn.example.com",
|
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 },
|
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 },
|
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 },
|
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 },
|
serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true },
|
||||||
natRuleManaged: true,
|
natRuleManaged: true,
|
||||||
@@ -90,18 +116,18 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
|||||||
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
|
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
|
||||||
{
|
{
|
||||||
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
|
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
name: "alice", authMethod: "certificate", certificateName: "ipsec-user-alice", commonName: "alice",
|
name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice",
|
||||||
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
|
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
|
||||||
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
|
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
|
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
name: "bob", authMethod: "certificate", certificateName: "ipsec-user-bob", commonName: "bob",
|
name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
|
||||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
|
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
name: "tablet-psk", authMethod: "pre-shared-key", remoteId: "tablet",
|
name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
|
||||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -157,6 +183,9 @@ export default function IpsecPage() {
|
|||||||
const [certOpen, setCertOpen] = useState(false)
|
const [certOpen, setCertOpen] = useState(false)
|
||||||
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
||||||
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
|
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
|
||||||
|
const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null)
|
||||||
|
const [peerOpen, setPeerOpen] = useState(false)
|
||||||
|
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
@@ -270,11 +299,19 @@ export default function IpsecPage() {
|
|||||||
)
|
)
|
||||||
}, [scopedClients, search])
|
}, [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 clientsOnline = scopedClients.filter((c) => c.online).length
|
||||||
const caOk = scopedSummaries.some((s) => s.caCert)
|
const caOk = scopedSummaries.some((s) => s.caCert)
|
||||||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
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) => ({
|
const serverOptions = displayServers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -284,7 +321,7 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
/** Подсказка свободного IP из пула выбранного сервера. */
|
/** Подсказка свободного IP из пула выбранного сервера. */
|
||||||
const freeIpHint = useMemo(() => {
|
const freeIpHint = useMemo(() => {
|
||||||
const summary = scopedSummaries.find((s) => s.initialized && s.pool)
|
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
|
||||||
if (!summary?.pool) return undefined
|
if (!summary?.pool) return undefined
|
||||||
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
||||||
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
||||||
@@ -340,6 +377,7 @@ export default function IpsecPage() {
|
|||||||
const res = await createIpsecUser(backendUrl, {
|
const res = await createIpsecUser(backendUrl, {
|
||||||
serverId: form.serverId,
|
serverId: form.serverId,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
peerName: form.peerName.trim() || undefined,
|
||||||
authMethod: form.authMethod,
|
authMethod: form.authMethod,
|
||||||
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
|
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
|
||||||
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
|
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
|
||||||
@@ -353,6 +391,7 @@ export default function IpsecPage() {
|
|||||||
if (res.bundle) {
|
if (res.bundle) {
|
||||||
setCertBundle(res.bundle)
|
setCertBundle(res.bundle)
|
||||||
setCertClient(res.client ?? null)
|
setCertClient(res.client ?? null)
|
||||||
|
setCertByName(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -385,13 +424,38 @@ export default function IpsecPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openCert = async (client: IpsecClientDto) => {
|
const openCert = async (client: IpsecClientDto) => {
|
||||||
setCertClient(client)
|
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||||
|
setCertClient(byName ? null : client)
|
||||||
|
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
setCertBusy(true)
|
setCertBusy(true)
|
||||||
try {
|
try {
|
||||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
const bundle = await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
const bundle = byName
|
||||||
|
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
||||||
|
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||||
|
setCertByName({ serverId, name: cert.name })
|
||||||
|
setCertClient(null)
|
||||||
|
setCertBundle(null)
|
||||||
|
setCertOpen(true)
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
||||||
setCertBundle(bundle)
|
setCertBundle(bundle)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Ошибка экспорта сертификата", {
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
@@ -404,10 +468,12 @@ export default function IpsecPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reexportCert = async (passphrase: string) => {
|
const reexportCert = async (passphrase: string) => {
|
||||||
if (!certClient) return
|
if (!certClient && !certByName) return
|
||||||
setCertBusy(true)
|
setCertBusy(true)
|
||||||
try {
|
try {
|
||||||
const bundle = await exportIpsecUserCert(backendUrl, certClient.serverId, certClient.rosId, passphrase)
|
const bundle = certByName
|
||||||
|
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
||||||
setCertBundle(bundle)
|
setCertBundle(bundle)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||||
@@ -418,24 +484,111 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!pendingDelete) return
|
if (!pendingDelete) return
|
||||||
|
const target = pendingDelete
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (pendingDelete.kind === "user") {
|
if (target.kind === "user") {
|
||||||
await deleteIpsecUser(backendUrl, pendingDelete.client.serverId, pendingDelete.client.rosId)
|
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
|
||||||
toast.success(`Клиент «${pendingDelete.client.name}» удалён`)
|
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 {
|
} else {
|
||||||
await deleteIpsecServer(backendUrl, pendingDelete.summary.serverId)
|
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
|
||||||
toast.success(`IKEv2-сервер на ${pendingDelete.summary.serverName} удалён`)
|
toast.success(`Сертификат «${target.cert.name}» удалён`)
|
||||||
}
|
}
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setPendingDelete(null)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ServerRailLayout
|
<ServerRailLayout
|
||||||
@@ -593,6 +746,10 @@ export default function IpsecPage() {
|
|||||||
servers={scopedSummaries}
|
servers={scopedSummaries}
|
||||||
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
|
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
|
||||||
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
|
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
|
||||||
|
onEditPeer={isLive ? openEditPeer : undefined}
|
||||||
|
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
||||||
|
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
||||||
|
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -667,6 +824,7 @@ export default function IpsecPage() {
|
|||||||
open={userOpen}
|
open={userOpen}
|
||||||
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
|
peers={peerOptions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -674,6 +832,15 @@ export default function IpsecPage() {
|
|||||||
onSubmit={editing ? handleEditUser : handleCreateUser}
|
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
|
<IpsecInitSheet
|
||||||
open={initOpen}
|
open={initOpen}
|
||||||
onOpenChange={setInitOpen}
|
onOpenChange={setInitOpen}
|
||||||
@@ -685,7 +852,7 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
<IpsecCertSheet
|
<IpsecCertSheet
|
||||||
open={certOpen}
|
open={certOpen}
|
||||||
onOpenChange={setCertOpen}
|
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
||||||
bundle={certBundle}
|
bundle={certBundle}
|
||||||
busy={certBusy}
|
busy={certBusy}
|
||||||
onReexport={reexportCert}
|
onReexport={reexportCert}
|
||||||
@@ -697,16 +864,8 @@ export default function IpsecPage() {
|
|||||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
</AlertDialogMedia>
|
</AlertDialogMedia>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
||||||
{pendingDelete?.kind === "server"
|
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
||||||
? `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
|
|
||||||
: `Удалить клиента «${pendingDelete?.kind === "user" ? pendingDelete.client.name : ""}»?`}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{pendingDelete?.kind === "server"
|
|
||||||
? "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
|
|
||||||
: "Удалит identity, персональный mode-config и клиентский сертификат на роутере."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
||||||
|
|||||||
+293
-54
@@ -1,8 +1,11 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import type { FastifyReply } from "fastify"
|
import type { FastifyReply } from "fastify"
|
||||||
import {
|
import {
|
||||||
|
ipsecCertDeleteRequestSchema,
|
||||||
|
ipsecCertExportByNameRequestSchema,
|
||||||
ipsecCertExportRequestSchema,
|
ipsecCertExportRequestSchema,
|
||||||
ipsecInitRequestSchema,
|
ipsecInitRequestSchema,
|
||||||
|
ipsecPeerPatchSchema,
|
||||||
ipsecUserCreateRequestSchema,
|
ipsecUserCreateRequestSchema,
|
||||||
ipsecUserPatchSchema,
|
ipsecUserPatchSchema,
|
||||||
type IpsecCertBundle,
|
type IpsecCertBundle,
|
||||||
@@ -16,12 +19,17 @@ import {
|
|||||||
buildClientInstructions,
|
buildClientInstructions,
|
||||||
buildSswanConfig,
|
buildSswanConfig,
|
||||||
clientCertName,
|
clientCertName,
|
||||||
findFreePoolIp,
|
identityDisplayName,
|
||||||
ipsecManagedComment,
|
ipsecManagedComment,
|
||||||
ipsecUserComment,
|
ipsecUserComment,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
parseIpsecUserComment,
|
parseIpsecUserComment,
|
||||||
poolRangesFromCidr,
|
poolRangesFromCidr,
|
||||||
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
|
selectSharedIke2Identity,
|
||||||
userModeConfigName,
|
userModeConfigName,
|
||||||
} from "../services/ipsec-config.js"
|
} from "../services/ipsec-config.js"
|
||||||
import {
|
import {
|
||||||
@@ -37,12 +45,15 @@ import {
|
|||||||
putIdentity,
|
putIdentity,
|
||||||
patchIdentity,
|
patchIdentity,
|
||||||
deleteIdentity,
|
deleteIdentity,
|
||||||
|
patchPeer,
|
||||||
|
deletePeer,
|
||||||
|
ensureSharedIke2Identity,
|
||||||
listByPath,
|
listByPath,
|
||||||
} from "../services/ipsec-ros.js"
|
} from "../services/ipsec-ros.js"
|
||||||
import {
|
import {
|
||||||
ensureCaCertificate,
|
ensureCaCertificate,
|
||||||
ensureServerCertificate,
|
ensureServerCertificate,
|
||||||
exportClientP12,
|
exportCertificateP12ByName,
|
||||||
findCertificate,
|
findCertificate,
|
||||||
issueClientCertificate,
|
issueClientCertificate,
|
||||||
} from "../services/ipsec-ca.js"
|
} from "../services/ipsec-ca.js"
|
||||||
@@ -108,11 +119,11 @@ function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "a
|
|||||||
|
|
||||||
async function buildCertBundle(
|
async function buildCertBundle(
|
||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
args: { userName: string; serverEndpoint: string; passphrase: string; dns?: string },
|
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
||||||
): Promise<IpsecCertBundle> {
|
): Promise<IpsecCertBundle> {
|
||||||
const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase)
|
const certName = args.certName?.trim() || clientCertName(args.userName)
|
||||||
|
const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase)
|
||||||
const p12B64 = content.toString("base64")
|
const p12B64 = content.toString("base64")
|
||||||
const certName = clientCertName(args.userName)
|
|
||||||
return {
|
return {
|
||||||
user: args.userName,
|
user: args.userName,
|
||||||
serverEndpoint: args.serverEndpoint,
|
serverEndpoint: args.serverEndpoint,
|
||||||
@@ -240,22 +251,45 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
const state = await fetchIpsecState(server)
|
const state = await fetchIpsecState(server)
|
||||||
const peer = state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const peer = (body.peerName
|
||||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
||||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
: undefined)
|
||||||
if (!peer || !sharedMc || !caCert || !serverCert) {
|
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||||||
return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" })
|
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
?? ike2Peers[0]
|
||||||
|
?? state.peers[0]
|
||||||
|
if (!peer) {
|
||||||
|
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||||||
|
}
|
||||||
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
|
const ike2Identities = state.identities.filter(
|
||||||
|
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
||||||
|
)
|
||||||
|
const idMcName = ike2Identities
|
||||||
|
.map((i) => (i["mode-config"] ?? "").trim())
|
||||||
|
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
||||||
|
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
||||||
|
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||||
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||||
|
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const peerName = (peer.name ?? "").trim()
|
const peerName = (peer.name ?? "").trim()
|
||||||
const serverEndpoint = String(serverCert["common-name"] ?? "").trim()
|
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||||
const dns = sharedMc["static-dns"]?.trim() || undefined
|
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||||
|
|
||||||
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
||||||
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
||||||
}
|
}
|
||||||
|
|
||||||
let modeConfig = (sharedMc.name ?? "").trim()
|
const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||||
|
const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined
|
||||||
|
|
||||||
|
let modeConfig = (sharedMc?.name ?? "").trim()
|
||||||
if (body.staticIp) {
|
if (body.staticIp) {
|
||||||
const ip = body.staticIp.trim()
|
const ip = body.staticIp.trim()
|
||||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||||
@@ -271,7 +305,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (body.authMethod === "certificate") {
|
if (body.authMethod === "certificate") {
|
||||||
const issued = await issueClientCertificate(client, {
|
const issued = await issueClientCertificate(client, {
|
||||||
userName: body.name,
|
userName: body.name,
|
||||||
caCertName: IPSEC_CA_CERT,
|
caCertName: caName!,
|
||||||
daysValid: body.daysValid ?? 1825,
|
daysValid: body.daysValid ?? 1825,
|
||||||
})
|
})
|
||||||
if (issued.existed) {
|
if (issued.existed) {
|
||||||
@@ -280,16 +314,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
certName = issued.certName
|
certName = issued.certName
|
||||||
}
|
}
|
||||||
|
|
||||||
await putIdentity(client, {
|
if (body.authMethod === "certificate" && !body.staticIp) {
|
||||||
peerName,
|
// Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна,
|
||||||
modeConfig,
|
// RouterOS сам принимает любой клиентский серт, подписанный доверенным CA.
|
||||||
comment: ipsecUserComment(body.name),
|
await ensureSharedIke2Identity(client, {
|
||||||
authMethod: body.authMethod,
|
peerName,
|
||||||
certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined,
|
serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||||||
remoteCertificate: certName,
|
modeConfigName: modeConfig,
|
||||||
secret: body.psk,
|
comment: ipsecManagedComment("IKEv2 listener"),
|
||||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
})
|
||||||
})
|
} else {
|
||||||
|
// PSK или персональный статический IP: отдельная identity (match-by=certificate для cert).
|
||||||
|
// place-before гарантирует матчинг раньше общей listener-identity.
|
||||||
|
await putIdentity(client, {
|
||||||
|
peerName,
|
||||||
|
modeConfig,
|
||||||
|
comment: ipsecUserComment(body.name),
|
||||||
|
authMethod: body.authMethod,
|
||||||
|
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||||
|
remoteCertificate: certName,
|
||||||
|
secret: body.psk,
|
||||||
|
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||||
|
placeBefore: sharedIdentityId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let bundle: IpsecCertBundle | undefined
|
let bundle: IpsecCertBundle | undefined
|
||||||
if (body.authMethod === "certificate" && certName) {
|
if (body.authMethod === "certificate" && certName) {
|
||||||
@@ -326,42 +374,88 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
const state = await fetchIpsecState(server)
|
const state = await fetchIpsecState(server)
|
||||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
const targetRosId = rosIdParam(rosId)
|
||||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||||
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? ""
|
const certRow = identity
|
||||||
|
? undefined
|
||||||
|
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||||
|
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||||
|
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||||
|
|
||||||
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
|
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||||
|
const managedIdentity = identity
|
||||||
|
? isIpsecManagedComment(identity.comment)
|
||||||
|
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||||||
|
const oldName = identity
|
||||||
|
? (parseIpsecUserComment(identity.comment) ?? identity.comment ?? "")
|
||||||
|
: String(certRow?.["common-name"] ?? certRow?.name ?? "").trim()
|
||||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
const sharedMcName = (sharedMc?.name ?? "").trim()
|
||||||
|
const newName = d.name?.trim() || oldName
|
||||||
|
|
||||||
if (d.name && d.name !== oldName) {
|
if (d.name && d.name !== oldName) {
|
||||||
await patchIdentity(client, identity[".id"]!, {
|
if (identity) {
|
||||||
comment: ipsecUserComment(d.name),
|
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||||||
})
|
await patchIdentity(client, identity[".id"]!, {
|
||||||
|
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
|
||||||
|
if (certRow?.[".id"] && managedIdentity) {
|
||||||
|
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (d.staticIp !== undefined) {
|
if (identity && d.staticIp !== undefined) {
|
||||||
|
const currentMc = (identity["mode-config"] ?? "").trim()
|
||||||
if (d.staticIp == null) {
|
if (d.staticIp == null) {
|
||||||
// вернуть выдачу из пула
|
// вернуть выдачу из пула
|
||||||
if (sharedMc) await patchIdentity(client, identity[".id"]!, { "mode-config": (sharedMc.name ?? "").trim() })
|
if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName })
|
||||||
const personal = (identity["mode-config"] ?? "").trim()
|
const personal = currentMc && currentMc !== sharedMcName ? currentMc : ""
|
||||||
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
|
if (personal) await deleteUserModeConfig(client, personal)
|
||||||
} else {
|
} else {
|
||||||
const ip = d.staticIp.trim()
|
const ip = d.staticIp.trim()
|
||||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||||
const others = takenStaticIps(
|
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} уже назначен другому клиенту` })
|
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||||
const name = userModeConfigName(d.name || oldName)
|
const name = userModeConfigName(newName)
|
||||||
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`))
|
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||||||
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
|
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
|
||||||
}
|
}
|
||||||
|
} else if (!identity && certRow && d.staticIp) {
|
||||||
|
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
|
||||||
|
const ip = d.staticIp.trim()
|
||||||
|
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||||
|
const taken = takenStaticIps(state.modeConfigs)
|
||||||
|
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||||
|
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
|
||||||
|
if (!peer) return reply.status(400).send({ error: "На роутере нет IKEv2 peer для привязки клиента" })
|
||||||
|
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||||
|
const mcName = userModeConfigName(newName)
|
||||||
|
await putUserModeConfig(client, mcName, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||||||
|
await putIdentity(client, {
|
||||||
|
peerName: (peer.name ?? "").trim(),
|
||||||
|
modeConfig: mcName,
|
||||||
|
comment: ipsecUserComment(newName),
|
||||||
|
authMethod: "certificate",
|
||||||
|
certificate: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||||||
|
remoteCertificate: String(certRow.name ?? "").trim(),
|
||||||
|
placeBefore: shared?.[".id"] ? String(shared[".id"]) : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch: Record<string, string> = {}
|
if (identity) {
|
||||||
if (d.psk) patch.secret = d.psk
|
const patch: Record<string, string> = {}
|
||||||
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
|
if (d.psk) patch.secret = d.psk
|
||||||
if (d.disabled === true) patch.disabled = "yes"
|
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
|
||||||
if (d.disabled === false) patch.disabled = "no"
|
if (d.disabled === true) patch.disabled = "yes"
|
||||||
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
|
if (d.disabled === false) patch.disabled = "no"
|
||||||
|
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
|
||||||
|
}
|
||||||
|
|
||||||
await recordIpsec(server, "apply")
|
await recordIpsec(server, "apply")
|
||||||
return reply.send({ ok: true })
|
return reply.send({ ok: true })
|
||||||
@@ -379,18 +473,130 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
const state = await fetchIpsecState(server)
|
const state = await fetchIpsecState(server)
|
||||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
const targetRosId = rosIdParam(rosId)
|
||||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
const certRow = identity
|
||||||
const personal = (identity["mode-config"] ?? "").trim()
|
? undefined
|
||||||
|
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||||
|
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||||
|
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||||
|
const managed = identity
|
||||||
|
? isIpsecManagedComment(identity.comment)
|
||||||
|
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||||||
|
const userName = identity ? (parseIpsecUserComment(identity.comment) ?? "") : ""
|
||||||
|
const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
|
||||||
|
const personal = identity ? (identity["mode-config"] ?? "").trim() : ""
|
||||||
|
const remoteCert = identity
|
||||||
|
? (identity["remote-certificate"] ?? "").trim()
|
||||||
|
: String(certRow?.name ?? "").trim()
|
||||||
|
|
||||||
await deleteIdentity(client, identity[".id"]!)
|
if (identity) {
|
||||||
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
|
await deleteIdentity(client, identity[".id"]!)
|
||||||
if (removeCertificate && identity["remote-certificate"]) {
|
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||||
await client.removeCertificate(identity["remote-certificate"]).catch(() => undefined)
|
|
||||||
} else if (removeCertificate && userName) {
|
|
||||||
await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
if (removeCertificate) {
|
||||||
|
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
|
||||||
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
|
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
||||||
|
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
|
||||||
|
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
|
||||||
|
if (certName && certName !== caName && certName !== serverCertName) {
|
||||||
|
await client.removeCertificate(certName).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await recordIpsec(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return errReply(reply, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
await recordIpsec(server, "apply")
|
||||||
return reply.send({ ok: true })
|
return reply.send({ ok: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -456,6 +662,39 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||||
|
app.post("/ipsec/certs/:serverId/export", async (req, reply) => {
|
||||||
|
const { serverId } = req.params as { serverId: string }
|
||||||
|
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const { name, passphrase } = 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 cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
||||||
|
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
||||||
|
const serverCertRow = resolveIke2ServerCert(state.certs, selectIke2Peers(state.peers))
|
||||||
|
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||||
|
const userName = String(cert["common-name"] ?? "").trim() || name
|
||||||
|
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||||
|
const bundle = await buildCertBundle(client, {
|
||||||
|
userName,
|
||||||
|
certName: name,
|
||||||
|
serverEndpoint,
|
||||||
|
passphrase,
|
||||||
|
dns,
|
||||||
|
})
|
||||||
|
return reply.send(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
return errReply(reply, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get("/ipsec/revisions", async (req, reply) => {
|
app.get("/ipsec/revisions", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
const q = req.query as { serverId?: string | number }
|
||||||
const serverId = parseDbServerId(q.serverId)
|
const serverId = parseDbServerId(q.serverId)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
certificateRole,
|
||||||
|
isCaCertificate,
|
||||||
|
mapRosCertificateRow,
|
||||||
|
type RosCertificateRow,
|
||||||
|
} from "./certificate-parse.js"
|
||||||
|
|
||||||
|
// Реальные серты пользователя: MyCA (root) → vpn-server, client1, anakondra.
|
||||||
|
const myCa: RosCertificateRow = {
|
||||||
|
name: "MyCA",
|
||||||
|
"common-name": "MyCA",
|
||||||
|
"key-usage": "key-cert-sign,crl-sign",
|
||||||
|
ca: "",
|
||||||
|
flags: "KAT",
|
||||||
|
authority: "true",
|
||||||
|
}
|
||||||
|
const vpnServer: RosCertificateRow = {
|
||||||
|
name: "vpn-server",
|
||||||
|
"common-name": "vpn.example.com",
|
||||||
|
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
|
||||||
|
ca: "MyCA",
|
||||||
|
flags: "KLAT",
|
||||||
|
}
|
||||||
|
const client1: RosCertificateRow = {
|
||||||
|
name: "client1",
|
||||||
|
"common-name": "client1",
|
||||||
|
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
|
||||||
|
ca: "MyCA",
|
||||||
|
flags: "KLAT",
|
||||||
|
}
|
||||||
|
const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-name": "anakondra" }
|
||||||
|
|
||||||
|
{
|
||||||
|
assert.ok(isCaCertificate(myCa))
|
||||||
|
// default key-usage содержит key-cert-sign, но ca заполнен → не CA
|
||||||
|
assert.ok(!isCaCertificate(vpnServer))
|
||||||
|
assert.ok(!isCaCertificate(client1))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const ctx = {
|
||||||
|
peerCertNames: ["vpn-server"],
|
||||||
|
identityCertNames: ["client1", "anakondra"],
|
||||||
|
}
|
||||||
|
assert.equal(certificateRole(myCa, ctx), "ca")
|
||||||
|
assert.equal(certificateRole(vpnServer, ctx), "server")
|
||||||
|
// reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client
|
||||||
|
assert.equal(certificateRole(client1, ctx), "client")
|
||||||
|
assert.equal(certificateRole(anakondra, ctx), "client")
|
||||||
|
// без reference-контекста: подпись локальным CA делает серт клиентским, несмотря на tls-server в usage
|
||||||
|
assert.equal(certificateRole(client1), "client")
|
||||||
|
assert.equal(certificateRole(anakondra), "client")
|
||||||
|
// без контекста — по key-usage (default содержит оба, поэтому server)
|
||||||
|
assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client")
|
||||||
|
assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const dto = mapRosCertificateRow(1, "mt", client1)
|
||||||
|
assert.equal(dto?.signedByCertName, "MyCA")
|
||||||
|
assert.equal(mapRosCertificateRow(1, "mt", myCa)?.signedByCertName, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("certificate-parse.test.ts: ok")
|
||||||
@@ -2,6 +2,64 @@ import type { CertificateDto } from "@mmapp/contracts/certificates"
|
|||||||
|
|
||||||
export type RosCertificateRow = Record<string, string | undefined>
|
export type RosCertificateRow = Record<string, string | undefined>
|
||||||
|
|
||||||
|
/** Роль сертификата в контексте IPsec/IKEv2. */
|
||||||
|
export type CertRole = "ca" | "server" | "client" | "other"
|
||||||
|
|
||||||
|
/** Контекст для reference-based определения роли (peer/identity ссылки точнее, чем key-usage). */
|
||||||
|
export interface CertificateRoleContext {
|
||||||
|
/** Имена сертификатов из `peer.certificate`. */
|
||||||
|
peerCertNames?: Iterable<string>
|
||||||
|
/** Имена сертификатов из `identity.remote-certificate`. */
|
||||||
|
identityCertNames?: Iterable<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosTrue(v: string | undefined): boolean {
|
||||||
|
return v === "true" || v === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Флаги RouterOS (`flags` = строка вида "KLAT"). */
|
||||||
|
function certFlags(row: RosCertificateRow): string {
|
||||||
|
return (row.flags ?? "").toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CA: `key-cert-sign`, серт self-signed (read-only `ca` пуст).
|
||||||
|
* Только флаг authority/`A` не годится: RouterOS ставит `A` и серверным сертам,
|
||||||
|
* т.к. дефолтный `key-usage` содержит `key-cert-sign`. Реальный признак — отсутствие подписавшего.
|
||||||
|
*/
|
||||||
|
export function isCaCertificate(row: RosCertificateRow): boolean {
|
||||||
|
const usage = row["key-usage"] ?? ""
|
||||||
|
const signer = (row.ca ?? "").trim()
|
||||||
|
if (signer !== "") return false
|
||||||
|
return certFlags(row).includes("A") || rosTrue(row.authority) || usage.includes("key-cert-sign")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Роль сертификата. Приоритет:
|
||||||
|
* 1. CA (authority / key-cert-sign без подписавшего);
|
||||||
|
* 2. серт из `peer.certificate` → server;
|
||||||
|
* 3. серт из `identity.remote-certificate` → client;
|
||||||
|
* 4. `key-usage` tls-server → server, tls-client → client (reference-based важнее: RouterOS
|
||||||
|
* дефолт содержит и tls-server, и tls-client).
|
||||||
|
*/
|
||||||
|
export function certificateRole(
|
||||||
|
row: RosCertificateRow,
|
||||||
|
ctx: CertificateRoleContext = {},
|
||||||
|
): CertRole {
|
||||||
|
const name = (row.name ?? "").trim()
|
||||||
|
if (isCaCertificate(row)) return "ca"
|
||||||
|
if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server"
|
||||||
|
if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client"
|
||||||
|
// Серт, подписанный локальным CA (`ca` заполнен), но не используемый как peer-серт — клиентский.
|
||||||
|
// `client1`/`anakondra` имеют дефолтный key-usage (tls-server+tls-client), поэтому по usage
|
||||||
|
// они выглядели бы «server».
|
||||||
|
if ((row.ca ?? "").trim() !== "") return "client"
|
||||||
|
const usage = row["key-usage"] ?? ""
|
||||||
|
if (usage.includes("tls-server")) return "server"
|
||||||
|
if (usage.includes("tls-client")) return "client"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
function parseRosDate(raw: string | undefined): Date | null {
|
function parseRosDate(raw: string | undefined): Date | null {
|
||||||
if (!raw?.trim()) return null
|
if (!raw?.trim()) return null
|
||||||
const d = new Date(raw)
|
const d = new Date(raw)
|
||||||
@@ -88,6 +146,8 @@ export function mapRosCertificateRow(
|
|||||||
trusted: row.trusted === "true",
|
trusted: row.trusted === "true",
|
||||||
status,
|
status,
|
||||||
acmeStatus: row["acme-status"]?.trim() || undefined,
|
acmeStatus: row["acme-status"]?.trim() || undefined,
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
|
||||||
|
signedByCertName: (row.ca ?? "").trim() || undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,15 +107,24 @@ export async function issueClientCertificate(
|
|||||||
return { certName, commonName: args.userName.trim(), existed: false }
|
return { certName, commonName: args.userName.trim(), existed: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Экспорт произвольного сертификата по имени (существующие client1/anakondra и т.п.). */
|
||||||
|
export async function exportCertificateP12ByName(
|
||||||
|
client: MikrotikClient,
|
||||||
|
certName: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||||
|
const name = certName.trim()
|
||||||
|
const cert = await findCertificate(client, name)
|
||||||
|
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
||||||
|
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase })
|
||||||
|
return { fileName, content, certName: name }
|
||||||
|
}
|
||||||
|
|
||||||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||||||
export async function exportClientP12(
|
export async function exportClientP12(
|
||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
userName: string,
|
userName: string,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||||
const certName = clientCertName(userName)
|
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
|
||||||
const cert = await findCertificate(client, certName)
|
|
||||||
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
|
|
||||||
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
|
|
||||||
return { fileName, content, certName }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ import {
|
|||||||
buildSswanConfig,
|
buildSswanConfig,
|
||||||
clientCertName,
|
clientCertName,
|
||||||
findFreePoolIp,
|
findFreePoolIp,
|
||||||
|
identityDisplayName,
|
||||||
ipsecManagedComment,
|
ipsecManagedComment,
|
||||||
ipsecSlug,
|
ipsecSlug,
|
||||||
ipsecUserComment,
|
ipsecUserComment,
|
||||||
|
isIke2Peer,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
parseIpsecUserComment,
|
parseIpsecUserComment,
|
||||||
poolRangesFromCidr,
|
poolRangesFromCidr,
|
||||||
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
|
selectSharedIke2Identity,
|
||||||
userModeConfigName,
|
userModeConfigName,
|
||||||
} from "./ipsec-config.js"
|
} from "./ipsec-config.js"
|
||||||
|
import { identityRosBody } from "./ipsec-ros.js"
|
||||||
|
|
||||||
{
|
{
|
||||||
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
|
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
|
||||||
@@ -52,6 +60,31 @@ import {
|
|||||||
assert.equal(findFreePoolIp("мусор", []), null)
|
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": "[email protected]", ".id": "*3" }, []), "[email protected]")
|
||||||
|
// 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({
|
const sswan = buildSswanConfig({
|
||||||
name: "IKEv2 vpn.example.com",
|
name: "IKEv2 vpn.example.com",
|
||||||
@@ -84,4 +117,75 @@ import {
|
|||||||
assert.ok(text.includes("strongSwan"))
|
assert.ok(text.includes("strongSwan"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IKEv2-only отбор ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const grePeer = { name: "gre-tunnel", "exchange-mode": "ike2", address: "1.2.3.4", passive: "no" }
|
||||||
|
const s2sPeer = { name: "s2s", "exchange-mode": "main", address: "5.6.7.8", certificate: "vpn-server" }
|
||||||
|
const rwPeer = { name: "vpn-server", "exchange-mode": "ike2", address: "0.0.0.0/0", passive: "yes", certificate: "vpn-server" }
|
||||||
|
|
||||||
|
assert.ok(isIke2Peer(rwPeer))
|
||||||
|
assert.ok(!isIke2Peer(s2sPeer))
|
||||||
|
|
||||||
|
const selected = selectIke2Peers([grePeer, s2sPeer, rwPeer])
|
||||||
|
assert.deepEqual(selected.map((p) => p.name), ["vpn-server"])
|
||||||
|
// fallback: нет строгого набора → ike2 + сертификат
|
||||||
|
const fallback = selectIke2Peers([grePeer, { name: "ike2-cert", "exchange-mode": "ike2", certificate: "c" }])
|
||||||
|
assert.deepEqual(fallback.map((p) => p.name), ["ike2-cert"])
|
||||||
|
|
||||||
|
const names = ["vpn-server"]
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity(
|
||||||
|
{ peer: "vpn-server", "mode-config": "ipsec-vpn", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1" },
|
||||||
|
names,
|
||||||
|
))
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server" }, names))
|
||||||
|
// GRE/site-to-site PSK-туннели отсеиваются
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "s2s", "auth-method": "pre-shared-key" }, names))
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "pre-shared-key" }, names))
|
||||||
|
// без ограничения по peer (набор пуст) — mode-config/серт всё равно нужен
|
||||||
|
assert.ok(isIke2RemoteAccessIdentity({ peer: "x", "mode-config": "mc" }, []))
|
||||||
|
assert.ok(!isIke2RemoteAccessIdentity({ peer: "x", "auth-method": "pre-shared-key" }, []))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const myCa = { name: "MyCA", "key-usage": "key-cert-sign,crl-sign", ca: "", flags: "KAT", authority: "true" }
|
||||||
|
const vpnServer = { name: "vpn-server", "key-usage": "digital-signature,key-encipherment,tls-server,tls-client", ca: "MyCA" }
|
||||||
|
const client1 = { name: "client1", "key-usage": "digital-signature,tls-client,tls-server", ca: "MyCA" }
|
||||||
|
const certs = [myCa, vpnServer, client1]
|
||||||
|
|
||||||
|
assert.equal(resolveIke2ServerCert(certs, [{ name: "vpn-server", certificate: "vpn-server" }])?.name, "vpn-server")
|
||||||
|
assert.equal(resolveIke2ServerCert([myCa], [])?.name, undefined)
|
||||||
|
assert.equal(resolveIke2CaName(certs, vpnServer), "MyCA")
|
||||||
|
assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Общая listener-identity: без remote-certificate, сертификатный auth.
|
||||||
|
const shared = { ".id": "*S", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "mode-config": "ikev2-modeconf" }
|
||||||
|
const perClient = { ".id": "*C", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1", "match-by": "certificate" }
|
||||||
|
const psk = { ".id": "*P", peer: "vpn-server", "auth-method": "pre-shared-key" }
|
||||||
|
const foreignPeer = { ".id": "*F", peer: "gre-tunnel", "auth-method": "rsa-key", certificate: "vpn-server" }
|
||||||
|
|
||||||
|
assert.equal(selectSharedIke2Identity([perClient, psk, foreignPeer, shared], ["vpn-server"])?.[".id"], "*S")
|
||||||
|
// только per-client identity (с remote-certificate) общей не считается
|
||||||
|
assert.equal(selectSharedIke2Identity([perClient, psk], ["vpn-server"]), undefined)
|
||||||
|
// managed-приоритет
|
||||||
|
const managed = { ".id": "*M", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", comment: ipsecManagedComment("listener") }
|
||||||
|
assert.equal(selectSharedIke2Identity([shared, managed], ["vpn-server"])?.[".id"], "*M")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const body = identityRosBody({
|
||||||
|
peerName: "vpn-server",
|
||||||
|
modeConfig: "mc-ipsec-alice",
|
||||||
|
comment: ipsecUserComment("alice"),
|
||||||
|
authMethod: "certificate",
|
||||||
|
certificate: "vpn-server",
|
||||||
|
remoteCertificate: "ipsec-user-alice",
|
||||||
|
placeBefore: "*S",
|
||||||
|
})
|
||||||
|
assert.equal(body["place-before"], "*S")
|
||||||
|
assert.equal(body["remote-certificate"], "ipsec-user-alice")
|
||||||
|
assert.equal(body["match-by"], "certificate")
|
||||||
|
}
|
||||||
|
|
||||||
console.log("ipsec-config.test.ts: ok")
|
console.log("ipsec-config.test.ts: ok")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { PRODUCT_NAME } from "../managed-markers.js"
|
import { PRODUCT_NAME } from "../managed-markers.js"
|
||||||
|
import { isCaCertificate } from "./certificate-parse.js"
|
||||||
|
|
||||||
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
|
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
|
||||||
|
|
||||||
@@ -24,6 +25,65 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
|
|||||||
return m?.[1] ?? null
|
return m?.[1] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IdentityLike = {
|
||||||
|
".id"?: string
|
||||||
|
comment?: string
|
||||||
|
"remote-id"?: string
|
||||||
|
"remote-certificate"?: string
|
||||||
|
peer?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity + поля, нужные для отбора IKEv2 remote-access. */
|
||||||
|
export interface Ike2IdentityLike extends IdentityLike {
|
||||||
|
certificate?: string
|
||||||
|
"auth-method"?: string
|
||||||
|
"mode-config"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peer (/ip/ipsec/peer). */
|
||||||
|
export interface PeerLike {
|
||||||
|
".id"?: string
|
||||||
|
name?: string
|
||||||
|
address?: string
|
||||||
|
"exchange-mode"?: string
|
||||||
|
passive?: string
|
||||||
|
certificate?: string
|
||||||
|
comment?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertLike {
|
||||||
|
name?: string
|
||||||
|
"common-name"?: string
|
||||||
|
"key-usage"?: string
|
||||||
|
/** read-only: имя CA, которым подписан серт. */
|
||||||
|
ca?: string
|
||||||
|
/** флаги RouterOS (строка вида "KLAT"). */
|
||||||
|
flags?: string
|
||||||
|
authority?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка сертификата RouterOS (raw). */
|
||||||
|
type RosRow = Record<string, string | undefined>
|
||||||
|
|
||||||
|
/** Отображаемое имя 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 ──────────────────────────
|
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
|
||||||
|
|
||||||
export const IPSEC_CA_CERT = "ipsec-ca"
|
export const IPSEC_CA_CERT = "ipsec-ca"
|
||||||
@@ -47,6 +107,91 @@ export function userModeConfigName(userName: string): string {
|
|||||||
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
|
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── отбор IKEv2 remote-access (без GRE/site-to-site) ────────────────────────
|
||||||
|
|
||||||
|
function rosBool(v: string | undefined): boolean {
|
||||||
|
return v === "true" || v === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyAddress(a: string | undefined): boolean {
|
||||||
|
const v = (a ?? "").trim()
|
||||||
|
return v === "" || v === "0.0.0.0/0" || v === "::/0"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peer с IKEv2 (exchange-mode ike2). */
|
||||||
|
export function isIke2Peer(p: PeerLike): boolean {
|
||||||
|
return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат.
|
||||||
|
* Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер).
|
||||||
|
*/
|
||||||
|
export function selectIke2Peers<T extends PeerLike>(peers: T[]): T[] {
|
||||||
|
const ike2 = peers.filter(isIke2Peer)
|
||||||
|
const strict = ike2.filter(
|
||||||
|
(p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()),
|
||||||
|
)
|
||||||
|
if (strict.length > 0) return strict
|
||||||
|
const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim()))
|
||||||
|
return withCert.length > 0 ? withCert : ike2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config
|
||||||
|
* либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются.
|
||||||
|
* Пустой набор peer-имён → не ограничиваем по peer.
|
||||||
|
*/
|
||||||
|
export function isIke2RemoteAccessIdentity(
|
||||||
|
i: Ike2IdentityLike,
|
||||||
|
ike2PeerNames?: Iterable<string>,
|
||||||
|
): boolean {
|
||||||
|
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
|
||||||
|
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
|
||||||
|
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
|
||||||
|
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
|
||||||
|
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
|
||||||
|
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общая listener-identity на IKEv2 peer: `remote-certificate` пуст, auth-method сертификатный.
|
||||||
|
* Именно она обслуживает всех клиентов, чей серт подписан доверенным CA (RouterOS валидирует по CA,
|
||||||
|
* см. docs «If a remote-certificate is not specified then the received certificate is checked against CA»).
|
||||||
|
* Приоритет managed, иначе первая подходящая (на устройстве это `denozord`/`ikev2-peer`).
|
||||||
|
*/
|
||||||
|
export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
|
||||||
|
identities: T[],
|
||||||
|
ike2PeerNames?: Iterable<string>,
|
||||||
|
): T | undefined {
|
||||||
|
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
|
||||||
|
const candidates = identities.filter((i) => {
|
||||||
|
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
|
||||||
|
if ((i["remote-certificate"] ?? "").trim() !== "") return false
|
||||||
|
const auth = (i["auth-method"] ?? "").trim().toLowerCase()
|
||||||
|
if (auth !== "rsa-key" && auth !== "digital-signature") return false
|
||||||
|
return Boolean((i.certificate ?? "").trim())
|
||||||
|
})
|
||||||
|
return candidates.find((i) => isIpsecManagedComment(i.comment)) ?? candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
|
||||||
|
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
|
||||||
|
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
|
||||||
|
return certs.find((c) => names.has(String(c.name ?? "").trim()))
|
||||||
|
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
|
||||||
|
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */
|
||||||
|
export function resolveIke2CaName<T extends RosRow>(certs: T[], serverCert?: T | null): string | undefined {
|
||||||
|
const signed = String(serverCert?.ca ?? "").trim()
|
||||||
|
if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed
|
||||||
|
if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT
|
||||||
|
const ca = certs.find((c) => isCaCertificate(c))
|
||||||
|
return ca ? String(ca.name ?? "").trim() || undefined : undefined
|
||||||
|
}
|
||||||
|
|
||||||
// ── пул адресов клиентов ────────────────────────────────────────────────────
|
// ── пул адресов клиентов ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ipToInt(b: Array<number | undefined>): number {
|
function ipToInt(b: Array<number | undefined>): number {
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ import {
|
|||||||
IPSEC_COMMON_NAME,
|
IPSEC_COMMON_NAME,
|
||||||
IPSEC_SERVER_CERT,
|
IPSEC_SERVER_CERT,
|
||||||
clientCertName,
|
clientCertName,
|
||||||
|
identityDisplayName,
|
||||||
|
isIke2RemoteAccessIdentity,
|
||||||
isIpsecManagedComment,
|
isIpsecManagedComment,
|
||||||
parseIpsecUserComment,
|
resolveIke2CaName,
|
||||||
|
resolveIke2ServerCert,
|
||||||
|
selectIke2Peers,
|
||||||
|
selectSharedIke2Identity,
|
||||||
} from "./ipsec-config.js"
|
} from "./ipsec-config.js"
|
||||||
|
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
|
||||||
import {
|
import {
|
||||||
canonicalIpsecSnapshot,
|
canonicalIpsecSnapshot,
|
||||||
type IpsecLiveIdentity,
|
type IpsecLiveIdentity,
|
||||||
@@ -107,11 +113,13 @@ function asBool(v: string | undefined): boolean {
|
|||||||
return v === "true" || v === "yes"
|
return v === "true" || v === "yes"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
|
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
|
||||||
const name = String(c.name ?? "")
|
const name = String(c.name ?? "")
|
||||||
const keyUsage = String(c["key-usage"] ?? "")
|
|
||||||
const isCa = keyUsage.includes("key-cert-sign")
|
|
||||||
const isUser = name.startsWith("ipsec-user-")
|
const isUser = name.startsWith("ipsec-user-")
|
||||||
|
const role = certificateRole(c, {
|
||||||
|
peerCertNames: ctx.peerCertNames,
|
||||||
|
identityCertNames: ctx.identityCertNames,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
commonName: c["common-name"] || undefined,
|
commonName: c["common-name"] || undefined,
|
||||||
@@ -120,8 +128,9 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
|
|||||||
expiresAt: c["invalid-after"] || undefined,
|
expiresAt: c["invalid-after"] || undefined,
|
||||||
trusted: asBool(c.trusted),
|
trusted: asBool(c.trusted),
|
||||||
hasPrivateKey: asBool(c["private-key"]),
|
hasPrivateKey: asBool(c["private-key"]),
|
||||||
role: isCa ? "ca" : name === IPSEC_SERVER_CERT ? "server" : isUser ? "client" : "other",
|
role,
|
||||||
managed: isCa && name === IPSEC_CA_CERT
|
signedBy: (c.ca ?? "").trim() || undefined,
|
||||||
|
managed: (name === IPSEC_CA_CERT && role === "ca")
|
||||||
|| name === IPSEC_SERVER_CERT
|
|| name === IPSEC_SERVER_CERT
|
||||||
|| isUser
|
|| isUser
|
||||||
|| isIpsecManagedComment(c.comment),
|
|| isIpsecManagedComment(c.comment),
|
||||||
@@ -202,99 +211,195 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
|
|||||||
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Клиенты = managed-идентичности (созданы менеджером). */
|
/**
|
||||||
|
* Клиенты IKEv2:
|
||||||
|
* 1) клиентские сертификаты (роль client) — включая существующие client1/anakondra на устройстве;
|
||||||
|
* 2) PSK-identity.
|
||||||
|
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
|
||||||
|
*/
|
||||||
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
const certByCommonName = new Map<string, RosCertRow>()
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
for (const c of state.certs) {
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
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 mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
|
||||||
|
const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
|
||||||
|
|
||||||
|
const roleCtx: CertificateRoleContext = {
|
||||||
|
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
|
||||||
|
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
||||||
|
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||||
|
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
|
||||||
|
|
||||||
const activeByRemote = new Map<string, RosIpsecActivePeer>()
|
const activeByRemote = new Map<string, RosIpsecActivePeer>()
|
||||||
for (const a of state.active) {
|
for (const a of state.active) {
|
||||||
const rid = String(a["remote-id"] ?? "").trim()
|
const rid = String(a["remote-id"] ?? "").trim()
|
||||||
if (rid) activeByRemote.set(rid, a)
|
if (rid) activeByRemote.set(rid, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.identities
|
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
|
||||||
.filter((i) => isIpsecManagedComment(i.comment))
|
for (const i of state.identities) {
|
||||||
.map((i): IpsecClientDto => {
|
const rc = (i["remote-certificate"] ?? "").trim()
|
||||||
const comment = i.comment ?? ""
|
if (rc) identityByRemoteCert.set(rc, i)
|
||||||
const name = parseIpsecUserComment(comment) ?? (comment || "client")
|
}
|
||||||
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
|
|
||||||
const certName = (i["remote-certificate"] ?? "").trim()
|
const personalStaticIp = (mcNameRaw: string): string | undefined => {
|
||||||
const cn = certName
|
const mc = mcByName.get(mcNameRaw)
|
||||||
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
|
if (!mc) return undefined
|
||||||
: ""
|
return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
|
||||||
const mcName = (i["mode-config"] ?? "").trim()
|
}
|
||||||
const mc = mcByName.get(mcName)
|
|
||||||
const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-")
|
const out: IpsecClientDto[] = []
|
||||||
? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined
|
|
||||||
: undefined
|
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
|
||||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
for (const cert of state.certs) {
|
||||||
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
|
const certName = String(cert.name ?? "").trim()
|
||||||
return {
|
if (!certName) continue
|
||||||
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
|
if (certificateRole(cert, roleCtx) !== "client") continue
|
||||||
rosId: String(i[".id"] ?? "identity"),
|
const signedBy = (cert.ca ?? "").trim()
|
||||||
serverId: String(server.id),
|
const isUser = certName.startsWith("ipsec-user-")
|
||||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
const referenced = identityByRemoteCert.has(certName)
|
||||||
name,
|
if (!isUser && !referenced && !(caName && signedBy === caName)) continue
|
||||||
authMethod: psk ? "pre-shared-key" : "certificate",
|
|
||||||
certificateName: certName || undefined,
|
const identity = identityByRemoteCert.get(certName)
|
||||||
commonName: cn || undefined,
|
const comment = identity?.comment ?? ""
|
||||||
remoteId: (i["remote-id"] ?? "").trim() || undefined,
|
const mcName = (identity?.["mode-config"] ?? "").trim()
|
||||||
staticIp,
|
const cn = String(cert["common-name"] ?? "").trim()
|
||||||
modeConfigName: mcName || undefined,
|
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||||
peerName: (i.peer ?? "").trim() || undefined,
|
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
|
||||||
online: Boolean(active),
|
out.push({
|
||||||
activeAddress: active?.address || undefined,
|
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||||
activeSince: active?.established || undefined,
|
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
|
||||||
disabled: asBool(i.disabled),
|
serverId: String(server.id),
|
||||||
comment: comment || undefined,
|
serverName,
|
||||||
managed: true,
|
name: cn || certName,
|
||||||
}
|
authMethod: "certificate",
|
||||||
|
kind: "cert",
|
||||||
|
certificateName: certName,
|
||||||
|
certName,
|
||||||
|
signedBy: signedBy || undefined,
|
||||||
|
commonName: cn || undefined,
|
||||||
|
remoteId: (identity?.["remote-id"] ?? "").trim() || undefined,
|
||||||
|
staticIp: personalStaticIp(mcName),
|
||||||
|
modeConfigName: mcName || undefined,
|
||||||
|
peerName: (identity?.peer ?? "").trim() || undefined,
|
||||||
|
online: Boolean(active),
|
||||||
|
activeAddress: active?.address || undefined,
|
||||||
|
activeSince: active?.established || undefined,
|
||||||
|
disabled: asBool(identity?.disabled),
|
||||||
|
comment: comment || undefined,
|
||||||
|
managed: isUser || isIpsecManagedComment(comment),
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
}
|
||||||
|
|
||||||
|
// 2. PSK-identity (managed или IKEv2 remote-access).
|
||||||
|
for (const i of state.identities) {
|
||||||
|
if ((i["auth-method"] ?? "").trim().toLowerCase() !== "pre-shared-key") continue
|
||||||
|
const rosId = String(i[".id"] ?? "")
|
||||||
|
if (sharedRosId && rosId === sharedRosId) continue
|
||||||
|
const managed = isIpsecManagedComment(i.comment)
|
||||||
|
if (!managed && !isIke2RemoteAccessIdentity(i, ike2PeerNames)) continue
|
||||||
|
const mcName = (i["mode-config"] ?? "").trim()
|
||||||
|
const remoteId = (i["remote-id"] ?? "").trim()
|
||||||
|
const active = remoteId ? activeByRemote.get(remoteId) : undefined
|
||||||
|
out.push({
|
||||||
|
id: `${server.id}:${rosId || "identity"}`,
|
||||||
|
rosId: rosId || "identity",
|
||||||
|
serverId: String(server.id),
|
||||||
|
serverName,
|
||||||
|
name: identityDisplayName(i, state.certs),
|
||||||
|
authMethod: "pre-shared-key",
|
||||||
|
kind: "psk",
|
||||||
|
remoteId: remoteId || undefined,
|
||||||
|
staticIp: personalStaticIp(mcName),
|
||||||
|
modeConfigName: mcName || undefined,
|
||||||
|
peerName: (i.peer ?? "").trim() || undefined,
|
||||||
|
online: Boolean(active),
|
||||||
|
activeAddress: active?.address || undefined,
|
||||||
|
activeSince: active?.established || undefined,
|
||||||
|
disabled: asBool(i.disabled),
|
||||||
|
comment: i.comment || undefined,
|
||||||
|
managed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
const sharedMc = state.modeConfigs.find(
|
const ike2Identities = state.identities.filter(
|
||||||
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
|
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||||||
|
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
/** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */
|
||||||
|
const primaryPeer = managedPeer ?? ike2Peers[0]
|
||||||
|
|
||||||
|
// mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared
|
||||||
|
const idMcName = ike2Identities
|
||||||
|
.map((i) => (i["mode-config"] ?? "").trim())
|
||||||
|
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
||||||
|
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
||||||
|
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
|
?? state.modeConfigs.find((m) => isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-"))
|
||||||
const pool = sharedMc
|
const pool = sharedMc
|
||||||
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
|
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
|
||||||
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
||||||
: undefined
|
: undefined
|
||||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
|
||||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||||
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||||
|
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
|
||||||
|
|
||||||
|
const roleCtx: CertificateRoleContext = {
|
||||||
|
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
|
||||||
|
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
||||||
|
}
|
||||||
|
const referencedCertNames = new Set<string>([
|
||||||
|
...(roleCtx.peerCertNames ?? []),
|
||||||
|
...(roleCtx.identityCertNames ?? []),
|
||||||
|
...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean),
|
||||||
|
])
|
||||||
|
// только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA
|
||||||
|
const relevantCerts = state.certs.filter((c) => {
|
||||||
|
const n = String(c.name ?? "").trim()
|
||||||
|
if (caName && n === caName) return true
|
||||||
|
if (referencedCertNames.has(n)) return true
|
||||||
|
return Boolean(caName) && (c.ca ?? "").trim() === caName
|
||||||
|
})
|
||||||
|
|
||||||
|
const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined
|
||||||
|
const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined
|
||||||
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
||||||
return {
|
return {
|
||||||
serverId: String(server.id),
|
serverId: String(server.id),
|
||||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||||
serverCountry: server.country ?? undefined,
|
serverCountry: server.country ?? undefined,
|
||||||
initialized: Boolean(managedPeer && caCert && serverCert),
|
initialized: Boolean(managedPeer && caCert && serverCert),
|
||||||
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
|
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
|
||||||
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
|
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
|
||||||
|
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
||||||
|
peers: ike2Peers.map((p) => mapPeer(server, p)),
|
||||||
pool: pool ? mapPool(server, pool) : undefined,
|
pool: pool ? mapPool(server, pool) : undefined,
|
||||||
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
||||||
caCert: caCert ? mapCertificate(caCert) : undefined,
|
caCert,
|
||||||
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
|
serverCert,
|
||||||
natRuleManaged,
|
natRuleManaged,
|
||||||
clientsTotal: clients.length,
|
clientsTotal: clients.length,
|
||||||
clientsOnline: clients.filter((c) => c.online).length,
|
clientsOnline: clients.filter((c) => c.online).length,
|
||||||
certs: state.certs
|
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
|
||||||
.filter((c) => {
|
|
||||||
const n = String(c.name ?? "")
|
|
||||||
return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-")
|
|
||||||
})
|
|
||||||
.map(mapCertificate),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
|
||||||
|
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
|
||||||
|
*/
|
||||||
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
|
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
|
||||||
const state = await fetchIpsecState(server)
|
const state = await fetchIpsecState(server)
|
||||||
return canonicalIpsecSnapshot({
|
return canonicalIpsecSnapshot({
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { MikrotikClient } from "./mikrotik.js"
|
import type { MikrotikClient } from "./mikrotik.js"
|
||||||
import { ipsecManagedComment } from "./ipsec-config.js"
|
import {
|
||||||
|
ipsecManagedComment,
|
||||||
|
selectIke2Peers,
|
||||||
|
selectSharedIke2Identity,
|
||||||
|
type Ike2IdentityLike,
|
||||||
|
type PeerLike,
|
||||||
|
} from "./ipsec-config.js"
|
||||||
|
|
||||||
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
|
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
|
||||||
const out: Record<string, string> = {}
|
const out: Record<string, string> = {}
|
||||||
@@ -177,6 +183,8 @@ export interface IdentityFields {
|
|||||||
/** psk. */
|
/** psk. */
|
||||||
secret?: string
|
secret?: string
|
||||||
remoteId?: string
|
remoteId?: string
|
||||||
|
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
|
||||||
|
placeBefore?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function identityRosBody(f: IdentityFields): Record<string, string> {
|
export function identityRosBody(f: IdentityFields): Record<string, string> {
|
||||||
@@ -190,6 +198,7 @@ export function identityRosBody(f: IdentityFields): Record<string, string> {
|
|||||||
"remote-id": f.remoteId,
|
"remote-id": f.remoteId,
|
||||||
"mode-config": f.modeConfig,
|
"mode-config": f.modeConfig,
|
||||||
"generate-policy": "port-strict",
|
"generate-policy": "port-strict",
|
||||||
|
"place-before": f.placeBefore,
|
||||||
comment: f.comment,
|
comment: f.comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -198,6 +207,33 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields
|
|||||||
await client.put("/ip/ipsec/identity", identityRosBody(fields))
|
await client.put("/ip/ipsec/identity", identityRosBody(fields))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общая listener-identity на IKEv2 peer для всех клиентов (reuse существующей, иначе создать).
|
||||||
|
* Без `remote-certificate`/`match-by` — RouterOS принимает любой клиентский серт, подписанный доверенным CA.
|
||||||
|
*/
|
||||||
|
export async function ensureSharedIke2Identity(
|
||||||
|
client: MikrotikClient,
|
||||||
|
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const identities = await listByPath(client, "/ip/ipsec/identity")
|
||||||
|
const peers = await listByPath(client, "/ip/ipsec/peer")
|
||||||
|
const peerNames = selectIke2Peers(peers as PeerLike[]).map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
|
const existing = selectSharedIke2Identity(
|
||||||
|
identities as Ike2IdentityLike[],
|
||||||
|
peerNames.length > 0 ? peerNames : [args.peerName],
|
||||||
|
)
|
||||||
|
if (existing?.[".id"]) return String(existing[".id"])
|
||||||
|
|
||||||
|
await putIdentity(client, {
|
||||||
|
peerName: args.peerName,
|
||||||
|
modeConfig: args.modeConfigName ?? "",
|
||||||
|
comment: args.comment,
|
||||||
|
authMethod: "certificate",
|
||||||
|
certificate: args.serverCertName,
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
|
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
|
||||||
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
|
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
|
||||||
}
|
}
|
||||||
@@ -206,6 +242,14 @@ export async function deleteIdentity(client: MikrotikClient, rosId: string): Pro
|
|||||||
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
|
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 не шлём. */
|
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
|
||||||
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
|
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
|
||||||
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
|
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
|
||||||
|
|||||||
@@ -415,6 +415,13 @@ export function encodeRosId(rosId: string): string {
|
|||||||
|
|
||||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Имя параметра из ошибки RouterOS `unknown parameter <name>` (400) — для деградации запроса. */
|
||||||
|
function unknownParameterName(error: MikrotikError): string | undefined {
|
||||||
|
if (error.statusCode !== 400) return undefined
|
||||||
|
const match = error.body.match(/unknown parameter\s+"?([A-Za-z0-9_-]+)"?/i)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
export class MikrotikClient {
|
export class MikrotikClient {
|
||||||
constructor(private readonly params: MikrotikConnectParams) {}
|
constructor(private readonly params: MikrotikConnectParams) {}
|
||||||
|
|
||||||
@@ -689,9 +696,32 @@ export class MikrotikClient {
|
|||||||
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
|
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST с деградацией по несовместимым параметрам: набор полей `/certificate/*` зависит от версии
|
||||||
|
* RouterOS (напр. `comment`, `days-valid`). При 400 «unknown parameter X» убираем X и повторяем.
|
||||||
|
*/
|
||||||
|
private async postTolerant(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, string>,
|
||||||
|
timeoutMs: number,
|
||||||
|
maxAttempts = 4,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const payload: Record<string, string> = { ...body }
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await this.post(path, payload, timeoutMs)
|
||||||
|
} catch (error) {
|
||||||
|
const param = error instanceof MikrotikError ? unknownParameterName(error) : undefined
|
||||||
|
if (!param || !(param in payload)) throw error
|
||||||
|
delete payload[param]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`RouterOS: не удалось выполнить ${path} (несовместимые параметры): ${Object.keys(body).join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
|
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
|
||||||
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
|
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
|
||||||
return this.post("/certificate/add", body, timeoutMs)
|
return this.postTolerant("/certificate/add", body, timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
|
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
|
||||||
@@ -704,14 +734,14 @@ export class MikrotikClient {
|
|||||||
if (params.ca) body.ca = params.ca
|
if (params.ca) body.ca = params.ca
|
||||||
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
|
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
|
||||||
try {
|
try {
|
||||||
return await this.post("/certificate/sign", body, timeoutMs)
|
return await this.postTolerant("/certificate/sign", body, timeoutMs)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Некоторые версии REST принимают цель подписи только как .id.
|
// Некоторые версии REST принимают цель подписи только как .id.
|
||||||
const certs = await this.getCertificates()
|
const certs = await this.getCertificates()
|
||||||
const row = certs.find((c) => String(c.name ?? "") === params.name)
|
const row = certs.find((c) => String(c.name ?? "") === params.name)
|
||||||
const id = row?.[".id"]
|
const id = row?.[".id"]
|
||||||
if (!id) throw e
|
if (!id) throw e
|
||||||
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
return await this.postTolerant("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,12 +755,12 @@ export class MikrotikClient {
|
|||||||
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
|
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
|
||||||
let raw: unknown
|
let raw: unknown
|
||||||
try {
|
try {
|
||||||
raw = await this.post("/certificate/export-certificate", body, timeoutMs)
|
raw = await this.postTolerant("/certificate/export-certificate", body, timeoutMs)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const certs = await this.getCertificates()
|
const certs = await this.getCertificates()
|
||||||
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
|
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
|
||||||
if (!id) throw e
|
if (!id) throw e
|
||||||
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
raw = await this.postTolerant("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
||||||
}
|
}
|
||||||
void raw
|
void raw
|
||||||
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
|
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client"
|
"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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
|
DownloadIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
|
PencilIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -22,70 +25,254 @@ 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,
|
||||||
|
onExport,
|
||||||
|
}: {
|
||||||
|
cert: IpsecCertInfoDto
|
||||||
|
onDelete?: (cert: IpsecCertInfoDto) => void
|
||||||
|
onExport?: (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.signedBy ? `подписан ${cert.signedBy}` : undefined,
|
||||||
|
cert.expiresAt ? `до ${cert.expiresAt}` : undefined,
|
||||||
|
].filter(Boolean).join(" · ") || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-0.5">
|
||||||
|
{onExport && cert.role === "client" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7"
|
||||||
|
aria-label="Скачать .p12"
|
||||||
|
title="Скачать .p12 (сертификат + ключ)"
|
||||||
|
onClick={() => onExport(cert)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface IpsecServerGridProps {
|
export interface IpsecServerGridProps {
|
||||||
servers: IpsecServerSummaryDto[]
|
servers: IpsecServerSummaryDto[]
|
||||||
onInit?: (row: IpsecServerSummaryDto) => void
|
onInit?: (row: IpsecServerSummaryDto) => void
|
||||||
onRemove?: (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
|
||||||
|
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function IpsecServerGrid({ servers, onInit, onRemove }: IpsecServerGridProps) {
|
function IpsecServerGrid({
|
||||||
|
servers,
|
||||||
|
onInit,
|
||||||
|
onRemove,
|
||||||
|
onEditPeer,
|
||||||
|
onDeletePeer,
|
||||||
|
onDeleteCert,
|
||||||
|
onExportCert,
|
||||||
|
}: IpsecServerGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{servers.map((s) => (
|
{servers.map((s) => {
|
||||||
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
const certs = s.certs ?? []
|
||||||
<div className="flex items-start justify-between gap-2">
|
return (
|
||||||
<div className="min-w-0">
|
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||||
<p className="truncate text-sm font-medium">{s.serverName}</p>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
<div className="min-w-0">
|
||||||
{s.serverEndpoint ?? "не инициализирован"}
|
<p className="truncate text-sm font-medium">{s.serverName}</p>
|
||||||
</p>
|
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||||
|
{s.serverEndpoint ?? "endpoint не определён"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
{s.ike2Ready ? (
|
||||||
|
<Badge className="gap-1">
|
||||||
|
<ShieldCheckIcon className="size-3" />
|
||||||
|
IKEv2 готов
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{s.peers.length > 0 ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
{s.peers.length} peer
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">нет peer</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Row label="Клиенты" value={`${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
|
Peer / серверы
|
||||||
<Row label="Peer" value={s.peer ? `${s.peer.name} (${s.peer.exchangeMode ?? "ike2"}${s.peer.passive ? ", passive" : ""})` : "—"} />
|
</p>
|
||||||
<Row label="CA-сертификат" value={s.caCert ? `${s.caCert.commonName ?? s.caCert.name}${s.caCert.expiresAt ? ` · до ${s.caCert.expiresAt}` : ""}` : "—"} />
|
{s.peers.length > 0 ? (
|
||||||
<Row label="Серверный серт." value={s.serverCert ? `${s.serverCert.commonName ?? s.serverCert.name}${s.serverCert.expiresAt ? ` · до ${s.serverCert.expiresAt}` : ""}` : "—"} />
|
<div className="flex flex-col gap-1.5">
|
||||||
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
|
{s.peers.map((p) => (
|
||||||
</div>
|
<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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
type="button"
|
Сертификаты
|
||||||
size="sm"
|
</p>
|
||||||
variant="outline"
|
{certs.length > 0 ? (
|
||||||
onClick={() => onInit?.(s)}
|
<div className="flex flex-col gap-1.5">
|
||||||
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
|
{certs.map((c) => (
|
||||||
>
|
<CertRow
|
||||||
<GlobeIcon className="size-3.5" />
|
key={c.name}
|
||||||
{s.initialized ? "Переинициализировать" : "Инициализировать"}
|
cert={c}
|
||||||
</Button>
|
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
|
||||||
{s.initialized && onRemove ? (
|
onExport={onExportCert ? (cert) => onExportCert(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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="sm"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
className="size-8 text-destructive"
|
onClick={() => onInit?.(s)}
|
||||||
aria-label="Удалить IKEv2-сервер"
|
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
|
||||||
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
|
|
||||||
onClick={() => onRemove(s)}
|
|
||||||
>
|
>
|
||||||
<Trash2Icon className="size-3.5" />
|
<GlobeIcon className="size-3.5" />
|
||||||
|
{s.initialized ? "Переинициализировать" : "Инициализировать"}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||||
<NetworkIcon className="size-4" />
|
<NetworkIcon className="size-4" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type IpsecAuthKind = "certificate" | "pre-shared-key"
|
|||||||
export type IpsecUserFormState = {
|
export type IpsecUserFormState = {
|
||||||
serverId: string
|
serverId: string
|
||||||
name: string
|
name: string
|
||||||
|
peerName: string
|
||||||
authMethod: IpsecAuthKind
|
authMethod: IpsecAuthKind
|
||||||
psk: string
|
psk: string
|
||||||
remoteId: string
|
remoteId: string
|
||||||
@@ -26,6 +27,7 @@ export type IpsecUserFormState = {
|
|||||||
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
|
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
|
||||||
serverId: "",
|
serverId: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
peerName: "",
|
||||||
authMethod: "certificate",
|
authMethod: "certificate",
|
||||||
psk: "",
|
psk: "",
|
||||||
remoteId: "",
|
remoteId: "",
|
||||||
@@ -35,11 +37,13 @@ export const defaultIpsecUserForm = (): IpsecUserFormState => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
type ServerOption = { id: string; name: string; host: string }
|
type ServerOption = { id: string; name: string; host: string }
|
||||||
|
export type PeerOption = { serverId: string; name: string; managed: boolean }
|
||||||
|
|
||||||
function IpsecUserSheet({
|
function IpsecUserSheet({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
servers,
|
servers,
|
||||||
|
peers,
|
||||||
busy,
|
busy,
|
||||||
defaultServerId,
|
defaultServerId,
|
||||||
editing,
|
editing,
|
||||||
@@ -49,6 +53,8 @@ function IpsecUserSheet({
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (v: boolean) => void
|
onOpenChange: (v: boolean) => void
|
||||||
servers: ServerOption[]
|
servers: ServerOption[]
|
||||||
|
/** Доступные peers (серверы) для привязки identity при создании. */
|
||||||
|
peers?: PeerOption[]
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
defaultServerId?: string
|
defaultServerId?: string
|
||||||
/** Режим редактирования: сервер и метод аутентификации не меняются. */
|
/** Режим редактирования: сервер и метод аутентификации не меняются. */
|
||||||
@@ -67,6 +73,7 @@ function IpsecUserSheet({
|
|||||||
queueMicrotask(() => setForm({
|
queueMicrotask(() => setForm({
|
||||||
serverId: edit.serverId,
|
serverId: edit.serverId,
|
||||||
name: edit.name,
|
name: edit.name,
|
||||||
|
peerName: edit.peerName ?? "",
|
||||||
authMethod: edit.authMethod,
|
authMethod: edit.authMethod,
|
||||||
psk: "",
|
psk: "",
|
||||||
remoteId: edit.remoteId ?? "",
|
remoteId: edit.remoteId ?? "",
|
||||||
@@ -118,6 +125,24 @@ function IpsecUserSheet({
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : 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 сертификата и отображаемое имя"}>
|
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="alice"
|
placeholder="alice"
|
||||||
@@ -126,7 +151,13 @@ function IpsecUserSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<FormField label="Аутентификация" required>
|
<FormField
|
||||||
|
label="Аутентификация"
|
||||||
|
required
|
||||||
|
hint={form.authMethod === "certificate"
|
||||||
|
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -189,6 +220,7 @@ function IpsecUserSheet({
|
|||||||
<p className="text-sm font-medium">Статический IP</p>
|
<p className="text-sm font-medium">Статический IP</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
|
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
|
||||||
|
{" "}Статический IP создаёт персональную identity на peer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
|
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ function IpsecUsersGrid({
|
|||||||
{c.disabled ? (
|
{c.disabled ? (
|
||||||
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
|
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!c.managed ? (
|
||||||
|
<Badge variant="outline" className="ml-1 text-[10px]">RouterOS</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -81,15 +84,26 @@ function IpsecUsersGrid({
|
|||||||
accessorKey: "authMethod",
|
accessorKey: "authMethod",
|
||||||
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
|
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const cert = row.original.authMethod === "certificate"
|
const c = row.original
|
||||||
|
const cert = c.kind === "cert"
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||||
{cert ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<BadgeCheckIcon className="size-3.5 text-info" />
|
{cert ? (
|
||||||
) : (
|
<BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
|
||||||
<KeyRoundIcon className="size-3.5 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
{cert ? "Сертификат" : "PSK"}
|
)}
|
||||||
|
{cert ? "Сертификат" : "PSK"}
|
||||||
|
</div>
|
||||||
|
{cert && c.signedBy ? (
|
||||||
|
<span
|
||||||
|
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||||
|
title={`Подписан: ${c.signedBy}`}
|
||||||
|
>
|
||||||
|
подписан: {c.signedBy}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -172,7 +186,7 @@ function IpsecUsersGrid({
|
|||||||
const c = row.original
|
const c = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
{c.authMethod === "certificate" && onDownloadCert ? (
|
{c.kind === "cert" && onDownloadCert ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const certificateDtoSchema = z.object({
|
|||||||
trusted: z.boolean(),
|
trusted: z.boolean(),
|
||||||
status: certStatusSchema,
|
status: certStatusSchema,
|
||||||
acmeStatus: z.string().optional(),
|
acmeStatus: z.string().optional(),
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
|
||||||
|
signedByCertName: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const certificatesListResponseSchema = z.object({
|
export const certificatesListResponseSchema = z.object({
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export const ipsecClientDtoSchema = z.object({
|
|||||||
/** Управляемое имя клиента (из managed-комментария identity). */
|
/** Управляемое имя клиента (из managed-комментария identity). */
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
authMethod: ipsecAuthMethodSchema,
|
authMethod: ipsecAuthMethodSchema,
|
||||||
|
/** cert — клиент по сертификату (может быть без персональной identity); psk — pre-shared-key identity. */
|
||||||
|
kind: z.enum(["cert", "psk"]).default("cert"),
|
||||||
certificateName: z.string().optional(),
|
certificateName: z.string().optional(),
|
||||||
|
/** Имя клиентского сертификата (роль client). Для psk — undefined. */
|
||||||
|
certName: z.string().optional(),
|
||||||
|
/** CA, которым подписан клиентский сертификат (read-only поле `ca`). */
|
||||||
|
signedBy: z.string().optional(),
|
||||||
commonName: z.string().optional(),
|
commonName: z.string().optional(),
|
||||||
remoteId: z.string().optional(),
|
remoteId: z.string().optional(),
|
||||||
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
|
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
|
||||||
@@ -77,6 +83,8 @@ export const ipsecCertInfoDtoSchema = z.object({
|
|||||||
trusted: z.boolean().optional(),
|
trusted: z.boolean().optional(),
|
||||||
hasPrivateKey: z.boolean().optional(),
|
hasPrivateKey: z.boolean().optional(),
|
||||||
role: z.enum(["ca", "server", "client", "other"]).optional(),
|
role: z.enum(["ca", "server", "client", "other"]).optional(),
|
||||||
|
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
|
||||||
|
signedBy: z.string().optional(),
|
||||||
managed: z.boolean(),
|
managed: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,8 +95,13 @@ export const ipsecServerSummaryDtoSchema = z.object({
|
|||||||
serverCountry: z.string().optional(),
|
serverCountry: z.string().optional(),
|
||||||
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
|
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
|
||||||
initialized: z.boolean(),
|
initialized: z.boolean(),
|
||||||
|
/** Существующий IKEv2-сервер пригоден: есть peer + CA + серверный серт. */
|
||||||
|
ike2Ready: z.boolean(),
|
||||||
serverEndpoint: z.string().optional(),
|
serverEndpoint: z.string().optional(),
|
||||||
|
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
|
||||||
peer: ipsecPeerDtoSchema.optional(),
|
peer: ipsecPeerDtoSchema.optional(),
|
||||||
|
/** Все peers роутера (managed + существующие). */
|
||||||
|
peers: z.array(ipsecPeerDtoSchema).default([]),
|
||||||
pool: ipsecPoolDtoSchema.optional(),
|
pool: ipsecPoolDtoSchema.optional(),
|
||||||
sharedModeConfig: ipsecModeConfigDtoSchema.optional(),
|
sharedModeConfig: ipsecModeConfigDtoSchema.optional(),
|
||||||
caCert: ipsecCertInfoDtoSchema.optional(),
|
caCert: ipsecCertInfoDtoSchema.optional(),
|
||||||
@@ -96,9 +109,24 @@ export const ipsecServerSummaryDtoSchema = z.object({
|
|||||||
natRuleManaged: z.boolean(),
|
natRuleManaged: z.boolean(),
|
||||||
clientsTotal: z.number().int().nonnegative(),
|
clientsTotal: z.number().int().nonnegative(),
|
||||||
clientsOnline: z.number().int().nonnegative(),
|
clientsOnline: z.number().int().nonnegative(),
|
||||||
|
/** Все сертификаты роутера (managed + существующие). */
|
||||||
certs: z.array(ipsecCertInfoDtoSchema).optional(),
|
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({
|
export const ipsecListResponseSchema = z.object({
|
||||||
servers: z.array(ipsecServerSummaryDtoSchema),
|
servers: z.array(ipsecServerSummaryDtoSchema),
|
||||||
clients: z.array(ipsecClientDtoSchema),
|
clients: z.array(ipsecClientDtoSchema),
|
||||||
@@ -131,6 +159,8 @@ export const ipsecUserCreateRequestSchema = z.object({
|
|||||||
serverId: z.union([z.string(), z.number()]),
|
serverId: z.union([z.string(), z.number()]),
|
||||||
/** Отображаемое имя клиента; из него slug для серта/CN. */
|
/** Отображаемое имя клиента; из него slug для серта/CN. */
|
||||||
name: z.string().min(1).max(64),
|
name: z.string().min(1).max(64),
|
||||||
|
/** Peer (сервер), к которому привязать identity; без — primary managed peer выбранного сервера. */
|
||||||
|
peerName: z.string().optional(),
|
||||||
authMethod: ipsecAuthMethodSchema.default("certificate"),
|
authMethod: ipsecAuthMethodSchema.default("certificate"),
|
||||||
psk: z.string().min(8).optional(),
|
psk: z.string().min(8).optional(),
|
||||||
remoteId: z.string().optional(),
|
remoteId: z.string().optional(),
|
||||||
@@ -156,6 +186,12 @@ export const ipsecCertExportRequestSchema = z.object({
|
|||||||
passphrase: z.string().min(4),
|
passphrase: z.string().min(4),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||||||
|
export const ipsecCertExportByNameRequestSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
passphrase: z.string().min(4),
|
||||||
|
})
|
||||||
|
|
||||||
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
||||||
export const ipsecCertBundleSchema = z.object({
|
export const ipsecCertBundleSchema = z.object({
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
@@ -178,6 +214,8 @@ export const ipsecUserCreatedSchema = z.object({
|
|||||||
export type IpsecAuthMethod = z.infer<typeof ipsecAuthMethodSchema>
|
export type IpsecAuthMethod = z.infer<typeof ipsecAuthMethodSchema>
|
||||||
export type IpsecClientDto = z.infer<typeof ipsecClientDtoSchema>
|
export type IpsecClientDto = z.infer<typeof ipsecClientDtoSchema>
|
||||||
export type IpsecPeerDto = z.infer<typeof ipsecPeerDtoSchema>
|
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 IpsecModeConfigDto = z.infer<typeof ipsecModeConfigDtoSchema>
|
||||||
export type IpsecPoolDto = z.infer<typeof ipsecPoolDtoSchema>
|
export type IpsecPoolDto = z.infer<typeof ipsecPoolDtoSchema>
|
||||||
export type IpsecCertInfoDto = z.infer<typeof ipsecCertInfoDtoSchema>
|
export type IpsecCertInfoDto = z.infer<typeof ipsecCertInfoDtoSchema>
|
||||||
@@ -187,5 +225,6 @@ export type IpsecInitRequest = z.infer<typeof ipsecInitRequestSchema>
|
|||||||
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
|
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
|
||||||
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
|
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
|
||||||
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
|
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
|
||||||
|
export type IpsecCertExportByNameRequest = z.infer<typeof ipsecCertExportByNameRequestSchema>
|
||||||
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
|
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
|
||||||
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
|
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
IpsecCertBundle,
|
IpsecCertBundle,
|
||||||
IpsecInitRequest,
|
IpsecInitRequest,
|
||||||
IpsecListResponse,
|
IpsecListResponse,
|
||||||
|
IpsecPeerPatch,
|
||||||
IpsecServerSummaryDto,
|
IpsecServerSummaryDto,
|
||||||
IpsecUserCreateRequest,
|
IpsecUserCreateRequest,
|
||||||
IpsecUserCreated,
|
IpsecUserCreated,
|
||||||
@@ -96,6 +97,19 @@ export async function exportIpsecUserCert(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportIpsecCertByName(
|
||||||
|
baseUrl: string,
|
||||||
|
serverId: string,
|
||||||
|
name: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<IpsecCertBundle> {
|
||||||
|
return requestJson<IpsecCertBundle>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreIpsecRevision(
|
export async function restoreIpsecRevision(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
@@ -106,3 +120,48 @@ export async function restoreIpsecRevision(
|
|||||||
body: JSON.stringify({ serverId }),
|
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) ~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 для вложенных сервисов (тот же %, но база = страна).
|
||||||
Reference in New Issue
Block a user