Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7365d8d8fb | ||
|
|
7755d77340 | ||
|
|
564aae21f0 | ||
|
|
0fdd2fc1e1 | ||
|
|
f15a7348db | ||
|
|
f3c846201c | ||
|
|
ee9804f1bb | ||
|
|
4ce6169d14 |
@@ -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): …`.
|
||||
@@ -0,0 +1,864 @@
|
||||
"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,
|
||||
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,
|
||||
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", certificateName: "ipsec-user-alice", commonName: "alice",
|
||||
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
|
||||
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
|
||||
},
|
||||
{
|
||||
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||
name: "bob", authMethod: "certificate", certificateName: "ipsec-user-bob", commonName: "bob",
|
||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||
},
|
||||
{
|
||||
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||
name: "tablet-psk", authMethod: "pre-shared-key", remoteId: "tablet",
|
||||
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||
},
|
||||
]
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
type?: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
asn?: string
|
||||
}
|
||||
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: "",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: s.status ?? "online",
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function IpsecPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [summaries, setSummaries] = useState<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 [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.initialized && s.pool)
|
||||
if (!summary?.pool) return undefined
|
||||
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
||||
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
||||
}, [scopedSummaries, scopedClients])
|
||||
|
||||
const railItems = useMemo<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)
|
||||
setCertOpen(true)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Ошибка создания клиента", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = async (form: IpsecUserFormState) => {
|
||||
if (!editing) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
|
||||
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
|
||||
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
|
||||
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
|
||||
})
|
||||
toast.success("Клиент обновлён")
|
||||
setUserOpen(false)
|
||||
setEditing(null)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openCert = async (client: IpsecClientDto) => {
|
||||
setCertClient(client)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||
const bundle = await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта сертификата", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
setCertOpen(false)
|
||||
} finally {
|
||||
setCertBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reexportCert = async (passphrase: string) => {
|
||||
if (!certClient) return
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const bundle = await exportIpsecUserCert(backendUrl, certClient.serverId, certClient.rosId, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setCertBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return
|
||||
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}
|
||||
/>
|
||||
</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={setCertOpen}
|
||||
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="конфигурация"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+462
-26
@@ -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,10 +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)
|
||||
@@ -1198,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
|
||||
@@ -1229,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)
|
||||
@@ -1282,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
|
||||
}
|
||||
@@ -1292,6 +1423,10 @@ export default function NetworkMapPage() {
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
setMapServicePaths([])
|
||||
setMapCountries([])
|
||||
setMapCountryEdges([])
|
||||
setMapCountryPaths([])
|
||||
setMapCountryServiceGroups([])
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1307,9 +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) => {
|
||||
@@ -1513,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
|
||||
@@ -1537,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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1623,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)
|
||||
@@ -1720,6 +1882,7 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setExpandedCountryId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1754,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)
|
||||
@@ -1768,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)
|
||||
@@ -1917,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
|
||||
@@ -1935,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: "" },
|
||||
@@ -1962,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
|
||||
@@ -2281,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}
|
||||
@@ -2291,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 }
|
||||
@@ -2330,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>
|
||||
@@ -2384,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} />
|
||||
@@ -2422,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" />
|
||||
@@ -2486,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 })}
|
||||
/>
|
||||
@@ -2721,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
|
||||
@@ -2767,6 +3165,18 @@ export default function NetworkMapPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!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">
|
||||
@@ -2779,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)
|
||||
@@ -2799,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>
|
||||
@@ -3040,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
|
||||
@@ -3049,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>
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply } from "fastify"
|
||||
import {
|
||||
ipsecCertDeleteRequestSchema,
|
||||
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,
|
||||
findFreePoolIp,
|
||||
identityDisplayName,
|
||||
ipsecManagedComment,
|
||||
ipsecUserComment,
|
||||
isIpsecManagedComment,
|
||||
parseIpsecUserComment,
|
||||
poolRangesFromCidr,
|
||||
userModeConfigName,
|
||||
} from "../services/ipsec-config.js"
|
||||
import {
|
||||
ensureIpsecPeer,
|
||||
ensureIpsecPool,
|
||||
ensureIpsecProfile,
|
||||
ensureIpsecProposal,
|
||||
ensureNatRule,
|
||||
ensurePolicyTemplate,
|
||||
ensureSharedModeConfig,
|
||||
putUserModeConfig,
|
||||
deleteUserModeConfig,
|
||||
putIdentity,
|
||||
patchIdentity,
|
||||
deleteIdentity,
|
||||
patchPeer,
|
||||
deletePeer,
|
||||
listByPath,
|
||||
} from "../services/ipsec-ros.js"
|
||||
import {
|
||||
ensureCaCertificate,
|
||||
ensureServerCertificate,
|
||||
exportClientP12,
|
||||
findCertificate,
|
||||
issueClientCertificate,
|
||||
} from "../services/ipsec-ca.js"
|
||||
import {
|
||||
captureIpsecSnapshot,
|
||||
fetchIpsecRestoreState,
|
||||
fetchIpsecState,
|
||||
getEnabledIpsecServerById,
|
||||
listIpsec,
|
||||
mapClients,
|
||||
mapServerSummary,
|
||||
} from "../services/ipsec-live.js"
|
||||
import {
|
||||
captureAndAppendRevision,
|
||||
listRevisions,
|
||||
loadRevisionForRestore,
|
||||
type ConfigRevisionSource,
|
||||
} from "../services/config-revisions.js"
|
||||
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
|
||||
import { executeRosOps } from "../services/ros-ops.js"
|
||||
import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
|
||||
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
|
||||
|
||||
function generatePassphrase(): string {
|
||||
return randomBytes(9).toString("base64url")
|
||||
}
|
||||
|
||||
function serverIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function errReply(reply: FastifyReply, e: unknown) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
|
||||
async function recordIpsec(
|
||||
server: NonNullable<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; serverEndpoint: string; passphrase: string; dns?: string },
|
||||
): Promise<IpsecCertBundle> {
|
||||
const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase)
|
||||
const p12B64 = content.toString("base64")
|
||||
const certName = clientCertName(args.userName)
|
||||
return {
|
||||
user: args.userName,
|
||||
serverEndpoint: args.serverEndpoint,
|
||||
filename: fileName,
|
||||
contentB64: p12B64,
|
||||
mime: "application/x-pkcs12",
|
||||
passphrase: args.passphrase,
|
||||
sswanFilename: `${certName}.sswan`,
|
||||
sswanContent: buildSswanConfig({
|
||||
name: `IKEv2 ${args.serverEndpoint}`,
|
||||
serverEndpoint: args.serverEndpoint,
|
||||
serverId: args.serverEndpoint,
|
||||
p12B64,
|
||||
}),
|
||||
instructions: buildClientInstructions({
|
||||
userName: args.userName,
|
||||
serverEndpoint: args.serverEndpoint,
|
||||
p12Filename: fileName,
|
||||
passphrase: args.passphrase,
|
||||
dns: args.dns,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||||
|
||||
const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/ipsec", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string }
|
||||
const result = await listIpsec({ serverId: q.serverId })
|
||||
const sid = parseDbServerId(q.serverId)
|
||||
if (sid !== null) {
|
||||
const server = await getEnabledIpsecServerById(sid)
|
||||
if (server) await recordIpsec(server, "observed")
|
||||
}
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/ipsec/server/init", async (req, reply) => {
|
||||
const parsed = ipsecInitRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const ranges = poolRangesFromCidr(body.poolCidr)
|
||||
if (!ranges) {
|
||||
return reply.status(400).send({ error: `Некорректная подсеть пула: ${body.poolCidr}` })
|
||||
}
|
||||
const server = await getEnabledIpsecServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const caCert = await ensureCaCertificate(client, body.caDaysValid)
|
||||
const serverCert = await ensureServerCertificate(client, {
|
||||
serverEndpoint: body.serverEndpoint,
|
||||
caCertName: caCert,
|
||||
daysValid: body.serverDaysValid,
|
||||
})
|
||||
const comment = ipsecManagedComment("IKEv2 road-warrior")
|
||||
await ensureIpsecProfile(client, IPSEC_COMMON_NAME, comment)
|
||||
await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment)
|
||||
await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment)
|
||||
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
|
||||
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
|
||||
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
|
||||
if (body.createNatRule) {
|
||||
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
const state = await fetchIpsecState(server)
|
||||
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/ipsec/server/:serverId", async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string }
|
||||
const q = req.query as { removeCertificates?: string }
|
||||
const removeCertificates = q.removeCertificates !== "false"
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) {
|
||||
const rows = await listByPath(client, path)
|
||||
for (const row of rows) {
|
||||
if (!row[".id"]) continue
|
||||
if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) {
|
||||
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
const nat = await listByPath(client, "/ip/firewall/nat")
|
||||
for (const row of nat) {
|
||||
if (row[".id"] && isIpsecManagedComment(row.comment)) {
|
||||
await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
// profile/proposal не критично оставлять; сертификаты — по флагу
|
||||
if (removeCertificates) {
|
||||
const certs = await client.getCertificates()
|
||||
for (const c of certs) {
|
||||
const name = String(c.name ?? "")
|
||||
if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) {
|
||||
await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/ipsec/users", async (req, reply) => {
|
||||
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = (body.peerName
|
||||
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
||||
: undefined)
|
||||
?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
?? state.peers[0]
|
||||
if (!peer) {
|
||||
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||||
}
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
|
||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||
if (body.authMethod === "certificate" && (!caCert || !serverCert)) {
|
||||
return reply.status(400).send({
|
||||
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
||||
})
|
||||
}
|
||||
const peerName = (peer.name ?? "").trim()
|
||||
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
|
||||
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
||||
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
||||
}
|
||||
|
||||
let modeConfig = (sharedMc?.name ?? "").trim()
|
||||
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: String(caCert!.name ?? IPSEC_CA_CERT),
|
||||
daysValid: body.daysValid ?? 1825,
|
||||
})
|
||||
if (issued.existed) {
|
||||
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
|
||||
}
|
||||
certName = issued.certName
|
||||
}
|
||||
|
||||
await putIdentity(client, {
|
||||
peerName,
|
||||
modeConfig,
|
||||
comment: ipsecUserComment(body.name),
|
||||
authMethod: body.authMethod,
|
||||
certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||
remoteCertificate: certName,
|
||||
secret: body.psk,
|
||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||
})
|
||||
|
||||
let bundle: IpsecCertBundle | undefined
|
||||
if (body.authMethod === "certificate" && certName) {
|
||||
const passphrase = body.passphrase?.trim() || generatePassphrase()
|
||||
bundle = await buildCertBundle(client, {
|
||||
userName: body.name,
|
||||
serverEndpoint,
|
||||
passphrase,
|
||||
dns,
|
||||
})
|
||||
}
|
||||
|
||||
await recordIpsec(server, "apply")
|
||||
const fresh = await fetchIpsecState(server)
|
||||
const clients = mapClients(fresh)
|
||||
const created: IpsecClientDto | undefined = clients.find(
|
||||
(c) => c.name === body.name.trim(),
|
||||
) ?? clients.find((c) => c.certificateName === certName)
|
||||
return reply.status(201).send({ client: created ?? null, bundle })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/ipsec/users/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = ipsecUserPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const d = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managedIdentity = isIpsecManagedComment(identity.comment)
|
||||
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? ""
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const sharedMcName = (sharedMc?.name ?? "").trim()
|
||||
|
||||
if (d.name && d.name !== oldName) {
|
||||
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||||
await patchIdentity(client, identity[".id"]!, {
|
||||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (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(d.name || oldName)
|
||||
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`))
|
||||
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
|
||||
}
|
||||
}
|
||||
|
||||
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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managed = isIpsecManagedComment(identity.comment)
|
||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
||||
const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
|
||||
const personal = (identity["mode-config"] ?? "").trim()
|
||||
const remoteCert = (identity["remote-certificate"] ?? "").trim()
|
||||
|
||||
await deleteIdentity(client, identity[".id"]!)
|
||||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||
if (removeCertificate) {
|
||||
// только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем
|
||||
if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined)
|
||||
else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} 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 reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
||||
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
|
||||
const certName = identity["remote-certificate"]?.trim() || clientCertName(userName)
|
||||
if (!(await findCertificate(client, certName))) {
|
||||
return reply.status(404).send({ error: `Сертификат ${certName} не найден на роутере` })
|
||||
}
|
||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
||||
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
// export работает по имени сертификата (certName), не по userName
|
||||
const { fileName, content } = await client.exportCertificatePkcs12({
|
||||
name: certName,
|
||||
passphrase: body.passphrase,
|
||||
})
|
||||
const p12B64 = content.toString("base64")
|
||||
const bundle: IpsecCertBundle = {
|
||||
user: userName,
|
||||
serverEndpoint,
|
||||
filename: fileName,
|
||||
contentB64: p12B64,
|
||||
mime: "application/x-pkcs12",
|
||||
passphrase: body.passphrase,
|
||||
sswanFilename: `${certName}.sswan`,
|
||||
sswanContent: buildSswanConfig({
|
||||
name: `IKEv2 ${serverEndpoint}`,
|
||||
serverEndpoint,
|
||||
serverId: serverEndpoint,
|
||||
p12B64,
|
||||
}),
|
||||
instructions: buildClientInstructions({
|
||||
userName,
|
||||
serverEndpoint,
|
||||
p12Filename: fileName,
|
||||
passphrase: body.passphrase,
|
||||
dns,
|
||||
}),
|
||||
}
|
||||
return reply.send(bundle)
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/ipsec/revisions", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseDbServerId(q.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const revisions = await listRevisions(serverId, "ipsec")
|
||||
return reply.send({ revisions })
|
||||
})
|
||||
|
||||
app.post("/ipsec/revisions/:id/restore", {
|
||||
schema: { params: RevisionIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const loaded = await loadRevisionForRestore({
|
||||
id,
|
||||
section: "ipsec",
|
||||
requestedServerId: parseDbServerId(body?.serverId),
|
||||
})
|
||||
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
|
||||
try {
|
||||
const desired = parseIpsecSnapshot(loaded.row.payload)
|
||||
const state = await fetchIpsecRestoreState(loaded.server)
|
||||
const ops = planIpsecRestore(desired, {
|
||||
peers: state.peers,
|
||||
identities: state.identities,
|
||||
modeConfigs: state.modeConfigs,
|
||||
pools: state.pools,
|
||||
nat: state.nat,
|
||||
})
|
||||
await executeRosOps(state.client, ops)
|
||||
await recordIpsec(loaded.server, "rollback")
|
||||
const result = await listIpsec({ serverId: String(loaded.server.id) })
|
||||
return reply.send({ ok: true, ...result })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default ipsecRoutes
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
|
||||
|
||||
type RosCertRow = Record<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 }
|
||||
}
|
||||
|
||||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||||
export async function exportClientP12(
|
||||
client: MikrotikClient,
|
||||
userName: string,
|
||||
passphrase: string,
|
||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
||||
const certName = clientCertName(userName)
|
||||
const cert = await findCertificate(client, certName)
|
||||
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
|
||||
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
|
||||
return { fileName, content, certName }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
IPSEC_MARKER,
|
||||
buildClientInstructions,
|
||||
buildSswanConfig,
|
||||
clientCertName,
|
||||
findFreePoolIp,
|
||||
identityDisplayName,
|
||||
ipsecManagedComment,
|
||||
ipsecSlug,
|
||||
ipsecUserComment,
|
||||
isIpsecManagedComment,
|
||||
parseIpsecUserComment,
|
||||
poolRangesFromCidr,
|
||||
userModeConfigName,
|
||||
} from "./ipsec-config.js"
|
||||
|
||||
{
|
||||
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
|
||||
assert.equal(ipsecSlug("Иван"), "")
|
||||
assert.equal(clientCertName("Alice Cooper"), "ipsec-user-alice-cooper")
|
||||
assert.equal(userModeConfigName("Bob"), "mc-ipsec-bob")
|
||||
}
|
||||
|
||||
{
|
||||
assert.ok(isIpsecManagedComment(ipsecManagedComment("IKEv2 road-warrior")))
|
||||
assert.ok(isIpsecManagedComment(`${IPSEC_MARKER} user=alice`))
|
||||
assert.ok(!isIpsecManagedComment("какой-то чужой комментарий"))
|
||||
assert.ok(!isIpsecManagedComment(undefined))
|
||||
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
|
||||
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
|
||||
assert.equal(parseIpsecUserComment("не managed"), null)
|
||||
assert.equal(parseIpsecUserComment(null), null)
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(poolRangesFromCidr("10.77.0.0/24"), "10.77.0.2-10.77.0.254")
|
||||
assert.equal(poolRangesFromCidr("10.77.0.1/24"), "10.77.0.2-10.77.0.254")
|
||||
assert.equal(poolRangesFromCidr("192.168.7.0/28"), "192.168.7.2-192.168.7.14")
|
||||
assert.equal(poolRangesFromCidr("10.77.0.0/31"), null, "/31 — нет адресов клиентам")
|
||||
assert.equal(poolRangesFromCidr("10.77.0.0"), null)
|
||||
assert.equal(poolRangesFromCidr("не-сидр"), null)
|
||||
}
|
||||
|
||||
{
|
||||
const range = "10.77.0.2-10.77.0.10"
|
||||
assert.equal(findFreePoolIp(range, []), "10.77.0.2")
|
||||
assert.equal(findFreePoolIp(range, ["10.77.0.2"]), "10.77.0.3")
|
||||
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3/32", "10.77.0.4"]), "10.77.0.5")
|
||||
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3", "10.77.0.4", "10.77.0.5", "10.77.0.6", "10.77.0.7", "10.77.0.8", "10.77.0.9", "10.77.0.10"]), null)
|
||||
// одиночный адрес без диапазона
|
||||
assert.equal(findFreePoolIp("10.77.0.7", []), "10.77.0.7")
|
||||
assert.equal(findFreePoolIp("мусор", []), null)
|
||||
}
|
||||
|
||||
{
|
||||
// managed: user= из comment
|
||||
assert.equal(identityDisplayName({ comment: ipsecUserComment("alice"), ".id": "*1" }, []), "alice")
|
||||
// существующий RouterOS identity: сырой comment
|
||||
assert.equal(identityDisplayName({ comment: "home office", ".id": "*2" }, []), "home office")
|
||||
// remote-id
|
||||
assert.equal(identityDisplayName({ "remote-id": "client@example.com", ".id": "*3" }, []), "client@example.com")
|
||||
// CN сертификата по remote-certificate
|
||||
assert.equal(
|
||||
identityDisplayName(
|
||||
{ "remote-certificate": "ipsec-user-bob", ".id": "*4" },
|
||||
[{ name: "ipsec-user-bob", "common-name": "bob" }],
|
||||
),
|
||||
"bob",
|
||||
)
|
||||
// fallback: peer#shortId
|
||||
assert.equal(
|
||||
identityDisplayName({ peer: "ikev2-srv", ".id": "*AB12CD34" }, []),
|
||||
"ikev2-srv#AB12CD",
|
||||
)
|
||||
// fallback: rosId
|
||||
assert.equal(identityDisplayName({ ".id": "*FF" }, []), "FF")
|
||||
assert.equal(identityDisplayName({}, []), "identity")
|
||||
}
|
||||
|
||||
{
|
||||
const sswan = buildSswanConfig({
|
||||
name: "IKEv2 vpn.example.com",
|
||||
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"))
|
||||
}
|
||||
|
||||
console.log("ipsec-config.test.ts: ok")
|
||||
@@ -0,0 +1,187 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { PRODUCT_NAME } from "../managed-markers.js"
|
||||
|
||||
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
|
||||
|
||||
export const IPSEC_MARKER = `${PRODUCT_NAME}:ipsec`
|
||||
|
||||
export function ipsecManagedComment(label = ""): string {
|
||||
return label ? `${IPSEC_MARKER} ${label}` : IPSEC_MARKER
|
||||
}
|
||||
|
||||
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
|
||||
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
|
||||
}
|
||||
|
||||
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
|
||||
export function ipsecUserComment(name: string): string {
|
||||
return `${IPSEC_MARKER} user=${name.trim()}`
|
||||
}
|
||||
|
||||
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
|
||||
if (!comment) return null
|
||||
const m = /user=([^\s]+)/.exec(comment.trim())
|
||||
return m?.[1] ?? null
|
||||
}
|
||||
|
||||
type IdentityLike = {
|
||||
".id"?: string
|
||||
comment?: string
|
||||
"remote-id"?: string
|
||||
"remote-certificate"?: string
|
||||
peer?: string
|
||||
}
|
||||
|
||||
type CertLike = { name?: string; "common-name"?: string }
|
||||
|
||||
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
|
||||
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
|
||||
const comment = (i.comment ?? "").trim()
|
||||
const managedName = parseIpsecUserComment(comment)
|
||||
if (managedName) return managedName
|
||||
if (comment) return comment
|
||||
const remoteId = (i["remote-id"] ?? "").trim()
|
||||
if (remoteId) return remoteId
|
||||
const remoteCert = (i["remote-certificate"] ?? "").trim()
|
||||
if (remoteCert) {
|
||||
const cn = String(certs.find((c) => String(c.name ?? "").trim() === remoteCert)?.["common-name"] ?? "").trim()
|
||||
return cn || remoteCert
|
||||
}
|
||||
const rosId = String(i[".id"] ?? "").replace(/^\*/, "")
|
||||
const peer = (i.peer ?? "").trim()
|
||||
if (peer) return `${peer}#${rosId.slice(0, 6) || "identity"}`
|
||||
return rosId || "identity"
|
||||
}
|
||||
|
||||
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
|
||||
|
||||
export const IPSEC_CA_CERT = "ipsec-ca"
|
||||
export const IPSEC_SERVER_CERT = "ipsec-server"
|
||||
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
|
||||
export const IPSEC_COMMON_NAME = "ipsec-vpn"
|
||||
|
||||
export function ipsecSlug(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
export function clientCertName(userName: string): string {
|
||||
return `ipsec-user-${ipsecSlug(userName) || "client"}`
|
||||
}
|
||||
|
||||
export function userModeConfigName(userName: string): string {
|
||||
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
|
||||
}
|
||||
|
||||
// ── пул адресов клиентов ────────────────────────────────────────────────────
|
||||
|
||||
function ipToInt(b: Array<number | 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")
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
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,
|
||||
isIpsecManagedComment,
|
||||
} from "./ipsec-config.js"
|
||||
import {
|
||||
canonicalIpsecSnapshot,
|
||||
type IpsecLiveIdentity,
|
||||
type IpsecLiveModeConfig,
|
||||
type IpsecLiveNat,
|
||||
type IpsecLivePeer,
|
||||
type IpsecLivePool,
|
||||
type IpsecSnapshot,
|
||||
} from "./entity-snapshots.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
export interface RosIpsecPeer {
|
||||
".id"?: string
|
||||
name?: string
|
||||
address?: string
|
||||
"exchange-mode"?: string
|
||||
passive?: string
|
||||
certificate?: string
|
||||
profile?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpsecIdentity {
|
||||
".id"?: string
|
||||
peer?: string
|
||||
"auth-method"?: string
|
||||
certificate?: string
|
||||
"remote-certificate"?: string
|
||||
"match-by"?: string
|
||||
secret?: string
|
||||
"remote-id"?: string
|
||||
"mode-config"?: string
|
||||
"generate-policy"?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpsecModeConfig {
|
||||
".id"?: string
|
||||
name?: string
|
||||
"address-pool"?: string
|
||||
"address-prefix"?: string
|
||||
address?: string
|
||||
"split-dns"?: string
|
||||
"static-dns"?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpsecPool {
|
||||
".id"?: string
|
||||
name?: string
|
||||
ranges?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpsecPolicy {
|
||||
".id"?: string
|
||||
"src-address"?: string
|
||||
"dst-address"?: string
|
||||
proposal?: string
|
||||
template?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosFirewallNat {
|
||||
".id"?: string
|
||||
chain?: string
|
||||
action?: string
|
||||
"src-address"?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface RosIpsecActivePeer {
|
||||
".id"?: string
|
||||
address?: string
|
||||
"remote-id"?: string
|
||||
identity?: string
|
||||
established?: string
|
||||
}
|
||||
|
||||
type RosCertRow = Record<string, string | undefined>
|
||||
|
||||
function asBool(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
|
||||
const name = String(c.name ?? "")
|
||||
const keyUsage = String(c["key-usage"] ?? "")
|
||||
const isCa = keyUsage.includes("key-cert-sign")
|
||||
const isServer = keyUsage.includes("tls-server")
|
||||
const isClient = keyUsage.includes("tls-client")
|
||||
const isUser = name.startsWith("ipsec-user-")
|
||||
return {
|
||||
name,
|
||||
commonName: c["common-name"] || undefined,
|
||||
keySize: c["key-size"] || undefined,
|
||||
fingerprint: c.fingerprint || undefined,
|
||||
expiresAt: c["invalid-after"] || undefined,
|
||||
trusted: asBool(c.trusted),
|
||||
hasPrivateKey: asBool(c["private-key"]),
|
||||
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
|
||||
managed: isCa && name === IPSEC_CA_CERT
|
||||
|| name === IPSEC_SERVER_CERT
|
||||
|| isUser
|
||||
|| isIpsecManagedComment(c.comment),
|
||||
}
|
||||
}
|
||||
|
||||
function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto {
|
||||
return {
|
||||
id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`,
|
||||
rosId: String(p[".id"] ?? p.name ?? "peer"),
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
name: (p.name ?? "").trim(),
|
||||
address: p.address || undefined,
|
||||
exchangeMode: p["exchange-mode"] || undefined,
|
||||
passive: asBool(p.passive),
|
||||
certificate: p.certificate || undefined,
|
||||
profile: p.profile || undefined,
|
||||
disabled: asBool(p.disabled),
|
||||
comment: p.comment || undefined,
|
||||
managed: isIpsecManagedComment(p.comment),
|
||||
}
|
||||
}
|
||||
|
||||
function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto {
|
||||
return {
|
||||
id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`,
|
||||
rosId: String(m[".id"] ?? m.name ?? "mc"),
|
||||
serverId: String(server.id),
|
||||
name: (m.name ?? "").trim(),
|
||||
addressPool: m["address-pool"] || m["address-prefix"] || undefined,
|
||||
address: m.address || undefined,
|
||||
splitDns: m["split-dns"] || undefined,
|
||||
staticDns: m["static-dns"] || undefined,
|
||||
comment: m.comment || undefined,
|
||||
managed: isIpsecManagedComment(m.comment),
|
||||
}
|
||||
}
|
||||
|
||||
function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto {
|
||||
return {
|
||||
id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`,
|
||||
rosId: String(p[".id"] ?? p.name ?? "pool"),
|
||||
serverId: String(server.id),
|
||||
name: (p.name ?? "").trim(),
|
||||
ranges: (p.ranges ?? "").trim(),
|
||||
comment: p.comment || undefined,
|
||||
managed: isIpsecManagedComment(p.comment),
|
||||
}
|
||||
}
|
||||
|
||||
export interface IpsecServerState {
|
||||
server: ServerRow
|
||||
client: MikrotikClient
|
||||
peers: RosIpsecPeer[]
|
||||
identities: RosIpsecIdentity[]
|
||||
modeConfigs: RosIpsecModeConfig[]
|
||||
pools: RosIpsecPool[]
|
||||
policies: RosIpsecPolicy[]
|
||||
nat: RosFirewallNat[]
|
||||
active: RosIpsecActivePeer[]
|
||||
certs: RosCertRow[]
|
||||
}
|
||||
|
||||
export async function fetchIpsecState(server: ServerRow): Promise<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 }
|
||||
}
|
||||
|
||||
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
|
||||
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
const server = state.server
|
||||
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
|
||||
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)
|
||||
}
|
||||
|
||||
return state.identities
|
||||
.map((i): IpsecClientDto => {
|
||||
const comment = i.comment ?? ""
|
||||
const managed = isIpsecManagedComment(comment)
|
||||
const name = identityDisplayName(i, state.certs)
|
||||
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
|
||||
const certName = (i["remote-certificate"] ?? "").trim()
|
||||
const cn = certName
|
||||
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
|
||||
: ""
|
||||
const mcName = (i["mode-config"] ?? "").trim()
|
||||
const mc = mcByName.get(mcName)
|
||||
const staticIp = mc
|
||||
? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined)
|
||||
: undefined
|
||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
|
||||
return {
|
||||
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
|
||||
rosId: String(i[".id"] ?? "identity"),
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
name,
|
||||
authMethod: psk ? "pre-shared-key" : "certificate",
|
||||
certificateName: certName || undefined,
|
||||
commonName: cn || undefined,
|
||||
remoteId: (i["remote-id"] ?? "").trim() || undefined,
|
||||
staticIp,
|
||||
modeConfigName: mcName || undefined,
|
||||
peerName: (i.peer ?? "").trim() || undefined,
|
||||
online: Boolean(active),
|
||||
activeAddress: active?.address || undefined,
|
||||
activeSince: active?.established || undefined,
|
||||
disabled: asBool(i.disabled),
|
||||
comment: comment || undefined,
|
||||
managed,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
||||
const server = state.server
|
||||
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
|
||||
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
/** Для отображения: managed peer, иначе первый существующий peer роутера. */
|
||||
const primaryPeer = managedPeer ?? state.peers[0]
|
||||
const sharedMc = state.modeConfigs.find(
|
||||
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
|
||||
)
|
||||
const pool = sharedMc
|
||||
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
|
||||
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
||||
: undefined
|
||||
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
|
||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
|
||||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
||||
return {
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
serverCountry: server.country ?? undefined,
|
||||
initialized: Boolean(managedPeer && caCert && serverCert),
|
||||
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
|
||||
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
||||
peers: state.peers.map((p) => mapPeer(server, p)),
|
||||
pool: pool ? mapPool(server, pool) : undefined,
|
||||
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
||||
caCert: caCert ? mapCertificate(caCert) : undefined,
|
||||
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
|
||||
natRuleManaged,
|
||||
clientsTotal: clients.length,
|
||||
clientsOnline: clients.filter((c) => c.online).length,
|
||||
certs: state.certs.map(mapCertificate),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
|
||||
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
|
||||
*/
|
||||
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
|
||||
const state = await fetchIpsecState(server)
|
||||
return canonicalIpsecSnapshot({
|
||||
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 }
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { ipsecManagedComment } 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
|
||||
}
|
||||
|
||||
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",
|
||||
comment: f.comment,
|
||||
})
|
||||
}
|
||||
|
||||
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
|
||||
await client.put("/ip/ipsec/identity", identityRosBody(fields))
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -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,
|
||||
@@ -621,6 +672,92 @@ 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`)
|
||||
}
|
||||
|
||||
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
|
||||
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
|
||||
return this.post("/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.post("/certificate/sign", body, timeoutMs)
|
||||
} catch (e) {
|
||||
// Некоторые версии REST принимают цель подписи только как .id.
|
||||
const certs = await this.getCertificates()
|
||||
const row = certs.find((c) => String(c.name ?? "") === params.name)
|
||||
const id = row?.[".id"]
|
||||
if (!id) throw e
|
||||
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
|
||||
async exportCertificate(params: {
|
||||
name: string
|
||||
type: "pkcs12" | "pem"
|
||||
passphrase?: string
|
||||
}, timeoutMs = 60_000): Promise<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.post("/certificate/export-certificate", body, timeoutMs)
|
||||
} catch (e) {
|
||||
const certs = await this.getCertificates()
|
||||
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
|
||||
if (!id) throw e
|
||||
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
||||
}
|
||||
void raw
|
||||
// RouterOS создаёт cert_export_<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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -416,14 +416,44 @@ try {
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedRipeCacheForTests({
|
||||
prefix: "74.125.0.0/16",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
seedRipeCacheForTests({
|
||||
prefix: "104.18.0.0/16",
|
||||
asn: 13335,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "CLOUDFLARENET",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
seedRipeCacheForTests({
|
||||
prefix: "146.75.0.0/16",
|
||||
asn: 54113,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "FASTLY",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "173.194.151.65",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
src: "74.125.104.196/32",
|
||||
dst: "10.200.100.53/32",
|
||||
proto: 17,
|
||||
srcPort: 443,
|
||||
dstPort: 57182,
|
||||
dstPort: 62598,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
@@ -440,15 +470,40 @@ try {
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
{
|
||||
src: "146.75.118.132/32",
|
||||
dst: "10.200.100.53/32",
|
||||
proto: 6,
|
||||
srcPort: 80,
|
||||
dstPort: 35026,
|
||||
bytes: 4_000,
|
||||
packets: 5,
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const rev = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
|
||||
const google = rev.conversationsList.find((r) => r.src === "74.125.104.196")
|
||||
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
|
||||
const fastly = rev.conversationsList.find((r) => r.src === "146.75.118.132")
|
||||
assert.equal(google?.service, "YouTube")
|
||||
assert.equal(google?.category, "Видео / стриминг")
|
||||
assert.equal(google?.internetPeer, "74.125.104.196")
|
||||
assert.equal(google?.internetPeerPort, 443)
|
||||
assert.equal(google?.clientIp, "10.200.100.53")
|
||||
assert.equal(google?.direction, "to_client")
|
||||
assert.equal(google?.dstAsn, 15169)
|
||||
assert.equal(google?.dstCountry, "US")
|
||||
assert.ok(!String(google?.src).includes("/"), "DTO src без /32")
|
||||
assert.equal(cf?.service, "Cloudflare")
|
||||
assert.equal(cf?.category, "CDN")
|
||||
assert.equal(fastly?.service, "Fastly")
|
||||
assert.equal(fastly?.dstAsn, 54113)
|
||||
assert.equal(fastly?.dstCountry, "US")
|
||||
assert.ok(rev.asns?.some((r) => r.id === "54113"))
|
||||
assert.ok(rev.countries?.some((r) => r.id === "US"))
|
||||
assert.ok(!rev.services?.every((s) => s.label === "Прочее"), "сервисы не схлопнуты в Прочее")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
|
||||
@@ -265,8 +265,9 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
|
||||
natSrcPort: r.natSrcPort,
|
||||
natDstPort: r.natDstPort,
|
||||
})
|
||||
const ep = destMeta.endpoints
|
||||
if (destMeta.dest) peers.add(destMeta.dest)
|
||||
const app = applicationName(r.proto, r.dstPort, r.srcPort)
|
||||
const app = applicationName(r.proto, ep.peerPort || r.dstPort, ep.otherPort || r.srcPort)
|
||||
const ripe = destMeta.ripe
|
||||
const classified = destMeta.classified
|
||||
bump(applications, app, r.bytes, r.packets)
|
||||
@@ -312,8 +313,8 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
|
||||
conv.set(ckey, {
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
src: ep.packetSrc || r.src,
|
||||
dst: ep.packetDst || r.dst,
|
||||
proto: r.proto,
|
||||
protoName: protoName(r.proto),
|
||||
srcPort: r.srcPort,
|
||||
@@ -332,6 +333,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
|
||||
dstAsn: ripe?.asn || undefined,
|
||||
clientId: client?.userId,
|
||||
clientName: client?.name,
|
||||
clientIp: ep.clientIp || undefined,
|
||||
internetPeer: ep.internetPeer || undefined,
|
||||
internetPeerPort: ep.peerPort || undefined,
|
||||
direction: ep.direction,
|
||||
enId: en ? String(en.id) : undefined,
|
||||
enName: en?.name,
|
||||
plane,
|
||||
|
||||
@@ -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")
|
||||
@@ -79,6 +88,33 @@ assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 17, 443, 50000)?.service, "
|
||||
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 6, 80, 50000)?.service, "Google")
|
||||
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 53, 53000)?.service, "Google")
|
||||
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 443, 50000)?.service, "YouTube")
|
||||
|
||||
assert.equal(brandByAsn(32934)?.service, "Meta")
|
||||
assert.equal(lookupBrand("157.240.12.52", 0)?.service, "Meta")
|
||||
assert.equal(lookupBrand("57.144.22.192", 0)?.service, "Meta")
|
||||
assert.equal(brandByHolder("Instagram LLC")?.service, "Instagram")
|
||||
assert.equal(mapServiceNodeId("Instagram"), "svc:instagram")
|
||||
assert.equal(isNamedInternetService("Instagram", "Видео / стриминг"), true)
|
||||
assert.equal(
|
||||
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 443, 51234)?.service,
|
||||
"Instagram",
|
||||
"HTTPS на Meta front → Instagram, как YouTube на Google",
|
||||
)
|
||||
assert.equal(
|
||||
resolveFlowBrand("57.144.22.192", 0, "", 17, 443, 50000)?.service,
|
||||
"Instagram",
|
||||
"cdninstagram CIDR :443 без ASN → Instagram",
|
||||
)
|
||||
assert.equal(
|
||||
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 80, 50000)?.service,
|
||||
"Meta",
|
||||
":80 на Meta остаётся Meta",
|
||||
)
|
||||
assert.equal(
|
||||
resolveFlowBrand("157.240.1.1", 54115, "WHATSAPP", 6, 443, 1)?.service,
|
||||
"Meta",
|
||||
"AS54115 WhatsApp не становится Instagram",
|
||||
)
|
||||
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
|
||||
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
|
||||
const BEGET: BrandHit = { service: "Beget", ...CDN }
|
||||
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
|
||||
const META: BrandHit = { service: "Meta", ...CDN }
|
||||
const INSTAGRAM: BrandHit = { service: "Instagram", ...VIDEO }
|
||||
|
||||
const GOOGLE: BrandHit = { service: "Google", ...WEB }
|
||||
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
|
||||
@@ -184,10 +185,27 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||
{ cidr: "216.239.32.0/19", prefixLen: 19, hit: GOOGLE },
|
||||
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
|
||||
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||
{ cidr: "31.13.64.0/18", prefixLen: 18, hit: META },
|
||||
{ cidr: "57.141.0.0/16", prefixLen: 16, hit: META },
|
||||
{ cidr: "57.142.0.0/15", prefixLen: 15, hit: META },
|
||||
{ cidr: "57.144.0.0/14", prefixLen: 14, hit: META },
|
||||
{ cidr: "57.148.0.0/15", prefixLen: 15, hit: META },
|
||||
{ cidr: "66.220.144.0/20", prefixLen: 20, hit: META },
|
||||
{ cidr: "69.63.176.0/20", prefixLen: 20, hit: META },
|
||||
{ cidr: "69.171.224.0/19", prefixLen: 19, hit: META },
|
||||
{ cidr: "74.119.76.0/22", prefixLen: 22, hit: META },
|
||||
{ cidr: "129.134.0.0/16", prefixLen: 16, hit: META },
|
||||
{ cidr: "157.240.0.0/16", prefixLen: 16, hit: META },
|
||||
{ cidr: "173.252.64.0/18", prefixLen: 18, hit: META },
|
||||
{ cidr: "179.60.192.0/22", prefixLen: 22, hit: META },
|
||||
{ cidr: "185.60.216.0/22", prefixLen: 22, hit: META },
|
||||
{ cidr: "199.201.64.0/22", prefixLen: 22, hit: META },
|
||||
{ cidr: "204.15.20.0/22", prefixLen: 22, hit: META },
|
||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
|
||||
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
|
||||
{ re: /youtube/i, hit: YOUTUBE },
|
||||
{ re: /instagram/i, hit: INSTAGRAM },
|
||||
{ re: /valve|\bsteam\b/i, hit: STEAM },
|
||||
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
|
||||
{ re: /openai/i, hit: CHATGPT },
|
||||
@@ -205,6 +223,9 @@ const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||
|
||||
const STEAM_ASN = 32590
|
||||
const GOOGLE_FRONT_ASN = new Set([15169, 396982])
|
||||
/** AS32934 / AS63293 — Meta front (Facebook + Instagram CDN). AS54115 — WhatsApp, не Instagram. */
|
||||
const META_FRONT_ASN = new Set([32934, 63293])
|
||||
const WHATSAPP_ASN = 54115
|
||||
|
||||
function isGooglePublicDns(ip: string): boolean {
|
||||
return ipInCidrV4(ip, "8.8.8.0/24") || ipInCidrV4(ip, "8.8.4.0/24")
|
||||
@@ -274,9 +295,19 @@ export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||
return brandByCidr(ip) || brandByAsn(asn)
|
||||
}
|
||||
|
||||
function isGoogleFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
|
||||
return GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google"
|
||||
}
|
||||
|
||||
function isInstagramFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
|
||||
if (asn === WHATSAPP_ASN) return false
|
||||
return META_FRONT_ASN.has(asn) || cidrBrand?.service === "Meta" || asnBrand?.service === "Meta"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
|
||||
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
|
||||
* Holder (YouTube / Instagram и др.) бьёт остальные CIDR/ASN.
|
||||
* HTTPS/QUIC на Google front → YouTube (кроме 8.8.8.8); на Meta front → Instagram (кроме WhatsApp ASN).
|
||||
* Порты Steam — только AS32590 и не выше Cloudflare CIDR.
|
||||
*/
|
||||
export function resolveFlowBrand(
|
||||
@@ -292,13 +323,12 @@ export function resolveFlowBrand(
|
||||
const holderBrand = brandByHolder(holder)
|
||||
if (holderBrand) return holderBrand
|
||||
const asnBrand = brandByAsn(asn)
|
||||
if (
|
||||
!isGooglePublicDns(ip)
|
||||
&& isHttpsOrQuic(proto, dstPort, srcPort)
|
||||
&& (GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google")
|
||||
) {
|
||||
if (!isGooglePublicDns(ip) && isHttpsOrQuic(proto, dstPort, srcPort) && isGoogleFront(asn, cidrBrand, asnBrand)) {
|
||||
return YOUTUBE
|
||||
}
|
||||
if (isHttpsOrQuic(proto, dstPort, srcPort) && isInstagramFront(asn, cidrBrand, asnBrand)) {
|
||||
return INSTAGRAM
|
||||
}
|
||||
const fromLookup = cidrBrand || asnBrand
|
||||
if (fromLookup) return fromLookup
|
||||
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
|
||||
@@ -334,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}`
|
||||
}
|
||||
|
||||
@@ -132,6 +132,73 @@ const greIgnore = classifyFlowDst("8.8.8.8", 47, 0, 0, {
|
||||
fetchedAt: Date.now(),
|
||||
}, { ignoreTunnelProto: true })
|
||||
assert.equal(greIgnore.service, "Google")
|
||||
const dnsGoogle = classifyFlowDst("8.8.8.8", 17, 53, 53000, {
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(dnsGoogle.service, "Google")
|
||||
assert.notEqual(dnsGoogle.service, "Прочее")
|
||||
const ipv6Yt = classifyFlowDst("2001:4860:4860::8888", 17, 443, 50000, {
|
||||
prefix: "2001:4860:4860::8888/128",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(ipv6Yt.service, "YouTube")
|
||||
|
||||
const instagram = classifyFlowDst("157.240.12.52", 6, 443, 62598, {
|
||||
prefix: "157.240.0.0/16",
|
||||
asn: 32934,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "FACEBOOK",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(instagram.service, "Instagram")
|
||||
assert.equal(instagram.category, "Видео / стриминг")
|
||||
assert.notEqual(instagram.service, "Meta")
|
||||
|
||||
const metaHttp = classifyFlowDst("157.240.12.52", 6, 80, 50000, {
|
||||
prefix: "157.240.0.0/16",
|
||||
asn: 32934,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "FACEBOOK",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(metaHttp.service, "Meta")
|
||||
assert.equal(metaHttp.category, "CDN")
|
||||
|
||||
const igHolder = classifyFlowDst("203.0.113.80", 6, 443, 1, {
|
||||
prefix: "203.0.113.0/24",
|
||||
asn: 64503,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "Instagram LLC",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(igHolder.service, "Instagram")
|
||||
|
||||
const igCidr = classifyFlowDst("57.144.22.192", 17, 443, 50000, null)
|
||||
assert.equal(igCidr.service, "Instagram")
|
||||
assert.equal(igCidr.category, "Видео / стриминг")
|
||||
|
||||
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
|
||||
assert.equal(esp.category, "Туннель")
|
||||
assert.equal(applicationName(17, 443, 50000), "QUIC")
|
||||
|
||||
@@ -48,7 +48,7 @@ export function seedFlowCatalogForTests(input: {
|
||||
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||
const p = purpose.toLowerCase()
|
||||
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
|
||||
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
|
||||
if (/streaming|youtube|netflix|twitch|video|spotify|instagram/.test(p)) return "Видео / стриминг"
|
||||
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
|
||||
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
resetEngineForTests,
|
||||
} from "./traffic-flow-engine.js"
|
||||
import { factsSnapshotForTests } from "./traffic-flow-facts.js"
|
||||
import { classifyInternetBrand, mapInternetBrand } from "./traffic-flow-dest.js"
|
||||
import { classifyInternetBrand, mapInternetBrand, resolveInternetDest } from "./traffic-flow-dest.js"
|
||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||
import {
|
||||
disableRipeEnqueueForTests,
|
||||
@@ -149,6 +149,106 @@ assert.equal(mapInternetBrand("8.8.8.8", 6, 443, 51234, {
|
||||
fetchedAt: Date.now(),
|
||||
}).service, "Google")
|
||||
|
||||
assert.equal(classifyInternetBrand("8.8.8.8", 17, 53, 53000, {
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})?.service, "Google")
|
||||
assert.equal(mapInternetBrand("8.8.8.8", 17, 53, 53000, {
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
}).service, "Google")
|
||||
assert.equal(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, {
|
||||
prefix: "2001:4860:4860::8888/128",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
}).service, "YouTube")
|
||||
assert.equal(mapInternetBrand("64.233.161.1", 17, 443, 50000, null).service, "YouTube")
|
||||
assert.equal(mapInternetBrand("142.250.1.10", 6, 443, 1, null).service, "YouTube")
|
||||
|
||||
{
|
||||
const meta = resolveInternetDest({
|
||||
src: "74.125.104.196/32",
|
||||
dst: "10.200.100.53/32",
|
||||
proto: 17,
|
||||
srcPort: 443,
|
||||
dstPort: 62598,
|
||||
serverId: 1,
|
||||
inIface: "gre-client",
|
||||
topo,
|
||||
})
|
||||
assert.equal(meta.dest, "74.125.104.196")
|
||||
assert.equal(meta.classified.service, "YouTube")
|
||||
assert.notEqual(meta.classified.service, "Прочее")
|
||||
assert.equal(meta.endpoints.direction, "to_client")
|
||||
assert.equal(meta.endpoints.clientIp, "10.200.100.53")
|
||||
assert.equal(meta.asn, 0)
|
||||
}
|
||||
|
||||
{
|
||||
seedRipeCacheForTests({
|
||||
prefix: "146.75.0.0/16",
|
||||
asn: 54113,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "FASTLY",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
seedRipeCacheForTests({
|
||||
prefix: "3.174.0.0/16",
|
||||
asn: 16509,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "AMAZON-AES",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
const fastly = resolveInternetDest({
|
||||
src: "146.75.118.132/32",
|
||||
dst: "10.200.100.53/32",
|
||||
proto: 6,
|
||||
srcPort: 80,
|
||||
dstPort: 35026,
|
||||
serverId: 1,
|
||||
inIface: "gre-client",
|
||||
topo,
|
||||
})
|
||||
assert.equal(fastly.classified.service, "Fastly")
|
||||
assert.equal(fastly.asn, 54113)
|
||||
assert.equal(fastly.country, "US")
|
||||
const aws = resolveInternetDest({
|
||||
src: "3.174.2.35/32",
|
||||
dst: "10.200.100.53/32",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 43726,
|
||||
serverId: 1,
|
||||
inIface: "gre-client",
|
||||
topo,
|
||||
})
|
||||
assert.equal(aws.classified.service, "AWS")
|
||||
assert.equal(aws.asn, 16509)
|
||||
}
|
||||
|
||||
resetEngineForTests()
|
||||
seedFlowTopologyForTests(null)
|
||||
resetRipeCacheForTests()
|
||||
|
||||
@@ -3,7 +3,11 @@ import { isIsoCountry, isNamedInternetService, OTHER_SERVICE, resolveFlowBrand }
|
||||
import { classifyFlowDst, type FlowClassification } from "./traffic-flow-classify.js"
|
||||
import { resolveFlowIp } from "./traffic-flow-geoip.js"
|
||||
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
|
||||
import { pickInternetDest, type InternetDestCtx } from "./traffic-flow-ip.js"
|
||||
import {
|
||||
resolveFlowEndpoints,
|
||||
type FlowEndpoints,
|
||||
type InternetDestCtx,
|
||||
} from "./traffic-flow-ip.js"
|
||||
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||
import {
|
||||
flowOursHosts,
|
||||
@@ -17,6 +21,7 @@ export interface InternetDestMeta {
|
||||
classified: FlowClassification
|
||||
country: string
|
||||
asn: number
|
||||
endpoints: FlowEndpoints
|
||||
}
|
||||
|
||||
export function destCtxForIface(
|
||||
@@ -41,7 +46,14 @@ export function destCtxForIface(
|
||||
}
|
||||
}
|
||||
|
||||
/** Бренд интернет-dest как на карте: GRE/ESP/WG — транспорт, не сервис. */
|
||||
const OTHER_BRAND: FlowClassification = { service: OTHER_SERVICE, category: OTHER_SERVICE }
|
||||
|
||||
function isTunnelProto(proto: number, dstPort: number, srcPort: number): boolean {
|
||||
if (proto === 47 || proto === 50) return true
|
||||
return applicationName(proto, dstPort, srcPort) === "WireGuard"
|
||||
}
|
||||
|
||||
/** Бренд интернет-dest: ASN/CIDR до skip DNS. GRE/ESP/WG — не сервис. */
|
||||
export function classifyInternetBrand(
|
||||
dst: string,
|
||||
proto: number,
|
||||
@@ -49,17 +61,15 @@ export function classifyInternetBrand(
|
||||
srcPort: number,
|
||||
ripe: FlowIpMeta | null,
|
||||
): FlowClassification | null {
|
||||
if (proto === 47 || proto === 50) return null
|
||||
const app = applicationName(proto, dstPort, srcPort)
|
||||
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||
if (isTunnelProto(proto, dstPort, srcPort)) return null
|
||||
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
|
||||
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||
return brand
|
||||
if (brand && isNamedInternetService(brand.service, brand.category)) return brand
|
||||
const app = applicationName(proto, dstPort, srcPort)
|
||||
if (app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||
return null
|
||||
}
|
||||
|
||||
const OTHER_BRAND: FlowClassification = { service: OTHER_SERVICE, category: OTHER_SERVICE }
|
||||
|
||||
/** Бренд для карты: именованный сервис или «Прочее» (GRE/ESP не сервис). */
|
||||
/** Тот же классификатор, что аналитика (GeoLite2 ASN + catalog). Туннель → Прочее. */
|
||||
export function mapInternetBrand(
|
||||
dst: string,
|
||||
proto: number,
|
||||
@@ -67,8 +77,10 @@ export function mapInternetBrand(
|
||||
srcPort: number,
|
||||
ripe: FlowIpMeta | null,
|
||||
): FlowClassification {
|
||||
if (proto === 47 || proto === 50) return OTHER_BRAND
|
||||
return classifyInternetBrand(dst, proto, dstPort, srcPort, ripe) ?? OTHER_BRAND
|
||||
if (isTunnelProto(proto, dstPort, srcPort)) return OTHER_BRAND
|
||||
const classified = classifyFlowDst(dst, proto, dstPort, srcPort, ripe, { ignoreTunnelProto: true })
|
||||
if (isNamedInternetService(classified.service, classified.category)) return classified
|
||||
return OTHER_BRAND
|
||||
}
|
||||
|
||||
export function resolveInternetDest(opts: {
|
||||
@@ -85,28 +97,35 @@ export function resolveInternetDest(opts: {
|
||||
natSrcPort?: number
|
||||
natDstPort?: number
|
||||
}): InternetDestMeta {
|
||||
const dest = pickInternetDest(
|
||||
opts.src,
|
||||
opts.dst,
|
||||
opts.srcPort,
|
||||
opts.dstPort,
|
||||
destCtxForIface(opts.topo, opts.serverId, opts.inIface, {
|
||||
natSrc: opts.natSrc,
|
||||
natDst: opts.natDst,
|
||||
natSrcPort: opts.natSrcPort,
|
||||
natDstPort: opts.natDstPort,
|
||||
}),
|
||||
)
|
||||
const ripe = dest ? resolveFlowIp(dest) : null
|
||||
const classified = dest
|
||||
? classifyFlowDst(dest, opts.proto, opts.dstPort, opts.srcPort, ripe, { ignoreTunnelProto: true })
|
||||
: classifyFlowDst(opts.dst, opts.proto, opts.dstPort, opts.srcPort, ripe)
|
||||
const ctx = destCtxForIface(opts.topo, opts.serverId, opts.inIface, {
|
||||
natSrc: opts.natSrc,
|
||||
natDst: opts.natDst,
|
||||
natSrcPort: opts.natSrcPort,
|
||||
natDstPort: opts.natDstPort,
|
||||
})
|
||||
const endpoints = resolveFlowEndpoints({
|
||||
src: opts.src,
|
||||
dst: opts.dst,
|
||||
srcPort: opts.srcPort,
|
||||
dstPort: opts.dstPort,
|
||||
ctx,
|
||||
})
|
||||
const dest = endpoints.internetPeer
|
||||
if (!dest) {
|
||||
return { dest: "", ripe: null, classified, country: "", asn: 0 }
|
||||
return { dest: "", ripe: null, classified: OTHER_BRAND, country: "", asn: 0, endpoints }
|
||||
}
|
||||
const ripe = resolveFlowIp(dest)
|
||||
const classified = classifyFlowDst(
|
||||
dest,
|
||||
opts.proto,
|
||||
endpoints.peerPort,
|
||||
endpoints.otherPort,
|
||||
ripe,
|
||||
{ ignoreTunnelProto: true },
|
||||
)
|
||||
const country = ripe?.ok && isIsoCountry(ripe.country)
|
||||
? ripe.country
|
||||
: (ripe?.ok ? "" : "unknown")
|
||||
const asn = ripe?.ok && ripe.asn ? ripe.asn : 0
|
||||
return { dest, ripe, classified, country, asn }
|
||||
return { dest, ripe, classified, country, asn, endpoints }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
|
||||
import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
|
||||
import { canonicalIp } from "./traffic-flow-ip.js"
|
||||
import { resolveInternetDest } from "./traffic-flow-dest.js"
|
||||
import {
|
||||
getServerCatalog,
|
||||
@@ -68,12 +69,12 @@ export interface PendingFlowRow {
|
||||
}
|
||||
|
||||
function inetOrNull(value: string | null | undefined): string | null {
|
||||
const s = String(value ?? "").trim()
|
||||
const s = canonicalIp(value)
|
||||
return s.length > 0 ? s : null
|
||||
}
|
||||
|
||||
export function isValidFlowInet(value: string): boolean {
|
||||
const s = value.trim()
|
||||
const s = canonicalIp(value)
|
||||
if (!s) return false
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
|
||||
if (v4) {
|
||||
@@ -100,16 +101,16 @@ function clampPort(n: number): number {
|
||||
}
|
||||
|
||||
function sanitizeNatIp(value: string | null | undefined): string {
|
||||
const s = String(value ?? "").trim()
|
||||
const s = canonicalIp(value)
|
||||
if (!s || s === "0.0.0.0") return ""
|
||||
return isValidFlowInet(s) ? s : ""
|
||||
}
|
||||
|
||||
function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
|
||||
const src = (r.src || "").trim() || "0.0.0.0"
|
||||
const dst = (r.dst || "").trim() || "0.0.0.0"
|
||||
const src = canonicalIp(r.src) || "0.0.0.0"
|
||||
const dst = canonicalIp(r.dst) || "0.0.0.0"
|
||||
if (!isValidFlowInet(src) || !isValidFlowInet(dst)) return null
|
||||
const next = inetOrNull(r.nextHop)
|
||||
const next = inetOrNull(canonicalIp(r.nextHop))
|
||||
return {
|
||||
...r,
|
||||
src,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
minuteDimsSnapshotForTests,
|
||||
} from "./traffic-flow-engine.js"
|
||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||
import { mapInternetBrand } from "./traffic-flow-dest.js"
|
||||
import { disableGeoipDbForTests } from "./geoip-settings.js"
|
||||
import {
|
||||
collectGeoipUpdateOnce,
|
||||
@@ -89,6 +90,7 @@ setGeoipReadersForTests({
|
||||
const hit = resolveFlowIp("8.8.8.8")
|
||||
assert.equal(hit?.country, "US")
|
||||
assert.equal(hit?.asn, 15169)
|
||||
assert.equal(resolveFlowIp("8.8.8.8/32")?.asn, 15169, "GeoIP по inet::text /32")
|
||||
assert.equal(hit?.holder, "GOOGLE")
|
||||
assert.equal(hit?.ok, true)
|
||||
|
||||
@@ -102,6 +104,21 @@ assert.equal(lookupGeoip("6.6.6.6")?.country, "US")
|
||||
const classified = classifyFlowDst("8.8.8.8", 6, 443, 51504, hit)
|
||||
assert.equal(classified.service, "Google")
|
||||
|
||||
setGeoipReadersForTests({
|
||||
country: fakeCountryReader({ "8.8.8.8": "US", "2001:4860:4860::8888": "US" }),
|
||||
asn: fakeAsnReader({
|
||||
"8.8.8.8": { asn: 15169, org: "GOOGLE" },
|
||||
"2001:4860:4860::8888": { asn: 15169, org: "GOOGLE" },
|
||||
"64.233.161.1": { asn: 15169, org: "GOOGLE" },
|
||||
}),
|
||||
})
|
||||
const v6meta = resolveFlowIp("2001:4860:4860::8888")
|
||||
assert.equal(v6meta?.asn, 15169)
|
||||
assert.equal(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, v6meta).service, "YouTube")
|
||||
assert.notEqual(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, v6meta).service, "Прочее")
|
||||
const cidrYt = mapInternetBrand("64.233.161.1", 17, 443, 50000, resolveFlowIp("64.233.161.1"))
|
||||
assert.equal(cidrYt.service, "YouTube")
|
||||
|
||||
// ── движок: dims country/asn наполняются из geoip-ридеров ────────────────────
|
||||
resetEngineForTests()
|
||||
ingestParsedFlowsForServerForTests(1, [{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { open, type AsnResponse, type CountryResponse, type Reader } from "maxmind"
|
||||
import { isNonPublicIp } from "./traffic-flow-ip.js"
|
||||
import { canonicalIp, isNonPublicIp } from "./traffic-flow-ip.js"
|
||||
import { isIsoCountry, resolveRipeCountry } from "./traffic-flow-brands.js"
|
||||
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||
|
||||
@@ -117,7 +117,7 @@ function safeAsn(reader: Reader<AsnResponse>, ip: string): { asn: number; holder
|
||||
* (ok=true когда есть страна или ASN; null — данных нет, пусть пробует RIPE).
|
||||
*/
|
||||
export function lookupGeoip(ip: string): FlowIpMeta | null {
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
const trimmed = canonicalIp(ip)
|
||||
if (!trimmed) return null
|
||||
if (isNonPublicIp(trimmed)) return negativeMeta(trimmed)
|
||||
const { country: countryReader, asn: asnReader } = readers
|
||||
|
||||
@@ -72,6 +72,7 @@ setPendingCapForTests(null)
|
||||
|
||||
assert.equal(isValidFlowInet("10.0.0.1"), true)
|
||||
assert.equal(isValidFlowInet("8.8.8.8"), true)
|
||||
assert.equal(isValidFlowInet("8.8.8.8/32"), true)
|
||||
assert.equal(isValidFlowInet("0:0:0:0:0:0:0:1"), true)
|
||||
assert.equal(isValidFlowInet("not-an-ip"), false)
|
||||
assert.equal(isValidFlowInet("999.1.1.1"), false)
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { canonicalIp } from "./traffic-flow-ip.js"
|
||||
import { getServerCatalog } from "./traffic-flow-topology.js"
|
||||
|
||||
export type { PendingFlowRow }
|
||||
@@ -357,10 +358,10 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
|
||||
const keep = Math.max(20, settings.topN)
|
||||
const stored = await dbAll<StoredBucketRow>(`
|
||||
SELECT
|
||||
server_id, bucket_at, src::text AS src, dst::text AS dst, proto,
|
||||
server_id, bucket_at, COALESCE(host(src), '') AS src, COALESCE(host(dst), '') AS dst, proto,
|
||||
src_port, dst_port, bytes, packets, in_iface, out_iface,
|
||||
next_hop::text AS next_hop, flow_start_ms, flow_end_ms,
|
||||
nat_src::text AS nat_src, nat_dst::text AS nat_dst, nat_src_port, nat_dst_port
|
||||
COALESCE(host(next_hop), '') AS next_hop, flow_start_ms, flow_end_ms,
|
||||
COALESCE(host(nat_src), '') AS nat_src, COALESCE(host(nat_dst), '') AS nat_dst, nat_src_port, nat_dst_port
|
||||
FROM (
|
||||
SELECT fb.*,
|
||||
ROW_NUMBER() OVER (
|
||||
@@ -376,8 +377,8 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
|
||||
mergeInto(merged, {
|
||||
serverId: Number(r.server_id),
|
||||
bucketAt: isoBucketAt(r.bucket_at),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
src: canonicalIp(r.src),
|
||||
dst: canonicalIp(r.dst),
|
||||
proto: Number(r.proto) || 0,
|
||||
srcPort: Number(r.src_port) || 0,
|
||||
dstPort: Number(r.dst_port) || 0,
|
||||
@@ -385,11 +386,11 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
|
||||
packets: Number(r.packets) || 0,
|
||||
inIface: r.in_iface ?? "",
|
||||
outIface: r.out_iface ?? "",
|
||||
nextHop: r.next_hop ?? "",
|
||||
nextHop: canonicalIp(r.next_hop ?? ""),
|
||||
flowStartMs: Number(r.flow_start_ms) || 0,
|
||||
flowEndMs: Number(r.flow_end_ms) || 0,
|
||||
natSrc: r.nat_src ?? "",
|
||||
natDst: r.nat_dst ?? "",
|
||||
natSrc: canonicalIp(r.nat_src ?? ""),
|
||||
natDst: canonicalIp(r.nat_dst ?? ""),
|
||||
natSrcPort: Number(r.nat_src_port) || 0,
|
||||
natDstPort: Number(r.nat_dst_port) || 0,
|
||||
})
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { isNonPublicIp, pickInternetDest, pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import {
|
||||
canonicalIp,
|
||||
isNonPublicIp,
|
||||
pickInternetDest,
|
||||
pickInternetPeer,
|
||||
pickMapInternetDest,
|
||||
resolveFlowEndpoints,
|
||||
} from "./traffic-flow-ip.js"
|
||||
|
||||
assert.equal(canonicalIp("74.125.104.196/32"), "74.125.104.196")
|
||||
assert.equal(canonicalIp("10.200.100.53/32"), "10.200.100.53")
|
||||
assert.equal(canonicalIp("::ffff:8.8.8.8"), "8.8.8.8")
|
||||
assert.equal(canonicalIp("2001:4860:4860::8888/128"), "2001:4860:4860::8888")
|
||||
|
||||
assert.equal(isNonPublicIp("10.200.100.53"), true)
|
||||
assert.equal(isNonPublicIp("10.200.100.53/32"), true)
|
||||
assert.equal(isNonPublicIp("173.194.151.65"), false)
|
||||
assert.equal(isNonPublicIp("74.125.104.196/32"), false, "PG inet::text не делает Google приватным")
|
||||
|
||||
assert.equal(
|
||||
pickInternetPeer("173.194.151.65", "10.200.100.53", 443, 57182),
|
||||
"173.194.151.65",
|
||||
"reverse IPFIX: Google:443 → RFC1918",
|
||||
)
|
||||
assert.equal(
|
||||
pickInternetPeer("74.125.104.196/32", "10.200.100.53/32", 443, 62598),
|
||||
"74.125.104.196",
|
||||
"inet::text /32 reverse Google",
|
||||
)
|
||||
assert.equal(
|
||||
pickInternetPeer("10.200.100.53", "104.18.35.51", 53880, 443),
|
||||
"104.18.35.51",
|
||||
@@ -78,5 +97,62 @@ assert.equal(
|
||||
"",
|
||||
"NAT 0.0.0.0 не dest",
|
||||
)
|
||||
assert.equal(
|
||||
pickMapInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
|
||||
...client,
|
||||
natDst: "8.8.8.8",
|
||||
natDstPort: 443,
|
||||
}),
|
||||
"8.8.8.8",
|
||||
"карта: RFC1918 + NAT Google",
|
||||
)
|
||||
assert.equal(
|
||||
pickMapInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, client),
|
||||
"",
|
||||
"карта: RFC1918 без NAT → Прочее",
|
||||
)
|
||||
assert.equal(
|
||||
pickInternetDest("173.194.151.65", "10.200.100.53", 12345, 57182, client),
|
||||
"173.194.151.65",
|
||||
"реверс googlevideo не :443 — публичный src",
|
||||
)
|
||||
assert.equal(
|
||||
pickMapInternetDest("173.194.151.65", "10.200.100.53", 12345, 57182, client),
|
||||
"173.194.151.65",
|
||||
"карта: реверс googlevideo не :443 — всё равно публичный src",
|
||||
)
|
||||
assert.equal(
|
||||
pickMapInternetDest("203.0.113.10", "198.51.100.1", 0, 0, { ours }),
|
||||
"",
|
||||
"карта: JH ours → EN ours всё ещё не dest",
|
||||
)
|
||||
|
||||
{
|
||||
const ep = resolveFlowEndpoints({
|
||||
src: "74.125.104.196/32",
|
||||
dst: "10.200.100.53/32",
|
||||
srcPort: 443,
|
||||
dstPort: 62598,
|
||||
})
|
||||
assert.equal(ep.internetPeer, "74.125.104.196")
|
||||
assert.equal(ep.peerPort, 443)
|
||||
assert.equal(ep.clientIp, "10.200.100.53")
|
||||
assert.equal(ep.direction, "to_client")
|
||||
assert.equal(ep.packetSrc, "74.125.104.196")
|
||||
assert.equal(ep.packetDst, "10.200.100.53")
|
||||
}
|
||||
|
||||
{
|
||||
const ep = resolveFlowEndpoints({
|
||||
src: "10.200.100.53",
|
||||
dst: "104.18.35.51",
|
||||
srcPort: 53880,
|
||||
dstPort: 443,
|
||||
})
|
||||
assert.equal(ep.internetPeer, "104.18.35.51")
|
||||
assert.equal(ep.peerPort, 443)
|
||||
assert.equal(ep.clientIp, "10.200.100.53")
|
||||
assert.equal(ep.direction, "from_client")
|
||||
}
|
||||
|
||||
console.log("traffic-flow-ip.test.ts: ok")
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
|
||||
|
||||
/**
|
||||
* Host-семантика PostgreSQL `host(inet)`: снимает `/32` `/128`, `::ffff:`.
|
||||
* IPFIX 5-tuple не меняем — только канонический вид адреса.
|
||||
*/
|
||||
export function canonicalIp(raw: string | undefined | null): string {
|
||||
let t = String(raw ?? "").trim()
|
||||
if (!t) return ""
|
||||
const zone = t.indexOf("%")
|
||||
if (zone >= 0) t = t.slice(0, zone)
|
||||
if (t.toLowerCase().startsWith("::ffff:")) t = t.slice(7)
|
||||
const slash = t.lastIndexOf("/")
|
||||
if (slash >= 0) {
|
||||
const plen = t.slice(slash + 1)
|
||||
if (/^\d+$/.test(plen)) t = t.slice(0, slash)
|
||||
}
|
||||
return t.trim()
|
||||
}
|
||||
|
||||
export function ipv4ToInt(ip: string): number | null {
|
||||
const parts = String(ip ?? "").trim().split(".")
|
||||
const parts = canonicalIp(ip).split(".")
|
||||
if (parts.length !== 4) return null
|
||||
let n = 0
|
||||
for (const p of parts) {
|
||||
@@ -26,12 +44,12 @@ export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLe
|
||||
export function ipInCidrV4(ip: string, cidr: string): boolean {
|
||||
const addr = ipv4ToInt(ip)
|
||||
const parsed = parseCidrV4(cidr)
|
||||
if (addr == null || !parsed) return false
|
||||
if (addr == null || parsed == null) return false
|
||||
return ((addr & parsed.mask) >>> 0) === parsed.net
|
||||
}
|
||||
|
||||
export function isNonPublicIp(ip: string): boolean {
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
const trimmed = canonicalIp(ip)
|
||||
if (!trimmed) return true
|
||||
if (trimmed.includes(":")) {
|
||||
const lower = trimmed.toLowerCase()
|
||||
@@ -56,14 +74,14 @@ export function isNonPublicIp(ip: string): boolean {
|
||||
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
|
||||
|
||||
export function isUnspecifiedIp(ip: string): boolean {
|
||||
const t = String(ip ?? "").trim()
|
||||
const t = canonicalIp(ip)
|
||||
if (!t) return true
|
||||
const lower = t.toLowerCase()
|
||||
return t === "0.0.0.0" || lower === "::" || lower === "::0"
|
||||
}
|
||||
|
||||
function usableIp(ip: string | undefined): string {
|
||||
const t = String(ip ?? "").trim()
|
||||
const t = canonicalIp(ip)
|
||||
return isUnspecifiedIp(t) ? "" : t
|
||||
}
|
||||
|
||||
@@ -81,13 +99,22 @@ export interface InternetDestCtx {
|
||||
}
|
||||
|
||||
export function isLocalIp(ip: string, ours?: ReadonlySet<string>): boolean {
|
||||
if (isUnspecifiedIp(ip) || isNonPublicIp(ip)) return true
|
||||
return Boolean(ours?.has(String(ip ?? "").trim()))
|
||||
const host = canonicalIp(ip)
|
||||
if (isUnspecifiedIp(host) || isNonPublicIp(host)) return true
|
||||
if (!ours || ours.size === 0) return false
|
||||
if (ours.has(host) || ours.has(String(ip ?? "").trim())) return true
|
||||
for (const o of ours) {
|
||||
if (canonicalIp(o) === host) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Интернет-назначение потока для ASN/страны/сервиса.
|
||||
* Пустая строка — dest нет (не GeoIP IP клиента / GRE-пира).
|
||||
*
|
||||
* boundClient: download CDN→overlay берём публичный src даже без :80/:443
|
||||
* (googlevideo). Client-ISP → overlay:well-known — не dest (ASN клиента).
|
||||
*/
|
||||
export function pickInternetDest(
|
||||
srcRaw: string,
|
||||
@@ -112,8 +139,8 @@ export function pickInternetDest(
|
||||
if (srcIp) {
|
||||
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPortEff)
|
||||
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPortEff)
|
||||
if (srcWk && !dstWk) return srcIp
|
||||
return ""
|
||||
if (!srcWk && dstWk) return ""
|
||||
return srcIp
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -132,10 +159,93 @@ export function pickInternetDest(
|
||||
return dst
|
||||
}
|
||||
|
||||
/**
|
||||
* Dest для карты = тот же internet peer, что аналитика/факты.
|
||||
* (исторически отдельный fallback без well-known trap — теперь в pickInternetDest.)
|
||||
*/
|
||||
export function pickMapInternetDest(
|
||||
srcRaw: string,
|
||||
dstRaw: string,
|
||||
srcPort: number,
|
||||
dstPort: number,
|
||||
ctx?: InternetDestCtx,
|
||||
): string {
|
||||
return pickInternetDest(srcRaw, dstRaw, srcPort, dstPort, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Интернет-сторона потока без топологии: у IPFIX сервис часто в src (Google:443 → RFC1918).
|
||||
* Для куба статистики используйте pickInternetDest.
|
||||
*/
|
||||
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
|
||||
return pickInternetDest(src, dst, srcPort, dstPort) || dst
|
||||
return pickInternetDest(src, dst, srcPort, dstPort) || ""
|
||||
}
|
||||
|
||||
export type FlowDirection = "to_client" | "from_client" | "transit"
|
||||
|
||||
export interface FlowEndpoints {
|
||||
packetSrc: string
|
||||
packetDst: string
|
||||
internetPeer: string
|
||||
peerPort: number
|
||||
otherPort: number
|
||||
clientIp: string
|
||||
direction: FlowDirection
|
||||
}
|
||||
|
||||
function sameHost(a: string, b: string | undefined): boolean {
|
||||
const x = canonicalIp(a)
|
||||
const y = canonicalIp(b)
|
||||
return Boolean(x) && x === y
|
||||
}
|
||||
|
||||
/** Роли концов IPFIX-пакета. 5-tuple не переворачивается. */
|
||||
export function resolveFlowEndpoints(opts: {
|
||||
src: string
|
||||
dst: string
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
ctx?: InternetDestCtx
|
||||
}): FlowEndpoints {
|
||||
const packetSrc = usableIp(opts.src)
|
||||
const packetDst = usableIp(opts.dst)
|
||||
const internetPeer = pickInternetDest(opts.src, opts.dst, opts.srcPort, opts.dstPort, opts.ctx)
|
||||
const ours = opts.ctx?.ours
|
||||
const srcLocal = Boolean(packetSrc) && isLocalIp(packetSrc, ours)
|
||||
const dstLocal = Boolean(packetDst) && isLocalIp(packetDst, ours)
|
||||
|
||||
let peerPort = 0
|
||||
let otherPort = 0
|
||||
if (internetPeer) {
|
||||
if (sameHost(internetPeer, packetSrc) || sameHost(internetPeer, opts.ctx?.natSrc)) {
|
||||
peerPort = opts.srcPort
|
||||
otherPort = opts.dstPort
|
||||
} else if (sameHost(internetPeer, packetDst) || sameHost(internetPeer, opts.ctx?.natDst)) {
|
||||
peerPort = opts.dstPort
|
||||
otherPort = opts.srcPort
|
||||
} else {
|
||||
peerPort = opts.ctx?.natDstPort || opts.dstPort
|
||||
otherPort = opts.srcPort
|
||||
}
|
||||
}
|
||||
|
||||
let clientIp = ""
|
||||
if (srcLocal && !dstLocal) clientIp = packetSrc
|
||||
else if (dstLocal && !srcLocal) clientIp = packetDst
|
||||
else if (srcLocal) clientIp = packetSrc
|
||||
else if (dstLocal) clientIp = packetDst
|
||||
|
||||
let direction: FlowDirection = "transit"
|
||||
if (internetPeer && clientIp) {
|
||||
direction = sameHost(internetPeer, packetSrc) ? "to_client" : "from_client"
|
||||
}
|
||||
|
||||
return {
|
||||
packetSrc,
|
||||
packetDst,
|
||||
internetPeer,
|
||||
peerPort,
|
||||
otherPort,
|
||||
clientIp,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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, 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"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import { destCtxForIface, mapInternetBrand } from "./traffic-flow-dest.js"
|
||||
import { pickInternetDest } from "./traffic-flow-ip.js"
|
||||
import { resolveFlowIp } from "./traffic-flow-geoip.js"
|
||||
import { resolveFlowEndpoints } from "./traffic-flow-ip.js"
|
||||
import { geoipReadersStatus, resolveFlowIp } from "./traffic-flow-geoip.js"
|
||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
import { flowDataEpoch } from "./traffic-flow-engine.js"
|
||||
@@ -19,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 {
|
||||
@@ -63,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 || "—"
|
||||
@@ -99,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 {
|
||||
@@ -192,6 +407,7 @@ async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> {
|
||||
}
|
||||
|
||||
async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Promise<FlowMapHopsDto> {
|
||||
refreshFlowCatalogInBackground()
|
||||
const windowSec = Math.max(60, q.minutes * 60)
|
||||
const raw = await listFlowRowsForWindow(q.minutes)
|
||||
const allow = q.userId ? await userIfaceAllow(q.userId) : null
|
||||
@@ -334,18 +550,19 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
const inName = resolveIfaceName(r.serverId, r.inIface).name
|
||||
const outName = resolveIfaceName(r.serverId, r.outIface).name
|
||||
totalBytes += r.bytes
|
||||
const dest = pickInternetDest(
|
||||
r.src,
|
||||
r.dst,
|
||||
r.srcPort,
|
||||
r.dstPort,
|
||||
destCtxForIface(topo, r.serverId, inName, {
|
||||
const ep = resolveFlowEndpoints({
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
ctx: destCtxForIface(topo, r.serverId, inName, {
|
||||
natSrc: r.natSrc,
|
||||
natDst: r.natDst,
|
||||
natSrcPort: r.natSrcPort,
|
||||
natDstPort: r.natDstPort,
|
||||
}),
|
||||
)
|
||||
})
|
||||
const dest = ep.internetPeer
|
||||
const destKey = dest || "__other__"
|
||||
const client = resolveMapClient(topo, r.serverId, inName, outName)
|
||||
const prevDst = dstAcc.get(destKey)
|
||||
@@ -356,8 +573,8 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
const acc: DstAcc = {
|
||||
bytes: r.bytes,
|
||||
proto: r.proto,
|
||||
dstPort: r.dstPort,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: ep.peerPort || r.dstPort,
|
||||
srcPort: ep.otherPort || r.srcPort,
|
||||
fromBytes: new Map(),
|
||||
}
|
||||
bumpFrom(acc, String(r.serverId), r.bytes, client)
|
||||
@@ -365,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
|
||||
@@ -420,114 +627,41 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const namedBytes = [...svcTotals.values()]
|
||||
.filter((s) => s.label !== OTHER_SERVICE)
|
||||
.filter((s) => isNamedInternetService(s.label, s.category))
|
||||
.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()
|
||||
return {
|
||||
hops: [...hops.values()]
|
||||
.map((a) => toHop(a, windowSec))
|
||||
@@ -538,9 +672,15 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
totalBytes,
|
||||
namedBytes,
|
||||
unclassifiedBytes,
|
||||
services,
|
||||
serviceEdges,
|
||||
servicePaths,
|
||||
asnLoaded: geo.asnLoaded,
|
||||
countryLoaded: geo.countryLoaded,
|
||||
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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { canonicalIp } from "./traffic-flow-ip.js"
|
||||
|
||||
export interface ParsedFlow {
|
||||
src: string
|
||||
dst: string
|
||||
@@ -44,11 +46,13 @@ export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
|
||||
return {
|
||||
...emptyParsedFlow(),
|
||||
...flow,
|
||||
nextHop: flow.nextHop ?? "",
|
||||
src: canonicalIp(flow.src) || flow.src || "",
|
||||
dst: canonicalIp(flow.dst) || flow.dst || "",
|
||||
nextHop: canonicalIp(flow.nextHop ?? ""),
|
||||
flowStartMs: flow.flowStartMs ?? 0,
|
||||
flowEndMs: flow.flowEndMs ?? 0,
|
||||
natSrc: flow.natSrc ?? "",
|
||||
natDst: flow.natDst ?? "",
|
||||
natSrc: canonicalIp(flow.natSrc ?? ""),
|
||||
natDst: canonicalIp(flow.natDst ?? ""),
|
||||
natSrcPort: flow.natSrcPort ?? 0,
|
||||
natDstPort: flow.natDstPort ?? 0,
|
||||
inIface: flow.inIface ?? "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dbAll, dbQuery } from "../db/index.js"
|
||||
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||
import { canonicalIp, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||
import { resolveRipeCountry } from "./traffic-flow-brands.js"
|
||||
|
||||
export interface FlowIpMeta {
|
||||
@@ -264,7 +264,7 @@ function negative(prefix: string): FlowIpMeta {
|
||||
|
||||
export function lookupRipeCached(ip: string): FlowIpMeta | null {
|
||||
loadSqlite()
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
const trimmed = canonicalIp(ip)
|
||||
lastCandidateCount = 0
|
||||
if (!trimmed) return null
|
||||
if (isNonPublicIp(trimmed)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db, dbAll } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { mapRosInterfaceType, parseRawInterfaces } from "../modules/users/iface-type.js"
|
||||
import { canonicalIp } from "./traffic-flow-ip.js"
|
||||
import type { PlaneTopology } from "./traffic-flow-planes.js"
|
||||
|
||||
export interface FlowClientBinding {
|
||||
@@ -176,10 +177,12 @@ export function flowOursHosts(topo: FlowTopology | null | undefined): Set<string
|
||||
const ours = new Set<string>()
|
||||
if (!topo) return ours
|
||||
for (const h of topo.enHosts) {
|
||||
if (h) ours.add(h)
|
||||
const ip = canonicalIp(h)
|
||||
if (ip) ours.add(ip)
|
||||
}
|
||||
for (const h of topo.jhHosts) {
|
||||
if (h) ours.add(h)
|
||||
const ip = canonicalIp(h)
|
||||
if (ip) ours.add(ip)
|
||||
}
|
||||
return ours
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react"
|
||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
@@ -19,6 +20,40 @@ function formatBytes(n: number): string {
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function formatEndpoint(ip: string | undefined, port: number | undefined): string {
|
||||
const host = String(ip ?? "").trim()
|
||||
if (!host) return "—"
|
||||
return port ? `${host}:${port}` : host
|
||||
}
|
||||
|
||||
function packetTuple(row: FlowTalkerDto): string {
|
||||
const src = formatEndpoint(row.src, row.srcPort)
|
||||
const dst = formatEndpoint(row.dst, row.dstPort)
|
||||
return `${src} → ${dst}`
|
||||
}
|
||||
|
||||
function FlowDirectionMark({ direction }: { direction: FlowTalkerDto["direction"] }) {
|
||||
if (direction === "to_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Download: интернет → клиент">
|
||||
<ArrowDownIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (direction === "from_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Upload: клиент → интернет">
|
||||
<ArrowUpIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Транзит">
|
||||
<MinusIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TrafficFlowsDataGrid({
|
||||
rows,
|
||||
emptyHint,
|
||||
@@ -30,9 +65,16 @@ function TrafficFlowsDataGrid({
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (r) => r.clientName ?? "",
|
||||
accessorFn: (r) => r.clientName || r.clientIp || "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.clientName || "—"}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs truncate">{row.original.clientName || "—"}</span>
|
||||
{row.original.clientIp ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground truncate">{row.original.clientIp}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
@@ -43,27 +85,26 @@ function TrafficFlowsDataGrid({
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
accessorKey: "src",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.src}
|
||||
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
|
||||
</span>
|
||||
),
|
||||
id: "direction",
|
||||
accessorFn: (r) => r.direction ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Напр.</span>,
|
||||
cell: ({ row }) => <FlowDirectionMark direction={row.original.direction} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.dst}
|
||||
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
|
||||
</span>
|
||||
),
|
||||
id: "internet",
|
||||
accessorFn: (r) => r.internetPeer ?? r.dst,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интернет</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const label = formatEndpoint(r.internetPeer, r.internetPeerPort)
|
||||
const tuple = packetTuple(r)
|
||||
return (
|
||||
<span className="font-mono text-xs truncate max-w-[220px]" title={tuple}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import 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 {
|
||||
const bytes = new Uint8Array(9)
|
||||
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 >= 4), [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="Нужна при импорте .p12 на устройстве">
|
||||
<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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,254 @@
|
||||
"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 {
|
||||
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,
|
||||
}: {
|
||||
cert: IpsecCertInfoDto
|
||||
onDelete?: (cert: IpsecCertInfoDto) => void
|
||||
}) {
|
||||
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium">{cert.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{roleLabel}</Badge>
|
||||
<OriginBadge managed={cert.managed} />
|
||||
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 text-destructive"
|
||||
aria-label="Удалить сертификат"
|
||||
title="Удалить сертификат"
|
||||
onClick={() => onDelete(cert)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface IpsecServerGridProps {
|
||||
servers: IpsecServerSummaryDto[]
|
||||
onInit?: (row: IpsecServerSummaryDto) => void
|
||||
onRemove?: (row: IpsecServerSummaryDto) => void
|
||||
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||
}
|
||||
|
||||
function IpsecServerGrid({
|
||||
servers,
|
||||
onInit,
|
||||
onRemove,
|
||||
onEditPeer,
|
||||
onDeletePeer,
|
||||
onDeleteCert,
|
||||
}: 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>
|
||||
{s.peers.length > 0 ? (
|
||||
<Badge className="gap-1">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
{s.peers.length} peer
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">нет peer</Badge>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</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 }
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
export type IpsecAuthKind = "certificate" | "pre-shared-key"
|
||||
|
||||
export type IpsecUserFormState = {
|
||||
serverId: string
|
||||
name: string
|
||||
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
|
||||
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>
|
||||
<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="Пусто — сгенерируем автоматически">
|
||||
<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}` : "Иначе — выдача из пула"}
|
||||
</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 }
|
||||
@@ -0,0 +1,248 @@
|
||||
"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 cert = row.original.authMethod === "certificate"
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{cert ? (
|
||||
<BadgeCheckIcon className="size-3.5 text-info" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{cert ? "Сертификат" : "PSK"}
|
||||
</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.authMethod === "certificate" && 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -74,6 +74,14 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
|
||||
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "instagram":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" fill="#E4405F" />
|
||||
<circle cx="12" cy="12.2" r="4.1" fill="none" stroke="#fff" strokeWidth="1.8" />
|
||||
<circle cx="16.3" cy="7.7" r="1.15" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "netflix":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 концов.
|
||||
|
||||
@@ -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
@@ -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: "Администратор",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"])
|
||||
|
||||
/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */
|
||||
export const ipsecClientDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
rosId: z.string().min(1),
|
||||
serverId: z.string().min(1),
|
||||
serverName: z.string(),
|
||||
/** Управляемое имя клиента (из managed-комментария identity). */
|
||||
name: z.string(),
|
||||
authMethod: ipsecAuthMethodSchema,
|
||||
certificateName: z.string().optional(),
|
||||
commonName: z.string().optional(),
|
||||
remoteId: z.string().optional(),
|
||||
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
|
||||
staticIp: z.string().optional(),
|
||||
modeConfigName: z.string().optional(),
|
||||
peerName: z.string().optional(),
|
||||
online: z.boolean(),
|
||||
activeAddress: z.string().optional(),
|
||||
activeSince: z.string().optional(),
|
||||
disabled: z.boolean(),
|
||||
comment: z.string().optional(),
|
||||
/** Создан менеджером (managed-комментарий). */
|
||||
managed: z.boolean(),
|
||||
})
|
||||
|
||||
/** Слушатель IKEv2 — /ip/ipsec/peer (passive). */
|
||||
export const ipsecPeerDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
rosId: z.string().min(1),
|
||||
serverId: z.string().min(1),
|
||||
serverName: z.string(),
|
||||
name: z.string(),
|
||||
address: z.string().optional(),
|
||||
exchangeMode: z.string().optional(),
|
||||
passive: z.boolean().optional(),
|
||||
certificate: z.string().optional(),
|
||||
profile: z.string().optional(),
|
||||
disabled: z.boolean(),
|
||||
comment: z.string().optional(),
|
||||
managed: z.boolean(),
|
||||
})
|
||||
|
||||
export const ipsecModeConfigDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
rosId: z.string().min(1),
|
||||
serverId: z.string().min(1),
|
||||
name: z.string(),
|
||||
/** Общий пул (shared) или персональный статический адрес (per-user). */
|
||||
addressPool: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
splitDns: z.string().optional(),
|
||||
staticDns: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
managed: z.boolean(),
|
||||
})
|
||||
|
||||
export const ipsecPoolDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
rosId: z.string().min(1),
|
||||
serverId: z.string().min(1),
|
||||
name: z.string(),
|
||||
ranges: z.string(),
|
||||
comment: z.string().optional(),
|
||||
managed: z.boolean(),
|
||||
})
|
||||
|
||||
export const ipsecCertInfoDtoSchema = z.object({
|
||||
name: z.string(),
|
||||
commonName: z.string().optional(),
|
||||
keySize: z.string().optional(),
|
||||
fingerprint: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
trusted: z.boolean().optional(),
|
||||
hasPrivateKey: z.boolean().optional(),
|
||||
role: z.enum(["ca", "server", "client", "other"]).optional(),
|
||||
managed: z.boolean(),
|
||||
})
|
||||
|
||||
/** Сводка IKEv2-сервера на одном роутере. */
|
||||
export const ipsecServerSummaryDtoSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
serverName: z.string(),
|
||||
serverCountry: z.string().optional(),
|
||||
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
|
||||
initialized: z.boolean(),
|
||||
serverEndpoint: z.string().optional(),
|
||||
/** 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. */
|
||||
passphrase: z.string().min(4).optional(),
|
||||
daysValid: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export const ipsecUserPatchSchema = z.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
/** null — вернуть выдачу из пула. */
|
||||
staticIp: z.string().nullable().optional(),
|
||||
psk: z.string().min(8).optional(),
|
||||
remoteId: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const ipsecCertExportRequestSchema = z.object({
|
||||
serverId: z.union([z.string(), z.number()]),
|
||||
clientId: z.string().min(1),
|
||||
passphrase: z.string().min(4),
|
||||
})
|
||||
|
||||
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
|
||||
export const ipsecCertBundleSchema = z.object({
|
||||
user: z.string(),
|
||||
serverEndpoint: z.string().optional(),
|
||||
filename: z.string(),
|
||||
contentB64: z.string(),
|
||||
mime: z.string().default("application/x-pkcs12"),
|
||||
passphrase: z.string().optional(),
|
||||
sswanFilename: z.string().optional(),
|
||||
sswanContent: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ipsecUserCreatedSchema = z.object({
|
||||
client: ipsecClientDtoSchema,
|
||||
/** Одноразовый бандл сертификата (для cert-клиентов). */
|
||||
bundle: ipsecCertBundleSchema.optional(),
|
||||
})
|
||||
|
||||
export type IpsecAuthMethod = z.infer<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 IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
|
||||
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
|
||||
@@ -93,6 +93,10 @@ export const flowTalkerDtoSchema = z.object({
|
||||
dstAsn: z.number().int().optional(),
|
||||
clientId: z.string().optional(),
|
||||
clientName: z.string().optional(),
|
||||
clientIp: z.string().optional(),
|
||||
internetPeer: z.string().optional(),
|
||||
internetPeerPort: z.number().int().optional(),
|
||||
direction: z.enum(["to_client", "from_client", "transit"]).optional(),
|
||||
enId: z.string().optional(),
|
||||
enName: z.string().optional(),
|
||||
plane: z.string().optional(),
|
||||
@@ -312,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(),
|
||||
@@ -320,9 +332,15 @@ export const flowMapHopsDtoSchema = z.object({
|
||||
totalBytes: z.number().nonnegative().optional(),
|
||||
namedBytes: z.number().nonnegative().optional(),
|
||||
unclassifiedBytes: z.number().nonnegative().optional(),
|
||||
asnLoaded: z.boolean().optional(),
|
||||
countryLoaded: z.boolean().optional(),
|
||||
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(),
|
||||
@@ -347,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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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 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 }) },
|
||||
)
|
||||
}
|
||||
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) ~567–583). Связки «Google внутри US» в API нет — без неё UI может только гадать.
|
||||
|
||||
## Поведение на холсте
|
||||
|
||||
- Режим **Страны**, слой «Назначения» включён: как сейчас (EN → страны).
|
||||
- **Клик по стране**: она selected; справа появляется **второй столбец** сервисов этой страны; остальные страны **остаются**, но приглушаются (`opacity` как у `isVis`).
|
||||
- Рёбра раскрытия: **страна → сервис** (пунктир, те же подписи скорости). EN → страна для выбранной страны подсвечивается.
|
||||
- Повторный клик по той же стране / клик по пустому холсту / смена `destMode` → свернуть столбец.
|
||||
- Клик по другой стране → свернуть предыдущий набор и раскрыть новый.
|
||||
- Клик по сервису: selected = сервис; столбец остаётся; панель — «Сервис в {страна}».
|
||||
|
||||
Раскладка в [`lib/network-map-layout.ts`](c:/Users/shats/Dev/MikrotikManager-3/lib/network-map-layout.ts): сейчас одна колонка `x = W - MARGIN - SERVICE_COL_W/2`. При раскрытии:
|
||||
|
||||
- страны сдвигаются влево на `SERVICE_COL_W` (чтобы не налезать на Канаду/Вьетнам с скрина);
|
||||
- сервисы занимают прежний правый `x`, **по вертикали вокруг Y выбранной страны** (новый хелпер `placeCountryServiceNodes(ids, parentPos)`), не общий центр EN.
|
||||
|
||||
Доля на узле сервиса = **bytes / bytes страны** (не доля окна): иначе у DE 2% все сервисы будут «0%». В панели дополнительно можно показать долю окна.
|
||||
|
||||
Cap вложенного слоя: тот же `pickMapServices`, но `shareBase` = байты страны, cap **8**, min узлов **4**. «Прочее» внутри страны — `svc:other`.
|
||||
|
||||
## Контракт
|
||||
|
||||
В [`packages/contracts/src/traffic-flow.ts`](c:/Users/shats/Dev/MikrotikManager-3/packages/contracts/src/traffic-flow.ts) optional:
|
||||
|
||||
```ts
|
||||
countryServiceGroups: z.array(z.object({
|
||||
countryId: z.string(), // cc:us
|
||||
services: z.array(flowMapServiceDtoSchema),
|
||||
edges: z.array(flowMapServiceEdgeDtoSchema), // fromId = countryId
|
||||
paths: z.array(flowMapServicePathDtoSchema), // serviceId = nested id
|
||||
})).optional()
|
||||
```
|
||||
|
||||
id сервиса во вложении: `cc:us|svc:google` (`mapCountryServiceNodeId`), чтобы Google в US и NL не смешивались в одном payload. Группы только для стран, прошедших `pickMapServices` стран.
|
||||
|
||||
## Бэкенд
|
||||
|
||||
В том же цикле `dstAcc`: после `svcId` + `country.id` копить `nestedTotals/edges/paths` с ключом `${country.id}|${svcId}`. Рёбра вложенного слоя: `fromId = country.id` (не EN). Пути: как сервисные, `serviceId` = nested id (enId остаётся для подсветки HR→JH→EN).
|
||||
|
||||
`finalize` по каждой стране из `countriesOut.nodes`.
|
||||
|
||||
## Фронт
|
||||
|
||||
- Состояние `expandedCountryId` (не в sessionStorage).
|
||||
- `visibleNested = groups.find(g => g.countryId === expandedCountryId)`.
|
||||
- `ServiceNode` для вложенных: `destMode="services"` (бренд), подпись label, доля от страны.
|
||||
- Панель страны: текущие KPI + короткий список топ-сервисов (клик = тот же expand/select).
|
||||
- Панель сервиса: бренд + «в {countryName}», доля страны, пути `visibleNested.paths` с `serviceId`.
|
||||
- Mock: `MOCK_COUNTRY_SERVICE_GROUPS` для `cc:us` (Google / Cloudflare / AWS).
|
||||
|
||||
## Тесты
|
||||
|
||||
[`traffic-flow-map-hops.test.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.test.ts): ripe US → группа `cc:us` с брендом 8.8.8.8; кейс US+NL — сервисы не пересекают `countryId`; nested `edges.fromId` = `cc:us`, не `svc:*`.
|
||||
|
||||
Хелпер id — рядом с `mapCountryNodeId` в brands-тесте.
|
||||
|
||||
## Вне скоупа
|
||||
|
||||
- Одновременное раскрытие двух стран.
|
||||
- Географическая карта мира.
|
||||
- Отдельный порог NetFlow для вложенных сервисов (тот же %, но база = страна).
|
||||
Reference in New Issue
Block a user