feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s

Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-06 21:54:30 +07:00
co-authored by Cursor
parent 5884bd8873
commit 1e9312acbd
24 changed files with 2007 additions and 71 deletions
@@ -0,0 +1,104 @@
"use client"
import { useMemo } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { fmtRate } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
function formatBytes(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
return `${n} Б`
}
function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
() => [
{
id: "server",
accessorKey: "serverName",
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "src",
accessorKey: "src",
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.src}
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.dst}
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "proto",
accessorKey: "protoName",
header: () => <span className="text-xs font-medium text-muted-foreground">Proto</span>,
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rate",
accessorFn: (r) => r.bps,
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bytes",
accessorKey: "bytes",
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "iface",
accessorKey: "inIface",
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage="Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
/>
)
}
export { TrafficFlowsDataGrid }
+119
View File
@@ -0,0 +1,119 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { CopyIcon } from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
function FlowOverlaySheet({
open,
onOpenChange,
servers,
backendUrl,
onDone,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerRead[]
backendUrl: string
onDone?: (result: TrafficFlowOverlayResult) => void
}) {
const jumpHosts = useMemo(
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
[servers],
)
const [serverId, setServerId] = useState("")
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
useEffect(() => {
if (!open) return
setResult(null)
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
}, [open, jumpHosts])
async function handleSubmit() {
if (!serverId) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId)
setResult(res)
toast.success(`wg-flow на ${res.address}`)
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
} finally {
setBusy(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Подключить jump-host</SheetTitle>
<SheetDescription>
Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard.
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<FormField label="Jump-host" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{jumpHosts.map((s) => (
<option key={s.id} value={s.id}>
{s.name || s.host} ({s.host})
</option>
))}
</select>
</FormField>
{result ? (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Пир для хоста MM (`wg set` или допишите conf):</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(result.linuxPeerBlock)
toast.success("Скопировано")
}}
>
<CopyIcon className="size-3.5" />
Копировать
</Button>
</div>
<pre className="text-[11px] font-mono bg-muted/40 border rounded-md p-3 whitespace-pre-wrap">{result.linuxPeerBlock}</pre>
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
{result.steps.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
) : null}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
<Button disabled={!serverId || busy} onClick={() => { void handleSubmit() }}>
{busy ? "Подключение…" : "Подключить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { FlowOverlaySheet }
@@ -0,0 +1,224 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { FormField, FormToggle } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
getTrafficFlowSettings,
putTrafficFlowSettings,
} from "@/shared/api/traffic-flow"
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
const HOST_STEPS = [
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
function NetflowSettingsPanel({
backendUrl,
enabled,
}: {
backendUrl: string
enabled: boolean
}) {
const [settings, setSettings] = useState<TrafficFlowSettingsDto | null>(null)
const [busy, setBusy] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [formats, setFormats] = useState<CodeExportFormat[]>([])
const [collectorIp, setCollectorIp] = useState("10.255.254.1")
const [flowPort, setFlowPort] = useState("4739")
const [wgPort, setWgPort] = useState("51821")
const [prefix, setPrefix] = useState("10.255.254.0/24")
const [endpoint, setEndpoint] = useState("")
const [retention, setRetention] = useState("24")
const [topN, setTopN] = useState("200")
const [ingestOn, setIngestOn] = useState(false)
const load = useCallback(async () => {
if (!enabled) return
const s = await getTrafficFlowSettings(backendUrl)
setSettings(s)
setCollectorIp(s.collectorIp)
setFlowPort(String(s.flowListenPort))
setWgPort(String(s.wgListenPort))
setPrefix(s.prefix)
setEndpoint(s.publicEndpoint)
setRetention(String(s.retentionHours))
setTopN(String(s.topN))
setIngestOn(s.enabled)
}, [backendUrl, enabled])
useEffect(() => {
void load().catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow")
})
}, [load])
async function handleSave() {
setBusy(true)
try {
const res = await putTrafficFlowSettings(backendUrl, {
enabled: ingestOn,
collectorIp,
flowListenPort: Number.parseInt(flowPort, 10) || 4739,
wgListenPort: Number.parseInt(wgPort, 10) || 51821,
prefix,
publicEndpoint: endpoint,
retentionHours: Number.parseInt(retention, 10) || 24,
topN: Number.parseInt(topN, 10) || 200,
})
setSettings(res.settings)
toast.success("Настройки NetFlow сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setBusy(false)
}
}
async function handleKeys() {
setBusy(true)
try {
const res = await generateTrafficFlowKeys(backendUrl)
setSettings(res.settings)
toast.success(res.created ? "Ключи хоста созданы" : "Ключи уже есть")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сгенерировать ключи")
} finally {
setBusy(false)
}
}
async function handleExport() {
setBusy(true)
try {
const res = await getTrafficFlowHostFiles(backendUrl)
setFormats(res.files.map((f) => ({
id: f.id,
label: f.label,
filename: f.filename,
code: f.code,
})))
setExportOpen(true)
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось получить файлы")
} finally {
setBusy(false)
}
}
return (
<>
<OpsPanel
title="Traffic Flow / NetFlow (IPFIX)"
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM. Preview: https://reui.io/preview/base/settings-16"
headerRight={
<div className="flex items-center gap-2">
{settings?.listenerBound ? (
<Badge variant="success">listener {settings.listenerAddress}</Badge>
) : (
<Badge variant="secondary">listener выкл</Badge>
)}
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<Alert>
<InfoIcon />
<AlertTitle>Ключи и UDP 4739</AlertTitle>
<AlertDescription>
Приватный ключ хранится в SQLite панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
</AlertDescription>
</Alert>
<div className="flex items-center gap-3">
<FormToggle checked={ingestOn} onChange={setIngestOn} />
<span className="text-sm">Принимать IPFIX</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
<Input className="font-mono" value={collectorIp} onChange={(e) => setCollectorIp(e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
</FormField>
<FormField label="UDP IPFIX">
<Input className="font-mono" value={flowPort} onChange={(e) => setFlowPort(e.target.value)} />
</FormField>
<FormField label="WG listen">
<Input className="font-mono" value={wgPort} onChange={(e) => setWgPort(e.target.value)} />
</FormField>
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH" required>
<Input className="font-mono" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="203.0.113.10" />
</FormField>
<FormField label="Public key хоста">
<Input className="font-mono text-xs" readOnly value={settings?.hostPublicKey || "— сгенерируйте ключи —"} />
</FormField>
<FormField label="Хранение (часов)">
<Input value={retention} onChange={(e) => setRetention(e.target.value)} inputMode="numeric" />
</FormField>
<FormField label="Top-N разговоров">
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Last datagram:{" "}
{settings?.lastDatagramAt
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
: "—"}
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
{settings?.lastError ? ` · ${settings.lastError}` : ""}
</p>
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
Сохранить NetFlow
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
<KeyRoundIcon className="size-4" />
Ключи хоста
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
<DownloadIcon className="size-4" />
wg-quick / compose / firewall
</Button>
</div>
</OpsPanel>
<CodeExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
title="Файлы для хоста Docker MM"
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
formats={formats}
/>
</>
)
}
export { NetflowSettingsPanel }