Compare commits

..
10 Commits
Author SHA1 Message Date
DenozordecandCursor 752256f12e fix(ipsec): требовать пароль .p12 не короче 8 символов
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:10:10 +07:00
DenozordecandCursor a3502b9755 fix(ipsec): убрать неподдерживаемый параметр при подписи сертификата
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m17s
Docker images / updater-image (push) Successful in 38s
Docker images / backend-image (push) Successful in 2m20s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:21:19 +07:00
DenozordecandCursor 0e1524c600 fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00
DenozordecandCursor 11ae7593f4 fix(ipsec): убрать неподдерживаемый параметр при добавлении сертификата
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:27 +07:00
Denozordec 3eb75ea0b8 feat(ipsec): add certificate export functionality by name
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Implemented a new backend route for exporting existing client certificates in .p12 format by name.
- Enhanced the frontend to support exporting certificates directly from the IPsec server grid.
- Updated the IPsec page to manage certificate states and handle exports effectively.
- Introduced new utility functions for certificate handling and improved data structures to accommodate the changes.
- Added tests to ensure the reliability of the new certificate export feature.
2026-09-12 21:39:14 +07:00
Denozordec 7365d8d8fb feat(ipsec): enhance IPsec management with peer and certificate handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
- Added support for managing IPsec peers, including editing and deleting functionality.
- Introduced a new UI component for displaying peers and their details within the IPsec server grid.
- Updated the IPsec user creation form to allow binding identities to specific peers.
- Enhanced backend routes and services to handle peer patching and deletion requests.
- Improved data structures to accommodate multiple peers and certificates for each server.
- Added tests to ensure proper functionality of new peer management features.
2026-09-12 21:04:49 +07:00
Denozordec 7755d77340 feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
2026-09-12 20:20:56 +07:00
Denozordec 564aae21f0 fix(statistics): выровнять трафик-данные с картой сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
Docker images / frontend-image (push) Successful in 3m28s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены).
- Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна.
- Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service.
- Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
2026-09-12 13:22:06 +07:00
Denozordec 0fdd2fc1e1 feat(traffic-flow): раскрытие сервисов страны на карте сети
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup.
- Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны.
- Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются.
- Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса.
- Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
2026-09-12 12:47:30 +07:00
DenozordecandCursor f15a7348db feat(traffic-flow): enhance country handling in flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced country-specific handling in traffic flow analytics, including new mappings for country nodes and edges.
- Added mock data for countries and their respective service paths to improve visualization in the Network Map.
- Updated the `countryName` function to utilize `Intl.DisplayNames` for better localization of country names.
- Enhanced tests to validate country handling and ensure accurate representation in flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:38:37 +07:00
54 changed files with 6871 additions and 177 deletions
@@ -0,0 +1,46 @@
# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard
## Архитектура (как у WireGuard)
Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers).
## Модель RouterOS (что создаём/читаем)
Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам».
Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`.
Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере).
## Бэкенд
1. **`mikrotik.ts`**: `downloadFile(name): Promise<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download.
4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило.
5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара.
6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет).
7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`.
8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту.
## Контракт
`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`.
## Фронт
`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима.
## Тесты и проверка
- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts``planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`.
- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок.
- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз).
## Риски (проверить на живом роутере при внедрении)
- REST-скачивание файла `GET /rest/file/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
+896
View File
@@ -0,0 +1,896 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { IpsecUsersGrid } from "@/components/ipsec/ipsec-users-grid"
import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid"
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet"
import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet"
import { Button } from "@/components/ui/button"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import {
createIpsecUser,
deleteIpsecCert,
deleteIpsecPeer,
deleteIpsecServer,
deleteIpsecUser,
exportIpsecCertByName,
exportIpsecUserCert,
initIpsecServer,
listIpsec,
patchIpsecPeer,
patchIpsecUser,
restoreIpsecRevision,
} from "@/shared/api/ipsec"
import type {
IpsecCertBundle,
IpsecCertInfoDto,
IpsecClientDto,
IpsecPeerDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
ALL_SERVERS_ID,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { toast } from "sonner"
import { findFreePoolIp } from "@/lib/ipsec-client"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react"
type IpsecWorkspaceTab = "clients" | "server" | "cli"
type IpsecFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "user"; client: IpsecClientDto }
| { kind: "server"; summary: IpsecServerSummaryDto }
| { 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[] = [
{
serverId: "srv2",
serverName: "mt-spb-edge-01",
initialized: true,
ike2Ready: true,
serverEndpoint: "vpn.example.com",
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
peer: MOCK_IPSEC_PEER,
peers: [MOCK_IPSEC_PEER],
caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true },
serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true },
natRuleManaged: true,
clientsTotal: 3,
clientsOnline: 1,
certs: [],
},
]
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
{
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "alice", authMethod: "certificate", 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",
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
},
{
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
},
{
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
},
]
interface BackendServer {
id: number
name: string
host: string
country: string
type?: Server["type"]
enabled: boolean
status?: Server["status"]
latency?: number | null
asn?: string
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
export default function IpsecPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
const [clients, setClients] = useState<IpsecClientDto[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<IpsecFailure[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [certBusy, setCertBusy] = useState(false)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
const [search, setSearch] = useState("")
const [initOpen, setInitOpen] = useState(false)
const [userOpen, setUserOpen] = useState(false)
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
const [certOpen, setCertOpen] = useState(false)
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
const [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 [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
try {
const [ipsec, servers] = await Promise.all([
listIpsec(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setSummaries(ipsec.servers)
setClients(ipsec.clients)
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(ipsec.failures ?? [])
if (ipsec.failures?.length) {
toast.warning(
`Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec")
setSummaries([])
setClients([])
setFailures([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setSummaries([])
setClients([])
setLiveServers([])
setFailures([])
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
backendUrl,
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await restoreIpsecRevision(backendUrl, id, historyServerId)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
const scopedClients = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
return displayClients.filter((c) => c.serverId === effectiveServerId)
}, [displayClients, effectiveServerId])
const scopedSummaries = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displaySummaries
return displaySummaries.filter((s) => s.serverId === effectiveServerId)
}, [displaySummaries, effectiveServerId])
const filteredClients = useMemo(() => {
if (!search) return scopedClients
const q = search.toLowerCase()
return scopedClients.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.serverName.toLowerCase().includes(q) ||
(c.commonName ?? "").toLowerCase().includes(q) ||
(c.staticIp ?? "").includes(q),
)
}, [scopedClients, search])
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
const clientsOnline = scopedClients.filter((c) => c.online).length
const caOk = scopedSummaries.some((s) => s.caCert)
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const certNames = useMemo(
() => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(),
[scopedSummaries],
)
const peerOptions = useMemo(
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
[scopedSummaries],
)
const serverOptions = displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
}))
/** Подсказка свободного IP из пула выбранного сервера. */
const freeIpHint = useMemo(() => {
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
if (!summary?.pool) return undefined
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
}, [scopedSummaries, scopedClients])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const c of displayClients) {
counts.set(c.serverId, (counts.get(c.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, displayClients])
const handleInit = async (form: IpsecInitFormState) => {
setBusy(true)
try {
await initIpsecServer(backendUrl, {
serverId: form.serverId,
serverEndpoint: form.serverEndpoint.trim(),
poolCidr: form.poolCidr.trim(),
dns: form.dns.trim() || undefined,
caDaysValid: 3650,
serverDaysValid: 3650,
clientDaysValid: 1825,
createNatRule: form.createNatRule,
})
toast.success("IKEv2-сервер инициализирован")
setInitOpen(false)
await loadLive()
} catch (e) {
toast.error("Ошибка инициализации", {
description: e instanceof Error ? e.message : String(e),
})
} finally {
setBusy(false)
}
}
const handleCreateUser = async (form: IpsecUserFormState) => {
setBusy(true)
try {
const res = await createIpsecUser(backendUrl, {
serverId: form.serverId,
name: form.name.trim(),
peerName: form.peerName.trim() || undefined,
authMethod: form.authMethod,
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
passphrase: form.passphrase.trim() || undefined,
})
toast.success(`Клиент «${form.name.trim()}» создан`)
setUserOpen(false)
setEditing(null)
await loadLive()
if (res.bundle) {
setCertBundle(res.bundle)
setCertClient(res.client ?? null)
setCertByName(null)
setCertOpen(true)
}
} catch (e) {
toast.error("Ошибка создания клиента", {
description: e instanceof Error ? e.message : String(e),
})
} finally {
setBusy(false)
}
}
const handleEditUser = async (form: IpsecUserFormState) => {
if (!editing) return
setBusy(true)
try {
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
})
toast.success("Клиент обновлён")
setUserOpen(false)
setEditing(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
const openCert = async (client: IpsecClientDto) => {
const byName = client.kind === "cert" && Boolean(client.certName)
setCertClient(byName ? null : client)
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
setCertBundle(null)
setCertOpen(true)
setCertBusy(true)
try {
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
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)
} catch (e) {
toast.error("Ошибка экспорта сертификата", {
description: e instanceof Error ? e.message : String(e),
})
setCertOpen(false)
} finally {
setCertBusy(false)
}
}
const reexportCert = async (passphrase: string) => {
if (!certClient && !certByName) return
setCertBusy(true)
try {
const bundle = certByName
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
setCertBundle(bundle)
} catch (e) {
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
} finally {
setCertBusy(false)
}
}
const confirmDelete = async () => {
if (!pendingDelete) return
const target = pendingDelete
setBusy(true)
try {
if (target.kind === "user") {
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
toast.success(`Клиент «${target.client.name}» удалён`)
} else if (target.kind === "server") {
await deleteIpsecServer(backendUrl, target.summary.serverId)
toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`)
} else if (target.kind === "peer") {
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId)
toast.success(`Peer «${target.peer.name}» удалён`)
} else {
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
toast.success(`Сертификат «${target.cert.name}» удалён`)
}
await loadLive()
} catch (e) {
const forceDelete = async (fn: () => Promise<unknown>) => {
try {
await fn()
toast.success("Удалено")
await loadLive()
} catch (err) {
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
}
}
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
toast.error("На peer ссылаются identity", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
},
})
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
toast.error("Сертификат используется", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
},
})
} else {
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
}
} finally {
setBusy(false)
setPendingDelete(null)
}
}
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
setEditingPeer({ serverId, peer })
setPeerOpen(true)
}
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
setBusy(true)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
address: form.address.trim() || undefined,
exchangeMode: form.exchangeMode.trim() || undefined,
passive: form.passive,
certificate: form.certificate.trim() || undefined,
profile: form.profile.trim() || undefined,
disabled: form.disabled,
})
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
const deleteDialogTitle = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
case "user": return `Удалить клиента «${pendingDelete.client.name}»?`
case "peer": return `Удалить peer «${pendingDelete.peer.name}»?`
case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?`
}
})()
const deleteDialogDescription = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server":
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
case "user":
return "Удалит identity, персональный mode-config и клиентский сертификат на роутере."
case "peer":
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
case "cert":
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
}
})()
return (
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayClients.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
)}
{isLive ? (
<Button size="sm" onClick={() => setUserOpen(true)}>
<PlusIcon className="size-4" />
Новый клиент
</Button>
) : null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка IPsec"
items={[
{
id: "servers",
label: "Серверов IKEv2",
value: serversWithIpsec,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "clients",
label: "Клиентов",
value: scopedClients.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Онлайн",
value: clientsOnline,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ca",
label: "CA",
value: caOk ? "ок" : "—",
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
</AlertDescription>
</Alert>
) : null}
{!isLive ? (
<Alert variant="info">
<InfoIcon />
<AlertTitle>Mock-режим</AlertTitle>
<AlertDescription>
Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik.
</AlertDescription>
</Alert>
) : null}
<Tabs
value={workspaceTab}
onValueChange={(v) => setWorkspaceTab(v as IpsecWorkspaceTab)}
className="gap-3"
>
<TabsList variant="line">
<TabsTrigger value="clients" className="gap-1.5">
<UsersIcon className="size-3.5" />
Клиенты
</TabsTrigger>
<TabsTrigger value="server" className="gap-1.5">
<ShieldCheckIcon className="size-3.5" />
Сервер
</TabsTrigger>
<TabsTrigger value="cli" className="gap-1.5">
<CodeXmlIcon className="size-3.5" />
CLI
</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени / CN / IP…"
actions={
isLive ? (
<Button size="sm" onClick={() => { setEditing(null); setUserOpen(true) }}>
<PlusIcon className="size-4" />
Клиент
</Button>
) : undefined
}
/>
<IpsecUsersGrid
clients={filteredClients}
compactServer={compactServer}
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="server" className="mt-0 outline-none">
<IpsecServerGrid
servers={scopedSummaries}
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
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 value="cli" className="mt-0 outline-none">
<OpsPanel title="RouterOS 7 · /ip ipsec — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
{[
{
title: "CA и серверный серт.",
lines: [
"/certificate add name=ipsec-ca \\",
" common-name=\"MM IPsec CA\" \\",
" key-size=4096 \\",
" key-usage=key-cert-sign,crl-sign",
"/certificate sign ipsec-ca",
"/certificate add name=ipsec-server \\",
" common-name=vpn.example.com \\",
" subject-alt-name=DNS:vpn.example.com",
"/certificate sign ipsec-server ca=ipsec-ca",
],
},
{
title: "Peer + identity",
lines: [
"/ip ipsec peer add \\",
" name=ipsec-vpn address=0.0.0.0/0 \\",
" exchange-mode=ike2 passive=yes \\",
" certificate=ipsec-server send-cert=always",
"/ip ipsec identity add \\",
" peer=ipsec-vpn auth-method=rsa-key \\",
" certificate=ipsec-server \\",
" match-by=certificate \\",
" generate-policy=port-strict \\",
" mode-config=ipsec-vpn",
],
},
{
title: "Клиент: серт и .p12",
lines: [
"/certificate add name=ipsec-user-alice \\",
" common-name=alice key-size=2048 \\",
" key-usage=digital-signature,\\",
" key-encipherment,tls-client",
"/certificate sign ipsec-user-alice \\",
" ca=ipsec-ca",
"/certificate export-certificate \\",
" ipsec-user-alice type=pkcs12 \\",
" export-passphrase=*****",
"",
"# Онлайн-клиенты:",
"/ip ipsec active-peers print",
],
},
].map((b) => (
<div key={b.title}>
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
{b.title}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</TabsContent>
</Tabs>
</div>
</ServerRailLayout>
<IpsecUserSheet
open={userOpen}
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
servers={serverOptions}
peers={peerOptions}
busy={busy}
defaultServerId={sheetServerId}
editing={editing}
freeIpHint={freeIpHint}
onSubmit={editing ? handleEditUser : handleCreateUser}
/>
<IpsecPeerSheet
open={peerOpen}
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
busy={busy}
editing={editingPeer?.peer ?? null}
certificates={certNames}
onSubmit={handleEditPeer}
/>
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
servers={serverOptions}
busy={busy}
defaultServerId={sheetServerId}
onSubmit={handleInit}
/>
<IpsecCertSheet
open={certOpen}
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
bundle={certBundle}
busy={certBusy}
onReexport={reexportCert}
/>
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={(e) => { e.preventDefault(); void confirmDelete() }}
>
<Trash2Icon className="size-4" />
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={(id) => void restoreRevision(id)}
title="История конфигураций IPsec"
itemLabel="конфигурация"
/>
</>
)
}
+455 -27
View File
@@ -27,7 +27,9 @@ import {
findServerByGreRemote,
greSourceWanIndexOnMap,
greTunnelProbe,
placeCountryServiceNodes,
placeServiceNodes,
SERVICE_COL_W,
type GreMapEdge,
type WanJhEdge,
} from "@/lib/network-map-layout"
@@ -55,8 +57,9 @@ import {
matchNetflowForWan,
type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
@@ -69,7 +72,7 @@ import {
import { cn } from "@/lib/utils"
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
import Link from "next/link"
import { Flag } from "@/components/flag"
import { Flag, countryName } from "@/components/flag"
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
@@ -285,6 +288,12 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]
const MOCK_MAP_COUNTRIES: FlowMapService[] = [
{ id: "cc:us", label: "US", category: "Страна", bytes: 22_000_000, bps: 8_800_000, share: 0.38 },
{ id: "cc:nl", label: "NL", category: "Страна", bytes: 14_000_000, bps: 5_600_000, share: 0.31 },
{ id: "cc:de", label: "DE", category: "Страна", bytes: 9_000_000, bps: 3_600_000, share: 0.21 },
]
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
@@ -293,6 +302,14 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_COUNTRY_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "cc:us", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:us", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv2", toId: "cc:nl", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:nl", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv3", toId: "cc:de", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
@@ -301,6 +318,56 @@ const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
]
const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:nl", bytes: 9_000_000, bps: 3_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:nl", bytes: 5_000_000, bps: 2_000_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
]
/** Доли — от байтов страны cc:us (22M из моков выше). */
const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [
{
countryId: "cc:us",
services: [
{ id: "cc:us|svc:google", label: "Google", category: "Веб", bytes: 12_000_000, bps: 4_800_000, share: 12 / 22 },
{ id: "cc:us|svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 7_000_000, bps: 2_800_000, share: 7 / 22 },
{ id: "cc:us|svc:aws", label: "AWS", category: "CDN", bytes: 3_000_000, bps: 1_200_000, share: 3 / 22 },
],
edges: [
{ fromId: "cc:us", toId: "cc:us|svc:google", bytes: 12_000_000, bps: 4_800_000, bpsFwd: 9_000_000, bpsRev: 3_000_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "cc:us", toId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000, bpsFwd: 5_250_000, bpsRev: 1_750_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "cc:us", toId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000, bpsFwd: 2_250_000, bpsRev: 750_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
],
paths: [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:google", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:google", bytes: 4_000_000, bps: 1_600_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000 },
],
},
]
const DEST_MODE_KEY = "mm-network-map-dest-mode"
type DestMode = "services" | "countries"
function readDestMode(): DestMode {
if (typeof window === "undefined") return "services"
try {
return sessionStorage.getItem(DEST_MODE_KEY) === "countries" ? "countries" : "services"
} catch {
return "services"
}
}
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
if (!node) return fallback
if (mode !== "countries") return node.label
if (node.id === "cc:other" || node.label === "Прочее") return "Прочее"
return countryName(node.label)
}
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
}
@@ -738,6 +805,10 @@ function ServiceNode({
isSel,
isVis,
isDragged,
destMode,
iso,
dim,
shareLabel,
onClick,
onMouseDown,
}: {
@@ -748,20 +819,27 @@ function ServiceNode({
isSel: boolean
isVis: boolean
isDragged: boolean
destMode: DestMode
iso?: string
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
dim?: boolean
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
shareLabel?: string
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return (
<g
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? 1 : 0.08}
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} payload окна`}</title>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
{isSel && (
<rect
x={-bw / 2 - 6}
@@ -786,7 +864,9 @@ function ServiceNode({
strokeWidth={isSel ? 2.2 : 1.4}
/>
<g transform="translate(-11,-24)" pointerEvents="none">
<ServiceBrandIcon label={label} size={22} />
{flagIso
? <CountryFlagSvg iso={flagIso} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : label} size={22} />}
</g>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
{label}
@@ -804,6 +884,7 @@ function ServicePathList({
services,
highlight,
viaMode,
destMode,
onToggle,
}: {
paths: FlowMapServicePath[]
@@ -811,6 +892,7 @@ function ServicePathList({
services: FlowMapService[]
highlight: { viaId: string; enId: string; serviceId: string } | null
viaMode: "via" | "service"
destMode: DestMode
onToggle: (p: FlowMapServicePath) => void
}) {
if (paths.length === 0) {
@@ -823,13 +905,14 @@ function ServicePathList({
const via = servers.find((s) => s.id === p.viaId)
const en = servers.find((s) => s.id === p.enId)
const svc = services.find((s) => s.id === p.serviceId)
const destLabel = destDisplayLabel(svc, destMode, p.serviceId)
const label = formatServicePathLabel(p, viaMode, {
viaName: via?.name,
viaSite: via?.site,
enName: en?.name,
serviceLabel: svc?.label,
serviceLabel: destLabel,
})
const title = formatServicePathTitle(label, svc?.label ?? p.serviceId)
const title = formatServicePathTitle(label, destLabel)
const active = Boolean(
highlight
&& highlight.viaId === p.viaId
@@ -1099,11 +1182,16 @@ export default function NetworkMapPage() {
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState<FlowMapCountryServiceGroup[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
const [mapNamedBytes, setMapNamedBytes] = useState(0)
const [mapTotalBytes, setMapTotalBytes] = useState(0)
const [mapWindowSec, setMapWindowSec] = useState(300)
const [mapAsnLoaded, setMapAsnLoaded] = useState(true)
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -1199,9 +1287,14 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapNamedBytes(0)
setMapTotalBytes(0)
setMapCountryLoaded(true)
setDataError(null)
})
return
@@ -1230,8 +1323,40 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
const [destMode, setDestModeState] = useState<DestMode>("services")
useEffect(() => {
queueMicrotask(() => setDestModeState(readDestMode()))
}, [])
function setDestMode(mode: DestMode) {
setDestModeState(mode)
setSelectedService(null)
setExpandedCountryId(null)
setHighlightedPath(null)
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
}
const expandedCountryGroup = useMemo(
() => destMode === "countries" && expandedCountryId
? mapCountryServiceGroups.find((g) => g.countryId === expandedCountryId) ?? null
: null,
[destMode, expandedCountryId, mapCountryServiceGroups],
)
const nestedServices = useMemo(() => expandedCountryGroup?.services ?? [], [expandedCountryGroup])
const liveSelectedService = selectedService
? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService)
? (
(destMode === "countries" ? mapCountries : mapServices)
.find((s) => s.id === selectedService.id)
?? nestedServices.find((s) => s.id === selectedService.id)
?? selectedService
)
: null
const selectedNestedService = liveSelectedService
&& nestedServices.some((s) => s.id === liveSelectedService.id)
? liveSelectedService
: null
const expandedCountry = expandedCountryId
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
: null
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
@@ -1283,7 +1408,12 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapCountryLoaded(true)
})
return
}
@@ -1293,6 +1423,10 @@ export default function NetworkMapPage() {
setMapServices([])
setMapServiceEdges([])
setMapServicePaths([])
setMapCountries([])
setMapCountryEdges([])
setMapCountryPaths([])
setMapCountryServiceGroups([])
})
return
}
@@ -1308,10 +1442,15 @@ export default function NetworkMapPage() {
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
setMapCountries(res.countries ?? [])
setMapCountryEdges(res.countryEdges ?? [])
setMapCountryPaths(res.countryPaths ?? [])
setMapCountryServiceGroups(res.countryServiceGroups ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
setMapNamedBytes(res.namedBytes ?? 0)
setMapTotalBytes(res.totalBytes ?? 0)
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
if (res.windowSec) setMapWindowSec(res.windowSec)
})
.catch((err: unknown) => {
@@ -1515,9 +1654,13 @@ export default function NetworkMapPage() {
return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : []
const destNodes = destMode === "countries" ? mapCountries : mapServices
const destEdges = destMode === "countries" ? mapCountryEdges : mapServiceEdges
const destPaths = destMode === "countries" ? mapCountryPaths : mapServicePaths
const visibleMapServices = showServices ? destNodes : []
const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
? drawableServiceEdges(visibleMapServices, destEdges, mapServers, greEdges, nodePosById)
: []
const nodes = mapServers
@@ -1539,8 +1682,24 @@ export default function NetworkMapPage() {
.map((s) => nodePosById[s.id])
.filter((p): p is { x: number; y: number } => Boolean(p)),
)
// Раскрытая страна: колонка стран уходит влево, правый x занимает столбец её сервисов.
const countryColShift = expandedCountryGroup ? SERVICE_COL_W : 0
const autoDestPos = countryColShift
? Object.fromEntries(
Object.entries(autoServicePos).map(([id, p]) => [id, { x: p.x - countryColShift, y: p.y }]),
)
: autoServicePos
const servicePosById = Object.fromEntries(
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoDestPos[s.id]!]),
)
const autoNestedPos = placeCountryServiceNodes(
nestedServices.map((s) => s.id),
expandedCountryId ? servicePosById[expandedCountryId] ?? autoDestPos[expandedCountryId] : undefined,
)
const nestedPosById = Object.fromEntries(
nestedServices
.map((s) => [s.id, servicePositions[s.id] ?? autoNestedPos[s.id]] as const)
.filter((entry): entry is readonly [string, { x: number; y: number }] => Boolean(entry[1])),
)
// ── Refs ─────────────────────────────────────────────────────────────────────
@@ -1625,6 +1784,7 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
@@ -1722,6 +1882,7 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
}
@@ -1756,6 +1917,34 @@ export default function NetworkMapPage() {
}
// ── Side panel ────────────────────────────────────────────────────────────
useEffect(() => {
if (
expandedCountryId
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
) {
queueMicrotask(() => setExpandedCountryId(null))
}
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
useEffect(() => {
if (!selectedService) return
const stillVisible =
destNodes.some((s) => s.id === selectedService.id)
|| nestedServices.some((s) => s.id === selectedService.id)
if (!stillVisible) {
queueMicrotask(() => {
setSelectedService(null)
setHighlightedPath(null)
})
}
}, [destMode, destNodes, nestedServices, selectedService])
useEffect(() => {
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
}, [showServices])
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
useEffect(() => {
queueMicrotask(() => setServicePositions({}))
}, [expandedCountryId])
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
@@ -1770,6 +1959,10 @@ export default function NetworkMapPage() {
setSelWanIdx(null)
setHighlightedPath(null)
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
// Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает.
if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) {
setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id))
}
}
function selectWan(s: Server, wanIdx: number) {
setSelectedGreEdge(null)
@@ -1919,6 +2112,27 @@ export default function NetworkMapPage() {
)}
</div>
{/* Dest overlay: services vs countries */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
<button
key={b.value}
onClick={() => setDestMode(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
destMode === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{b.label}
</button>
))}
</div>
{/* Layers dropdown */}
<div className="relative">
<button
@@ -1937,7 +2151,7 @@ export default function NetworkMapPage() {
{([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
{ key: "showServices", label: "Назначения", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1964,9 +2178,14 @@ export default function NetworkMapPage() {
))}
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0
? `Порог доли сервиса${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
</p>
{destMode === "countries" && !mapCountryLoaded && (
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
GeoIP Country не загружен, страны из RIPE-кэша
</p>
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1">
<button
@@ -2283,6 +2502,32 @@ export default function NetworkMapPage() {
{visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={destDisplayLabel(svc, destMode)}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
{expandedCountryGroup && nestedServices.map((svc) => {
const pos = nestedPosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
@@ -2293,6 +2538,8 @@ export default function NetworkMapPage() {
isSel={selectedService?.id === svc.id}
isVis
isDragged={draggedSvcId === svc.id}
destMode="services"
shareLabel="трафика страны"
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
@@ -2332,15 +2579,16 @@ export default function NetworkMapPage() {
&& highlightedPath.serviceId === edge.toId,
)
const pathDim = Boolean(highlightedPath) && !pathHit
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId))
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId || expandedCountryId === edge.toId))
const countryDim = !hl && Boolean(expandedCountryGroup) && edge.toId !== expandedCountryId
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
const pathTitle = `${clientLabel}${enName}${destDisplayLabel(svc, destMode, edge.toId)}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
opacity={pathDim ? 0.12 : hl ? 1 : countryDim ? 0.35 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
@@ -2386,6 +2634,81 @@ export default function NetworkMapPage() {
)
})}
{/* ── Раскрытая страна → её сервисы ── */}
{expandedCountryGroup?.edges.map((edge) => {
const from = servicePosById[edge.fromId]
const to = nestedPosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const clipped = clipSegmentCircleToRect(
from.x,
from.y,
MAP_SERVICE_NODE_W / 2,
to.x,
to.y,
MAP_SERVICE_NODE_W / 2,
MAP_SERVICE_NODE_H / 2,
)
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
const hl = selectedService?.id === edge.toId
const svc = nestedServices.find((s) => s.id === edge.toId)
const country = mapCountries.find((s) => s.id === edge.fromId)
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${destDisplayLabel(country, "countries", edge.fromId)}${edge.toId.split("|")[1] ?? edge.toId}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={hl ? 1 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#22d3ee"
strokeWidth={hopHasRate(hop) ? 2 : 1.3}
strokeDasharray="4 4"
opacity="0.9"
pointerEvents="none"
/>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#00000000"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={(ev) => {
ev.stopPropagation()
if (svc) selectService(svc)
}}
/>
{showAnimDots && hopHasRate(hop) && (
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const hit = nestedServices.find((s) => s.id === edge.toId)
if (hit) selectService(hit)
}}
/>
)}
</g>
)
})}
{/* ── Hover tooltip ── */}
{hoveredNode && !isDragging && (
<SvgTooltip n={hoveredNode} />
@@ -2424,7 +2747,9 @@ export default function NetworkMapPage() {
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
{destMode === "countries" ? "Страна" : "Сервис"}
</text>
</g>
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
@@ -2488,7 +2813,7 @@ export default function NetworkMapPage() {
satPos={effectiveSatPos}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
servicePos={servicePosById}
servicePos={{ ...servicePosById, ...nestedPosById }}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
/>
@@ -2723,16 +3048,87 @@ export default function NetworkMapPage() {
})()}
</div>
</>
) : selectedNestedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={selectedNestedService.label} size={22} />
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">
{selectedNestedService.label}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}
</p>
</div>
<button
type="button"
onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля страны</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedNestedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{serviceSharePct(selectedNestedService.bytes / mapTotalBytes)}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
{formatNetflowRate({
bytes: selectedNestedService.bytes,
bps: selectedNestedService.bps,
bpsFwd: selectedNestedService.bps,
bpsRev: 0,
})}
</span>
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<ServicePathList
paths={(expandedCountryGroup?.paths ?? [])
.filter((p) => p.serviceId === selectedNestedService.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={nestedServices}
highlight={highlightedPath}
viaMode="via"
destMode="services"
onToggle={togglePathHighlight}
/>
</div>
</div>
</>
) : liveSelectedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={liveSelectedService.label} size={22} />
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
? <Flag code={liveSelectedService.label} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : liveSelectedService.label} size={22} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{liveSelectedService.label}</p>
<p className="font-mono font-semibold text-sm truncate">
{destDisplayLabel(liveSelectedService, destMode)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {liveSelectedService.category}
{destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
</p>
</div>
<button
@@ -2769,12 +3165,18 @@ export default function NetworkMapPage() {
</span>
</div>
)}
{!mapAsnLoaded && (
{!mapAsnLoaded && destMode === "services" && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
</div>
)}
{destMode === "countries" && !mapCountryLoaded && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoIP Country</span>
<span className="text-xs font-mono font-medium text-amber-500">RIPE-кэш</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
@@ -2787,12 +3189,36 @@ export default function NetworkMapPage() {
</span>
</div>
</div>
{expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Сервисы в стране</p>
<div className="flex flex-col gap-1">
{expandedCountryGroup.services.map((svc) => (
<button
key={svc.id}
type="button"
onClick={() => selectService(svc)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
selectedService?.id === svc.id ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)}
>
<span className="flex items-center gap-1.5 min-w-0">
<ServiceBrandIcon label={svc.label} size={14} />
<span className="font-mono truncate">{svc.label}</span>
</span>
<span className="font-mono text-cyan-400 tabular-nums shrink-0">{serviceSharePct(svc.share)}</span>
</button>
))}
</div>
</div>
)}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<div className="flex flex-col gap-3">
{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
const enPaths = mapServicePaths
const enPaths = destPaths
.filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId)
.slice()
.sort((a, b) => b.bps - a.bps)
@@ -2807,9 +3233,10 @@ export default function NetworkMapPage() {
<ServicePathList
paths={enPaths}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="via"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
@@ -3048,7 +3475,7 @@ export default function NetworkMapPage() {
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={mapServicePaths
paths={destPaths
.filter((p) => (
selected.type === "exit-node"
? p.enId === selected.id
@@ -3057,9 +3484,10 @@ export default function NetworkMapPage() {
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="service"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
+2 -2
View File
@@ -96,7 +96,7 @@ export const filterRules = pgTable("filter_rules", {
export const configRevisions = pgTable("config_revisions", {
id: text("id").primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "gre"] }).notNull(),
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "ipsec", "gre"] }).notNull(),
source: text("source", { enum: ["apply", "rollback", "observed", "copy"] }).notNull(),
fingerprint: text("fingerprint").notNull(),
payload: jsonb("payload").$type<unknown>().notNull().default(sql`'[]'::jsonb`),
@@ -716,7 +716,7 @@ export const userInterfaceBindings = pgTable("user_interface_bindings", {
userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "ipsec", "other"] })
.notNull().default("other"),
peerPublicKey: text("peer_public_key").notNull().default(""),
peerName: text("peer_name").notNull().default(""),
+2
View File
@@ -29,6 +29,7 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import ipsecRoutes from "./routes/ipsec.js"
import vxlanRoutes from "./routes/vxlan.js"
import containersRoutes from "./routes/containers.js"
import firewallRoutes from "./routes/firewall.js"
@@ -137,6 +138,7 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(ipsecRoutes, { prefix: "/api" })
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
+2
View File
@@ -155,6 +155,7 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/ipsec") ||
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:read",
@@ -169,6 +170,7 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/ipsec") ||
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:write",
+2 -1
View File
@@ -1,4 +1,4 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export type InterfaceType = "ether" | "gre" | "wg" | "ipsec" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
@@ -10,6 +10,7 @@ export function mapRosInterfaceType(raw: string | undefined | null, name?: strin
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ipsec")) return "ipsec"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
+7 -1
View File
@@ -28,7 +28,7 @@ export function peerDisplayName(opts: {
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
/** Ether/GRE — пустой ключ. WG — обязательный public-key. IPsec — CN сертификата клиента. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
@@ -40,5 +40,11 @@ export function normalizeBindingPeer(
}
return key
}
if (type === "ipsec") {
if (!key) {
throw new PeerBindError("Для IPsec укажите клиента (CN сертификата)", 400)
}
return key
}
return ""
}
@@ -43,6 +43,7 @@ import {
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
export class UsersServiceError extends Error {
constructor(
@@ -305,7 +306,10 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
// IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата).
const ipsecLive = await listIpsecClientsForCatalog(serverId)
const entries: CatalogInterface[] = ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
@@ -335,7 +339,37 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
})
if (ipsecLive.clients.length > 0) {
const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
entries.push({
name: "ipsec-vpn",
type: "ipsec",
running: true,
disabled: false,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
peersError: ipsecLive.error,
peers: ipsecLive.clients.map((c) => {
const cn = c.commonName ?? c.name
const bind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && b.peerPublicKey === cn)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: cn,
name: c.name,
comment: c.comment ?? "",
allowedIps: c.staticIp ? [c.staticIp] : [],
latestHandshake: c.activeSince,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
})
}
return entries.sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
+754
View File
@@ -0,0 +1,754 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply } from "fastify"
import {
IPSEC_MIN_PASSPHRASE,
ipsecCertDeleteRequestSchema,
ipsecCertExportByNameRequestSchema,
ipsecCertExportRequestSchema,
ipsecInitRequestSchema,
ipsecPeerPatchSchema,
ipsecUserCreateRequestSchema,
ipsecUserPatchSchema,
type IpsecCertBundle,
type IpsecClientDto,
} from "@mmapp/contracts/ipsec"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
buildClientInstructions,
buildSswanConfig,
clientCertName,
identityDisplayName,
ipsecManagedComment,
ipsecUserComment,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
} from "../services/ipsec-config.js"
import {
ensureIpsecPeer,
ensureIpsecPool,
ensureIpsecProfile,
ensureIpsecProposal,
ensureNatRule,
ensurePolicyTemplate,
ensureSharedModeConfig,
putUserModeConfig,
deleteUserModeConfig,
putIdentity,
patchIdentity,
deleteIdentity,
patchPeer,
deletePeer,
ensureSharedIke2Identity,
listByPath,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
ensureServerCertificate,
exportCertificateP12ByName,
findCertificate,
issueClientCertificate,
} from "../services/ipsec-ca.js"
import {
captureIpsecSnapshot,
fetchIpsecRestoreState,
fetchIpsecState,
getEnabledIpsecServerById,
listIpsec,
mapClients,
mapServerSummary,
} from "../services/ipsec-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import { randomBytes } from "node:crypto"
import { z } from "zod"
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
function generatePassphrase(): string {
return randomBytes(9).toString("base64url")
}
function serverIdParam(v: string): string {
return decodeURIComponent(v)
}
function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function errReply(reply: FastifyReply, e: unknown) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
/**
* 400 по невалидному телу. Для короткого пароля .p12 — понятный текст вместо generic-сообщения:
* RouterOS отклоняет `export-passphrase` короче 8 символов.
*/
function badBodyReply(reply: FastifyReply, error: z.ZodError) {
const shortPassphrase = error.issues.some(
(issue) => issue.path[0] === "passphrase" && issue.code === "too_small",
)
if (shortPassphrase) {
return reply.status(400).send({
error: `Пароль архива .p12 должен быть не короче ${IPSEC_MIN_PASSPHRASE} символов (требование RouterOS)`,
})
}
return reply.status(400).send({ error: "Некорректное тело запроса", details: error.flatten() })
}
async function recordIpsec(
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "ipsec",
source,
capture: () => captureIpsecSnapshot(server),
})
}
/** Существующие статические IP клиентов (персональные mode-config). */
function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "address-prefix"?: string }>): string[] {
return modeConfigs
.filter((m) => (m.name ?? "").startsWith("mc-ipsec-"))
.map((m) => String(m.address ?? m["address-prefix"] ?? "").replace(/\/\d+$/, "").trim())
.filter(Boolean)
}
async function buildCertBundle(
client: MikrotikClient,
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> {
const certName = args.certName?.trim() || clientCertName(args.userName)
const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase)
const p12B64 = content.toString("base64")
return {
user: args.userName,
serverEndpoint: args.serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase: args.passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${args.serverEndpoint}`,
serverEndpoint: args.serverEndpoint,
serverId: args.serverEndpoint,
p12B64,
}),
instructions: buildClientInstructions({
userName: args.userName,
serverEndpoint: args.serverEndpoint,
p12Filename: fileName,
passphrase: args.passphrase,
dns: args.dns,
}),
}
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/ipsec", async (req, reply) => {
const q = req.query as { serverId?: string }
const result = await listIpsec({ serverId: q.serverId })
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledIpsecServerById(sid)
if (server) await recordIpsec(server, "observed")
}
return reply.send(result)
})
app.post("/ipsec/server/init", async (req, reply) => {
const parsed = ipsecInitRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const ranges = poolRangesFromCidr(body.poolCidr)
if (!ranges) {
return reply.status(400).send({ error: `Некорректная подсеть пула: ${body.poolCidr}` })
}
const server = await getEnabledIpsecServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const caCert = await ensureCaCertificate(client, body.caDaysValid)
const serverCert = await ensureServerCertificate(client, {
serverEndpoint: body.serverEndpoint,
caCertName: caCert,
daysValid: body.serverDaysValid,
})
const comment = ipsecManagedComment("IKEv2 road-warrior")
await ensureIpsecProfile(client, IPSEC_COMMON_NAME, comment)
await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment)
await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment)
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
}
await recordIpsec(server, "apply")
const state = await fetchIpsecState(server)
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/server/:serverId", async (req, reply) => {
const { serverId } = req.params as { serverId: string }
const q = req.query as { removeCertificates?: string }
const removeCertificates = q.removeCertificates !== "false"
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) {
const rows = await listByPath(client, path)
for (const row of rows) {
if (!row[".id"]) continue
if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) {
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
}
const nat = await listByPath(client, "/ip/firewall/nat")
for (const row of nat) {
if (row[".id"] && isIpsecManagedComment(row.comment)) {
await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
// profile/proposal не критично оставлять; сертификаты — по флагу
if (removeCertificates) {
const certs = await client.getCertificates()
for (const c of certs) {
const name = String(c.name ?? "")
if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) {
await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined)
}
}
}
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.post("/ipsec/users", async (req, reply) => {
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return badBodyReply(reply, parsed.error)
}
const body = parsed.data
const server = await getEnabledIpsecServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const ike2Peers = selectIke2Peers(state.peers)
const peer = (body.peerName
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
: undefined)
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? 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 serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
const dns = sharedMc?.["static-dns"]?.trim() || undefined
if (body.authMethod === "pre-shared-key" && !body.psk) {
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
}
const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined
let modeConfig = (sharedMc?.name ?? "").trim()
if (body.staticIp) {
const ip = body.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) {
return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
}
modeConfig = userModeConfigName(body.name)
await putUserModeConfig(client, modeConfig, ip, ipsecManagedComment(`клиент ${body.name.trim()}`))
}
let certName: string | undefined
if (body.authMethod === "certificate") {
const issued = await issueClientCertificate(client, {
userName: body.name,
caCertName: caName!,
daysValid: body.daysValid ?? 1825,
})
if (issued.existed) {
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
}
certName = issued.certName
}
if (body.authMethod === "certificate" && !body.staticIp) {
// Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна,
// RouterOS сам принимает любой клиентский серт, подписанный доверенным CA.
await ensureSharedIke2Identity(client, {
peerName,
serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
modeConfigName: modeConfig,
comment: ipsecManagedComment("IKEv2 listener"),
})
} 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
if (body.authMethod === "certificate" && certName) {
const passphrase = body.passphrase?.trim() || generatePassphrase()
bundle = await buildCertBundle(client, {
userName: body.name,
serverEndpoint,
passphrase,
dns,
})
}
await recordIpsec(server, "apply")
const fresh = await fetchIpsecState(server)
const clients = mapClients(fresh)
const created: IpsecClientDto | undefined = clients.find(
(c) => c.name === body.name.trim(),
) ?? clients.find((c) => c.certificateName === certName)
return reply.status(201).send({ client: created ?? null, bundle })
} catch (e) {
return errReply(reply, e)
}
})
app.patch("/ipsec/users/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = ipsecUserPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const d = parsed.data
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
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 sharedMcName = (sharedMc?.name ?? "").trim()
const newName = d.name?.trim() || oldName
if (d.name && d.name !== oldName) {
if (identity) {
// 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 (identity && d.staticIp !== undefined) {
const currentMc = (identity["mode-config"] ?? "").trim()
if (d.staticIp == null) {
// вернуть выдачу из пула
if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName })
const personal = currentMc && currentMc !== sharedMcName ? currentMc : ""
if (personal) await deleteUserModeConfig(client, personal)
} else {
const ip = d.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const others = takenStaticIps(
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
)
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const name = userModeConfigName(newName)
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
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,
})
}
if (identity) {
const patch: Record<string, string> = {}
if (d.psk) patch.secret = d.psk
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
if (d.disabled === true) patch.disabled = "yes"
if (d.disabled === false) patch.disabled = "no"
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
}
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/users/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const q = req.query as { removeCertificate?: string }
const removeCertificate = q.removeCertificate !== "false"
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
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 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()
if (identity) {
await deleteIdentity(client, identity[".id"]!)
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
}
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")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
if (!parsed.success) {
return badBodyReply(reply, parsed.error)
}
const body = parsed.data
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const userName = parseIpsecUserComment(identity.comment) ?? ""
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
const certName = identity["remote-certificate"]?.trim() || clientCertName(userName)
if (!(await findCertificate(client, certName))) {
return reply.status(404).send({ error: `Сертификат ${certName} не найден на роутере` })
}
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const dns = sharedMc?.["static-dns"]?.trim() || undefined
// export работает по имени сертификата (certName), не по userName
const { fileName, content } = await client.exportCertificatePkcs12({
name: certName,
passphrase: body.passphrase,
})
const p12B64 = content.toString("base64")
const bundle: IpsecCertBundle = {
user: userName,
serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase: body.passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${serverEndpoint}`,
serverEndpoint,
serverId: serverEndpoint,
p12B64,
}),
instructions: buildClientInstructions({
userName,
serverEndpoint,
p12Filename: fileName,
passphrase: body.passphrase,
dns,
}),
}
return reply.send(bundle)
} catch (e) {
return errReply(reply, e)
}
})
/** Экспорт .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 badBodyReply(reply, parsed.error)
}
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) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "ipsec")
return reply.send({ revisions })
})
app.post("/ipsec/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "ipsec",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseIpsecSnapshot(loaded.row.payload)
const state = await fetchIpsecRestoreState(loaded.server)
const ops = planIpsecRestore(desired, {
peers: state.peers,
identities: state.identities,
modeConfigs: state.modeConfigs,
pools: state.pools,
nat: state.nat,
})
await executeRosOps(state.client, ops)
await recordIpsec(loaded.server, "rollback")
const result = await listIpsec({ serverId: String(loaded.server.id) })
return reply.send({ ok: true, ...result })
} catch (e) {
return errReply(reply, e)
}
})
}
export default ipsecRoutes
+4 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { countIpsecClients } from "../services/ipsec-live.js"
import { countVxlanTunnels } from "../services/vxlan-live.js"
import { countContainers } from "../services/containers-live.js"
import { countBgpSessions } from "../services/bgp-peers-live.js"
@@ -27,9 +28,10 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
const uptimeProbesTotal = await tableCount(uptimeProbes)
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
const [certRes, wireguardTotal, ipsecTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
listCertificatesFromServers(),
countWireGuardInterfaces().catch(() => 0),
countIpsecClients().catch(() => 0),
countBgpSessions().catch(() => 0),
countVxlanTunnels().catch(() => 0),
countContainers().catch(() => 0),
@@ -46,6 +48,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
ipsec: ipsecTotal,
users: usersTotal,
bgpSessions: bgpTotal,
vxlan: vxlanTotal,
@@ -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")
+60
View File
@@ -2,6 +2,64 @@ import type { CertificateDto } from "@mmapp/contracts/certificates"
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 {
if (!raw?.trim()) return null
const d = new Date(raw)
@@ -88,6 +146,8 @@ export function mapRosCertificateRow(
trusted: row.trusted === "true",
status,
acmeStatus: row["acme-status"]?.trim() || undefined,
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
signedByCertName: (row.ca ?? "").trim() || undefined,
}
}
+1
View File
@@ -10,6 +10,7 @@ export const CONFIG_SECTIONS = [
"recursive-routes",
"firewall",
"wireguard",
"ipsec",
"gre",
] as const
@@ -2,13 +2,16 @@ import assert from "node:assert/strict"
import {
canonicalFirewallSnapshot,
canonicalGreSnapshot,
canonicalIpsecSnapshot,
canonicalWireguardSnapshot,
opsPaths,
opsTouchOnly,
parseIpsecSnapshot,
planFirewallRestore,
planGreCreate,
planGreDelete,
planGreRestore,
planIpsecRestore,
planWireguardRestore,
} from "./entity-snapshots.js"
import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
@@ -161,4 +164,53 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
assert.notEqual(p1, p3)
}
{
const desired = canonicalIpsecSnapshot({
peers: [{ name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", comment: "MikrotikManager:ipsec", disabled: false }],
identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }],
modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }],
pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
})
// round-trip через payload ревизии
const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired)))
assert.equal(fingerprintPayload(restored), fingerprintPayload(desired))
assert.equal(restored.identities.length, 1)
assert.equal(restored.identities[0]?.comment, "MikrotikManager:ipsec user=alice")
const ops = planIpsecRestore(restored, {
peers: [
{ ...restored.peers[0]!, rosId: "*P1" },
{ name: "site-to-site", address: "203.0.113.7", exchangeMode: "ike2", passive: false, certificate: "", profile: "default", comment: "", disabled: false, rosId: "*P2" },
],
identities: [
{ ...restored.identities[0]!, secret: "(hidden)", rosId: "*I1" },
{
peerName: "site-to-site", authMethod: "pre-shared-key", certificate: "", remoteCertificate: "", matchBy: "",
secret: "(hidden)", remoteId: "peer-b", modeConfig: "", generatePolicy: "", comment: "не managed",
disabled: false, rosId: "*I2",
},
],
modeConfigs: [
{ ...restored.modeConfigs[0]!, rosId: "*M1" },
{ name: "mc-ipsec-ghost", addressPool: "", address: "10.77.0.9", staticDns: "", comment: "MikrotikManager:ipsec клиент ghost", rosId: "*M2" },
],
pools: [{ ...restored.pools[0]!, rosId: "*PL1" }],
nat: [{ ...restored.nat[0]!, rosId: "*N1" }],
})
// лишние managed-объекты удаляются, чужие (site-to-site / «не managed») не трогаем
assert.ok(!ops.some((op) => op.path.includes("*P2")), "чужой peer не тронут")
assert.ok(!ops.some((op) => op.path.includes("*I2")), "чужая identity не тронута")
assert.ok(ops.some((op) => op.op === "delete" && op.path === "/ip/ipsec/mode-config/*M2"), "персональный mc лишнего клиента удалён")
const patchOp = ops.find((op) => op.op === "patch" && op.path === "/ip/ipsec/identity/*I1")
assert.ok(patchOp, "identity желаемого клиента патчится")
assert.ok(patchOp?.body.secret === undefined, "секрет (hidden) не перезаписываем")
assert.equal(
opsTouchOnly(ops, ["/ip/ipsec", "/ip/pool", "/ip/firewall/nat"]),
true,
"restore не выходит за пределы ipsec-объектов",
)
}
console.log("entity-snapshots.test.ts: ok")
+312 -1
View File
@@ -1,4 +1,5 @@
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE. */
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
import { isIpsecManagedComment } from "./ipsec-config.js"
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
@@ -161,6 +162,94 @@ export interface GreLiveAddr {
address: string
}
// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ────────────
export interface IpsecSnapshotPeer {
name: string
address: string
exchangeMode: string
passive: boolean
certificate: string
profile: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotIdentity {
peerName: string
authMethod: string
certificate: string
remoteCertificate: string
matchBy: string
secret: string
remoteId: string
modeConfig: string
generatePolicy: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotModeConfig {
name: string
addressPool: string
address: string
staticDns: string
comment: string
}
export interface IpsecSnapshotPool {
name: string
ranges: string
comment: string
}
export interface IpsecSnapshotPolicy {
srcAddress: string
dstAddress: string
proposal: string
comment: string
}
export interface IpsecSnapshotNat {
chain: string
action: string
srcAddress: string
comment: string
}
export interface IpsecSnapshot {
peers: IpsecSnapshotPeer[]
identities: IpsecSnapshotIdentity[]
modeConfigs: IpsecSnapshotModeConfig[]
pools: IpsecSnapshotPool[]
policies: IpsecSnapshotPolicy[]
nat: IpsecSnapshotNat[]
}
export interface IpsecLivePeer extends IpsecSnapshotPeer {
rosId: string
}
export interface IpsecLiveIdentity extends IpsecSnapshotIdentity {
rosId: string
}
export interface IpsecLiveModeConfig extends IpsecSnapshotModeConfig {
rosId: string
}
export interface IpsecLivePool extends IpsecSnapshotPool {
rosId: string
}
export interface IpsecLivePolicy extends IpsecSnapshotPolicy {
rosId: string
}
export interface IpsecLiveNat extends IpsecSnapshotNat {
rosId: string
}
function str(v: unknown): string {
return String(v ?? "").trim()
}
@@ -508,6 +597,228 @@ export function planWireguardRestore(
return ops
}
// ── IPsec: canonical / parse / plan ─────────────────────────────────────────
function canonicalIpsecPeerRaw(p: Partial<IpsecSnapshotPeer>): IpsecSnapshotPeer {
return {
name: str(p.name),
address: str(p.address),
exchangeMode: str(p.exchangeMode),
passive: Boolean(p.passive),
certificate: str(p.certificate),
profile: str(p.profile),
comment: str(p.comment),
disabled: Boolean(p.disabled),
}
}
function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnapshotIdentity {
return {
peerName: str(i.peerName),
authMethod: str(i.authMethod),
certificate: str(i.certificate),
remoteCertificate: str(i.remoteCertificate),
matchBy: str(i.matchBy),
secret: str(i.secret),
remoteId: str(i.remoteId),
modeConfig: str(i.modeConfig),
generatePolicy: str(i.generatePolicy),
comment: str(i.comment),
disabled: Boolean(i.disabled),
}
}
function canonicalIpsecModeConfigRaw(m: Partial<IpsecSnapshotModeConfig>): IpsecSnapshotModeConfig {
return {
name: str(m.name),
addressPool: str(m.addressPool),
address: str(m.address),
staticDns: str(m.staticDns),
comment: str(m.comment),
}
}
export function canonicalIpsecSnapshot(input: {
peers?: Array<Partial<IpsecSnapshotPeer>>
identities?: Array<Partial<IpsecSnapshotIdentity>>
modeConfigs?: Array<Partial<IpsecSnapshotModeConfig>>
pools?: Array<Partial<IpsecSnapshotPool>>
policies?: Array<Partial<IpsecSnapshotPolicy>>
nat?: Array<Partial<IpsecSnapshotNat>>
}): IpsecSnapshot {
return {
peers: (input.peers ?? []).map(canonicalIpsecPeerRaw).filter((p) => p.name).sort((a, b) => a.name.localeCompare(b.name)),
identities: (input.identities ?? [])
.map(canonicalIpsecIdentityRaw)
.filter((i) => i.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
modeConfigs: (input.modeConfigs ?? [])
.map(canonicalIpsecModeConfigRaw)
.filter((m) => m.name)
.sort((a, b) => a.name.localeCompare(b.name)),
pools: (input.pools ?? [])
.map((p) => ({ name: str(p.name), ranges: str(p.ranges), comment: str(p.comment) }))
.filter((p) => p.name)
.sort((a, b) => a.name.localeCompare(b.name)),
policies: (input.policies ?? [])
.map((p) => ({ srcAddress: str(p.srcAddress), dstAddress: str(p.dstAddress), proposal: str(p.proposal), comment: str(p.comment) }))
.filter((p) => p.dstAddress)
.sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)),
nat: (input.nat ?? [])
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) }))
.filter((n) => n.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
}
}
export function parseIpsecSnapshot(payload: unknown): IpsecSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return canonicalIpsecSnapshot({})
}
const o = payload as Record<string, unknown>
return canonicalIpsecSnapshot({
peers: Array.isArray(o.peers) ? o.peers as Array<Partial<IpsecSnapshotPeer>> : [],
identities: Array.isArray(o.identities) ? o.identities as Array<Partial<IpsecSnapshotIdentity>> : [],
modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array<Partial<IpsecSnapshotModeConfig>> : [],
pools: Array.isArray(o.pools) ? o.pools as Array<Partial<IpsecSnapshotPool>> : [],
policies: Array.isArray(o.policies) ? o.policies as Array<Partial<IpsecSnapshotPolicy>> : [],
nat: Array.isArray(o.nat) ? o.nat as Array<Partial<IpsecSnapshotNat>> : [],
})
}
function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
return compactBody({
name: p.name,
address: p.address,
"exchange-mode": p.exchangeMode,
passive: rosYesNo(p.passive),
certificate: p.certificate,
"send-cert": "always",
profile: p.profile,
comment: p.comment,
disabled: rosYesNo(p.disabled),
})
}
function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
return compactBody({
peer: i.peerName,
"auth-method": i.authMethod,
certificate: i.certificate,
"remote-certificate": i.remoteCertificate,
"match-by": i.matchBy,
secret: isHiddenSecret(i.secret) ? undefined : i.secret,
"remote-id": i.remoteId,
"mode-config": i.modeConfig,
"generate-policy": i.generatePolicy,
comment: i.comment,
disabled: rosYesNo(i.disabled),
})
}
function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string> {
return compactBody({
name: m.name,
"address-pool": m.addressPool,
address: m.address,
"static-dns": m.staticDns,
comment: m.comment,
})
}
function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment })
}
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
return compactBody({
chain: n.chain,
action: n.action,
"src-address": n.srcAddress,
comment: n.comment,
})
}
/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем;
* чужие (не ipsec-managed) live-объекты не трогаем даже если их передали). */
export function planIpsecRestore(
desiredInput: IpsecSnapshot,
current: {
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
},
): RosWriteOp[] {
const desired = canonicalIpsecSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment))
const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment))
const managedModeConfigs = current.modeConfigs.filter((m) => isIpsecManagedComment(m.comment))
const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment))
const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment))
for (const identity of managedIdentities) {
if (!desired.identities.some((i) => i.comment === identity.comment)) {
ops.push({ op: "delete", path: `/ip/ipsec/identity/${identity.rosId}` })
}
}
for (const mc of managedModeConfigs) {
if (!desired.modeConfigs.some((m) => m.name === mc.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` })
}
}
for (const peer of managedPeers) {
if (!desired.peers.some((p) => p.name === peer.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` })
}
}
for (const pool of managedPools) {
if (!desired.pools.some((p) => p.name === pool.name)) {
ops.push({ op: "delete", path: `/ip/pool/${pool.rosId}` })
}
}
for (const rule of managedNat) {
if (!desired.nat.some((n) => n.comment === rule.comment)) {
ops.push({ op: "delete", path: `/ip/firewall/nat/${rule.rosId}` })
}
}
for (const want of desired.peers) {
const live = managedPeers.find((p) => p.name === want.name)
const body = ipsecPeerBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/peer", body })
else ops.push({ op: "patch", path: `/ip/ipsec/peer/${live.rosId}`, body })
}
for (const want of desired.modeConfigs) {
const live = managedModeConfigs.find((m) => m.name === want.name)
const body = ipsecModeConfigBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body })
else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body })
}
for (const want of desired.pools) {
const live = managedPools.find((p) => p.name === want.name)
const body = ipsecPoolBody(want)
if (!live) ops.push({ op: "put", path: "/ip/pool", body })
else ops.push({ op: "patch", path: `/ip/pool/${live.rosId}`, body })
}
for (const want of desired.identities) {
const live = managedIdentities.find((i) => i.comment === want.comment)
const body = ipsecIdentityBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/identity", body })
else ops.push({ op: "patch", path: `/ip/ipsec/identity/${live.rosId}`, body })
}
for (const want of desired.nat) {
const live = managedNat.find((n) => n.comment === want.comment)
const body = ipsecNatBody(want)
if (!live) ops.push({ op: "put", path: "/ip/firewall/nat", body })
else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body })
}
return ops
}
export function canonicalGreSnapshot(input: {
tunnels?: Array<Partial<GreSnapshotTunnel>>
}): GreSnapshot {
+130
View File
@@ -0,0 +1,130 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
type RosCertRow = Record<string, string | undefined>
const SIGN_POLL_TIMEOUT_MS = 90_000
const SIGN_POLL_INTERVAL_MS = 1_000
function isIp(value: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim())
}
export async function findCertificate(client: MikrotikClient, name: string): Promise<RosCertRow | undefined> {
const certs = await client.getCertificates()
return certs.find((c) => String(c.name ?? "").trim() === name)
}
/** Сертификат подписан: у него заполнен invalid-after. */
export async function isCertificateSigned(client: MikrotikClient, name: string): Promise<boolean> {
const cert = await findCertificate(client, name)
return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "")
}
/** sign в RouterOS не мгновенный: ждём появления invalid-after. */
export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isCertificateSigned(client, name)) return
await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS))
}
throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`)
}
/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */
export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise<string> {
const existing = await findCertificate(client, IPSEC_CA_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_CA_CERT,
"common-name": "MikrotikManager IPsec CA",
"key-size": "4096",
"key-usage": "key-cert-sign,crl-sign",
"days-valid": String(daysValid),
comment: ipsecManagedComment("CA"),
})
}
if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) {
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT)
}
return IPSEC_CA_CERT
}
/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */
export async function ensureServerCertificate(
client: MikrotikClient,
args: { serverEndpoint: string; caCertName: string; daysValid: number },
): Promise<string> {
const endpoint = args.serverEndpoint.trim()
const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}`
const existing = await findCertificate(client, IPSEC_SERVER_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_SERVER_CERT,
"common-name": endpoint,
"subject-alt-name": san,
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-server",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment("server"),
})
}
if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) {
await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, IPSEC_SERVER_CERT)
}
return IPSEC_SERVER_CERT
}
export interface IssuedClientCert {
certName: string
commonName: string
/** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */
existed: boolean
}
/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */
export async function issueClientCertificate(
client: MikrotikClient,
args: { userName: string; caCertName: string; daysValid: number },
): Promise<IssuedClientCert> {
const certName = clientCertName(args.userName)
const existing = await findCertificate(client, certName)
if (existing) {
return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true }
}
await client.addCertificate({
name: certName,
"common-name": args.userName.trim(),
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-client",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment(`client ${args.userName.trim()}`),
})
await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, certName)
return { certName, commonName: args.userName.trim(), existed: false }
}
/** Экспорт произвольного сертификата по имени (существующие 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 в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
}
+191
View File
@@ -0,0 +1,191 @@
import assert from "node:assert/strict"
import {
IPSEC_MARKER,
buildClientInstructions,
buildSswanConfig,
clientCertName,
findFreePoolIp,
identityDisplayName,
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
isIke2Peer,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
} from "./ipsec-config.js"
import { identityRosBody } from "./ipsec-ros.js"
{
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
assert.equal(ipsecSlug("Иван"), "")
assert.equal(clientCertName("Alice Cooper"), "ipsec-user-alice-cooper")
assert.equal(userModeConfigName("Bob"), "mc-ipsec-bob")
}
{
assert.ok(isIpsecManagedComment(ipsecManagedComment("IKEv2 road-warrior")))
assert.ok(isIpsecManagedComment(`${IPSEC_MARKER} user=alice`))
assert.ok(!isIpsecManagedComment("какой-то чужой комментарий"))
assert.ok(!isIpsecManagedComment(undefined))
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
assert.equal(parseIpsecUserComment("не managed"), null)
assert.equal(parseIpsecUserComment(null), null)
}
{
assert.equal(poolRangesFromCidr("10.77.0.0/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("10.77.0.1/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("192.168.7.0/28"), "192.168.7.2-192.168.7.14")
assert.equal(poolRangesFromCidr("10.77.0.0/31"), null, "/31 — нет адресов клиентам")
assert.equal(poolRangesFromCidr("10.77.0.0"), null)
assert.equal(poolRangesFromCidr("не-сидр"), null)
}
{
const range = "10.77.0.2-10.77.0.10"
assert.equal(findFreePoolIp(range, []), "10.77.0.2")
assert.equal(findFreePoolIp(range, ["10.77.0.2"]), "10.77.0.3")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3/32", "10.77.0.4"]), "10.77.0.5")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3", "10.77.0.4", "10.77.0.5", "10.77.0.6", "10.77.0.7", "10.77.0.8", "10.77.0.9", "10.77.0.10"]), null)
// одиночный адрес без диапазона
assert.equal(findFreePoolIp("10.77.0.7", []), "10.77.0.7")
assert.equal(findFreePoolIp("мусор", []), null)
}
{
// 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({
name: "IKEv2 vpn.example.com",
serverEndpoint: "vpn.example.com",
serverId: "vpn.example.com",
p12B64: "cDEy",
uuid: "fix-me",
})
const parsed = JSON.parse(sswan) as Record<string, unknown>
assert.equal(parsed.version, 1)
assert.equal(parsed.type, "ikev2-cert")
const remote = parsed.remote as Record<string, string>
const local = parsed.local as Record<string, string>
assert.equal(remote.addr, "vpn.example.com")
assert.equal(remote.id, "vpn.example.com")
assert.equal(local.p12, "cDEy")
}
{
const text = buildClientInstructions({
userName: "alice",
serverEndpoint: "vpn.example.com",
p12Filename: "cert_export_ipsec-user-alice.p12",
passphrase: "s3cret",
dns: "10.77.0.1",
})
assert.ok(text.includes("alice"))
assert.ok(text.includes("vpn.example.com"))
assert.ok(text.includes("s3cret"))
assert.ok(text.includes("strongSwan"))
}
// ── 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")
+303
View File
@@ -0,0 +1,303 @@
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
import { isCaCertificate } from "./certificate-parse.js"
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
export const IPSEC_MARKER = `${PRODUCT_NAME}:ipsec`
export function ipsecManagedComment(label = ""): string {
return label ? `${IPSEC_MARKER} ${label}` : IPSEC_MARKER
}
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
}
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
export function ipsecUserComment(name: string): string {
return `${IPSEC_MARKER} user=${name.trim()}`
}
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
if (!comment) return null
const m = /user=([^\s]+)/.exec(comment.trim())
return m?.[1] ?? null
}
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 ──────────────────────────
export const IPSEC_CA_CERT = "ipsec-ca"
export const IPSEC_SERVER_CERT = "ipsec-server"
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
export const IPSEC_COMMON_NAME = "ipsec-vpn"
export function ipsecSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function clientCertName(userName: string): string {
return `ipsec-user-${ipsecSlug(userName) || "client"}`
}
export function userModeConfigName(userName: string): string {
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
}
// ── отбор 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 {
return (((b[0]! << 24) | (b[1]! << 16) | (b[2]! << 8) | b[3]!) >>> 0)
}
function intToIp(n: number): string {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
}
function parseIpv4(s: string): number | null {
const parts = s.trim().split(".")
if (parts.length !== 4) return null
const octets = parts.map((p) => Number(p))
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null
return ipToInt(octets)
}
/** «10.77.0.0/24» → pool ranges «10.77.0.2-10.77.0.254» (шлюзы .1 и broadcast не раздаём). */
export function poolRangesFromCidr(cidr: string): string | null {
const m = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(cidr.trim())
if (!m) return null
const base = parseIpv4(m[1]!)
const prefix = Number(m[2])
if (base == null || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return null
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
const network = (base & mask) >>> 0
const size = 2 ** (32 - prefix)
const from = network + 2
const to = network + size - 2
if (to <= from) return null
return `${intToIp(from)}-${intToIp(to)}`
}
export interface PoolIp {
ip: string
n: number
}
/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e», берём первый диапазон из списка), исключая занятые. */
export function findFreePoolIp(range: string, taken: Iterable<string>): string | null {
const takenSet = new Set(
Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()),
)
const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? ""
const [fromRaw, toRaw] = first.split("-")
const from = parseIpv4(fromRaw ?? "")
const to = parseIpv4(toRaw ?? fromRaw ?? "")
if (from == null) return null
const last = to ?? from
if (last < from) return null
const cap = Math.min(last, from + 65_534)
for (let n = from; n <= cap; n++) {
const ip = intToIp(n)
if (!takenSet.has(ip)) return ip
}
return null
}
// ── клиентские конфиги ──────────────────────────────────────────────────────
/** strongSwan (Android/iOS) .sswan-профиль с встроенным .p12. */
export function buildSswanConfig(args: {
name: string
serverEndpoint: string
serverId: string
p12B64: string
uuid?: string
}): string {
return JSON.stringify(
{
version: 1,
uuid: args.uuid ?? randomUUID(),
name: args.name,
type: "ikev2-cert",
remote: { addr: args.serverEndpoint, id: args.serverId },
local: { p12: args.p12B64 },
"ike-proposal": "AES256-SHA256-MODP2048",
"esp-proposal": "AES256-SHA256-MODP2048",
},
null,
2,
)
}
/** Короткая текстовая инструкция по подключению (Windows/macOS/iOS/Android). */
export function buildClientInstructions(args: {
userName: string
serverEndpoint: string
p12Filename: string
passphrase?: string
dns?: string
}): string {
const pass = args.passphrase ? `Пароль архива (при импорте): ${args.passphrase}\n` : ""
return [
`IKEv2/IPsec VPN — клиент «${args.userName}»`,
`Сервер: ${args.serverEndpoint}${args.dns ? ` (DNS: ${args.dns})` : ""}`,
"",
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).",
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
"",
"Адрес выдаётся автоматически при подключении (mode-config).",
].join("\n")
}
+618
View File
@@ -0,0 +1,618 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type {
IpsecCertInfoDto,
IpsecClientDto,
IpsecListResponse,
IpsecModeConfigDto,
IpsecPeerDto,
IpsecPoolDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
clientCertName,
identityDisplayName,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
} from "./ipsec-config.js"
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
type IpsecLiveModeConfig,
type IpsecLiveNat,
type IpsecLivePeer,
type IpsecLivePool,
type IpsecSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
export interface RosIpsecPeer {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
profile?: string
disabled?: string
comment?: string
}
export interface RosIpsecIdentity {
".id"?: string
peer?: string
"auth-method"?: string
certificate?: string
"remote-certificate"?: string
"match-by"?: string
secret?: string
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
disabled?: string
comment?: string
}
export interface RosIpsecModeConfig {
".id"?: string
name?: string
"address-pool"?: string
"address-prefix"?: string
address?: string
"split-dns"?: string
"static-dns"?: string
comment?: string
}
export interface RosIpsecPool {
".id"?: string
name?: string
ranges?: string
comment?: string
}
export interface RosIpsecPolicy {
".id"?: string
"src-address"?: string
"dst-address"?: string
proposal?: string
template?: string
comment?: string
}
export interface RosFirewallNat {
".id"?: string
chain?: string
action?: string
"src-address"?: string
comment?: string
}
export interface RosIpsecActivePeer {
".id"?: string
address?: string
"remote-id"?: string
identity?: string
established?: string
}
type RosCertRow = Record<string, string | undefined>
function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
const name = String(c.name ?? "")
const isUser = name.startsWith("ipsec-user-")
const role = certificateRole(c, {
peerCertNames: ctx.peerCertNames,
identityCertNames: ctx.identityCertNames,
})
return {
name,
commonName: c["common-name"] || undefined,
keySize: c["key-size"] || undefined,
fingerprint: c.fingerprint || undefined,
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role,
signedBy: (c.ca ?? "").trim() || undefined,
managed: (name === IPSEC_CA_CERT && role === "ca")
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
}
}
function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`,
rosId: String(p[".id"] ?? p.name ?? "peer"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name: (p.name ?? "").trim(),
address: p.address || undefined,
exchangeMode: p["exchange-mode"] || undefined,
passive: asBool(p.passive),
certificate: p.certificate || undefined,
profile: p.profile || undefined,
disabled: asBool(p.disabled),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto {
return {
id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`,
rosId: String(m[".id"] ?? m.name ?? "mc"),
serverId: String(server.id),
name: (m.name ?? "").trim(),
addressPool: m["address-pool"] || m["address-prefix"] || undefined,
address: m.address || undefined,
splitDns: m["split-dns"] || undefined,
staticDns: m["static-dns"] || undefined,
comment: m.comment || undefined,
managed: isIpsecManagedComment(m.comment),
}
}
function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`,
rosId: String(p[".id"] ?? p.name ?? "pool"),
serverId: String(server.id),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
export interface IpsecServerState {
server: ServerRow
client: MikrotikClient
peers: RosIpsecPeer[]
identities: RosIpsecIdentity[]
modeConfigs: RosIpsecModeConfig[]
pools: RosIpsecPool[]
policies: RosIpsecPolicy[]
nat: RosFirewallNat[]
active: RosIpsecActivePeer[]
certs: RosCertRow[]
}
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> {
const client = MikrotikClient.fromServer(server)
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]),
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]),
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]),
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]),
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
client.getCertificates().catch(() => [] as RosCertRow[]),
])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/**
* Клиенты IKEv2:
* 1) клиентские сертификаты (роль client) — включая существующие client1/anakondra на устройстве;
* 2) PSK-identity.
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
*/
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
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>()
for (const a of state.active) {
const rid = String(a["remote-id"] ?? "").trim()
if (rid) activeByRemote.set(rid, a)
}
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
for (const i of state.identities) {
const rc = (i["remote-certificate"] ?? "").trim()
if (rc) identityByRemoteCert.set(rc, i)
}
const personalStaticIp = (mcNameRaw: string): string | undefined => {
const mc = mcByName.get(mcNameRaw)
if (!mc) return undefined
return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
}
const out: IpsecClientDto[] = []
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
for (const cert of state.certs) {
const certName = String(cert.name ?? "").trim()
if (!certName) continue
if (certificateRole(cert, roleCtx) !== "client") continue
const signedBy = (cert.ca ?? "").trim()
const isUser = certName.startsWith("ipsec-user-")
const referenced = identityByRemoteCert.has(certName)
if (!isUser && !referenced && !(caName && signedBy === caName)) continue
const identity = identityByRemoteCert.get(certName)
const comment = identity?.comment ?? ""
const mcName = (identity?.["mode-config"] ?? "").trim()
const cn = String(cert["common-name"] ?? "").trim()
const active = (cn ? activeByRemote.get(cn) : undefined)
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
out.push({
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
serverId: String(server.id),
serverName,
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),
})
}
// 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 {
const server = state.server
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const ike2Identities = state.identities.filter(
(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
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
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")
return {
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
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,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert,
serverCert,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
}
}
/**
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
*/
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "").trim(),
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
policies: state.policies
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
srcAddress: (p["src-address"] ?? "").trim(),
dstAddress: (p["dst-address"] ?? "").trim(),
proposal: (p.proposal ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
})
}
export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
snapshot: IpsecSnapshot
}> {
const state = await fetchIpsecState(server)
return {
client: state.client,
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
rosId: String(i[".id"] ?? ""),
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const,
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
rosId: String(n[".id"] ?? ""),
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
}
}
export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: IpsecListResult["failures"] = []
const summaries: IpsecServerSummaryDto[] = []
const clients: IpsecClientDto[] = []
await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchIpsecState(server)
const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
summaries.sort((a, b) => a.serverName.localeCompare(b.serverName))
clients.sort((a, b) => a.name.localeCompare(b.name))
return { servers: summaries, clients, failures }
}
export async function countIpsecClients(): Promise<number> {
try {
const result = await Promise.race([
listIpsec(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.clients.length
} catch {
return 0
}
}
export async function getEnabledIpsecServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
export async function listIpsecClientsForCatalog(serverId: number): Promise<{
clients: IpsecClientDto[]
error?: string
}> {
const row = await getEnabledIpsecServerById(serverId)
if (!row) return { clients: [], error: "Сервер не найден" }
try {
const state = await Promise.race([
fetchIpsecState(row),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), 5_000)
}),
])
return { clients: mapClients(state) }
} catch (e) {
return { clients: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { clientCertName }
+258
View File
@@ -0,0 +1,258 @@
import type { MikrotikClient } from "./mikrotik.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> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v == null) continue
const s = String(v)
if (s === "") continue
out[k] = s
}
return out
}
type RosRow = Record<string, string | undefined>
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
const raw = await client.get<unknown>(path).catch(() => [])
return Array.isArray(raw) ? (raw as RosRow[]) : []
}
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
export async function putOrPatchByName(
client: MikrotikClient,
path: string,
name: string,
body: Record<string, string>,
): Promise<"created" | "patched"> {
const rows = await listByPath(client, path)
const existing = rows.find((r) => String(r.name ?? "").trim() === name)
if (existing?.[".id"]) {
await client.patch(`${path}/${encodeURIComponent(existing[".id"])}`, body)
return "patched"
}
await client.put(path, body)
return "created"
}
async function findRosId(client: MikrotikClient, path: string, name: string): Promise<string | null> {
const rows = await listByPath(client, path)
return rows.find((r) => String(r.name ?? "").trim() === name)?.[".id"] ?? null
}
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
name,
"hash-algorithm": "sha256",
"enc-algorithm": "aes-256,aes-192,aes-128",
"dh-group": "modp2048,modp1536,modp1024",
"nat-traversal": "yes",
comment,
}))
}
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
name,
"auth-algorithms": "sha256,sha1",
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
"pfs-group": "modp2048",
comment,
}))
}
export async function ensureIpsecPool(client: MikrotikClient, name: string, ranges: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/pool", name, toRosBody({ name, ranges, comment }))
}
/** Общий mode-config: адрес клиентам из пула. */
export async function ensureSharedModeConfig(
client: MikrotikClient,
name: string,
poolName: string,
dns: string | undefined,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
"address-pool": poolName,
"static-dns": dns,
comment,
}))
}
export async function ensurePolicyTemplate(
client: MikrotikClient,
dstCidr: string,
proposalName: string,
comment: string,
): Promise<void> {
const rows = await listByPath(client, "/ip/ipsec/policy")
const managed = rows.find((r) => (r.comment ?? "").trim() === comment && (r.template === "true" || r.template === "yes"))
if (managed?.[".id"]) {
await client.patch(`/ip/ipsec/policy/${encodeURIComponent(managed[".id"])}`, toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
comment,
}))
return
}
await client.put("/ip/ipsec/policy", toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
template: "yes",
comment,
}))
}
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
serverCertName: string,
profileName: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/peer", name, toRosBody({
name,
address: "0.0.0.0/0",
"exchange-mode": "ike2",
passive: "yes",
certificate: serverCertName,
"send-cert": "always",
profile: profileName,
comment,
}))
}
/** Managed srcnat masquerade: интернет клиентам VPN. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
const rows = await listByPath(client, "/ip/firewall/nat")
const existing = rows.find(
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
)
if (existing?.[".id"]) return
await client.put("/ip/firewall/nat", toRosBody({
chain: "srcnat",
action: "masquerade",
"src-address": poolCidr,
"out-interface-list": "WAN",
comment,
}))
}
// ── клиенты ─────────────────────────────────────────────────────────────────
export async function putUserModeConfig(
client: MikrotikClient,
name: string,
staticIp: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
address: staticIp,
comment,
}))
}
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
}
export interface IdentityFields {
peerName: string
modeConfig: string
comment: string
authMethod: "certificate" | "pre-shared-key"
/** cert: серверный сертификат (router представляет его клиенту). */
certificate?: string
/** cert: строгий мэтч конкретного клиента по его сертификату. */
remoteCertificate?: string
/** psk. */
secret?: string
remoteId?: string
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
placeBefore?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
return toRosBody({
peer: f.peerName,
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
certificate: f.certificate,
"remote-certificate": f.remoteCertificate,
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
secret: f.secret,
"remote-id": f.remoteId,
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
"place-before": f.placeBefore,
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
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> {
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
}
export async function deleteIdentity(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
}
export async function patchPeer(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
}
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
}
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
}
export { listByPath, findRosId, ipsecManagedComment }
+167
View File
@@ -267,6 +267,57 @@ function rosDelete(
})
}
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */
function rosDownload(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const req = lib.request(options, (res) => {
const chunks: Buffer[] = []
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
res.on("end", () => {
const buf = Buffer.concat(chunks)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200)))
return
}
const contentType = String(res.headers["content-type"] ?? "")
if (contentType.includes("application/json")) {
reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`))
return
}
resolve(buf)
})
})
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
req.on("close", () => clearTimeout(timer))
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPatch(
params: MikrotikConnectParams,
path: string,
@@ -364,6 +415,13 @@ export function encodeRosId(rosId: string): string {
// ── 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 {
constructor(private readonly params: MikrotikConnectParams) {}
@@ -621,6 +679,115 @@ export class MikrotikClient {
}
}
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> {
const normalized = routerFileBasename(fileName)
const candidates = [normalized, `flash/${normalized}`]
let lastError: unknown
for (const name of candidates) {
try {
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
}
/**
* 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…). */
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
return this.postTolerant("/certificate/add", body, timeoutMs)
}
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
async signCertificate(params: {
name: string
ca?: string
daysValid?: number
}, timeoutMs = 120_000): Promise<unknown> {
const body: Record<string, string> = { name: params.name }
if (params.ca) body.ca = params.ca
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
try {
return await this.postTolerant("/certificate/sign", body, timeoutMs)
} catch (e) {
// Некоторые версии REST принимают цель подписи только как .id.
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === params.name)
const id = row?.[".id"]
if (!id) throw e
return await this.postTolerant("/certificate/sign", { ".id": id, ...body }, timeoutMs)
}
}
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
async exportCertificate(params: {
name: string
type: "pkcs12" | "pem"
passphrase?: string
}, timeoutMs = 60_000): Promise<string> {
const body: Record<string, string> = { name: params.name, type: params.type }
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
let raw: unknown
try {
raw = await this.postTolerant("/certificate/export-certificate", body, timeoutMs)
} catch (e) {
const certs = await this.getCertificates()
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
if (!id) throw e
raw = await this.postTolerant("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
}
void raw
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
const ext = params.type === "pkcs12" ? "p12" : "crt"
const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`]
const files = await this.listFiles()
const hit = files.find((f) => wanted.includes(f.name))
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
return hit.name
}
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
const content = await this.downloadFile(fileName)
return { fileName, content }
}
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId)
const id = row?.[".id"]
if (!id) return
await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs)
}
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
try {
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
import { getStatistics, getStatisticsPivot, normalizeFactService, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
@@ -24,6 +24,43 @@ import { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics"
assert.equal(pivotDimsConflict("country", "service"), false)
}
{
const nowMs = Date.parse("2026-09-12T12:00:00Z")
// «Сегодня»: окно до сейчас, не до конца суток — иначе avgBps размывается будущими часами.
const today = parseStatisticsPeriod("2026-09-12", "2026-09-12", nowMs)
assert.ok(today)
assert.equal(today.windowSec, 12 * 3600)
assert.equal(today.grain, "hour")
assert.equal(today.toDayExclusive, "2026-09-13", "дневные факты текущего дня не теряем")
// Прошлые периоды не клампятся.
const past = parseStatisticsPeriod("2026-09-10", "2026-09-10", nowMs)
assert.ok(past)
assert.equal(past.windowSec, 86_400)
// ISO-диапазон ровно 24 часа.
const iso24 = parseStatisticsPeriod("2026-09-11T12:00:00Z", "2026-09-12T12:00:00Z", nowMs)
assert.ok(iso24)
assert.equal(iso24.windowSec, 86_400)
assert.equal(iso24.grain, "hour")
// «to» далеко в будущем клампится к сейчас.
const futureTo = parseStatisticsPeriod("2026-09-11", "2026-09-20", nowMs)
assert.ok(futureTo)
assert.equal(futureTo.windowSec, 86_400 + 12 * 3600)
// Полностью будущий диапазон невалиден.
assert.equal(parseStatisticsPeriod("2026-09-13", "2026-09-14", nowMs), null)
}
{
// Таксономия сервисов как на карте: skip-список сворачивается в «Прочее».
assert.equal(normalizeFactService("Google"), "Google")
assert.equal(normalizeFactService("DNS"), "Прочее")
assert.equal(normalizeFactService("SSH"), "Прочее")
assert.equal(normalizeFactService("BGP"), "Прочее")
assert.equal(normalizeFactService("WireGuard"), "Прочее")
assert.equal(normalizeFactService("GRE"), "Прочее")
assert.equal(normalizeFactService("Прочее"), "Прочее")
assert.equal(normalizeFactService(""), "Прочее")
}
if (!(await withPgOrSkip())) {
console.log("statistics-aggregate.test.ts: skip")
process.exit(0)
+26 -7
View File
@@ -27,6 +27,13 @@ import {
wanIfaceLabel,
} from "./traffic-flow-facts-filter.js"
import { getServerCatalog, loadFlowTopology, type FlowTopology } from "./traffic-flow-topology.js"
import { OTHER_SERVICE, isNamedInternetService } from "./traffic-flow-brands.js"
/** Так же, как на карте сети: DNS/SSH/BGP/туннели и пустые метки — не отдельные сервисы, а «Прочее». */
export function normalizeFactService(label: string): string {
const s = String(label ?? "").trim()
return isNamedInternetService(s, "") ? s : OTHER_SERVICE
}
const TOP_N = 200
const HOUR_WINDOW_MS = 48 * 3600_000
@@ -58,7 +65,7 @@ function addUtcDays(day: string, n: number): string {
}
/** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */
export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPeriod | null {
export function parseStatisticsPeriod(fromRaw: string, toRaw: string, nowMs: number = Date.now()): ParsedPeriod | null {
const from = Date.parse(fromRaw.includes("T") ? fromRaw : `${fromRaw}T00:00:00Z`)
const toHasTime = toRaw.includes("T")
const to = Date.parse(toHasTime ? toRaw : `${toRaw}T00:00:00Z`)
@@ -75,6 +82,9 @@ export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPer
toDayExclusive = addUtcDays(toUtcDay(toDate), 1)
toDate = new Date(`${toDayExclusive}T00:00:00Z`)
}
// Конец периода в будущем (например, «сегодня»): окно длится только до сейчас,
// иначе avgBps размывается ещё не наступившими часами суток.
if (toDate.getTime() > nowMs) toDate = new Date(nowMs)
if (toDate.getTime() <= from) return null
const windowSec = Math.max(1, Math.round((toDate.getTime() - from) / 1000))
const grain: "hour" | "day" = toDate.getTime() - from <= HOUR_WINDOW_MS ? "hour" : "day"
@@ -553,12 +563,14 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
period.windowSec,
)
const services = toBreakdown(
serviceRows.map((r) => ({
id: r.id,
label: r.id,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
Object.entries(serviceRows.reduce<Record<string, { bytes: number; packets: number }>>((acc, r) => {
const key = normalizeFactService(r.id)
const prev = acc[key] ?? { bytes: 0, packets: 0 }
prev.bytes += Number(r.bytes) || 0
prev.packets += Number(r.packets) || 0
acc[key] = prev
return acc
}, {})).map(([label, v]) => ({ id: label, label, bytes: v.bytes, packets: v.packets })),
bytes,
period.windowSec,
)
@@ -702,6 +714,13 @@ export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<S
GROUP BY 1, 2
`, [...join.params, ...where.params])
if (query.row === "service" || query.col === "service") {
for (const r of raw) {
if (query.row === "service") r.row_id = normalizeFactService(String(r.row_id ?? ""))
if (query.col === "service") r.col_id = normalizeFactService(String(r.col_id ?? ""))
}
}
if (query.row === "iface" || query.col === "iface") {
const ifaceServerIds: number[] = []
for (const r of raw) {
@@ -8,6 +8,8 @@ import {
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
mapCountryNodeId,
mapCountryServiceNodeId,
resolveFlowBrand,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -44,6 +46,13 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(mapServiceNodeId("Прочее"), "svc:other")
assert.equal(mapCountryNodeId("US"), "cc:us")
assert.equal(mapCountryNodeId("nl"), "cc:nl")
assert.equal(mapCountryNodeId(""), "cc:other")
assert.equal(mapCountryNodeId("Прочее"), "cc:other")
assert.equal(mapCountryNodeId("EU"), "cc:other")
assert.equal(mapCountryServiceNodeId("cc:us", "svc:google"), "cc:us|svc:google")
assert.equal(mapCountryServiceNodeId("cc:other", "svc:other"), "cc:other|svc:other")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
@@ -364,3 +364,15 @@ export function mapServiceNodeId(label: string): string {
.replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}`
}
/** `US` → `cc:us`; неизвестная / пустая → `cc:other`. */
export function mapCountryNodeId(code: string): string {
const iso = normalizeIsoCountry(code)
if (!iso) return "cc:other"
return `cc:${iso.toLowerCase()}`
}
/** id сервиса внутри страны: `cc:us|svc:google` — Google в US и NL не смешиваются в одном payload. */
export function mapCountryServiceNodeId(countryId: string, serviceId: string): string {
return `${countryId}|${serviceId}`
}
@@ -6,6 +6,8 @@ import {
} from "./traffic-flow-ingest.js"
import {
buildFlowMapHops,
MAP_COUNTRY_SERVICE_MIN_NODES,
MAP_COUNTRY_SERVICE_NODE_CAP,
MAP_SERVICE_MIN_NODES,
MAP_SERVICE_NODE_CAP,
pickMapServices,
@@ -306,6 +308,15 @@ try {
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
const us = six.countries?.find((s) => s.id === "cc:us")
assert.ok(us, "Google ripe country US")
assert.equal(us.label, "US")
const usEdge = six.countryEdges?.find((e) => e.toId === "cc:us" && e.fromId === "9")
assert.ok(usEdge)
assert.ok(!(six.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")), "страны не смешиваются с svc:*")
const usPath = six.countryPaths?.find((p) => p.serviceId === "cc:us" && p.enId === "9")
assert.ok(usPath)
assert.equal(usPath.clientName, "Alice")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -810,4 +821,180 @@ try {
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
seedRipeCacheForTests({
prefix: "185.45.12.0/24",
asn: 13335,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "CLOUDFLARENET, NL",
ok: true,
fetchedAt: Date.now(),
})
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
payloadFlow("185.45.12.10", 5000),
])
try {
resetFlowMapHopsCacheForTests()
const split = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const us = split.countries?.find((s) => s.id === "cc:us")
const nl = split.countries?.find((s) => s.id === "cc:nl")
assert.ok(us, "US из ripe Google")
assert.ok(nl, "NL из ripe Cloudflare")
assert.equal(us.bytes, 5000)
assert.equal(nl.bytes, 5000)
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:us" && e.fromId === "9"))
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:nl" && e.fromId === "9"))
assert.ok(!(split.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")))
assert.ok(split.serviceEdges?.some((e) => e.toId === "svc:google"))
assert.ok(!(split.serviceEdges ?? []).some((e) => e.toId.startsWith("cc:")))
assert.ok(split.countryPaths?.some((p) => p.serviceId === "cc:nl" && p.enId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 600),
payloadFlow("203.0.113.50", 9400),
])
try {
resetFlowMapHopsCacheForTests()
const nested = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usGroup, "группа сервисов cc:us")
const nestedGoogle = usGroup.services.find((s) => s.id === "cc:us|svc:google")
assert.ok(nestedGoogle, "cc:us|svc:google в группе")
assert.equal(nestedGoogle.label, "Google")
assert.ok(Math.abs(nestedGoogle.share - 1) < 0.01, "доля от байтов страны (600/600), не окна")
const nestedEdge = usGroup.edges.find((e) => e.toId === "cc:us|svc:google")
assert.ok(nestedEdge)
assert.equal(nestedEdge.fromId, "cc:us", "ребро вложенного слоя: страна → сервис")
assert.equal(nestedEdge.bytes, 600)
assert.ok(!usGroup.edges.some((e) => e.fromId.startsWith("svc:")), "fromId вложенных рёбер не svc:*")
const nestedPath = usGroup.paths.find((p) => p.serviceId === "cc:us|svc:google")
assert.ok(nestedPath)
assert.equal(nestedPath.enId, "9", "путь держит реальный EN для подсветки HR→JH→EN")
assert.equal(nestedPath.clientName, "Alice")
const otherGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:other")
assert.ok(otherGroup, "группа cc:other (Прочее-страна)")
assert.ok(otherGroup.services.some((s) => s.id === "cc:other|svc:other"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
seedRipeCacheForTests({
prefix: "185.45.12.0/24",
asn: 13335,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "CLOUDFLARENET, NL",
ok: true,
fetchedAt: Date.now(),
})
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
payloadFlow("185.45.12.10", 5000),
])
try {
resetFlowMapHopsCacheForTests()
const nestedSplit = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:us")
const nlNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:nl")
assert.ok(usNested && nlNested, "группы у обеих стран")
assert.ok(usNested.services.every((s) => s.id.startsWith("cc:us|")), "сервисы US только cc:us|*")
assert.ok(nlNested.services.every((s) => s.id.startsWith("cc:nl|")), "сервисы NL только cc:nl|*")
assert.ok(usNested.services.some((s) => s.id === "cc:us|svc:google"), "Google в US")
assert.ok(nlNested.services.some((s) => s.id === "cc:nl|svc:cloudflare"), "Cloudflare в NL")
assert.ok(!nlNested.services.some((s) => s.id === "cc:us|svc:google"), "Google US не утек в NL")
assert.ok(usNested.edges.every((e) => e.fromId === "cc:us"))
assert.ok(nlNested.edges.every((e) => e.fromId === "cc:nl"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
])
try {
resetFlowMapHopsCacheForTests()
const nestedTop = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usTop = nestedTop.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usTop)
assert.equal(
usTop.services.length,
MAP_COUNTRY_SERVICE_MIN_NODES,
"мелкий хвост держится минимумом узлов внутри страны",
)
assert.ok(usTop.services.some((s) => s.id === "cc:us|svc:google"))
resetFlowMapHopsCacheForTests()
const nestedAll = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const usAll = nestedAll.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usAll)
assert.equal(usAll.services.length, MAP_COUNTRY_SERVICE_NODE_CAP, "cap вложенного слоя = 8")
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:epic"), "ранг 9+ скрыт")
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:riot"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok")
+253 -119
View File
@@ -1,9 +1,10 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js"
import { userInterfaceBindings } from "../db/schema.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { OTHER_SERVICE, isNamedInternetService, mapServiceNodeId } from "./traffic-flow-brands.js"
import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapCountryServiceNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
@@ -20,6 +21,11 @@ export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
export const MAP_SERVICE_MIN_NODES = 8
/** Cap сервисов внутри раскрытой страны (база доли — байты страны, не окна). */
export const MAP_COUNTRY_SERVICE_NODE_CAP = 8
/** Минимум узлов-сервисов внутри страны, даже если доля ниже порога. */
export const MAP_COUNTRY_SERVICE_MIN_NODES = 4
export const MAP_COUNTRY_CATEGORY = "Страна"
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery {
@@ -64,6 +70,32 @@ interface DstAcc {
fromBytes: Map<string, FromAcc>
}
interface DestTotal {
label: string
category: string
bytes: number
}
interface DestEdgeAcc {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}
interface DestPathAcc {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
const id = client?.userId || "—"
const name = client?.name || "—"
@@ -100,12 +132,194 @@ export function clampMapServiceMinSharePct(n: unknown): number {
}
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
export function pickMapServices(
ranked: FlowMapService[],
minSharePct: number,
cap: number = MAP_SERVICE_NODE_CAP,
minNodes: number = MAP_SERVICE_MIN_NODES,
): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, cap)
const minShare = minSharePct / 100
return ranked
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
.slice(0, MAP_SERVICE_NODE_CAP)
.filter((s, i) => s.share >= minShare || i < minNodes)
.slice(0, cap)
}
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
const prev = totals.get(id)
if (prev) {
prev.bytes += bytes
return
}
totals.set(id, { label, category, bytes })
}
function bumpDestFrom(
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
toId: string,
from: FromAcc,
exporterId: string,
fromId: string,
enName: string,
viaName: string,
/** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */
edgeFromId: string = fromId,
): void {
const edgeKey = `${edgeFromId}|${toId}`
const prevEdge = edges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
edges.set(edgeKey, {
fromId: edgeFromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = paths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
paths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
}
function finalizeDestLayer(
totals: Map<string, DestTotal>,
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
windowSec: number,
minSharePct: number,
shareBase: number,
cap: number = MAP_SERVICE_NODE_CAP,
minNodes: number = MAP_SERVICE_MIN_NODES,
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
const nodes = pickMapServices(
[...totals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
cap,
minNodes,
)
const keep = new Set(nodes.map((s) => s.id))
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
.filter((e) => keep.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const outPaths: FlowMapServicePath[] = [...paths.values()]
.filter((p) => keep.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
return { nodes, edges: outEdges, paths: outPaths }
}
/** Группы сервисов по странам: только страны, прошедшие отбор слоя стран; доля — от байтов страны, не окна. */
function buildCountryServiceGroups(
nestedTotals: Map<string, DestTotal>,
nestedEdges: Map<string, DestEdgeAcc>,
nestedPaths: Map<string, DestPathAcc>,
countryNodes: FlowMapService[],
windowSec: number,
minSharePct: number,
): FlowMapCountryServiceGroup[] {
const groups: FlowMapCountryServiceGroup[] = []
for (const country of countryNodes) {
const prefix = `${country.id}|`
const totals = new Map<string, DestTotal>()
const edges = new Map<string, DestEdgeAcc>()
const paths = new Map<string, DestPathAcc>()
for (const [id, t] of nestedTotals) {
if (id.startsWith(prefix)) totals.set(id, t)
}
if (totals.size === 0) continue
for (const [key, e] of nestedEdges) {
if (e.toId.startsWith(prefix)) edges.set(key, e)
}
for (const [key, p] of nestedPaths) {
if (p.serviceId.startsWith(prefix)) paths.set(key, p)
}
const out = finalizeDestLayer(
totals,
edges,
paths,
windowSec,
minSharePct,
country.bytes,
MAP_COUNTRY_SERVICE_NODE_CAP,
MAP_COUNTRY_SERVICE_MIN_NODES,
)
groups.push({ countryId: country.id, services: out.nodes, edges: out.edges, paths: out.paths })
}
return groups
}
function countryDestFromRipe(ripe: FlowIpMeta | null, destKey: string): { id: string; label: string; category: string } {
if (!destKey || destKey === "__other__" || !ripe?.ok) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
const iso = resolveRipeCountry(ripe.country, ripe.asn, ripe.holder)
if (!iso) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
return { id: mapCountryNodeId(iso), label: iso, category: MAP_COUNTRY_CATEGORY }
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
@@ -368,25 +582,15 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
}
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
const svcEdges = new Map<string, {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}>()
const svcPaths = new Map<string, {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}>()
const svcTotals = new Map<string, DestTotal>()
const svcEdges = new Map<string, DestEdgeAcc>()
const svcPaths = new Map<string, DestPathAcc>()
const ccTotals = new Map<string, DestTotal>()
const ccEdges = new Map<string, DestEdgeAcc>()
const ccPaths = new Map<string, DestPathAcc>()
const nestedTotals = new Map<string, DestTotal>()
const nestedEdges = new Map<string, DestEdgeAcc>()
const nestedPaths = new Map<string, DestPathAcc>()
for (const h of hops.values()) {
if (h.kind !== "gre" || !h.toId) continue
@@ -423,58 +627,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const classified = dst && dst !== "__other__"
? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
: { service: OTHER_SERVICE, category: OTHER_SERVICE }
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += acc.bytes
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes })
const svcId = mapServiceNodeId(classified.service)
bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
const country = countryDestFromRipe(ripe, dst)
bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes)
const nestedId = mapCountryServiceNodeId(country.id, svcId)
bumpDestTotal(nestedTotals, nestedId, classified.service, classified.category, acc.bytes)
for (const [exporterId, from] of acc.fromBytes) {
const fromId = anchorEnId(exporterId)
if (!fromId) continue
const edgeKey = `${fromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
svcEdges.set(edgeKey, {
fromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
const enName = nodeName(fromId)
const viaName = nodeName(exporterId)
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = svcPaths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
svcPaths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
bumpDestFrom(nestedEdges, nestedPaths, nestedId, from, exporterId, fromId, enName, viaName, country.id)
}
}
@@ -483,52 +649,16 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
.reduce((n, s) => n + s.bytes, 0)
const unclassifiedBytes = Math.max(0, totalBytes - namedBytes)
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
const services = pickMapServices(
[...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
const countryServiceGroups = buildCountryServiceGroups(
nestedTotals,
nestedEdges,
nestedPaths,
countriesOut.nodes,
windowSec,
minSharePct,
)
const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
.filter((p) => keepSvc.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
const listener = getFlowListenerState()
const geo = geoipReadersStatus()
@@ -544,9 +674,13 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
unclassifiedBytes,
asnLoaded: geo.asnLoaded,
countryLoaded: geo.countryLoaded,
services,
serviceEdges,
servicePaths,
services: servicesOut.nodes,
serviceEdges: servicesOut.edges,
servicePaths: servicesOut.paths,
countries: countriesOut.nodes,
countryEdges: countriesOut.edges,
countryPaths: countriesOut.paths,
countryServiceGroups,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
+4
View File
@@ -37,6 +37,7 @@ import {
BoxIcon,
BadgeCheckIcon,
UsersIcon,
LockIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
@@ -78,6 +79,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
{ title: "IPsec / IKEv2", url: "/ipsec", icon: <LockIcon /> },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
@@ -110,6 +112,7 @@ type LiveSidebarCounts = SidebarCountsDto & {
greTunnels?: number
certificates?: number
wireguard?: number
ipsec?: number
users?: number
bgpSessions?: number
vxlan?: number
@@ -179,6 +182,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/ipsec") return formatSidebarBadgeCount(liveCounts.ipsec ?? 0)
if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0)
if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0)
if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0)
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -42,6 +42,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
{ id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: <ShieldCheckIcon />, keywords: ["vpn","tunnel","туннель","wg"] },
{ id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: <LockIcon />, keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] },
{ id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: <CableIcon />, keywords: ["gre","ipsec","tunnel","туннель"] },
{ id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: <NetworkIcon />, keywords: ["overlay","vni","vtep","l2"] },
{ id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: <BoxIcon />, keywords: ["docker","container","образ","image"] },
@@ -12,12 +12,13 @@ import {
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
import { CableIcon, KeyRoundIcon, LockIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
ipsec: "info-light",
other: "secondary",
}
@@ -25,6 +26,7 @@ const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: stri
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
ipsec: { icon: LockIcon, className: "text-info" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
+29 -2
View File
@@ -20,9 +20,29 @@ const COUNTRY_NAMES: Record<string, string> = {
HK: "Гонконг",
}
/** Country name in Russian (fallback to code) */
let regionNames: Intl.DisplayNames | null | undefined
function regionDisplayName(iso: string): string | undefined {
try {
if (regionNames === undefined) {
regionNames = typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["ru"], { type: "region" })
: null
}
return regionNames?.of(iso) ?? undefined
} catch {
return undefined
}
}
/** Country name in Russian (fallback to ISO code) */
export function countryName(code: string): string {
return COUNTRY_NAMES[code.toUpperCase()] ?? code
const iso = code.toUpperCase()
if (!iso) return code
if (COUNTRY_NAMES[iso]) return COUNTRY_NAMES[iso]
const intl = regionDisplayName(iso)
if (intl && intl !== iso) return intl
return iso
}
interface FlagProps {
@@ -39,6 +59,13 @@ function nearestCdnSize(px: number): number {
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
}
/** CDN URL for SVG `<image href>` (flagcdn widths only). */
export function flagCdnUrl(code: string, size = 40): string | null {
const lower = code.toLowerCase()
if (!/^[a-z]{2}$/.test(lower)) return null
return `https://flagcdn.com/w${nearestCdnSize(size)}/${lower}.png`
}
/**
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
* Source: https://flagcdn.com — free CDN, no API key needed.
+193
View File
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { toast } from "sonner"
import { DownloadIcon, CopyIcon, RefreshCwIcon } from "lucide-react"
function downloadBlob(filename: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function downloadText(filename: string, content: string) {
downloadBlob(filename, new Blob([content], { type: "text/plain;charset=utf-8" }))
}
function downloadB64(filename: string, b64: string, mime: string) {
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
downloadBlob(filename, new Blob([bytes], { type: mime }))
}
function randomPassphrase(): string {
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
crypto.getRandomValues(bytes)
let s = ""
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
return s
}
function IpsecCertSheet({
open,
onOpenChange,
bundle,
busy,
onReexport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
bundle: IpsecCertBundle | null
busy?: boolean
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
onReexport?: (passphrase: string) => void | Promise<void>
}) {
const [passphrase, setPassphrase] = useState("")
useEffect(() => {
if (!open) return
const initial = bundle?.passphrase ?? ""
queueMicrotask(() => setPassphrase(initial))
}, [open, bundle])
const canDownload = useMemo(
() => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE),
[bundle, passphrase],
)
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>Сертификат клиента {bundle ? `«${bundle.user}»` : ""}</SheetTitle>
<SheetDescription>
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{!bundle ? (
<p className="text-sm text-muted-foreground">
Бандл сертификата пуст перезапустите экспорт с новой парольной фразой.
</p>
) : (
<>
<div className="flex flex-col gap-4">
<SectionTitle>Пароль архива .p12</SectionTitle>
<FormField
label="Passphrase"
required
hint={
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
}
>
<div className="flex gap-2">
<Input
className="font-mono"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
/>
<Button
type="button"
size="icon"
variant="outline"
title="Сгенерировать и перекачать"
disabled={busy}
onClick={() => {
const next = randomPassphrase()
setPassphrase(next)
if (onReexport) void onReexport(next)
}}
>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
</Button>
</div>
</FormField>
{bundle.serverEndpoint ? (
<p className="text-xs text-muted-foreground">
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
</p>
) : null}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Файлы</SectionTitle>
<Button
variant="outline"
className="justify-start"
disabled={!canDownload}
onClick={() => {
if (!bundle) return
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
toast.success(`Скачан ${bundle.filename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.filename} (.p12, сертификат + ключ)
</Button>
{bundle.sswanContent && bundle.sswanFilename ? (
<Button
variant="outline"
className="justify-start"
disabled={!canDownload}
onClick={() => {
if (!bundle.sswanContent || !bundle.sswanFilename) return
downloadText(bundle.sswanFilename, bundle.sswanContent)
toast.success(`Скачан ${bundle.sswanFilename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.sswanFilename} (strongSwan)
</Button>
) : null}
{bundle.instructions ? (
<Button
variant="ghost"
className="justify-start text-muted-foreground"
onClick={() => {
if (!bundle.instructions) return
void navigator.clipboard?.writeText(bundle.instructions)
toast.success("Инструкция скопирована")
}}
>
<CopyIcon className="size-4" />
Скопировать инструкцию по подключению
</Button>
) : null}
</div>
{bundle.instructions ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap">
{bundle.instructions}
</pre>
) : null}
</>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0">
<SheetClose render={<Button variant="outline" className="w-full" />}>
Закрыть
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecCertSheet, downloadText, downloadB64 }
+162
View File
@@ -0,0 +1,162 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { InfoIcon } from "lucide-react"
export type IpsecInitFormState = {
serverId: string
serverEndpoint: string
poolCidr: string
dns: string
createNatRule: boolean
}
export const defaultIpsecInitForm = (): IpsecInitFormState => ({
serverId: "",
serverEndpoint: "",
poolCidr: "10.77.0.0/24",
dns: "",
createNatRule: true,
})
type ServerOption = { id: string; name: string; host: string }
function IpsecInitSheet({
open,
onOpenChange,
servers,
busy,
defaultServerId,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onSubmit: (form: IpsecInitFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecInitFormState>(defaultIpsecInitForm)
const set = <K extends keyof IpsecInitFormState>(k: K, v: IpsecInitFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
const server = servers.find((s) => s.id === (defaultServerId ?? ""))
queueMicrotask(() => setForm({
...defaultIpsecInitForm(),
serverId: defaultServerId ?? "",
serverEndpoint: server?.host ?? "",
}))
}, [open, defaultServerId, servers])
const canSubmit = useMemo(() => {
return Boolean(
form.serverId &&
form.serverEndpoint.trim() &&
/^(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[\w.-]+\.[a-z]{2,})$/i.test(form.serverEndpoint.trim()) &&
/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()),
)
}, [form])
return (
<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>Инициализация IKEv2-сервера</SheetTitle>
<SheetDescription>
CA + серверный сертификат, peer (ike2/passive), пул адресов и mode-config
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Alert variant="info">
<InfoIcon />
<AlertTitle>Выпуск CA и сертификатов занимает до минуты</AlertTitle>
<AlertDescription>
Повторный запуск безопасен: существующие managed-объекты обновятся, сертификаты
не перевыпускаются.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required>
<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.serverId}
onChange={(e) => {
const id = e.target.value
const server = servers.find((s) => s.id === id)
setForm((f) => ({ ...f, serverId: id, serverEndpoint: server?.host ?? f.serverEndpoint }))
}}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Адрес сервера (CN/SAN)" required hint="Домен или IP — по нему подключаются клиенты">
<Input
className="font-mono"
placeholder="vpn.example.com"
value={form.serverEndpoint}
onChange={(e) => set("serverEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Подсеть клиентов" required hint="Из неё пул .2–.254 и статические IP">
<Input
className="font-mono"
placeholder="10.77.0.0/24"
value={form.poolCidr}
onChange={(e) => set("poolCidr", e.target.value)}
/>
</FormField>
<FormField label="DNS для клиентов" hint="Например 10.77.0.1 или 1.1.1.1">
<Input
className="font-mono"
value={form.dns}
onChange={(e) => set("dns", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Интернет клиентам (NAT)</p>
<p className="text-xs text-muted-foreground">
Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)
</p>
</div>
<FormToggle checked={form.createNatRule} onChange={(v) => set("createNatRule", 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 { IpsecInitSheet }
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import type { IpsecPeerDto } from "@mmapp/contracts/ipsec"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
export type IpsecPeerFormState = {
name: string
address: string
exchangeMode: string
passive: boolean
certificate: string
profile: string
disabled: boolean
}
export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({
name: "",
address: "0.0.0.0/0",
exchangeMode: "ike2",
passive: true,
certificate: "",
profile: "",
disabled: false,
})
const EXCHANGE_MODES = ["ike2", "main", "aggressive", "base"]
/** Редактирование существующего peer (сервера) RouterOS. */
function IpsecPeerSheet({
open,
onOpenChange,
busy,
editing,
certificates,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
busy?: boolean
editing: IpsecPeerDto | null
certificates: string[]
onSubmit: (form: IpsecPeerFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
const set = <K extends keyof IpsecPeerFormState>(k: K, v: IpsecPeerFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open || !editing) return
queueMicrotask(() => setForm({
name: editing.name,
address: editing.address ?? "",
exchangeMode: editing.exchangeMode ?? "ike2",
passive: editing.passive ?? false,
certificate: editing.certificate ?? "",
profile: editing.profile ?? "",
disabled: editing.disabled,
}))
}, [open, editing])
const canSubmit = useMemo(() => form.name.trim().length > 0, [form.name])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{editing ? `Peer «${editing.name}»` : "Peer"}</SheetTitle>
<SheetDescription>
{editing?.managed
? "Managed peer: изменения применяются напрямую на роутере"
: "Существующий peer RouterOS: изменения применяются напрямую"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Имя" required>
<Input value={form.name} onChange={(e) => set("name", e.target.value)} />
</FormField>
<FormField label="Адрес" hint="Обычно 0.0.0.0/0 для road-warrior">
<Input className="font-mono" value={form.address} onChange={(e) => set("address", e.target.value)} />
</FormField>
<FormField label="Exchange mode">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.exchangeMode}
onChange={(e) => set("exchangeMode", e.target.value)}
>
{EXCHANGE_MODES.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</FormField>
<FormField label="Сертификат" hint="Серверный сертификат роутера">
<Input
className="font-mono"
list="ipsec-peer-certs"
placeholder="ipsec-server"
value={form.certificate}
onChange={(e) => set("certificate", e.target.value)}
/>
<datalist id="ipsec-peer-certs">
{certificates.map((c) => <option key={c} value={c} />)}
</datalist>
</FormField>
<FormField label="Profile" hint="Пусто — не менять">
<Input className="font-mono" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Состояние</SectionTitle>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Passive</p>
<FormToggle checked={form.passive} onChange={(v) => set("passive", v)} />
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Отключён</p>
<FormToggle checked={form.disabled} onChange={(v) => set("disabled", v)} />
</div>
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? "Сохранение…" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecPeerSheet }
+286
View File
@@ -0,0 +1,286 @@
"use client"
import type { IpsecCertInfoDto, IpsecPeerDto, IpsecServerSummaryDto } from "@mmapp/contracts/ipsec"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DownloadIcon,
GlobeIcon,
KeyRoundIcon,
NetworkIcon,
PencilIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react"
function Row({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3 py-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("truncate text-xs", mono ? "font-mono" : "")} title={value}>
{value || "—"}
</span>
</div>
)
}
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 {
servers: IpsecServerSummaryDto[]
onInit?: (row: IpsecServerSummaryDto) => void
onRemove?: (row: IpsecServerSummaryDto) => void
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
}
function IpsecServerGrid({
servers,
onInit,
onRemove,
onEditPeer,
onDeletePeer,
onDeleteCert,
onExportCert,
}: IpsecServerGridProps) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => {
const certs = s.certs ?? []
return (
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{s.serverName}</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{s.serverEndpoint ?? "endpoint не определён"}
</p>
</div>
<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 className="flex flex-col gap-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Peer / серверы
</p>
{s.peers.length > 0 ? (
<div className="flex flex-col gap-1.5">
{s.peers.map((p) => (
<PeerRow
key={p.rosId}
peer={p}
onEdit={onEditPeer ? (peer) => onEditPeer(s.serverId, peer) : undefined}
onDelete={onDeletePeer ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">Peers не найдены</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Сертификаты
</p>
{certs.length > 0 ? (
<div className="flex flex-col gap-1.5">
{certs.map((c) => (
<CertRow
key={c.name}
cert={c}
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
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
type="button"
size="sm"
variant="outline"
onClick={() => onInit?.(s)}
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
>
<GlobeIcon className="size-3.5" />
{s.initialized ? "Переинициализировать" : "Инициализировать"}
</Button>
{s.initialized && onRemove ? (
<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>
)
})}
{servers.length === 0 ? (
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<NetworkIcon className="size-4" />
Серверы не найдены
</div>
) : null}
</div>
)
}
export { IpsecServerGrid }
+268
View File
@@ -0,0 +1,268 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
export type IpsecAuthKind = "certificate" | "pre-shared-key"
export type IpsecUserFormState = {
serverId: string
name: string
peerName: string
authMethod: IpsecAuthKind
psk: string
remoteId: string
useStaticIp: boolean
staticIp: string
passphrase: string
}
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
serverId: "",
name: "",
peerName: "",
authMethod: "certificate",
psk: "",
remoteId: "",
useStaticIp: false,
staticIp: "",
passphrase: "",
})
type ServerOption = { id: string; name: string; host: string }
export type PeerOption = { serverId: string; name: string; managed: boolean }
function IpsecUserSheet({
open,
onOpenChange,
servers,
peers,
busy,
defaultServerId,
editing,
freeIpHint,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
/** Доступные peers (серверы) для привязки identity при создании. */
peers?: PeerOption[]
busy?: boolean
defaultServerId?: string
/** Режим редактирования: сервер и метод аутентификации не меняются. */
editing?: IpsecClientDto | null
freeIpHint?: string
onSubmit: (form: IpsecUserFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecUserFormState>(defaultIpsecUserForm)
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
if (editing) {
const edit = editing
queueMicrotask(() => setForm({
serverId: edit.serverId,
name: edit.name,
peerName: edit.peerName ?? "",
authMethod: edit.authMethod,
psk: "",
remoteId: edit.remoteId ?? "",
useStaticIp: Boolean(edit.staticIp),
staticIp: edit.staticIp ?? "",
passphrase: "",
}))
return
}
queueMicrotask(() => setForm({ ...defaultIpsecUserForm(), serverId: defaultServerId ?? "" }))
}, [open, defaultServerId, editing])
const canSubmit = useMemo(() => {
if (!editing && !form.serverId) return false
if (!form.name.trim()) return false
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
// RouterOS отклоняет export-passphrase короче 8 символов
const passphrase = form.passphrase.trim()
if (!editing && passphrase.length > 0 && passphrase.length < IPSEC_MIN_PASSPHRASE) return false
return true
}, [form, editing])
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 ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"}</SheetTitle>
<SheetDescription>
{editing
? "Имя, статический IP и secret (для PSK)"
: "Identity на роутере; для сертификата — выпуск .p12 для авторизации"}
</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>
{!editing ? (
<FormField label="Сервер" required>
<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.serverId}
onChange={(e) => set("serverId", e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
) : null}
{!editing && (peers ?? []).some((p) => p.serverId === form.serverId) ? (
<FormField label="Peer (сервер)" hint="К какому peer привязать identity">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.peerName}
onChange={(e) => set("peerName", e.target.value)}
>
<option value="">Автоматически (managed/первый)</option>
{(peers ?? [])
.filter((p) => p.serverId === form.serverId)
.map((p) => (
<option key={`${p.serverId}:${p.name}`} value={p.name}>
{p.name}{p.managed ? "" : " (RouterOS)"}
</option>
))}
</select>
</FormField>
) : null}
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
<Input
placeholder="alice"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField
label="Аутентификация"
required
hint={form.authMethod === "certificate"
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
: undefined}
>
<div className="flex gap-1.5">
<Button
type="button"
size="sm"
variant={form.authMethod === "certificate" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "certificate")}
>
Сертификат
</Button>
<Button
type="button"
size="sm"
variant={form.authMethod === "pre-shared-key" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "pre-shared-key")}
>
PSK
</Button>
</div>
</FormField>
) : null}
{form.authMethod === "pre-shared-key" ? (
<>
<FormField label={editing ? "Новый secret (PSK)" : "Secret (PSK)"} hint={editing ? "Пусто — не менять" : "Минимум 8 символов"} required={!editing}>
<Input
className="font-mono"
type="password"
value={form.psk}
onChange={(e) => set("psk", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField label="Remote ID" hint="По умолчанию — имя клиента">
<Input
className="font-mono"
placeholder="alice"
value={form.remoteId}
onChange={(e) => set("remoteId", e.target.value)}
/>
</FormField>
) : null}
</>
) : (
!editing ? (
<FormField
label="Пароль архива .p12"
hint={
form.passphrase.trim().length > 0 && form.passphrase.trim().length < IPSEC_MIN_PASSPHRASE
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
: `Пусто — сгенерируем автоматически (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
}
>
<Input
className="font-mono"
placeholder="например MySecret123"
value={form.passphrase}
onChange={(e) => set("passphrase", e.target.value)}
/>
</FormField>
) : null
)}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>IP-адрес</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Статический IP</p>
<p className="text-xs text-muted-foreground">
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
{" "}Статический IP создаёт персональную identity на peer.
</p>
</div>
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
</div>
{form.useStaticIp && (
<FormField label="IP клиента" hint="Например 10.77.0.10">
<Input
className="font-mono"
placeholder={freeIpHint ?? "10.77.0.10"}
value={form.staticIp}
onChange={(e) => set("staticIp", e.target.value)}
/>
</FormField>
)}
</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 ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecUserSheet }
+259
View File
@@ -0,0 +1,259 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
BadgeCheckIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
WifiIcon,
} from "lucide-react"
export interface IpsecUsersGridProps {
clients: IpsecClientDto[]
compactServer?: boolean
emptyAction?: ReactNode
onDownloadCert?: (row: IpsecClientDto) => void
onEdit?: (row: IpsecClientDto) => void
onDelete?: (row: IpsecClientDto) => void
}
function IpsecUsersGrid({
clients,
compactServer = false,
emptyAction,
onDownloadCert,
onEdit,
onDelete,
}: IpsecUsersGridProps) {
const columns = useMemo<ColumnDef<IpsecClientDto>[]>(() => {
const cols: ColumnDef<IpsecClientDto>[] = [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Клиент" className="ml-1" />
),
cell: ({ row }) => {
const c = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<UsersIcon className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
className="truncate text-left font-medium hover:underline"
onClick={() => onEdit?.(c)}
>
{c.name}
</button>
{c.disabled ? (
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
) : null}
{!c.managed ? (
<Badge variant="outline" className="ml-1 text-[10px]">RouterOS</Badge>
) : null}
</div>
)
},
meta: {
headerTitle: "Клиент",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "auth",
accessorKey: "authMethod",
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
cell: ({ row }) => {
const c = row.original
const cert = c.kind === "cert"
return (
<div className="flex min-w-0 flex-col gap-0.5 text-xs">
<div className="flex items-center gap-1.5">
{cert ? (
<BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
) : (
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
{cert ? "Сертификат" : "PSK"}
</div>
{cert && c.signedBy ? (
<span
className="truncate font-mono text-[10px] text-muted-foreground"
title={`Подписан: ${c.signedBy}`}
>
подписан: {c.signedBy}
</span>
) : null}
</div>
)
},
meta: {
headerTitle: "Аутентификация",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.serverName}
</span>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "ip",
accessorFn: (row) => row.staticIp ?? "",
header: ({ column }) => <DataGridSortHeader column={column} title="IP" />,
cell: ({ row }) => (
<span
className={cn(
"font-mono text-xs",
row.original.staticIp ? "text-foreground" : "text-muted-foreground",
)}
>
{row.original.staticIp ?? "из пула"}
</span>
),
meta: {
headerTitle: "IP",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "online",
accessorKey: "online",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span
className={cn(
"inline-flex size-1.5 rounded-full",
row.original.online ? "bg-success" : "bg-muted-foreground/40",
)}
/>
<span className={cn("font-mono text-[11px]", row.original.online ? "text-success" : "text-muted-foreground")}>
{row.original.online ? row.original.activeAddress ?? "онлайн" : "офлайн"}
</span>
</div>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 96,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-0.5">
{c.kind === "cert" && onDownloadCert ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Скачать сертификат"
title="Скачать .p12"
onClick={() => onDownloadCert(c)}
>
<WifiIcon className="size-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить клиента"
onClick={() => onDelete(c)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDownloadCert, onEdit, onDelete])
const table = useReactTable({
data: clients,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (clients.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет клиентов IKEv2"
description="Создайте клиента — сертификат и .p12 выпустятся автоматически"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={clients.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { IpsecUsersGrid }
@@ -0,0 +1,17 @@
"use client"
import { flagCdnUrl } from "@/components/flag"
export function CountryFlagSvg({ iso, size = 22 }: { iso: string; size?: number }) {
const url = flagCdnUrl(iso, size)
if (!url) return null
const h = Math.round(size * 0.75)
return (
<image
href={url}
width={size}
height={h}
preserveAspectRatio="xMidYMid meet"
/>
)
}
+29 -7
View File
@@ -48,13 +48,24 @@ export function parseYmd(s: string): Date | null {
return Number.isNaN(d.getTime()) ? null : d
}
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
export function parseRangeDate(s: string): Date | null {
if (s.includes("T")) {
const d = new Date(s)
return Number.isNaN(d.getTime()) ? null : d
}
return parseYmd(s)
}
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
const to = formatYmd(now)
if (preset === "today") return { from: to, to }
if (preset === "24h") {
const from = new Date(now)
from.setDate(from.getDate() - 1)
return { from: formatYmd(from), to }
// Последние 24 часа от сейчас: полный ISO, иначе date-only «вчера…сегодня» даёт до ~48 ч.
return {
from: new Date(now.getTime() - 24 * 3600_000).toISOString(),
to: now.toISOString(),
}
}
if (preset === "7d") {
const from = new Date(now)
@@ -110,15 +121,20 @@ export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
return {
period: "day",
operator: "between",
startDate: parseYmd(range.from) ?? undefined,
endDate: parseYmd(range.to) ?? undefined,
startDate: parseRangeDate(range.from) ?? undefined,
endDate: parseRangeDate(range.to) ?? undefined,
}
}
function formatRangeLabel(range: DateRangeYmd): string {
const from = parseYmd(range.from)
const to = parseYmd(range.to)
const from = parseRangeDate(range.from)
const to = parseRangeDate(range.to)
if (!from || !to) return "Период"
if (range.from.includes("T") || range.to.includes("T")) {
return formatYmd(from) === formatYmd(to)
? `${format(from, "d MMM yyyy HH:mm", { locale: ru })} ${format(to, "HH:mm", { locale: ru })}`
: `${format(from, "d MMM HH:mm", { locale: ru })} ${format(to, "d MMM HH:mm", { locale: ru })}`
}
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
return `${format(from, "d MMM", { locale: ru })} ${format(to, "d MMM yyyy", { locale: ru })}`
}
@@ -145,6 +161,12 @@ export function PeriodSelector({
)
const activePreset = PRESETS.find((p) => {
// «24 ч» хранится ISO-таймстампами: сверяем разбором (длительность окна), а не строками.
if (p.id === "24h") {
const f = parseRangeDate(range.from)
const t = parseRangeDate(range.to)
return Boolean(f && t && Math.abs((t.getTime() - f.getTime()) - 24 * 3600_000) < 60_000)
}
const r = rangeForPreset(p.id)
return r.from === range.from && r.to === range.to
})?.id
+2 -1
View File
@@ -55,13 +55,14 @@ import {
} from "@/lib/users"
import {
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
LockIcon, NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
} from "lucide-react"
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
ipsec: { icon: LockIcon, className: "text-info" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
+33
View File
@@ -0,0 +1,33 @@
/** Клиентские хелперы IPsec (зеркало чистых функций бэкенда). */
function parseIpv4(s: string): number | null {
const parts = s.trim().split(".")
if (parts.length !== 4) return null
const octets = parts.map((p) => Number(p))
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null
return (((octets[0]! << 24) | (octets[1]! << 16) | (octets[2]! << 8) | octets[3]!) >>> 0)
}
function intToIp(n: number): string {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
}
/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e»), исключая занятые. */
export function findFreePoolIp(range: string, taken: Iterable<string>): string | null {
const takenSet = new Set(
Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()),
)
const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? ""
const [fromRaw, toRaw] = first.split("-")
const from = parseIpv4(fromRaw ?? "")
const to = parseIpv4(toRaw ?? fromRaw ?? "")
if (from == null) return null
const last = to ?? from
if (last < from) return null
const cap = Math.min(last, from + 65_534)
for (let n = from; n <= cap; n++) {
const ip = intToIp(n)
if (!takenSet.has(ip)) return ip
}
return null
}
+21
View File
@@ -39,6 +39,7 @@ const W = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
export { SERVICE_COL_W }
/** Карточка конечного сервиса на карте (центр = позиция узла). */
export const MAP_SERVICE_NODE_W = 86
@@ -454,6 +455,26 @@ export function placeServiceNodes(
return out
}
/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */
export function placeCountryServiceNodes(
serviceIds: string[],
parentPos: { x: number; y: number } | undefined,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0 || !parentPos) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const n = serviceIds.length
const gap = Math.min(80, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(parentPos.y - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(parentPos.y, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
* Отдельного ICMP по ребру нет это не замер линии, а сумма каталожных latency концов.
+5
View File
@@ -24,6 +24,9 @@ export function formatSidebarBadgeCount(n: number): string {
return `${s}к`
}
/** Мок-клиенты IKEv2 для демо-режима (см. MOCK_IPSEC_CLIENTS на странице /ipsec). */
export const MOCK_IPSEC_CLIENTS_COUNT = 3
function mockWireGuardIfacesCount(): number {
let n = 0
for (const s of servers) n += s.wireGuardIfaces?.length ?? 0
@@ -41,6 +44,7 @@ export function mockSidebarBadgesByUrl(): Record<string, string> {
"/users": formatSidebarBadgeCount(INIT_USERS.length),
"/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/ipsec": formatSidebarBadgeCount(MOCK_IPSEC_CLIENTS_COUNT),
"/gre": formatSidebarBadgeCount(greTunnels.length),
"/vxlan": formatSidebarBadgeCount(vxlanTunnels.length),
"/containers": formatSidebarBadgeCount(routerContainers.length),
@@ -58,6 +62,7 @@ export interface SidebarCountsDto {
recursiveRoutes: number
certificates?: number
wireguard?: number
ipsec?: number
users?: number
bgpSessions?: number
vxlan?: number
+2 -1
View File
@@ -79,10 +79,11 @@ export const IFACE_TYPE_LABEL: Record<InterfaceType, string> = {
ether: "Ethernet",
gre: "GRE",
wg: "WireGuard",
ipsec: "IPsec",
other: "Прочие",
}
export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "other"]
export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "ipsec", "other"]
export const ROLE_LABEL: Record<Role, string> = {
admin: "Администратор",
+4
View File
@@ -38,6 +38,10 @@
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
},
"./ipsec": {
"types": "./dist/ipsec.d.ts",
"default": "./dist/ipsec.js"
},
"./users": {
"types": "./dist/users.d.ts",
"default": "./dist/users.js"
+2
View File
@@ -25,6 +25,8 @@ export const certificateDtoSchema = z.object({
trusted: z.boolean(),
status: certStatusSchema,
acmeStatus: z.string().optional(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedByCertName: z.string().optional(),
})
export const certificatesListResponseSchema = z.object({
+1
View File
@@ -4,6 +4,7 @@ export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
export * from "./ipsec.js"
export * from "./users.js"
export * from "./traffic-flow.js"
export * from "./geoip.js"
+236
View File
@@ -0,0 +1,236 @@
import { z } from "zod"
export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"])
/**
* Минимальная длина пароля .p12. Ограничение RouterOS REST:
* `Failure: If used, passphrase must be at least 8 chars long!` (см. /certificate/export-certificate).
*/
export const IPSEC_MIN_PASSPHRASE = 8
/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */
export const ipsecClientDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
/** Управляемое имя клиента (из managed-комментария identity). */
name: z.string(),
authMethod: ipsecAuthMethodSchema,
/** cert — клиент по сертификату (может быть без персональной identity); psk — pre-shared-key identity. */
kind: z.enum(["cert", "psk"]).default("cert"),
certificateName: z.string().optional(),
/** Имя клиентского сертификата (роль client). Для psk — undefined. */
certName: z.string().optional(),
/** CA, которым подписан клиентский сертификат (read-only поле `ca`). */
signedBy: z.string().optional(),
commonName: z.string().optional(),
remoteId: z.string().optional(),
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
staticIp: z.string().optional(),
modeConfigName: z.string().optional(),
peerName: z.string().optional(),
online: z.boolean(),
activeAddress: z.string().optional(),
activeSince: z.string().optional(),
disabled: z.boolean(),
comment: z.string().optional(),
/** Создан менеджером (managed-комментарий). */
managed: z.boolean(),
})
/** Слушатель IKEv2 — /ip/ipsec/peer (passive). */
export const ipsecPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
name: z.string(),
address: z.string().optional(),
exchangeMode: z.string().optional(),
passive: z.boolean().optional(),
certificate: z.string().optional(),
profile: z.string().optional(),
disabled: z.boolean(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecModeConfigDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
name: z.string(),
/** Общий пул (shared) или персональный статический адрес (per-user). */
addressPool: z.string().optional(),
address: z.string().optional(),
splitDns: z.string().optional(),
staticDns: z.string().optional(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecPoolDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
name: z.string(),
ranges: z.string(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecCertInfoDtoSchema = z.object({
name: z.string(),
commonName: z.string().optional(),
keySize: z.string().optional(),
fingerprint: z.string().optional(),
expiresAt: z.string().optional(),
trusted: z.boolean().optional(),
hasPrivateKey: z.boolean().optional(),
role: z.enum(["ca", "server", "client", "other"]).optional(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedBy: z.string().optional(),
managed: z.boolean(),
})
/** Сводка IKEv2-сервера на одном роутере. */
export const ipsecServerSummaryDtoSchema = z.object({
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
initialized: z.boolean(),
/** Существующий IKEv2-сервер пригоден: есть peer + CA + серверный серт. */
ike2Ready: z.boolean(),
serverEndpoint: z.string().optional(),
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
peer: ipsecPeerDtoSchema.optional(),
/** Все peers роутера (managed + существующие). */
peers: z.array(ipsecPeerDtoSchema).default([]),
pool: ipsecPoolDtoSchema.optional(),
sharedModeConfig: ipsecModeConfigDtoSchema.optional(),
caCert: ipsecCertInfoDtoSchema.optional(),
serverCert: ipsecCertInfoDtoSchema.optional(),
natRuleManaged: z.boolean(),
clientsTotal: z.number().int().nonnegative(),
clientsOnline: z.number().int().nonnegative(),
/** Все сертификаты роутера (managed + существующие). */
certs: z.array(ipsecCertInfoDtoSchema).optional(),
})
export const ipsecPeerPatchSchema = z.object({
name: z.string().min(1).max(64).optional(),
address: z.string().optional(),
exchangeMode: z.string().optional(),
passive: z.boolean().optional(),
certificate: z.string().optional(),
profile: z.string().optional(),
disabled: z.boolean().optional(),
})
export const ipsecCertDeleteRequestSchema = z.object({
name: z.string().min(1),
})
export const ipsecListResponseSchema = z.object({
servers: z.array(ipsecServerSummaryDtoSchema),
clients: z.array(ipsecClientDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const ipsecInitRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
/** Домен или IP для CN/SAN серверного сертификата (к нему подключаются клиенты). */
serverEndpoint: z.string().min(1),
/** Подсеть пула клиентов; из неё pool ranges .2.254. */
poolCidr: z.string().regex(/^[0-9a-fA-F.:/]+$/).default("10.77.0.0/24"),
dns: z.string().optional(),
caDaysValid: z.number().int().positive().default(3650),
serverDaysValid: z.number().int().positive().default(3650),
clientDaysValid: z.number().int().positive().default(1825),
/** Managed srcnat masquerade, чтобы у клиентов был интернет. */
createNatRule: z.boolean().default(true),
})
export const ipsecUserCreateRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
/** Отображаемое имя клиента; из него slug для серта/CN. */
name: z.string().min(1).max(64),
/** Peer (сервер), к которому привязать identity; без — primary managed peer выбранного сервера. */
peerName: z.string().optional(),
authMethod: ipsecAuthMethodSchema.default("certificate"),
psk: z.string().min(8).optional(),
remoteId: z.string().optional(),
/** Конкретный IP клиента; без — выдаётся из пула. */
staticIp: z.string().optional(),
/** Пароль на экспортируемый .p12 (RouterOS требует ≥ IPSEC_MIN_PASSPHRASE). */
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE).optional(),
daysValid: z.number().int().positive().optional(),
})
export const ipsecUserPatchSchema = z.object({
name: z.string().min(1).max(64).optional(),
/** null — вернуть выдачу из пула. */
staticIp: z.string().nullable().optional(),
psk: z.string().min(8).optional(),
remoteId: z.string().optional(),
disabled: z.boolean().optional(),
})
export const ipsecCertExportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
clientId: z.string().min(1),
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
})
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
export const ipsecCertExportByNameRequestSchema = z.object({
name: z.string().min(1),
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE),
})
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
export const ipsecCertBundleSchema = z.object({
user: z.string(),
serverEndpoint: z.string().optional(),
filename: z.string(),
contentB64: z.string(),
mime: z.string().default("application/x-pkcs12"),
passphrase: z.string().optional(),
sswanFilename: z.string().optional(),
sswanContent: z.string().optional(),
instructions: z.string().optional(),
})
export const ipsecUserCreatedSchema = z.object({
client: ipsecClientDtoSchema,
/** Одноразовый бандл сертификата (для cert-клиентов). */
bundle: ipsecCertBundleSchema.optional(),
})
export type IpsecAuthMethod = z.infer<typeof ipsecAuthMethodSchema>
export type IpsecClientDto = z.infer<typeof ipsecClientDtoSchema>
export type IpsecPeerDto = z.infer<typeof ipsecPeerDtoSchema>
export type IpsecPeerPatch = z.infer<typeof ipsecPeerPatchSchema>
export type IpsecCertDeleteRequest = z.infer<typeof ipsecCertDeleteRequestSchema>
export type IpsecModeConfigDto = z.infer<typeof ipsecModeConfigDtoSchema>
export type IpsecPoolDto = z.infer<typeof ipsecPoolDtoSchema>
export type IpsecCertInfoDto = z.infer<typeof ipsecCertInfoDtoSchema>
export type IpsecServerSummaryDto = z.infer<typeof ipsecServerSummaryDtoSchema>
export type IpsecListResponse = z.infer<typeof ipsecListResponseSchema>
export type IpsecInitRequest = z.infer<typeof ipsecInitRequestSchema>
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
export type IpsecCertExportByNameRequest = z.infer<typeof ipsecCertExportByNameRequestSchema>
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
+13
View File
@@ -316,6 +316,14 @@ export const flowMapServicePathDtoSchema = z.object({
bps: z.number().nonnegative(),
})
/** Сервисы внутри одной страны: id вида `cc:us|svc:google`, рёбра от `countryId`. */
export const flowMapCountryServiceGroupDtoSchema = z.object({
countryId: z.string(),
services: z.array(flowMapServiceDtoSchema),
edges: z.array(flowMapServiceEdgeDtoSchema),
paths: z.array(flowMapServicePathDtoSchema),
})
export const flowMapHopsDtoSchema = z.object({
hops: z.array(flowMapHopDtoSchema),
live: z.boolean(),
@@ -329,6 +337,10 @@ export const flowMapHopsDtoSchema = z.object({
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
countries: z.array(flowMapServiceDtoSchema).optional(),
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
countryServiceGroups: z.array(flowMapCountryServiceGroupDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
@@ -353,4 +365,5 @@ export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
export type FlowMapCountryServiceGroup = z.infer<typeof flowMapCountryServiceGroupDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+1 -1
View File
@@ -2,7 +2,7 @@ import { z } from "zod"
export const appUserRoleSchema = z.enum(["admin", "operator", "viewer"])
export const permLevelSchema = z.enum(["none", "read", "write"])
export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "other"])
export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "ipsec", "other"])
export const sectionPermSchema = z.object({
section: z.string().min(1),
+167
View File
@@ -0,0 +1,167 @@
import { requestJson } from "@/shared/api/http-client"
import type {
IpsecCertBundle,
IpsecInitRequest,
IpsecListResponse,
IpsecPeerPatch,
IpsecServerSummaryDto,
IpsecUserCreateRequest,
IpsecUserCreated,
IpsecUserPatch,
} from "@mmapp/contracts/ipsec"
export async function listIpsec(
baseUrl: string,
opts?: { serverId?: string },
): Promise<IpsecListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
const suffix = q.size > 0 ? `?${q.toString()}` : ""
return requestJson<IpsecListResponse>(baseUrl, `/api/ipsec${suffix}`, { method: "GET" })
}
export async function initIpsecServer(
baseUrl: string,
body: IpsecInitRequest,
): Promise<IpsecServerSummaryDto> {
return requestJson<IpsecServerSummaryDto>(baseUrl, "/api/ipsec/server/init", {
method: "POST",
body: JSON.stringify(body),
})
}
export async function deleteIpsecServer(
baseUrl: string,
serverId: string,
opts?: { removeCertificates?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.removeCertificates === false) q.set("removeCertificates", "false")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
return requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/server/${encodeURIComponent(serverId)}${suffix}`,
{ method: "DELETE" },
)
}
export async function createIpsecUser(
baseUrl: string,
body: IpsecUserCreateRequest,
): Promise<IpsecUserCreated> {
return requestJson<IpsecUserCreated>(baseUrl, "/api/ipsec/users", {
method: "POST",
body: JSON.stringify(body),
})
}
export async function patchIpsecUser(
baseUrl: string,
serverId: string,
clientId: string,
patch: IpsecUserPatch,
): Promise<{ ok: boolean }> {
return requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}`,
{ method: "PATCH", body: JSON.stringify(patch) },
)
}
export async function deleteIpsecUser(
baseUrl: string,
serverId: string,
clientId: string,
opts?: { removeCertificate?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.removeCertificate === false) q.set("removeCertificate", "false")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
return requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}${suffix}`,
{ method: "DELETE" },
)
}
export async function exportIpsecUserCert(
baseUrl: string,
serverId: string,
clientId: string,
passphrase: string,
): Promise<IpsecCertBundle> {
return requestJson<IpsecCertBundle>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`,
{ method: "POST", body: JSON.stringify({ passphrase }) },
)
}
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(
baseUrl: string,
revisionId: string,
serverId: string,
): Promise<{ ok: boolean }> {
return requestJson<{ ok: boolean }>(baseUrl, `/api/ipsec/revisions/${encodeURIComponent(revisionId)}/restore`, {
method: "POST",
body: JSON.stringify({ serverId }),
})
}
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 }) },
)
}
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
---
name: Страна раскрывает сервисы
overview: Клик по стране на карте оставляет остальные страны на холсте и раскрывает справа столбец сервисов этой страны (доля от трафика страны). Данные — вложенная агрегация brand×ISO в том же hops-проходе.
todos:
- id: contract-nested
content: countryServiceGroups + mapCountryServiceNodeId в контракте/брендах
status: done
- id: backend-nested
content: Агрегация brand×country в hops, рёбра страна→сервис
status: done
- id: backend-nested-tests
content: Тесты групп US/NL, id cc:us|svc:*, fromId=страна
status: done
- id: ui-expand
content: "Клик по стране: сдвиг колонки, столбец сервисов, панель, mock"
status: done
isProject: false
---
# Раскрытие сервисов страны на карте
Эталон холста — [`app/(main)/network-map/page.tsx`](c:/Users/shats/Dev/MikrotikManager-3/app/(main)/network-map/page.tsx) ([Frame](https://reui.io/docs/components/base/frame) не меняем: SVG, не ops-list). UX: **клик по стране → справа узлы сервисов, остальные страны остаются**.
```mermaid
flowchart LR
dest[internetPeer] --> brand[mapInternetBrand]
dest --> geo[ISO country]
brand --> nested["group cc:us"]
geo --> nested
nested --> col["столбец сервисов"]
click[клик по стране] --> col
```
Сейчас hops считает бренд и страну **независимо** в одном цикле по `dstAcc` ([`traffic-flow-map-hops.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.ts) ~567583). Связки «Google внутри US» в API нет — без неё UI может только гадать.
## Поведение на холсте
- Режим **Страны**, слой «Назначения» включён: как сейчас (EN → страны).
- **Клик по стране**: она selected; справа появляется **второй столбец** сервисов этой страны; остальные страны **остаются**, но приглушаются (`opacity` как у `isVis`).
- Рёбра раскрытия: **страна → сервис** (пунктир, те же подписи скорости). EN → страна для выбранной страны подсвечивается.
- Повторный клик по той же стране / клик по пустому холсту / смена `destMode` → свернуть столбец.
- Клик по другой стране → свернуть предыдущий набор и раскрыть новый.
- Клик по сервису: selected = сервис; столбец остаётся; панель — «Сервис в {страна}».
Раскладка в [`lib/network-map-layout.ts`](c:/Users/shats/Dev/MikrotikManager-3/lib/network-map-layout.ts): сейчас одна колонка `x = W - MARGIN - SERVICE_COL_W/2`. При раскрытии:
- страны сдвигаются влево на `SERVICE_COL_W` (чтобы не налезать на Канаду/Вьетнам с скрина);
- сервисы занимают прежний правый `x`, **по вертикали вокруг Y выбранной страны** (новый хелпер `placeCountryServiceNodes(ids, parentPos)`), не общий центр EN.
Доля на узле сервиса = **bytes / bytes страны** (не доля окна): иначе у DE 2% все сервисы будут «0%». В панели дополнительно можно показать долю окна.
Cap вложенного слоя: тот же `pickMapServices`, но `shareBase` = байты страны, cap **8**, min узлов **4**. «Прочее» внутри страны — `svc:other`.
## Контракт
В [`packages/contracts/src/traffic-flow.ts`](c:/Users/shats/Dev/MikrotikManager-3/packages/contracts/src/traffic-flow.ts) optional:
```ts
countryServiceGroups: z.array(z.object({
countryId: z.string(), // cc:us
services: z.array(flowMapServiceDtoSchema),
edges: z.array(flowMapServiceEdgeDtoSchema), // fromId = countryId
paths: z.array(flowMapServicePathDtoSchema), // serviceId = nested id
})).optional()
```
id сервиса во вложении: `cc:us|svc:google` (`mapCountryServiceNodeId`), чтобы Google в US и NL не смешивались в одном payload. Группы только для стран, прошедших `pickMapServices` стран.
## Бэкенд
В том же цикле `dstAcc`: после `svcId` + `country.id` копить `nestedTotals/edges/paths` с ключом `${country.id}|${svcId}`. Рёбра вложенного слоя: `fromId = country.id` (не EN). Пути: как сервисные, `serviceId` = nested id (enId остаётся для подсветки HR→JH→EN).
`finalize` по каждой стране из `countriesOut.nodes`.
## Фронт
- Состояние `expandedCountryId` (не в sessionStorage).
- `visibleNested = groups.find(g => g.countryId === expandedCountryId)`.
- `ServiceNode` для вложенных: `destMode="services"` (бренд), подпись label, доля от страны.
- Панель страны: текущие KPI + короткий список топ-сервисов (клик = тот же expand/select).
- Панель сервиса: бренд + «в {countryName}», доля страны, пути `visibleNested.paths` с `serviceId`.
- Mock: `MOCK_COUNTRY_SERVICE_GROUPS` для `cc:us` (Google / Cloudflare / AWS).
## Тесты
[`traffic-flow-map-hops.test.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.test.ts): ripe US → группа `cc:us` с брендом 8.8.8.8; кейс US+NL — сервисы не пересекают `countryId`; nested `edges.fromId` = `cc:us`, не `svc:*`.
Хелпер id — рядом с `mapCountryNodeId` в brands-тесте.
## Вне скоупа
- Одновременное раскрытие двух стран.
- Географическая карта мира.
- Отдельный порог NetFlow для вложенных сервисов (тот же %, но база = страна).