Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a | ||
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd | ||
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 |
@@ -61,7 +61,16 @@ jobs:
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||
cp package.json package-lock.json "$STAGING/"
|
||||
cp package-lock.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*", "backend"]
|
||||
pkg.dependencies = {}
|
||||
pkg.devDependencies = {}
|
||||
delete pkg.scripts
|
||||
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
@@ -70,6 +79,7 @@ jobs:
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -177,6 +187,7 @@ jobs:
|
||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||
tags: |
|
||||
|
||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
||||
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
||||
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
|
||||
COPY packages/contracts packages/contracts
|
||||
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||
COPY app app
|
||||
|
||||
@@ -135,6 +135,7 @@ sequenceDiagram
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
|
||||
@@ -272,6 +273,8 @@ sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-t
|
||||
|
||||
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
|
||||
|
||||
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
|
||||
|
||||
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
|
||||
|
||||
### Прод-контейнеры
|
||||
|
||||
+36
-25
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import type { Backup, Server } from "@/lib/data"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -28,6 +27,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { requestBlob } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Stepper,
|
||||
@@ -276,8 +276,7 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, fallbackFilename: string) {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@@ -343,27 +342,39 @@ export default function BackupsPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
||||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-center gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка бэкапов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего бэкапов",
|
||||
value: backupList.length,
|
||||
icon: <HardDriveIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Авто",
|
||||
value: autoCount,
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "manual",
|
||||
label: "Вручную",
|
||||
value: manualCount,
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов охвачено",
|
||||
value: serverCount,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||||
|
||||
+71
-38
@@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -20,9 +21,10 @@ import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon, AlertCircleIcon,
|
||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -462,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* summary row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка префиксов BGP"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "Всего префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активных маршрутов",
|
||||
value: fmtNum(totalActive),
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "ebgp",
|
||||
label: "eBGP сессий",
|
||||
value: ebgpSessions,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ibgp",
|
||||
label: "iBGP сессий",
|
||||
value: ibgpSessions,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||
{/* prefixes by peer — horizontal bar chart */}
|
||||
@@ -621,11 +640,7 @@ export default function BgpPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
@@ -725,22 +740,40 @@ export default function BgpPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BGP"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий всего",
|
||||
value: sessions.length,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "established",
|
||||
label: "Established",
|
||||
value: established,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-estab",
|
||||
label: "Не установлено",
|
||||
value: notEstab,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: notEstab > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
label: "Получено префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{notEstab > 0 && (
|
||||
|
||||
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -119,42 +120,41 @@ function CertPartKpi({
|
||||
expired: CertificateDto[]
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сертификатов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: displayCerts.length,
|
||||
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
label: "Действующих",
|
||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
icon: <BadgeCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "expiring",
|
||||
label: "Истекают",
|
||||
value: expiring.length,
|
||||
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: expiring.length > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "expired",
|
||||
label: "Истёкших",
|
||||
value: expired.length,
|
||||
icon: <AlertCircleIcon className="size-4 text-red-500" />,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: expired.length > 0 ? "destructive" : "default",
|
||||
},
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ export default function CertificatesPage() {
|
||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
)
|
||||
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
|
||||
return routerCertificates.map(mockToDto)
|
||||
}, [prefsHydrated, isLive, certificates])
|
||||
|
||||
const displayServers = isLive ? serverList : mockServers
|
||||
|
||||
const scopedCerts = useMemo(() => {
|
||||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||
}, [displayCerts, selectedServerId])
|
||||
|
||||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayCerts])
|
||||
|
||||
const serverById = useMemo(() => {
|
||||
const map = new Map<string, Server>()
|
||||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
|
||||
}, [isLive, loadLive, loadAcmeSettings])
|
||||
|
||||
const expiring = useMemo(
|
||||
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[displayCerts],
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[scopedCerts],
|
||||
)
|
||||
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
|
||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayCerts.filter((c) => {
|
||||
return scopedCerts.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
|
||||
c.sans.some((s) => s.includes(q))
|
||||
)
|
||||
})
|
||||
}, [displayCerts, search, statusFilter])
|
||||
}, [scopedCerts, search, statusFilter])
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={certRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!liveReady || issueBusy}
|
||||
onClick={() => {
|
||||
setIssueStep(1)
|
||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{isLive && backendStatus === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||
|
||||
{liveReady && (
|
||||
<CertPartAcmeSettings
|
||||
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
|
||||
|
||||
<CertPartReference />
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
|
||||
<StepperContent key={s} value={s}>
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
serverList={serverList}
|
||||
serverList={displayServers}
|
||||
issueServerId={issueServerId}
|
||||
setIssueServerId={setIssueServerId}
|
||||
issueCertName={issueCertName}
|
||||
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
|
||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ACTION_COLOR,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(listData.length) },
|
||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Frame key={label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка communities"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего communities",
|
||||
value: String(listData.length),
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Активных",
|
||||
value: String(listData.filter((c) => c.enabled).length),
|
||||
icon: <CheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
label: "Стандартных",
|
||||
value: String(listData.filter((c) => c.type === "standard").length),
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Использует фильтры",
|
||||
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
|
||||
icon: <FilterIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||
{/* ── main table ── */}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
import {
|
||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
||||
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
|
||||
function ExportSheet({ open, container, onClose }: {
|
||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт Container</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт Container"
|
||||
description="RouterOS 7.4+ · /container · /interface/veth"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
filename: `${container?.name ?? "container"}.rsc`,
|
||||
code,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,27 +286,40 @@ export default function ContainersPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка контейнеров"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: routerContainers.length,
|
||||
icon: <BoxIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "running",
|
||||
label: "Running",
|
||||
value: running,
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "stopped",
|
||||
label: "Stopped",
|
||||
value: stopped,
|
||||
icon: <StopCircleIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
label: "Ошибок",
|
||||
value: errors,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: "text-destructive",
|
||||
variant: errors > 0 ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
||||
@@ -47,49 +45,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
|
||||
}: {
|
||||
label: string; value: string; unit?: string; delta?: string
|
||||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||||
icon?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
{icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className="size-10.5 text-muted-foreground"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
|
||||
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
|
||||
</div>
|
||||
{delta ? (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||||
{delta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{spark && spark.length > 1 ? (
|
||||
<div className="absolute right-4 bottom-4 opacity-60">
|
||||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||||
</div>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
@@ -725,49 +680,53 @@ export default function DashboardPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн"
|
||||
value={dashboardKpi.servers.value}
|
||||
unit={dashboardKpi.servers.unit}
|
||||
delta={dashboardKpi.servers.delta}
|
||||
deltaDir={dashboardKpi.servers.deltaDir}
|
||||
spark={dashboardKpi.servers.spark}
|
||||
sparkColor={dashboardKpi.servers.sparkColor}
|
||||
icon={<ServerIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные фильтры"
|
||||
value={dashboardKpi.filters.value}
|
||||
unit={dashboardKpi.filters.unit}
|
||||
delta={dashboardKpi.filters.delta}
|
||||
deltaDir={dashboardKpi.filters.deltaDir}
|
||||
spark={dashboardKpi.filters.spark}
|
||||
sparkColor={dashboardKpi.filters.sparkColor}
|
||||
icon={<FilterIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="BGP-префиксы"
|
||||
value={dashboardKpi.bgp.value}
|
||||
unit={dashboardKpi.bgp.unit}
|
||||
delta={dashboardKpi.bgp.delta}
|
||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
||||
spark={dashboardKpi.bgp.spark}
|
||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
||||
icon={<GitMergeIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные алерты"
|
||||
value={dashboardKpi.alerts.value}
|
||||
unit={dashboardKpi.alerts.unit}
|
||||
delta={dashboardKpi.alerts.delta}
|
||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||||
spark={dashboardKpi.alerts.spark}
|
||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||||
icon={<BellIcon aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка дашборда"
|
||||
items={[
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы онлайн",
|
||||
value: dashboardKpi.servers.unit
|
||||
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
|
||||
: dashboardKpi.servers.value,
|
||||
hint: dashboardKpi.servers.delta,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Активные фильтры",
|
||||
value: dashboardKpi.filters.unit
|
||||
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
|
||||
: dashboardKpi.filters.value,
|
||||
hint: dashboardKpi.filters.delta,
|
||||
icon: <FilterIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "bgp",
|
||||
label: "BGP-префиксы",
|
||||
value: dashboardKpi.bgp.unit
|
||||
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
|
||||
: dashboardKpi.bgp.value,
|
||||
hint: dashboardKpi.bgp.delta,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
{
|
||||
id: "alerts",
|
||||
label: "Активные алерты",
|
||||
value: dashboardKpi.alerts.unit
|
||||
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
|
||||
: dashboardKpi.alerts.value,
|
||||
hint: dashboardKpi.alerts.delta,
|
||||
icon: <BellIcon className="size-4" />,
|
||||
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
|
||||
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Latency chart + Events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||||
|
||||
@@ -4,10 +4,9 @@ import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
type SchedulerJobGridRow,
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "traffic") {
|
||||
const t = snap as TrafficRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{t.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||
) : null}
|
||||
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_resources") {
|
||||
const u = snap as ResourcesRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{u.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||
) : null}
|
||||
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "servers_rest_ping") {
|
||||
const s = snap as ServersRestPingRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{s.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||
) : null}
|
||||
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_ping") {
|
||||
const p = snap as PingRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{p.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||
) : null}
|
||||
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_speed") {
|
||||
const s = snap as SpeedScheduledRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||
</p>
|
||||
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "gre_bgp") {
|
||||
const g = snap as GreBgpSnapshotRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{g.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||
) : null}
|
||||
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{g.errors?.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||
{g.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "internet_path") {
|
||||
const p = snap as InternetPathRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Снимок internet-path на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "certificates_renew") {
|
||||
const c = snap as CertificatesRenewRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{c.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||
) : null}
|
||||
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{c.errors.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||
{c.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "backups") {
|
||||
const b = snap as BackupsRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{b.errors?.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||
{b.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Снимок на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{a.errors?.length ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="space-y-1 text-xs">
|
||||
<AlertDescription className="flex flex-col gap-1 text-xs">
|
||||
{a.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</Alert>
|
||||
) : null}
|
||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
||||
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
||||
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
||||
<dd>
|
||||
<span className="font-medium">{jobTitle}</span>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
||||
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
||||
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
||||
<dd className="tabular-nums">
|
||||
<span className="font-mono">{r.durationMs}</span> мс
|
||||
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
||||
<dd>
|
||||
<Badge
|
||||
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
</div>
|
||||
</dl>
|
||||
{r.error ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
||||
<pre
|
||||
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
||||
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||
{snapshot ? (
|
||||
<SnapshotTables snap={snapshot} />
|
||||
@@ -967,13 +967,13 @@ export default function DataCollectionPage() {
|
||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||
? "По сохранённым задачам планировщика"
|
||||
: "По переключателям на этой странице",
|
||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <CalendarClockIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Сейчас выполняется",
|
||||
value: String(runningJobsCount),
|
||||
sub: "Фоновые прогоны планировщика",
|
||||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
||||
icon: <LoaderCircleIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Трафик — последний сбор",
|
||||
@@ -982,16 +982,16 @@ export default function DataCollectionPage() {
|
||||
: "—",
|
||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||
icon: trafficCollector?.lastError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
<XCircleIcon className="size-4" />
|
||||
) : (
|
||||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
||||
<CheckCircleIcon className="size-4" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Журнал (в списке)",
|
||||
value: String(schedulerRuns.length),
|
||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||||
icon: <DatabaseIcon className="size-4 text-sky-500" />,
|
||||
icon: <DatabaseIcon className="size-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1064,22 +1064,32 @@ export default function DataCollectionPage() {
|
||||
|
||||
{isLive && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сбора данных"
|
||||
items={stats.map((s, i) => ({
|
||||
id: `dc-${i}`,
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
hint: s.sub,
|
||||
icon: s.icon,
|
||||
iconClassName:
|
||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||
? "text-destructive"
|
||||
: s.label === "Сейчас выполняется" && runningJobsCount > 0
|
||||
? "text-warning"
|
||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||
? "text-destructive"
|
||||
: s.label === "Трафик — последний сбор"
|
||||
? "text-success"
|
||||
: "text-muted-foreground",
|
||||
variant:
|
||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||
? "destructive" as const
|
||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||
? "warning" as const
|
||||
: "default" as const,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<DataPageCard>
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
@@ -1090,7 +1100,7 @@ export default function DataCollectionPage() {
|
||||
</div>
|
||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||
<Separator />
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||
@@ -1142,7 +1152,7 @@ export default function DataCollectionPage() {
|
||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||
Сохранить настройки планировщика
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||
<p>
|
||||
Трафик — последний сбор:{" "}
|
||||
{trafficCollector?.lastCollectedAt
|
||||
@@ -1201,6 +1211,8 @@ export default function DataCollectionPage() {
|
||||
</div>
|
||||
</DataPageCard>
|
||||
|
||||
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||
|
||||
<OpsPanel
|
||||
title="Журнал прогонов"
|
||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||
|
||||
+153
-226
@@ -35,6 +35,9 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
||||
tunnelsList: GreTunnel[]
|
||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const singleRuleset = useMemo(
|
||||
() => rulesets.filter(r => r.serverId === serverId),
|
||||
[rulesets, serverId],
|
||||
)
|
||||
const server = serversList.find(s => s.id === serverId)
|
||||
const server = serversList.find(s => s.id === serverId)
|
||||
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
||||
|
||||
const config = useMemo(
|
||||
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
||||
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
||||
)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(config).catch(() => {})
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
|
||||
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCodeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">RouterOS config</span>
|
||||
{server && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
<Flag code={server.country} size={11} />
|
||||
<span className="font-mono">{server.name}</span>
|
||||
<span>· {totalRules} правил</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
|
||||
</div>
|
||||
|
||||
{/* code */}
|
||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
|
||||
{config.split("\n").map((line, i) => {
|
||||
const trimmed = line.trimStart()
|
||||
const cls =
|
||||
// section dividers
|
||||
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
|
||||
? "text-[#444c56]" :
|
||||
// inline comments inside rule body
|
||||
trimmed.startsWith("# ")
|
||||
? "text-[#8b949e]" :
|
||||
// RouterOS scripting keywords
|
||||
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
|
||||
? "text-[#d2a8ff]" :
|
||||
// :if identity branch / closing brace
|
||||
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
|
||||
? "text-[#ff7b72] font-semibold" :
|
||||
// filter rule add command
|
||||
trimmed.startsWith("/routing filter rule")
|
||||
? "text-[#79c0ff]" :
|
||||
// rule body: if/else if branches
|
||||
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
|
||||
? "text-[#ff7b72]" :
|
||||
// rule body: blackhole action
|
||||
trimmed.startsWith("set type blackhole")
|
||||
? "text-[#ff7b72] font-semibold" :
|
||||
// rule body: set actions
|
||||
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
||||
? "text-[#a5d6ff]" :
|
||||
// rule body: accept / rule close
|
||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
||||
? "text-[#79c0ff]" :
|
||||
// named params
|
||||
trimmed.match(/^(chain|comment|bgp-communities)=/)
|
||||
? "text-[#a5d6ff]" :
|
||||
"text-[#c9d1d9]"
|
||||
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
<p>1. Скопируйте скрипт в буфер обмена</p>
|
||||
<p>3. Вставьте в терминал и нажмите Enter</p>
|
||||
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
|
||||
<p>4. Скрипт сам определит свои правила по identity</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
|
||||
<Button onClick={handleCopy} className="flex-1">
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано!" : "Скопировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="RouterOS config"
|
||||
description={
|
||||
server
|
||||
? `${server.name} · ${totalRules} правил`
|
||||
: "Экспорт правил фильтрации"
|
||||
}
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "RouterOS",
|
||||
filename: `filters-${server?.name ?? "server"}.rsc`,
|
||||
code: config,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1470,6 +1403,25 @@ export default function FiltersPage() {
|
||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||
|
||||
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
allServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
|
||||
}))
|
||||
), [allServers, rulesets])
|
||||
|
||||
const handleSelectServer = useCallback((id: string) => {
|
||||
setSelectedServerId(id)
|
||||
setSearch("")
|
||||
}, [])
|
||||
|
||||
const currentRules = useMemo(
|
||||
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
||||
[rulesets, selectedServerId],
|
||||
@@ -1635,142 +1587,117 @@ export default function FiltersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLive && liveLoadState === "error" && (
|
||||
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
|
||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего правил</span>
|
||||
<span className="font-semibold tabular-nums">{totalRules}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||
if (bhTotal === 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
||||
⊘ {bhTotal} blackhole
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||||
|
||||
{allServers.map(s => {
|
||||
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
|
||||
const active = selectedServerId === s.id
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
!s.enabled && !active && "opacity-40",
|
||||
)}>
|
||||
<StatusDot status={s.status} />
|
||||
<Flag code={s.country} size={12} />
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<TypeChip type={s.type} />
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
||||
)}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── toolbar ── */}
|
||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
||||
value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRules.length !== currentRules.length
|
||||
? `${filteredRules.length} из ${currentRules.length} правил`
|
||||
: `${currentRules.length} правил`
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={filterRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── main content ── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<>
|
||||
{isLive && liveLoadState === "error" && (
|
||||
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
||||
value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего правил</span>
|
||||
<span className="font-semibold tabular-nums">{totalRules}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||
if (bhTotal === 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
||||
⊘ {bhTotal} blackhole
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRules.length !== currentRules.length
|
||||
? `${filteredRules.length} из ${currentRules.length} правил`
|
||||
: `${currentRules.length} правил`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
||||
@@ -1886,7 +1813,7 @@ export default function FiltersPage() {
|
||||
</DataPageCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<RuleSheet
|
||||
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
||||
@@ -1927,6 +1854,6 @@ export default function FiltersPage() {
|
||||
recRoutesByServer={recRoutesByServer}
|
||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+922
-210
File diff suppressed because it is too large
Load Diff
+155
-149
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -26,14 +26,16 @@ import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
|
||||
DatabaseIcon,
|
||||
} from "lucide-react"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,11 +242,11 @@ export default function GrePage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||
@@ -293,6 +295,25 @@ export default function GrePage() {
|
||||
[isLive, displayTunnels],
|
||||
)
|
||||
|
||||
const greRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayTunnels])
|
||||
|
||||
const scopedTunnels = useMemo(() => {
|
||||
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
|
||||
return displayTunnels.filter((t) => t.serverId === selectedServerId)
|
||||
}, [displayTunnels, selectedServerId])
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
@@ -341,7 +362,7 @@ export default function GrePage() {
|
||||
}, [dataError])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayTunnels.filter((t) => {
|
||||
return scopedTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -354,60 +375,68 @@ export default function GrePage() {
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search, displayTunnels, serverById])
|
||||
}, [tabFilter, search, scopedTunnels, serverById])
|
||||
|
||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
toast.success("Команды скопированы")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
const greExportCode = useMemo(
|
||||
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||
[codePreviewTunnel, serverById],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={greRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && dataLoading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Legacy banner */}
|
||||
@@ -424,27 +453,39 @@ export default function GrePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка GRE"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Всего туннелей",
|
||||
value: displayTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активно",
|
||||
value: upCount,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
label: "Защищены IPsec",
|
||||
value: ipsecCount,
|
||||
icon: <LockIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "pools",
|
||||
label: "IP-пулов",
|
||||
value: displayPools.length,
|
||||
icon: <DatabaseIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Page tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
@@ -548,85 +589,50 @@ export default function GrePage() {
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => handleCopy(code)}
|
||||
>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* meta strip */}
|
||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
||||
{codePreviewTunnel.ipsec && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* code block */}
|
||||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isSection = isComment && line.includes("──")
|
||||
const isKey = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isSection ? "text-muted-foreground/60"
|
||||
: isComment ? "text-muted-foreground"
|
||||
: isKey ? "text-sky-400/90"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать команды"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<CodeExportSheet
|
||||
open={!!codePreviewTunnel}
|
||||
onClose={() => setCodePreviewTunnel(null)}
|
||||
title={codePreviewTunnel?.name ?? "GRE"}
|
||||
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "RouterOS",
|
||||
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||
code: greExportCode,
|
||||
},
|
||||
]}
|
||||
beforeCode={
|
||||
codePreviewTunnel ? (
|
||||
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">
|
||||
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||
{" → "}
|
||||
{codePreviewTunnel.remoteAddress}
|
||||
</span>
|
||||
{codePreviewTunnel.ipsec ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LockIcon className="size-3" />
|
||||
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||
@@ -839,6 +845,6 @@ export default function GrePage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-17
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
<AuthGuard>
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
|
||||
+468
-17
@@ -17,11 +17,14 @@ import {
|
||||
buildServerResourceMap,
|
||||
buildWanJhEdges,
|
||||
computeNetworkMapLayout,
|
||||
NETWORK_MAP_H,
|
||||
NETWORK_MAP_LAYOUT_REVISION,
|
||||
NETWORK_MAP_PIPELINE_Y,
|
||||
NETWORK_MAP_W,
|
||||
findServerByGreRemote,
|
||||
greSourceWanIndexOnMap,
|
||||
greTunnelProbe,
|
||||
placeServiceNodes,
|
||||
type GreMapEdge,
|
||||
type WanJhEdge,
|
||||
} from "@/lib/network-map-layout"
|
||||
@@ -41,6 +44,16 @@ import {
|
||||
wanJhEdgeMapKey,
|
||||
type GreSpeedProbeSnapshot,
|
||||
} from "@/lib/map-gre-speed-probe"
|
||||
import {
|
||||
formatNetflowDir,
|
||||
formatNetflowRate,
|
||||
hopHasRate,
|
||||
matchNetflowForGreEdge,
|
||||
matchNetflowForWan,
|
||||
type MatchedNetflowHop,
|
||||
} from "@/lib/map-netflow-hops"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -226,8 +239,8 @@ function Sparkline({ history }: { history: number[] }) {
|
||||
|
||||
// ─── Canvas dimensions ────────────────────────────────────────────────────────
|
||||
|
||||
const W = 1060
|
||||
const H = 580
|
||||
const W = NETWORK_MAP_W
|
||||
const H = NETWORK_MAP_H
|
||||
const ZOOM_MIN = 0.2
|
||||
const ZOOM_MAX = 6
|
||||
|
||||
@@ -262,6 +275,24 @@ const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const MOCK_MAP_SERVICES: FlowMapService[] = [
|
||||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 },
|
||||
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 },
|
||||
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
|
||||
]
|
||||
|
||||
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 },
|
||||
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 },
|
||||
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000 },
|
||||
]
|
||||
|
||||
function serviceSharePct(share: number): string {
|
||||
return `${Math.round(share * 100)}%`
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pingColor(ms: number | null) {
|
||||
@@ -420,6 +451,53 @@ function GreEdgeMetricBadge({
|
||||
)
|
||||
}
|
||||
|
||||
/** Живой поток NetFlow (не ёмкость канала / не BT). */
|
||||
function NetflowRateBadge({
|
||||
mx,
|
||||
my,
|
||||
hop,
|
||||
onOpen,
|
||||
}: {
|
||||
mx: number
|
||||
my: number
|
||||
hop: MatchedNetflowHop
|
||||
onOpen?: (e: React.MouseEvent<SVGElement>) => void
|
||||
}) {
|
||||
const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||
const bw = showDir ? 86 : 72
|
||||
const bh = showDir ? 32 : 20
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${mx},${my})`}
|
||||
style={{ cursor: onOpen ? "pointer" : "default" }}
|
||||
onPointerDown={(e) => { e.stopPropagation() }}
|
||||
onClick={(e) => { e.stopPropagation(); onOpen?.(e) }}
|
||||
>
|
||||
<title>
|
||||
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
|
||||
</title>
|
||||
<rect
|
||||
x={-bw / 2}
|
||||
y={-bh / 2}
|
||||
width={bw}
|
||||
height={bh}
|
||||
rx="6"
|
||||
fill="rgba(6,13,26,0.94)"
|
||||
stroke="#34d399"
|
||||
strokeWidth="1.15"
|
||||
/>
|
||||
<text textAnchor="middle" y={showDir ? "-4" : "4"} fontFamily="ui-monospace,monospace">
|
||||
<tspan fill="#6ee7b7" fontSize="8" fontWeight="700">{formatNetflowRate(hop)}</tspan>
|
||||
</text>
|
||||
{showDir && (
|
||||
<text textAnchor="middle" y="10" fontFamily="ui-monospace,monospace">
|
||||
<tspan fill="#34d399" fontSize="6.5" fontWeight="600">{formatNetflowDir(hop)}</tspan>
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
||||
const ss = STATUS_STYLE[n.status]
|
||||
const ts = TYPE_STYLE[n.type]
|
||||
@@ -552,6 +630,74 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceNode({
|
||||
label,
|
||||
share,
|
||||
x,
|
||||
y,
|
||||
isSel,
|
||||
isVis,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
share: number
|
||||
x: number
|
||||
y: number
|
||||
isSel: boolean
|
||||
isVis: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const bw = 86
|
||||
const bh = 58
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x},${y})`}
|
||||
style={{ cursor: "pointer", transition: "opacity 0.25s" }}
|
||||
opacity={isVis ? 1 : 0.08}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
||||
{isSel && (
|
||||
<rect
|
||||
x={-bw / 2 - 6}
|
||||
y={-bh / 2 - 6}
|
||||
width={bw + 12}
|
||||
height={bh + 12}
|
||||
rx="12"
|
||||
fill="none"
|
||||
stroke="rgba(56,189,248,0.55)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={-bw / 2}
|
||||
y={-bh / 2}
|
||||
width={bw}
|
||||
height={bh}
|
||||
rx="10"
|
||||
fill="#08202c"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={isSel ? 2.2 : 1.4}
|
||||
/>
|
||||
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }}
|
||||
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
|
||||
>
|
||||
<ServiceBrandIcon label={label} size={22} />
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
||||
{label}
|
||||
</text>
|
||||
<text textAnchor="middle" y="25" fontSize="7.5" fill="#67e8f9" fontFamily="ui-monospace,monospace">
|
||||
{serviceSharePct(share)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
||||
x: number; y: number
|
||||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||||
@@ -667,13 +813,14 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
|
||||
|
||||
const MM_W = 172, MM_H = 94
|
||||
|
||||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: {
|
||||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
|
||||
pan: { x: number; y: number }; zoom: number
|
||||
nodes: (Server & { x: number; y: number })[]
|
||||
greEdges: GreMapEdge[]
|
||||
satPos: Record<string, { x: number; y: number }[]>
|
||||
wanJhEdges: WanJhEdge[]
|
||||
homeRouters: Server[]
|
||||
servicePos: Record<string, { x: number; y: number }>
|
||||
onClose: () => void
|
||||
onPan: (x: number, y: number) => void
|
||||
}) {
|
||||
@@ -726,6 +873,10 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
||||
fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
|
||||
))
|
||||
)}
|
||||
{Object.entries(servicePos).map(([id, p]) => (
|
||||
<rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
|
||||
fill="#08202c" stroke="#22d3ee" strokeWidth="3" opacity="0.85" />
|
||||
))}
|
||||
{/* viewport rect */}
|
||||
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
|
||||
fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
|
||||
@@ -784,6 +935,10 @@ export default function NetworkMapPage() {
|
||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
|
||||
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
|
||||
const [mapSharePct, setMapSharePct] = useState(5)
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
@@ -875,6 +1030,10 @@ export default function NetworkMapPage() {
|
||||
setMapGreTunnels(mockGreTunnels)
|
||||
setSpeedProbes([])
|
||||
setGreResolvedIpv4ByHost({})
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
setMapSharePct(5)
|
||||
setDataError(null)
|
||||
})
|
||||
return
|
||||
@@ -902,6 +1061,7 @@ export default function NetworkMapPage() {
|
||||
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | null>(null)
|
||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
@@ -935,11 +1095,59 @@ export default function NetworkMapPage() {
|
||||
const [filter, setFilter] = useState<FilterKey>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [showPingBadges, setShowPingBadges] = useState(true)
|
||||
const [showNetflow, setShowNetflow] = useState(true)
|
||||
const [showServices, setShowServices] = useState(true)
|
||||
const [showAnimDots, setShowAnimDots] = useState(true)
|
||||
const [showMinimap, setShowMinimap] = useState(true)
|
||||
const [showHints, setShowHints] = useState(false)
|
||||
const [showLayers, setShowLayers] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!useLiveData) {
|
||||
queueMicrotask(() => {
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
setMapSharePct(5)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!showNetflow && !showServices) {
|
||||
queueMicrotask(() => {
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
let ac: AbortController | null = null
|
||||
const tick = () => {
|
||||
ac?.abort()
|
||||
ac = new AbortController()
|
||||
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setMapHops(res.hops ?? [])
|
||||
setMapServices(res.services ?? [])
|
||||
setMapServiceEdges(res.serviceEdges ?? [])
|
||||
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
const name = err instanceof Error ? err.name : ""
|
||||
if (name === "AbortError") return
|
||||
})
|
||||
}
|
||||
tick()
|
||||
const id = window.setInterval(tick, 4000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
ac?.abort()
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [useLiveData, showNetflow, showServices, apiFetch])
|
||||
|
||||
const effectiveSatPos = useMemo(() => {
|
||||
const out: Record<string, { x: number; y: number }[]> = {}
|
||||
mapServers
|
||||
@@ -1100,6 +1308,37 @@ export default function NetworkMapPage() {
|
||||
return out
|
||||
}, [greEdges])
|
||||
|
||||
const netflowByGreKey = useMemo(() => {
|
||||
const m = new Map<string, MatchedNetflowHop>()
|
||||
if (!showNetflow) return m
|
||||
for (const e of greEdges) {
|
||||
const hop = matchNetflowForGreEdge(e, mapHops)
|
||||
if (hopHasRate(hop)) m.set(greEdgeKey(e), hop)
|
||||
}
|
||||
return m
|
||||
}, [greEdges, mapHops, showNetflow])
|
||||
|
||||
const netflowByWanKey = useMemo(() => {
|
||||
const m = new Map<string, MatchedNetflowHop>()
|
||||
if (!showNetflow) return m
|
||||
for (const home of homeRouters) {
|
||||
for (const [wIdx, wan] of (home.wanUplinks ?? []).entries()) {
|
||||
const hop = matchNetflowForWan(home.id, wan.iface, mapHops)
|
||||
if (!hopHasRate(hop)) continue
|
||||
m.set(`${home.id}\t${wIdx}`, hop)
|
||||
for (const e of wanJhEdges) {
|
||||
if (e.homeId === home.id && e.wanIdx === wIdx) m.set(wanJhEdgeMapKey(e), hop)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
||||
|
||||
const visibleMapServices = showServices ? mapServices : []
|
||||
const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
|
||||
visibleMapServices.some((s) => s.id === e.toId),
|
||||
) : []
|
||||
|
||||
const nodes = mapServers
|
||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
||||
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
||||
@@ -1112,6 +1351,14 @@ export default function NetworkMapPage() {
|
||||
})
|
||||
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||
|
||||
const servicePosById = placeServiceNodes(
|
||||
visibleMapServices.map((s) => s.id),
|
||||
mapServers
|
||||
.filter((s) => s.type === "exit-node")
|
||||
.map((s) => nodePosById[s.id])
|
||||
.filter((p): p is { x: number; y: number } => Boolean(p)),
|
||||
)
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
@@ -1186,7 +1433,7 @@ export default function NetworkMapPage() {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
|
||||
@@ -1274,7 +1521,7 @@ export default function NetworkMapPage() {
|
||||
const moved = dragRef.current?.moved ?? false
|
||||
dragRef.current = null
|
||||
setIsDragging(false)
|
||||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
}
|
||||
|
||||
// ── Node drag start ──────────────────────────────────────────────────────
|
||||
@@ -1306,12 +1553,20 @@ export default function NetworkMapPage() {
|
||||
// ── Side panel ────────────────────────────────────────────────────────────
|
||||
function selectServer(s: Server) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(prev => prev?.id === s.id ? null : s)
|
||||
setSelWanIdx(null)
|
||||
setHoveredId(null)
|
||||
}
|
||||
function selectService(svc: FlowMapService) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelected(null)
|
||||
setSelWanIdx(null)
|
||||
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
||||
}
|
||||
function selectWan(s: Server, wanIdx: number) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(s)
|
||||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
||||
}
|
||||
@@ -1321,6 +1576,7 @@ export default function NetworkMapPage() {
|
||||
const home = mapServers.find((s) => s.id === edge.homeId)
|
||||
if (!home) return
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(home)
|
||||
setSelWanIdx(edge.wanIdx)
|
||||
setHoveredId(null)
|
||||
@@ -1461,6 +1717,8 @@ export default function NetworkMapPage() {
|
||||
onMouseLeave={() => setShowLayers(false)}>
|
||||
{([
|
||||
{ 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: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
|
||||
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
||||
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
||||
@@ -1485,6 +1743,11 @@ export default function NetworkMapPage() {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
|
||||
{mapSharePct > 0
|
||||
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
|
||||
</p>
|
||||
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && (
|
||||
<div className="border-t border-border/50 mt-1 pt-1">
|
||||
<button
|
||||
@@ -1588,17 +1851,27 @@ export default function NetworkMapPage() {
|
||||
tBadge,
|
||||
normalPx,
|
||||
)
|
||||
const flowHop = netflowByGreKey.get(edgeId)
|
||||
const flowPos = edgeBadgePosition(
|
||||
e.from.x,
|
||||
e.from.y,
|
||||
e.to.x,
|
||||
e.to.y,
|
||||
tBadge,
|
||||
-normalPx - (normalPx === 0 ? 22 : 0),
|
||||
)
|
||||
function openGreDetail(ev: React.MouseEvent<SVGElement>) {
|
||||
ev.stopPropagation()
|
||||
setSelectedGreEdge(e)
|
||||
setSelected(null)
|
||||
setSelectedService(null)
|
||||
setSelWanIdx(null)
|
||||
}
|
||||
return (
|
||||
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
||||
<line
|
||||
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
||||
stroke={ts.stroke} strokeWidth="1.5"
|
||||
stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
|
||||
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
||||
opacity={ts.opacity}
|
||||
/>
|
||||
@@ -1632,6 +1905,14 @@ export default function NetworkMapPage() {
|
||||
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
||||
/>
|
||||
)}
|
||||
{showNetflow && hopHasRate(flowHop) && (
|
||||
<NetflowRateBadge
|
||||
mx={flowPos.mx}
|
||||
my={flowPos.my}
|
||||
hop={flowHop}
|
||||
onOpen={openGreDetail}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -1663,14 +1944,16 @@ export default function NetworkMapPage() {
|
||||
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
||||
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
||||
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
||||
const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18)
|
||||
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||||
const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge))
|
||||
return (
|
||||
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
||||
style={{ transition: "opacity 0.3s" }}>
|
||||
<line
|
||||
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
||||
stroke={color}
|
||||
strokeWidth={edge.active ? 2 : 1.2}
|
||||
strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
|
||||
strokeDasharray={edge.active ? "none" : "5 4"}
|
||||
opacity={edge.active ? 0.7 : 0.4}
|
||||
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
|
||||
@@ -1710,6 +1993,58 @@ export default function NetworkMapPage() {
|
||||
}
|
||||
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
||||
})()}
|
||||
{showNetflow && hopHasRate(wanFlow) && (
|
||||
<NetflowRateBadge
|
||||
mx={flowPos.mx}
|
||||
my={flowPos.my}
|
||||
hop={wanFlow}
|
||||
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN/JH → destination services ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
const to = servicePosById[edge.toId]
|
||||
if (!from || !to) return null
|
||||
const hop: MatchedNetflowHop = {
|
||||
bytes: edge.bytes,
|
||||
bps: edge.bps,
|
||||
bpsFwd: edge.bpsFwd,
|
||||
bpsRev: edge.bpsRev,
|
||||
}
|
||||
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
|
||||
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
|
||||
return (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
|
||||
strokeDasharray="5 5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -1760,6 +2095,26 @@ export default function NetworkMapPage() {
|
||||
}).filter(Boolean)
|
||||
})}
|
||||
|
||||
{visibleMapServices.map((svc) => {
|
||||
const pos = servicePosById[svc.id]
|
||||
if (!pos) return null
|
||||
return (
|
||||
<ServiceNode
|
||||
key={svc.id}
|
||||
label={svc.label}
|
||||
share={svc.share}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
isSel={selectedService?.id === svc.id}
|
||||
isVis
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||
selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
@@ -1767,7 +2122,7 @@ export default function NetworkMapPage() {
|
||||
|
||||
{/* ── Legend (viewport-fixed) ── */}
|
||||
<g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
|
||||
<rect width="140" height="224" rx="8"
|
||||
<rect width="140" height="250" rx="8"
|
||||
fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
<text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
|
||||
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
|
||||
@@ -1796,12 +2151,17 @@ export default function NetworkMapPage() {
|
||||
</g>
|
||||
))}
|
||||
|
||||
<line x1="10" y1="166" x2="130" y2="166" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
<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>
|
||||
</g>
|
||||
|
||||
<text x="10" y="180" fontSize="7.5" fontWeight="700" fill="#475569"
|
||||
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
|
||||
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="#475569"
|
||||
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
|
||||
{WAN_COLORS.slice(0, 2).map((c, i) => (
|
||||
<g key={i} transform={`translate(10, ${190 + i * 14})`}>
|
||||
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
|
||||
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
|
||||
<text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
|
||||
WAN{i + 1}
|
||||
@@ -1857,6 +2217,7 @@ export default function NetworkMapPage() {
|
||||
satPos={effectiveSatPos}
|
||||
wanJhEdges={visibleWanJhEdges}
|
||||
homeRouters={homeRouters}
|
||||
servicePos={servicePosById}
|
||||
onClose={() => setShowMinimap(false)}
|
||||
onPan={(x, y) => setPan({ x, y })}
|
||||
/>
|
||||
@@ -1885,7 +2246,7 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Side panel (узел или выбранное GRE-ребро) ── */}
|
||||
{(selectedGreEdge || selected) && (
|
||||
{(selectedGreEdge || selected || selectedService) && (
|
||||
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
|
||||
{selectedGreEdge ? (
|
||||
<>
|
||||
@@ -1976,7 +2337,7 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{bwMon ? "TX / RX (BT)" : "Скорость (модель)"}
|
||||
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
|
||||
</span>
|
||||
<span className="text-xs font-mono font-semibold text-sky-400">
|
||||
{merged.dlMbps != null && merged.ulMbps != null
|
||||
@@ -1984,10 +2345,23 @@ export default function NetworkMapPage() {
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">Поток (NetFlow)</span>
|
||||
<span className="text-xs font-mono font-semibold text-emerald-400">
|
||||
{(() => {
|
||||
const hop = netflowByGreKey.get(selectedEdgeId)
|
||||
if (!hopHasRate(hop)) return "—"
|
||||
return hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||
? formatNetflowDir(hop)
|
||||
: formatNetflowRate(hop)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
||||
{merged.hasSpeedMonitor
|
||||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам."
|
||||
: "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}
|
||||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
|
||||
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
|
||||
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2078,6 +2452,62 @@ export default function NetworkMapPage() {
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
) : selectedService ? (
|
||||
<>
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||
<div className="mt-0.5">
|
||||
<ServiceBrandIcon label={selectedService.label} size={22} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono font-semibold text-sm truncate">{selectedService.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Конечный сервис · {selectedService.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(selectedService.share)}</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: selectedService.bytes,
|
||||
bps: selectedService.bps,
|
||||
bpsFwd: selectedService.bps,
|
||||
bpsRev: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">С узлов</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
||||
const src = mapServers.find((s) => s.id === e.fromId)
|
||||
return (
|
||||
<div key={`${e.fromId}|${e.toId}`} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono truncate">{src?.name ?? e.fromId}</span>
|
||||
<span className="font-mono text-emerald-400 tabular-nums">
|
||||
{formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||
@@ -2168,12 +2598,26 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
|
||||
["BW", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
|
||||
["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{k}</span>
|
||||
<span className="text-[10px] font-mono">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`)
|
||||
if (!hopHasRate(hop)) return null
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Поток</span>
|
||||
<span className="text-[10px] font-mono text-emerald-400">
|
||||
{hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||
? formatNetflowDir(hop)
|
||||
: formatNetflowRate(hop)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{myEdges.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40">
|
||||
@@ -2230,9 +2674,11 @@ export default function NetworkMapPage() {
|
||||
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||
const pc = pingColor(merged.pingMs)
|
||||
const greFlow = netflowByGreKey.get(tunnelPanelKey)
|
||||
const showMetrics =
|
||||
merged.pingMs != null ||
|
||||
(merged.dlMbps != null && merged.ulMbps != null)
|
||||
(merged.dlMbps != null && merged.ulMbps != null) ||
|
||||
hopHasRate(greFlow)
|
||||
return (
|
||||
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -2272,6 +2718,11 @@ export default function NetworkMapPage() {
|
||||
↓{merged.dlMbps} ↑{merged.ulMbps}
|
||||
</span>
|
||||
)}
|
||||
{hopHasRate(greFlow) && (
|
||||
<span className="text-[9px] ml-1.5 text-emerald-400">
|
||||
{formatNetflowRate(greFlow)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+160
-142
@@ -7,16 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
|
||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
@@ -706,7 +710,7 @@ function InterfacesTab({
|
||||
}, [grouped, isLive])
|
||||
|
||||
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
||||
const canOptimizeLive = isLive && filterServerId !== "all"
|
||||
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
|
||||
const uniqueLiveFallbackOpt = useMemo(() => {
|
||||
const out: Record<string, number> = {}
|
||||
const byRouter: Record<string, OspfItem[]> = {}
|
||||
@@ -733,13 +737,14 @@ function InterfacesTab({
|
||||
const ra = readStoredRouteOptimizerSettings()
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json() as BackendOspfOptimizeResponse
|
||||
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||
backendUrl,
|
||||
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
},
|
||||
)
|
||||
const byKey: Record<string, number> = {}
|
||||
data.interfaces.forEach((row) => {
|
||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||
@@ -919,20 +924,33 @@ function NeighborsTab({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка соседей OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "neighbors",
|
||||
label: "Всего соседей",
|
||||
value: neighbors.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "full",
|
||||
label: "Full",
|
||||
value: fullCount,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-full",
|
||||
label: "Не Full",
|
||||
value: neighbors.length - fullCount,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{graphNodes.length > 0 && (
|
||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||
@@ -1042,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
||||
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BFD"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий BFD",
|
||||
value: sessions.length,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Up",
|
||||
value: upCount,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "down",
|
||||
label: "Down / Admin",
|
||||
value: downCount,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: downCount > 0 ? "destructive" : "default",
|
||||
},
|
||||
{
|
||||
id: "init",
|
||||
label: "Init / другие",
|
||||
value: initCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: initCount > 0 ? "warning" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
@@ -1094,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
|
||||
|
||||
export default function OspfPage() {
|
||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
|
||||
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
@@ -1120,8 +1158,7 @@ export default function OspfPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
@@ -1217,9 +1254,26 @@ export default function OspfPage() {
|
||||
}, [items, neighbors, bfdSessions])
|
||||
|
||||
// ── filtered display data ─────────────────────────────────────────────────────
|
||||
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||
|
||||
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
ospfServers.map((s) => {
|
||||
const counts = serverCounts[s.id]
|
||||
const host = s.label.replace(/^mt-/, "")
|
||||
return {
|
||||
id: s.id,
|
||||
name: host,
|
||||
host,
|
||||
site: s.site,
|
||||
country: s.country || undefined,
|
||||
meta: counts
|
||||
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
), [ospfServers, serverCounts])
|
||||
|
||||
// Graph always shows full topology (highlight is handled by node click inside tab)
|
||||
// KPIs reflect the current filter
|
||||
@@ -1228,91 +1282,45 @@ export default function OspfPage() {
|
||||
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
|
||||
{ospfServers.length > 0 && (
|
||||
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* "All" chip */}
|
||||
<button
|
||||
onClick={() => setFilterServerId("all")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
filterServerId === "all"
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
)}>
|
||||
Все серверы
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
filterServerId === "all" ? "" : "text-foreground/60",
|
||||
)}>{items.length}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
{ospfServers.map(s => {
|
||||
const counts = serverCounts[s.id]
|
||||
const active = filterServerId === s.id
|
||||
return (
|
||||
<button key={s.id} onClick={() => setFilterServerId(s.id)}
|
||||
<ServerRailLayout
|
||||
items={ospfRailItems}
|
||||
selectedId={filterServerId}
|
||||
onSelect={setFilterServerId}
|
||||
showAll
|
||||
allCount={ospfServers.length}
|
||||
loading={isLive && loading && ospfServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{s.country ? <Flag code={s.country} size={12} /> : null}
|
||||
{s.site && (
|
||||
<span className={cn(
|
||||
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
|
||||
active
|
||||
? "bg-white/20"
|
||||
: "bg-muted-foreground/15 text-foreground/70",
|
||||
)}>{s.site}</span>
|
||||
)}
|
||||
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
|
||||
{counts && (
|
||||
<span className={cn(
|
||||
"tabular-nums text-[10px]",
|
||||
active ? "opacity-80" : "text-foreground/50",
|
||||
)}>
|
||||
{counts.neighbors}n · {counts.ifaces}i
|
||||
{counts.bfd > 0 && ` · ${counts.bfd}b`}
|
||||
</span>
|
||||
)}
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* data source banner */}
|
||||
@@ -1345,21 +1353,32 @@ export default function OspfPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Роутеров", value: totalRouters },
|
||||
{ label: "Интерфейсов", value: totalInterfaces },
|
||||
{ label: "Зон (Area)", value: totalAreas },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "routers",
|
||||
label: "Роутеров",
|
||||
value: totalRouters,
|
||||
icon: <RouterIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: totalInterfaces,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "areas",
|
||||
label: "Зон (Area)",
|
||||
value: totalAreas,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === "interfaces" && (
|
||||
<InterfacesTab
|
||||
@@ -1383,7 +1402,6 @@ export default function OspfPage() {
|
||||
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-32
@@ -17,6 +17,8 @@ import {
|
||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
@@ -480,19 +482,25 @@ function ScheduleTab({
|
||||
setRules,
|
||||
serverOptions,
|
||||
tunnelsForServer,
|
||||
defaultSrc,
|
||||
}: {
|
||||
rules: SchedRule[]
|
||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
serverOptions: Server[]
|
||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||
defaultSrc?: string
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
|
||||
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
||||
const [addTun, setAddTun] = useState("")
|
||||
const [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultSrc) setAddSrc(defaultSrc)
|
||||
}, [defaultSrc])
|
||||
|
||||
useEffect(() => {
|
||||
const list = tunnelsForServer(addSrc)
|
||||
if (list.length && !list.some(t => t.id === addTun)) {
|
||||
@@ -630,6 +638,20 @@ export default function ProbesPage() {
|
||||
return liveServers
|
||||
}, [isLive, liveServers])
|
||||
|
||||
const probeRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
allServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
selectable: s.enabled,
|
||||
}))
|
||||
), [allServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
@@ -887,24 +909,33 @@ export default function ProbesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
liveProbeRunRef.current?.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ServerRailLayout
|
||||
items={probeRailItems}
|
||||
selectedId={srcId}
|
||||
onSelect={setSrcId}
|
||||
showAll={false}
|
||||
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
liveProbeRunRef.current?.ctrl.abort()
|
||||
setTests(p => p.map(t => (t.status === "running"
|
||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{isLive && liveLoad === "error" && (
|
||||
@@ -916,7 +947,7 @@ export default function ProbesPage() {
|
||||
|
||||
{isLive && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -945,16 +976,6 @@ export default function ProbesPage() {
|
||||
{/* main config row */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
|
||||
{/* source server */}
|
||||
<div>
|
||||
<OptionLabel>Источник</OptionLabel>
|
||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
||||
{allServers.filter(s => s.enabled).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* target — all tools except bandwidth */}
|
||||
{tool !== "bandwidth" && (
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
|
||||
setRules={setRules}
|
||||
serverOptions={allServers}
|
||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||
defaultSrc={srcId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
|
||||
}
|
||||
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
servers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
}))
|
||||
), [servers])
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return rows
|
||||
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
|
||||
}, [filteredRows])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||||
{servers.map((s) => {
|
||||
const count = s.id === selectedServerId ? rows.length : 0
|
||||
const active = selectedServerId === s.id
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelectedServerId(s.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
!s.enabled && !active && "opacity-40",
|
||||
)}>
|
||||
<StatusDot status={s.status} />
|
||||
<Flag code={s.country} size={12} />
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<TypeChip type={s.type} />
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
||||
)}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={rrRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
loading={isLive && !liveServerListReady}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
</p>
|
||||
{opError && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{opError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
</p>
|
||||
{opError && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{opError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
}
|
||||
>
|
||||
{!isLive ? (
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
|
||||
</button>
|
||||
</DataPageCard>
|
||||
)}
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<RouteSheet
|
||||
open={sheetOpen}
|
||||
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
|
||||
onClose={() => setSheetOpen(false)}
|
||||
gateways={gatewayOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op
|
||||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <MonitorIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: (
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
icon: <ZapIcon className="size-4" />,
|
||||
},
|
||||
]
|
||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{statsChips.map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка оптимизатора"
|
||||
items={statsChips.map((s, i) => ({
|
||||
id: `ro-${i}`,
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
hint: s.sub,
|
||||
icon: s.icon,
|
||||
iconClassName: s.label === "Переключений" && totalSwitches > 0
|
||||
? "text-warning"
|
||||
: s.label === "Exit Node"
|
||||
? "text-success"
|
||||
: s.label === "JumpHost"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground",
|
||||
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
|
||||
+34
-23
@@ -27,8 +27,7 @@ import {
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -491,27 +490,39 @@ export default function ServersPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
|
||||
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка серверов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего серверов",
|
||||
value: counts.all,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Онлайн",
|
||||
value: counts.online,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "jh-en",
|
||||
label: "JH + Exit Node",
|
||||
value: counts["jump-host"] + counts["exit-node"],
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
label: "Home Router",
|
||||
value: counts["home-router"],
|
||||
icon: <HomeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<DataPageCard>
|
||||
|
||||
+98
-825
File diff suppressed because it is too large
Load Diff
+121
-119
@@ -4,8 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import type { ServerStatus } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import {
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
@@ -259,12 +273,14 @@ function Terminal({
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const data = await requestJson<{ output?: string; error?: string }>(
|
||||
backendUrl,
|
||||
`/api/servers/${server.backendId}/exec`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
},
|
||||
)
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
@@ -427,7 +443,7 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
@@ -437,14 +453,13 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !prefsHydrated) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
@@ -462,7 +477,7 @@ export default function TerminalPage() {
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
@@ -490,6 +505,64 @@ export default function TerminalPage() {
|
||||
|
||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||
|
||||
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||
return termServers.map((s) => ({
|
||||
id: s.uid,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
country: s.country || undefined,
|
||||
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||
enabled: s.enabled,
|
||||
selectable: s.enabled && s.status !== "offline",
|
||||
title: [s.name, s.host].filter(Boolean).join(" · "),
|
||||
}))
|
||||
}, [termServers])
|
||||
|
||||
const handleSelectServer = useCallback((id: string) => {
|
||||
setSelectedUid(id)
|
||||
setRefreshKey((k) => k + 1)
|
||||
}, [])
|
||||
|
||||
const railHeaderRight = isLive && !serversLoading
|
||||
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||
: undefined
|
||||
|
||||
function QuickCmds() {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||
<FrameHeader className="border-b px-3 py-2">
|
||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Быстрые команды
|
||||
</FrameTitle>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-1.5">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-info">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>}
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function injectCommand(cmd: string) {
|
||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||
if (!el) return
|
||||
@@ -500,108 +573,39 @@ export default function TerminalPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}>
|
||||
<RefreshCwIcon className="size-4" />Переподключить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
||||
|
||||
{/* ── sidebar ── */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
{/* server picker */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
||||
{isLive && serversLoading && (
|
||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{isLive && !serversLoading && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2.5">
|
||||
Нет доступных серверов
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{termServers.map(s => {
|
||||
const isOffline = s.status === "offline"
|
||||
const isSelected = s.uid === selectedUid
|
||||
return (
|
||||
<button
|
||||
key={s.uid}
|
||||
disabled={isOffline || !s.enabled}
|
||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
||||
"flex items-center gap-2",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block size-1.5 rounded-full shrink-0",
|
||||
s.status === "online" ? "bg-emerald-500" :
|
||||
s.status === "degraded" ? "bg-amber-400" :
|
||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
||||
)} />
|
||||
{s.country && <Flag code={s.country} />}
|
||||
<span className="truncate font-mono">{s.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* quick commands */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Быстрые команды
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* hints */}
|
||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── terminal ── */}
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
headerRight={railHeaderRight}
|
||||
loading={serversLoading}
|
||||
extra={<QuickCmds />}
|
||||
contentClassName="overflow-hidden p-3 md:p-4"
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Переподключить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
@@ -610,12 +614,10 @@ export default function TerminalPage() {
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+695
-315
File diff suppressed because it is too large
Load Diff
+89
-46
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
|
||||
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
|
||||
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
].map(kpi => (
|
||||
<Frame key={kpi.sub} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
||||
{kpi.icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка ресурсов"
|
||||
items={[
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов всего",
|
||||
value: rows.length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
label: "Средний CPU",
|
||||
value: `${avgCpu}%`,
|
||||
icon: <CpuIcon className="size-4" />,
|
||||
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
|
||||
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
label: "Средний RAM",
|
||||
value: `${avgRam}%`,
|
||||
icon: <HardDriveIcon className="size-4" />,
|
||||
iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
|
||||
variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "high-cpu",
|
||||
label: "CPU > 85%",
|
||||
value: highCpu,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: highCpu > 0 ? "destructive" : "default",
|
||||
},
|
||||
{
|
||||
id: "high-ram",
|
||||
label: "RAM > 85%",
|
||||
value: highRam,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: highRam > 0 ? "destructive" : "default",
|
||||
},
|
||||
{
|
||||
id: "high-hdd",
|
||||
label: "Диск > 85%",
|
||||
value: highHdd,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: highHdd > 0 ? "warning" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||
<DataPageCard>
|
||||
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
|
||||
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
||||
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||
{[
|
||||
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
|
||||
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
|
||||
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
|
||||
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
|
||||
].map(k => (
|
||||
<Frame key={k.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
|
||||
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
|
||||
</div>
|
||||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
||||
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
|
||||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
||||
</p>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка speed-проб"
|
||||
items={[
|
||||
{
|
||||
id: "probes",
|
||||
label: "Speed-пробы",
|
||||
value: `${speedProbes.length} шт`,
|
||||
icon: <ArrowUpDownIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
label: "Тестов выполнено",
|
||||
value: `${doneRuns.length} run`,
|
||||
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: runningCnt > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "max-tx",
|
||||
label: "Макс TX",
|
||||
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "max-rx",
|
||||
label: "Макс RX",
|
||||
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { UserSheet } from "@/components/users/user-sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
type UserServerOption,
|
||||
} from "@/lib/users"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import {
|
||||
createAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} from "@/shared/api/users"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
CableIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function toServerOptionsFromMock(): UserServerOption[] {
|
||||
return mockServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
}))
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
||||
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, srvs] = await Promise.all([
|
||||
listAppUsers(backendUrl),
|
||||
listServers(backendUrl),
|
||||
])
|
||||
setUsers(list)
|
||||
setServerOptions(srvs.map((s) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
})))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setUsers(INIT_USERS)
|
||||
setServerOptions(toServerOptionsFromMock())
|
||||
return
|
||||
}
|
||||
void loadLive()
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||
const activeCount = users.filter((u) => u.active).length
|
||||
|
||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||
for (const b of prev) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||
await createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId),
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
comment: b.comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (form: AppUserForm) => {
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = `u${Date.now()}`
|
||||
const created: AppUser = {
|
||||
id,
|
||||
...form,
|
||||
last: "только что",
|
||||
avatar: userInitials(form.name),
|
||||
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||
}
|
||||
setUsers((prev) => [...prev, created])
|
||||
} else {
|
||||
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!editUser) {
|
||||
const created = await createAppUser(backendUrl, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(created.id, form.bindings, [])
|
||||
await loadLive()
|
||||
} else {
|
||||
await updateAppUser(backendUrl, editUser.id, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||
await loadLive()
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
if (!isLive) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
toast.success("Пользователь удалён")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteAppUser(backendUrl, deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
await loadLive()
|
||||
toast.success("Пользователь удалён")
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Пригласить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка пользователей"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Пользователи",
|
||||
value: users.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активные",
|
||||
value: activeCount,
|
||||
icon: <UserCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Привязанные ifaces",
|
||||
value: boundCount,
|
||||
icon: <CableIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||
<DataPageCard>
|
||||
<UsersDataGrid
|
||||
users={users}
|
||||
serversCount={serverOptions.length}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading}
|
||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
<UserSheet
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
users={users}
|
||||
servers={serverOptions}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+50
-78
@@ -4,22 +4,16 @@ import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
function ExportSheet({ open, tunnel, onClose }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт VXLAN"
|
||||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||
code,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,27 +117,39 @@ export default function VxlanPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка VXLAN"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Туннелей",
|
||||
value: vxlanTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных",
|
||||
value: upCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
label: "Уникальных VNI",
|
||||
value: vnis,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов",
|
||||
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
|
||||
+774
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
export default function AccessDeniedPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||
<p className="text-muted-foreground max-w-md text-sm">
|
||||
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||
необходимых прав. Обратитесь к администратору auth-portal.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/callback"
|
||||
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Войти снова
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
clearPortalHandoffFlag,
|
||||
clearToken,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getToken,
|
||||
parseHashToken,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
setToken,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter()
|
||||
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const error = params.get("error")
|
||||
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
|
||||
const { accessToken } = parseHashToken(window.location.hash)
|
||||
if (accessToken) {
|
||||
setToken(accessToken)
|
||||
clearPortalHandoffFlag()
|
||||
const claims = getClaims()
|
||||
if (!claims) {
|
||||
clearToken()
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes("mm")) {
|
||||
setMessage("Нет доступа к приложению")
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/config", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* ignore network — proceed */
|
||||
}
|
||||
|
||||
const next = firstAllowedPath()
|
||||
if (next === "/access-denied") {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
router.replace(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (getToken() && getClaims()) {
|
||||
clearPortalHandoffFlag()
|
||||
if (!getClaims()!.apps.includes("mm")) {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
router.replace(firstAllowedPath())
|
||||
return
|
||||
}
|
||||
|
||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,3 +6,10 @@ PORT=8000
|
||||
|
||||
# Allowed CORS origin (Next.js frontend)
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Portal SSO (false = open API for local/dev)
|
||||
AUTH_REQUIRED=false
|
||||
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
|
||||
+19
-2
@@ -5,10 +5,23 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY backend/package.json backend/
|
||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
||||
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||
RUN node -e "\
|
||||
const fs=require('fs');\
|
||||
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||
p.dependencies={};\
|
||||
p.devDependencies={};\
|
||||
delete p.scripts;\
|
||||
p.workspaces=['packages/*','backend'];\
|
||||
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||
"
|
||||
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||
&& npm rebuild better-sqlite3
|
||||
|
||||
FROM deps AS build
|
||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& npm run build -w mikrotik-manager-backend \
|
||||
&& npm prune --omit=dev
|
||||
&& npm prune --omit=dev \
|
||||
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||
&& mkdir -p backend/node_modules
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
|
||||
+10
-2
@@ -10,10 +10,16 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -21,7 +27,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
@@ -29,6 +35,8 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
+46
-2
@@ -3,17 +3,61 @@ import { z } from "zod"
|
||||
|
||||
config()
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === "") return fallback
|
||||
return v === "1" || v.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production"
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
AUTH_REQUIRED: z.boolean().default(false),
|
||||
AUTH_JWT_SECRET: z.string().default(""),
|
||||
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
|
||||
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
const raw = {
|
||||
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||
PORT: process.env.PORT,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
AUTH_JWT_SECRET:
|
||||
process.env.AUTH_JWT_SECRET?.trim() ||
|
||||
process.env.JWT_SECRET?.trim() ||
|
||||
(isProd ? "" : "dev-secret-change-me"),
|
||||
AUTH_ISSUER:
|
||||
process.env.AUTH_ISSUER?.trim() ||
|
||||
process.env.ISSUER?.trim() ||
|
||||
"https://auth.shnt.top",
|
||||
AUTH_PORTAL_URL: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
|
||||
"http://localhost:5175"
|
||||
).replace(/\/$/, ""),
|
||||
}
|
||||
|
||||
const parsed = envSchema.safeParse(raw)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = {
|
||||
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||
PORT: parsed.data.PORT,
|
||||
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||
authRequired: parsed.data.AUTH_REQUIRED,
|
||||
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
|
||||
authIssuer: parsed.data.AUTH_ISSUER,
|
||||
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
|
||||
}
|
||||
|
||||
+279
-6
@@ -7,11 +7,23 @@ import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||
import { env } from "../config.js"
|
||||
import * as schema from "./schema.js"
|
||||
|
||||
const sqlite = new Database(env.DATABASE_PATH)
|
||||
export const SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
export function applySqlitePragmas(handle: SqliteHandle): void {
|
||||
handle.pragma("journal_mode = WAL")
|
||||
handle.pragma("foreign_keys = ON")
|
||||
handle.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
|
||||
handle.pragma("synchronous = NORMAL")
|
||||
}
|
||||
|
||||
function openSqlite(): SqliteHandle {
|
||||
const handle = new Database(env.DATABASE_PATH)
|
||||
applySqlitePragmas(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
let sqlite = openSqlite()
|
||||
|
||||
// WAL mode for better concurrent read performance
|
||||
sqlite.pragma("journal_mode = WAL")
|
||||
sqlite.pragma("foreign_keys = ON")
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -106,6 +118,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TEXT NOT NULL,
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -120,6 +133,101 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id INTEGER,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct REAL NOT NULL DEFAULT 5,
|
||||
last_datagram_at TEXT,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received INTEGER NOT NULL DEFAULT 0,
|
||||
peers_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
bucket_at TEXT NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop TEXT NOT NULL DEFAULT '',
|
||||
flow_start_ms INTEGER NOT NULL DEFAULT 0,
|
||||
flow_end_ms INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
|
||||
ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id INTEGER NOT NULL,
|
||||
bucket_at TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id INTEGER NOT NULL,
|
||||
bucket_at TEXT NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -511,9 +619,102 @@ CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
last_source_finished_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TEXT,
|
||||
sections_json TEXT NOT NULL DEFAULT '[]',
|
||||
servers_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
||||
ON user_interface_bindings(user_id);
|
||||
`)
|
||||
|
||||
// Lightweight schema evolution for existing databases without migrations
|
||||
{
|
||||
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
|
||||
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
|
||||
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
|
||||
if (!bindCols.some((c) => c.name === "peer_public_key")) {
|
||||
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
if (!bindCols.some((c) => c.name === "peer_name")) {
|
||||
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
|
||||
name?: string
|
||||
unique?: number
|
||||
}>
|
||||
let hasPeerUnique = false
|
||||
for (const idx of indexes) {
|
||||
if (!idx.name || !idx.unique) continue
|
||||
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||
const names = info.map((c) => c.name)
|
||||
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
|
||||
hasPeerUnique = true
|
||||
}
|
||||
}
|
||||
if (!hasPeerUnique) {
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`)
|
||||
sqlite.exec(`
|
||||
CREATE TABLE user_interface_bindings_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
INSERT INTO user_interface_bindings_new
|
||||
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
|
||||
SELECT id, user_id, server_id, interface_name, interface_type,
|
||||
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
|
||||
FROM user_interface_bindings;
|
||||
DROP TABLE user_interface_bindings;
|
||||
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
`)
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
}
|
||||
|
||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||
if (!hasCountryColumn) {
|
||||
@@ -580,6 +781,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||
}
|
||||
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
||||
@@ -625,6 +829,19 @@ SELECT 1, 1, 30, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
|
||||
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
{
|
||||
const flowSettingsCols = sqlite.prepare(`PRAGMA table_info('traffic_flow_settings')`).all() as Array<{ name?: string }>
|
||||
if (!flowSettingsCols.some((c) => c.name === "map_service_min_share_pct")) {
|
||||
sqlite.exec(`ALTER TABLE traffic_flow_settings ADD COLUMN map_service_min_share_pct REAL NOT NULL DEFAULT 5`)
|
||||
}
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 15, 14
|
||||
@@ -661,6 +878,38 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
{
|
||||
const flowIndexes = sqlite.prepare(`PRAGMA index_list('flow_buckets')`).all() as Array<{
|
||||
name?: string
|
||||
unique?: number
|
||||
}>
|
||||
let hasIfaceUnique = false
|
||||
for (const idx of flowIndexes) {
|
||||
if (!idx.name || !idx.unique) continue
|
||||
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||
const names = info.map((c) => c.name)
|
||||
if (names.includes("in_iface") && names.includes("src") && names.includes("dst")) {
|
||||
hasIfaceUnique = true
|
||||
}
|
||||
}
|
||||
if (!hasIfaceUnique) {
|
||||
sqlite.exec(`DROP INDEX IF EXISTS idx_flow_buckets_unique`)
|
||||
sqlite.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const flowCols = sqlite.prepare(`PRAGMA table_info('flow_buckets')`).all() as Array<{ name?: string }>
|
||||
const names = new Set(flowCols.map((c) => c.name))
|
||||
if (!names.has("out_iface")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN out_iface TEXT NOT NULL DEFAULT ''`)
|
||||
if (!names.has("next_hop")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN next_hop TEXT NOT NULL DEFAULT ''`)
|
||||
if (!names.has("flow_start_ms")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN flow_start_ms INTEGER NOT NULL DEFAULT 0`)
|
||||
if (!names.has("flow_end_ms")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN flow_end_ms INTEGER NOT NULL DEFAULT 0`)
|
||||
}
|
||||
|
||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||
@@ -719,7 +968,31 @@ if (backupEntryCount.c === 0) {
|
||||
}
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
export let db = drizzle(sqlite, { schema })
|
||||
|
||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||
export const sqliteDatabase: SqliteHandle = sqlite
|
||||
export let sqliteDatabase: SqliteHandle = sqlite
|
||||
|
||||
let sqliteExclusiveOp = false
|
||||
|
||||
export function beginSqliteExclusiveOp(): void {
|
||||
if (sqliteExclusiveOp) {
|
||||
throw new Error("Операция с базой данных уже выполняется")
|
||||
}
|
||||
sqliteExclusiveOp = true
|
||||
}
|
||||
|
||||
export function endSqliteExclusiveOp(): void {
|
||||
sqliteExclusiveOp = false
|
||||
}
|
||||
|
||||
export function reopenSqlite(): void {
|
||||
try {
|
||||
sqlite.close()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
sqlite = openSqlite()
|
||||
sqliteDatabase = sqlite
|
||||
db = drizzle(sqlite, { schema })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
|
||||
// ── servers ────────────────────────────────────────────────────────────────────
|
||||
@@ -31,6 +32,8 @@ export const servers = sqliteTable("servers", {
|
||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
|
||||
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
@@ -157,12 +160,111 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
|
||||
|
||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||
|
||||
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||
hubServerId: integer("hub_server_id"),
|
||||
retentionHours: integer("retention_hours").notNull().default(24),
|
||||
topN: integer("top_n").notNull().default(200),
|
||||
mapServiceMinSharePct: real("map_service_min_share_pct").notNull().default(5),
|
||||
lastDatagramAt: text("last_datagram_at"),
|
||||
lastExporterIp: text("last_exporter_ip"),
|
||||
lastError: text("last_error"),
|
||||
packetsReceived: integer("packets_received").notNull().default(0),
|
||||
peersJson: text("peers_json").notNull().default("[]"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const flowMinuteStats = sqliteTable("flow_minute_stats", {
|
||||
serverId: integer("server_id").notNull(),
|
||||
bucketAt: text("bucket_at").notNull(),
|
||||
bytes: integer("bytes").notNull().default(0),
|
||||
packets: integer("packets").notNull().default(0),
|
||||
uniqueSrc: integer("unique_src").notNull().default(0),
|
||||
uniqueDst: integer("unique_dst").notNull().default(0),
|
||||
conversations: integer("conversations").notNull().default(0),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_flow_minute_stats_pk").on(t.serverId, t.bucketAt),
|
||||
])
|
||||
|
||||
export const flowMinuteDims = sqliteTable("flow_minute_dims", {
|
||||
serverId: integer("server_id").notNull(),
|
||||
bucketAt: text("bucket_at").notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: integer("bytes").notNull().default(0),
|
||||
packets: integer("packets").notNull().default(0),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_flow_minute_dims_pk").on(t.serverId, t.bucketAt, t.dim, t.key),
|
||||
])
|
||||
|
||||
export const flowDailyDims = sqliteTable("flow_daily_dims", {
|
||||
serverId: integer("server_id").notNull(),
|
||||
day: text("day").notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: integer("bytes").notNull().default(0),
|
||||
packets: integer("packets").notNull().default(0),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_flow_daily_dims_pk").on(t.serverId, t.day, t.dim, t.key),
|
||||
])
|
||||
|
||||
export const flowBuckets = sqliteTable("flow_buckets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: text("bucket_at").notNull(),
|
||||
src: text("src").notNull(),
|
||||
dst: text("dst").notNull(),
|
||||
proto: integer("proto").notNull().default(0),
|
||||
srcPort: integer("src_port").notNull().default(0),
|
||||
dstPort: integer("dst_port").notNull().default(0),
|
||||
bytes: integer("bytes").notNull().default(0),
|
||||
packets: integer("packets").notNull().default(0),
|
||||
inIface: text("in_iface").notNull().default(""),
|
||||
outIface: text("out_iface").notNull().default(""),
|
||||
nextHop: text("next_hop").notNull().default(""),
|
||||
flowStartMs: integer("flow_start_ms").notNull().default(0),
|
||||
flowEndMs: integer("flow_end_ms").notNull().default(0),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_flow_buckets_unique").on(
|
||||
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface,
|
||||
),
|
||||
])
|
||||
|
||||
export const flowIpMeta = sqliteTable("flow_ip_meta", {
|
||||
prefix: text("prefix").primaryKey(),
|
||||
asn: integer("asn").notNull().default(0),
|
||||
country: text("country").notNull().default(""),
|
||||
lat: real("lat"),
|
||||
lng: real("lng"),
|
||||
holder: text("holder").notNull().default(""),
|
||||
ok: integer("ok").notNull().default(1),
|
||||
fetchedAt: text("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
export const flowAsnMeta = sqliteTable("flow_asn_meta", {
|
||||
asn: integer("asn").primaryKey(),
|
||||
holder: text("holder").notNull().default(""),
|
||||
fetchedAt: text("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||
txBytes: integer("tx_bytes").notNull().default(0),
|
||||
@@ -540,6 +642,44 @@ export const internetPathSettings = sqliteTable("internet_path_settings", {
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
// ── app users (local catalog, not portal JWT) ────────────────────────────────
|
||||
|
||||
export const appUsers = sqliteTable("app_users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().default(""),
|
||||
login: text("login").notNull().unique(),
|
||||
email: text("email").notNull().default(""),
|
||||
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
avatar: text("avatar").notNull().default(""),
|
||||
lastSeen: text("last_seen"),
|
||||
sectionsJson: text("sections_json").notNull().default("[]"),
|
||||
serversJson: text("servers_json").notNull().default("[]"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => appUsers.id, { onDelete: "cascade" }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
||||
.notNull()
|
||||
.default("other"),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
peerName: text("peer_name").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||
])
|
||||
|
||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
@@ -555,6 +695,10 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||
export type FlowIpMetaRow = typeof flowIpMeta.$inferSelect
|
||||
export type FlowAsnMetaRow = typeof flowAsnMeta.$inferSelect
|
||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
@@ -582,3 +726,5 @@ export type AlertDestinationRow = typeof alertDestinations.$inferSelect
|
||||
export type AlertHistoryRow = typeof alertHistory.$inferSelect
|
||||
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
|
||||
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
|
||||
+153
-62
@@ -1,14 +1,17 @@
|
||||
import Fastify from "fastify"
|
||||
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||
import { env } from "./config.js"
|
||||
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import networkRoutes from "./routes/network.js"
|
||||
@@ -22,73 +25,161 @@ import backupsRoutes from "./routes/backups.js"
|
||||
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 firewallRoutes from "./routes/firewall.js"
|
||||
import usersRoutes from "./routes/users.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||
eventLoopDelay.enable()
|
||||
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
},
|
||||
})
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
startScheduler?: boolean
|
||||
}): Promise<FastifyInstance> {
|
||||
const usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 2 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger:
|
||||
opts?.logger === false
|
||||
? false
|
||||
: usePrettyLogger
|
||||
? {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}
|
||||
: true,
|
||||
})
|
||||
|
||||
// Use Zod for request validation and response serialization
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
// CORS — allow Next.js frontend
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const status = typeof error.statusCode === "number" && error.statusCode >= 400
|
||||
? error.statusCode
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
request.log.error(error)
|
||||
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Ошибка запроса"
|
||||
return reply.status(status).send({ error: message })
|
||||
})
|
||||
|
||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
}))
|
||||
await app.register(authPlugin)
|
||||
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(probesRoutes, { prefix: "/api" })
|
||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
|
||||
flowWorker: getFlowWorkerHealth(),
|
||||
}))
|
||||
|
||||
refreshScheduler()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
})
|
||||
app.get("/api/auth/config", async () => ({
|
||||
required: env.authRequired,
|
||||
portal_url: env.authPortalUrl,
|
||||
issuer: env.authIssuer,
|
||||
}))
|
||||
|
||||
// ── start ──────────────────────────────────────────────────────────────────────
|
||||
if (env.authRequired) {
|
||||
app.addHook("preHandler", async (request, reply) => {
|
||||
const pathname = request.url.split("?")[0] ?? request.url
|
||||
if (!pathname.startsWith("/api/")) return
|
||||
if (pathname === "/api/auth/config") return
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||
await app.register(probesRoutes, { prefix: "/api" })
|
||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
await app.register(firewallRoutes, { prefix: "/api" })
|
||||
await app.register(usersRoutes, { prefix: "/api" })
|
||||
|
||||
if (opts?.startScheduler !== false) {
|
||||
refreshScheduler()
|
||||
startTrafficFlowListener()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
})
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
const app = await buildApp()
|
||||
let shuttingDown = false
|
||||
const shutdown = async (code: number) => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
try {
|
||||
stopTrafficFlowListener()
|
||||
await app.close()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
process.exit(code)
|
||||
}
|
||||
}
|
||||
process.on("SIGTERM", () => { void shutdown(0) })
|
||||
process.on("SIGINT", () => { void shutdown(0) })
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error(err)
|
||||
void shutdown(1)
|
||||
})
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(reason)
|
||||
void shutdown(1)
|
||||
})
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(
|
||||
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||
)
|
||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { hasPermission, permissionForRequest } from "./permissions.js"
|
||||
|
||||
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
|
||||
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
|
||||
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
|
||||
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/servers"),
|
||||
"mm:servers:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/servers"),
|
||||
"mm:servers:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/system/database/backup"),
|
||||
"mm:settings:admin",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
||||
"mm:traffic:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||
"mm:traffic:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/unknown-thing"),
|
||||
"mm:dashboard:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/wireguard"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||
"mm:network:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/firewall/all"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/users"),
|
||||
"mm:users:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/users"),
|
||||
"mm:users:write",
|
||||
)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
|
||||
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
|
||||
|
||||
console.log("permissions.test.ts: ok")
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: mm:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
|
||||
return true
|
||||
}
|
||||
const parts = required.split(":")
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === "read") {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === "write") {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[]
|
||||
match: (path: string) => boolean
|
||||
permission: string
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/system") ||
|
||||
p.startsWith("/api/scheduler") ||
|
||||
p.startsWith("/api/evobgp"),
|
||||
permission: "mm:settings:admin",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||
permission: "mm:dashboard:read",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/servers"),
|
||||
permission: "mm:servers:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/filters"),
|
||||
permission: "mm:filters:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/bgp"),
|
||||
permission: "mm:bgp:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/uptime"),
|
||||
permission: "mm:uptime:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/traffic"),
|
||||
permission: "mm:traffic:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/traffic"),
|
||||
permission: "mm:traffic:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/alerts"),
|
||||
permission: "mm:alerts:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/backups"),
|
||||
permission: "mm:backups:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/certificates"),
|
||||
permission: "mm:certificates:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/firewall"),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/network") ||
|
||||
p.startsWith("/api/ospf") ||
|
||||
p.startsWith("/api/recursive") ||
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/firewall"),
|
||||
permission: "mm:network:write",
|
||||
},
|
||||
]
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase()
|
||||
const pathname = path.split("?")[0] ?? path
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue
|
||||
if (rule.match(pathname)) return rule.permission
|
||||
}
|
||||
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Database from "better-sqlite3"
|
||||
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||
|
||||
const sqlite = new Database(":memory:")
|
||||
sqlite.pragma("foreign_keys = ON")
|
||||
sqlite.exec(`
|
||||
CREATE TABLE servers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '127.0.0.1'
|
||||
);
|
||||
CREATE TABLE app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TEXT,
|
||||
sections_json TEXT NOT NULL DEFAULT '[]',
|
||||
servers_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
`)
|
||||
|
||||
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
|
||||
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
|
||||
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
|
||||
sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
|
||||
`).run()
|
||||
|
||||
assert.throws(
|
||||
() => sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
|
||||
`).run(),
|
||||
/UNIQUE/i,
|
||||
"один интерфейс на сервере — один пользователь",
|
||||
)
|
||||
|
||||
sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||
`).run()
|
||||
sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||
`).run()
|
||||
assert.throws(
|
||||
() => sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
|
||||
`).run(),
|
||||
/UNIQUE/i,
|
||||
"один пир — один пользователь",
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => normalizeBindingPeer("wg", ""),
|
||||
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||
"WG без ключа — 400",
|
||||
)
|
||||
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||
|
||||
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
||||
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
||||
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||
|
||||
console.log("users bindings unique+cascade tests ok")
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
|
||||
|
||||
assert.equal(mapRosInterfaceType("ether"), "ether")
|
||||
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
||||
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
||||
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
|
||||
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
|
||||
assert.equal(mapRosInterfaceType("wg"), "wg")
|
||||
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
||||
assert.equal(mapRosInterfaceType("vlan"), "other")
|
||||
assert.equal(mapRosInterfaceType(""), "other")
|
||||
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
|
||||
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
|
||||
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
|
||||
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
|
||||
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
|
||||
|
||||
const parsed = parseRawInterfaces(JSON.stringify([
|
||||
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
||||
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
|
||||
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
||||
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
|
||||
{ name: "", type: "ether" },
|
||||
]))
|
||||
assert.equal(parsed.length, 4)
|
||||
assert.equal(parsed[0]?.type, "ether")
|
||||
assert.equal(parsed[0]?.running, true)
|
||||
assert.equal(parsed[1]?.type, "gre")
|
||||
assert.equal(parsed[1]?.running, false)
|
||||
assert.equal(parsed[2]?.type, "wg")
|
||||
assert.equal(parsed[3]?.type, "gre")
|
||||
|
||||
assert.equal(parseRawInterfaces("not-json").length, 0)
|
||||
assert.equal(parseRawInterfaces(null).length, 0)
|
||||
|
||||
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
|
||||
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
|
||||
assert.equal(isUniqueConstraintError({ message: "other" }), false)
|
||||
|
||||
console.log("users iface-type tests ok")
|
||||
@@ -0,0 +1,60 @@
|
||||
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
||||
|
||||
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||
const t = String(raw ?? "").trim().toLowerCase()
|
||||
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
|
||||
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
|
||||
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
|
||||
if (t === "wg" || t === "wireguard") return "wg"
|
||||
|
||||
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("ether") || n.startsWith("sfp")) return "ether"
|
||||
return "other"
|
||||
}
|
||||
|
||||
export interface ParsedRosIface {
|
||||
name: string
|
||||
type: InterfaceType
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function asBool(raw: unknown): boolean {
|
||||
if (typeof raw === "boolean") return raw
|
||||
const s = String(raw ?? "").trim().toLowerCase()
|
||||
return s === "true" || s === "yes" || s === "1"
|
||||
}
|
||||
|
||||
export function parseRawInterfaces(json: string | null | undefined): ParsedRosIface[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const parsed = JSON.parse(json) as unknown
|
||||
const arr = Array.isArray(parsed) ? parsed : []
|
||||
const out: ParsedRosIface[] = []
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const rec = item as Record<string, unknown>
|
||||
const name = String(rec.name ?? "").trim()
|
||||
if (!name) continue
|
||||
out.push({
|
||||
name,
|
||||
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||
running: asBool(rec.running),
|
||||
disabled: asBool(rec.disabled),
|
||||
})
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function isUniqueConstraintError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false
|
||||
const rec = err as { code?: unknown; message?: unknown }
|
||||
const code = String(rec.code ?? "")
|
||||
const msg = String(rec.message ?? "")
|
||||
return code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { InterfaceType } from "./iface-type.js"
|
||||
|
||||
export class PeerBindError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PeerBindError"
|
||||
}
|
||||
}
|
||||
|
||||
export function truncPeerKey(key: string): string {
|
||||
const k = key.trim()
|
||||
if (k.length <= 20) return k
|
||||
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||
}
|
||||
|
||||
export function peerDisplayName(opts: {
|
||||
publicKey: string
|
||||
name?: string | null
|
||||
comment?: string | null
|
||||
}): string {
|
||||
const name = (opts.name ?? "").trim()
|
||||
if (name) return name
|
||||
const comment = (opts.comment ?? "").trim()
|
||||
if (comment) return comment
|
||||
return truncPeerKey(opts.publicKey)
|
||||
}
|
||||
|
||||
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||
export function normalizeBindingPeer(
|
||||
type: InterfaceType,
|
||||
peerPublicKey: string | undefined,
|
||||
): string {
|
||||
const key = (peerPublicKey ?? "").trim()
|
||||
if (type === "wg") {
|
||||
if (!key) {
|
||||
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||
}
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
|
||||
export function listUserRows(): AppUserRow[] {
|
||||
return db.select().from(appUsers).all()
|
||||
}
|
||||
|
||||
export function getUserRowById(id: string): AppUserRow | undefined {
|
||||
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function getUserRowByLogin(login: string): AppUserRow | undefined {
|
||||
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
||||
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export function updateUserRowById(
|
||||
id: string,
|
||||
values: Partial<AppUserRow>,
|
||||
): AppUserRow {
|
||||
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteUserRowById(id: string): void {
|
||||
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
||||
}
|
||||
|
||||
export function listBindingRows(): BindingRow[] {
|
||||
return db.select().from(userInterfaceBindings).all()
|
||||
}
|
||||
|
||||
export function listBindingRowsByUser(userId: string): BindingRow[] {
|
||||
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||
}
|
||||
|
||||
export function getBindingRowById(id: string): BindingRow | undefined {
|
||||
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function getBindingByServerIfacePeer(
|
||||
serverId: number,
|
||||
interfaceName: string,
|
||||
peerPublicKey = "",
|
||||
): BindingRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(userInterfaceBindings)
|
||||
.where(and(
|
||||
eq(userInterfaceBindings.serverId, serverId),
|
||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||
))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
}
|
||||
|
||||
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
||||
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export function deleteBindingRowById(id: string): void {
|
||||
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
||||
}
|
||||
|
||||
export function countUserRows(): number {
|
||||
return db.select().from(appUsers).all().length
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type {
|
||||
AppUserCreate,
|
||||
AppUserRead,
|
||||
AppUserUpdate,
|
||||
CatalogInterface,
|
||||
InterfaceType,
|
||||
SectionPerm,
|
||||
ServerPerm,
|
||||
UserBinding,
|
||||
UserBindingCreate,
|
||||
} from "@mmapp/contracts/users"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||
import {
|
||||
createBindingRow,
|
||||
createUserRow,
|
||||
deleteBindingRowById,
|
||||
deleteUserRowById,
|
||||
getBindingByServerIfacePeer,
|
||||
getBindingRowById,
|
||||
getUserRowById,
|
||||
getUserRowByLogin,
|
||||
listBindingRows,
|
||||
listBindingRowsByUser,
|
||||
listUserRows,
|
||||
updateUserRowById,
|
||||
type AppUserRow,
|
||||
type BindingRow,
|
||||
} from "../repository/users-repository.js"
|
||||
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
|
||||
import {
|
||||
isUniqueConstraintError,
|
||||
mapRosInterfaceType,
|
||||
parseRawInterfaces,
|
||||
} from "../iface-type.js"
|
||||
import {
|
||||
normalizeBindingPeer,
|
||||
PeerBindError,
|
||||
peerDisplayName,
|
||||
} from "../peer-bind.js"
|
||||
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||
|
||||
export class UsersServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "UsersServiceError"
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray<T>(raw: string, fallback: T[]): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
|
||||
}
|
||||
|
||||
function serverMeta(serverId: number): { name: string; site: string; country: string } {
|
||||
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
return {
|
||||
name: row?.name || row?.host || String(serverId),
|
||||
site: row?.site || "—",
|
||||
country: row?.country || "UN",
|
||||
}
|
||||
}
|
||||
|
||||
function toBindingDto(row: BindingRow): UserBinding {
|
||||
const meta = serverMeta(row.serverId)
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
serverId: row.serverId,
|
||||
serverName: meta.name,
|
||||
serverSite: meta.site,
|
||||
serverCountry: meta.country,
|
||||
interfaceName: row.interfaceName,
|
||||
interfaceType: row.interfaceType,
|
||||
peerPublicKey: row.peerPublicKey ?? "",
|
||||
peerName: row.peerName ?? "",
|
||||
comment: row.comment,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
login: row.login,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
active: Boolean(row.active),
|
||||
avatar: row.avatar,
|
||||
lastSeen: row.lastSeen ?? null,
|
||||
sections: parseJsonArray<SectionPerm>(row.sectionsJson, []),
|
||||
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
|
||||
bindings: bindings.map(toBindingDto),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function listUsers(): AppUserRead[] {
|
||||
const users = listUserRows()
|
||||
const allBindings = listBindingRows()
|
||||
const byUser = new Map<string, BindingRow[]>()
|
||||
for (const b of allBindings) {
|
||||
const arr = byUser.get(b.userId) ?? []
|
||||
arr.push(b)
|
||||
byUser.set(b.userId, arr)
|
||||
}
|
||||
return users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
|
||||
}
|
||||
|
||||
export function getUserById(id: string): AppUserRead | undefined {
|
||||
const row = getUserRowById(id)
|
||||
if (!row) return undefined
|
||||
return toUserDto(row, listBindingRowsByUser(id))
|
||||
}
|
||||
|
||||
export function createUser(input: AppUserCreate): AppUserRead {
|
||||
const login = input.login.trim()
|
||||
if (getUserRowByLogin(login)) {
|
||||
throw new UsersServiceError("Логин уже занят", 409)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const row = createUserRow({
|
||||
id: randomUUID(),
|
||||
name: input.name.trim(),
|
||||
login,
|
||||
email: input.email.trim(),
|
||||
role: input.role ?? "viewer",
|
||||
active: input.active ?? true,
|
||||
avatar: (input.avatar ?? "").trim() || initials(input.name),
|
||||
lastSeen: input.lastSeen ?? null,
|
||||
sectionsJson: JSON.stringify(input.sections ?? []),
|
||||
serversJson: JSON.stringify(input.servers ?? []),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return toUserDto(row, [])
|
||||
}
|
||||
|
||||
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
|
||||
const existing = getUserRowById(id)
|
||||
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
if (input.login != null) {
|
||||
const other = getUserRowByLogin(input.login.trim())
|
||||
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
|
||||
}
|
||||
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
|
||||
if (input.name != null) patch.name = input.name.trim()
|
||||
if (input.login != null) patch.login = input.login.trim()
|
||||
if (input.email != null) patch.email = input.email.trim()
|
||||
if (input.role != null) patch.role = input.role
|
||||
if (input.active != null) patch.active = input.active
|
||||
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
|
||||
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
|
||||
if (input.sections != null) patch.sectionsJson = JSON.stringify(input.sections)
|
||||
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
|
||||
const updated = updateUserRowById(id, patch)
|
||||
return toUserDto(updated, listBindingRowsByUser(id))
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
const existing = getUserRowById(id)
|
||||
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
deleteUserRowById(id)
|
||||
}
|
||||
|
||||
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
|
||||
const user = getUserRowById(userId)
|
||||
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
|
||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||
const ifaceName = input.interfaceName.trim()
|
||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
||||
let peerPublicKey = ""
|
||||
try {
|
||||
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||
} catch (err) {
|
||||
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||
throw err
|
||||
}
|
||||
const peerName = type === "wg"
|
||||
? peerDisplayName({
|
||||
publicKey: peerPublicKey,
|
||||
name: input.peerName,
|
||||
})
|
||||
: ""
|
||||
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||
if (taken) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const row = createBindingRow({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
serverId: input.serverId,
|
||||
interfaceName: ifaceName,
|
||||
interfaceType: type,
|
||||
peerPublicKey,
|
||||
peerName,
|
||||
comment: (input.comment ?? "").trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return toBindingDto(row)
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBinding(userId: string, bindingId: string): void {
|
||||
const row = getBindingRowById(bindingId)
|
||||
if (!row || row.userId !== userId) {
|
||||
throw new UsersServiceError("Привязка не найдена", 404)
|
||||
}
|
||||
deleteBindingRowById(bindingId)
|
||||
}
|
||||
|
||||
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
|
||||
const snap = getLatestSnapshot(serverId)
|
||||
const parsed = parseRawInterfaces(snap?.rawInterfaces)
|
||||
const found = parsed.find((i) => i.name === ifaceName)
|
||||
return found?.type ?? "other"
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||
|
||||
const snap = getLatestSnapshot(serverId)
|
||||
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
|
||||
if (ifaces.length === 0) {
|
||||
const last = db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (last) {
|
||||
const rows = db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
running: trafficSamples.running,
|
||||
disabled: trafficSamples.disabled,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.all()
|
||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||
const seen = new Set<string>()
|
||||
ifaces = []
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.interfaceName)) continue
|
||||
seen.add(r.interfaceName)
|
||||
ifaces.push({
|
||||
name: r.interfaceName,
|
||||
type: mapRosInterfaceType("", r.interfaceName),
|
||||
running: Boolean(r.running),
|
||||
disabled: Boolean(r.disabled),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
||||
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
||||
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||
const wgLive = hasWg
|
||||
? await listWireGuardPeersForCatalog(serverId)
|
||||
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||
for (const peer of wgLive.peers) {
|
||||
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||
list.push(peer)
|
||||
peersByIface.set(peer.interfaceName, list)
|
||||
}
|
||||
|
||||
return 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 = {
|
||||
name: iface.name,
|
||||
type: iface.type,
|
||||
running: iface.running,
|
||||
disabled: iface.disabled,
|
||||
boundUserId: ifaceBind?.userId ?? null,
|
||||
boundUserLogin: owner?.login ?? null,
|
||||
}
|
||||
if (iface.type !== "wg") return base
|
||||
const livePeers = peersByIface.get(iface.name) ?? []
|
||||
return {
|
||||
...base,
|
||||
peersError: wgLive.error,
|
||||
peers: livePeers.map((p) => {
|
||||
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||
return {
|
||||
publicKey: p.publicKey,
|
||||
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||
comment: p.comment,
|
||||
allowedIps: p.allowedIps,
|
||||
latestHandshake: p.latestHandshake,
|
||||
boundUserId: bind?.userId ?? null,
|
||||
boundUserLogin: peerOwner?.login ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export { parseRawInterfaces, mapRosInterfaceType }
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Smoke: AUTH_REQUIRED gate via Fastify inject.
|
||||
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
import { SignJWT } from "jose"
|
||||
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
|
||||
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
|
||||
process.env.CORS_ORIGIN = "http://localhost:3000"
|
||||
process.env.DATABASE_PATH = ":memory:"
|
||||
process.env.NODE_ENV = "test"
|
||||
|
||||
// Dynamic import after env is set
|
||||
const { buildApp } = await import("../index.js")
|
||||
|
||||
const secret = new TextEncoder().encode("test-secret-at-least-8")
|
||||
|
||||
async function mint(payload: Record<string, unknown>): Promise<string> {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuer("https://auth.test.local")
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
const app = await buildApp({ logger: false, startScheduler: false })
|
||||
|
||||
const health = await app.inject({ method: "GET", url: "/health" })
|
||||
assert.equal(health.statusCode, 200)
|
||||
|
||||
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
|
||||
assert.equal(cfg.statusCode, 200)
|
||||
assert.equal(cfg.json().required, true)
|
||||
|
||||
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
|
||||
assert.equal(noToken.statusCode, 401)
|
||||
|
||||
const badApp = await mint({
|
||||
sub: "u1",
|
||||
email: "[email protected]",
|
||||
name: "A",
|
||||
apps: ["cdn"],
|
||||
permissions: ["cdn:dashboard:read"],
|
||||
})
|
||||
const forbiddenApp = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sidebar-counts",
|
||||
headers: { authorization: `Bearer ${badApp}` },
|
||||
})
|
||||
assert.equal(forbiddenApp.statusCode, 403)
|
||||
|
||||
const okToken = await mint({
|
||||
sub: "u1",
|
||||
email: "[email protected]",
|
||||
name: "A",
|
||||
apps: ["mm"],
|
||||
permissions: ["mm:dashboard:read"],
|
||||
})
|
||||
const ok = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sidebar-counts",
|
||||
headers: { authorization: `Bearer ${okToken}` },
|
||||
})
|
||||
// May be 200 or 500 if DB missing — must not be 401/403
|
||||
assert.notEqual(ok.statusCode, 401)
|
||||
assert.notEqual(ok.statusCode, 403)
|
||||
|
||||
await app.close()
|
||||
console.log("auth.smoke.test.ts: ok")
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { env } from "../config.js"
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
type AuthUser,
|
||||
} from "../lib/permissions.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string
|
||||
email?: string
|
||||
name?: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
exp?: number
|
||||
}
|
||||
user: {
|
||||
sub: string
|
||||
email?: string
|
||||
name?: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
exp?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function authPlugin(app: FastifyInstance) {
|
||||
if (env.authRequired && env.jwtSecret.length < 8) {
|
||||
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||
}
|
||||
|
||||
await app.register(import("@fastify/jwt"), {
|
||||
secret: env.jwtSecret,
|
||||
...(env.authRequired
|
||||
? {
|
||||
verify: {
|
||||
allowedIss: [env.authIssuer],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
if (env.authRequired) {
|
||||
app.log.info(
|
||||
{ issuer: env.authIssuer, portal: env.authPortalUrl },
|
||||
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||
)
|
||||
} else {
|
||||
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect /api/* when AUTH_REQUIRED=true.
|
||||
* Public: /health, /api/auth/config
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
if (!env.authRequired) return
|
||||
|
||||
const pathname = (request.url.split("?")[0] ?? request.url)
|
||||
if (pathname === "/api/auth/config") return
|
||||
|
||||
const authHeader = request.headers.authorization ?? ""
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const payload = request.user
|
||||
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
|
||||
const permissions = Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: []
|
||||
|
||||
if (!apps.includes("mm")) {
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ error: "Нет доступа к приложению MikrotikManager" })
|
||||
}
|
||||
|
||||
request.authUser = {
|
||||
id: String(payload.sub),
|
||||
email: String(payload.email ?? ""),
|
||||
name: String(payload.name ?? ""),
|
||||
apps,
|
||||
permissions,
|
||||
isAdmin: Boolean(payload.is_admin),
|
||||
}
|
||||
|
||||
const required = permissionForRequest(request.method, pathname)
|
||||
if (required && !hasPermission(permissions, required)) {
|
||||
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(authPlugin, { name: "auth" })
|
||||
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
|
||||
function normalizeApiKey(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
return trimmed.replace(/^Bearer\s+/i, "").trim()
|
||||
}
|
||||
|
||||
interface EvoCatalogRaw {
|
||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||
domains: {
|
||||
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
const row = ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = row.apiKey.trim()
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
if (!root || !apiKey) return null
|
||||
return { root, apiKey }
|
||||
}
|
||||
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let nextEnabled = cur.enabled
|
||||
let nextKey = cur.apiKey
|
||||
|
||||
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
|
||||
if (parsed.data.baseUrl !== undefined) {
|
||||
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
|
||||
}
|
||||
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
|
||||
if (parsed.data.apiKey !== undefined) {
|
||||
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
|
||||
nextKey =
|
||||
parsed.data.apiKey === null || parsed.data.apiKey === ""
|
||||
? ""
|
||||
: normalizeApiKey(parsed.data.apiKey)
|
||||
}
|
||||
|
||||
db.update(evobgpSettings)
|
||||
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const keyRaw =
|
||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||
const root = normalizeBaseUrl(urlRaw.trim())
|
||||
const token = keyRaw.trim()
|
||||
const token = normalizeApiKey(keyRaw)
|
||||
if (!root || !token) {
|
||||
return reply.status(400).send({
|
||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
|
||||
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||
import { listFirewallAll } from "../services/firewall-live.js"
|
||||
import type { FirewallFamily, FirewallTable } from "../types/server.js"
|
||||
|
||||
const FamilySchema = z.enum(["ip", "ip6"])
|
||||
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
|
||||
|
||||
const RuleKeySchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
table: TableSchema,
|
||||
rosId: z.string().min(1),
|
||||
})
|
||||
|
||||
const RuleWriteSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
table: TableSchema,
|
||||
rosId: z.string().min(1).optional(),
|
||||
chain: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
protocol: z.string().optional(),
|
||||
srcAddress: z.string().optional(),
|
||||
dstAddress: z.string().optional(),
|
||||
srcAddressList: z.string().optional(),
|
||||
dstAddressList: z.string().optional(),
|
||||
srcPort: z.string().optional(),
|
||||
dstPort: z.string().optional(),
|
||||
inInterface: z.string().optional(),
|
||||
outInterface: z.string().optional(),
|
||||
connectionState: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
log: z.boolean().optional(),
|
||||
logPrefix: z.string().optional(),
|
||||
tlsHost: z.string().optional(),
|
||||
layer7Proto: z.string().optional(),
|
||||
})
|
||||
|
||||
const RulePatchSchema = RuleKeySchema.extend({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
|
||||
const RuleMoveSchema = RuleKeySchema.extend({
|
||||
destinationRosId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
const AddressKeySchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
rosId: z.string().min(1),
|
||||
})
|
||||
|
||||
const AddressWriteSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
rosId: z.string().min(1).optional(),
|
||||
list: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
comment: z.string().optional(),
|
||||
timeout: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const AddressPatchSchema = AddressKeySchema.extend({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
|
||||
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
|
||||
return toRosBody({
|
||||
chain: d.chain,
|
||||
action: d.action,
|
||||
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
|
||||
"src-address": d.srcAddress,
|
||||
"dst-address": d.dstAddress,
|
||||
"src-address-list": d.srcAddressList,
|
||||
"dst-address-list": d.dstAddressList,
|
||||
"src-port": d.srcPort,
|
||||
"dst-port": d.dstPort,
|
||||
"in-interface": d.inInterface,
|
||||
"out-interface": d.outInterface,
|
||||
"connection-state": d.connectionState,
|
||||
comment: d.comment,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
|
||||
"log-prefix": d.logPrefix,
|
||||
"tls-host": d.tlsHost,
|
||||
"layer7-protocol": d.layer7Proto,
|
||||
})
|
||||
}
|
||||
|
||||
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
|
||||
return toRosBody({
|
||||
list: d.list,
|
||||
address: d.address,
|
||||
comment: d.comment,
|
||||
timeout: d.timeout,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function rosErr(e: unknown): string {
|
||||
if (e instanceof MikrotikError) return e.message
|
||||
if (e instanceof Error) return e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
function requireServer(serverId: string) {
|
||||
return getEnabledServerById(serverId)
|
||||
}
|
||||
|
||||
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/firewall/all", async (_req, reply) => {
|
||||
const data = await listFirewallAll()
|
||||
return reply.send(data)
|
||||
})
|
||||
|
||||
app.post("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
|
||||
try {
|
||||
await client.put(path, ruleToRos(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, ruleToRos(body))
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RulePatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleKeySchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.delete(path)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/firewall/rules/move", async (req, reply) => {
|
||||
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/move`
|
||||
try {
|
||||
await client.post(path, {
|
||||
numbers: body.rosId,
|
||||
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
|
||||
})
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, addressToRos(body))
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressKeySchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.delete(path)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default firewallRoutes
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
uptimeProbes,
|
||||
uptimeSpeedProbes,
|
||||
} from "../db/schema.js"
|
||||
import { listUsers } from "../modules/users/service/users-service.js"
|
||||
|
||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/sidebar-counts", async (_req, reply) => {
|
||||
@@ -18,6 +20,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
certificatesTotal,
|
||||
wireguardTotal,
|
||||
usersTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
@@ -25,6 +29,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
countWireGuardInterfaces().catch(() => 0),
|
||||
Promise.resolve(listUsers().length),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
@@ -35,6 +41,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||
recursiveRoutes: recursiveRoutesTotal,
|
||||
certificates: certificatesTotal,
|
||||
wireguard: wireguardTotal,
|
||||
users: usersTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import { env } from "../config.js"
|
||||
import {
|
||||
trafficFlowOverlayRequestSchema,
|
||||
trafficFlowSettingsPatchSchema,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
toTrafficFlowSettingsDto,
|
||||
updateTrafficFlowSettings,
|
||||
} from "../services/traffic-flow-settings.js"
|
||||
import {
|
||||
getFlowListenerState,
|
||||
purgeTrafficFlowStore,
|
||||
startTrafficFlowListener,
|
||||
listFlowTalkers,
|
||||
} from "../services/traffic-flow-ingest.js"
|
||||
import {
|
||||
buildFlowAnalytics,
|
||||
getFlowMonthly,
|
||||
listFlowClients,
|
||||
listFlowExporters,
|
||||
safeBuildLiveFlowSample,
|
||||
} from "../services/traffic-flow-analytics.js"
|
||||
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
|
||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
|
||||
const LIVE_TICK_MS = 2000
|
||||
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
|
||||
let liveSubscribers = 0
|
||||
|
||||
export function tryAcquireFlowLiveSlot(): boolean {
|
||||
if (liveSubscribers >= MAX_FLOW_LIVE_SUBSCRIBERS) return false
|
||||
liveSubscribers += 1
|
||||
return true
|
||||
}
|
||||
|
||||
export function releaseFlowLiveSlot(): void {
|
||||
liveSubscribers = Math.max(0, liveSubscribers - 1)
|
||||
}
|
||||
|
||||
export function resetFlowLiveSlotsForTests(): void {
|
||||
liveSubscribers = 0
|
||||
}
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
switch ((range ?? "5m").toLowerCase()) {
|
||||
case "5m": return 5
|
||||
case "15m": return 15
|
||||
case "1h": return 60
|
||||
case "4h": return 240
|
||||
case "24h": return 1440
|
||||
case "30d": return 1440
|
||||
default: return 5
|
||||
}
|
||||
}
|
||||
|
||||
function parseId(raw: unknown): number | undefined {
|
||||
if (raw == null || raw === "") return undefined
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function parseDedup(raw: unknown): boolean {
|
||||
if (raw == null || raw === "") return true
|
||||
const s = String(raw).toLowerCase()
|
||||
return s !== "0" && s !== "false" && s !== "off"
|
||||
}
|
||||
|
||||
function analyticsQuery(req: FastifyRequest) {
|
||||
const q = req.query as {
|
||||
range?: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
dedup?: string
|
||||
excludeMesh?: string
|
||||
excludeOverlay?: string
|
||||
}
|
||||
return {
|
||||
minutes: rangeToMinutes(q.range),
|
||||
serverId: parseId(q.serverId),
|
||||
userId: q.userId?.trim() || undefined,
|
||||
iface: q.iface?.trim() || undefined,
|
||||
dedup: parseDedup(q.dedup),
|
||||
excludeMesh: parseDedup(q.excludeMesh),
|
||||
excludeOverlay: parseDedup(q.excludeOverlay),
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||
}
|
||||
|
||||
function requestPublicHost(req: FastifyRequest): string {
|
||||
const forwarded = req.headers["x-forwarded-host"]
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||
return raw || req.hostname || ""
|
||||
}
|
||||
|
||||
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
try {
|
||||
const result = await applyFlowOverlay(parsed.data.serverId, {
|
||||
publicEndpoint: parsed.data.publicEndpoint,
|
||||
requestHost: requestPublicHost(req),
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (e) {
|
||||
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(status).send({ error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||
})
|
||||
|
||||
app.put("/traffic/flow/settings", async (req, reply) => {
|
||||
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
updateTrafficFlowSettings(parsed.data)
|
||||
startTrafficFlowListener()
|
||||
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
|
||||
const result = ensureHostKeys()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
created: result.created,
|
||||
publicKey: result.publicKey,
|
||||
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
if (!row.hostPrivateKey) ensureHostKeys()
|
||||
return reply.send({ files: listTrafficFlowHostFiles() })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/purge", async (_req, reply) => {
|
||||
try {
|
||||
const result = await purgeTrafficFlowStore()
|
||||
appendEvent({
|
||||
level: "warning",
|
||||
eventType: "traffic.flow.purge",
|
||||
sourceModule: "traffic",
|
||||
title: "Сброшены данные NetFlow",
|
||||
message: `Удалены сессии ${result.deleted.buckets}, minute ${result.deleted.minuteStats}, daily ${result.deleted.dailyDims}`,
|
||||
entityType: "traffic_flow",
|
||||
entityId: "purge",
|
||||
payload: {
|
||||
buckets: result.deleted.buckets,
|
||||
minuteStats: result.deleted.minuteStats,
|
||||
minuteDims: result.deleted.minuteDims,
|
||||
dailyDims: result.deleted.dailyDims,
|
||||
fileBytesBefore: result.fileBytesBefore,
|
||||
fileBytesAfter: result.fileBytesAfter,
|
||||
vacuumed: result.vacuumed,
|
||||
},
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const status = message.includes("уже выполняется") ? 409 : 500
|
||||
return reply.status(status).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||
|
||||
app.get("/traffic/flow", sendFlowTalkers)
|
||||
app.get("/traffic/flows", sendFlowTalkers)
|
||||
|
||||
app.get("/traffic/flow/exporters", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(listFlowExporters(rangeToMinutes(q.range)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/clients", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(listFlowClients(rangeToMinutes(q.range)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/analytics", async (req, reply) => {
|
||||
return reply.send(buildFlowAnalytics(analyticsQuery(req)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/map-hops", async (req, reply) => {
|
||||
return reply.send(buildFlowMapHops(analyticsQuery(req)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/monthly", async (req, reply) => {
|
||||
const q = req.query as { month?: string; serverId?: string }
|
||||
const now = new Date()
|
||||
const month = /^\d{4}-\d{2}$/.test(q.month ?? "")
|
||||
? (q.month as string)
|
||||
: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
|
||||
return reply.send(getFlowMonthly(month, parseId(q.serverId)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/live", async (req, reply) => {
|
||||
if (!tryAcquireFlowLiveSlot()) {
|
||||
return reply.status(429).send({ error: "Слишком много live-подписок" })
|
||||
}
|
||||
const query = analyticsQuery(req)
|
||||
const liveQuery = {
|
||||
serverId: query.serverId,
|
||||
userId: query.userId,
|
||||
iface: query.iface,
|
||||
dedup: query.dedup,
|
||||
excludeMesh: query.excludeMesh,
|
||||
excludeOverlay: query.excludeOverlay,
|
||||
}
|
||||
const abort = new AbortController()
|
||||
const onClose = () => abort.abort()
|
||||
req.raw.on("close", onClose)
|
||||
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0)
|
||||
reply.raw.setTimeout(0)
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||
const allowed = env.CORS_ORIGIN
|
||||
const sseHeaders: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
if (origin && (allowed === "*" || allowed === origin)) {
|
||||
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||
sseHeaders.Vary = "Origin"
|
||||
}
|
||||
reply.raw.writeHead(200, sseHeaders)
|
||||
reply.raw.write(":\n\n")
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
const payload = safeBuildLiveFlowSample(liveQuery)
|
||||
writeSse(reply.raw, payload.event, payload.data)
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
} catch {
|
||||
/* abort / disconnect */
|
||||
} finally {
|
||||
releaseFlowLiveSlot()
|
||||
req.raw.off("close", onClose)
|
||||
try {
|
||||
reply.raw.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficFlowRoutes
|
||||
+249
-95
@@ -1,3 +1,4 @@
|
||||
import { env } from "../config.js"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
@@ -12,6 +13,19 @@ import {
|
||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||
import {
|
||||
bpsToMbps,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
} from "../services/traffic-rate.js"
|
||||
import {
|
||||
buildBoundInterfaceTraffic,
|
||||
buildUserTrafficList,
|
||||
} from "../services/traffic-users.js"
|
||||
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
@@ -40,6 +54,9 @@ interface TrafficInterfaceDto {
|
||||
txNow: number
|
||||
}
|
||||
|
||||
const LIVE_TICK_MS = 1500
|
||||
const LIVE_ROS_TIMEOUT_MS = 4000
|
||||
|
||||
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
@@ -61,14 +78,6 @@ function rangeToMinutes(range: string | undefined): number {
|
||||
}
|
||||
}
|
||||
|
||||
function toSeries(values: number[], target = 60): number[] {
|
||||
if (values.length === 0) return Array(target).fill(0)
|
||||
if (values.length === target) return values
|
||||
if (values.length > target) return values.slice(values.length - target)
|
||||
const head = Array(target - values.length).fill(values[0] ?? 0)
|
||||
return [...head, ...values]
|
||||
}
|
||||
|
||||
function buildServerTraffic(
|
||||
s: typeof servers.$inferSelect,
|
||||
status: TrafficServerDto["status"],
|
||||
@@ -82,88 +91,135 @@ function buildServerTraffic(
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
): TrafficServerDto {
|
||||
const filteredRows = onlyInterface
|
||||
? rows.filter((r) => r.interfaceName === onlyInterface)
|
||||
: rows
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotal: 0,
|
||||
txTotal: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array(60).fill(0),
|
||||
txSeries: Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
const bySampleTs = new Map<string, { rx: number; tx: number }>()
|
||||
const byIface = new Map<string, typeof filteredRows>()
|
||||
for (const r of filteredRows) {
|
||||
const ts = r.sampledAt
|
||||
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
|
||||
cur.rx += Math.max(0, r.rxBps) / 1_000_000
|
||||
cur.tx += Math.max(0, r.txBps) / 1_000_000
|
||||
bySampleTs.set(ts, cur)
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const seriesPoints = [...bySampleTs.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
|
||||
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
|
||||
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
|
||||
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
|
||||
const txNow = txSeries[txSeries.length - 1] ?? 0
|
||||
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
for (const arr of byIface.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const first = sorted[0]
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (first && last) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
}
|
||||
}
|
||||
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow,
|
||||
txNow,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
rxNow: built.rxNow,
|
||||
txNow: built.txNow,
|
||||
rxPeak: built.rxPeak,
|
||||
txPeak: built.txPeak,
|
||||
rxTotal: built.rxTotalGiB,
|
||||
txTotal: built.txTotalGiB,
|
||||
sessions: built.sessions,
|
||||
rxSeries: built.rxSeries,
|
||||
txSeries: built.txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotStatus(serverId: number): TrafficServerDto["status"] {
|
||||
const snap = latestSnapshot(serverId)
|
||||
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
}
|
||||
|
||||
function ifaceNowMbps(
|
||||
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
|
||||
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
|
||||
): { rxNow: number; txNow: number } {
|
||||
if (!prev) {
|
||||
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
|
||||
}
|
||||
const t0 = Date.parse(prev.sampledAt)
|
||||
const t1 = Date.parse(last.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
|
||||
return {
|
||||
rxNow: bpsToMbps(rxBps ?? last.rxBps),
|
||||
txNow: bpsToMbps(txBps ?? last.txBps),
|
||||
}
|
||||
}
|
||||
|
||||
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function flattenMonitor(raw: unknown): unknown[] {
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (raw != null) return [raw]
|
||||
return []
|
||||
}
|
||||
|
||||
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
|
||||
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
|
||||
"/interface",
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
)
|
||||
return ifaces
|
||||
.filter((i) => (i.running ?? "false") === "true"
|
||||
&& (i.disabled ?? "false") !== "true"
|
||||
&& !isLoopbackName(i.name ?? ""))
|
||||
.map((i) => i.name ?? "")
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function monitorTrafficOnce(
|
||||
client: MikrotikClient,
|
||||
onlyInterface: string | undefined,
|
||||
cache: { names: string[]; joinedFailed: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
if (onlyInterface) {
|
||||
return client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: onlyInterface, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
if (cache.names.length === 0) {
|
||||
cache.names = await listRunningIfaceNames(client)
|
||||
}
|
||||
if (cache.names.length === 0) return []
|
||||
if (!cache.joinedFailed) {
|
||||
try {
|
||||
return await client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: cache.names.join(","), once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
} catch {
|
||||
cache.joinedFailed = true
|
||||
}
|
||||
}
|
||||
const chunks = await Promise.all(
|
||||
cache.names.map((name) =>
|
||||
client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: name, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
).then(flattenMonitor).catch(() => [] as unknown[]),
|
||||
),
|
||||
)
|
||||
return chunks.flat()
|
||||
}
|
||||
|
||||
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/settings", async (_req, reply) => {
|
||||
const settings = getTrafficSettings()
|
||||
@@ -243,20 +299,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/servers", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const data = allServers.map((s): TrafficServerDto => {
|
||||
const snap = latestSnapshot(s.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
|
||||
const rows = readServerSamplesInRange(s.id, sinceIso)
|
||||
return buildServerTraffic(s, status, rows)
|
||||
return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
|
||||
})
|
||||
|
||||
return reply.send({ servers: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/live", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const q = req.query as { iface?: string }
|
||||
const server = getEnabledServerById(p.id ?? "")
|
||||
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
|
||||
const abort = new AbortController()
|
||||
const onClose = () => abort.abort()
|
||||
req.raw.on("close", onClose)
|
||||
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0)
|
||||
reply.raw.setTimeout(0)
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||
const allowed = env.CORS_ORIGIN
|
||||
const sseHeaders: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
if (origin && (allowed === "*" || allowed === origin)) {
|
||||
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||
sseHeaders.Vary = "Origin"
|
||||
}
|
||||
reply.raw.writeHead(200, sseHeaders)
|
||||
reply.raw.write(":\n\n")
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
|
||||
const sample = parseMonitorTraffic(raw, { onlyInterface })
|
||||
writeSse(reply.raw, "sample", sample)
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) break
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
writeSse(reply.raw, "error", { error: msg })
|
||||
}
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
} catch {
|
||||
/* abort / disconnect */
|
||||
} finally {
|
||||
req.raw.off("close", onClose)
|
||||
try {
|
||||
reply.raw.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
@@ -270,6 +383,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const rows = readServerSamplesInRange(serverId, sinceIso)
|
||||
const byIface = new Map<string, typeof rows>()
|
||||
for (const r of rows) {
|
||||
if (isLoopbackName(r.interfaceName)) continue
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
@@ -277,12 +391,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
||||
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
const prev = sorted[sorted.length - 2]
|
||||
if (!last) {
|
||||
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
|
||||
}
|
||||
const now = ifaceNowMbps(prev, last)
|
||||
return {
|
||||
name,
|
||||
running: Boolean(last?.running),
|
||||
disabled: Boolean(last?.disabled),
|
||||
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
|
||||
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
|
||||
running: Boolean(last.running),
|
||||
disabled: Boolean(last.disabled),
|
||||
rxNow: now.rxNow,
|
||||
txNow: now.txNow,
|
||||
}
|
||||
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
||||
|
||||
@@ -298,15 +417,50 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const rows = readServerSamplesInRange(server.id, sinceIso)
|
||||
const snap = latestSnapshot(server.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
|
||||
const data = buildServerTraffic(
|
||||
server,
|
||||
snapshotStatus(server.id),
|
||||
rows,
|
||||
rangeStartMs,
|
||||
rangeEndMs,
|
||||
q.iface && q.iface !== "__all__" ? q.iface : undefined,
|
||||
)
|
||||
return reply.send({ server: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/users", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
return reply.send({ users: buildUserTrafficList(rangeStartMs, rangeEndMs) })
|
||||
})
|
||||
|
||||
app.get("/traffic/users/:id", async (req, reply) => {
|
||||
const p = req.params as { id?: string }
|
||||
const q = req.query as { range?: string }
|
||||
const id = String(p.id ?? "")
|
||||
if (!id) return reply.status(400).send({ error: "id is required" })
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const users = buildUserTrafficList(rangeStartMs, rangeEndMs)
|
||||
const user = users.find((u) => u.id === id)
|
||||
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||
return reply.send({ user })
|
||||
})
|
||||
|
||||
app.get("/traffic/bound-interfaces", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
return reply.send({ interfaces: buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficRoutes
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
appUserCreateSchema,
|
||||
appUserIdParamSchema,
|
||||
appUserUpdateSchema,
|
||||
bindingIdParamSchema,
|
||||
interfaceCatalogQuerySchema,
|
||||
userBindingCreateSchema,
|
||||
type AppUserCreate,
|
||||
type AppUserUpdate,
|
||||
type UserBindingCreate,
|
||||
} from "@mmapp/contracts/users"
|
||||
import {
|
||||
addBinding,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
listInterfaceCatalog,
|
||||
listUsers,
|
||||
removeBinding,
|
||||
updateUser,
|
||||
UsersServiceError,
|
||||
} from "../modules/users/service/users-service.js"
|
||||
|
||||
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
|
||||
if (err instanceof UsersServiceError) {
|
||||
return reply.status(err.status).send({ error: err.message })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/users", async (_req, reply) => {
|
||||
return reply.send({ users: listUsers() })
|
||||
})
|
||||
|
||||
app.get("/users/interface-catalog", {
|
||||
schema: { querystring: interfaceCatalogQuerySchema },
|
||||
}, async (req, reply) => {
|
||||
const q = req.query as { serverId: number }
|
||||
try {
|
||||
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
const user = getUserById(id)
|
||||
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||
return reply.send({ user })
|
||||
})
|
||||
|
||||
app.post("/users", {
|
||||
schema: { body: appUserCreateSchema },
|
||||
}, async (req, reply) => {
|
||||
try {
|
||||
const user = createUser(req.body as AppUserCreate)
|
||||
return reply.status(201).send({ user })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
const user = updateUser(id, req.body as AppUserUpdate)
|
||||
return reply.send({ user })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
deleteUser(id)
|
||||
return reply.status(204).send()
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/users/:id/bindings", {
|
||||
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
const binding = addBinding(id, req.body as UserBindingCreate)
|
||||
return reply.status(201).send({ binding })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/users/:id/bindings/:bindingId", {
|
||||
schema: { params: bindingIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id, bindingId } = req.params as { id: string; bindingId: string }
|
||||
try {
|
||||
removeBinding(id, bindingId)
|
||||
return reply.status(204).send()
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default usersRoutes
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
wgCreateInterfaceSchema,
|
||||
wgCreatePeerRequestSchema,
|
||||
wgExportRequestSchema,
|
||||
wgImportRequestSchema,
|
||||
wgPatchInterfaceSchema,
|
||||
wgPatchPeerSchema,
|
||||
type WgCreatePeerRequest,
|
||||
type WgIfaceDto,
|
||||
} from "@mmapp/contracts/wireguard"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import {
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
generatePeerClientConf,
|
||||
parseWgConfig,
|
||||
type WgParsedConfig,
|
||||
} from "../services/wireguard-config.js"
|
||||
import {
|
||||
getEnabledServerById,
|
||||
listWireGuardInterfaces,
|
||||
} from "../services/wireguard-live.js"
|
||||
import {
|
||||
putIpAddress,
|
||||
putWireguardInterface,
|
||||
putWireguardPeer,
|
||||
toRosBody,
|
||||
} from "../services/wireguard-ros.js"
|
||||
|
||||
function serverIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||
return toRosBody({
|
||||
interface: p.interfaceName,
|
||||
"public-key": p.publicKey,
|
||||
"allowed-address": p.allowedAddresses.join(","),
|
||||
"endpoint-address": p.endpointAddress,
|
||||
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
|
||||
"persistent-keepalive":
|
||||
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
|
||||
"client-address": p.clientAddress,
|
||||
"client-dns": p.clientDns,
|
||||
"client-endpoint": p.clientEndpoint,
|
||||
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function previewFromParsed(parsed: WgParsedConfig) {
|
||||
return {
|
||||
format: parsed.format,
|
||||
interface: {
|
||||
name: parsed.interface.name,
|
||||
listenPort: parsed.interface.listenPort,
|
||||
mtu: parsed.interface.mtu,
|
||||
privateKey: parsed.interface.privateKey,
|
||||
comment: parsed.interface.comment,
|
||||
address: parsed.interface.address,
|
||||
disabled: parsed.interface.disabled,
|
||||
},
|
||||
peers: parsed.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedAddresses: p.allowedAddresses,
|
||||
endpointAddress: p.endpointAddress,
|
||||
endpointPort: p.endpointPort,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
privateKey: p.privateKey,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
disabled: p.disabled,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function applyParsedConfig(
|
||||
client: MikrotikClient,
|
||||
parsed: WgParsedConfig,
|
||||
): Promise<{ interfaceName: string; peersCreated: number }> {
|
||||
const name = parsed.interface.name
|
||||
const ifaceBody = toRosBody({
|
||||
name,
|
||||
"listen-port": String(parsed.interface.listenPort ?? 13231),
|
||||
mtu: String(parsed.interface.mtu ?? 1420),
|
||||
"private-key": parsed.interface.privateKey,
|
||||
comment: parsed.interface.comment,
|
||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||
})
|
||||
await putWireguardInterface(client, ifaceBody)
|
||||
|
||||
if (parsed.interface.address) {
|
||||
await putIpAddress(client, parsed.interface.address, name)
|
||||
}
|
||||
|
||||
let peersCreated = 0
|
||||
for (const p of parsed.peers) {
|
||||
if (!p.publicKey) continue
|
||||
await putWireguardPeer(
|
||||
client,
|
||||
peerToRosBody({
|
||||
interfaceName: name,
|
||||
publicKey: p.publicKey,
|
||||
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
|
||||
endpointAddress: p.endpointAddress,
|
||||
endpointPort: p.endpointPort,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
privateKey: p.privateKey,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
disabled: p.disabled,
|
||||
}),
|
||||
)
|
||||
peersCreated += 1
|
||||
}
|
||||
return { interfaceName: name, peersCreated }
|
||||
}
|
||||
|
||||
function findIface(
|
||||
list: WgIfaceDto[],
|
||||
serverId: string,
|
||||
interfaceName: string,
|
||||
): WgIfaceDto | undefined {
|
||||
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
|
||||
}
|
||||
|
||||
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/wireguard", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string; includePrivateKey?: string }
|
||||
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
|
||||
const result = await listWireGuardInterfaces({
|
||||
serverId: q.serverId,
|
||||
includePrivateKey,
|
||||
})
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/wireguard/interfaces", async (req, reply) => {
|
||||
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await putWireguardInterface(client, {
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
})
|
||||
|
||||
if (body.address) {
|
||||
await putIpAddress(client, body.address, body.name)
|
||||
}
|
||||
|
||||
if (body.peer) {
|
||||
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
||||
}
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
serverId: String(server.id),
|
||||
includePrivateKey: true,
|
||||
})
|
||||
const created = list.interfaces.find((i) => i.name === body.name)
|
||||
return reply.status(201).send(created ?? { ok: true, name: body.name })
|
||||
} catch (e) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const d = parsed.data
|
||||
try {
|
||||
await client.patch(
|
||||
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
|
||||
toRosBody({
|
||||
name: d.name,
|
||||
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
|
||||
mtu: d.mtu != null ? String(d.mtu) : undefined,
|
||||
comment: d.comment,
|
||||
"private-key": d.privateKey,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
}),
|
||||
)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/peers", async (req, reply) => {
|
||||
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await putWireguardPeer(client, peerToRosBody(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const d = parsed.data
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.patch(
|
||||
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
|
||||
toRosBody({
|
||||
"public-key": d.publicKey,
|
||||
"allowed-address": d.allowedAddresses?.join(","),
|
||||
"endpoint-address": d.endpointAddress,
|
||||
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
|
||||
"persistent-keepalive":
|
||||
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
|
||||
comment: d.comment,
|
||||
name: d.name,
|
||||
"client-address": d.clientAddress,
|
||||
"client-dns": d.clientDns,
|
||||
"client-endpoint": d.clientEndpoint,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
}),
|
||||
)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/import", async (req, reply) => {
|
||||
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
let config: WgParsedConfig
|
||||
try {
|
||||
config = parseWgConfig(body.content, body.format)
|
||||
} catch (e) {
|
||||
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
|
||||
}
|
||||
const preview = previewFromParsed(config)
|
||||
if (body.dryRun) {
|
||||
return reply.send({ dryRun: true, preview })
|
||||
}
|
||||
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const applied = await applyParsedConfig(client, config)
|
||||
return reply.send({ dryRun: false, preview, applied })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/export", async (req, reply) => {
|
||||
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
serverId: String(server.id),
|
||||
includePrivateKey: body.includePrivateKey === true,
|
||||
})
|
||||
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
|
||||
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
|
||||
|
||||
if (body.format === "rsc") {
|
||||
const content = generateMikrotikRsc({
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
})),
|
||||
})
|
||||
return reply.send({
|
||||
format: "rsc",
|
||||
filename: `${iface.name}.rsc`,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.format === "conf") {
|
||||
const content = generateNativeConf(
|
||||
{
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: iface.privateKey,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
})),
|
||||
},
|
||||
{ includePrivateKey: body.includePrivateKey === true },
|
||||
)
|
||||
return reply.send({
|
||||
format: "conf",
|
||||
filename: `${iface.name}.conf`,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
// peer-conf
|
||||
const peer = body.peerId
|
||||
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
|
||||
: iface.peers[0]
|
||||
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
|
||||
if (!iface.publicKey) {
|
||||
return reply.status(400).send({ error: "У интерфейса нет public-key" })
|
||||
}
|
||||
const endpoint =
|
||||
peer.clientEndpoint ||
|
||||
(peer.endpoint
|
||||
? peer.endpoint
|
||||
: undefined)
|
||||
const content = generatePeerClientConf({
|
||||
peerAddress: peer.clientAddress,
|
||||
peerDns: peer.clientDns,
|
||||
serverPublicKey: iface.publicKey,
|
||||
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
|
||||
endpoint:
|
||||
endpoint ||
|
||||
(peer.clientEndpoint
|
||||
? peer.clientEndpoint.includes(":")
|
||||
? peer.clientEndpoint
|
||||
: `${peer.clientEndpoint}:${iface.listenPort}`
|
||||
: undefined),
|
||||
persistentKeepalive: peer.persistentKeepalive ?? 25,
|
||||
})
|
||||
return reply.send({
|
||||
format: "peer-conf",
|
||||
filename: `${iface.name}-peer.conf`,
|
||||
content,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default wireguardRoutes
|
||||
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
|
||||
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||
const existingA = records.find((record) => record.type === "A")
|
||||
if (existingA) {
|
||||
if (existingA.content === ip) return
|
||||
if (existingA.content === ip) return "updated"
|
||||
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return
|
||||
return "updated"
|
||||
}
|
||||
|
||||
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
|
||||
if (records.some((record) => record.type === "CNAME")) {
|
||||
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
|
||||
return "skipped_cname"
|
||||
}
|
||||
|
||||
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
||||
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return "created"
|
||||
}
|
||||
|
||||
async function syncCertificateDomainRecords(
|
||||
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
|
||||
domains: string[],
|
||||
serverIp: string,
|
||||
defaultZoneId?: string,
|
||||
): Promise<void> {
|
||||
): Promise<{ skippedCname: string[] }> {
|
||||
const skippedCname: string[] = []
|
||||
for (const domain of domains) {
|
||||
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
||||
await upsertARecord(token, zoneId, domain, serverIp)
|
||||
const result = await upsertARecord(token, zoneId, domain, serverIp)
|
||||
if (result === "skipped_cname") skippedCname.push(domain)
|
||||
}
|
||||
return { skippedCname }
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
|
||||
const finalized = await client.finalizeOrder(order, csr)
|
||||
const certPem = await client.getCertificate(finalized)
|
||||
|
||||
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
|
||||
const clientRos = MikrotikClient.fromServer(params.server)
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
|
||||
try {
|
||||
params.onStep?.("dns_a_sync")
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
const { skippedCname } = await syncCertificateDomainRecords(
|
||||
token,
|
||||
domains,
|
||||
serverIp,
|
||||
settings.defaultZoneId,
|
||||
)
|
||||
if (skippedCname.length > 0) {
|
||||
params.onStep?.(
|
||||
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
|
||||
params.onStep?.(`dns_a_sync_warn:${msg}`)
|
||||
}
|
||||
|
||||
const trustStores = params.trustStore.filter(Boolean)
|
||||
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import {
|
||||
MikrotikClient,
|
||||
firewallRestPath,
|
||||
} from "./mikrotik.js"
|
||||
import type {
|
||||
FirewallFamily,
|
||||
FirewallTable,
|
||||
RosFirewallAddressList,
|
||||
RosFirewallFilter,
|
||||
} from "../types/server.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
export interface FirewallRuleDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
table: FirewallTable
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
hits: number
|
||||
log: boolean
|
||||
logPrefix: string
|
||||
tlsHost?: string
|
||||
layer7Proto?: string
|
||||
}
|
||||
|
||||
export interface FirewallAddressListDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
list: string
|
||||
address: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
|
||||
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
|
||||
|
||||
function dash(v: string | undefined): string {
|
||||
const s = v?.trim() ?? ""
|
||||
return s.length > 0 ? s : "—"
|
||||
}
|
||||
|
||||
function rosDisabled(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
function parseHits(raw: RosFirewallFilter): number {
|
||||
const n = Number.parseInt(raw.packets ?? "0", 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export function ruleUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:${table}:${rosId}`
|
||||
}
|
||||
|
||||
export function addressUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:address-list:${rosId}`
|
||||
}
|
||||
|
||||
export function mapFirewallRule(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
raw: RosFirewallFilter,
|
||||
idx: number,
|
||||
): FirewallRuleDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
const src = raw["src-address"] || raw["src-address-list"]
|
||||
const dst = raw["dst-address"] || raw["dst-address-list"]
|
||||
const port = raw["dst-port"] || raw["src-port"]
|
||||
const iface = raw["in-interface"] || raw["out-interface"]
|
||||
return {
|
||||
id: ruleUiId(server.id, family, table, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
table,
|
||||
chain: raw.chain || "",
|
||||
action: raw.action || "",
|
||||
proto: raw.protocol || "all",
|
||||
src: dash(src),
|
||||
dst: dash(dst),
|
||||
port: dash(port),
|
||||
iface: dash(iface),
|
||||
comment: raw.comment ?? "",
|
||||
enabled: !rosDisabled(raw.disabled),
|
||||
hits: parseHits(raw),
|
||||
log: raw.log === "true" || raw.log === "yes",
|
||||
logPrefix: raw["log-prefix"] ?? "",
|
||||
tlsHost: raw["tls-host"],
|
||||
layer7Proto: raw["layer7-protocol"],
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAddressList(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
raw: RosFirewallAddressList,
|
||||
idx: number,
|
||||
): FirewallAddressListDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
return {
|
||||
id: addressUiId(server.id, family, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
list: raw.list || "",
|
||||
address: raw.address || "",
|
||||
comment: raw.comment ?? "",
|
||||
disabled: rosDisabled(raw.disabled),
|
||||
timeout: raw.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
|
||||
try {
|
||||
const rows = await fn()
|
||||
return Array.isArray(rows) ? rows : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServerFirewall(server: ServerRow): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const ruleJobs = FAMILIES.flatMap((family) =>
|
||||
TABLES.map(async (table) => {
|
||||
const raw = await safeGet(() => client.getFirewallRules(family, table))
|
||||
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
|
||||
}),
|
||||
)
|
||||
const listJobs = FAMILIES.map(async (family) => {
|
||||
const raw = await safeGet(() => client.getFirewallAddressList(family))
|
||||
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
|
||||
})
|
||||
const [ruleChunks, listChunks] = await Promise.all([
|
||||
Promise.all(ruleJobs),
|
||||
Promise.all(listJobs),
|
||||
])
|
||||
return {
|
||||
rules: ruleChunks.flat(),
|
||||
addressLists: listChunks.flat(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFirewallAll(): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
return await fetchServerFirewall(server)
|
||||
} catch {
|
||||
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rules: perServer.flatMap((r) => r.rules),
|
||||
addressLists: perServer.flatMap((r) => r.addressLists),
|
||||
}
|
||||
}
|
||||
|
||||
export { firewallRestPath, FAMILIES, TABLES }
|
||||
@@ -7,9 +7,20 @@ import type {
|
||||
RosBgpSession,
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
RosBfdSession,
|
||||
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
|
||||
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
|
||||
FirewallFamily, FirewallTable,
|
||||
} from "../types/server.js"
|
||||
|
||||
const MAX_ROS_BODY_BYTES = 8 * 1024 * 1024
|
||||
|
||||
function appendRosBody(body: string, chunk: string, req?: http.ClientRequest): string {
|
||||
if (body.length + chunk.length > MAX_ROS_BODY_BYTES) {
|
||||
req?.destroy(new Error("RouterOS: ответ больше 8 МиБ"))
|
||||
return body
|
||||
}
|
||||
return body + chunk
|
||||
}
|
||||
|
||||
// ── connection params ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface MikrotikConnectParams {
|
||||
@@ -51,7 +62,7 @@ function rosRequest(
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body += chunk })
|
||||
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
@@ -134,7 +145,7 @@ function rosPost(
|
||||
req = lib.request(options, (res) => {
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf += chunk })
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
res.on("end", () => {
|
||||
settle(() => {
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
@@ -188,7 +199,7 @@ function rosPut(
|
||||
const req = lib.request(options, (res) => {
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf += chunk })
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
@@ -238,7 +249,7 @@ function rosDelete(
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body += chunk })
|
||||
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
@@ -288,7 +299,7 @@ function rosPatch(
|
||||
const req = lib.request(options, (res) => {
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf += chunk })
|
||||
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
@@ -338,6 +349,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
export function firewallRestPath(
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable | "address-list",
|
||||
): string {
|
||||
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
|
||||
return `${root}/${table}`
|
||||
}
|
||||
|
||||
export function encodeRosId(rosId: string): string {
|
||||
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||
return encodeURIComponent(id)
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
@@ -461,7 +485,15 @@ export class MikrotikClient {
|
||||
}
|
||||
|
||||
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
||||
return this.getFirewallRules("ip", "filter")
|
||||
}
|
||||
|
||||
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
|
||||
}
|
||||
|
||||
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
|
||||
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
|
||||
}
|
||||
|
||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||
|
||||
@@ -4,16 +4,19 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import Database from "better-sqlite3"
|
||||
import { env } from "../config.js"
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
import { beginSqliteExclusiveOp, endSqliteExclusiveOp, reopenSqlite, sqliteDatabase } from "../db/index.js"
|
||||
import { refreshScheduler, stopScheduler } from "./scheduler.js"
|
||||
import {
|
||||
reattachFlowSqlite,
|
||||
startTrafficFlowListener,
|
||||
stopTrafficFlowListener,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
|
||||
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
|
||||
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
|
||||
let operationInFlight = false
|
||||
|
||||
function fmtTimestamp(date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
||||
@@ -33,16 +36,15 @@ function assertSqliteFile(buffer: Buffer): void {
|
||||
}
|
||||
|
||||
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
if (operationInFlight) {
|
||||
throw new Error("Операция с базой данных уже выполняется")
|
||||
}
|
||||
operationInFlight = true
|
||||
beginSqliteExclusiveOp()
|
||||
stopTrafficFlowListener()
|
||||
stopScheduler()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
startTrafficFlowListener()
|
||||
refreshScheduler()
|
||||
operationInFlight = false
|
||||
endSqliteExclusiveOp()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +82,8 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void>
|
||||
await writeFile(tempPath, buffer)
|
||||
source = new Database(tempPath, { readonly: true, fileMustExist: true })
|
||||
await source.backup(resolveDatabasePath())
|
||||
reopenSqlite()
|
||||
reattachFlowSqlite()
|
||||
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} finally {
|
||||
source?.close()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
||||
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
|
||||
|
||||
interface RosIfaceTraffic {
|
||||
".id"?: string
|
||||
name?: string
|
||||
ifindex?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
"rx-byte"?: string
|
||||
@@ -15,6 +19,38 @@ interface RosIfaceTraffic {
|
||||
"tx-bits-per-second"?: string
|
||||
}
|
||||
|
||||
interface RosWgPeerTraffic {
|
||||
interface?: string
|
||||
name?: string
|
||||
comment?: string
|
||||
"public-key"?: string
|
||||
rx?: string
|
||||
tx?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
function waveKey(interfaceName: string, peerPublicKey = ""): string {
|
||||
return `${interfaceName}\0${peerPublicKey}`
|
||||
}
|
||||
|
||||
function sampleRate(
|
||||
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
|
||||
key: string,
|
||||
rxBytes: number,
|
||||
txBytes: number,
|
||||
nowMs: number,
|
||||
): { rxBps: number; txBps: number } {
|
||||
const prev = prevWave.get(key)
|
||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||
const rxBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const txBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
return { rxBps, txBps }
|
||||
}
|
||||
|
||||
export interface TrafficCollectorState {
|
||||
running: boolean
|
||||
lastRunAt: string | null
|
||||
@@ -50,6 +86,32 @@ function cleanupOldSamples(retentionDays: number) {
|
||||
.run()
|
||||
}
|
||||
|
||||
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
|
||||
const last = db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (!last) return new Map()
|
||||
const rows = db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
rxBytes: trafficSamples.rxBytes,
|
||||
txBytes: trafficSamples.txBytes,
|
||||
sampledAt: trafficSamples.sampledAt,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||
))
|
||||
.all()
|
||||
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
if (collecting) {
|
||||
@@ -80,26 +142,70 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||
let sumRx = 0
|
||||
let sumTx = 0
|
||||
for (const i of ifaces) {
|
||||
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
||||
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
||||
}
|
||||
if (ifaces.length > 0) {
|
||||
db.insert(trafficSamples).values(
|
||||
ifaces.map((i) => ({
|
||||
rememberServerIfaces(srv.id, ifaces)
|
||||
const prevWave = readPreviousWave(srv.id)
|
||||
const nowMs = Date.parse(now)
|
||||
let sumRxMbps = 0
|
||||
let sumTxMbps = 0
|
||||
const rows = ifaces.map((i) => {
|
||||
const interfaceName = i.name ?? "unknown"
|
||||
const rxBytes = toNum(i["rx-byte"])
|
||||
const txBytes = toNum(i["tx-byte"])
|
||||
const running = (i.running ?? "false") === "true"
|
||||
const disabled = (i.disabled ?? "false") === "true"
|
||||
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
|
||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||
sumRxMbps += bpsToMbps(rxBps)
|
||||
sumTxMbps += bpsToMbps(txBps)
|
||||
}
|
||||
return {
|
||||
serverId: srv.id,
|
||||
interfaceName,
|
||||
peerPublicKey: "",
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
}
|
||||
})
|
||||
try {
|
||||
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
|
||||
for (const p of peers) {
|
||||
const interfaceName = (p.interface ?? "").trim()
|
||||
const peerPublicKey = (p["public-key"] ?? "").trim()
|
||||
if (!interfaceName || !peerPublicKey) continue
|
||||
const rxBytes = toNum(p.rx)
|
||||
const txBytes = toNum(p.tx)
|
||||
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
|
||||
const running = !disabled
|
||||
const { rxBps, txBps } = sampleRate(
|
||||
prevWave,
|
||||
waveKey(interfaceName, peerPublicKey),
|
||||
rxBytes,
|
||||
txBytes,
|
||||
nowMs,
|
||||
)
|
||||
rows.push({
|
||||
serverId: srv.id,
|
||||
interfaceName: i.name ?? "unknown",
|
||||
interfaceName,
|
||||
peerPublicKey,
|
||||
sampledAt: now,
|
||||
rxBytes: toNum(i["rx-byte"]),
|
||||
txBytes: toNum(i["tx-byte"]),
|
||||
rxBps: toNum(i["rx-bits-per-second"]),
|
||||
txBps: toNum(i["tx-bits-per-second"]),
|
||||
running: (i.running ?? "false") === "true",
|
||||
disabled: (i.disabled ?? "false") === "true",
|
||||
})),
|
||||
).run()
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* WG peers optional — iface samples already recorded */
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
db.insert(trafficSamples).values(rows).run()
|
||||
}
|
||||
snapshot.servers.push({
|
||||
serverId: srv.id,
|
||||
@@ -107,8 +213,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
host: srv.host,
|
||||
ok: true,
|
||||
interfaces: ifaces.length,
|
||||
sumRxMbps: Math.round(sumRx),
|
||||
sumTxMbps: Math.round(sumTx),
|
||||
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
|
||||
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
|
||||
})
|
||||
} catch (err) {
|
||||
snapshot.servers.push({
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||
import {
|
||||
ingestParsedFlowsForServerForTests,
|
||||
resetFlowRingsForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||
import {
|
||||
disableRipeEnqueueForTests,
|
||||
disableRipePersistForTests,
|
||||
resetRipeCacheForTests,
|
||||
seedRipeCacheForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
disableCatalogFetchForTests()
|
||||
resetFlowCatalogForTests()
|
||||
disableRipePersistForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetFlowRingsForTests()
|
||||
seedFlowTopologyForTests({
|
||||
clientIfaces: new Map(),
|
||||
clientByIface: new Map(),
|
||||
enNodes: [],
|
||||
enHosts: new Set(),
|
||||
jhHosts: new Set(),
|
||||
wanIfaces: new Map(),
|
||||
plane: { clientIfaceNames: new Set(), enHosts: new Set(), jhHosts: new Set() },
|
||||
})
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "ether1" },
|
||||
{ ".id": "*B", name: "ether2" },
|
||||
{ ".id": "*A", name: "wg-flow" },
|
||||
])
|
||||
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "11",
|
||||
},
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "1.1.1.1",
|
||||
proto: 17,
|
||||
srcPort: 53000,
|
||||
dstPort: 53,
|
||||
bytes: 800,
|
||||
packets: 4,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
|
||||
try {
|
||||
const all = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
assert.equal(all.applications[0]?.label, "HTTPS")
|
||||
assert.ok(all.protocols.some((p) => p.label === "TCP"))
|
||||
assert.equal(all.ifaces[0]?.name, "ether1")
|
||||
assert.notEqual(all.ifaces[0]?.name, "2")
|
||||
const conv = all.conversationsList[0]
|
||||
assert.ok(conv)
|
||||
assert.equal(conv.inIface, "ether1")
|
||||
assert.equal(conv.inIfaceIndex, "2")
|
||||
assert.equal(conv.application, "HTTPS")
|
||||
assert.ok(!/^\d+$/.test(conv.inIface))
|
||||
|
||||
const filtered = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" })
|
||||
assert.ok(filtered.bytes >= 12_000)
|
||||
assert.equal(filtered.ifaces[0]?.name, "ether1")
|
||||
|
||||
const miss = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" })
|
||||
assert.equal(miss.conversations, 0)
|
||||
|
||||
const other = buildFlowAnalytics({ minutes: 5, serverId: 99 })
|
||||
assert.equal(other.conversations, 0)
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "ether1" },
|
||||
{ ".id": "*B", name: "ether2" },
|
||||
{ ".id": "*A", name: "wg-flow" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "11",
|
||||
},
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 9_000,
|
||||
packets: 9,
|
||||
inIface: "11",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const summed = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false })
|
||||
assert.equal(summed.bytes, 21_000)
|
||||
assert.equal(summed.conversations, 2)
|
||||
const deduped = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true })
|
||||
assert.equal(deduped.bytes, 12_000)
|
||||
assert.equal(deduped.conversations, 1)
|
||||
assert.equal(deduped.dedupApplied, true)
|
||||
assert.equal(deduped.interfaces.length, 2)
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedRipeCacheForTests({
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: 37.4,
|
||||
lng: -122.1,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
seedFlowCatalogForTests({
|
||||
cidrs: [{ cidr: "8.8.8.0/24", purpose: "steam-gaming" }],
|
||||
})
|
||||
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const geo = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
assert.equal(geo.categories?.[0]?.label, "Игры")
|
||||
assert.ok(geo.asns?.some((r) => r.label.includes("AS15169")))
|
||||
assert.equal(geo.countries?.[0]?.id, "US")
|
||||
assert.equal(geo.mapEdges?.[0]?.toCountry, "US")
|
||||
assert.ok(geo.mapEdges?.every((e) => e.toCountry !== "?"))
|
||||
assert.equal(geo.conversationsList[0]?.dstCountry, "US")
|
||||
assert.equal(geo.asns?.[0]?.id, "15169")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
seedRipeCacheForTests({
|
||||
prefix: "1.1.1.0/24",
|
||||
asn: 13335,
|
||||
country: "?",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "CLOUDFLARENET, US",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "1.1.1.1",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 5000,
|
||||
packets: 5,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const cf = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
assert.equal(cf.countries?.[0]?.id, "US")
|
||||
assert.ok(cf.mapEdges?.every((e) => e.toCountry !== "?"))
|
||||
assert.equal(cf.services?.[0]?.label, "Cloudflare")
|
||||
assert.equal(cf.categories?.[0]?.label, "CDN")
|
||||
assert.equal(cf.conversationsList[0]?.dstCountry, "US")
|
||||
assert.equal(cf.asns?.[0]?.id, "13335")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
{
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
const degraded = buildFlowAnalytics({ minutes: 5, serverId: 7, skipHeavy: true })
|
||||
assert.equal(degraded.degraded, true)
|
||||
assert.equal(degraded.conversationsList.length, 0)
|
||||
assert.ok((degraded.bytes ?? 0) >= 12_000)
|
||||
const liveErr = formatLiveSseFromBuilder(() => {
|
||||
throw new Error("SQLITE_BUSY")
|
||||
})
|
||||
assert.equal(liveErr.event, "error")
|
||||
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
|
||||
const liveOk = formatLiveSseFromBuilder(() => ({ ok: true }))
|
||||
assert.equal(liveOk.event, "sample")
|
||||
const exporters = listFlowExporters(5)
|
||||
const clients = listFlowClients(5)
|
||||
assert.ok(Array.isArray(exporters.exporters))
|
||||
assert.ok(Array.isArray(clients.clients))
|
||||
resetFlowRingsForTests()
|
||||
}
|
||||
|
||||
{
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
|
||||
sqliteDatabase.exec(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES
|
||||
(7, '2026-09-01', 'country', 'US', 1000, 10),
|
||||
(7, '2026-09-02', 'country', 'US', 500, 5),
|
||||
(7, '2026-09-01', 'service', 'steam', 800, 8),
|
||||
(7, '2026-09-01', 'asn', '15169', 900, 9),
|
||||
(7, '2026-09-01', 'asn', 'other', 100, 1)
|
||||
`)
|
||||
const monthly = getFlowMonthly("2026-09", 7)
|
||||
assert.equal(monthly.bytes, 1500)
|
||||
assert.equal(monthly.countries[0]?.id, "US")
|
||||
assert.equal(monthly.countries[0]?.bytes, 1500)
|
||||
assert.ok(monthly.asns.some((row) => row.id === "other"))
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
|
||||
}
|
||||
|
||||
{
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "NSK-SERVHOST-RTK" },
|
||||
{ ".id": "*4", name: "gre-en-nsk" },
|
||||
])
|
||||
const topo: FlowTopology = {
|
||||
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
|
||||
clientByIface: new Map([["7|gre-client", {
|
||||
userId: "u1",
|
||||
login: "alice",
|
||||
name: "Alice",
|
||||
serverId: 7,
|
||||
interfaceName: "gre-client",
|
||||
}]]),
|
||||
enNodes: [{ id: 9, name: "NSK-SERVHOST-RTK", hosts: ["198.51.100.1"] }],
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
wanIfaces: new Map(),
|
||||
plane: {
|
||||
clientIfaceNames: new Set(["gre-client"]),
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
},
|
||||
}
|
||||
seedFlowTopologyForTests(topo)
|
||||
seedRipeCacheForTests({
|
||||
prefix: "173.194.0.0/16",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.100.1.17",
|
||||
dst: "173.194.160.163",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 5_000_000,
|
||||
packets: 4000,
|
||||
inIface: "4",
|
||||
outIface: "4",
|
||||
},
|
||||
{
|
||||
src: "10.100.1.17",
|
||||
dst: "10.100.1.18",
|
||||
proto: 6,
|
||||
srcPort: 50000,
|
||||
dstPort: 443,
|
||||
bytes: 8000,
|
||||
packets: 8,
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
])
|
||||
try {
|
||||
const def = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||
assert.equal(def.bytes, 12_000)
|
||||
assert.equal(def.bytesPayload, 12_000)
|
||||
assert.equal(def.bytesOverlay, 5_000_000)
|
||||
assert.equal(def.bytesMesh, 8000)
|
||||
assert.equal(def.excludeOverlayApplied, true)
|
||||
assert.equal(def.excludeMeshApplied, true)
|
||||
assert.ok(!def.conversationsList.some((r) => r.proto === 47))
|
||||
assert.equal(def.conversationsList[0]?.service, "Google")
|
||||
assert.equal(def.conversationsList[0]?.category, "Веб")
|
||||
assert.equal(def.conversationsList[0]?.clientName, "Alice")
|
||||
assert.equal(def.conversationsList[0]?.enName, "NSK-SERVHOST-RTK")
|
||||
assert.equal(def.conversationsList[0]?.plane, "payload")
|
||||
const path = def.paths?.[0]
|
||||
assert.ok(path)
|
||||
assert.equal(path.clientName, "Alice")
|
||||
assert.equal(path.enName, "NSK-SERVHOST-RTK")
|
||||
assert.equal(path.dst, "173.194.160.163")
|
||||
const withAll = buildFlowAnalytics({ minutes: 5, serverId: 7, excludeOverlay: false, excludeMesh: false })
|
||||
assert.equal(withAll.bytes, 12_000 + 5_000_000 + 8000)
|
||||
assert.ok(withAll.conversationsList.some((r) => r.plane === "overlay"))
|
||||
assert.ok(withAll.conversationsList.some((r) => r.plane === "client_mesh"))
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
const sidRow = sqliteDatabase.prepare(`SELECT id FROM servers LIMIT 1`).get() as { id?: number } | undefined
|
||||
if (sidRow?.id) {
|
||||
const sid = sidRow.id
|
||||
rememberServerIfaces(sid, [{ ".id": "*4", name: "gre-en-nsk" }])
|
||||
ingestParsedFlowsForServerForTests(sid, [{
|
||||
src: "10.100.1.17",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 1,
|
||||
dstPort: 443,
|
||||
bytes: 100,
|
||||
packets: 1,
|
||||
inIface: "4",
|
||||
outIface: "4",
|
||||
}])
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO traffic_samples (server_id, interface_name, sampled_at, rx_bytes, tx_bytes, rx_bps, tx_bps)
|
||||
VALUES (?, 'gre-en-nsk', datetime('now'), 9000000, 1000000, 40000000, 2000000)
|
||||
`).run(sid)
|
||||
try {
|
||||
const wire = buildFlowAnalytics({ minutes: 5, serverId: sid })
|
||||
assert.ok((wire.bpsWire ?? 0) >= 40_000_000)
|
||||
assert.notEqual(wire.bpsWire, (wire.bytes * 8) / 300)
|
||||
} finally {
|
||||
sqliteDatabase.prepare(`DELETE FROM traffic_samples WHERE server_id = ? AND interface_name = 'gre-en-nsk'`).run(sid)
|
||||
}
|
||||
}
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-analytics.test.ts: ok")
|
||||
@@ -0,0 +1,659 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db, sqliteDatabase } from "../db/index.js"
|
||||
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import type {
|
||||
FlowAnalyticsDto,
|
||||
FlowBreakdownRow,
|
||||
FlowClientsDto,
|
||||
FlowEntityCard,
|
||||
FlowExportersDto,
|
||||
FlowMapEdge,
|
||||
FlowMonthlyDto,
|
||||
FlowPathRow,
|
||||
FlowTalkerDto,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import { protoName } from "./traffic-flow-parse.js"
|
||||
import {
|
||||
getFlowListenerState,
|
||||
getFlowRuntimeCounters,
|
||||
getFlowWorkerHealth,
|
||||
getRingMbps,
|
||||
listFlowRowsForWindow,
|
||||
type PendingFlowRow,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { MAX_PENDING, RING_OVERLAY } from "./traffic-flow-engine.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import {
|
||||
enGreIfaceNames,
|
||||
latestWireBps,
|
||||
loadFlowTopology,
|
||||
resolveClient,
|
||||
resolveEn,
|
||||
} from "./traffic-flow-topology.js"
|
||||
|
||||
export const LIVE_ANALYTICS_MINUTES = 5
|
||||
const LIVE_DEGRADED_PENDING = Math.floor(MAX_PENDING * 0.8)
|
||||
|
||||
export interface FlowAnalyticsQuery {
|
||||
minutes: number
|
||||
serverId?: number
|
||||
userId?: string
|
||||
iface?: string
|
||||
/** Default true: один 5-tuple = max байт по ifaces. */
|
||||
dedup?: boolean
|
||||
/** Default true: скрыть GRE/WG между клиентами JH. */
|
||||
excludeMesh?: boolean
|
||||
/** Default true: скрыть overlay GRE/ESP JH↔EN из payload KPI. */
|
||||
excludeOverlay?: boolean
|
||||
skipHeavy?: boolean
|
||||
}
|
||||
|
||||
function bpsToMbps(bps: number): number {
|
||||
return bps / 1_000_000
|
||||
}
|
||||
|
||||
function topN(
|
||||
map: Map<string, { bytes: number; packets: number; label?: string }>,
|
||||
windowSec: number,
|
||||
n: number,
|
||||
): FlowBreakdownRow[] {
|
||||
const total = [...map.values()].reduce((a, v) => a + v.bytes, 0) || 1
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||
.slice(0, n)
|
||||
.map(([id, v]) => ({
|
||||
id,
|
||||
label: v.label || id,
|
||||
bytes: v.bytes,
|
||||
packets: v.packets,
|
||||
bps: (v.bytes * 8) / windowSec,
|
||||
percent: (v.bytes / total) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
function bump(
|
||||
map: Map<string, { bytes: number; packets: number; label?: string }>,
|
||||
id: string,
|
||||
bytes: number,
|
||||
packets: number,
|
||||
label?: string,
|
||||
) {
|
||||
const prev = map.get(id) ?? { bytes: 0, packets: 0, label }
|
||||
prev.bytes += bytes
|
||||
prev.packets += packets
|
||||
if (label) prev.label = label
|
||||
map.set(id, prev)
|
||||
}
|
||||
|
||||
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
|
||||
if (!userId) return null
|
||||
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||
const allow = new Map<number, Set<string>>()
|
||||
for (const b of binds) {
|
||||
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||
set.add(b.interfaceName)
|
||||
allow.set(b.serverId, set)
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
function seriesFromRows(rows: PendingFlowRow[], minutes: number): { rx: number[]; tx: number[] } {
|
||||
const slots = Math.min(60, Math.max(5, minutes))
|
||||
const slotMs = (minutes * 60_000) / slots
|
||||
const start = Date.now() - minutes * 60_000
|
||||
const rx = Array(slots).fill(0) as number[]
|
||||
const tx = Array(slots).fill(0) as number[]
|
||||
for (const r of rows) {
|
||||
const t = Date.parse(r.bucketAt)
|
||||
if (!Number.isFinite(t)) continue
|
||||
const idx = Math.min(slots - 1, Math.max(0, Math.floor((t - start) / slotMs)))
|
||||
rx[idx] += r.bytes
|
||||
}
|
||||
const slotSec = Math.max(1, slotMs / 1000)
|
||||
return {
|
||||
rx: rx.map((b) => bpsToMbps((b * 8) / slotSec)),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotStatus(serverId: number): FlowEntityCard["status"] {
|
||||
void serverId
|
||||
return "online"
|
||||
}
|
||||
|
||||
function topLabel(map: Map<string, { bytes: number; packets: number; label?: string }>, fallback = "—"): string {
|
||||
let best = fallback
|
||||
let bestBytes = 0
|
||||
for (const [id, v] of map) {
|
||||
if (v.bytes > bestBytes) {
|
||||
bestBytes = v.bytes
|
||||
best = v.label || id
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const top = Math.min(50, Math.max(10, settings.topN))
|
||||
const windowSec = Math.max(60, q.minutes * 60)
|
||||
const raw = listFlowRowsForWindow(q.minutes)
|
||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||
const excludeMesh = q.excludeMesh !== false
|
||||
const excludeOverlay = q.excludeOverlay !== false
|
||||
const topo = loadFlowTopology()
|
||||
|
||||
refreshFlowCatalogInBackground()
|
||||
|
||||
const applications = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const protocols = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const sources = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const destinations = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const ifacesMap = new Map<string, { bytes: number; packets: number; index: string }>()
|
||||
const asns = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const countries = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const categories = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const services = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||
const conv = new Map<string, FlowTalkerDto & { rawBytes: number; flowStartMs: number; flowEndMs: number }>()
|
||||
const edgeAcc = new Map<string, FlowMapEdge & { catBytes: Map<string, number> }>()
|
||||
const pathAcc = new Map<string, FlowPathRow>()
|
||||
const srcs = new Set<string>()
|
||||
const dsts = new Set<string>()
|
||||
const matched: PendingFlowRow[] = []
|
||||
const skipHeavy = Boolean(q.skipHeavy)
|
||||
let bytesPayload = 0
|
||||
let bytesOverlay = 0
|
||||
let bytesMesh = 0
|
||||
const ifacesForWire = new Set<string>()
|
||||
|
||||
for (const r of raw) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||
ifacesForWire.add(resolved.name)
|
||||
if (outResolved.name && outResolved.name !== "—") ifacesForWire.add(outResolved.name)
|
||||
const plane = classifyFlowPlane({
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
inIface: resolved.name,
|
||||
outIface: outResolved.name,
|
||||
}, topo.plane)
|
||||
if (plane === "payload") bytesPayload += r.bytes
|
||||
else if (plane === "overlay") bytesOverlay += r.bytes
|
||||
else if (plane === "client_mesh") bytesMesh += r.bytes
|
||||
if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
|
||||
matched.push(r)
|
||||
|
||||
const ifaceKey = resolved.name
|
||||
const prevIf = ifacesMap.get(ifaceKey) ?? { bytes: 0, packets: 0, index: resolved.index }
|
||||
prevIf.bytes += r.bytes
|
||||
prevIf.packets += r.packets
|
||||
ifacesMap.set(ifaceKey, prevIf)
|
||||
}
|
||||
|
||||
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||
const conversationsRaw = new Set(matched.map((r) => `${flowTupleKey(r)}|${r.inIface}`)).size
|
||||
|
||||
let totalBytes = 0
|
||||
let totalPackets = 0
|
||||
for (const r of working) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
totalBytes += r.bytes
|
||||
totalPackets += r.packets
|
||||
srcs.add(r.src)
|
||||
dsts.add(r.dst)
|
||||
const app = applicationName(r.proto, r.dstPort, r.srcPort)
|
||||
const ripe = lookupRipeCached(r.dst)
|
||||
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
|
||||
bump(applications, app, r.bytes, r.packets)
|
||||
bump(protocols, protoName(r.proto), r.bytes, r.packets)
|
||||
bump(sources, r.src, r.bytes, r.packets)
|
||||
bump(destinations, r.dst, r.bytes, r.packets)
|
||||
bump(categories, classified.category, r.bytes, r.packets)
|
||||
bump(services, classified.service, r.bytes, r.packets)
|
||||
if (ripe?.ok && ripe.asn) {
|
||||
const asnId = String(ripe.asn)
|
||||
const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}`
|
||||
bump(asns, asnId, r.bytes, r.packets, asnLabel)
|
||||
}
|
||||
const dstCountry = ripe?.ok && isIsoCountry(ripe.country) ? ripe.country : ""
|
||||
if (dstCountry) {
|
||||
bump(countries, dstCountry, r.bytes, r.packets)
|
||||
}
|
||||
|
||||
if (!skipHeavy) {
|
||||
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||
const plane = classifyFlowPlane({
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
inIface: resolved.name,
|
||||
outIface: outResolved.name,
|
||||
}, topo.plane)
|
||||
const client = resolveClient(topo, r.serverId, resolved.name)
|
||||
const en = resolveEn(topo, r.nextHop, outResolved.name)
|
||||
const ckey = wantDedup
|
||||
? flowTupleKey(r)
|
||||
: `${flowTupleKey(r)}|${r.inIface}`
|
||||
const prev = conv.get(ckey)
|
||||
if (prev) {
|
||||
prev.rawBytes += r.bytes
|
||||
prev.bytes += r.bytes
|
||||
prev.packets += r.packets
|
||||
if (r.flowStartMs && (!prev.flowStartMs || r.flowStartMs < prev.flowStartMs)) prev.flowStartMs = r.flowStartMs
|
||||
if (r.flowEndMs > prev.flowEndMs) prev.flowEndMs = r.flowEndMs
|
||||
} else {
|
||||
conv.set(ckey, {
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
protoName: protoName(r.proto),
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
bps: 0,
|
||||
inIface: resolved.name,
|
||||
inIfaceIndex: resolved.index,
|
||||
outIface: outResolved.name !== "—" ? outResolved.name : undefined,
|
||||
nextHop: r.nextHop || undefined,
|
||||
application: app,
|
||||
category: classified.category,
|
||||
service: classified.service,
|
||||
dstCountry: dstCountry || undefined,
|
||||
dstAsn: ripe?.asn || undefined,
|
||||
clientId: client?.userId,
|
||||
clientName: client?.name,
|
||||
enId: en ? String(en.id) : undefined,
|
||||
enName: en?.name,
|
||||
plane,
|
||||
rawBytes: r.bytes,
|
||||
flowStartMs: r.flowStartMs ?? 0,
|
||||
flowEndMs: r.flowEndMs ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
const pathKey = `${client?.userId || "unknown"}|${r.serverId}|${en?.id || ""}|${r.dst}|${resolved.name}`
|
||||
const pathPrev = pathAcc.get(pathKey)
|
||||
if (pathPrev) {
|
||||
pathPrev.bytes += r.bytes
|
||||
pathPrev.packets += r.packets
|
||||
} else {
|
||||
pathAcc.set(pathKey, {
|
||||
id: pathKey,
|
||||
clientId: client?.userId || "unknown",
|
||||
clientName: client?.name || "Неизвестный клиент",
|
||||
ifaces: client ? [...(topo.clientIfaces.get(r.serverId) ?? [resolved.name])].join(", ") : resolved.name,
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
inIface: resolved.name,
|
||||
outIface: outResolved.name !== "—" ? outResolved.name : "",
|
||||
enId: en ? String(en.id) : "",
|
||||
enName: en?.name || "",
|
||||
dst: r.dst,
|
||||
service: classified.service,
|
||||
category: classified.category,
|
||||
plane,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
bps: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const toCountry = dstCountry
|
||||
if (toCountry) {
|
||||
const fromCountry = countryById.get(r.serverId) || "UN"
|
||||
const ekey = `${r.serverId}|${toCountry}`
|
||||
let edge = edgeAcc.get(ekey)
|
||||
if (!edge) {
|
||||
edge = {
|
||||
fromId: String(r.serverId),
|
||||
fromLabel: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
fromCountry,
|
||||
toCountry,
|
||||
toAsn: ripe?.asn ?? 0,
|
||||
category: classified.category,
|
||||
bytes: 0,
|
||||
bps: 0,
|
||||
catBytes: new Map(),
|
||||
}
|
||||
edgeAcc.set(ekey, edge)
|
||||
}
|
||||
edge.bytes += r.bytes
|
||||
if (ripe?.asn) edge.toAsn = ripe.asn
|
||||
edge.catBytes.set(classified.category, (edge.catBytes.get(classified.category) ?? 0) + r.bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enqueueRipeMisses(dsts)
|
||||
|
||||
const conversationsList = [...conv.values()]
|
||||
.map((t) => {
|
||||
const { rawBytes, flowStartMs, flowEndMs, ...rest } = t
|
||||
return { ...rest, bps: flowBps(rawBytes, flowStartMs, flowEndMs, windowSec) }
|
||||
})
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, top)
|
||||
|
||||
const paths: FlowPathRow[] = [...pathAcc.values()]
|
||||
.map((p) => ({ ...p, bps: (p.bytes * 8) / windowSec }))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, top)
|
||||
|
||||
const topProto = topLabel(protocols)
|
||||
const topCategory = topLabel(categories)
|
||||
|
||||
const ringServer = q.serverId ?? (matched[0]?.serverId ?? 0)
|
||||
const ring = ringServer
|
||||
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
|
||||
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
||||
|
||||
const fromBuckets = seriesFromRows(matched, q.minutes)
|
||||
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
|
||||
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
|
||||
|
||||
const ifaceRows = [...ifacesMap.entries()]
|
||||
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||
.map(([name, v]) => ({
|
||||
name,
|
||||
index: v.index,
|
||||
bps: (v.bytes * 8) / windowSec,
|
||||
}))
|
||||
|
||||
const ifaceRawBytes = [...ifacesMap.values()].reduce((a, v) => a + v.bytes, 0) || 1
|
||||
const listener = getFlowListenerState()
|
||||
const mapEdges: FlowMapEdge[] = [...edgeAcc.values()]
|
||||
.map((e) => {
|
||||
let cat = e.category
|
||||
let catBest = 0
|
||||
for (const [label, bytes] of e.catBytes) {
|
||||
if (bytes > catBest) {
|
||||
catBest = bytes
|
||||
cat = label
|
||||
}
|
||||
}
|
||||
return {
|
||||
fromId: e.fromId,
|
||||
fromLabel: e.fromLabel,
|
||||
fromCountry: e.fromCountry,
|
||||
toCountry: e.toCountry,
|
||||
toAsn: e.toAsn,
|
||||
category: cat,
|
||||
bytes: e.bytes,
|
||||
bps: (e.bytes * 8) / windowSec,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, top)
|
||||
|
||||
const overlayRing = ringServer
|
||||
? getRingMbps(ringServer, RING_OVERLAY)
|
||||
: { rxNow: 0, txNow: 0 }
|
||||
const greNames = ringServer ? enGreIfaceNames(topo, ringServer, [...ifacesForWire]) : []
|
||||
const wire = ringServer ? latestWireBps(ringServer, greNames) : { bps: 0, bytes: 0 }
|
||||
|
||||
return {
|
||||
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
|
||||
bytes: totalBytes,
|
||||
packets: totalPackets,
|
||||
conversations: conv.size,
|
||||
conversationsRaw,
|
||||
uniqueSrc: srcs.size,
|
||||
uniqueDst: dsts.size,
|
||||
topProto,
|
||||
topCategory,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
applications: topN(applications, windowSec, top),
|
||||
protocols: topN(protocols, windowSec, top),
|
||||
sources: topN(sources, windowSec, top),
|
||||
destinations: topN(destinations, windowSec, top),
|
||||
interfaces: [...ifacesMap.entries()].map(([label, v]) => ({
|
||||
id: label,
|
||||
label,
|
||||
bytes: v.bytes,
|
||||
packets: v.packets,
|
||||
bps: (v.bytes * 8) / windowSec,
|
||||
percent: (v.bytes / ifaceRawBytes) * 100,
|
||||
})).sort((a, b) => b.bytes - a.bytes),
|
||||
asns: topN(asns, windowSec, top),
|
||||
countries: topN(countries, windowSec, top),
|
||||
categories: topN(categories, windowSec, top),
|
||||
services: topN(services, windowSec, top),
|
||||
mapEdges,
|
||||
conversationsList,
|
||||
paths,
|
||||
ifaces: ifaceRows,
|
||||
live: listener.bound,
|
||||
dedupApplied: wantDedup,
|
||||
degraded: skipHeavy,
|
||||
bytesPayload,
|
||||
bytesOverlay,
|
||||
bytesMesh,
|
||||
bytesWire: wire.bytes,
|
||||
bpsOverlay: (overlayRing.rxNow + overlayRing.txNow) * 1_000_000 || (bytesOverlay * 8) / windowSec,
|
||||
bpsWire: wire.bps,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
excludeOverlayApplied: excludeOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeByServer(rows: PendingFlowRow[]) {
|
||||
const bytes = new Map<number, number>()
|
||||
const sessions = new Map<number, number>()
|
||||
for (const r of rows) {
|
||||
bytes.set(r.serverId, (bytes.get(r.serverId) ?? 0) + r.bytes)
|
||||
sessions.set(r.serverId, (sessions.get(r.serverId) ?? 0) + 1)
|
||||
}
|
||||
return { bytes, sessions }
|
||||
}
|
||||
|
||||
export function listFlowExporters(minutes: number): FlowExportersDto {
|
||||
const runtime = getFlowRuntimeCounters()
|
||||
const rows = listFlowRowsForWindow(minutes)
|
||||
const { bytes, sessions } = summarizeByServer(rows)
|
||||
const ids = new Set<number>([...bytes.keys()])
|
||||
for (const p of listHostPeers()) ids.add(p.serverId)
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const emptySeries = Array(60).fill(0) as number[]
|
||||
const exporters = serverRows
|
||||
.filter((s) => ids.has(s.id))
|
||||
.map((s) => {
|
||||
const ring = getRingMbps(s.id, "__all__")
|
||||
const total = bytes.get(s.id) ?? 0
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
subtitle: s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status: snapshotStatus(s.id),
|
||||
rxNow: ring.rxNow || (total * 8) / Math.max(60, minutes * 60) / 1_000_000,
|
||||
txNow: ring.txNow,
|
||||
sessions: sessions.get(s.id) ?? 0,
|
||||
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : emptySeries,
|
||||
txSeries: ring.tx,
|
||||
bytes: total,
|
||||
} satisfies FlowEntityCard
|
||||
})
|
||||
.sort((a, b) => b.rxNow - a.rxNow)
|
||||
const listener = getFlowListenerState()
|
||||
return {
|
||||
exporters,
|
||||
lastExporterIp: runtime.lastExporterIp,
|
||||
lastError: runtime.lastError,
|
||||
packetsReceived: runtime.packetsReceived,
|
||||
lastDatagramAt: runtime.lastDatagramAt,
|
||||
listenerBound: listener.bound,
|
||||
listenerAddress: listener.address,
|
||||
}
|
||||
}
|
||||
|
||||
export function listFlowClients(minutes: number): FlowClientsDto {
|
||||
const users = db.select().from(appUsers).all()
|
||||
const binds = db.select().from(userInterfaceBindings).all()
|
||||
const byUser = new Map<string, typeof binds>()
|
||||
for (const b of binds) {
|
||||
const list = byUser.get(b.userId) ?? []
|
||||
list.push(b)
|
||||
byUser.set(b.userId, list)
|
||||
}
|
||||
const rows = listFlowRowsForWindow(minutes)
|
||||
const emptySeries = Array(60).fill(0) as number[]
|
||||
const windowSec = Math.max(60, minutes * 60)
|
||||
const clients: FlowEntityCard[] = []
|
||||
for (const u of users) {
|
||||
const userBinds = byUser.get(u.id) ?? []
|
||||
if (userBinds.length === 0) continue
|
||||
const allow = new Map<number, Set<string>>()
|
||||
for (const b of userBinds) {
|
||||
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||
set.add(b.interfaceName)
|
||||
allow.set(b.serverId, set)
|
||||
}
|
||||
let total = 0
|
||||
let sessions = 0
|
||||
for (const r of rows) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
const names = allow.get(r.serverId)
|
||||
if (!names) continue
|
||||
if (!names.has(resolved.name) && !names.has(r.inIface)) continue
|
||||
total += r.bytes
|
||||
sessions += 1
|
||||
}
|
||||
const firstServer = userBinds[0]?.serverId
|
||||
const ring = firstServer ? getRingMbps(firstServer, "__all__") : { rx: emptySeries, tx: emptySeries, rxNow: 0, txNow: 0 }
|
||||
clients.push({
|
||||
id: u.id,
|
||||
name: u.login,
|
||||
subtitle: u.name || u.login,
|
||||
site: `${userBinds.length} ifaces`,
|
||||
country: "UN",
|
||||
status: u.active ? "online" : "offline",
|
||||
rxNow: (total * 8) / windowSec / 1_000_000 || ring.rxNow,
|
||||
txNow: ring.txNow,
|
||||
sessions,
|
||||
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : emptySeries,
|
||||
txSeries: ring.tx,
|
||||
bytes: total,
|
||||
})
|
||||
}
|
||||
clients.sort((a, b) => b.rxNow - a.rxNow)
|
||||
return { clients }
|
||||
}
|
||||
|
||||
export function formatLiveSseFromBuilder(build: () => unknown): { event: "sample" | "error"; data: unknown } {
|
||||
try {
|
||||
return { event: "sample", data: build() }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { event: "error", data: { error: message } }
|
||||
}
|
||||
}
|
||||
|
||||
export function isFlowAnalyticsDegraded(): boolean {
|
||||
const health = getFlowWorkerHealth()
|
||||
return health.pendingSize >= LIVE_DEGRADED_PENDING
|
||||
}
|
||||
|
||||
export function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): {
|
||||
event: "sample" | "error"
|
||||
data: unknown
|
||||
} {
|
||||
return formatLiveSseFromBuilder(() => {
|
||||
const skipHeavy = isFlowAnalyticsDegraded()
|
||||
return buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
|
||||
})
|
||||
}
|
||||
|
||||
function monthBounds(month: string): { start: string; end: string } | null {
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) return null
|
||||
const [yearRaw, monthRaw] = month.split("-")
|
||||
const year = Number(yearRaw)
|
||||
const monthIdx = Number(monthRaw)
|
||||
if (!Number.isFinite(year) || monthIdx < 1 || monthIdx > 12) return null
|
||||
const start = `${month}-01`
|
||||
const endDate = new Date(Date.UTC(year, monthIdx, 1))
|
||||
const end = endDate.toISOString().slice(0, 10)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function toBreakdown(
|
||||
rows: Array<{ key: string; bytes: number; packets: number }>,
|
||||
totalBytes: number,
|
||||
windowSec: number,
|
||||
): FlowBreakdownRow[] {
|
||||
const denom = totalBytes || 1
|
||||
return rows
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.map((r) => ({
|
||||
id: r.key,
|
||||
label: r.key,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
bps: (r.bytes * 8) / windowSec,
|
||||
percent: (r.bytes / denom) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getFlowMonthly(month: string, serverId?: number): FlowMonthlyDto {
|
||||
const bounds = monthBounds(month)
|
||||
if (!bounds) {
|
||||
return { month, bytes: 0, countries: [], services: [], asns: [] }
|
||||
}
|
||||
const params: Array<string | number> = [bounds.start, bounds.end]
|
||||
let where = "day >= ? AND day < ? AND dim IN ('country', 'service', 'asn')"
|
||||
if (serverId != null) {
|
||||
where += " AND server_id = ?"
|
||||
params.push(serverId)
|
||||
}
|
||||
const rows = sqliteDatabase.prepare(`
|
||||
SELECT dim AS dim, key AS key, SUM(bytes) AS bytes, SUM(packets) AS packets
|
||||
FROM flow_daily_dims
|
||||
WHERE ${where}
|
||||
GROUP BY dim, key
|
||||
`).all(...params) as Array<{ dim: string; key: string; bytes: number; packets: number }>
|
||||
|
||||
const countries: Array<{ key: string; bytes: number; packets: number }> = []
|
||||
const services: Array<{ key: string; bytes: number; packets: number }> = []
|
||||
const asns: Array<{ key: string; bytes: number; packets: number }> = []
|
||||
let bytes = 0
|
||||
for (const row of rows) {
|
||||
const rec = { key: row.key, bytes: Number(row.bytes) || 0, packets: Number(row.packets) || 0 }
|
||||
if (row.dim === "country") {
|
||||
countries.push(rec)
|
||||
bytes += rec.bytes
|
||||
} else if (row.dim === "service") services.push(rec)
|
||||
else if (row.dim === "asn") asns.push(rec)
|
||||
}
|
||||
const daysInMonth = Math.max(1, Math.round((Date.parse(`${bounds.end}T00:00:00Z`) - Date.parse(`${bounds.start}T00:00:00Z`)) / 86_400_000))
|
||||
const windowSec = daysInMonth * 86_400
|
||||
const countryTotal = countries.reduce((a, r) => a + r.bytes, 0) || bytes || 1
|
||||
return {
|
||||
month,
|
||||
bytes,
|
||||
countries: toBreakdown(countries, countryTotal, windowSec),
|
||||
services: toBreakdown(services, services.reduce((a, r) => a + r.bytes, 0) || 1, windowSec),
|
||||
asns: toBreakdown(asns, asns.reduce((a, r) => a + r.bytes, 0) || 1, windowSec),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { protoName } from "./traffic-flow-parse.js"
|
||||
|
||||
const WELL_KNOWN: Record<string, string> = {
|
||||
"6:80": "HTTP",
|
||||
"6:443": "HTTPS",
|
||||
"6:8080": "HTTP-alt",
|
||||
"6:8443": "HTTPS-alt",
|
||||
"6:22": "SSH",
|
||||
"6:21": "FTP",
|
||||
"6:25": "SMTP",
|
||||
"6:110": "POP3",
|
||||
"6:143": "IMAP",
|
||||
"6:993": "IMAPS",
|
||||
"6:995": "POP3S",
|
||||
"6:587": "SMTP",
|
||||
"6:465": "SMTPS",
|
||||
"6:3306": "MySQL",
|
||||
"6:5432": "PostgreSQL",
|
||||
"6:6379": "Redis",
|
||||
"6:3389": "RDP",
|
||||
"6:445": "SMB",
|
||||
"6:139": "NetBIOS",
|
||||
"6:179": "BGP",
|
||||
"6:8291": "WinBox",
|
||||
"6:8728": "ROS-API",
|
||||
"6:8729": "ROS-API-SSL",
|
||||
"17:53": "DNS",
|
||||
"6:53": "DNS",
|
||||
"17:123": "NTP",
|
||||
"17:161": "SNMP",
|
||||
"17:162": "SNMP-trap",
|
||||
"17:500": "IKE",
|
||||
"17:4500": "NAT-T",
|
||||
"17:1194": "OpenVPN",
|
||||
"17:443": "QUIC",
|
||||
"17:853": "DNS",
|
||||
"6:853": "DNS",
|
||||
"17:51820": "WireGuard",
|
||||
"17:13232": "WireGuard",
|
||||
"17:51821": "WireGuard",
|
||||
"17:4789": "VXLAN",
|
||||
"17:4739": "IPFIX",
|
||||
"17:2055": "NetFlow",
|
||||
"17:67": "DHCP",
|
||||
"17:68": "DHCP",
|
||||
"17:69": "TFTP",
|
||||
"17:1812": "RADIUS",
|
||||
"1:0": "ICMP",
|
||||
"47:0": "GRE",
|
||||
"50:0": "ESP",
|
||||
"89:0": "OSPF",
|
||||
}
|
||||
|
||||
export function applicationName(proto: number, dstPort: number, srcPort = 0): string {
|
||||
if (proto === 1) return "ICMP"
|
||||
if (proto === 47) return "GRE"
|
||||
if (proto === 50) return "ESP"
|
||||
if (proto === 89) return "OSPF"
|
||||
if (proto === 17 && (dstPort === 443 || srcPort === 443)) return "QUIC"
|
||||
const dstKey = `${proto}:${dstPort}`
|
||||
const srcKey = `${proto}:${srcPort}`
|
||||
return WELL_KNOWN[dstKey] ?? WELL_KNOWN[srcKey] ?? `${protoName(proto)}/${dstPort || srcPort || "—"}`
|
||||
}
|
||||
|
||||
export interface FlowMatchQuery {
|
||||
serverId?: number
|
||||
userId?: string
|
||||
iface?: string
|
||||
}
|
||||
|
||||
export function flowRowMatchesFilter(
|
||||
row: { serverId: number; inIface: string },
|
||||
resolvedName: string,
|
||||
q: FlowMatchQuery,
|
||||
allow: Map<number, Set<string>> | null,
|
||||
): boolean {
|
||||
if (q.serverId != null && row.serverId !== q.serverId) return false
|
||||
if (allow) {
|
||||
const names = allow.get(row.serverId)
|
||||
if (!names || !names.has(resolvedName)) return false
|
||||
}
|
||||
const iface = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||
if (iface && resolvedName !== iface && row.inIface !== iface) return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
brandByAsn,
|
||||
countryFromHolder,
|
||||
lookupBrand,
|
||||
OTHER_SERVICE,
|
||||
isNamedInternetService,
|
||||
mapServiceNodeId,
|
||||
resolveRipeCountry,
|
||||
} from "./traffic-flow-brands.js"
|
||||
|
||||
assert.equal(resolveRipeCountry("?", 13335, "CLOUDFLARENET, US"), "US")
|
||||
assert.equal(resolveRipeCountry("EU", 13335, ""), "US")
|
||||
assert.equal(resolveRipeCountry("?", 0, "CLOUDFLARENET, US"), "US")
|
||||
assert.equal(countryFromHolder("CLOUDFLARENET, US"), "US")
|
||||
assert.equal(resolveRipeCountry("NL", 0, ""), "NL")
|
||||
assert.equal(resolveRipeCountry("?", 0, ""), "")
|
||||
|
||||
assert.equal(brandByAsn(13335)?.service, "Cloudflare")
|
||||
assert.equal(brandByAsn(13335)?.category, "CDN")
|
||||
assert.equal(brandByAsn(15169)?.service, "Google")
|
||||
assert.equal(brandByAsn(15169)?.category, "Веб")
|
||||
assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube")
|
||||
assert.equal(brandByAsn(32590)?.service, "Steam")
|
||||
assert.equal(brandByAsn(32590)?.category, "Игры")
|
||||
assert.equal(brandByAsn(16509)?.service, "AWS")
|
||||
assert.equal(brandByAsn(57976)?.service, "Blizzard")
|
||||
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
||||
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
|
||||
assert.equal(lookupBrand("203.0.113.9", 64500), null)
|
||||
assert.equal(OTHER_SERVICE, "Прочее")
|
||||
assert.equal(isNamedInternetService("Google", "Веб"), true)
|
||||
assert.equal(isNamedInternetService("Прочее", "Прочее"), false)
|
||||
assert.equal(isNamedInternetService("GRE", "Туннель"), false)
|
||||
assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
||||
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
||||
|
||||
console.log("traffic-flow-brands.test.ts: ok")
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||
|
||||
export const OTHER_SERVICE = "Прочее"
|
||||
|
||||
export interface BrandHit {
|
||||
service: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const ASN_BRANDS = new Map<number, BrandHit>([
|
||||
[13335, { service: "Cloudflare", category: "CDN" }],
|
||||
[209242, { service: "Cloudflare", category: "CDN" }],
|
||||
[54113, { service: "Fastly", category: "CDN" }],
|
||||
[20940, { service: "Akamai", category: "CDN" }],
|
||||
[16509, { service: "AWS", category: "CDN" }],
|
||||
[14618, { service: "AWS", category: "CDN" }],
|
||||
[8075, { service: "Microsoft", category: "CDN" }],
|
||||
[13238, { service: "Yandex", category: "CDN" }],
|
||||
[32590, { service: "Steam", category: "Игры" }],
|
||||
[57976, { service: "Blizzard", category: "Игры" }],
|
||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[15169, { service: "Google", category: "Веб" }],
|
||||
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
||||
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
||||
[401115, { service: "ChatGPT", category: "ИИ" }],
|
||||
[49544, { service: "Discord", category: "Голос" }],
|
||||
[62041, { service: "Telegram", category: "Голос" }],
|
||||
[59930, { service: "Telegram", category: "Голос" }],
|
||||
[211157, { service: "Telegram", category: "Голос" }],
|
||||
[32934, { service: "Meta", category: "CDN" }],
|
||||
[396986, { service: "TikTok", category: "Видео / стриминг" }],
|
||||
])
|
||||
|
||||
const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||
[13335, "US"],
|
||||
[209242, "US"],
|
||||
[54113, "US"],
|
||||
[20940, "US"],
|
||||
[16509, "US"],
|
||||
[14618, "US"],
|
||||
[8075, "US"],
|
||||
[15169, "US"],
|
||||
[32590, "US"],
|
||||
[57976, "US"],
|
||||
[2906, "US"],
|
||||
[40027, "US"],
|
||||
[36040, "US"],
|
||||
[46489, "US"],
|
||||
[401115, "US"],
|
||||
[49544, "US"],
|
||||
[32934, "US"],
|
||||
[13238, "RU"],
|
||||
[62041, "NL"],
|
||||
[59930, "NL"],
|
||||
[211157, "NL"],
|
||||
])
|
||||
|
||||
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: { service: "Cloudflare", category: "CDN" } },
|
||||
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
|
||||
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||
|
||||
export function isIsoCountry(code: string): boolean {
|
||||
const c = String(code ?? "").trim().toUpperCase()
|
||||
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
||||
}
|
||||
|
||||
export function normalizeIsoCountry(code: string): string {
|
||||
const c = String(code ?? "").trim().toUpperCase()
|
||||
return isIsoCountry(c) ? c : ""
|
||||
}
|
||||
|
||||
/** `CLOUDFLARENET, US` → `US`. */
|
||||
export function countryFromHolder(holder: string): string {
|
||||
const m = String(holder ?? "").trim().match(/,\s*([A-Za-z]{2})\s*$/)
|
||||
return m?.[1] ? normalizeIsoCountry(m[1]) : ""
|
||||
}
|
||||
|
||||
export function countryForAsn(asn: number): string {
|
||||
if (!asn) return ""
|
||||
return ASN_HQ_COUNTRY.get(asn) ?? ""
|
||||
}
|
||||
|
||||
export function resolveRipeCountry(country: string, asn: number, holder: string): string {
|
||||
return normalizeIsoCountry(country) || countryFromHolder(holder) || countryForAsn(asn)
|
||||
}
|
||||
|
||||
export function brandByAsn(asn: number): BrandHit | null {
|
||||
if (!asn) return null
|
||||
return ASN_BRANDS.get(asn) ?? null
|
||||
}
|
||||
|
||||
export function brandByCidr(ip: string): BrandHit | null {
|
||||
for (const row of CIDR_BRANDS) {
|
||||
if (parseCidrV4(row.cidr) && ipInCidrV4(ip, row.cidr)) return row.hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||
return brandByCidr(ip) || brandByAsn(asn)
|
||||
}
|
||||
|
||||
const SKIP_MAP_SERVICES = new Set([
|
||||
OTHER_SERVICE,
|
||||
"GRE",
|
||||
"ESP",
|
||||
"WireGuard",
|
||||
"DNS",
|
||||
"SSH",
|
||||
"BGP",
|
||||
])
|
||||
|
||||
const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"])
|
||||
|
||||
/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */
|
||||
export function isNamedInternetService(service: string, category: string): boolean {
|
||||
const s = service.trim()
|
||||
const c = category.trim()
|
||||
if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function mapServiceNodeId(label: string): string {
|
||||
const slug = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return `svc:${slug || "unknown"}`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { classifyFlowDst, disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||
|
||||
disableCatalogFetchForTests()
|
||||
resetFlowCatalogForTests()
|
||||
seedFlowCatalogForTests({
|
||||
cidrs: [{ cidr: "192.0.2.0/24", purpose: "steam-gaming" }],
|
||||
})
|
||||
|
||||
const hit = classifyFlowDst("192.0.2.10", 6, 443, 50000, null)
|
||||
assert.equal(hit.category, "Игры")
|
||||
assert.equal(hit.service, "steam-gaming")
|
||||
|
||||
const miss = classifyFlowDst("203.0.113.9", 17, 53, 53000, null)
|
||||
assert.equal(miss.category, "DNS")
|
||||
|
||||
const cdn = classifyFlowDst("203.0.113.9", 6, 443, 1, { prefix: "203.0.113.0/24", asn: 13335, country: "US", lat: null, lng: null, holder: "CLOUDFLARENET", ok: true, fetchedAt: Date.now() })
|
||||
assert.equal(cdn.category, "CDN")
|
||||
assert.equal(cdn.service, "Cloudflare")
|
||||
|
||||
const amazonHolder = classifyFlowDst("203.0.113.50", 6, 443, 1, { prefix: "203.0.113.0/24", asn: 64500, country: "RU", lat: null, lng: null, holder: "AMAZON-AES - Amazon.com, Inc.", ok: true, fetchedAt: Date.now() })
|
||||
assert.equal(amazonHolder.service, "Прочее")
|
||||
assert.notEqual(amazonHolder.service, "AMAZON-AES - Amazon.com, Inc.")
|
||||
|
||||
const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||
prefix: "173.194.0.0/16",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(google.service, "Google")
|
||||
assert.equal(google.category, "Веб")
|
||||
|
||||
const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||
prefix: "173.194.0.0/16",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "YouTube LLC",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(youtube.service, "YouTube")
|
||||
assert.equal(youtube.category, "Видео / стриминг")
|
||||
|
||||
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
|
||||
assert.equal(gre.service, "GRE")
|
||||
assert.equal(gre.category, "Туннель")
|
||||
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
|
||||
assert.equal(esp.category, "Туннель")
|
||||
assert.equal(applicationName(17, 443, 50000), "QUIC")
|
||||
assert.equal(applicationName(17, 853, 50000), "DNS")
|
||||
|
||||
console.log("traffic-flow-classify.test.ts: ok")
|
||||
@@ -0,0 +1,152 @@
|
||||
import { lookupBrand, OTHER_SERVICE } from "./traffic-flow-brands.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { evobgpSettings } from "../db/schema.js"
|
||||
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
|
||||
export interface FlowClassification {
|
||||
service: string
|
||||
category: string
|
||||
}
|
||||
|
||||
interface CatalogCidr {
|
||||
cidr: string
|
||||
purpose: string
|
||||
prefixLen: number
|
||||
}
|
||||
|
||||
const CATALOG_TTL_MS = 10 * 60_000
|
||||
let cidrs: CatalogCidr[] = []
|
||||
let asnPurpose = new Map<number, string>()
|
||||
let fetchedAt = 0
|
||||
let catalogFetchEnabled = true
|
||||
let inflight: Promise<void> | null = null
|
||||
|
||||
export function disableCatalogFetchForTests(): void {
|
||||
catalogFetchEnabled = false
|
||||
}
|
||||
|
||||
export function resetFlowCatalogForTests(): void {
|
||||
cidrs = []
|
||||
asnPurpose = new Map()
|
||||
fetchedAt = 0
|
||||
inflight = null
|
||||
}
|
||||
|
||||
export function seedFlowCatalogForTests(input: {
|
||||
cidrs?: Array<{ cidr: string; purpose: string }>
|
||||
asns?: Array<{ asn: number; purpose: string }>
|
||||
}): void {
|
||||
cidrs = (input.cidrs ?? [])
|
||||
.map((c) => ({ cidr: c.cidr, purpose: c.purpose, prefixLen: parseCidrV4(c.cidr)?.prefixLen ?? 0 }))
|
||||
.sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
asnPurpose = new Map((input.asns ?? []).map((a) => [a.asn, a.purpose]))
|
||||
fetchedAt = Date.now()
|
||||
}
|
||||
|
||||
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||
const p = purpose.toLowerCase()
|
||||
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
|
||||
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
|
||||
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
||||
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
|
||||
if (/веб|web|google/.test(p)) return "Веб"
|
||||
const app = applicationName(proto, dstPort, srcPort)
|
||||
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
||||
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
||||
return OTHER_SERVICE
|
||||
}
|
||||
|
||||
function matchCidr(ip: string): CatalogCidr | null {
|
||||
for (const row of cidrs) {
|
||||
if (ipInCidrV4(ip, row.cidr)) return row
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function classifyFlowDst(
|
||||
dst: string,
|
||||
proto: number,
|
||||
dstPort: number,
|
||||
srcPort: number,
|
||||
ripe: FlowIpMeta | null,
|
||||
): FlowClassification {
|
||||
if (proto === 47) return { service: "GRE", category: "Туннель" }
|
||||
if (proto === 50) return { service: "ESP", category: "Туннель" }
|
||||
const app = applicationName(proto, dstPort, srcPort)
|
||||
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
||||
const hit = matchCidr(dst)
|
||||
const holder = ripe?.holder ?? ""
|
||||
const youtubeHolder = /youtube/i.test(holder)
|
||||
const brand = youtubeHolder
|
||||
? { service: "YouTube", category: "Видео / стриминг" }
|
||||
: lookupBrand(dst, ripe?.asn ?? 0)
|
||||
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
||||
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
||||
const category = hit
|
||||
? categoryFromPurpose(hit.purpose, proto, dstPort, srcPort)
|
||||
: (brand?.category || categoryFromPurpose(asnName || "", proto, dstPort, srcPort))
|
||||
return { service, category }
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<void> {
|
||||
if (!catalogFetchEnabled) return
|
||||
if (Date.now() - fetchedAt < CATALOG_TTL_MS) return
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const row = db.select().from(evobgpSettings).limit(1).all()[0]
|
||||
if (!row?.enabled) return
|
||||
const root = String(row.baseUrl ?? "").replace(/\/+$/, "")
|
||||
const token = String(row.apiKey ?? "").replace(/^Bearer\s+/i, "").trim()
|
||||
if (!root || !token) return
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 20_000)
|
||||
try {
|
||||
const res = await fetch(`${root}/v1/router-lists/catalog`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||
signal: ac.signal,
|
||||
})
|
||||
if (!res.ok) return
|
||||
const catalog = await res.json() as {
|
||||
modules?: { items?: Array<{ id: string; name: string }> }
|
||||
ip_ranges?: { items?: Array<{ module_id: string; entry: { prefix: string } }> }
|
||||
asns?: { items?: Array<{ module_id: string; entry: { asn: number } }> }
|
||||
}
|
||||
const mods = new Map((catalog.modules?.items ?? []).map((m) => [m.id, m.name]))
|
||||
const next: CatalogCidr[] = []
|
||||
for (const item of catalog.ip_ranges?.items ?? []) {
|
||||
const prefix = String(item.entry?.prefix ?? "").trim()
|
||||
const purpose = mods.get(item.module_id) ?? ""
|
||||
const parsed = parseCidrV4(prefix)
|
||||
if (!prefix || !parsed) continue
|
||||
next.push({ cidr: prefix, purpose, prefixLen: parsed.prefixLen })
|
||||
}
|
||||
next.sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
const nextAsn = new Map<number, string>()
|
||||
for (const item of catalog.asns?.items ?? []) {
|
||||
const purpose = mods.get(item.module_id)
|
||||
const asn = Number(item.entry?.asn)
|
||||
if (purpose && Number.isFinite(asn) && asn > 0) nextAsn.set(asn, purpose)
|
||||
}
|
||||
cidrs = next
|
||||
asnPurpose = nextAsn
|
||||
fetchedAt = Date.now()
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
} catch {
|
||||
/* catalog optional */
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
|
||||
/** Background refresh — analytics never awaits the HTTP. */
|
||||
export function refreshFlowCatalogInBackground(): void {
|
||||
void fetchCatalog()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||
|
||||
export interface ExporterMapPayload {
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Array<[string, number]>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Array<[string, number]>
|
||||
}
|
||||
|
||||
export interface CollectorStartPayload {
|
||||
dbPath: string
|
||||
listenHost: string
|
||||
listenPort: number
|
||||
topN: number
|
||||
retentionHours: number
|
||||
exporterMap: ExporterMapPayload
|
||||
}
|
||||
|
||||
export interface CollectorHeartbeat {
|
||||
bound: boolean
|
||||
address: string | null
|
||||
packetsReceived: number
|
||||
lastExporterIp: string | null
|
||||
lastError: string
|
||||
lastDatagramAt: string | null
|
||||
pendingSize: number
|
||||
dropped: number
|
||||
rowsStored: number
|
||||
workerAlive: boolean
|
||||
rings: Array<{ key: string; inBps: number[]; outBps: number[] }>
|
||||
}
|
||||
|
||||
export type MainToWorker =
|
||||
| { type: "start"; payload: CollectorStartPayload }
|
||||
| { type: "stop" }
|
||||
| { type: "updateExporterMap"; payload: ExporterMapPayload }
|
||||
| { type: "updateSettings"; payload: { topN: number; retentionHours: number } }
|
||||
|
||||
export type WorkerToMain =
|
||||
| { type: "heartbeat"; payload: CollectorHeartbeat }
|
||||
| { type: "error"; payload: { message: string } }
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createSocket, type Socket } from "node:dgram"
|
||||
import { parentPort } from "node:worker_threads"
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
import type {
|
||||
CollectorStartPayload,
|
||||
ExporterMapPayload,
|
||||
MainToWorker,
|
||||
WorkerToMain,
|
||||
} from "./traffic-flow-collector-ipc.js"
|
||||
import {
|
||||
TICK_MS,
|
||||
attachEngineSqlite,
|
||||
configureEngine,
|
||||
flushPending,
|
||||
getEngineStats,
|
||||
ingestDatagram,
|
||||
setEngineError,
|
||||
setExporterResolveCtx,
|
||||
snapshotRings,
|
||||
} from "./traffic-flow-engine.js"
|
||||
|
||||
let socket: Socket | null = null
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
let bound = false
|
||||
let address: string | null = null
|
||||
let attached = false
|
||||
|
||||
function send(msg: WorkerToMain): void {
|
||||
parentPort?.postMessage(msg)
|
||||
}
|
||||
|
||||
function heartbeat(): void {
|
||||
const stats = getEngineStats()
|
||||
send({
|
||||
type: "heartbeat",
|
||||
payload: {
|
||||
bound,
|
||||
address,
|
||||
packetsReceived: stats.packetsReceived,
|
||||
lastExporterIp: stats.lastExporterIp,
|
||||
lastError: stats.lastError,
|
||||
lastDatagramAt: stats.lastDatagramAt,
|
||||
pendingSize: stats.pendingSize,
|
||||
dropped: stats.dropped,
|
||||
rowsStored: stats.rowsStored,
|
||||
workerAlive: true,
|
||||
rings: snapshotRings(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function applyExporterMap(payload: ExporterMapPayload): void {
|
||||
setExporterResolveCtx({
|
||||
overlayPrefix: payload.overlayPrefix,
|
||||
byTunnelIp: new Map(payload.byTunnelIp),
|
||||
peers: payload.peers,
|
||||
hostIps: new Map(payload.hostIps),
|
||||
})
|
||||
}
|
||||
|
||||
function ensureSqlite(): void {
|
||||
if (attached) return
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
attached = true
|
||||
}
|
||||
|
||||
function stopListener(): void {
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
try {
|
||||
flushPending()
|
||||
} catch (e) {
|
||||
setEngineError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
if (socket) {
|
||||
try { socket.close() } catch { /* ignore */ }
|
||||
socket = null
|
||||
}
|
||||
bound = false
|
||||
address = null
|
||||
}
|
||||
|
||||
function startListener(payload: CollectorStartPayload): void {
|
||||
stopListener()
|
||||
ensureSqlite()
|
||||
configureEngine({ topN: payload.topN, retentionHours: payload.retentionHours })
|
||||
applyExporterMap(payload.exporterMap)
|
||||
|
||||
const sock = createSocket("udp4")
|
||||
sock.on("error", (err) => {
|
||||
setEngineError(err.message)
|
||||
bound = false
|
||||
address = null
|
||||
send({ type: "error", payload: { message: err.message } })
|
||||
heartbeat()
|
||||
})
|
||||
sock.on("message", (msg, rinfo) => {
|
||||
try {
|
||||
ingestDatagram(msg, rinfo.address)
|
||||
} catch (e) {
|
||||
setEngineError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})
|
||||
try {
|
||||
sock.setRecvBufferSize(8 * 1024 * 1024)
|
||||
} catch {
|
||||
/* platform may ignore */
|
||||
}
|
||||
sock.bind(payload.listenPort, payload.listenHost, () => {
|
||||
bound = true
|
||||
address = `${payload.listenHost}:${payload.listenPort}`
|
||||
setEngineError("")
|
||||
heartbeat()
|
||||
})
|
||||
socket = sock
|
||||
flushTimer = setInterval(() => {
|
||||
try {
|
||||
flushPending()
|
||||
} catch (e) {
|
||||
setEngineError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
heartbeat()
|
||||
}, TICK_MS)
|
||||
}
|
||||
|
||||
parentPort?.on("message", (msg: MainToWorker) => {
|
||||
try {
|
||||
if (msg.type === "start") startListener(msg.payload)
|
||||
else if (msg.type === "stop") {
|
||||
stopListener()
|
||||
heartbeat()
|
||||
} else if (msg.type === "updateExporterMap") applyExporterMap(msg.payload)
|
||||
else if (msg.type === "updateSettings") configureEngine(msg.payload)
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
setEngineError(message)
|
||||
send({ type: "error", payload: { message } })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
|
||||
|
||||
const a = {
|
||||
serverId: 7,
|
||||
src: "10.1.1.8",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 1,
|
||||
dstPort: 443,
|
||||
inIface: "2",
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
}
|
||||
const b = { ...a, inIface: "10", bytes: 8_000, packets: 8 }
|
||||
const out = dedupFlowRowsMaxBytes([a, b])
|
||||
assert.equal(out.length, 1)
|
||||
assert.equal(out[0]?.bytes, 12_000)
|
||||
assert.equal(out[0]?.inIface, "2")
|
||||
assert.equal(flowTupleKey(a), flowTupleKey(b))
|
||||
|
||||
const sameIface = dedupFlowRowsMaxBytes([a, { ...a, bytes: 3_000, packets: 2 }])
|
||||
assert.equal(sameIface[0]?.bytes, 15_000)
|
||||
|
||||
console.log("traffic-flow-dedup.test.ts: ok")
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface FlowTupleRow {
|
||||
serverId: number
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
inIface: string
|
||||
bytes: number
|
||||
packets: number
|
||||
}
|
||||
|
||||
export function flowTupleKey(r: Pick<FlowTupleRow, "serverId" | "src" | "dst" | "proto" | "srcPort" | "dstPort">): string {
|
||||
return `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
|
||||
}
|
||||
|
||||
function ifaceKey(r: FlowTupleRow): string {
|
||||
return `${flowTupleKey(r)}|${r.inIface}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Один 5-tuple на двух ifIndex — это один поток: сначала сумма по бакетам/iface,
|
||||
* затем max байт между интерфейсами (не sum).
|
||||
*/
|
||||
export function dedupFlowRowsMaxBytes<T extends FlowTupleRow>(rows: T[]): T[] {
|
||||
const byIface = new Map<string, T>()
|
||||
for (const row of rows) {
|
||||
const key = ifaceKey(row)
|
||||
const prev = byIface.get(key)
|
||||
if (!prev) {
|
||||
byIface.set(key, { ...row })
|
||||
continue
|
||||
}
|
||||
prev.bytes += row.bytes
|
||||
prev.packets += row.packets
|
||||
}
|
||||
const byTuple = new Map<string, T>()
|
||||
for (const row of byIface.values()) {
|
||||
const key = flowTupleKey(row)
|
||||
const prev = byTuple.get(key)
|
||||
if (!prev || row.bytes > prev.bytes) byTuple.set(key, row)
|
||||
}
|
||||
return [...byTuple.values()]
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import type Database from "better-sqlite3"
|
||||
import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
|
||||
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
|
||||
export const TICK_MS = 2_000
|
||||
export const RING_LEN = 60
|
||||
export const MAX_PENDING = 50_000
|
||||
export const DAILY_ASN_TOP = 500
|
||||
export const DAILY_RETENTION_DAYS = 396
|
||||
export const MINUTE_RETENTION_HOURS = 48
|
||||
|
||||
let pendingCap = MAX_PENDING
|
||||
|
||||
export interface PendingFlowRow {
|
||||
serverId: number
|
||||
bucketAt: string
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
bytes: number
|
||||
packets: number
|
||||
inIface: string
|
||||
outIface: string
|
||||
nextHop: string
|
||||
flowStartMs: number
|
||||
flowEndMs: number
|
||||
}
|
||||
|
||||
export interface EngineStats {
|
||||
packetsReceived: number
|
||||
lastExporterIp: string | null
|
||||
lastError: string
|
||||
lastDatagramAt: string | null
|
||||
dropped: number
|
||||
rowsStored: number
|
||||
pendingSize: number
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
serverId: number
|
||||
bucketAt: string
|
||||
flow: ParsedFlow
|
||||
bytes: number
|
||||
packets: number
|
||||
}
|
||||
|
||||
interface MinuteRollup {
|
||||
bytes: number
|
||||
packets: number
|
||||
srcs: Set<string>
|
||||
dsts: Set<string>
|
||||
conversations: number
|
||||
}
|
||||
|
||||
interface DimAcc {
|
||||
bytes: number
|
||||
packets: number
|
||||
}
|
||||
|
||||
export interface ExporterResolveCtx {
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Map<string, number>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Map<string, number>
|
||||
}
|
||||
|
||||
let sqliteRef: SqliteHandle | null = null
|
||||
let topN = 200
|
||||
let retentionHours = 24
|
||||
|
||||
const pending = new Map<string, PendingEntry>()
|
||||
const recent = new Map<string, PendingFlowRow>()
|
||||
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
|
||||
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
|
||||
const minuteRollup = new Map<string, MinuteRollup>()
|
||||
const minuteDims = new Map<string, DimAcc>()
|
||||
|
||||
let packetsReceived = 0
|
||||
let lastExporterIp: string | null = null
|
||||
let lastError = ""
|
||||
let lastDatagramAt: string | null = null
|
||||
let dropped = 0
|
||||
let rowsStored = 0
|
||||
let lastFlushUsedTransaction = false
|
||||
let lastPruneAt = 0
|
||||
let exporterCtx: ExporterResolveCtx | null = null
|
||||
|
||||
const PRUNE_MS = 5 * 60_000
|
||||
const LIVE_WINDOW_MS = 15 * 60_000
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function minuteBucketIso(at = Date.now()): string {
|
||||
const d = new Date(at)
|
||||
d.setSeconds(0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function dayKey(bucketAt: string): string {
|
||||
return bucketAt.slice(0, 10)
|
||||
}
|
||||
|
||||
export const RING_PAYLOAD = "__all__"
|
||||
export const RING_OVERLAY = "__overlay__"
|
||||
export const RING_MESH = "__mesh__"
|
||||
|
||||
function ringKey(serverId: number, iface: string): string {
|
||||
return `${serverId}\0${iface || RING_PAYLOAD}`
|
||||
}
|
||||
|
||||
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string {
|
||||
return `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
|
||||
}
|
||||
|
||||
function rowKey(row: PendingFlowRow): string {
|
||||
return `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
|
||||
}
|
||||
|
||||
function rollupKey(serverId: number, bucketAt: string): string {
|
||||
return `${serverId}\0${bucketAt}`
|
||||
}
|
||||
|
||||
function dimKey(serverId: number, bucketAt: string, dim: string, key: string): string {
|
||||
return `${serverId}\0${bucketAt}\0${dim}\0${key}`
|
||||
}
|
||||
|
||||
function bumpTick(key: string, inBytes: number, outBytes: number): void {
|
||||
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||
prev.inBytes += inBytes
|
||||
prev.outBytes += outBytes
|
||||
tickAccum.set(key, prev)
|
||||
}
|
||||
|
||||
function addToTick(serverId: number, flow: ParsedFlow, bytes: number): void {
|
||||
const plane = classifyFlowPlaneLite(flow)
|
||||
if (plane === "mgmt") return
|
||||
const bucket = plane === "overlay" ? RING_OVERLAY : plane === "client_mesh" ? RING_MESH : RING_PAYLOAD
|
||||
bumpTick(ringKey(serverId, bucket), bytes, 0)
|
||||
if (flow.inIface) bumpTick(ringKey(serverId, flow.inIface), bytes, 0)
|
||||
if (flow.outIface && flow.outIface !== flow.inIface) bumpTick(ringKey(serverId, flow.outIface), 0, bytes)
|
||||
}
|
||||
|
||||
function emptyRing(): { inBps: number[]; outBps: number[] } {
|
||||
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
|
||||
}
|
||||
|
||||
function bumpDim(serverId: number, bucketAt: string, dim: string, key: string, bytes: number, packets: number): void {
|
||||
if (!key) return
|
||||
const k = dimKey(serverId, bucketAt, dim, key)
|
||||
const prev = minuteDims.get(k)
|
||||
if (prev) {
|
||||
prev.bytes += bytes
|
||||
prev.packets += packets
|
||||
return
|
||||
}
|
||||
minuteDims.set(k, { bytes, packets })
|
||||
}
|
||||
|
||||
function bumpRollup(serverId: number, bucketAt: string, flow: ParsedFlow, bytes: number, packets: number): void {
|
||||
const k = rollupKey(serverId, bucketAt)
|
||||
let acc = minuteRollup.get(k)
|
||||
if (!acc) {
|
||||
acc = { bytes: 0, packets: 0, srcs: new Set(), dsts: new Set(), conversations: 0 }
|
||||
minuteRollup.set(k, acc)
|
||||
}
|
||||
acc.bytes += bytes
|
||||
acc.packets += packets
|
||||
if (flow.src) acc.srcs.add(flow.src)
|
||||
if (flow.dst) acc.dsts.add(flow.dst)
|
||||
acc.conversations += 1
|
||||
}
|
||||
|
||||
export function attachEngineSqlite(handle: SqliteHandle): void {
|
||||
sqliteRef = handle
|
||||
}
|
||||
|
||||
export function setPendingCapForTests(n: number | null): void {
|
||||
pendingCap = n == null ? MAX_PENDING : Math.max(1, n)
|
||||
}
|
||||
|
||||
export function configureEngine(opts: { topN?: number; retentionHours?: number }): void {
|
||||
if (opts.topN != null) topN = Math.max(20, opts.topN)
|
||||
if (opts.retentionHours != null) retentionHours = Math.max(1, opts.retentionHours)
|
||||
}
|
||||
|
||||
export function setExporterResolveCtx(ctx: ExporterResolveCtx | null): void {
|
||||
exporterCtx = ctx
|
||||
}
|
||||
|
||||
export function resolveServerId(exporterIp: string): number | null {
|
||||
if (!exporterCtx) return null
|
||||
return pickServerIdForExporter({
|
||||
exporterIp,
|
||||
overlayPrefix: exporterCtx.overlayPrefix,
|
||||
byTunnelIp: exporterCtx.byTunnelIp,
|
||||
peers: exporterCtx.peers,
|
||||
hostIps: exporterCtx.hostIps,
|
||||
})
|
||||
}
|
||||
|
||||
export function bumpPacketMeta(exporterIp: string): void {
|
||||
packetsReceived += 1
|
||||
lastExporterIp = exporterIp
|
||||
lastDatagramAt = nowIso()
|
||||
}
|
||||
|
||||
export function setEngineError(message: string): void {
|
||||
lastError = message
|
||||
}
|
||||
|
||||
export function getEngineStats(): EngineStats {
|
||||
return {
|
||||
packetsReceived,
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
lastDatagramAt,
|
||||
dropped,
|
||||
rowsStored,
|
||||
pendingSize: pending.size,
|
||||
}
|
||||
}
|
||||
|
||||
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||
const bucketAt = minuteBucketIso()
|
||||
const ripeMisses: string[] = []
|
||||
for (const raw of flows) {
|
||||
const flow = normalizeParsedFlow(raw)
|
||||
addToTick(serverId, flow, flow.bytes)
|
||||
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
|
||||
const ripe = lookupRipeCached(flow.dst)
|
||||
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
|
||||
const classified = classifyFlowDst(flow.dst, flow.proto, flow.dstPort, flow.srcPort, ripe)
|
||||
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
|
||||
const country = ripe?.ok && isIsoCountry(ripe.country)
|
||||
? ripe.country
|
||||
: (ripe?.ok ? "" : "unknown")
|
||||
const asnKey = ripe?.ok && ripe.asn ? String(ripe.asn) : "unknown"
|
||||
bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "category", classified.category, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets)
|
||||
if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets)
|
||||
|
||||
const key = pendingKey(serverId, bucketAt, flow)
|
||||
const prev = pending.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += flow.bytes
|
||||
prev.packets += flow.packets
|
||||
if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface
|
||||
if (flow.nextHop && !prev.flow.nextHop) prev.flow.nextHop = flow.nextHop
|
||||
if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) {
|
||||
prev.flow.flowStartMs = flow.flowStartMs
|
||||
}
|
||||
if (flow.flowEndMs > (prev.flow.flowEndMs ?? 0)) prev.flow.flowEndMs = flow.flowEndMs
|
||||
continue
|
||||
}
|
||||
if (pending.size >= pendingCap) {
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
pending.set(key, {
|
||||
serverId,
|
||||
bucketAt,
|
||||
flow: { ...flow },
|
||||
bytes: flow.bytes,
|
||||
packets: flow.packets,
|
||||
})
|
||||
}
|
||||
if (ripeMisses.length) enqueueRipeMisses(ripeMisses)
|
||||
}
|
||||
|
||||
export function ingestDatagram(msg: Buffer, exporterIp: string): boolean {
|
||||
bumpPacketMeta(exporterIp)
|
||||
const flows = parseFlowPacket(msg, exporterIp)
|
||||
if (!flows.length) return true
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) {
|
||||
setEngineError(
|
||||
`IPFIX от ${exporterIp}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
setEngineError("")
|
||||
maybeRefreshIfaces(serverId)
|
||||
queueParsedFlows(serverId, flows)
|
||||
return true
|
||||
}
|
||||
|
||||
function toPendingRow(row: PendingEntry): PendingFlowRow {
|
||||
const flow = normalizeParsedFlow(row.flow)
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
bucketAt: row.bucketAt,
|
||||
src: flow.src || "0.0.0.0",
|
||||
dst: flow.dst || "0.0.0.0",
|
||||
proto: flow.proto,
|
||||
srcPort: flow.srcPort,
|
||||
dstPort: flow.dstPort,
|
||||
bytes: row.bytes,
|
||||
packets: row.packets,
|
||||
inIface: flow.inIface,
|
||||
outIface: flow.outIface,
|
||||
nextHop: flow.nextHop,
|
||||
flowStartMs: flow.flowStartMs,
|
||||
flowEndMs: flow.flowEndMs,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
|
||||
const key = rowKey(row)
|
||||
const prev = map.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += row.bytes
|
||||
prev.packets += row.packets
|
||||
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||
return
|
||||
}
|
||||
map.set(key, { ...row })
|
||||
}
|
||||
|
||||
function pruneRecent(sinceMs = Date.now() - LIVE_WINDOW_MS): void {
|
||||
const cutoff = new Date(sinceMs).toISOString()
|
||||
for (const [key, row] of recent) {
|
||||
if (row.bucketAt < cutoff) recent.delete(key)
|
||||
}
|
||||
while (recent.size > MAX_PENDING) {
|
||||
const first = recent.keys().next().value
|
||||
if (first == null) break
|
||||
recent.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
export function peekPendingFlows(): PendingFlowRow[] {
|
||||
return [...pending.values()].map(toPendingRow)
|
||||
}
|
||||
|
||||
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
const merged = new Map<string, PendingFlowRow>()
|
||||
for (const row of recent.values()) {
|
||||
if (row.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, row)
|
||||
}
|
||||
for (const row of peekPendingFlows()) {
|
||||
if (row.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, row)
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function rollFlowRings(): void {
|
||||
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
|
||||
const sec = TICK_MS / 1000
|
||||
for (const key of keys) {
|
||||
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||
tickAccum.delete(key)
|
||||
const inBps = (acc.inBytes * 8) / sec
|
||||
const outBps = (acc.outBytes * 8) / sec
|
||||
let ring = rings.get(key)
|
||||
if (!ring) {
|
||||
ring = emptyRing()
|
||||
rings.set(key, ring)
|
||||
}
|
||||
ring.inBps.push(inBps)
|
||||
ring.inBps.shift()
|
||||
ring.outBps.push(outBps)
|
||||
ring.outBps.shift()
|
||||
const silent = ring.inBps.every((v) => v === 0) && ring.outBps.every((v) => v === 0)
|
||||
if (silent && !tickAccum.has(key)) rings.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRingMbps(serverId: number, iface = RING_PAYLOAD): {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
} {
|
||||
const ring = rings.get(ringKey(serverId, iface))
|
||||
const scale = 1_000_000
|
||||
if (!ring) {
|
||||
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
|
||||
}
|
||||
return {
|
||||
rx: ring.inBps.map((b) => b / scale),
|
||||
tx: ring.outBps.map((b) => b / scale),
|
||||
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
|
||||
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotRings(): Array<{ key: string; inBps: number[]; outBps: number[] }> {
|
||||
return [...rings.entries()].map(([key, ring]) => ({
|
||||
key,
|
||||
inBps: [...ring.inBps],
|
||||
outBps: [...ring.outBps],
|
||||
}))
|
||||
}
|
||||
|
||||
export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; outBps: number[] }>): void {
|
||||
rings.clear()
|
||||
for (const row of rows) {
|
||||
rings.set(row.key, { inBps: row.inBps, outBps: row.outBps })
|
||||
}
|
||||
}
|
||||
|
||||
function persistListenerStats(handle: SqliteHandle): void {
|
||||
handle.prepare(`
|
||||
UPDATE traffic_flow_settings
|
||||
SET packets_received = @packetsReceived,
|
||||
last_datagram_at = @lastDatagramAt,
|
||||
last_exporter_ip = @lastExporterIp,
|
||||
last_error = @lastError,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
packetsReceived,
|
||||
lastDatagramAt,
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
updatedAt: nowIso(),
|
||||
})
|
||||
}
|
||||
|
||||
function upsertMinuteAndDaily(handle: SqliteHandle): void {
|
||||
const upsertMinute = handle.prepare(`
|
||||
INSERT INTO flow_minute_stats (
|
||||
server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations
|
||||
) VALUES (
|
||||
@serverId, @bucketAt, @bytes, @packets, @uniqueSrc, @uniqueDst, @conversations
|
||||
)
|
||||
ON CONFLICT(server_id, bucket_at) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets,
|
||||
unique_src = MAX(unique_src, excluded.unique_src),
|
||||
unique_dst = MAX(unique_dst, excluded.unique_dst),
|
||||
conversations = conversations + excluded.conversations
|
||||
`)
|
||||
const upsertDim = handle.prepare(`
|
||||
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
|
||||
VALUES (@serverId, @bucketAt, @dim, @key, @bytes, @packets)
|
||||
ON CONFLICT(server_id, bucket_at, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
const upsertDaily = handle.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (@serverId, @day, @dim, @key, @bytes, @packets)
|
||||
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
|
||||
const tx = handle.transaction(() => {
|
||||
for (const [k, acc] of minuteRollup) {
|
||||
const [serverIdRaw, bucketAt] = k.split("\0")
|
||||
upsertMinute.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
bucketAt,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
uniqueSrc: acc.srcs.size,
|
||||
uniqueDst: acc.dsts.size,
|
||||
conversations: acc.conversations,
|
||||
})
|
||||
}
|
||||
for (const [k, acc] of minuteDims) {
|
||||
const [serverIdRaw, bucketAt, dim, key] = k.split("\0")
|
||||
upsertDim.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
bucketAt,
|
||||
dim,
|
||||
key,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
})
|
||||
if (dim === "country" || dim === "service" || dim === "asn") {
|
||||
upsertDaily.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
day: dayKey(bucketAt ?? ""),
|
||||
dim,
|
||||
key,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
tx()
|
||||
minuteRollup.clear()
|
||||
minuteDims.clear()
|
||||
}
|
||||
|
||||
function capDailyAsn(handle: SqliteHandle): void {
|
||||
const today = nowIso().slice(0, 10)
|
||||
const rows = handle.prepare(`
|
||||
SELECT server_id AS serverId, key, bytes, packets
|
||||
FROM flow_daily_dims
|
||||
WHERE day = ? AND dim = 'asn'
|
||||
ORDER BY server_id, bytes DESC
|
||||
`).all(today) as Array<{ serverId: number; key: string; bytes: number; packets: number }>
|
||||
const byServer = new Map<number, typeof rows>()
|
||||
for (const row of rows) {
|
||||
const list = byServer.get(row.serverId) ?? []
|
||||
list.push(row)
|
||||
byServer.set(row.serverId, list)
|
||||
}
|
||||
const del = handle.prepare(`
|
||||
DELETE FROM flow_daily_dims WHERE server_id = ? AND day = ? AND dim = 'asn' AND key = ?
|
||||
`)
|
||||
const upsertOther = handle.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (?, ?, 'asn', 'other', ?, ?)
|
||||
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
for (const [serverId, list] of byServer) {
|
||||
if (list.length <= DAILY_ASN_TOP) continue
|
||||
let otherBytes = 0
|
||||
let otherPackets = 0
|
||||
for (const row of list.slice(DAILY_ASN_TOP)) {
|
||||
if (row.key === "other") continue
|
||||
otherBytes += row.bytes
|
||||
otherPackets += row.packets
|
||||
del.run(serverId, today, row.key)
|
||||
}
|
||||
if (otherBytes > 0) upsertOther.run(serverId, today, otherBytes, otherPackets)
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStored(handle: SqliteHandle): void {
|
||||
const now = Date.now()
|
||||
if (now - lastPruneAt < PRUNE_MS) return
|
||||
lastPruneAt = now
|
||||
const flowCutoff = new Date(now - retentionHours * 3600_000).toISOString()
|
||||
const minuteCutoff = new Date(now - MINUTE_RETENTION_HOURS * 3600_000).toISOString()
|
||||
const dailyCutoff = new Date(now - DAILY_RETENTION_DAYS * 86400_000).toISOString().slice(0, 10)
|
||||
handle.prepare(`DELETE FROM flow_buckets WHERE bucket_at < ?`).run(flowCutoff)
|
||||
handle.prepare(`DELETE FROM flow_minute_stats WHERE bucket_at < ?`).run(minuteCutoff)
|
||||
handle.prepare(`DELETE FROM flow_minute_dims WHERE bucket_at < ?`).run(minuteCutoff)
|
||||
handle.prepare(`DELETE FROM flow_daily_dims WHERE day < ?`).run(dailyCutoff)
|
||||
|
||||
const keep = Math.max(20, topN)
|
||||
try {
|
||||
handle.prepare(`
|
||||
DELETE FROM flow_buckets WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
|
||||
) AS rn
|
||||
FROM flow_buckets
|
||||
) ranked WHERE rn > ?
|
||||
)
|
||||
`).run(keep)
|
||||
} catch {
|
||||
const buckets = handle.prepare(`
|
||||
SELECT DISTINCT server_id AS serverId, bucket_at AS bucketAt FROM flow_buckets
|
||||
`).all() as Array<{ serverId: number; bucketAt: string }>
|
||||
for (const b of buckets) {
|
||||
const rows = handle.prepare(`
|
||||
SELECT id, bytes FROM flow_buckets
|
||||
WHERE server_id = ? AND bucket_at = ?
|
||||
ORDER BY bytes DESC
|
||||
`).all(b.serverId, b.bucketAt) as Array<{ id: number; bytes: number }>
|
||||
for (const extra of rows.slice(keep)) {
|
||||
handle.prepare(`DELETE FROM flow_buckets WHERE id = ?`).run(extra.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
||||
const keep = Math.max(20, topN)
|
||||
const groups = new Map<string, PendingFlowRow[]>()
|
||||
for (const row of rows) {
|
||||
const k = `${row.serverId}\0${row.bucketAt}`
|
||||
const list = groups.get(k) ?? []
|
||||
list.push(row)
|
||||
groups.set(k, list)
|
||||
}
|
||||
const out: PendingFlowRow[] = []
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => b.bytes - a.bytes)
|
||||
out.push(...list.slice(0, keep))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function flushPending(): void {
|
||||
pruneRecent()
|
||||
rollFlowRings()
|
||||
const handle = sqliteRef
|
||||
if (!handle) {
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
persistListenerStats(handle)
|
||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
||||
pruneStored(handle)
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
const rows = topNPending([...pending.values()].map(toPendingRow))
|
||||
pending.clear()
|
||||
for (const row of rows) mergeInto(recent, row)
|
||||
|
||||
const upsertFlow = handle.prepare(`
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||
) VALUES (
|
||||
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs
|
||||
)
|
||||
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets,
|
||||
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE out_iface END,
|
||||
next_hop = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE next_hop END,
|
||||
flow_start_ms = CASE
|
||||
WHEN excluded.flow_start_ms > 0 AND (flow_start_ms = 0 OR excluded.flow_start_ms < flow_start_ms)
|
||||
THEN excluded.flow_start_ms ELSE flow_start_ms END,
|
||||
flow_end_ms = MAX(flow_end_ms, excluded.flow_end_ms)
|
||||
`)
|
||||
lastFlushUsedTransaction = false
|
||||
try {
|
||||
const tx = handle.transaction((batch: PendingFlowRow[]) => {
|
||||
for (const r of batch) {
|
||||
upsertFlow.run({
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
}
|
||||
})
|
||||
tx(rows)
|
||||
lastFlushUsedTransaction = true
|
||||
rowsStored += rows.length
|
||||
} catch {
|
||||
for (const r of rows) {
|
||||
try {
|
||||
upsertFlow.run({
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
rowsStored += 1
|
||||
} catch {
|
||||
/* ignore single-row failures */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
upsertMinuteAndDaily(handle)
|
||||
capDailyAsn(handle)
|
||||
} catch {
|
||||
/* rollup best-effort */
|
||||
}
|
||||
pruneStored(handle)
|
||||
try {
|
||||
handle.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
return lastFlushUsedTransaction
|
||||
}
|
||||
|
||||
export function flushPendingForTests(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function onEngineTick(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]): void {
|
||||
queueParsedFlows(serverId, flows)
|
||||
rollFlowRings()
|
||||
}
|
||||
|
||||
export function resetEngineForTests(): void {
|
||||
pending.clear()
|
||||
recent.clear()
|
||||
tickAccum.clear()
|
||||
rings.clear()
|
||||
minuteRollup.clear()
|
||||
minuteDims.clear()
|
||||
packetsReceived = 0
|
||||
lastExporterIp = null
|
||||
lastError = ""
|
||||
lastDatagramAt = null
|
||||
dropped = 0
|
||||
rowsStored = 0
|
||||
lastFlushUsedTransaction = false
|
||||
lastPruneAt = 0
|
||||
pendingCap = MAX_PENDING
|
||||
}
|
||||
|
||||
export function pendingSizeForTests(): number {
|
||||
return pending.size
|
||||
}
|
||||
|
||||
export function droppedForTests(): number {
|
||||
return dropped
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { SQLITE_BUSY_TIMEOUT_MS, sqliteDatabase } from "../db/index.js"
|
||||
import {
|
||||
MAX_FLOW_LIVE_SUBSCRIBERS,
|
||||
resetFlowLiveSlotsForTests,
|
||||
tryAcquireFlowLiveSlot,
|
||||
releaseFlowLiveSlot,
|
||||
} from "../routes/traffic-flow.js"
|
||||
|
||||
const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: number }>
|
||||
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
|
||||
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
|
||||
|
||||
resetFlowLiveSlotsForTests()
|
||||
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
|
||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||
}
|
||||
assert.equal(tryAcquireFlowLiveSlot(), false)
|
||||
releaseFlowLiveSlot()
|
||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||
resetFlowLiveSlotsForTests()
|
||||
|
||||
console.log("traffic-flow-hardening.test.ts: ok")
|
||||
@@ -0,0 +1,113 @@
|
||||
import { generateNativeConf } from "./wireguard-config.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||
|
||||
export function buildHostWgQuickConf(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = listHostPeers()
|
||||
return generateNativeConf({
|
||||
name: "wg-flow",
|
||||
mtu: 1420,
|
||||
privateKey: row.hostPrivateKey || undefined,
|
||||
address: `${row.collectorIp}/24`,
|
||||
comment: "MikrotikManager traffic-flow collector",
|
||||
peers: peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
comment: p.name,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.endpoint ? 25 : undefined,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHostComposeOverride(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return [
|
||||
"# Docker Compose merge для /opt/cdn-mm",
|
||||
"# Не править docker-compose.yml. Traefik не трогать.",
|
||||
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
|
||||
"# затем: docker compose up -d --force-recreate backend",
|
||||
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
|
||||
"",
|
||||
"services:",
|
||||
" backend:",
|
||||
" environment:",
|
||||
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
|
||||
" ports:",
|
||||
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function buildHostLinuxInstallSh(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
|
||||
const override = buildHostComposeOverride()
|
||||
const collector = row.collectorIp
|
||||
const flowPort = row.flowListenPort
|
||||
|
||||
return `#!/usr/bin/env bash
|
||||
# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend.
|
||||
# Запуск: sudo bash install-wg-flow.sh
|
||||
set -euo pipefail
|
||||
|
||||
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "Запустите от root: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COLLECTOR_IP="${collector}"
|
||||
FLOW_PORT="${flowPort}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR}"
|
||||
|
||||
if ! command -v wg >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
|
||||
fi
|
||||
|
||||
install -d -m 700 /etc/wireguard
|
||||
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
|
||||
${conf}WGEOF
|
||||
chmod 600 /etc/wireguard/wg-flow.conf
|
||||
|
||||
systemctl enable --now wg-quick@wg-flow
|
||||
echo "=== wg show wg-flow ==="
|
||||
wg show wg-flow
|
||||
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
|
||||
ip -4 addr show dev wg-flow
|
||||
|
||||
if [[ ! -d "\$COMPOSE_DIR" ]]; then
|
||||
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
|
||||
${override}OVEOF
|
||||
|
||||
cd "\$COMPOSE_DIR"
|
||||
docker compose up -d --force-recreate backend
|
||||
|
||||
echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ==="
|
||||
ss -ulnp | grep -E "\${FLOW_PORT}" || true
|
||||
echo "=== PortBindings mmapp-backend ==="
|
||||
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
|
||||
echo "=== handshake (keepalive 25s к JH:13232) ==="
|
||||
wg show wg-flow
|
||||
|
||||
# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0.
|
||||
# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend
|
||||
|
||||
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
|
||||
`
|
||||
}
|
||||
|
||||
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||
return [
|
||||
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
|
||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
resolveIfaceName,
|
||||
rosIdToIfIndex,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
|
||||
assert.equal(rosIdToIfIndex("*A"), 10)
|
||||
assert.equal(rosIdToIfIndex("*D"), 13)
|
||||
assert.equal(rosIdToIfIndex("*2"), 2)
|
||||
assert.equal(rosIdToIfIndex("*9"), 9)
|
||||
assert.equal(rosIdToIfIndex("0"), 0)
|
||||
assert.equal(rosIdToIfIndex(""), null)
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "ether1" },
|
||||
{ ".id": "*A", name: "wg-flow" },
|
||||
{ ".id": "*D", name: "bridge" },
|
||||
])
|
||||
assert.equal(resolveIfaceName(7, "2").name, "ether1")
|
||||
assert.equal(resolveIfaceName(7, "10").name, "wg-flow")
|
||||
assert.equal(resolveIfaceName(7, "13").name, "bridge")
|
||||
assert.equal(resolveIfaceName(7, "0").name, "—")
|
||||
assert.equal(resolveIfaceName(7, "ether1").name, "ether1")
|
||||
assert.equal(resolveIfaceName(7, "99").name, "#99")
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(8, [
|
||||
{ ifindex: "10", ".id": "*12", name: "gre1" },
|
||||
])
|
||||
assert.equal(rosIdToIfIndex("*12"), 18)
|
||||
assert.equal(resolveIfaceName(8, "10").name, "gre1")
|
||||
assert.equal(resolveIfaceName(8, "18").name, "gre1")
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
assert.equal(shouldRefreshIfaces(9), true)
|
||||
rememberServerIfaces(9, [{ ".id": "*2", name: "ether1" }])
|
||||
assert.equal(shouldRefreshIfaces(9), false)
|
||||
resetIfaceCacheForTests()
|
||||
markIfaceRefreshAttempt(9)
|
||||
assert.equal(shouldRefreshIfaces(9), false)
|
||||
|
||||
assert.equal(applicationName(6, 443), "HTTPS")
|
||||
assert.equal(applicationName(17, 53), "DNS")
|
||||
assert.equal(applicationName(6, 22), "SSH")
|
||||
assert.equal(applicationName(17, 51820), "WireGuard")
|
||||
assert.equal(applicationName(6, 179), "BGP")
|
||||
|
||||
const allow = new Map<number, Set<string>>([[7, new Set(["ether1", "wg-flow"])]])
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", {}, allow), true)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "bridge", {}, allow), false)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "ether1" }, allow), true)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "wg-flow" }, allow), false)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 8, inIface: "2" }, "ether1", { serverId: 7 }, null), false)
|
||||
|
||||
console.log("traffic-flow-ifaces.test.ts: ok")
|
||||
@@ -0,0 +1,56 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import {
|
||||
rememberServerIfaces,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
type RosIfaceIndexRow,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
export {
|
||||
ifaceCacheFresh,
|
||||
ifaceCacheHas,
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
resolveIfaceName,
|
||||
rosIdToIfIndex,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
const inflight = new Set<number>()
|
||||
let refreshIfacesImpl: (serverId: number, force?: boolean) => Promise<void> = refreshServerIfacesInner
|
||||
|
||||
async function refreshServerIfacesInner(serverId: number, force = false): Promise<void> {
|
||||
if (inflight.has(serverId)) return
|
||||
if (!force && !shouldRefreshIfaces(serverId)) return
|
||||
inflight.add(serverId)
|
||||
try {
|
||||
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!row) return
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const ifaces = await client.get<RosIfaceIndexRow[]>("/interface")
|
||||
rememberServerIfaces(serverId, Array.isArray(ifaces) ? ifaces : [])
|
||||
} catch {
|
||||
/* keep previous cache */
|
||||
} finally {
|
||||
markIfaceRefreshAttempt(serverId)
|
||||
inflight.delete(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> {
|
||||
return refreshIfacesImpl(serverId, force)
|
||||
}
|
||||
|
||||
export function maybeRefreshIfaces(serverId: number): boolean {
|
||||
if (!shouldRefreshIfaces(serverId)) return false
|
||||
void refreshIfacesImpl(serverId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function setRefreshIfacesForTests(fn: typeof refreshServerIfacesInner | null): void {
|
||||
refreshIfacesImpl = fn ?? refreshServerIfacesInner
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface RosIfaceIndexRow {
|
||||
".id"?: string
|
||||
name?: string
|
||||
ifindex?: string
|
||||
}
|
||||
|
||||
const cache = new Map<number, Map<number, string>>()
|
||||
const fetchedAt = new Map<number, number>()
|
||||
const lastAttempt = new Map<number, number>()
|
||||
|
||||
export const IFACE_CACHE_TTL_MS = 60_000
|
||||
|
||||
/** RouterOS `.id` (`*A`) → SNMP ifIndex (10). */
|
||||
export function rosIdToIfIndex(id: string | undefined | null): number | null {
|
||||
if (!id) return null
|
||||
const raw = String(id).trim()
|
||||
const hex = raw.startsWith("*") ? raw.slice(1) : raw
|
||||
if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return null
|
||||
const n = parseInt(hex, 16)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function rememberServerIfaces(serverId: number, rows: RosIfaceIndexRow[]): void {
|
||||
const map = new Map<number, string>()
|
||||
for (const row of rows) {
|
||||
const name = String(row.name ?? "").trim()
|
||||
if (!name) continue
|
||||
const fromProp = Number.parseInt(String(row.ifindex ?? ""), 10)
|
||||
const fromId = rosIdToIfIndex(row[".id"])
|
||||
if (Number.isFinite(fromProp) && fromProp > 0) map.set(fromProp, name)
|
||||
if (fromId != null && fromId > 0) map.set(fromId, name)
|
||||
}
|
||||
cache.set(serverId, map)
|
||||
fetchedAt.set(serverId, Date.now())
|
||||
}
|
||||
|
||||
export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } {
|
||||
const trimmed = String(indexOrName ?? "").trim()
|
||||
if (!trimmed || trimmed === "0") return { name: "—", index: trimmed }
|
||||
if (!/^\d+$/.test(trimmed)) return { name: trimmed, index: "" }
|
||||
const idx = Number(trimmed)
|
||||
const name = cache.get(serverId)?.get(idx)
|
||||
if (name) return { name, index: trimmed }
|
||||
return { name: `#${trimmed}`, index: trimmed }
|
||||
}
|
||||
|
||||
export function ifaceCacheHas(serverId: number): boolean {
|
||||
return cache.has(serverId)
|
||||
}
|
||||
|
||||
export function ifaceCacheFresh(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
|
||||
const prev = fetchedAt.get(serverId) ?? 0
|
||||
return Boolean(prev && Date.now() - prev < ttlMs && cache.has(serverId))
|
||||
}
|
||||
|
||||
/** Не ходить в REST, пока кэш жив или с момента последней попытки не прошёл TTL. */
|
||||
export function shouldRefreshIfaces(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
|
||||
if (ifaceCacheFresh(serverId, ttlMs)) return false
|
||||
const attempted = lastAttempt.get(serverId) ?? 0
|
||||
return !(attempted && Date.now() - attempted < ttlMs)
|
||||
}
|
||||
|
||||
export function markIfaceRefreshAttempt(serverId: number, at = Date.now()): void {
|
||||
lastAttempt.set(serverId, at)
|
||||
}
|
||||
|
||||
export function resetIfaceCacheForTests(): void {
|
||||
cache.clear()
|
||||
fetchedAt.clear()
|
||||
lastAttempt.clear()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
markIfaceRefreshAttempt,
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
shouldRefreshIfaces,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
import {
|
||||
applyHeartbeatForTests,
|
||||
flushPendingForTests,
|
||||
getFlowListenerState,
|
||||
getFlowRuntimeCounters,
|
||||
getFlowWorkerHealth,
|
||||
ingestParsedFlowsForServerForTests,
|
||||
lastFlushUsedTransactionForTests,
|
||||
maybeRefreshIfaces,
|
||||
peekPendingFlows,
|
||||
resetFlowRingsForTests,
|
||||
setPendingCapForTests,
|
||||
setRefreshIfacesForTests,
|
||||
setWantListenForTests,
|
||||
simulateWorkerExitForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
resetFlowRingsForTests()
|
||||
|
||||
let refreshCalls = 0
|
||||
setRefreshIfacesForTests(async () => {
|
||||
refreshCalls += 1
|
||||
})
|
||||
|
||||
rememberServerIfaces(1, [{ ".id": "*A", name: "wg-flow" }])
|
||||
assert.equal(shouldRefreshIfaces(1), false)
|
||||
assert.equal(maybeRefreshIfaces(1), false)
|
||||
assert.equal(refreshCalls, 0)
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
assert.equal(shouldRefreshIfaces(2), true)
|
||||
assert.equal(maybeRefreshIfaces(2), true)
|
||||
assert.equal(refreshCalls, 1)
|
||||
|
||||
markIfaceRefreshAttempt(2)
|
||||
assert.equal(shouldRefreshIfaces(2), false)
|
||||
assert.equal(maybeRefreshIfaces(2), false)
|
||||
assert.equal(refreshCalls, 1)
|
||||
|
||||
assert.equal(lastFlushUsedTransactionForTests(), false)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
setPendingCapForTests(3)
|
||||
const many = Array.from({ length: 6 }, (_, i) => ({
|
||||
src: `10.1.1.${i + 1}`,
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 50000 + i,
|
||||
dstPort: 443,
|
||||
bytes: 1000,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}))
|
||||
ingestParsedFlowsForServerForTests(9, many)
|
||||
assert.equal(pendingSizeForTests(), 3)
|
||||
assert.equal(droppedForTests(), 3)
|
||||
assert.equal(peekPendingFlows().length, 3)
|
||||
setPendingCapForTests(null)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
configureEngine({ topN: 20 })
|
||||
const talkers = Array.from({ length: 25 }, (_, i) => ({
|
||||
src: `10.2.1.${i + 1}`,
|
||||
dst: "1.1.1.1",
|
||||
proto: 6,
|
||||
srcPort: 40000 + i,
|
||||
dstPort: 443,
|
||||
bytes: 1000 + i,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}))
|
||||
ingestParsedFlowsForServerForTests(9, talkers)
|
||||
flushPendingForTests()
|
||||
const stored = sqliteDatabase.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flow_buckets WHERE server_id = 9
|
||||
`).get() as { n: number }
|
||||
assert.ok(stored.n <= 20, `expected topN cap, got ${stored.n}`)
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
|
||||
|
||||
applyHeartbeatForTests({
|
||||
bound: true,
|
||||
address: "127.0.0.1:4739",
|
||||
packetsReceived: 42,
|
||||
lastExporterIp: "10.255.254.3",
|
||||
lastError: "",
|
||||
lastDatagramAt: new Date().toISOString(),
|
||||
pendingSize: 1,
|
||||
dropped: 0,
|
||||
rowsStored: 1,
|
||||
workerAlive: true,
|
||||
rings: [],
|
||||
})
|
||||
assert.equal(getFlowListenerState().bound, true)
|
||||
assert.equal(getFlowRuntimeCounters().packetsReceived, 42)
|
||||
assert.equal(getFlowWorkerHealth().alive, false)
|
||||
setWantListenForTests(true)
|
||||
assert.equal(simulateWorkerExitForTests(), 1)
|
||||
assert.equal(getFlowListenerState().bound, false)
|
||||
setWantListenForTests(false)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
setRefreshIfacesForTests(null)
|
||||
|
||||
console.log("traffic-flow-ingest.test.ts: ok")
|
||||
@@ -0,0 +1,518 @@
|
||||
import { Worker } from "node:worker_threads"
|
||||
import { existsSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { gte, sql } from "drizzle-orm"
|
||||
import { beginSqliteExclusiveOp, db, endSqliteExclusiveOp, sqliteDatabase } from "../db/index.js"
|
||||
import { env } from "../config.js"
|
||||
import { flowBuckets, servers } from "../db/schema.js"
|
||||
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
|
||||
import {
|
||||
attachEngineSqlite,
|
||||
applyRingSnapshot,
|
||||
configureEngine,
|
||||
flushPending,
|
||||
getEngineStats,
|
||||
getRingMbps as engineGetRingMbps,
|
||||
ingestParsedFlowsForServerForTests as engineIngestForServer,
|
||||
lastFlushUsedTransactionForTests as engineLastFlushTx,
|
||||
listLiveFlowRows as engineListLive,
|
||||
peekPendingFlows,
|
||||
queueParsedFlows,
|
||||
resetEngineForTests,
|
||||
resolveServerId,
|
||||
rollFlowRings,
|
||||
setExporterResolveCtx,
|
||||
type PendingFlowRow,
|
||||
} from "./traffic-flow-engine.js"
|
||||
import {
|
||||
getTrafficFlowSettingsRow,
|
||||
listHostPeers,
|
||||
resetFlowIngestCounters,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
|
||||
export type { PendingFlowRow }
|
||||
|
||||
export interface FlowListenerState {
|
||||
bound: boolean
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export interface FlowWorkerHealth {
|
||||
alive: boolean
|
||||
bound: boolean
|
||||
pendingSize: number
|
||||
dropped: number
|
||||
packetsReceived: number
|
||||
}
|
||||
|
||||
let worker: Worker | null = null
|
||||
let restartTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let restartAttempts = 0
|
||||
let lastHeartbeat: CollectorHeartbeat | null = null
|
||||
let state: FlowListenerState = { bound: false, address: null }
|
||||
let wantListen = false
|
||||
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
|
||||
function workerFileUrl(): URL {
|
||||
const ts = import.meta.url.includes(".ts")
|
||||
return new URL(
|
||||
ts ? "./traffic-flow-collector-worker.ts" : "./traffic-flow-collector-worker.js",
|
||||
import.meta.url,
|
||||
)
|
||||
}
|
||||
|
||||
export function buildExporterMapPayload(): ExporterMapPayload {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const rows = db.select({
|
||||
id: servers.id,
|
||||
host: servers.host,
|
||||
mgmtTunnelIp: servers.mgmtTunnelIp,
|
||||
}).from(servers).all()
|
||||
const byTunnelIp: Array<[string, number]> = []
|
||||
const hostIps: Array<[string, number]> = []
|
||||
for (const row of rows) {
|
||||
if (row.mgmtTunnelIp) byTunnelIp.push([row.mgmtTunnelIp, row.id])
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.push([row.host, row.id])
|
||||
}
|
||||
return {
|
||||
overlayPrefix: settings.prefix,
|
||||
byTunnelIp,
|
||||
peers: listHostPeers(),
|
||||
hostIps,
|
||||
}
|
||||
}
|
||||
|
||||
function applyExporterCtxFromDb(): void {
|
||||
const payload = buildExporterMapPayload()
|
||||
setExporterResolveCtx({
|
||||
overlayPrefix: payload.overlayPrefix,
|
||||
byTunnelIp: new Map(payload.byTunnelIp),
|
||||
peers: payload.peers,
|
||||
hostIps: new Map(payload.hostIps),
|
||||
})
|
||||
}
|
||||
|
||||
function postToWorker(msg: MainToWorker): void {
|
||||
worker?.postMessage(msg)
|
||||
}
|
||||
|
||||
function handleWorkerMessage(msg: WorkerToMain): void {
|
||||
if (msg.type === "heartbeat") {
|
||||
lastHeartbeat = msg.payload
|
||||
state = { bound: msg.payload.bound, address: msg.payload.address }
|
||||
applyRingSnapshot(msg.payload.rings)
|
||||
restartAttempts = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
lastHeartbeat = lastHeartbeat
|
||||
? { ...lastHeartbeat, lastError: msg.payload.message, workerAlive: true }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function spawnWorker(): void {
|
||||
stopWorkerProcess()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
configureEngine({ topN: settings.topN, retentionHours: settings.retentionHours })
|
||||
applyExporterCtxFromDb()
|
||||
const w = new Worker(workerFileUrl(), { execArgv: process.execArgv })
|
||||
w.on("message", (msg: WorkerToMain) => handleWorkerMessage(msg))
|
||||
w.on("error", (err) => {
|
||||
state = { bound: false, address: null }
|
||||
lastHeartbeat = lastHeartbeat
|
||||
? { ...lastHeartbeat, workerAlive: false, lastError: err.message, bound: false }
|
||||
: {
|
||||
bound: false,
|
||||
address: null,
|
||||
packetsReceived: 0,
|
||||
lastExporterIp: null,
|
||||
lastError: err.message,
|
||||
lastDatagramAt: null,
|
||||
pendingSize: 0,
|
||||
dropped: 0,
|
||||
rowsStored: 0,
|
||||
workerAlive: false,
|
||||
rings: [],
|
||||
}
|
||||
})
|
||||
w.on("exit", (code) => {
|
||||
worker = null
|
||||
state = { bound: false, address: null }
|
||||
if (!wantListen) return
|
||||
const delay = Math.min(30_000, 1000 * 2 ** restartAttempts)
|
||||
restartAttempts += 1
|
||||
restartTimer = setTimeout(() => {
|
||||
if (wantListen) spawnWorker()
|
||||
}, delay)
|
||||
void code
|
||||
})
|
||||
worker = w
|
||||
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
||||
postToWorker({
|
||||
type: "start",
|
||||
payload: {
|
||||
dbPath: env.DATABASE_PATH,
|
||||
listenHost: host,
|
||||
listenPort: settings.flowListenPort,
|
||||
topN: settings.topN,
|
||||
retentionHours: settings.retentionHours,
|
||||
exporterMap: buildExporterMapPayload(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function stopWorkerProcess(): void {
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
if (worker) {
|
||||
try {
|
||||
postToWorker({ type: "stop" })
|
||||
void worker.terminate()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
worker = null
|
||||
}
|
||||
}
|
||||
|
||||
export function reattachFlowSqlite(): void {
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
}
|
||||
|
||||
export function applyHeartbeatForTests(payload: CollectorHeartbeat): void {
|
||||
handleWorkerMessage({ type: "heartbeat", payload })
|
||||
}
|
||||
|
||||
export function simulateWorkerExitForTests(): number {
|
||||
worker = null
|
||||
state = { bound: false, address: null }
|
||||
lastHeartbeat = lastHeartbeat ? { ...lastHeartbeat, workerAlive: false, bound: false } : null
|
||||
if (!wantListen) return restartAttempts
|
||||
restartAttempts += 1
|
||||
return restartAttempts
|
||||
}
|
||||
|
||||
export function setWantListenForTests(value: boolean): void {
|
||||
wantListen = value
|
||||
}
|
||||
|
||||
export function getFlowListenerState(): FlowListenerState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function getFlowWorkerHealth(): FlowWorkerHealth {
|
||||
const hb = lastHeartbeat
|
||||
const mem = getEngineStats()
|
||||
return {
|
||||
alive: Boolean(worker) && (hb?.workerAlive ?? false),
|
||||
bound: state.bound,
|
||||
pendingSize: hb?.pendingSize ?? mem.pendingSize,
|
||||
dropped: hb?.dropped ?? mem.dropped,
|
||||
packetsReceived: hb?.packetsReceived ?? mem.packetsReceived,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFlowRuntimeCounters() {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const hb = lastHeartbeat
|
||||
return {
|
||||
packetsReceived: hb?.packetsReceived ?? settings.packetsReceived,
|
||||
lastExporterIp: hb?.lastExporterIp ?? settings.lastExporterIp ?? null,
|
||||
lastError: (hb?.lastError ?? settings.lastError) || null,
|
||||
lastDatagramAt: hb?.lastDatagramAt ?? settings.lastDatagramAt ?? null,
|
||||
dropped: hb?.dropped ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function startTrafficFlowListener() {
|
||||
stopTrafficFlowListener()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
if (!settings.enabled) {
|
||||
wantListen = false
|
||||
state = { bound: false, address: null }
|
||||
return
|
||||
}
|
||||
wantListen = true
|
||||
spawnWorker()
|
||||
}
|
||||
|
||||
export function stopTrafficFlowListener() {
|
||||
wantListen = false
|
||||
stopWorkerProcess()
|
||||
try {
|
||||
flushPending()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
state = { bound: false, address: null }
|
||||
}
|
||||
|
||||
export function refreshFlowExporterMap(): void {
|
||||
applyExporterCtxFromDb()
|
||||
postToWorker({ type: "updateExporterMap", payload: buildExporterMapPayload() })
|
||||
}
|
||||
|
||||
export function getRingMbps(serverId: number, iface = "__all__") {
|
||||
return engineGetRingMbps(serverId, iface)
|
||||
}
|
||||
|
||||
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
|
||||
const key = `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
|
||||
const prev = map.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += row.bytes
|
||||
prev.packets += row.packets
|
||||
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||
return
|
||||
}
|
||||
map.set(key, { ...row })
|
||||
}
|
||||
|
||||
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
if (worker && lastHeartbeat?.workerAlive) {
|
||||
return listStoredFlowRows(sinceIso)
|
||||
}
|
||||
return engineListLive(sinceIso)
|
||||
}
|
||||
|
||||
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const cap = Math.max(20, settings.topN) * 60
|
||||
const stored = db.select().from(flowBuckets)
|
||||
.where(gte(flowBuckets.bucketAt, sinceIso))
|
||||
.orderBy(sql`${flowBuckets.bytes} DESC`)
|
||||
.limit(cap)
|
||||
.all()
|
||||
const merged = new Map<string, PendingFlowRow>()
|
||||
for (const r of stored) {
|
||||
mergeInto(merged, {
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface ?? "",
|
||||
nextHop: r.nextHop ?? "",
|
||||
flowStartMs: r.flowStartMs ?? 0,
|
||||
flowEndMs: r.flowEndMs ?? 0,
|
||||
})
|
||||
}
|
||||
if (!worker) {
|
||||
for (const p of peekPendingFlows()) {
|
||||
if (p.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, p)
|
||||
}
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function listFlowRowsForWindow(minutes: number): PendingFlowRow[] {
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
if (minutes <= 15 && !worker) return listLiveFlowRows(sinceIso)
|
||||
return listStoredFlowRows(sinceIso)
|
||||
}
|
||||
|
||||
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const runtime = getFlowRuntimeCounters()
|
||||
const rows = listFlowRowsForWindow(minutes)
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||
const protoBytes = new Map<number, number>()
|
||||
const srcs = new Set<string>()
|
||||
const dsts = new Set<string>()
|
||||
const exporters = new Set<number>()
|
||||
let totalBytes = 0
|
||||
for (const r of rows) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
|
||||
const prev = agg.get(key)
|
||||
const bytes = r.bytes
|
||||
totalBytes += bytes
|
||||
srcs.add(r.src)
|
||||
dsts.add(r.dst)
|
||||
exporters.add(r.serverId)
|
||||
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
|
||||
if (prev) {
|
||||
prev.rawBytes += bytes
|
||||
prev.bytes += bytes
|
||||
prev.packets += r.packets
|
||||
} else {
|
||||
agg.set(key, {
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
protoName: protoName(r.proto),
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes,
|
||||
packets: r.packets,
|
||||
bps: 0,
|
||||
inIface: resolved.name,
|
||||
inIfaceIndex: resolved.index,
|
||||
application: applicationName(r.proto, r.dstPort, r.srcPort),
|
||||
rawBytes: bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
const windowSec = Math.max(60, minutes * 60)
|
||||
const talkers = [...agg.values()]
|
||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, settings.topN)
|
||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||
let topProto = "—"
|
||||
let topProtoBytes = 0
|
||||
for (const [p, b] of protoBytes) {
|
||||
if (b > topProtoBytes) {
|
||||
topProtoBytes = b
|
||||
topProto = protoName(p)
|
||||
}
|
||||
}
|
||||
return {
|
||||
exportersOnline: exporters.size,
|
||||
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
|
||||
uniqueSrc: srcs.size,
|
||||
uniqueDst: dsts.size,
|
||||
topProto,
|
||||
talkers,
|
||||
lastExporterIp: runtime.lastExporterIp,
|
||||
lastError: runtime.lastError,
|
||||
packetsReceived: runtime.packetsReceived,
|
||||
lastDatagramAt: runtime.lastDatagramAt,
|
||||
listenerBound: state.bound,
|
||||
listenerAddress: state.address,
|
||||
}
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
|
||||
applyExporterCtxFromDb()
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) return
|
||||
queueParsedFlows(serverId, flows)
|
||||
rollFlowRings()
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
||||
engineIngestForServer(serverId, flows)
|
||||
}
|
||||
|
||||
export function resetFlowRingsForTests() {
|
||||
resetEngineForTests()
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
lastHeartbeat = null
|
||||
wantListen = false
|
||||
restartAttempts = 0
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
return engineLastFlushTx()
|
||||
}
|
||||
|
||||
export function flushPendingForTests(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
function tableCount(name: string): number {
|
||||
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
function dbFileBytes(): number {
|
||||
const resolved = path.resolve(process.cwd(), env.DATABASE_PATH)
|
||||
if (!existsSync(resolved)) return 0
|
||||
return statSync(resolved).size
|
||||
}
|
||||
|
||||
async function stopWorkerProcessAsync(): Promise<void> {
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
if (!worker) return
|
||||
const current = worker
|
||||
worker = null
|
||||
try {
|
||||
current.postMessage({ type: "stop" })
|
||||
await current.terminate()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаляет сессии, minute/daily rollup и сжимает SQLite. Ключи WG и пиры JH не трогает. */
|
||||
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
||||
beginSqliteExclusiveOp()
|
||||
try {
|
||||
wantListen = false
|
||||
await stopWorkerProcessAsync()
|
||||
resetEngineForTests()
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
lastHeartbeat = null
|
||||
state = { bound: false, address: null }
|
||||
const fileBytesBefore = dbFileBytes()
|
||||
const deleted = {
|
||||
buckets: tableCount("flow_buckets"),
|
||||
minuteStats: tableCount("flow_minute_stats"),
|
||||
minuteDims: tableCount("flow_minute_dims"),
|
||||
dailyDims: tableCount("flow_daily_dims"),
|
||||
}
|
||||
sqliteDatabase.exec(`
|
||||
DELETE FROM flow_buckets;
|
||||
DELETE FROM flow_minute_stats;
|
||||
DELETE FROM flow_minute_dims;
|
||||
DELETE FROM flow_daily_dims;
|
||||
`)
|
||||
resetFlowIngestCounters()
|
||||
try {
|
||||
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
let vacuumed = false
|
||||
try {
|
||||
sqliteDatabase.exec("VACUUM")
|
||||
vacuumed = true
|
||||
} catch {
|
||||
vacuumed = false
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
deleted,
|
||||
fileBytesBefore,
|
||||
fileBytesAfter: dbFileBytes(),
|
||||
vacuumed,
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
startTrafficFlowListener()
|
||||
} catch {
|
||||
/* ingest мог остаться выключенным */
|
||||
}
|
||||
endSqliteExclusiveOp()
|
||||
}
|
||||
}
|
||||
|
||||
export { peekPendingFlows }
|
||||
export { setPendingCapForTests } from "./traffic-flow-engine.js"
|
||||
export { maybeRefreshIfaces, setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
|
||||
@@ -0,0 +1,54 @@
|
||||
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
|
||||
|
||||
export function ipv4ToInt(ip: string): number | null {
|
||||
const parts = String(ip ?? "").trim().split(".")
|
||||
if (parts.length !== 4) return null
|
||||
let n = 0
|
||||
for (const p of parts) {
|
||||
if (!/^\d+$/.test(p)) return null
|
||||
const o = Number(p)
|
||||
if (o < 0 || o > 255) return null
|
||||
n = ((n << 8) >>> 0) + o
|
||||
}
|
||||
return n >>> 0
|
||||
}
|
||||
|
||||
export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLen: number } | null {
|
||||
const raw = String(cidr ?? "").trim()
|
||||
const [ip, lenRaw] = raw.split("/")
|
||||
const addr = ipv4ToInt(ip ?? "")
|
||||
const prefixLen = Number.parseInt(lenRaw ?? "", 10)
|
||||
if (addr == null || !Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return null
|
||||
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||
return { net: (addr & mask) >>> 0, mask, prefixLen }
|
||||
}
|
||||
|
||||
export function ipInCidrV4(ip: string, cidr: string): boolean {
|
||||
const addr = ipv4ToInt(ip)
|
||||
const parsed = parseCidrV4(cidr)
|
||||
if (addr == null || !parsed) return false
|
||||
return ((addr & parsed.mask) >>> 0) === parsed.net
|
||||
}
|
||||
|
||||
export function isNonPublicIp(ip: string): boolean {
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
if (!trimmed) return true
|
||||
if (trimmed.includes(":")) {
|
||||
const lower = trimmed.toLowerCase()
|
||||
return lower === "::1" || lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd") || lower === "::"
|
||||
}
|
||||
const n = ipv4ToInt(trimmed)
|
||||
if (n == null) return true
|
||||
const inRange = (cidr: string) => ipInCidrV4(trimmed, cidr)
|
||||
return (
|
||||
inRange("0.0.0.0/8")
|
||||
|| inRange("10.0.0.0/8")
|
||||
|| inRange("127.0.0.0/8")
|
||||
|| inRange("169.254.0.0/16")
|
||||
|| inRange("172.16.0.0/12")
|
||||
|| inRange("192.168.0.0/16")
|
||||
|| inRange("100.64.0.0/10")
|
||||
|| inRange("224.0.0.0/4")
|
||||
|| inRange("255.255.255.255/32")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bareIpv4,
|
||||
ipInCidr,
|
||||
isNatMasqueradeExporter,
|
||||
normalizeExporterIp,
|
||||
pickServerIdForExporter,
|
||||
} from "./traffic-flow-map-exporter.js"
|
||||
|
||||
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
|
||||
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
|
||||
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
|
||||
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
|
||||
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
|
||||
|
||||
const byTunnel = new Map([["10.255.254.3", 7]])
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "10.255.254.3",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
|
||||
peers: [
|
||||
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
|
||||
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
|
||||
],
|
||||
hostIps: new Map(),
|
||||
}), null)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "94.142.140.141",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map([["94.142.140.141", 7]]),
|
||||
}), 7)
|
||||
|
||||
console.log("traffic-flow-map-exporter.test.ts: ok")
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface OverlayPeerRef {
|
||||
serverId: number
|
||||
address: string
|
||||
allowedIps: string[]
|
||||
}
|
||||
|
||||
export function normalizeExporterIp(ip: string): string {
|
||||
const trimmed = ip.trim()
|
||||
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function bareIpv4(value: string): string {
|
||||
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
|
||||
return raw
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip: string): number | null {
|
||||
const parts = ip.split(".")
|
||||
if (parts.length !== 4) return null
|
||||
const n = parts.map((x) => Number(x))
|
||||
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
|
||||
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
|
||||
}
|
||||
|
||||
export function ipInCidr(ip: string, cidr: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
const [base, bitsRaw] = cidr.split("/")
|
||||
const bits = Number(bitsRaw ?? 32)
|
||||
const a = ipv4ToInt(host)
|
||||
const b = ipv4ToInt(bareIpv4(base ?? ""))
|
||||
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
||||
return (a & mask) === (b & mask)
|
||||
}
|
||||
|
||||
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
|
||||
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
if (!host) return false
|
||||
if (ipInCidr(host, overlayPrefix)) return false
|
||||
return ipInCidr(host, "10.0.0.0/8")
|
||||
|| ipInCidr(host, "172.16.0.0/12")
|
||||
|| ipInCidr(host, "192.168.0.0/16")
|
||||
|| ipInCidr(host, "127.0.0.0/8")
|
||||
}
|
||||
|
||||
export function pickServerIdForExporter(opts: {
|
||||
exporterIp: string
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Map<string, number>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Map<string, number>
|
||||
}): number | null {
|
||||
const exporter = bareIpv4(opts.exporterIp)
|
||||
if (!exporter) return null
|
||||
|
||||
const exact = opts.byTunnelIp.get(exporter)
|
||||
if (exact != null) return exact
|
||||
|
||||
for (const [ip, id] of opts.byTunnelIp) {
|
||||
if (bareIpv4(ip) === exporter) return id
|
||||
}
|
||||
|
||||
for (const peer of opts.peers) {
|
||||
if (bareIpv4(peer.address) === exporter) return peer.serverId
|
||||
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
|
||||
return peer.serverId
|
||||
}
|
||||
}
|
||||
|
||||
const byHost = opts.hostIps.get(exporter)
|
||||
if (byHost != null) return byHost
|
||||
|
||||
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
|
||||
|
||||
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
|
||||
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
|
||||
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
|
||||
if (peerIds.length === 1) return peerIds[0] ?? null
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||
import {
|
||||
ingestParsedFlowsForServerForTests,
|
||||
resetFlowRingsForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
|
||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||
import {
|
||||
disableRipeEnqueueForTests,
|
||||
disableRipePersistForTests,
|
||||
resetRipeCacheForTests,
|
||||
seedRipeCacheForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
disableCatalogFetchForTests()
|
||||
resetFlowCatalogForTests()
|
||||
disableRipePersistForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
|
||||
const topo: FlowTopology = {
|
||||
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
|
||||
clientByIface: new Map([["7|gre-client", {
|
||||
userId: "u1",
|
||||
login: "alice",
|
||||
name: "Alice",
|
||||
serverId: 7,
|
||||
interfaceName: "gre-client",
|
||||
}]]),
|
||||
enNodes: [{ id: 9, name: "NSK-EN", hosts: ["198.51.100.1"] }],
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
wanIfaces: new Map([[3, new Set(["ether1-rt"])]]),
|
||||
plane: {
|
||||
clientIfaceNames: new Set(["gre-client"]),
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
},
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
{ ".id": "*A", name: "wg-flow" },
|
||||
])
|
||||
rememberServerIfaces(3, [
|
||||
{ ".id": "*1", name: "ether1-rt" },
|
||||
])
|
||||
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "10.100.1.17",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes: 12_000,
|
||||
packets: 10,
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 5_000_000,
|
||||
packets: 4000,
|
||||
inIface: "3",
|
||||
outIface: "3",
|
||||
},
|
||||
{
|
||||
src: "10.100.1.17",
|
||||
dst: "10.100.1.18",
|
||||
proto: 6,
|
||||
srcPort: 50000,
|
||||
dstPort: 443,
|
||||
bytes: 8000,
|
||||
packets: 8,
|
||||
inIface: "2",
|
||||
outIface: "2",
|
||||
},
|
||||
{
|
||||
src: "10.255.254.1",
|
||||
dst: "10.255.254.2",
|
||||
proto: 17,
|
||||
srcPort: 4739,
|
||||
dstPort: 2055,
|
||||
bytes: 400,
|
||||
packets: 2,
|
||||
inIface: "10",
|
||||
outIface: "",
|
||||
},
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(3, [
|
||||
{
|
||||
src: "192.168.1.10",
|
||||
dst: "8.8.4.4",
|
||||
proto: 6,
|
||||
srcPort: 40000,
|
||||
dstPort: 443,
|
||||
bytes: 3000,
|
||||
packets: 4,
|
||||
inIface: "1",
|
||||
outIface: "1",
|
||||
},
|
||||
])
|
||||
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const def = buildFlowMapHops({ minutes: 5 })
|
||||
assert.equal(def.excludeOverlayApplied, true)
|
||||
assert.equal(def.excludeMeshApplied, true)
|
||||
assert.equal(def.dedupApplied, true)
|
||||
assert.equal(def.windowSec, 300)
|
||||
|
||||
const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||||
assert.ok(payloadGre, "payload JH→EN hop")
|
||||
assert.equal(payloadGre.bytes, 12_000)
|
||||
assert.equal(payloadGre.bps, (12_000 * 8) / 300)
|
||||
assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300)
|
||||
assert.equal(payloadGre.iface, "gre-jh-en")
|
||||
|
||||
const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7")
|
||||
assert.ok(greIface)
|
||||
assert.equal(greIface.bytes, 12_000)
|
||||
assert.equal(greIface.bpsFwd, (12_000 * 8) / 300)
|
||||
|
||||
assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded")
|
||||
assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded")
|
||||
const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||
assert.ok(clientIngress, "payload ingress on client iface")
|
||||
assert.equal(clientIngress.bytes, 12_000)
|
||||
|
||||
const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt")
|
||||
assert.ok(wan, "WAN hop from home-router")
|
||||
assert.equal(wan.bytes, 3000)
|
||||
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
|
||||
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
|
||||
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
|
||||
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||
assert.ok(meshIface && meshIface.bytes >= 20_000)
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: hops ok")
|
||||
|
||||
function googleRipe() {
|
||||
seedRipeCacheForTests({
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: 37.4,
|
||||
lng: -122.1,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
function payloadFlow(dst: string, bytes: number) {
|
||||
return {
|
||||
src: "10.100.1.17",
|
||||
dst,
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes,
|
||||
packets: Math.max(1, Math.round(bytes / 1200)),
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
}
|
||||
}
|
||||
|
||||
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 six = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
assert.equal(six.totalBytes, 10_000)
|
||||
const google = six.services?.find((s) => s.id === "svc:google")
|
||||
assert.ok(google, "Google ≥ 5%")
|
||||
assert.ok(google.share >= 0.05)
|
||||
assert.ok(six.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
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", 400),
|
||||
payloadFlow("203.0.113.50", 9600),
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const four = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
assert.equal(four.totalBytes, 10_000)
|
||||
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const off = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 9_000,
|
||||
packets: 90,
|
||||
inIface: "3",
|
||||
outIface: "3",
|
||||
},
|
||||
payloadFlow("203.0.113.50", 1000),
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
|
||||
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||
@@ -0,0 +1,382 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
import {
|
||||
isNamedInternetService,
|
||||
lookupBrand,
|
||||
mapServiceNodeId,
|
||||
} from "./traffic-flow-brands.js"
|
||||
import { 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 { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js"
|
||||
|
||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||
export const MAP_SERVICE_NODE_CAP = 20
|
||||
const HOPS_CACHE_TTL_MS = 2000
|
||||
|
||||
export interface FlowMapHopsQuery {
|
||||
minutes: number
|
||||
serverId?: number
|
||||
userId?: string
|
||||
iface?: string
|
||||
dedup?: boolean
|
||||
excludeMesh?: boolean
|
||||
excludeOverlay?: boolean
|
||||
/** Переопределение порога (тесты). Иначе из настроек NetFlow. */
|
||||
minSharePct?: number
|
||||
}
|
||||
|
||||
interface HopAcc {
|
||||
fromId: string
|
||||
fromLabel: string
|
||||
toId: string
|
||||
toLabel: string
|
||||
kind: FlowMapHop["kind"]
|
||||
iface?: string
|
||||
bytes: number
|
||||
bytesFwd: number
|
||||
bytesRev: number
|
||||
}
|
||||
|
||||
interface DstAcc {
|
||||
bytes: number
|
||||
proto: number
|
||||
dstPort: number
|
||||
srcPort: number
|
||||
fromBytes: Map<string, number>
|
||||
}
|
||||
|
||||
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
|
||||
|
||||
export function resetFlowMapHopsCacheForTests(): void {
|
||||
hopsCache = null
|
||||
}
|
||||
|
||||
export function clampMapServiceMinSharePct(n: unknown): number {
|
||||
const v = typeof n === "number" ? n : Number(n)
|
||||
if (!Number.isFinite(v)) return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
|
||||
return Math.min(100, Math.max(0, v))
|
||||
}
|
||||
|
||||
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||
return JSON.stringify({
|
||||
minutes: q.minutes,
|
||||
serverId: q.serverId ?? null,
|
||||
userId: q.userId ?? null,
|
||||
iface: q.iface ?? null,
|
||||
dedup: q.dedup !== false,
|
||||
excludeMesh: q.excludeMesh !== false,
|
||||
excludeOverlay: q.excludeOverlay !== false,
|
||||
minSharePct,
|
||||
})
|
||||
}
|
||||
|
||||
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
|
||||
if (!userId) return null
|
||||
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||
const allow = new Map<number, Set<string>>()
|
||||
for (const b of binds) {
|
||||
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||
set.add(b.interfaceName)
|
||||
allow.set(b.serverId, set)
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
function ifaceUsable(name: string): boolean {
|
||||
return Boolean(name) && name !== "—"
|
||||
}
|
||||
|
||||
function bump(acc: Map<string, HopAcc>, key: string, seed: Omit<HopAcc, "bytes" | "bytesFwd" | "bytesRev">, bytes: number, dir: "fwd" | "rev" | "both"): void {
|
||||
const prev = acc.get(key)
|
||||
const addFwd = dir === "fwd" || dir === "both" ? bytes : 0
|
||||
const addRev = dir === "rev" || dir === "both" ? bytes : 0
|
||||
if (prev) {
|
||||
prev.bytes += bytes
|
||||
prev.bytesFwd += addFwd
|
||||
prev.bytesRev += addRev
|
||||
if (seed.iface && !prev.iface) prev.iface = seed.iface
|
||||
return
|
||||
}
|
||||
acc.set(key, {
|
||||
...seed,
|
||||
bytes,
|
||||
bytesFwd: addFwd,
|
||||
bytesRev: addRev,
|
||||
})
|
||||
}
|
||||
|
||||
function toHop(a: HopAcc, windowSec: number): FlowMapHop {
|
||||
return {
|
||||
fromId: a.fromId,
|
||||
fromLabel: a.fromLabel,
|
||||
toId: a.toId,
|
||||
toLabel: a.toLabel,
|
||||
kind: a.kind,
|
||||
...(a.iface ? { iface: a.iface } : {}),
|
||||
bytes: a.bytes,
|
||||
bps: (a.bytes * 8) / windowSec,
|
||||
bpsFwd: (a.bytesFwd * 8) / windowSec,
|
||||
bpsRev: (a.bytesRev * 8) / windowSec,
|
||||
}
|
||||
}
|
||||
|
||||
/** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
|
||||
function classifyMapDstLite(
|
||||
dst: string,
|
||||
proto: number,
|
||||
dstPort: number,
|
||||
srcPort: number,
|
||||
ripe: FlowIpMeta | null,
|
||||
): { service: string; category: string } | 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 (/youtube/i.test(ripe?.holder ?? "")) {
|
||||
return { service: "YouTube", category: "Видео / стриминг" }
|
||||
}
|
||||
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
||||
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||
return brand
|
||||
}
|
||||
|
||||
function resolveMinSharePct(q: FlowMapHopsQuery): number {
|
||||
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
|
||||
try {
|
||||
const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
|
||||
return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
|
||||
} catch {
|
||||
return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
|
||||
}
|
||||
}
|
||||
|
||||
function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
|
||||
const windowSec = Math.max(60, q.minutes * 60)
|
||||
const raw = listFlowRowsForWindow(q.minutes)
|
||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||
const excludeMesh = q.excludeMesh !== false
|
||||
const excludeOverlay = q.excludeOverlay !== false
|
||||
const topo = loadFlowTopology()
|
||||
|
||||
const matched = []
|
||||
for (const r of raw) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||
const plane = classifyFlowPlane({
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
inIface: resolved.name,
|
||||
outIface: outResolved.name,
|
||||
}, topo.plane)
|
||||
if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
|
||||
matched.push(r)
|
||||
}
|
||||
|
||||
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||
const hops = new Map<string, HopAcc>()
|
||||
const dstAcc = new Map<string, DstAcc>()
|
||||
let totalBytes = 0
|
||||
|
||||
for (const r of working) {
|
||||
const inRes = resolveIfaceName(r.serverId, r.inIface)
|
||||
const outRes = resolveIfaceName(r.serverId, r.outIface)
|
||||
const inName = inRes.name
|
||||
const outName = outRes.name
|
||||
const fromId = String(r.serverId)
|
||||
const fromLabel = nameById.get(r.serverId) ?? fromId
|
||||
const wanSet = topo.wanIfaces.get(r.serverId)
|
||||
|
||||
const inOk = ifaceUsable(inName)
|
||||
const outOk = ifaceUsable(outName)
|
||||
const sameIface = inOk && outOk && inName.toLowerCase() === outName.toLowerCase()
|
||||
if (sameIface) {
|
||||
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId: "",
|
||||
toLabel: "",
|
||||
kind: "iface",
|
||||
iface: inName,
|
||||
}, r.bytes, "fwd")
|
||||
} else {
|
||||
if (inOk) {
|
||||
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId: "",
|
||||
toLabel: "",
|
||||
kind: "iface",
|
||||
iface: inName,
|
||||
}, r.bytes, "rev")
|
||||
}
|
||||
if (outOk) {
|
||||
bump(hops, `iface|${fromId}|${outName.toLowerCase()}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId: "",
|
||||
toLabel: "",
|
||||
kind: "iface",
|
||||
iface: outName,
|
||||
}, r.bytes, "fwd")
|
||||
}
|
||||
}
|
||||
|
||||
const enOut = ifaceUsable(outName) ? resolveEn(topo, r.nextHop, outName) : null
|
||||
const enIn = ifaceUsable(inName) ? resolveEn(topo, "", inName) : null
|
||||
const en = (enOut && enOut.id !== r.serverId ? enOut : null)
|
||||
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
||||
if (en) {
|
||||
const toId = String(en.id)
|
||||
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
||||
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
|
||||
bump(hops, `gre|${fromId}|${toId}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId,
|
||||
toLabel: en.name,
|
||||
kind: "gre",
|
||||
iface: greIface,
|
||||
}, r.bytes, dir)
|
||||
}
|
||||
|
||||
if (wanSet?.size) {
|
||||
if (ifaceUsable(inName) && wanSet.has(inName)) {
|
||||
bump(hops, `wan|${fromId}|${inName.toLowerCase()}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId: "",
|
||||
toLabel: "",
|
||||
kind: "wan",
|
||||
iface: inName,
|
||||
}, r.bytes, "rev")
|
||||
}
|
||||
if (ifaceUsable(outName) && wanSet.has(outName) && outName.toLowerCase() !== inName.toLowerCase()) {
|
||||
bump(hops, `wan|${fromId}|${outName.toLowerCase()}`, {
|
||||
fromId,
|
||||
fromLabel,
|
||||
toId: "",
|
||||
toLabel: "",
|
||||
kind: "wan",
|
||||
iface: outName,
|
||||
}, r.bytes, "fwd")
|
||||
}
|
||||
}
|
||||
|
||||
totalBytes += r.bytes
|
||||
const svcFromId = String((enOut ?? enIn)?.id ?? r.serverId)
|
||||
const prevDst = dstAcc.get(r.dst)
|
||||
if (prevDst) {
|
||||
prevDst.bytes += r.bytes
|
||||
prevDst.fromBytes.set(svcFromId, (prevDst.fromBytes.get(svcFromId) ?? 0) + r.bytes)
|
||||
} else {
|
||||
dstAcc.set(r.dst, {
|
||||
bytes: r.bytes,
|
||||
proto: r.proto,
|
||||
dstPort: r.dstPort,
|
||||
srcPort: r.srcPort,
|
||||
fromBytes: new Map([[svcFromId, r.bytes]]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 }>()
|
||||
|
||||
for (const [dst, acc] of dstAcc) {
|
||||
const ripe = lookupRipeCached(dst)
|
||||
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
|
||||
if (!classified) continue
|
||||
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 })
|
||||
for (const [fromId, bytes] of acc.fromBytes) {
|
||||
const edgeKey = `${fromId}|${toId}`
|
||||
const prevEdge = svcEdges.get(edgeKey)
|
||||
if (prevEdge) {
|
||||
prevEdge.bytes += bytes
|
||||
prevEdge.bytesFwd += bytes
|
||||
} else {
|
||||
svcEdges.set(edgeKey, {
|
||||
fromId,
|
||||
toId,
|
||||
bytes,
|
||||
bytesFwd: bytes,
|
||||
bytesRev: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minShare = minSharePct / 100
|
||||
let services: FlowMapService[] = [...svcTotals.entries()]
|
||||
.map(([id, s]) => ({
|
||||
id,
|
||||
label: s.label,
|
||||
category: s.category,
|
||||
bytes: s.bytes,
|
||||
bps: (s.bytes * 8) / windowSec,
|
||||
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
if (minSharePct > 0) {
|
||||
services = services.filter((s) => s.share >= minShare)
|
||||
}
|
||||
services = services.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
const keepSvc = new Set(services.map((s) => s.id))
|
||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||
.filter((e) => keepSvc.has(e.toId))
|
||||
.map((e) => ({
|
||||
fromId: e.fromId,
|
||||
toId: e.toId,
|
||||
bytes: e.bytes,
|
||||
bps: (e.bytes * 8) / windowSec,
|
||||
bpsFwd: (e.bytesFwd * 8) / windowSec,
|
||||
bpsRev: (e.bytesRev * 8) / windowSec,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
|
||||
const listener = getFlowListenerState()
|
||||
return {
|
||||
hops: [...hops.values()]
|
||||
.map((a) => toHop(a, windowSec))
|
||||
.sort((a, b) => a.bytes === b.bytes ? 0 : b.bytes - a.bytes),
|
||||
live: listener.bound,
|
||||
rangeMinutes: q.minutes,
|
||||
windowSec,
|
||||
totalBytes,
|
||||
services,
|
||||
serviceEdges,
|
||||
mapServiceMinSharePct: minSharePct,
|
||||
dedupApplied: wantDedup,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
excludeOverlayApplied: excludeOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
|
||||
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||
const minSharePct = resolveMinSharePct(q)
|
||||
const key = hopsQueryKey(q, minSharePct)
|
||||
const now = Date.now()
|
||||
if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
|
||||
return hopsCache.dto
|
||||
}
|
||||
const dto = buildFlowMapHopsUncached(q, minSharePct)
|
||||
hopsCache = { key, at: now, dto }
|
||||
return dto
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
||||
import {
|
||||
asRosArray,
|
||||
patchRosPath,
|
||||
putIpAddress,
|
||||
putWireguardInterface,
|
||||
putWireguardPeer,
|
||||
rosRowId,
|
||||
toRosBody,
|
||||
} from "./wireguard-ros.js"
|
||||
import {
|
||||
enableTrafficFlowIngest,
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
upsertHostPeer,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||
|
||||
const IFACE_NAME = "wg-flow"
|
||||
const JH_LISTEN_PORT = 13232
|
||||
const WG_INPUT_COMMENT = "mm-wg-flow"
|
||||
|
||||
export function allocateOverlayAddress(prefix: string, collectorIp: string, serverId: number, taken: Set<string>): string {
|
||||
const [base] = prefix.split("/")
|
||||
const parts = (base ?? "10.255.254.0").split(".").map((n) => Number.parseInt(n, 10))
|
||||
const a = parts[0] || 10
|
||||
const b = parts[1] || 255
|
||||
const c = parts[2] || 254
|
||||
const preferredLast = 2 + ((serverId - 1) % 250)
|
||||
const candidates = [preferredLast, ...Array.from({ length: 253 }, (_, i) => 2 + ((preferredLast - 2 + i) % 253))]
|
||||
for (const last of candidates) {
|
||||
const ip = `${a}.${b}.${c}.${last}`
|
||||
if (ip === collectorIp) continue
|
||||
if (taken.has(ip)) continue
|
||||
return ip
|
||||
}
|
||||
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
||||
}
|
||||
|
||||
function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string {
|
||||
return [
|
||||
`[Peer]`,
|
||||
`PublicKey = ${publicKey}`,
|
||||
`AllowedIPs = ${address}/32`,
|
||||
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
|
||||
`PersistentKeepalive = 25`,
|
||||
comment ? `# ${comment}` : "",
|
||||
].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
async function findIface(client: MikrotikClient, name: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard"))
|
||||
return list.find((i) => String(i.name ?? "") === name)
|
||||
}
|
||||
|
||||
async function findPeer(
|
||||
client: MikrotikClient,
|
||||
iface: string,
|
||||
publicKey: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard/peers"))
|
||||
return list.find((p) =>
|
||||
String(p.interface ?? "") === iface && String(p["public-key"] ?? "") === publicKey,
|
||||
)
|
||||
}
|
||||
|
||||
async function findAddress(client: MikrotikClient, iface: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/address"))
|
||||
return list.find((a) => String(a.interface ?? "") === iface)
|
||||
}
|
||||
|
||||
async function findRoute(client: MikrotikClient, dst: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/route"))
|
||||
return list.find((r) => String(r["dst-address"] ?? "") === dst)
|
||||
}
|
||||
|
||||
async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): Promise<boolean> {
|
||||
const rules = asRosArray<Record<string, unknown>>(await client.get("/ip/firewall/filter"))
|
||||
const existing = rules.find((r) => String(r.comment ?? "") === WG_INPUT_COMMENT)
|
||||
if (existing) return false
|
||||
await client.put("/ip/firewall/filter", toRosBody({
|
||||
chain: "input",
|
||||
protocol: "udp",
|
||||
"dst-port": String(listenPort),
|
||||
action: "accept",
|
||||
comment: WG_INPUT_COMMENT,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
||||
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
||||
|
||||
async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
|
||||
const body = toRosBody({
|
||||
bytes: "yes",
|
||||
packets: "yes",
|
||||
"src-address": "yes",
|
||||
"dst-address": "yes",
|
||||
protocol: "yes",
|
||||
"src-port": "yes",
|
||||
"dst-port": "yes",
|
||||
"in-interface": "yes",
|
||||
"out-interface": "yes",
|
||||
gateway: "yes",
|
||||
"first-forwarded": "yes",
|
||||
"last-forwarded": "yes",
|
||||
"nat-src-address": "yes",
|
||||
"nat-dst-address": "yes",
|
||||
})
|
||||
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/ipfix"))
|
||||
const id = rows[0] ? rosRowId(rows[0]) : ""
|
||||
if (id) {
|
||||
await patchRosPath(client, `/ip/traffic-flow/ipfix/${encodeRosId(id)}`, body)
|
||||
return
|
||||
}
|
||||
await client.post("/ip/traffic-flow/ipfix/set", body)
|
||||
}
|
||||
|
||||
async function ensureTrafficFlow(
|
||||
client: MikrotikClient,
|
||||
collectorIp: string,
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
const body = toRosBody({
|
||||
enabled: "yes",
|
||||
interfaces: "all",
|
||||
"active-flow-timeout": "1m",
|
||||
"inactive-flow-timeout": "15s",
|
||||
})
|
||||
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow"))
|
||||
const id = rows[0] ? rosRowId(rows[0]) : ""
|
||||
if (id) {
|
||||
await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body)
|
||||
} else {
|
||||
await client.post("/ip/traffic-flow/set", body)
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureIpfixFields(client)
|
||||
} catch {
|
||||
/* поля IPFIX опциональны на старых ROS */
|
||||
}
|
||||
|
||||
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||
const targetBody = toRosBody({
|
||||
"dst-address": collectorIp,
|
||||
"src-address": FLOW_TARGET_SRC_AUTO,
|
||||
port: String(port),
|
||||
version: "ipfix",
|
||||
})
|
||||
if (existing) {
|
||||
const targetId = rosRowId(existing)
|
||||
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
|
||||
return
|
||||
}
|
||||
await client.put("/ip/traffic-flow/target", targetBody)
|
||||
}
|
||||
|
||||
export function usablePublicHost(raw: string | undefined): string {
|
||||
if (!raw) return ""
|
||||
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
|
||||
const lower = host.toLowerCase()
|
||||
if (!host) return ""
|
||||
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
|
||||
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
|
||||
return host
|
||||
}
|
||||
|
||||
export async function applyFlowOverlay(
|
||||
serverIdRaw: string | number,
|
||||
opts?: { publicEndpoint?: string; requestHost?: string },
|
||||
): Promise<TrafficFlowOverlayResult> {
|
||||
const steps: string[] = []
|
||||
const keys = ensureHostKeys()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||
if (!hostPublicKey) {
|
||||
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
||||
}
|
||||
|
||||
const server = getEnabledServerById(String(serverIdRaw))
|
||||
if (!server || !server.enabled) {
|
||||
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||
}
|
||||
|
||||
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
|
||||
if (!endpointHost) {
|
||||
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
|
||||
}
|
||||
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
|
||||
|
||||
const taken = new Set(
|
||||
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||
.map((r) => r.ip)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
|
||||
try {
|
||||
let iface = await findIface(client, IFACE_NAME)
|
||||
if (!iface) {
|
||||
await putWireguardInterface(client, {
|
||||
name: IFACE_NAME,
|
||||
"listen-port": String(JH_LISTEN_PORT),
|
||||
mtu: "1420",
|
||||
comment: "MikrotikManager traffic-flow overlay",
|
||||
})
|
||||
steps.push(`Создан интерфейс ${IFACE_NAME}`)
|
||||
iface = await findIface(client, IFACE_NAME)
|
||||
} else {
|
||||
steps.push(`Интерфейс ${IFACE_NAME} уже есть`)
|
||||
}
|
||||
|
||||
const addrRow = await findAddress(client, IFACE_NAME)
|
||||
const mask = (settings.prefix.split("/")[1] || "24").replace(/\D/g, "") || "24"
|
||||
const cidr = `${address}/${mask}`
|
||||
if (!addrRow) {
|
||||
await putIpAddress(client, cidr, IFACE_NAME)
|
||||
steps.push(`Адрес ${cidr}`)
|
||||
} else {
|
||||
steps.push(`Адрес на ${IFACE_NAME} уже назначен`)
|
||||
}
|
||||
|
||||
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
||||
const peerBody = {
|
||||
interface: IFACE_NAME,
|
||||
"public-key": hostPublicKey,
|
||||
"allowed-address": `${settings.collectorIp}/32`,
|
||||
comment: "MM traffic-flow collector",
|
||||
name: "mm-collector",
|
||||
}
|
||||
if (!peer) {
|
||||
await putWireguardPeer(client, peerBody)
|
||||
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
|
||||
} else {
|
||||
const id = rosRowId(peer)
|
||||
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
|
||||
if (hadEndpoint && id) {
|
||||
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`)
|
||||
await putWireguardPeer(client, peerBody)
|
||||
steps.push("Пир пересоздан как сервер (endpoint снят)")
|
||||
} else if (id) {
|
||||
await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
|
||||
steps.push("Пир хоста MM обновлён")
|
||||
}
|
||||
}
|
||||
|
||||
const routeDst = `${settings.collectorIp}/32`
|
||||
const route = await findRoute(client, routeDst)
|
||||
if (!route) {
|
||||
await client.put("/ip/route", toRosBody({
|
||||
"dst-address": routeDst,
|
||||
gateway: IFACE_NAME,
|
||||
comment: "MM traffic-flow collector",
|
||||
}))
|
||||
steps.push(`Маршрут ${routeDst} через ${IFACE_NAME}`)
|
||||
} else {
|
||||
steps.push("Маршрут до collector уже есть")
|
||||
}
|
||||
|
||||
if (await ensureWgInputAccept(client, JH_LISTEN_PORT)) {
|
||||
steps.push(`Firewall input accept UDP ${JH_LISTEN_PORT}`)
|
||||
} else {
|
||||
steps.push("Firewall input WG уже есть")
|
||||
}
|
||||
|
||||
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
|
||||
|
||||
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||
const publicKey = created?.publicKey ?? ""
|
||||
if (!publicKey) {
|
||||
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
|
||||
}
|
||||
|
||||
db.update(servers).set({
|
||||
mgmtTunnelIp: address,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(servers.id, server.id)).run()
|
||||
|
||||
upsertHostPeer({
|
||||
serverId: server.id,
|
||||
name: server.name || server.host,
|
||||
publicKey,
|
||||
allowedIps: [`${address}/32`],
|
||||
address,
|
||||
endpoint: peerEndpoint,
|
||||
})
|
||||
|
||||
enableTrafficFlowIngest()
|
||||
startTrafficFlowListener()
|
||||
refreshFlowExporterMap()
|
||||
steps.push("Коллектор IPFIX на MM включён")
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
serverId: server.id,
|
||||
interfaceName: IFACE_NAME,
|
||||
address,
|
||||
publicKey,
|
||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
||||
trafficFlow: true,
|
||||
steps,
|
||||
hostFiles: listTrafficFlowHostFiles(),
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { parseFlowPacket, protoName, resetFlowTemplatesForTests, templateExporterCountForTests } from "./traffic-flow-parse.js"
|
||||
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
|
||||
|
||||
function netflowV5One(): Buffer {
|
||||
const buf = Buffer.alloc(24 + 48)
|
||||
buf.writeUInt16BE(5, 0)
|
||||
buf.writeUInt16BE(1, 2)
|
||||
buf[24] = 10; buf[25] = 1; buf[26] = 1; buf[27] = 8
|
||||
buf[28] = 8; buf[29] = 8; buf[30] = 8; buf[31] = 8
|
||||
buf.writeUInt16BE(1, 24 + 12)
|
||||
buf.writeUInt32BE(10, 24 + 16)
|
||||
buf.writeUInt32BE(1500, 24 + 20)
|
||||
buf.writeUInt16BE(443, 24 + 32)
|
||||
buf.writeUInt16BE(443, 24 + 34)
|
||||
buf.writeUInt8(6, 24 + 38)
|
||||
return buf
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
const flows = parseFlowPacket(netflowV5One(), "10.255.254.5")
|
||||
assert.equal(flows.length, 1)
|
||||
assert.equal(flows[0]?.src, "10.1.1.8")
|
||||
assert.equal(flows[0]?.dst, "8.8.8.8")
|
||||
assert.equal(flows[0]?.proto, 6)
|
||||
assert.equal(flows[0]?.bytes, 1500)
|
||||
assert.equal(flows[0]?.inIface, "1")
|
||||
assert.equal(protoName(6), "TCP")
|
||||
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
||||
|
||||
const taken = new Set(["10.255.254.2"])
|
||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||
|
||||
assert.equal(usablePublicHost("localhost:8000"), "")
|
||||
assert.equal(usablePublicHost("127.0.0.1"), "")
|
||||
assert.equal(usablePublicHost("192.168.1.10"), "")
|
||||
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
||||
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
|
||||
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const tpl = Buffer.alloc(16 + 16 + 20)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(16, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(2, 22)
|
||||
tpl.writeUInt16BE(8, 24)
|
||||
tpl.writeUInt16BE(4, 26)
|
||||
tpl.writeUInt16BE(12, 28)
|
||||
tpl.writeUInt16BE(4, 30)
|
||||
const data = Buffer.alloc(16 + 12)
|
||||
data.writeUInt16BE(10, 0)
|
||||
data.writeUInt16BE(data.length, 2)
|
||||
data.writeUInt16BE(256, 16)
|
||||
data.writeUInt16BE(12, 18)
|
||||
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||
const fromTpl = parseFlowPacket(tpl, "172.18.0.2")
|
||||
assert.equal(fromTpl.length, 0)
|
||||
const fromData = parseFlowPacket(data, "172.18.0.2")
|
||||
assert.equal(fromData.length, 1)
|
||||
assert.equal(fromData[0]?.src, "10.1.1.8")
|
||||
assert.equal(fromData[0]?.dst, "8.8.8.8")
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const tpl = Buffer.alloc(16 + 24)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(24, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(4, 22)
|
||||
tpl.writeUInt16BE(8, 24)
|
||||
tpl.writeUInt16BE(4, 26)
|
||||
tpl.writeUInt16BE(12, 28)
|
||||
tpl.writeUInt16BE(4, 30)
|
||||
tpl.writeUInt16BE(10, 32)
|
||||
tpl.writeUInt16BE(4, 34)
|
||||
tpl.writeUInt16BE(82, 36)
|
||||
tpl.writeUInt16BE(6, 38)
|
||||
const data = Buffer.alloc(16 + 22)
|
||||
data.writeUInt16BE(10, 0)
|
||||
data.writeUInt16BE(data.length, 2)
|
||||
data.writeUInt16BE(256, 16)
|
||||
data.writeUInt16BE(22, 18)
|
||||
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||
data.writeUInt32BE(13, 28)
|
||||
data.write("ether1", 32)
|
||||
parseFlowPacket(tpl, "10.255.254.3")
|
||||
const named = parseFlowPacket(data, "10.255.254.3")
|
||||
assert.equal(named.length, 1)
|
||||
assert.equal(named[0]?.inIface, "13")
|
||||
assert.equal(named[0]?.src, "10.1.1.8")
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const tpl = Buffer.alloc(16 + 20)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(20, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(3, 22)
|
||||
tpl.writeUInt16BE(8, 24)
|
||||
tpl.writeUInt16BE(4, 26)
|
||||
tpl.writeUInt16BE(12, 28)
|
||||
tpl.writeUInt16BE(4, 30)
|
||||
tpl.writeUInt16BE(82, 32)
|
||||
tpl.writeUInt16BE(6, 34)
|
||||
const data = Buffer.alloc(16 + 18)
|
||||
data.writeUInt16BE(10, 0)
|
||||
data.writeUInt16BE(data.length, 2)
|
||||
data.writeUInt16BE(256, 16)
|
||||
data.writeUInt16BE(18, 18)
|
||||
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||
data.write("ether1", 28)
|
||||
parseFlowPacket(tpl, "10.255.254.4")
|
||||
const namedOnly = parseFlowPacket(data, "10.255.254.4")
|
||||
assert.equal(namedOnly[0]?.inIface, "ether1")
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const fieldSpecs: Array<[number, number]> = [
|
||||
[8, 4],
|
||||
[12, 4],
|
||||
[10, 4],
|
||||
[14, 4],
|
||||
[15, 4],
|
||||
[152, 8],
|
||||
[153, 8],
|
||||
[1, 4],
|
||||
]
|
||||
const tplSetLen = 4 + 4 + fieldSpecs.length * 4
|
||||
const tpl = Buffer.alloc(16 + tplSetLen)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(tplSetLen, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(fieldSpecs.length, 22)
|
||||
let off = 24
|
||||
for (const [type, len] of fieldSpecs) {
|
||||
tpl.writeUInt16BE(type, off)
|
||||
tpl.writeUInt16BE(len, off + 2)
|
||||
off += 4
|
||||
}
|
||||
const recLen = fieldSpecs.reduce((n, [, len]) => n + len, 0)
|
||||
const data = Buffer.alloc(16 + 4 + recLen)
|
||||
data.writeUInt16BE(10, 0)
|
||||
data.writeUInt16BE(data.length, 2)
|
||||
data.writeUInt16BE(256, 16)
|
||||
data.writeUInt16BE(4 + recLen, 18)
|
||||
let d = 20
|
||||
data[d] = 10; data[d + 1] = 100; data[d + 2] = 1; data[d + 3] = 17; d += 4
|
||||
data[d] = 173; data[d + 1] = 194; data[d + 2] = 160; data[d + 3] = 163; d += 4
|
||||
data.writeUInt32BE(13, d); d += 4
|
||||
data.writeUInt32BE(42, d); d += 4
|
||||
data[d] = 198; data[d + 1] = 51; data[d + 2] = 100; data[d + 3] = 1; d += 4
|
||||
data.writeBigUInt64BE(1_700_000_000_000n, d); d += 8
|
||||
data.writeBigUInt64BE(1_700_000_060_000n, d); d += 8
|
||||
data.writeUInt32BE(1500, d)
|
||||
parseFlowPacket(tpl, "10.255.254.5")
|
||||
const extra = parseFlowPacket(data, "10.255.254.5")
|
||||
assert.equal(extra.length, 1)
|
||||
assert.equal(extra[0]?.src, "10.100.1.17")
|
||||
assert.equal(extra[0]?.dst, "173.194.160.163")
|
||||
assert.equal(extra[0]?.inIface, "13")
|
||||
assert.equal(extra[0]?.outIface, "42")
|
||||
assert.equal(extra[0]?.nextHop, "198.51.100.1")
|
||||
assert.equal(extra[0]?.flowStartMs, 1_700_000_000_000)
|
||||
assert.equal(extra[0]?.flowEndMs, 1_700_000_060_000)
|
||||
assert.equal(extra[0]?.bytes, 1500)
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
{
|
||||
const tpl = Buffer.alloc(16 + 16 + 20)
|
||||
tpl.writeUInt16BE(10, 0)
|
||||
tpl.writeUInt16BE(tpl.length, 2)
|
||||
tpl.writeUInt16BE(2, 16)
|
||||
tpl.writeUInt16BE(16, 18)
|
||||
tpl.writeUInt16BE(256, 20)
|
||||
tpl.writeUInt16BE(2, 22)
|
||||
tpl.writeUInt16BE(8, 24)
|
||||
tpl.writeUInt16BE(4, 26)
|
||||
tpl.writeUInt16BE(12, 28)
|
||||
tpl.writeUInt16BE(4, 30)
|
||||
for (let i = 0; i < 260; i++) {
|
||||
parseFlowPacket(tpl, `203.0.${Math.floor(i / 250)}.${i % 250}`)
|
||||
}
|
||||
assert.ok(templateExporterCountForTests() <= 256)
|
||||
}
|
||||
|
||||
console.log("traffic-flow-parse.test.ts: ok")
|
||||
@@ -0,0 +1,418 @@
|
||||
export interface ParsedFlow {
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
bytes: number
|
||||
packets: number
|
||||
inIface: string
|
||||
outIface: string
|
||||
nextHop: string
|
||||
flowStartMs: number
|
||||
flowEndMs: number
|
||||
natSrc: string
|
||||
natDst: string
|
||||
}
|
||||
|
||||
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
|
||||
|
||||
export function emptyParsedFlow(): ParsedFlow {
|
||||
return {
|
||||
src: "",
|
||||
dst: "",
|
||||
proto: 0,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 0,
|
||||
packets: 0,
|
||||
inIface: "",
|
||||
outIface: "",
|
||||
nextHop: "",
|
||||
flowStartMs: 0,
|
||||
flowEndMs: 0,
|
||||
natSrc: "",
|
||||
natDst: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
|
||||
return {
|
||||
...emptyParsedFlow(),
|
||||
...flow,
|
||||
nextHop: flow.nextHop ?? "",
|
||||
flowStartMs: flow.flowStartMs ?? 0,
|
||||
flowEndMs: flow.flowEndMs ?? 0,
|
||||
natSrc: flow.natSrc ?? "",
|
||||
natDst: flow.natDst ?? "",
|
||||
inIface: flow.inIface ?? "",
|
||||
outIface: flow.outIface ?? "",
|
||||
srcPort: flow.srcPort ?? 0,
|
||||
dstPort: flow.dstPort ?? 0,
|
||||
packets: flow.packets ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldSpec {
|
||||
type: number
|
||||
length: number
|
||||
}
|
||||
|
||||
interface Template {
|
||||
fields: FieldSpec[]
|
||||
}
|
||||
|
||||
const MAX_TEMPLATE_EXPORTERS = 256
|
||||
const templatesByExporter = new Map<string, Map<number, Template>>()
|
||||
|
||||
function templatesForExporter(exporter: string): Map<number, Template> {
|
||||
const existing = templatesByExporter.get(exporter)
|
||||
if (existing) {
|
||||
templatesByExporter.delete(exporter)
|
||||
templatesByExporter.set(exporter, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Map<number, Template>()
|
||||
templatesByExporter.set(exporter, created)
|
||||
while (templatesByExporter.size > MAX_TEMPLATE_EXPORTERS) {
|
||||
const oldest = templatesByExporter.keys().next().value
|
||||
if (oldest == null || oldest === exporter) break
|
||||
templatesByExporter.delete(oldest)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
function ipv4(buf: Buffer, offset: number): string {
|
||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||
}
|
||||
|
||||
function ipv6(buf: Buffer, offset: number): string {
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
|
||||
return parts.join(":")
|
||||
}
|
||||
|
||||
const VAR_LEN = 0xffff
|
||||
|
||||
function consumeField(
|
||||
buf: Buffer,
|
||||
off: number,
|
||||
length: number,
|
||||
limit: number,
|
||||
): { data: Buffer; next: number } | null {
|
||||
if (length === VAR_LEN) {
|
||||
if (off >= limit) return null
|
||||
const first = buf[off]!
|
||||
if (first < 255) {
|
||||
const end = off + 1 + first
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 1, end), next: end }
|
||||
}
|
||||
if (off + 3 > limit) return null
|
||||
const len = buf.readUInt16BE(off + 1)
|
||||
const end = off + 3 + len
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 3, end), next: end }
|
||||
}
|
||||
const end = off + length
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off, end), next: end }
|
||||
}
|
||||
|
||||
function fixedRecordSize(fields: FieldSpec[]): number | null {
|
||||
let n = 0
|
||||
for (const f of fields) {
|
||||
if (f.length === VAR_LEN) return null
|
||||
n += f.length
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) return buf.readUInt8(offset)
|
||||
if (length === 2) return buf.readUInt16BE(offset)
|
||||
if (length === 4) return buf.readUInt32BE(offset)
|
||||
if (length === 8) {
|
||||
const big = buf.readBigUInt64BE(offset)
|
||||
const n = Number(big)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
let v = 0
|
||||
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
|
||||
return v >>> 0
|
||||
}
|
||||
|
||||
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
||||
if (buf.length < 24) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
const out: ParsedFlow[] = []
|
||||
let off = 24
|
||||
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||
out.push(normalizeParsedFlow({
|
||||
src: ipv4(buf, off),
|
||||
dst: ipv4(buf, off + 4),
|
||||
packets: buf.readUInt32BE(off + 16),
|
||||
bytes: buf.readUInt32BE(off + 20),
|
||||
srcPort: buf.readUInt16BE(off + 32),
|
||||
dstPort: buf.readUInt16BE(off + 34),
|
||||
proto: buf.readUInt8(off + 38),
|
||||
inIface: String(buf.readUInt16BE(off + 12)),
|
||||
outIface: String(buf.readUInt16BE(off + 14)),
|
||||
}))
|
||||
off += 48
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||
let off = setStart + 4
|
||||
const map = templatesForExporter(exporter)
|
||||
while (off + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(off)
|
||||
const fieldCount = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (setId === 3) {
|
||||
// options template: skip scope count
|
||||
if (off + 2 > setEnd) break
|
||||
off += 2
|
||||
}
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
|
||||
const type = buf.readUInt16BE(off)
|
||||
const length = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (type & 0x8000) {
|
||||
if (off + 4 > setEnd) break
|
||||
off += 4
|
||||
}
|
||||
fields.push({ type: type & 0x7fff, length })
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
}
|
||||
|
||||
function recordFromFields(
|
||||
fields: FieldSpec[],
|
||||
buf: Buffer,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): { flow: ParsedFlow; next: number } | null {
|
||||
let off = offset
|
||||
let src = ""
|
||||
let dst = ""
|
||||
let proto = 0
|
||||
let srcPort = 0
|
||||
let dstPort = 0
|
||||
let bytes = 0
|
||||
let packets = 0
|
||||
let inIface = ""
|
||||
let outIface = ""
|
||||
let ifaceName = ""
|
||||
let nextHop = ""
|
||||
let flowStartMs = 0
|
||||
let flowEndMs = 0
|
||||
let natSrc = ""
|
||||
let natDst = ""
|
||||
for (const f of fields) {
|
||||
const field = consumeField(buf, off, f.length, limit)
|
||||
if (!field) return null
|
||||
const { data } = field
|
||||
switch (f.type) {
|
||||
case 8:
|
||||
if (data.length === 4) src = ipv4(data, 0)
|
||||
break
|
||||
case 12:
|
||||
if (data.length === 4) dst = ipv4(data, 0)
|
||||
break
|
||||
case 27:
|
||||
if (data.length === 16 && !src) src = ipv6(data, 0)
|
||||
break
|
||||
case 28:
|
||||
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
||||
break
|
||||
case 15:
|
||||
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||
break
|
||||
case 18:
|
||||
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||
break
|
||||
case 62:
|
||||
if (data.length === 16 && !nextHop) nextHop = ipv6(data, 0)
|
||||
break
|
||||
case 225:
|
||||
if (data.length === 4) {
|
||||
natSrc = ipv4(data, 0)
|
||||
if (!src) src = natSrc
|
||||
}
|
||||
break
|
||||
case 226:
|
||||
if (data.length === 4) {
|
||||
natDst = ipv4(data, 0)
|
||||
if (!dst) dst = natDst
|
||||
}
|
||||
break
|
||||
case 4:
|
||||
proto = readUint(data, 0, data.length)
|
||||
break
|
||||
case 7:
|
||||
srcPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 11:
|
||||
dstPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 1:
|
||||
bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 2:
|
||||
packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 85:
|
||||
if (!bytes) bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 86:
|
||||
if (!packets) packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 10:
|
||||
inIface = String(readUint(data, 0, data.length))
|
||||
break
|
||||
case 14:
|
||||
outIface = String(readUint(data, 0, data.length))
|
||||
break
|
||||
case 21:
|
||||
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 22:
|
||||
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 150:
|
||||
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length) * 1000
|
||||
break
|
||||
case 151:
|
||||
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length) * 1000
|
||||
break
|
||||
case 152:
|
||||
flowStartMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 153:
|
||||
flowEndMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 82:
|
||||
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
off = field.next
|
||||
}
|
||||
if (ifaceName && !inIface) inIface = ifaceName
|
||||
return {
|
||||
flow: normalizeParsedFlow({
|
||||
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst,
|
||||
}),
|
||||
next: off,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataRecords(
|
||||
tpl: Template,
|
||||
buf: Buffer,
|
||||
recOff: number,
|
||||
setEnd: number,
|
||||
out: ParsedFlow[],
|
||||
) {
|
||||
const size = fixedRecordSize(tpl.fields)
|
||||
while (recOff + 1 < setEnd) {
|
||||
if (size != null && recOff + size > setEnd) break
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
|
||||
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 16) return []
|
||||
const total = buf.readUInt16BE(2)
|
||||
const end = Math.min(buf.length, total)
|
||||
let off = 16
|
||||
const out: ParsedFlow[] = []
|
||||
while (off + 4 <= end) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > end) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 2 || setId === 3) {
|
||||
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 20) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
let off = 20
|
||||
const out: ParsedFlow[] = []
|
||||
const map = templatesForExporter(exporter)
|
||||
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > buf.length) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 0) {
|
||||
let tOff = off + 4
|
||||
while (tOff + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(tOff)
|
||||
const fieldCount = buf.readUInt16BE(tOff + 2)
|
||||
tOff += 4
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
|
||||
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
|
||||
tOff += 4
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = map.get(setId)
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
|
||||
if (buf.length < 2) return []
|
||||
const version = buf.readUInt16BE(0)
|
||||
if (version === 5) return parseNetflowV5(buf)
|
||||
if (version === 9) return parseNetflowV9(buf, exporterIp)
|
||||
if (version === 10) return parseIpfix(buf, exporterIp)
|
||||
return []
|
||||
}
|
||||
|
||||
export function protoName(proto: number): string {
|
||||
switch (proto) {
|
||||
case 1: return "ICMP"
|
||||
case 6: return "TCP"
|
||||
case 17: return "UDP"
|
||||
case 47: return "GRE"
|
||||
case 50: return "ESP"
|
||||
case 89: return "OSPF"
|
||||
default: return String(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFlowTemplatesForTests() {
|
||||
templatesByExporter.clear()
|
||||
}
|
||||
|
||||
export function templateExporterCountForTests(): number {
|
||||
return templatesByExporter.size
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
classifyFlowPlane,
|
||||
classifyFlowPlaneLite,
|
||||
flowBps,
|
||||
shouldKeepPlane,
|
||||
} from "./traffic-flow-planes.js"
|
||||
|
||||
const youtubeInner = {
|
||||
src: "10.100.1.17",
|
||||
dst: "173.194.160.163",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
inIface: "gre-client",
|
||||
outIface: "NSK-SERVHOST-RTK",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(youtubeInner), "payload")
|
||||
assert.equal(classifyFlowPlane(youtubeInner), "payload")
|
||||
|
||||
const greOverlay = {
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
inIface: "ether1",
|
||||
outIface: "NSK-SERVHOST-RTK",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(greOverlay), "overlay")
|
||||
|
||||
const espOverlay = { ...greOverlay, proto: 50 }
|
||||
assert.equal(classifyFlowPlaneLite(espOverlay), "overlay")
|
||||
|
||||
const mesh = {
|
||||
src: "10.100.1.17",
|
||||
dst: "10.100.1.18",
|
||||
proto: 6,
|
||||
srcPort: 50000,
|
||||
dstPort: 443,
|
||||
inIface: "gre-a",
|
||||
outIface: "gre-b",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(mesh), "client_mesh")
|
||||
|
||||
const mgmt = {
|
||||
src: "10.255.254.2",
|
||||
dst: "10.255.254.1",
|
||||
proto: 17,
|
||||
srcPort: 4739,
|
||||
dstPort: 4739,
|
||||
inIface: "wg-flow",
|
||||
outIface: "",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(mgmt), "mgmt")
|
||||
assert.equal(classifyFlowPlaneLite({ ...youtubeInner, outIface: "wg-flow" }), "payload")
|
||||
assert.equal(shouldKeepPlane("mgmt", {}), false)
|
||||
assert.equal(shouldKeepPlane("overlay", {}), false)
|
||||
assert.equal(shouldKeepPlane("client_mesh", {}), false)
|
||||
assert.equal(shouldKeepPlane("payload", {}), true)
|
||||
assert.equal(shouldKeepPlane("overlay", { excludeOverlay: false }), true)
|
||||
assert.equal(shouldKeepPlane("client_mesh", { excludeMesh: false }), true)
|
||||
|
||||
assert.equal(flowBps(1500, 1_000, 2_000, 300), (1500 * 8) / 1)
|
||||
assert.equal(flowBps(1500, 0, 0, 300), (1500 * 8) / 300)
|
||||
|
||||
const publicJhEn = {
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 6,
|
||||
srcPort: 1000,
|
||||
dstPort: 443,
|
||||
inIface: "ether1",
|
||||
outIface: "gre-en",
|
||||
}
|
||||
assert.equal(classifyFlowPlane(publicJhEn, {
|
||||
clientIfaceNames: new Set(["gre-client"]),
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
}), "overlay")
|
||||
|
||||
console.log("traffic-flow-planes.test.ts: ok")
|
||||
@@ -0,0 +1,107 @@
|
||||
export type FlowPlane = "payload" | "client_mesh" | "overlay" | "mgmt"
|
||||
|
||||
export const PLANE_LABEL: Record<FlowPlane, string> = {
|
||||
payload: "Интернет",
|
||||
client_mesh: "Клиенты",
|
||||
overlay: "JH↔EN",
|
||||
mgmt: "mgmt",
|
||||
}
|
||||
|
||||
const WG_PORTS = new Set([51820, 13232, 51821])
|
||||
const FLOW_PORTS = new Set([4739, 2055])
|
||||
|
||||
export function isRfc1918(ip: string): boolean {
|
||||
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||
const [a, b] = parts
|
||||
if (a === 10) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 172 && b != null && b >= 16 && b <= 31) return true
|
||||
if (a === 100 && b != null && b >= 64 && b <= 127) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isPublicV4(ip: string): boolean {
|
||||
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||
const a = parts[0] ?? 0
|
||||
if (a === 0 || a === 127 || a >= 224) return false
|
||||
return !isRfc1918(ip)
|
||||
}
|
||||
|
||||
export function isTunnelProto(proto: number, srcPort: number, dstPort: number): boolean {
|
||||
if (proto === 47 || proto === 50) return true
|
||||
if (proto === 17 && (WG_PORTS.has(srcPort) || WG_PORTS.has(dstPort))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function ifaceLooksMgmt(name: string): boolean {
|
||||
const n = name.trim().toLowerCase()
|
||||
return n === "wg-flow" || n.endsWith("/wg-flow") || n.includes("wg-flow")
|
||||
}
|
||||
|
||||
export interface PlaneFlowInput {
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
inIface: string
|
||||
outIface?: string
|
||||
}
|
||||
|
||||
/** Быстрая классификация без топологии — для live ring на ingest. */
|
||||
export function classifyFlowPlaneLite(flow: PlaneFlowInput): FlowPlane {
|
||||
if (ifaceLooksMgmt(flow.inIface)) return "mgmt"
|
||||
if (flow.proto === 17 && (FLOW_PORTS.has(flow.srcPort) || FLOW_PORTS.has(flow.dstPort))) return "mgmt"
|
||||
if (isTunnelProto(flow.proto, flow.srcPort, flow.dstPort)) return "overlay"
|
||||
if (isRfc1918(flow.src) && isRfc1918(flow.dst)) return "client_mesh"
|
||||
return "payload"
|
||||
}
|
||||
|
||||
export interface PlaneTopology {
|
||||
clientIfaceNames: Set<string>
|
||||
enHosts: Set<string>
|
||||
jhHosts: Set<string>
|
||||
}
|
||||
|
||||
function hostHit(ip: string, hosts: Set<string>): boolean {
|
||||
return Boolean(ip) && hosts.has(ip)
|
||||
}
|
||||
|
||||
export function classifyFlowPlane(
|
||||
flow: PlaneFlowInput,
|
||||
topo?: PlaneTopology | null,
|
||||
): FlowPlane {
|
||||
const lite = classifyFlowPlaneLite(flow)
|
||||
if (!topo) return lite
|
||||
if (lite === "mgmt") return "mgmt"
|
||||
if (lite === "overlay") return "overlay"
|
||||
const srcEn = hostHit(flow.src, topo.enHosts) || hostHit(flow.src, topo.jhHosts)
|
||||
const dstEn = hostHit(flow.dst, topo.enHosts) || hostHit(flow.dst, topo.jhHosts)
|
||||
if (srcEn && dstEn && isPublicV4(flow.src) && isPublicV4(flow.dst)) return "overlay"
|
||||
if (lite === "client_mesh") {
|
||||
const inClient = topo.clientIfaceNames.has(flow.inIface)
|
||||
const outClient = Boolean(flow.outIface && topo.clientIfaceNames.has(flow.outIface))
|
||||
if (inClient || outClient || (isRfc1918(flow.src) && isRfc1918(flow.dst))) return "client_mesh"
|
||||
}
|
||||
return "payload"
|
||||
}
|
||||
|
||||
export function shouldKeepPlane(
|
||||
plane: FlowPlane,
|
||||
opts: { excludeMesh?: boolean; excludeOverlay?: boolean },
|
||||
): boolean {
|
||||
if (plane === "mgmt") return false
|
||||
if (opts.excludeMesh !== false && plane === "client_mesh") return false
|
||||
if (opts.excludeOverlay !== false && plane === "overlay") return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function flowBps(bytes: number, startMs: number, endMs: number, windowSec: number): number {
|
||||
if (startMs > 0 && endMs > startMs) {
|
||||
const sec = Math.max(1, (endMs - startMs) / 1000)
|
||||
return (bytes * 8) / sec
|
||||
}
|
||||
return (bytes * 8) / Math.max(1, windowSec)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "mm-flow-purge-"))
|
||||
process.env.DATABASE_PATH = path.join(dir, "test.db")
|
||||
|
||||
const { sqliteDatabase } = await import("../db/index.js")
|
||||
const {
|
||||
getFlowRuntimeCounters,
|
||||
purgeTrafficFlowStore,
|
||||
stopTrafficFlowListener,
|
||||
} = await import("./traffic-flow-ingest.js")
|
||||
|
||||
function count(name: string): number {
|
||||
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
try {
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO servers (name, host) VALUES ('purge-test', '127.0.0.1')
|
||||
`).run()
|
||||
const serverId = Number(
|
||||
(sqliteDatabase.prepare(`SELECT id FROM servers WHERE name = 'purge-test'`).get() as { id: number }).id,
|
||||
)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', '10.0.0.1', '8.8.8.8', 6, 50000, 443, 100, 1, 'wg-flow')
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_minute_stats (server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', 100, 1, 1, 1, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', 'country', 'RU', 100, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (?, '2026-01-01', 'country', 'RU', 100, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
|
||||
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, '2026-01-01T00:00:00.000Z')
|
||||
`).run()
|
||||
sqliteDatabase.prepare(`
|
||||
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||
`).run()
|
||||
|
||||
const result = await purgeTrafficFlowStore()
|
||||
stopTrafficFlowListener()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.deleted.buckets, 1)
|
||||
assert.equal(result.deleted.minuteStats, 1)
|
||||
assert.equal(result.deleted.minuteDims, 1)
|
||||
assert.equal(result.deleted.dailyDims, 1)
|
||||
assert.equal(count("flow_buckets"), 0)
|
||||
assert.equal(count("flow_minute_stats"), 0)
|
||||
assert.equal(count("flow_minute_dims"), 0)
|
||||
assert.equal(count("flow_daily_dims"), 0)
|
||||
assert.equal(count("flow_ip_meta"), 1)
|
||||
assert.equal(count("servers"), 1)
|
||||
assert.equal(getFlowRuntimeCounters().packetsReceived, 0)
|
||||
assert.equal(getFlowRuntimeCounters().lastExporterIp, null)
|
||||
} finally {
|
||||
try {
|
||||
sqliteDatabase.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log("traffic-flow-purge.test.ts: ok")
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
disableRipeEnqueueForTests,
|
||||
disableRipePersistForTests,
|
||||
enqueueRipeMisses,
|
||||
flushRipeQueueForTests,
|
||||
lookupRipeCached,
|
||||
resetRipeCacheForTests,
|
||||
ripeFetchCountForTests,
|
||||
ripeLastCandidateCountForTests,
|
||||
seedRipeCacheForTests,
|
||||
setRipeFetchForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
disableRipePersistForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
|
||||
assert.equal(lookupRipeCached("10.1.1.8")?.ok, false)
|
||||
assert.equal(lookupRipeCached("192.168.0.1")?.ok, false)
|
||||
assert.equal(lookupRipeCached("100.64.1.2")?.ok, false)
|
||||
assert.equal(ripeFetchCountForTests(), 0)
|
||||
|
||||
seedRipeCacheForTests({
|
||||
prefix: "1.2.3.0/24",
|
||||
asn: 64500,
|
||||
country: "NL",
|
||||
lat: 52.3,
|
||||
lng: 4.9,
|
||||
holder: "TEST",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(lookupRipeCached("1.2.3.10")?.country, "NL")
|
||||
assert.equal(lookupRipeCached("1.2.3.10")?.asn, 64500)
|
||||
assert.equal(ripeFetchCountForTests(), 0)
|
||||
|
||||
resetRipeCacheForTests()
|
||||
disableRipePersistForTests()
|
||||
setRipeFetchForTests(async (input) => {
|
||||
const url = String(input)
|
||||
const body = url.includes("network-info")
|
||||
? { data: { prefix: "8.8.8.0/24", asns: ["15169"] } }
|
||||
: url.includes("maxmind-geo-lite")
|
||||
? { data: { located_resources: [{ locations: [{ country: "US", latitude: 37.4, longitude: -122.1 }] }] } }
|
||||
: { data: { holder: "GOOGLE" } }
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
})
|
||||
enqueueRipeMisses(["8.8.8.8"])
|
||||
await flushRipeQueueForTests()
|
||||
assert.equal(lookupRipeCached("8.8.8.8")?.country, "US")
|
||||
assert.equal(lookupRipeCached("8.8.8.10")?.prefix, "8.8.8.0/24")
|
||||
const afterFirst = ripeFetchCountForTests()
|
||||
assert.ok(afterFirst >= 2)
|
||||
enqueueRipeMisses(["8.8.8.10"])
|
||||
await flushRipeQueueForTests()
|
||||
assert.equal(ripeFetchCountForTests(), afterFirst)
|
||||
|
||||
resetRipeCacheForTests()
|
||||
disableRipePersistForTests()
|
||||
setRipeFetchForTests(async () => {
|
||||
throw new Error("timeout")
|
||||
})
|
||||
enqueueRipeMisses(["203.0.113.50"])
|
||||
await flushRipeQueueForTests()
|
||||
const neg = lookupRipeCached("203.0.113.50")
|
||||
assert.equal(neg?.ok, false)
|
||||
const afterNeg = ripeFetchCountForTests()
|
||||
enqueueRipeMisses(["203.0.113.50"])
|
||||
await flushRipeQueueForTests()
|
||||
assert.equal(ripeFetchCountForTests(), afterNeg)
|
||||
|
||||
resetRipeCacheForTests()
|
||||
disableRipePersistForTests()
|
||||
seedRipeCacheForTests({
|
||||
prefix: "1.1.1.0/24",
|
||||
asn: 13335,
|
||||
country: "?",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "CLOUDFLARENET, US",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(lookupRipeCached("1.1.1.1")?.country, "US")
|
||||
assert.ok(lookupRipeCached("1.1.1.1")?.country !== "?")
|
||||
|
||||
resetRipeCacheForTests()
|
||||
disableRipePersistForTests()
|
||||
setRipeFetchForTests(async (input) => {
|
||||
const url = String(input)
|
||||
const body = url.includes("network-info")
|
||||
? { data: { prefix: "1.0.0.0/24", asns: ["13335"] } }
|
||||
: url.includes("maxmind-geo-lite")
|
||||
? { data: { located_resources: [{ locations: [{ country: "?" }] }] } }
|
||||
: { data: { holder: "CLOUDFLARENET, US" } }
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
})
|
||||
enqueueRipeMisses(["1.0.0.1"])
|
||||
await flushRipeQueueForTests()
|
||||
assert.equal(lookupRipeCached("1.0.0.1")?.country, "US")
|
||||
assert.equal(lookupRipeCached("1.0.0.1")?.asn, 13335)
|
||||
|
||||
resetRipeCacheForTests()
|
||||
disableRipePersistForTests()
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const o2 = Math.floor(i / 256)
|
||||
const o3 = i % 256
|
||||
seedRipeCacheForTests({
|
||||
prefix: `203.${o2}.${o3}.0/24`,
|
||||
asn: 64500,
|
||||
country: "NL",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "NOISE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
seedRipeCacheForTests({
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(lookupRipeCached("8.8.8.8")?.asn, 15169)
|
||||
assert.ok(
|
||||
ripeLastCandidateCountForTests() < 8,
|
||||
`index should not scan all prefixes, got ${ripeLastCandidateCountForTests()}`,
|
||||
)
|
||||
|
||||
console.log("traffic-flow-ripe.test.ts: ok")
|
||||
@@ -0,0 +1,445 @@
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||
import { resolveRipeCountry } from "./traffic-flow-brands.js"
|
||||
|
||||
export interface FlowIpMeta {
|
||||
prefix: string
|
||||
asn: number
|
||||
country: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
holder: string
|
||||
ok: boolean
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
const HIT_TTL_MS = 24 * 60 * 60_000
|
||||
const NEG_TTL_MS = 6 * 60 * 60_000
|
||||
const MAX_NEW_PREFIX_PER_MIN = 30
|
||||
const MAX_QUEUE = 90
|
||||
const CONCURRENCY = 3
|
||||
const RIPE_BASE = "https://stat.ripe.net/data"
|
||||
const UA = "MikrotikManager-flow/1.0"
|
||||
|
||||
const mem = new Map<string, FlowIpMeta>()
|
||||
const asnHolder = new Map<number, { holder: string; fetchedAt: number }>()
|
||||
const inflight = new Map<string, Promise<FlowIpMeta | null>>()
|
||||
const queue: string[] = []
|
||||
const queued = new Set<string>()
|
||||
const recentFetches: number[] = []
|
||||
|
||||
interface RipeIndexed {
|
||||
entry: FlowIpMeta
|
||||
net: number
|
||||
mask: number
|
||||
prefixLen: number
|
||||
}
|
||||
|
||||
/** /24 → кандидаты с prefixLen ≥ 24. Более широкие префиксы — в `wideIndex`. */
|
||||
const v24Index = new Map<number, RipeIndexed[]>()
|
||||
const wideIndex: RipeIndexed[] = []
|
||||
let lastCandidateCount = 0
|
||||
|
||||
let persistEnabled = true
|
||||
let enqueueEnabled = true
|
||||
let loaded = false
|
||||
let workerRunning = false
|
||||
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
|
||||
let fetchCount = 0
|
||||
|
||||
export function disableRipePersistForTests(): void {
|
||||
persistEnabled = false
|
||||
}
|
||||
|
||||
export function disableRipeEnqueueForTests(): void {
|
||||
enqueueEnabled = false
|
||||
}
|
||||
|
||||
export function resetRipeCacheForTests(): void {
|
||||
mem.clear()
|
||||
asnHolder.clear()
|
||||
inflight.clear()
|
||||
queue.length = 0
|
||||
queued.clear()
|
||||
recentFetches.length = 0
|
||||
v24Index.clear()
|
||||
wideIndex.length = 0
|
||||
lastCandidateCount = 0
|
||||
loaded = persistEnabled ? false : true
|
||||
workerRunning = false
|
||||
fetchCount = 0
|
||||
enqueueEnabled = true
|
||||
fetchImpl = globalThis.fetch.bind(globalThis)
|
||||
}
|
||||
|
||||
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
|
||||
remember(entry)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/** Сколько CIDR смотрели в последнем lookup (для теста индекса /24). */
|
||||
export function ripeLastCandidateCountForTests(): number {
|
||||
return lastCandidateCount
|
||||
}
|
||||
|
||||
export function setRipeFetchForTests(fn: typeof fetch): void {
|
||||
fetchImpl = fn
|
||||
fetchCount = 0
|
||||
}
|
||||
|
||||
export function ripeFetchCountForTests(): number {
|
||||
return fetchCount
|
||||
}
|
||||
|
||||
export async function flushRipeQueueForTests(timeoutMs = 4000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!queue.length && !inflight.size && !workerRunning) return
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
}
|
||||
}
|
||||
|
||||
function ttlMs(ok: boolean): number {
|
||||
return ok ? HIT_TTL_MS : NEG_TTL_MS
|
||||
}
|
||||
|
||||
function isFresh(entry: FlowIpMeta): boolean {
|
||||
return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
|
||||
}
|
||||
|
||||
function unindexPrefix(prefix: string): void {
|
||||
const parsed = parseCidrV4(prefix)
|
||||
if (!parsed) return
|
||||
if (parsed.prefixLen >= 24) {
|
||||
const key = parsed.net >>> 8
|
||||
const list = v24Index.get(key)
|
||||
if (!list) return
|
||||
const next = list.filter((row) => row.entry.prefix !== prefix)
|
||||
if (next.length) v24Index.set(key, next)
|
||||
else v24Index.delete(key)
|
||||
return
|
||||
}
|
||||
const idx = wideIndex.findIndex((row) => row.entry.prefix === prefix)
|
||||
if (idx >= 0) wideIndex.splice(idx, 1)
|
||||
}
|
||||
|
||||
function indexEntry(entry: FlowIpMeta): void {
|
||||
const parsed = parseCidrV4(entry.prefix)
|
||||
if (!parsed) return
|
||||
const row: RipeIndexed = {
|
||||
entry,
|
||||
net: parsed.net,
|
||||
mask: parsed.mask,
|
||||
prefixLen: parsed.prefixLen,
|
||||
}
|
||||
if (parsed.prefixLen >= 24) {
|
||||
const key = parsed.net >>> 8
|
||||
const list = v24Index.get(key)
|
||||
if (list) list.push(row)
|
||||
else v24Index.set(key, [row])
|
||||
return
|
||||
}
|
||||
wideIndex.push(row)
|
||||
}
|
||||
|
||||
function remember(entry: FlowIpMeta): void {
|
||||
const prev = mem.get(entry.prefix)
|
||||
if (prev) unindexPrefix(prev.prefix)
|
||||
mem.set(entry.prefix, entry)
|
||||
indexEntry(entry)
|
||||
}
|
||||
|
||||
function loadSqlite(): void {
|
||||
if (loaded || !persistEnabled) {
|
||||
loaded = true
|
||||
return
|
||||
}
|
||||
loaded = true
|
||||
try {
|
||||
const rows = sqliteDatabase.prepare(`
|
||||
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
|
||||
FROM flow_ip_meta
|
||||
`).all() as Array<{
|
||||
prefix: string
|
||||
asn: number | null
|
||||
country: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
holder: string
|
||||
ok: number
|
||||
fetched_at: string
|
||||
}>
|
||||
for (const r of rows) {
|
||||
const fetchedAt = Date.parse(r.fetched_at)
|
||||
const asn = Number(r.asn ?? 0) || 0
|
||||
const holder = r.holder || ""
|
||||
remember({
|
||||
prefix: r.prefix,
|
||||
asn,
|
||||
country: resolveRipeCountry(r.country || "", asn, holder) || "—",
|
||||
lat: r.lat == null ? null : Number(r.lat),
|
||||
lng: r.lng == null ? null : Number(r.lng),
|
||||
holder,
|
||||
ok: r.ok !== 0,
|
||||
fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0,
|
||||
})
|
||||
}
|
||||
const asns = sqliteDatabase.prepare(`SELECT asn, holder, fetched_at FROM flow_asn_meta`).all() as Array<{
|
||||
asn: number
|
||||
holder: string
|
||||
fetched_at: string
|
||||
}>
|
||||
for (const a of asns) {
|
||||
const fetchedAt = Date.parse(a.fetched_at)
|
||||
asnHolder.set(a.asn, { holder: a.holder || "", fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0 })
|
||||
}
|
||||
} catch {
|
||||
/* table may not exist in isolated tests */
|
||||
}
|
||||
}
|
||||
|
||||
function persist(entry: FlowIpMeta): void {
|
||||
if (!persistEnabled) return
|
||||
try {
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_ip_meta (prefix, asn, country, lat, lng, holder, ok, fetched_at)
|
||||
VALUES (@prefix, @asn, @country, @lat, @lng, @holder, @ok, @fetchedAt)
|
||||
ON CONFLICT(prefix) DO UPDATE SET
|
||||
asn=excluded.asn, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
|
||||
holder=excluded.holder, ok=excluded.ok, fetched_at=excluded.fetched_at
|
||||
`).run({
|
||||
prefix: entry.prefix,
|
||||
asn: entry.asn,
|
||||
country: entry.country,
|
||||
lat: entry.lat,
|
||||
lng: entry.lng,
|
||||
holder: entry.holder,
|
||||
ok: entry.ok ? 1 : 0,
|
||||
fetchedAt: new Date(entry.fetchedAt).toISOString(),
|
||||
})
|
||||
} catch {
|
||||
/* ignore persist errors */
|
||||
}
|
||||
}
|
||||
|
||||
function persistAsn(asn: number, holder: string): void {
|
||||
if (!persistEnabled || !asn) return
|
||||
try {
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_asn_meta (asn, holder, fetched_at)
|
||||
VALUES (@asn, @holder, @fetchedAt)
|
||||
ON CONFLICT(asn) DO UPDATE SET holder=excluded.holder, fetched_at=excluded.fetched_at
|
||||
`).run({
|
||||
asn,
|
||||
holder,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function negative(prefix: string): FlowIpMeta {
|
||||
return {
|
||||
prefix,
|
||||
asn: 0,
|
||||
country: "—",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "",
|
||||
ok: false,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export function lookupRipeCached(ip: string): FlowIpMeta | null {
|
||||
loadSqlite()
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
lastCandidateCount = 0
|
||||
if (!trimmed) return null
|
||||
if (isNonPublicIp(trimmed)) {
|
||||
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
|
||||
}
|
||||
const addr = ipv4ToInt(trimmed)
|
||||
if (addr == null) return null
|
||||
const bucket = v24Index.get(addr >>> 8)
|
||||
const candidates = bucket ? bucket.concat(wideIndex) : wideIndex
|
||||
lastCandidateCount = candidates.length
|
||||
let best: FlowIpMeta | null = null
|
||||
let bestLen = -1
|
||||
for (const row of candidates) {
|
||||
if (!isFresh(row.entry)) continue
|
||||
if (((addr & row.mask) >>> 0) !== row.net) continue
|
||||
if (row.prefixLen > bestLen) {
|
||||
best = row.entry
|
||||
bestLen = row.prefixLen
|
||||
}
|
||||
}
|
||||
return best
|
||||
? { ...best, country: resolveRipeCountry(best.country, best.asn, best.holder) || "—" }
|
||||
: null
|
||||
}
|
||||
|
||||
async function ripeJson(path: string, resource: string): Promise<unknown> {
|
||||
fetchCount += 1
|
||||
const url = `${RIPE_BASE}/${path}/data.json?resource=${encodeURIComponent(resource)}`
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 12_000)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
headers: { Accept: "application/json", "User-Agent": UA },
|
||||
signal: ac.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json()
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
function pickPrefix(data: unknown): string {
|
||||
const d = data as { data?: { prefix?: string } }
|
||||
return String(d?.data?.prefix ?? "").trim()
|
||||
}
|
||||
|
||||
function pickAsns(data: unknown): number {
|
||||
const d = data as { data?: { asns?: unknown } }
|
||||
const raw = d?.data?.asns
|
||||
const first = Array.isArray(raw) ? raw[0] : raw
|
||||
const n = Number.parseInt(String(first ?? "").replace(/^AS/i, ""), 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function pickGeo(data: unknown): { country: string; lat: number | null; lng: number | null } {
|
||||
const d = data as {
|
||||
data?: {
|
||||
located_resources?: Array<{
|
||||
locations?: Array<{ country?: string; latitude?: number; longitude?: number }>
|
||||
}>
|
||||
}
|
||||
}
|
||||
const loc = d?.data?.located_resources?.[0]?.locations?.[0]
|
||||
const country = resolveRipeCountry(String(loc?.country ?? ""), 0, "")
|
||||
const lat = loc?.latitude == null ? null : Number(loc.latitude)
|
||||
const lng = loc?.longitude == null ? null : Number(loc.longitude)
|
||||
return {
|
||||
country: country || "—",
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
}
|
||||
}
|
||||
|
||||
function pickHolder(data: unknown): string {
|
||||
const d = data as { data?: { holder?: string } }
|
||||
return String(d?.data?.holder ?? "").trim()
|
||||
}
|
||||
|
||||
function allowNewPrefix(): boolean {
|
||||
const now = Date.now()
|
||||
while (recentFetches.length && now - recentFetches[0]! > 60_000) recentFetches.shift()
|
||||
return recentFetches.length < MAX_NEW_PREFIX_PER_MIN
|
||||
}
|
||||
|
||||
async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
|
||||
const cached = lookupRipeCached(ip)
|
||||
if (cached) return cached
|
||||
const pending = inflight.get(ip)
|
||||
if (pending) return pending
|
||||
|
||||
const job = (async () => {
|
||||
if (!allowNewPrefix()) return null
|
||||
recentFetches.push(Date.now())
|
||||
try {
|
||||
const net = await ripeJson("network-info", ip)
|
||||
const prefix = pickPrefix(net) || `${ip}/32`
|
||||
const existing = mem.get(prefix)
|
||||
if (existing && isFresh(existing)) return existing
|
||||
const asn = pickAsns(net)
|
||||
let geo = { country: "—", lat: null as number | null, lng: null as number | null }
|
||||
try {
|
||||
geo = pickGeo(await ripeJson("maxmind-geo-lite", prefix))
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
let holder = asnHolder.get(asn)?.holder ?? ""
|
||||
if (asn && (!holder || Date.now() - (asnHolder.get(asn)?.fetchedAt ?? 0) > HIT_TTL_MS)) {
|
||||
try {
|
||||
holder = pickHolder(await ripeJson("as-overview", `AS${asn}`))
|
||||
asnHolder.set(asn, { holder, fetchedAt: Date.now() })
|
||||
persistAsn(asn, holder)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
const country = resolveRipeCountry(geo.country, asn, holder)
|
||||
const entry: FlowIpMeta = {
|
||||
prefix,
|
||||
asn,
|
||||
country: country || "—",
|
||||
lat: geo.lat,
|
||||
lng: geo.lng,
|
||||
holder,
|
||||
ok: Boolean(asn || country),
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
remember(entry)
|
||||
persist(entry)
|
||||
return entry
|
||||
} catch {
|
||||
const prefix = `${ip}/32`
|
||||
const entry = negative(prefix)
|
||||
remember(entry)
|
||||
persist(entry)
|
||||
return entry
|
||||
} finally {
|
||||
inflight.delete(ip)
|
||||
}
|
||||
})()
|
||||
|
||||
inflight.set(ip, job)
|
||||
return job
|
||||
}
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
if (workerRunning) return
|
||||
workerRunning = true
|
||||
try {
|
||||
while (queue.length) {
|
||||
const batch: string[] = []
|
||||
while (batch.length < CONCURRENCY && queue.length) {
|
||||
const ip = queue.shift()
|
||||
if (!ip) break
|
||||
queued.delete(ip)
|
||||
if (lookupRipeCached(ip)) continue
|
||||
if (ipv4ToInt(ip) == null && !ip.includes(":")) continue
|
||||
batch.push(ip)
|
||||
}
|
||||
if (!batch.length) {
|
||||
if (!allowNewPrefix()) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
continue
|
||||
}
|
||||
await Promise.all(batch.map((ip) => resolveIp(ip)))
|
||||
}
|
||||
} finally {
|
||||
workerRunning = false
|
||||
if (queue.length) void runWorker()
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP / SSE never await this — cache miss is filled on a later tick. */
|
||||
export function enqueueRipeMisses(ips: Iterable<string>): void {
|
||||
if (!enqueueEnabled) return
|
||||
loadSqlite()
|
||||
for (const raw of ips) {
|
||||
if (queue.length >= MAX_QUEUE) break
|
||||
const ip = String(raw ?? "").trim()
|
||||
if (!ip || isNonPublicIp(ip)) continue
|
||||
if (lookupRipeCached(ip)) continue
|
||||
if (queued.has(ip) || inflight.has(ip)) continue
|
||||
queued.add(ip)
|
||||
queue.push(ip)
|
||||
}
|
||||
if (queue.length) void runWorker()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { trafficFlowSettings } from "../db/schema.js"
|
||||
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function parsePeers(raw: string): FlowHostPeer[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((p): p is FlowHostPeer =>
|
||||
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrafficFlowSettingsRow() {
|
||||
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = nowIso()
|
||||
db.insert(trafficFlowSettings).values({
|
||||
id: 1,
|
||||
enabled: false,
|
||||
collectorIp: "10.255.254.1",
|
||||
flowListenPort: 4739,
|
||||
wgListenPort: 51821,
|
||||
prefix: "10.255.254.0/24",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function toTrafficFlowSettingsDto(
|
||||
listener: { bound: boolean; address: string | null },
|
||||
): TrafficFlowSettingsDto {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
collectorIp: row.collectorIp,
|
||||
flowListenPort: row.flowListenPort,
|
||||
wgListenPort: row.wgListenPort,
|
||||
prefix: row.prefix,
|
||||
publicEndpoint: row.publicEndpoint,
|
||||
hostPublicKey: row.hostPublicKey,
|
||||
hasHostPrivateKey: Boolean(row.hostPrivateKey),
|
||||
hubServerId: row.hubServerId ?? null,
|
||||
retentionHours: row.retentionHours,
|
||||
topN: row.topN,
|
||||
mapServiceMinSharePct: Number(row.mapServiceMinSharePct ?? 5),
|
||||
lastDatagramAt: row.lastDatagramAt ?? null,
|
||||
lastExporterIp: row.lastExporterIp ?? null,
|
||||
lastError: row.lastError || null,
|
||||
packetsReceived: row.packetsReceived,
|
||||
listenerBound: listener.bound,
|
||||
listenerAddress: listener.address,
|
||||
peers: parsePeers(row.peersJson),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
db.update(trafficFlowSettings).set({
|
||||
enabled: patch.enabled ?? row.enabled,
|
||||
collectorIp: patch.collectorIp ?? row.collectorIp,
|
||||
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
|
||||
wgListenPort: patch.wgListenPort ?? row.wgListenPort,
|
||||
prefix: patch.prefix ?? row.prefix,
|
||||
publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint,
|
||||
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
||||
retentionHours: patch.retentionHours ?? row.retentionHours,
|
||||
topN: patch.topN ?? row.topN,
|
||||
mapServiceMinSharePct: patch.mapServiceMinSharePct == null
|
||||
? row.mapServiceMinSharePct
|
||||
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
return getTrafficFlowSettingsRow()
|
||||
}
|
||||
|
||||
export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
if (row.hostPublicKey && row.hostPrivateKey) {
|
||||
return { publicKey: row.hostPublicKey, created: false }
|
||||
}
|
||||
const keys = generateWireGuardKeyPair()
|
||||
db.update(trafficFlowSettings).set({
|
||||
hostPublicKey: keys.publicKey,
|
||||
hostPrivateKey: keys.privateKey,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
return { publicKey: keys.publicKey, created: true }
|
||||
}
|
||||
|
||||
export function upsertHostPeer(peer: FlowHostPeer) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
|
||||
peers.push(peer)
|
||||
db.update(trafficFlowSettings).set({
|
||||
peersJson: JSON.stringify(peers),
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function recordFlowPacket(exporterIp: string) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
db.update(trafficFlowSettings).set({
|
||||
lastDatagramAt: nowIso(),
|
||||
lastExporterIp: exporterIp,
|
||||
packetsReceived: row.packetsReceived + 1,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function recordFlowListenerError(message: string) {
|
||||
db.update(trafficFlowSettings).set({
|
||||
lastError: message,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function enableTrafficFlowIngest() {
|
||||
db.update(trafficFlowSettings).set({
|
||||
enabled: true,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function listHostPeers(): FlowHostPeer[] {
|
||||
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||
}
|
||||
|
||||
export function resetFlowIngestCounters(): void {
|
||||
db.update(trafficFlowSettings).set({
|
||||
packetsReceived: 0,
|
||||
lastDatagramAt: null,
|
||||
lastExporterIp: null,
|
||||
lastError: "",
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db, sqliteDatabase } from "../db/index.js"
|
||||
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { mapRosInterfaceType } from "../modules/users/iface-type.js"
|
||||
import type { PlaneTopology } from "./traffic-flow-planes.js"
|
||||
|
||||
export interface FlowClientBinding {
|
||||
userId: string
|
||||
login: string
|
||||
name: string
|
||||
serverId: number
|
||||
interfaceName: string
|
||||
}
|
||||
|
||||
export interface FlowEnNode {
|
||||
id: number
|
||||
name: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
export interface FlowTopology {
|
||||
clientIfaces: Map<number, Set<string>>
|
||||
clientByIface: Map<string, FlowClientBinding>
|
||||
enNodes: FlowEnNode[]
|
||||
enHosts: Set<string>
|
||||
jhHosts: Set<string>
|
||||
wanIfaces: Map<number, Set<string>>
|
||||
plane: PlaneTopology
|
||||
}
|
||||
|
||||
let seeded: FlowTopology | null = null
|
||||
|
||||
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || "[]") as unknown
|
||||
return Array.isArray(parsed) ? parsed as Array<{ iface?: string; ip?: string }> : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function ifaceKey(serverId: number, name: string): string {
|
||||
return `${serverId}|${name}`
|
||||
}
|
||||
|
||||
export function loadFlowTopology(): FlowTopology {
|
||||
if (seeded) return seeded
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const users = db.select().from(appUsers).all()
|
||||
const binds = db.select().from(userInterfaceBindings).all()
|
||||
const loginById = new Map(users.map((u) => [u.id, u]))
|
||||
const clientIfaces = new Map<number, Set<string>>()
|
||||
const clientByIface = new Map<string, FlowClientBinding>()
|
||||
const allClientNames = new Set<string>()
|
||||
for (const b of binds) {
|
||||
const set = clientIfaces.get(b.serverId) ?? new Set<string>()
|
||||
set.add(b.interfaceName)
|
||||
clientIfaces.set(b.serverId, set)
|
||||
allClientNames.add(b.interfaceName)
|
||||
const user = loginById.get(b.userId)
|
||||
clientByIface.set(ifaceKey(b.serverId, b.interfaceName), {
|
||||
userId: b.userId,
|
||||
login: user?.login || b.userId,
|
||||
name: user?.name || user?.login || b.userId,
|
||||
serverId: b.serverId,
|
||||
interfaceName: b.interfaceName,
|
||||
})
|
||||
}
|
||||
const enHosts = new Set<string>()
|
||||
const jhHosts = new Set<string>()
|
||||
const enNodes: FlowEnNode[] = []
|
||||
const wanIfaces = new Map<number, Set<string>>()
|
||||
for (const s of serverRows) {
|
||||
const wans = parseWanUplinks(s.wanUplinks)
|
||||
const hosts = [s.host, ...wans.map((w) => String(w.ip ?? "").trim())].filter(Boolean)
|
||||
const wanSet = new Set(wans.map((w) => String(w.iface ?? "").trim()).filter(Boolean))
|
||||
if (wanSet.size) wanIfaces.set(s.id, wanSet)
|
||||
if (s.type === "exit-node") {
|
||||
for (const h of hosts) enHosts.add(h)
|
||||
enNodes.push({ id: s.id, name: s.name || s.host, hosts })
|
||||
}
|
||||
if (s.type === "jump-host") {
|
||||
for (const h of hosts) jhHosts.add(h)
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientIfaces,
|
||||
clientByIface,
|
||||
enNodes,
|
||||
enHosts,
|
||||
jhHosts,
|
||||
wanIfaces,
|
||||
plane: {
|
||||
clientIfaceNames: allClientNames,
|
||||
enHosts,
|
||||
jhHosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
||||
seeded = topo
|
||||
}
|
||||
|
||||
export function resolveClient(
|
||||
topo: FlowTopology,
|
||||
serverId: number,
|
||||
inIfaceName: string,
|
||||
): FlowClientBinding | null {
|
||||
return topo.clientByIface.get(ifaceKey(serverId, inIfaceName)) ?? null
|
||||
}
|
||||
|
||||
export function resolveEn(
|
||||
topo: FlowTopology,
|
||||
nextHop: string,
|
||||
outIfaceName: string,
|
||||
): FlowEnNode | null {
|
||||
if (nextHop) {
|
||||
const hit = topo.enNodes.find((n) => n.hosts.includes(nextHop))
|
||||
if (hit) return hit
|
||||
}
|
||||
const needle = outIfaceName.trim().toLowerCase()
|
||||
if (!needle) return null
|
||||
return topo.enNodes.find((n) => {
|
||||
const name = n.name.toLowerCase()
|
||||
const host = (n.hosts[0] ?? "").toLowerCase()
|
||||
return (name && needle.includes(name)) || (host && needle.includes(host.split(".")[0] ?? ""))
|
||||
}) ?? null
|
||||
}
|
||||
|
||||
export function enGreIfaceNames(topo: FlowTopology, serverId: number, ifaceNames: string[]): string[] {
|
||||
const client = topo.clientIfaces.get(serverId) ?? new Set<string>()
|
||||
return ifaceNames.filter((name) => {
|
||||
if (client.has(name)) return false
|
||||
if (name === "wg-flow") return false
|
||||
return mapRosInterfaceType("", name) === "gre"
|
||||
})
|
||||
}
|
||||
|
||||
export function latestWireBps(serverId: number, ifaceNames: string[]): { bps: number; bytes: number } {
|
||||
if (!ifaceNames.length) return { bps: 0, bytes: 0 }
|
||||
const placeholders = ifaceNames.map(() => "?").join(",")
|
||||
const rows = sqliteDatabase.prepare(`
|
||||
SELECT interface_name AS name, rx_bps AS rxBps, tx_bps AS txBps, rx_bytes AS rxBytes, tx_bytes AS txBytes
|
||||
FROM traffic_samples
|
||||
WHERE server_id = ? AND interface_name IN (${placeholders})
|
||||
ORDER BY sampled_at DESC
|
||||
`).all(serverId, ...ifaceNames) as Array<{
|
||||
name: string
|
||||
rxBps: number
|
||||
txBps: number
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
}>
|
||||
const seen = new Set<string>()
|
||||
let bps = 0
|
||||
let bytes = 0
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.name)) continue
|
||||
seen.add(r.name)
|
||||
bps += (Number(r.rxBps) || 0) + (Number(r.txBps) || 0)
|
||||
bytes += (Number(r.rxBytes) || 0) + (Number(r.txBytes) || 0)
|
||||
}
|
||||
return { bps, bytes }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bpsToMbps,
|
||||
bucketAvg,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
mergeBuiltTraffic,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
shouldIncludeIface,
|
||||
type TrafficSampleLike,
|
||||
} from "./traffic-rate.js"
|
||||
|
||||
assert.equal(isLoopbackName("lo"), true)
|
||||
assert.equal(isLoopbackName("loopback"), true)
|
||||
assert.equal(isLoopbackName("ether1"), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, false), true)
|
||||
assert.equal(shouldIncludeIface("ether1", false, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, true), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
|
||||
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
|
||||
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
|
||||
assert.equal(bpsToMbps(1_500_000), 1.5)
|
||||
assert.equal(bpsToMbps(400_000), 0.4)
|
||||
|
||||
const buckets = bucketAvg(
|
||||
[
|
||||
{ t: 0, v: 10 },
|
||||
{ t: 1000, v: 20 },
|
||||
],
|
||||
0,
|
||||
1000,
|
||||
4,
|
||||
)
|
||||
assert.equal(buckets.length, 4)
|
||||
assert.equal(buckets[0], 10)
|
||||
assert.equal(buckets[3], 20)
|
||||
assert.equal(buckets[1], 0)
|
||||
assert.equal(buckets[2], 0)
|
||||
|
||||
const t0 = "2026-09-06T10:00:00.000Z"
|
||||
const t1 = "2026-09-06T10:00:30.000Z"
|
||||
const t2 = "2026-09-06T10:01:00.000Z"
|
||||
const start = Date.parse(t0)
|
||||
const end = Date.parse(t2)
|
||||
|
||||
const samples: TrafficSampleLike[] = [
|
||||
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
|
||||
const built = buildTrafficFromSamples(samples, start, end)
|
||||
assert.ok(built.rxPeak > 0, "peak RX from delta")
|
||||
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
|
||||
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
|
||||
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
|
||||
|
||||
const live = parseMonitorTraffic([
|
||||
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
|
||||
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
|
||||
])
|
||||
assert.equal(live.rxMbps, 2)
|
||||
assert.equal(live.txMbps, 0.5)
|
||||
|
||||
const onceOnly = parseMonitorTraffic(
|
||||
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
|
||||
{ onlyInterface: "ether1" },
|
||||
)
|
||||
assert.equal(onceOnly.rxMbps, 1)
|
||||
|
||||
const boundOnly = buildTrafficFromSamples(samples, start, end, ["ether1", "lo"])
|
||||
assert.ok(boundOnly.rxPeak > 0, "bound list includes ether1")
|
||||
assert.ok(boundOnly.rxPeak > built.rxPeak, "lo included when listed")
|
||||
|
||||
const merged = mergeBuiltTraffic([
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
])
|
||||
assert.equal(merged.rxNow, built.rxNow * 2)
|
||||
|
||||
const wgSamples: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-msk", sampledAt: t0, rxBytes: 2_000_000, txBytes: 1_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-msk", sampledAt: t1, rxBytes: 2_000_000 + 1_875_000, txBytes: 1_000_000 + 937_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-msk", sampledAt: t2, rxBytes: 50, txBytes: 25, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const userAgg = mergeBuiltTraffic([
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
buildTrafficFromSamples(wgSamples, start, end, "wg-msk"),
|
||||
])
|
||||
const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, ["ether1", "wg-msk"])
|
||||
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
||||
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
||||
|
||||
const peerA: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const peerB: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const ifaceWg: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const mixed = [...peerA, ...peerB, ...ifaceWg]
|
||||
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
|
||||
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
|
||||
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
|
||||
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
|
||||
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
|
||||
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
|
||||
assert.equal(
|
||||
buildTrafficFromSamples(mixed, start, end).rxNow,
|
||||
rateIface.rxNow,
|
||||
"режим сервера игнорирует семплы пиров",
|
||||
)
|
||||
|
||||
console.log("traffic-rate tests ok")
|
||||
@@ -0,0 +1,282 @@
|
||||
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
|
||||
|
||||
export const SERIES_POINTS = 60
|
||||
|
||||
export interface TrafficSampleLike {
|
||||
interfaceName: string
|
||||
peerPublicKey?: string
|
||||
sampledAt: string
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
rxBps: number
|
||||
txBps: number
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RatePoint {
|
||||
t: number
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
}
|
||||
|
||||
export interface BuiltTrafficSeries {
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotalGiB: number
|
||||
txTotalGiB: number
|
||||
sessions: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
export interface MonitorLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
}
|
||||
|
||||
export function isLoopbackName(name: string): boolean {
|
||||
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
|
||||
}
|
||||
|
||||
export function shouldIncludeIface(
|
||||
name: string,
|
||||
running: boolean,
|
||||
disabled: boolean,
|
||||
onlyInterface?: string,
|
||||
): boolean {
|
||||
if (onlyInterface) return name === onlyInterface
|
||||
if (isLoopbackName(name)) return false
|
||||
return running && !disabled
|
||||
}
|
||||
|
||||
export function bpsToMbps(bps: number): number {
|
||||
if (!Number.isFinite(bps) || bps <= 0) return 0
|
||||
return Math.round((bps / 1_000_000) * 1000) / 1000
|
||||
}
|
||||
|
||||
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
|
||||
export function rateBpsFromDelta(
|
||||
prevBytes: number,
|
||||
nextBytes: number,
|
||||
prevAtMs: number,
|
||||
nextAtMs: number,
|
||||
): number | null {
|
||||
const dtSec = (nextAtMs - prevAtMs) / 1000
|
||||
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
|
||||
if (nextBytes < prevBytes) return null
|
||||
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
|
||||
}
|
||||
|
||||
export function bucketAvg(
|
||||
points: Array<{ t: number; v: number }>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
target = SERIES_POINTS,
|
||||
): number[] {
|
||||
const buckets = Array.from({ length: target }, () => 0)
|
||||
const counts = Array.from({ length: target }, () => 0)
|
||||
const span = rangeEndMs - rangeStartMs
|
||||
if (span <= 0 || points.length === 0) return buckets
|
||||
for (const p of points) {
|
||||
const ratio = (p.t - rangeStartMs) / span
|
||||
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
|
||||
buckets[i] += p.v
|
||||
counts[i] += 1
|
||||
}
|
||||
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
|
||||
}
|
||||
|
||||
function parseIsoMs(iso: string): number {
|
||||
const t = Date.parse(iso)
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
|
||||
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||
return `${interfaceName}\0${peerPublicKey}`
|
||||
}
|
||||
|
||||
export function buildTrafficFromSamples(
|
||||
rows: TrafficSampleLike[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string | readonly string[],
|
||||
peerPublicKey?: string,
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
if (rows.length === 0) return empty
|
||||
|
||||
const bySeries = new Map<string, TrafficSampleLike[]>()
|
||||
for (const r of rows) {
|
||||
const peer = r.peerPublicKey ?? ""
|
||||
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
|
||||
arr.push(r)
|
||||
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
|
||||
}
|
||||
|
||||
const allowList = Array.isArray(onlyInterface)
|
||||
? onlyInterface
|
||||
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
|
||||
|
||||
const rxPoints: Array<{ t: number; v: number }> = []
|
||||
const txPoints: Array<{ t: number; v: number }> = []
|
||||
const byTs = new Map<number, { rx: number; tx: number }>()
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const [key, arr] of bySeries) {
|
||||
const sep = key.indexOf("\0")
|
||||
const name = sep >= 0 ? key.slice(0, sep) : key
|
||||
const peer = sep >= 0 ? key.slice(sep + 1) : ""
|
||||
if (allowList) {
|
||||
if (!allowList.includes(name)) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
if (peerPublicKey === undefined) {
|
||||
if (peer !== "") continue
|
||||
} else if (peer !== peerPublicKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (!last) continue
|
||||
if (!allowList && (!last.running || last.disabled)) continue
|
||||
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
|
||||
const first = sorted[0]
|
||||
if (first) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
}
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const cur = sorted[i]
|
||||
if (!prev || !cur) continue
|
||||
if (!allowList && (!cur.running || cur.disabled)) continue
|
||||
const t0 = parseIsoMs(prev.sampledAt)
|
||||
const t1 = parseIsoMs(cur.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
|
||||
if (rxBps == null && txBps == null) continue
|
||||
const rxMbps = bpsToMbps(rxBps ?? 0)
|
||||
const txMbps = bpsToMbps(txBps ?? 0)
|
||||
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
|
||||
acc.rx += rxMbps
|
||||
acc.tx += txMbps
|
||||
byTs.set(t1, acc)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [t, v] of byTs) {
|
||||
rxPoints.push({ t, v: v.rx })
|
||||
txPoints.push({ t, v: v.tx })
|
||||
}
|
||||
|
||||
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
||||
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
||||
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
|
||||
return {
|
||||
rxNow: last?.rx ?? 0,
|
||||
txNow: last?.tx ?? 0,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
if (parts.length === 0) return empty
|
||||
const acc = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
for (const p of parts) {
|
||||
acc.rxNow += p.rxNow
|
||||
acc.txNow += p.txNow
|
||||
acc.rxPeak += p.rxPeak
|
||||
acc.txPeak += p.txPeak
|
||||
acc.rxTotalGiB += p.rxTotalGiB
|
||||
acc.txTotalGiB += p.txTotalGiB
|
||||
acc.sessions += p.sessions
|
||||
for (let i = 0; i < SERIES_POINTS; i++) {
|
||||
acc.rxSeries[i] += p.rxSeries[i] ?? 0
|
||||
acc.txSeries[i] += p.txSeries[i] ?? 0
|
||||
}
|
||||
}
|
||||
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
|
||||
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
|
||||
return acc
|
||||
}
|
||||
|
||||
export function parseMonitorTraffic(
|
||||
raw: unknown,
|
||||
opts?: { onlyInterface?: string },
|
||||
): MonitorLiveSample {
|
||||
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
|
||||
let rxBps = 0
|
||||
let txBps = 0
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const rec = item as Record<string, unknown>
|
||||
const name = String(rec.name ?? rec.interface ?? "")
|
||||
if (opts?.onlyInterface) {
|
||||
if (name && name !== opts.onlyInterface) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
|
||||
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
|
||||
}
|
||||
return {
|
||||
rxMbps: bpsToMbps(rxBps),
|
||||
txMbps: bpsToMbps(txBps),
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { listUsers } from "../modules/users/service/users-service.js"
|
||||
import { readServerSamplesInRange } from "./traffic-collector.js"
|
||||
import {
|
||||
buildTrafficFromSamples,
|
||||
mergeBuiltTraffic,
|
||||
type BuiltTrafficSeries,
|
||||
} from "./traffic-rate.js"
|
||||
|
||||
export interface BoundIfaceTrafficDto {
|
||||
id: string
|
||||
bindingId: string
|
||||
userId: string
|
||||
userLogin: string
|
||||
userName: string
|
||||
interfaceName: string
|
||||
interfaceType: string
|
||||
peerPublicKey: string
|
||||
peerName: string
|
||||
comment: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotal: number
|
||||
txTotal: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
status: "online" | "offline"
|
||||
}
|
||||
|
||||
export interface UserTrafficDto {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
role: string
|
||||
active: boolean
|
||||
interfaces: BoundIfaceTrafficDto[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotal: number
|
||||
txTotal: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
function seriesFromBuilt(built: BuiltTrafficSeries) {
|
||||
return {
|
||||
rxNow: built.rxNow,
|
||||
txNow: built.txNow,
|
||||
rxPeak: built.rxPeak,
|
||||
txPeak: built.txPeak,
|
||||
rxTotal: built.rxTotalGiB,
|
||||
txTotal: built.txTotalGiB,
|
||||
rxSeries: built.rxSeries,
|
||||
txSeries: built.txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function serverStatus(serverId: number): "online" | "offline" {
|
||||
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!row?.enabled) return "offline"
|
||||
return "online"
|
||||
}
|
||||
|
||||
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const users = listUsers()
|
||||
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
|
||||
|
||||
return users.map((user) => {
|
||||
const parts: BuiltTrafficSeries[] = []
|
||||
const interfaces: BoundIfaceTrafficDto[] = []
|
||||
|
||||
for (const b of user.bindings) {
|
||||
let rows = sampleCache.get(b.serverId)
|
||||
if (!rows) {
|
||||
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||
sampleCache.set(b.serverId, rows)
|
||||
}
|
||||
const peerKey = b.peerPublicKey ?? ""
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
|
||||
parts.push(built)
|
||||
const last = [...rows.filter((r) =>
|
||||
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
|
||||
)]
|
||||
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
||||
.at(-1)
|
||||
const running = Boolean(last?.running) && !last?.disabled
|
||||
interfaces.push({
|
||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
|
||||
bindingId: b.id,
|
||||
userId: user.id,
|
||||
userLogin: user.login,
|
||||
userName: user.name,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: peerKey,
|
||||
peerName: b.peerName ?? "",
|
||||
comment: b.comment,
|
||||
serverId: String(b.serverId),
|
||||
serverName: b.serverName,
|
||||
serverSite: b.serverSite,
|
||||
serverCountry: b.serverCountry,
|
||||
...seriesFromBuilt(built),
|
||||
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
|
||||
})
|
||||
}
|
||||
|
||||
const merged = mergeBuiltTraffic(parts)
|
||||
return {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
displayName: user.name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
interfaces,
|
||||
...seriesFromBuilt(merged),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
|
||||
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { generateKeyPairSync } from "node:crypto"
|
||||
|
||||
/** WireGuard Curve25519 keypair as RouterOS/wg-quick base64 (32 bytes). */
|
||||
export function generateWireGuardKeyPair(): { publicKey: string; privateKey: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("x25519")
|
||||
const pubDer = publicKey.export({ type: "spki", format: "der" })
|
||||
const privDer = privateKey.export({ type: "pkcs8", format: "der" })
|
||||
return {
|
||||
publicKey: Buffer.from(pubDer.subarray(-32)).toString("base64"),
|
||||
privateKey: Buffer.from(privDer.subarray(-32)).toString("base64"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
detectWgConfigFormat,
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
parseMikrotikRsc,
|
||||
parseNativeConf,
|
||||
parseWgConfig,
|
||||
} from "./wireguard-config.js"
|
||||
|
||||
const sampleConf = `[Interface]
|
||||
PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
|
||||
Address = 10.210.0.1/30
|
||||
ListenPort = 13231
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
|
||||
AllowedIPs = 10.210.0.2/32, 192.168.20.0/24
|
||||
Endpoint = 10.0.1.1:13231
|
||||
PersistentKeepalive = 25
|
||||
`
|
||||
|
||||
const parsedConf = parseNativeConf(sampleConf)
|
||||
assert.equal(parsedConf.format, "conf")
|
||||
assert.equal(parsedConf.interface.listenPort, 13231)
|
||||
assert.equal(parsedConf.interface.address, "10.210.0.1/30")
|
||||
assert.equal(parsedConf.peers.length, 1)
|
||||
assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1")
|
||||
assert.equal(parsedConf.peers[0]?.endpointPort, 13231)
|
||||
assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"])
|
||||
|
||||
const roundConf = generateNativeConf({
|
||||
name: "wg0",
|
||||
listenPort: parsedConf.interface.listenPort ?? 13231,
|
||||
mtu: parsedConf.interface.mtu ?? 1420,
|
||||
privateKey: parsedConf.interface.privateKey,
|
||||
address: parsedConf.interface.address,
|
||||
peers: parsedConf.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedAddresses,
|
||||
endpoint: p.endpointAddress
|
||||
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
|
||||
: undefined,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
})),
|
||||
})
|
||||
const reparsed = parseNativeConf(roundConf)
|
||||
assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey)
|
||||
assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey)
|
||||
|
||||
const sampleRsc = `# WireGuard
|
||||
/interface wireguard add \\
|
||||
name=wg-msk-spb \\
|
||||
listen-port=13231 \\
|
||||
mtu=1420 \\
|
||||
comment="MSK → SPB"
|
||||
|
||||
/ip address add \\
|
||||
address=10.210.0.1/30 \\
|
||||
interface=wg-msk-spb
|
||||
|
||||
/interface wireguard peers add \\
|
||||
interface=wg-msk-spb \\
|
||||
public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\
|
||||
allowed-address=10.210.0.2/32,192.168.20.0/24 \\
|
||||
endpoint-address=10.0.1.1 \\
|
||||
endpoint-port=13231 \\
|
||||
persistent-keepalive=25
|
||||
`
|
||||
|
||||
assert.equal(detectWgConfigFormat(sampleRsc), "rsc")
|
||||
assert.equal(detectWgConfigFormat(sampleConf), "conf")
|
||||
|
||||
const parsedRsc = parseMikrotikRsc(sampleRsc)
|
||||
assert.equal(parsedRsc.interface.name, "wg-msk-spb")
|
||||
assert.equal(parsedRsc.interface.address, "10.210.0.1/30")
|
||||
assert.equal(parsedRsc.peers.length, 1)
|
||||
assert.equal(parsedRsc.peers[0]?.endpointPort, 13231)
|
||||
|
||||
const generatedRsc = generateMikrotikRsc({
|
||||
name: parsedRsc.interface.name,
|
||||
listenPort: parsedRsc.interface.listenPort ?? 13231,
|
||||
mtu: parsedRsc.interface.mtu ?? 1420,
|
||||
comment: parsedRsc.interface.comment,
|
||||
address: parsedRsc.interface.address,
|
||||
peers: parsedRsc.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedAddresses,
|
||||
endpoint: p.endpointAddress
|
||||
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
|
||||
: undefined,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
})),
|
||||
})
|
||||
const rscAgain = parseWgConfig(generatedRsc, "rsc")
|
||||
assert.equal(rscAgain.interface.name, "wg-msk-spb")
|
||||
assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey)
|
||||
|
||||
console.log("wireguard-config tests ok")
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* WireGuard config codecs: native .conf ↔ MikroTik .rsc
|
||||
*/
|
||||
|
||||
export type WgParsedPeer = {
|
||||
publicKey: string
|
||||
allowedAddresses: string[]
|
||||
endpointAddress?: string
|
||||
endpointPort?: number
|
||||
persistentKeepalive?: number
|
||||
comment?: string
|
||||
name?: string
|
||||
privateKey?: "auto" | "none" | string
|
||||
clientAddress?: string
|
||||
clientDns?: string
|
||||
clientEndpoint?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type WgParsedInterface = {
|
||||
name: string
|
||||
listenPort?: number
|
||||
mtu?: number
|
||||
privateKey?: string
|
||||
comment?: string
|
||||
address?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type WgParsedConfig = {
|
||||
format: "rsc" | "conf"
|
||||
interface: WgParsedInterface
|
||||
peers: WgParsedPeer[]
|
||||
}
|
||||
|
||||
export type WgExportIface = {
|
||||
name: string
|
||||
listenPort?: number
|
||||
mtu: number
|
||||
comment?: string
|
||||
enabled?: boolean
|
||||
privateKey?: string
|
||||
publicKey?: string
|
||||
address?: string
|
||||
serverName?: string
|
||||
peers: Array<{
|
||||
publicKey: string
|
||||
allowedIps: string[]
|
||||
endpoint?: string
|
||||
persistentKeepalive?: number
|
||||
persistent?: boolean
|
||||
comment?: string
|
||||
name?: string
|
||||
clientAddress?: string
|
||||
clientDns?: string
|
||||
clientEndpoint?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function stripQuotes(v: string): string {
|
||||
const t = v.trim()
|
||||
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
||||
return t.slice(1, -1)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
function parseKvLine(line: string): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
// Match key=value pairs; values may be quoted
|
||||
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
out[m[1]] = stripQuotes(m[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function joinContinuedLines(text: string): string[] {
|
||||
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
|
||||
const lines: string[] = []
|
||||
let buf = ""
|
||||
for (const line of raw) {
|
||||
const trimmedEnd = line.replace(/\s+$/, "")
|
||||
if (trimmedEnd.endsWith("\\")) {
|
||||
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
|
||||
continue
|
||||
}
|
||||
buf += trimmedEnd
|
||||
if (buf.trim()) lines.push(buf.trim())
|
||||
buf = ""
|
||||
}
|
||||
if (buf.trim()) lines.push(buf.trim())
|
||||
return lines
|
||||
}
|
||||
|
||||
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
|
||||
const t = content.trim()
|
||||
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
|
||||
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
|
||||
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
|
||||
return "rsc"
|
||||
}
|
||||
|
||||
export function parseNativeConf(content: string): WgParsedConfig {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n")
|
||||
let section: "interface" | "peer" | null = null
|
||||
const iface: WgParsedInterface = { name: "wg0" }
|
||||
const peers: WgParsedPeer[] = []
|
||||
let currentPeer: WgParsedPeer | null = null
|
||||
|
||||
const flushPeer = () => {
|
||||
if (currentPeer?.publicKey) peers.push(currentPeer)
|
||||
currentPeer = null
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith("#") || line.startsWith(";")) continue
|
||||
if (/^\[Interface\]$/i.test(line)) {
|
||||
flushPeer()
|
||||
section = "interface"
|
||||
continue
|
||||
}
|
||||
if (/^\[Peer\]$/i.test(line)) {
|
||||
flushPeer()
|
||||
section = "peer"
|
||||
currentPeer = { publicKey: "", allowedAddresses: [] }
|
||||
continue
|
||||
}
|
||||
const eq = line.indexOf("=")
|
||||
if (eq < 0) continue
|
||||
const key = line.slice(0, eq).trim().toLowerCase()
|
||||
const value = line.slice(eq + 1).trim()
|
||||
|
||||
if (section === "interface") {
|
||||
if (key === "privatekey") iface.privateKey = value
|
||||
else if (key === "address") iface.address = value.split(",")[0]?.trim()
|
||||
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
|
||||
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
|
||||
else if (key === "name") iface.name = value || iface.name
|
||||
} else if (section === "peer" && currentPeer) {
|
||||
if (key === "publickey") currentPeer.publicKey = value
|
||||
else if (key === "allowedips") {
|
||||
currentPeer.allowedAddresses = value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
} else if (key === "endpoint") {
|
||||
const lastColon = value.lastIndexOf(":")
|
||||
if (lastColon > 0 && !value.includes("]:")) {
|
||||
currentPeer.endpointAddress = value.slice(0, lastColon)
|
||||
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
|
||||
} else if (value.startsWith("[") && value.includes("]:")) {
|
||||
const idx = value.indexOf("]:")
|
||||
currentPeer.endpointAddress = value.slice(1, idx)
|
||||
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
|
||||
} else {
|
||||
currentPeer.endpointAddress = value
|
||||
}
|
||||
} else if (key === "persistentkeepalive") {
|
||||
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
|
||||
} else if (key === "presharedkey") {
|
||||
// ignore PSK for ROS import for now
|
||||
}
|
||||
}
|
||||
}
|
||||
flushPeer()
|
||||
|
||||
if (!iface.name) iface.name = "wg0"
|
||||
return { format: "conf", interface: iface, peers }
|
||||
}
|
||||
|
||||
export function parseMikrotikRsc(content: string): WgParsedConfig {
|
||||
const lines = joinContinuedLines(content)
|
||||
const iface: WgParsedInterface = { name: "wg0" }
|
||||
const peers: WgParsedPeer[] = []
|
||||
let foundIface = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#")) continue
|
||||
const lower = line.toLowerCase()
|
||||
|
||||
if (
|
||||
lower.startsWith("/interface wireguard add") ||
|
||||
lower.startsWith("/interface/wireguard add")
|
||||
) {
|
||||
const kv = parseKvLine(line)
|
||||
if (kv.name) iface.name = kv.name
|
||||
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
|
||||
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
|
||||
if (kv["private-key"]) iface.privateKey = kv["private-key"]
|
||||
if (kv.comment) iface.comment = kv.comment
|
||||
if (kv.disabled === "yes") iface.disabled = true
|
||||
foundIface = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
lower.startsWith("/interface wireguard peers add") ||
|
||||
lower.startsWith("/interface/wireguard/peers add")
|
||||
) {
|
||||
const kv = parseKvLine(line)
|
||||
const allowed = (kv["allowed-address"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
peers.push({
|
||||
publicKey: kv["public-key"] ?? "",
|
||||
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
|
||||
endpointAddress: kv["endpoint-address"],
|
||||
endpointPort: kv["endpoint-port"]
|
||||
? Number.parseInt(kv["endpoint-port"], 10) || undefined
|
||||
: undefined,
|
||||
persistentKeepalive: kv["persistent-keepalive"]
|
||||
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
|
||||
: undefined,
|
||||
comment: kv.comment,
|
||||
name: kv.name,
|
||||
clientAddress: kv["client-address"],
|
||||
clientDns: kv["client-dns"],
|
||||
clientEndpoint: kv["client-endpoint"],
|
||||
disabled: kv.disabled === "yes",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
|
||||
const kv = parseKvLine(line)
|
||||
if (kv.address) iface.address = kv.address
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundIface && peers.length === 0) {
|
||||
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
|
||||
}
|
||||
return { format: "rsc", interface: iface, peers }
|
||||
}
|
||||
|
||||
export function parseWgConfig(
|
||||
content: string,
|
||||
format: "auto" | "rsc" | "conf" = "auto",
|
||||
): WgParsedConfig {
|
||||
const detected = format === "auto" ? detectWgConfigFormat(content) : format
|
||||
if (detected === "conf") return parseNativeConf(content)
|
||||
return parseMikrotikRsc(content)
|
||||
}
|
||||
|
||||
export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`[Interface]`)
|
||||
if (opts?.includePrivateKey && iface.privateKey) {
|
||||
lines.push(`PrivateKey = ${iface.privateKey}`)
|
||||
} else if (iface.privateKey) {
|
||||
lines.push(`PrivateKey = ${iface.privateKey}`)
|
||||
} else {
|
||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||
}
|
||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
||||
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
|
||||
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||
lines.push(``)
|
||||
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`[Peer]`)
|
||||
lines.push(`PublicKey = ${p.publicKey}`)
|
||||
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
|
||||
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
|
||||
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
|
||||
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
|
||||
if (p.comment) lines.push(`# ${p.comment}`)
|
||||
lines.push(``)
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n"
|
||||
}
|
||||
|
||||
export function generatePeerClientConf(args: {
|
||||
peerPrivateKey?: string
|
||||
peerAddress?: string
|
||||
peerDns?: string
|
||||
serverPublicKey: string
|
||||
allowedIps?: string[]
|
||||
endpoint?: string
|
||||
persistentKeepalive?: number
|
||||
}): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`[Interface]`)
|
||||
lines.push(
|
||||
args.peerPrivateKey
|
||||
? `PrivateKey = ${args.peerPrivateKey}`
|
||||
: `# PrivateKey = <ключ клиента>`,
|
||||
)
|
||||
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
|
||||
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
|
||||
lines.push(``)
|
||||
lines.push(`[Peer]`)
|
||||
lines.push(`PublicKey = ${args.serverPublicKey}`)
|
||||
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
|
||||
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
|
||||
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
|
||||
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
|
||||
}
|
||||
lines.push(``)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function generateMikrotikRsc(iface: WgExportIface): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
|
||||
lines.push(`# RouterOS 7.x · MikrotikManager`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface wireguard add \\`)
|
||||
lines.push(` name=${iface.name} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
|
||||
lines.push(` mtu=${iface.mtu} \\`)
|
||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||
if (iface.enabled === false) lines.push(` disabled=yes \\`)
|
||||
// remove trailing backslash on last iface param by rewriting last line
|
||||
if (lines[lines.length - 1]?.endsWith(" \\")) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
|
||||
}
|
||||
lines.push(``)
|
||||
|
||||
if (iface.address) {
|
||||
lines.push(`/ip address add \\`)
|
||||
lines.push(` address=${iface.address} \\`)
|
||||
lines.push(` interface=${iface.name}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`/interface wireguard peers add \\`)
|
||||
lines.push(` interface=${iface.name} \\`)
|
||||
lines.push(` public-key="${p.publicKey}" \\`)
|
||||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
||||
if (p.endpoint) {
|
||||
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
|
||||
const port = p.endpoint.includes(":")
|
||||
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
|
||||
: "13231"
|
||||
lines.push(` endpoint-address=${host} \\`)
|
||||
lines.push(` endpoint-port=${port} \\`)
|
||||
}
|
||||
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
|
||||
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
|
||||
if (p.name) lines.push(` name=${p.name} \\`)
|
||||
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
|
||||
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
|
||||
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
|
||||
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
|
||||
if (lines[lines.length - 1]?.endsWith(" \\")) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
interface RosWireGuard {
|
||||
".id"?: string
|
||||
name?: string
|
||||
"listen-port"?: string
|
||||
mtu?: string
|
||||
"public-key"?: string
|
||||
"private-key"?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
interface RosWireGuardPeer {
|
||||
".id"?: string
|
||||
interface?: string
|
||||
name?: string
|
||||
"public-key"?: string
|
||||
"endpoint-address"?: string
|
||||
"endpoint-port"?: string
|
||||
"allowed-address"?: string
|
||||
"last-handshake"?: string
|
||||
rx?: string
|
||||
tx?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
"persistent-keepalive"?: string
|
||||
"client-address"?: string
|
||||
"client-dns"?: string
|
||||
"client-endpoint"?: string
|
||||
}
|
||||
|
||||
interface RosIpAddress {
|
||||
".id"?: string
|
||||
address?: string
|
||||
interface?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
function parseBytes(v: string | undefined): number | undefined {
|
||||
if (v == null || v === "") return undefined
|
||||
const n = Number.parseInt(v, 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
|
||||
const rosId = String(p[".id"] ?? `peer-${idx}`)
|
||||
const allowed = (p["allowed-address"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
const epAddr = (p["endpoint-address"] ?? "").trim()
|
||||
const epPort = (p["endpoint-port"] ?? "").trim()
|
||||
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
|
||||
const ka = p["persistent-keepalive"]
|
||||
? Number.parseInt(p["persistent-keepalive"], 10)
|
||||
: undefined
|
||||
return {
|
||||
id: rosId,
|
||||
rosId,
|
||||
publicKey: p["public-key"] ?? "",
|
||||
allowedIps: allowed,
|
||||
endpoint,
|
||||
latestHandshake: p["last-handshake"]?.trim() || undefined,
|
||||
transferRx: parseBytes(p.rx),
|
||||
transferTx: parseBytes(p.tx),
|
||||
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
|
||||
persistent: Number.isFinite(ka) && (ka as number) > 0,
|
||||
comment: p.comment ?? undefined,
|
||||
disabled: p.disabled === "true" || p.disabled === "yes",
|
||||
name: p.name,
|
||||
clientAddress: p["client-address"],
|
||||
clientDns: p["client-dns"],
|
||||
clientEndpoint: p["client-endpoint"],
|
||||
}
|
||||
}
|
||||
|
||||
function mapIface(
|
||||
server: ServerRow,
|
||||
w: RosWireGuard,
|
||||
peers: WgPeerDto[],
|
||||
address: string | undefined,
|
||||
includePrivateKey: boolean,
|
||||
): WgIfaceDto {
|
||||
const rosId = String(w[".id"] ?? w.name ?? "wg")
|
||||
const name = (w.name ?? "").trim() || rosId
|
||||
const disabled = w.disabled === "true" || w.disabled === "yes"
|
||||
const running = w.running === "true" || w.running === "yes"
|
||||
return {
|
||||
id: `${server.id}:${rosId}`,
|
||||
rosId,
|
||||
name,
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
serverCountry: server.country ?? undefined,
|
||||
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
|
||||
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
|
||||
publicKey: w["public-key"] || undefined,
|
||||
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
|
||||
address,
|
||||
peers,
|
||||
comment: w.comment ?? "",
|
||||
enabled: !disabled,
|
||||
status: disabled ? "down" : running ? "up" : "down",
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchForServer(
|
||||
server: ServerRow,
|
||||
includePrivateKey: boolean,
|
||||
): Promise<WgIfaceDto[]> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
|
||||
client.get<RosWireGuard[]>("/interface/wireguard"),
|
||||
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
|
||||
])
|
||||
|
||||
const peersByIface = new Map<string, WgPeerDto[]>()
|
||||
peersRaw.forEach((p, idx) => {
|
||||
const ifaceName = (p.interface ?? "").trim()
|
||||
if (!ifaceName) return
|
||||
const list = peersByIface.get(ifaceName) ?? []
|
||||
list.push(mapPeer(p, idx))
|
||||
peersByIface.set(ifaceName, list)
|
||||
})
|
||||
|
||||
const addrByIface = new Map<string, string>()
|
||||
for (const a of addrsRaw) {
|
||||
if (a.disabled === "true" || a.disabled === "yes") continue
|
||||
const iface = (a.interface ?? "").trim()
|
||||
const addr = (a.address ?? "").trim()
|
||||
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
|
||||
}
|
||||
|
||||
return ifacesRaw.map((w) => {
|
||||
const name = (w.name ?? "").trim()
|
||||
return mapIface(
|
||||
server,
|
||||
w,
|
||||
peersByIface.get(name) ?? [],
|
||||
addrByIface.get(name),
|
||||
includePrivateKey,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export type WgListResult = {
|
||||
interfaces: WgIfaceDto[]
|
||||
failures: Array<{ serverId: string; serverName?: string; error: string }>
|
||||
}
|
||||
|
||||
export async function listWireGuardInterfaces(opts?: {
|
||||
serverId?: string
|
||||
includePrivateKey?: boolean
|
||||
}): Promise<WgListResult> {
|
||||
const includePrivateKey = opts?.includePrivateKey === true
|
||||
let serverRows: ServerRow[]
|
||||
if (opts?.serverId) {
|
||||
const id = Number.parseInt(String(opts.serverId), 10)
|
||||
if (!Number.isFinite(id)) {
|
||||
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
|
||||
}
|
||||
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
|
||||
serverRows = row ? [row] : []
|
||||
} else {
|
||||
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
}
|
||||
|
||||
const failures: WgListResult["failures"] = []
|
||||
const results = await Promise.all(
|
||||
serverRows.map(async (server) => {
|
||||
try {
|
||||
return await fetchForServer(server, includePrivateKey)
|
||||
} catch (e) {
|
||||
failures.push({
|
||||
serverId: String(server.id),
|
||||
serverName: server.name ?? undefined,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
return [] as WgIfaceDto[]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { interfaces: results.flat(), failures }
|
||||
}
|
||||
|
||||
export async function countWireGuardInterfaces(): Promise<number> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
listWireGuardInterfaces({ includePrivateKey: false }),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
||||
])
|
||||
if (!result) return 0
|
||||
return result.interfaces.length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnabledServerById(serverId: string | number): ServerRow | null {
|
||||
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
|
||||
if (!Number.isFinite(id)) return null
|
||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||
}
|
||||
|
||||
export type CatalogWgPeer = {
|
||||
interfaceName: string
|
||||
publicKey: string
|
||||
name: string
|
||||
comment: string
|
||||
allowedIps: string[]
|
||||
latestHandshake?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const WG_CATALOG_TIMEOUT_MS = 5_000
|
||||
|
||||
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
|
||||
peers: CatalogWgPeer[]
|
||||
error?: string
|
||||
}> {
|
||||
const row = getEnabledServerById(serverId)
|
||||
if (!row) return { peers: [], error: "Сервер не найден" }
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const peersRaw = await Promise.race([
|
||||
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
|
||||
const mapped = mapPeer(p, idx)
|
||||
const interfaceName = (p.interface ?? "").trim()
|
||||
const publicKey = mapped.publicKey.trim()
|
||||
if (!interfaceName || !publicKey) return []
|
||||
return [{
|
||||
interfaceName,
|
||||
publicKey,
|
||||
name: mapped.name ?? "",
|
||||
comment: mapped.comment ?? "",
|
||||
allowedIps: mapped.allowedIps,
|
||||
latestHandshake: mapped.latestHandshake,
|
||||
disabled: mapped.disabled === true,
|
||||
}]
|
||||
})
|
||||
return { peers }
|
||||
} catch (e) {
|
||||
return { peers: [], error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export { type RosWireGuard, type RosWireGuardPeer }
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
|
||||
export function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function asRosArray<T>(raw: unknown): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[]
|
||||
if (raw && typeof raw === "object") return [raw as T]
|
||||
return []
|
||||
}
|
||||
|
||||
export function rosRowId(row: Record<string, unknown>): string {
|
||||
return String(row[".id"] ?? row.id ?? "")
|
||||
}
|
||||
|
||||
export async function putWireguardInterface(
|
||||
client: MikrotikClient,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.put("/interface/wireguard", toRosBody(fields))
|
||||
}
|
||||
|
||||
export async function putIpAddress(
|
||||
client: MikrotikClient,
|
||||
address: string,
|
||||
iface: string,
|
||||
): Promise<void> {
|
||||
await client.put("/ip/address", { address, interface: iface })
|
||||
}
|
||||
|
||||
export async function putWireguardPeer(
|
||||
client: MikrotikClient,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.put("/interface/wireguard/peers", toRosBody(fields))
|
||||
}
|
||||
|
||||
export async function patchRosPath(
|
||||
client: MikrotikClient,
|
||||
path: string,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.patch(path, toRosBody(fields))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user