Compare commits

..
17 Commits
Author SHA1 Message Date
DenozordecandCursor 6332d83a12 feat(traffic): показать поток NetFlow на карте сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m14s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Скорость между узлами считается как в Трафике (5 мин, без overlay/mesh), отдельно от ёмкости WAN и BT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:37:47 +07:00
DenozordecandCursor 3834c40aa8 fix(traffic): починить сборку tsc для частичных IPFIX-фикстур
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m50s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 11s
Тесты не должны попадать в Docker tsc; ingest принимает ParsedFlowInput и нормализует поля.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:45:22 +07:00
DenozordecandCursor 90c8c393e5 feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:39:14 +07:00
DenozordecandCursor cb799da13a fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 57s
Docker images / publish-release (push) Successful in 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:50:24 +07:00
DenozordecandCursor e0ddb17539 feat(traffic): классифицировать потоки по брендам и разгрузить ingest
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 1m48s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 03:01:14 +07:00
DenozordecandCursor 2820683cba feat(traffic): добавить карту и срезы IPFIX по странам и сессиям
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 14s
Имена ifIndex пишутся по обоим ключам REST, дедуп 5-tuple по max байт, RIPEstat только из prefix-кэша, карта откуда-куда в Frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 02:13:21 +07:00
DenozordecandCursor 37167f78e3 feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 01:21:12 +07:00
DenozordecandCursor cf68b59b3f fix(traffic): сбрасывать src-address Traffic Flow в авто
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m2s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Target с адресом wg-flow помечался invalid, IPFIX не уходил на коллектор.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 00:37:12 +07:00
DenozordecandCursor 5e512407e5 fix(traffic): записывать потоки при подмене UDP-источника в Docker
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Пакеты IPFIX доходили, но разговоры отбрасывались, если Docker подменял адрес jump-host на 172.x.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 00:02:50 +07:00
DenozordecandCursor 13889005f8 refactor(traffic): update traffic flow host file generation and configuration scripts
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`.
- Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts.
- Updated the `generateNativeConf` function to conditionally include the listen port in the configuration.
- Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 23:20:12 +07:00
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 9s
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 56s
Docker images / frontend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 16s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:16:41 +07:00
DenozordecandCursor 1e9312acbd 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 <cursoragent@cursor.com>
2026-09-06 21:54:30 +07:00
Denozordec 5884bd8873 feat(traffic, users): enhance interface and peer management features
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
2026-09-06 20:45:37 +07:00
Denozordec fc161506e7 feat(users): enhance interface type mapping and parsing logic
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m30s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 8s
Updated the mapRosInterfaceType function to support additional interface types, including "gre-tunnel" and improved handling for various input formats. Enhanced the parseRawInterfaces function to accommodate new interface types in the test cases, ensuring accurate parsing and type assignment. Added corresponding test cases to validate the new functionality and ensure robustness in interface type handling.
2026-09-06 19:35:01 +07:00
Denozordec b3e50a1f5f feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
2026-09-06 19:20:05 +07:00
96 changed files with 13121 additions and 1289 deletions
+3
View File
@@ -54,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,
@@ -1210,6 +1211,8 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
+172 -7
View File
@@ -41,6 +41,15 @@ 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 } from "@mmapp/contracts/traffic-flow"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
@@ -420,6 +429,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]
@@ -784,6 +840,7 @@ export default function NetworkMapPage() {
const [mapServers, setMapServers] = useState<Server[]>([])
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -935,11 +992,31 @@ 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 [showAnimDots, setShowAnimDots] = useState(true)
const [showMinimap, setShowMinimap] = useState(true)
const [showHints, setShowHints] = useState(false)
const [showLayers, setShowLayers] = useState(false)
useEffect(() => {
if (!useLiveData || !showNetflow) {
queueMicrotask(() => setMapHops([]))
return
}
let cancelled = false
const tick = () => {
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m")
.then((res) => { if (!cancelled) setMapHops(res.hops ?? []) })
.catch(() => { if (!cancelled) setMapHops([]) })
}
tick()
const id = window.setInterval(tick, 4000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [useLiveData, showNetflow, apiFetch])
const effectiveSatPos = useMemo(() => {
const out: Record<string, { x: number; y: number }[]> = {}
mapServers
@@ -1100,6 +1177,32 @@ 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 nodes = mapServers
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
// Визуальный приоритет: HR поверх JH, JH поверх EN.
@@ -1461,6 +1564,7 @@ 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: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1588,6 +1692,15 @@ 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)
@@ -1598,7 +1711,7 @@ export default function NetworkMapPage() {
<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 +1745,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 +1784,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 +1833,14 @@ 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>
)
})}
@@ -1976,7 +2107,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 +2115,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>
@@ -2168,12 +2312,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 +2388,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 +2432,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>
)}
+50 -802
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle } from "@/components/form-kit"
import { FormToggle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -12,10 +12,6 @@ import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
import { Separator } from "@/components/ui/separator"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
@@ -27,119 +23,21 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data"
import {
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
AlertCircleIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { toast } from "sonner"
// ─── types ────────────────────────────────────────────────────────────────────
type Role = "admin" | "operator" | "viewer"
type PermLevel = "none" | "read" | "write"
interface SectionPerm { section: string; level: PermLevel }
interface ServerPerm { serverId: string; level: PermLevel }
interface User {
id: string; name: string; login: string; email: string
role: Role; last: string; avatar: string; active: boolean
sections: SectionPerm[]; servers: ServerPerm[]
subUsers: SubUser[]
}
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
// Подчинённый пользователь — учётка для подключения устройства к JH через GRE
interface SubUser {
id: string
login: string
password: string
description: string // "офисный роутер", "склад", и т.п.
jhServerIds: string[] // на каких JH зарегистрирована учётка
clientIp: string // назначенный IP внутри туннеля
active: boolean
lastSeen: string | null
}
// ─── section definitions ──────────────────────────────────────────────────────
const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [
{ group: "Обзор", icon: <LayoutDashboardIcon className="size-3" />, items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
]
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
// ─── default permissions by role ──────────────────────────────────────────────
function defaultSections(role: Role): SectionPerm[] {
return ALL_SECTIONS.map(section => {
let level: PermLevel = "none"
if (role === "admin") level = "write"
else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read"
else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read"
return { section, level }
})
}
function defaultServers(role: Role): ServerPerm[] {
return servers.map(s => ({
serverId: s.id,
level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel,
}))
}
// ─── initial data ──────────────────────────────────────────────────────────────
const INIT_USERS: User[] = [
{
id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io",
role: "admin", last: "сейчас", avatar: "АК", active: true,
sections: defaultSections("admin"), servers: defaultServers("admin"),
subUsers: [
{ id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" },
{ id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" },
{ id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" },
],
},
{
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io",
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" },
],
},
{
id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io",
role: "viewer", last: "вчера", avatar: "МС", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
subUsers: [],
},
{
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io",
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null },
],
},
]
const INIT_API_KEYS: ApiKey[] = [
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
@@ -147,26 +45,7 @@ const INIT_API_KEYS: ApiKey[] = [
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
]
const ROLE_LABEL: Record<Role, string> = { admin: "Администратор", operator: "Оператор", viewer: "Наблюдатель" }
const ROLE_COLOR: Record<Role, string> = {
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
viewer: "bg-muted text-muted-foreground border border-border",
}
const PERM_OPTS: { v: PermLevel; label: string }[] = [
{ v: "none", label: "Нет" },
{ v: "read", label: "Просмотр" },
{ v: "write", label: "Управление"},
]
const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-success/10 text-success",
}
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number]
function makeApiFetch(backendUrl: string) {
@@ -175,8 +54,6 @@ function makeApiFetch(backendUrl: string) {
}
}
// ─── small components ─────────────────────────────────────────────────────────
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5">
@@ -189,512 +66,6 @@ function SettingRow({ label, description, children }: { label: string; descripti
)
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
>{o.label}</button>
))}
</div>
)
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div className="relative shrink-0">
<div className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold",
"bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}>{avatar}</div>
{!active && (
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-muted-foreground/50 border-2 border-background" />
)}
</div>
)
}
// ─── user sheet ───────────────────────────────────────────────────────────────
interface UserForm {
name: string; login: string; email: string; role: Role; active: boolean
sections: SectionPerm[]; servers: ServerPerm[]
subUsers: SubUser[]
}
function emptyForm(): UserForm {
return { name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [] }
}
function genPassword(): string {
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%"
return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join("")
}
function genSubLogin(userLogin: string, existing: SubUser[]): string {
const base = `gre-${userLogin.split(".").pop() ?? "client"}`
const n = existing.filter(s => s.login.startsWith(base)).length
return n === 0 ? base : `${base}-${String(n + 1).padStart(2, "0")}`
}
function UserSheet({ open, user, onSave, onClose }: {
open: boolean
user: User | null // null = create
onSave: (f: UserForm) => void
onClose: () => void
}) {
const isCreate = user === null
const [tab, setTab] = useState<"profile" | "sections" | "servers" | "subusers">("profile")
const [form, setForm] = useState<UserForm>(() =>
user ? {
name: user.name, login: user.login, email: user.email, role: user.role,
active: user.active,
sections: user.sections.map(s => ({ ...s })),
servers: user.servers.map(s => ({ ...s })),
subUsers: user.subUsers.map(s => ({ ...s })),
} : emptyForm()
)
// sub-user add form state
const [addSubOpen, setAddSubOpen] = useState(false)
const [newSubLogin, setNewSubLogin] = useState("")
const [newSubPwd, setNewSubPwd] = useState("")
const [newSubDesc, setNewSubDesc] = useState("")
const [newSubJhs, setNewSubJhs] = useState<string[]>([])
const [newSubIp, setNewSubIp] = useState("")
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
const [errors, setErrors] = useState<Partial<Record<keyof UserForm, string>>>({})
const setField = <K extends keyof UserForm>(k: K, v: UserForm[K]) => {
setForm(f => ({ ...f, [k]: v }))
if (errors[k]) setErrors(e => ({ ...e, [k]: undefined }))
}
// When role changes → reset permissions to defaults
const setRole = (role: Role) => {
setForm(f => ({ ...f, role, sections: defaultSections(role), servers: defaultServers(role) }))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm(f => ({
...f,
sections: f.sections.map(s => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm(f => ({
...f,
servers: f.servers.map(s => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm(f => ({ ...f, sections: f.sections.map(s => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm(f => ({ ...f, servers: f.servers.map(s => ({ ...s, level })) }))
}
const validate = () => {
const e: Partial<Record<keyof UserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!form.login.trim()) e.login = "Обязательное поле"
if (!form.email.trim()) e.email = "Обязательное поле"
setErrors(e)
return !Object.keys(e).length
}
const handleSave = () => { if (validate()) onSave(form) }
const isAdmin = form.role === "admin"
const jhServers = servers.filter(s => s.type === "jump-host" && s.enabled)
const addSubUser = () => {
if (!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0) return
const sub: SubUser = {
id: `su${Date.now()}`,
login: newSubLogin.trim(),
password: newSubPwd,
description: newSubDesc.trim(),
jhServerIds: [...newSubJhs],
clientIp: newSubIp.trim(),
active: true,
lastSeen: null,
}
setForm(f => ({ ...f, subUsers: [...f.subUsers, sub] }))
setAddSubOpen(false)
setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([])
}
const toggleNewSubJh = (id: string) =>
setNewSubJhs(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
const removeSubUser = (id: string) =>
setForm(f => ({ ...f, subUsers: f.subUsers.filter(s => s.id !== id) }))
const toggleSubUser = (id: string) =>
setForm(f => ({ ...f, subUsers: f.subUsers.map(s => s.id === id ? { ...s, active: !s.active } : s) }))
const toggleReveal = (id: string) =>
setRevealedIds(prev => {
const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next
})
const TABS = [
{ id: "profile", label: "Профиль" },
{ id: "sections", label: "Разделы" },
{ id: "servers", label: "Серверы" },
{ id: "subusers", label: "GRE-клиенты", badge: form.subUsers.length || undefined },
] as const
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="sm:max-w-[520px] p-0 flex flex-col" showCloseButton={false}>
{/* header */}
<SheetHeader className="px-5 pt-5 pb-0 border-b shrink-0">
<div className="flex items-start justify-between gap-2 pb-4">
<div className="flex items-center gap-3">
{!isCreate && user && (
<AvatarCircle avatar={user.avatar} active={form.active} />
)}
<div>
<SheetTitle className="text-base leading-tight">
{isCreate ? "Новый пользователь" : form.name || "—"}
</SheetTitle>
<SheetDescription className="text-xs mt-0.5">
{isCreate ? "Заполните данные и настройте доступ" : `@${form.login}`}
</SheetDescription>
</div>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5">
<XIcon className="size-4" />
</Button>
</div>
{/* tab bar */}
<div className="flex gap-0 -mb-px">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id as typeof tab)}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5",
tab === t.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{t.label}
{"badge" in t && t.badge !== undefined && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full tabular-nums",
tab === t.id
? "bg-foreground/10"
: "bg-muted text-muted-foreground",
)}>{t.badge}</span>
)}
</button>
))}
</div>
</SheetHeader>
{/* body */}
<div className="flex-1 overflow-y-auto">
{/* ── Profile tab ── */}
{tab === "profile" && (
<div className="px-5 py-5 flex flex-col gap-4">
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</FormField>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</FormField>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</FormField>
<Separator />
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
className={cn(
"px-3 py-1.5 rounded-md border text-xs font-medium transition-all",
form.role === r
? r === "admin" ? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30 ring-1 ring-violet-500/30"
: r === "operator" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30 ring-1 ring-sky-500/30"
: "bg-muted text-foreground border-border ring-1 ring-border"
: "border-border text-muted-foreground hover:bg-muted",
)}>
{ROLE_LABEL[r]}
</button>
))}
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
{form.role === "admin"
? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав"
: form.role === "operator"
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</FormField>
<Separator />
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</FormField>
</div>
)}
{/* ── Sections tab ── */}
{tab === "sections" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUPS.map(group => (
<div key={group.group}>
{/* group header */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{group.icon}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
{/* section rows */}
<div className="divide-y divide-border/60">
{group.items.map(section => {
const perm = form.sections.find(s => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between px-4 py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={v => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</div>
)}
{/* ── Servers tab ── */}
{tab === "servers" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
<div className="divide-y divide-border/60">
{servers.map(srv => {
const perm = form.servers.find(s => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<StatusDot status={srv.status} />
{srv.country && <Flag code={srv.country} size={14} />}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={v => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
)}
{/* ── GRE-клиенты tab ── */}
{tab === "subusers" && (
<div className="flex flex-col">
{/* hint */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<CableIcon className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-[11px] text-muted-foreground">
Учётные записи для подключения устройств к JH через GRE
</p>
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
<div className="border-t bg-muted/20 px-4 py-4 flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground">Новый GRE-клиент</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Логин</label>
<Input className="h-8 text-xs font-mono" placeholder="gre-office-msk"
value={newSubLogin} onChange={e => setNewSubLogin(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Описание</label>
<Input className="h-8 text-xs" placeholder="Офис MSK"
value={newSubDesc} onChange={e => setNewSubDesc(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Пароль</label>
<div className="flex gap-1.5">
<Input className="h-8 text-xs font-mono flex-1" placeholder="••••••••"
value={newSubPwd} onChange={e => setNewSubPwd(e.target.value)} />
<Button variant="outline" size="sm" className="h-8 px-2 shrink-0"
onClick={() => setNewSubPwd(genPassword())} title="Сгенерировать пароль">
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">
JH-серверы
<span className="ml-1 text-muted-foreground/50">(можно выбрать несколько)</span>
</label>
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5">
{jhServers.map(s => {
const checked = newSubJhs.includes(s.id)
return (
<label key={s.id}
className="flex items-center gap-2 py-0.5 cursor-pointer hover:text-foreground transition-colors">
<input type="checkbox" checked={checked}
onChange={() => toggleNewSubJh(s.id)}
className="rounded border-input accent-primary" />
<Flag code={s.country} size={12} />
<span className="font-mono text-xs">{s.name}</span>
<span className="text-[10px] text-muted-foreground">{s.site}</span>
</label>
)
})}
</div>
{newSubJhs.length === 0 && (
<p className="text-[10px] text-destructive">Выберите хотя бы один JH-сервер</p>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">IP-клиента</label>
<Input className="h-8 text-xs font-mono" placeholder="10.210.0.18"
value={newSubIp} onChange={e => setNewSubIp(e.target.value)} />
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => { setAddSubOpen(false); setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) }}>
Отмена
</Button>
<Button size="sm" className="flex-1"
disabled={!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0}
onClick={addSubUser}>
<PlusIcon className="size-3.5" />Добавить
</Button>
</div>
</div>
) : (
<button
onClick={() => {
setNewSubLogin(genSubLogin(form.login, form.subUsers))
setNewSubPwd(genPassword())
setNewSubJhs(jhServers.length > 0 ? [jhServers[0].id] : [])
setAddSubOpen(true)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />Добавить GRE-клиента
</button>
)}
</div>
)}
</div>
{/* footer */}
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1">
<CheckIcon className="size-4" />
{isCreate ? "Создать" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
open,
filename,
@@ -737,41 +108,6 @@ function DatabaseRestoreConfirm({
)
}
function DeleteConfirm({
open,
user,
onConfirm,
onCancel,
}: {
open: boolean
user: User
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{user.name} · @{user.login}. Это действие нельзя отменить.
Пользователь потеряет доступ немедленно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
@@ -814,6 +150,8 @@ export default function SettingsPage() {
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
@@ -824,12 +162,6 @@ export default function SettingsPage() {
const [notifBgp, setNotifBgp] = useState(true)
const [notifBackup, setNotifBackup] = useState(false)
// users
const [users, setUsers] = useState<User[]>(INIT_USERS)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null) // null = create mode
const [deleteTarget, setDeleteTarget]= useState<User | null>(null)
// api keys
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
@@ -851,22 +183,6 @@ export default function SettingsPage() {
setTimeout(() => setSaved(false), 2000)
}, [])
const handleUserSave = (form: UserForm) => {
if (!editUser) {
const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase()
setUsers(prev => [...prev, {
id: "u" + Date.now(), ...form,
last: "только что", avatar: initials || "??",
}])
} else {
setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form } : u))
}
setSheetOpen(false)
}
// total sub-users count for summary
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
@@ -954,6 +270,20 @@ export default function SettingsPage() {
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
const handleNetflowPurgeConfirm = useCallback(async () => {
if (!systemDbAvailable) return
setDbPurgeBusy(true)
try {
const result = await purgeTrafficFlowData(backendUrl)
toast.success(formatFlowPurgeResult(result))
setDbPurgeOpen(false)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
} finally {
setDbPurgeBusy(false)
}
}, [backendUrl, systemDbAvailable])
const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
@@ -1105,7 +435,7 @@ export default function SettingsPage() {
<OpsPanel
title="База данных приложения"
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
description="Резервная копия SQLite бекенда и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
contentClassName="divide-y px-5"
>
{!systemDbAvailable && (
@@ -1122,7 +452,7 @@ export default function SettingsPage() {
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => { void handleSystemDatabaseBackup() }}
>
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
@@ -1138,7 +468,7 @@ export default function SettingsPage() {
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
@@ -1149,6 +479,21 @@ export default function SettingsPage() {
)}
</div>
</SettingRow>
<SettingRow
label="Сбросить данные NetFlow"
description="Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает"
>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
onClick={() => setDbPurgeOpen(true)}
>
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
</Button>
</SettingRow>
</OpsPanel>
<OpsPanel
@@ -1409,94 +754,6 @@ export default function SettingsPage() {
</div>
)
// ── Пользователи ──
if (section === "Пользователи") return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{users.length} пользователей · {users.filter(u => u.active).length} активных
{totalSubUsers > 0 && ` · ${totalSubUsers} GRE-клиентов`}
</p>
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
</div>
<Frame dense className="w-full">
<FramePanel className="overflow-hidden p-0">
{/* table header */}
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
<span>Пользователь</span>
<span>Роль</span>
<span>Последний вход</span>
<span>Статус</span>
<span />
</div>
<div className="divide-y divide-border/60">
{users.map(u => {
return (
<div key={u.id} className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
{/* user */}
<div className="flex items-center gap-2.5 min-w-0">
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-[11px] font-mono text-muted-foreground">@{u.login}</p>
{u.subUsers.length > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground/60">
<CableIcon className="size-2.5" />{u.subUsers.length}
</span>
)}
</div>
</div>
</div>
{/* role */}
<span className={cn("text-[11px] font-medium px-2 py-0.5 rounded-full w-fit", ROLE_COLOR[u.role])}>
{ROLE_LABEL[u.role]}
</span>
{/* last */}
<span className="text-xs text-muted-foreground">{u.last}</span>
{/* status */}
<span className={cn("text-[11px] font-medium", u.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{u.active ? "Активен" : "Заблокирован"}
</span>
{/* actions */}
<div className="flex items-center gap-0.5 justify-end">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => { setEditUser(u); setSheetOpen(true) }} title="Редактировать">
<PencilIcon className="size-3.5" />
</Button>
{u.id !== "u1" && (
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(u)} title="Удалить">
<TrashIcon className="size-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
{/* access summary */}
<DataPageCard>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<UserIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">Сводка прав доступа</span>
</div>
<SettingsAccessSummaryDataGrid
users={users}
servers={servers}
allSectionsCount={ALL_SECTIONS.length}
/>
</DataPageCard>
</div>
)
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="flex flex-col gap-4">
@@ -1655,15 +912,6 @@ export default function SettingsPage() {
</div>
</div>
{/* user sheet */}
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
onSave={handleUserSave}
onClose={() => setSheetOpen(false)}
/>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
@@ -1677,14 +925,13 @@ export default function SettingsPage() {
}}
/>
)}
{deleteTarget && (
<DeleteConfirm
open={!!deleteTarget}
user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)}
/>
)}
<NetflowPurgeConfirm
open={dbPurgeOpen}
busy={dbPurgeBusy}
onConfirm={() => { void handleNetflowPurgeConfirm() }}
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
/>
<FileImportDialog
open={dbRestoreDialogOpen}
@@ -1701,3 +948,4 @@ export default function SettingsPage() {
</div>
)
}
File diff suppressed because it is too large Load Diff
+297
View File
@@ -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>
)
}
+3 -1
View File
@@ -13,7 +13,9 @@
"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-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",
+271 -6
View File
@@ -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,100 @@ 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,
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 +618,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 +780,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 +828,12 @@ 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);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
@@ -661,6 +870,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 +960,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 })
}
+145
View File
@@ -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,110 @@ 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),
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 +641,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 +694,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 +725,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
+50 -2
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } 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"
@@ -10,6 +11,7 @@ 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"
@@ -25,7 +27,12 @@ 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"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
export async function buildApp(opts?: {
logger?: boolean
@@ -34,7 +41,7 @@ export async function buildApp(opts?: {
const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
bodyLimit: 2 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
@@ -56,6 +63,18 @@ export async function buildApp(opts?: {
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
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 })
})
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
@@ -67,6 +86,8 @@ export async function buildApp(opts?: {
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
flowWorker: getFlowWorkerHealth(),
}))
app.get("/api/auth/config", async () => ({
@@ -92,6 +113,7 @@ export async function buildApp(opts?: {
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" })
@@ -107,11 +129,14 @@ export async function buildApp(opts?: {
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()
})
}
@@ -125,6 +150,29 @@ const isMain =
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}`,
+13 -2
View File
@@ -21,6 +21,10 @@ 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",
@@ -38,8 +42,15 @@ assert.equal(
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/firewall/rules"),
"mm:network:write",
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")
+14 -2
View File
@@ -17,6 +17,9 @@ export function hasPermission(
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
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
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",
},
{
@@ -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")
+60
View File
@@ -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)
}
+44
View File
@@ -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 }
+4
View File
@@ -9,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) => {
@@ -20,6 +21,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutesTotal,
certificatesTotal,
wireguardTotal,
usersTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
@@ -28,6 +30,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0),
Promise.resolve(listUsers().length),
])
return reply.send({
@@ -39,6 +42,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
users: usersTotal,
})
})
}
+296
View File
@@ -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
+34
View File
@@ -22,6 +22,10 @@ import {
parseMonitorTraffic,
rateBpsFromDelta,
} from "../services/traffic-rate.js"
import {
buildBoundInterfaceTraffic,
buildUserTrafficList,
} from "../services/traffic-users.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -427,6 +431,36 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
)
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
+117
View File
@@ -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
+21 -35
View File
@@ -21,6 +21,12 @@ 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)
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
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 peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
@@ -99,20 +97,17 @@ async function applyParsedConfig(
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
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 client.put("/ip/address", {
address: body.address,
interface: body.name,
})
await putIpAddress(client, body.address, body.name)
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
}
const list = await listWireGuardInterfaces({
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
await putWireguardPeer(client, peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
+15 -5
View File
@@ -11,6 +11,16 @@ import type {
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 {
@@ -52,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) {
@@ -135,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) {
@@ -189,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) {
@@ -239,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) {
@@ -289,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) {
+12 -8
View File
@@ -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()
+73 -9
View File
@@ -5,9 +5,12 @@ 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
@@ -16,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
@@ -63,6 +98,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
sampledAt: trafficSamples.sampledAt,
@@ -73,7 +109,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
eq(trafficSamples.sampledAt, last.sampledAt),
))
.all()
return new Map(rows.map((r) => [r.interfaceName, r]))
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
}
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
@@ -106,6 +142,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
rememberServerIfaces(srv.id, ifaces)
const prevWave = readPreviousWave(srv.id)
const nowMs = Date.parse(now)
let sumRxMbps = 0
@@ -116,14 +153,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const txBytes = toNum(i["tx-byte"])
const running = (i.running ?? "false") === "true"
const disabled = (i.disabled ?? "false") === "true"
const prev = prevWave.get(interfaceName)
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
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
if (shouldIncludeIface(interfaceName, running, disabled)) {
sumRxMbps += bpsToMbps(rxBps)
sumTxMbps += bpsToMbps(txBps)
@@ -131,6 +161,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
return {
serverId: srv.id,
interfaceName,
peerPublicKey: "",
sampledAt: now,
rxBytes,
txBytes,
@@ -140,6 +171,39 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
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,
peerPublicKey,
sampledAt: now,
rxBytes,
txBytes,
rxBps,
txBps,
running,
disabled,
})
}
} catch {
/* WG peers optional — iface samples already recorded */
}
if (rows.length > 0) {
db.insert(trafficSamples).values(rows).run()
}
@@ -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),
}
}
+85
View File
@@ -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,29 @@
import assert from "node:assert/strict"
import {
brandByAsn,
countryFromHolder,
lookupBrand,
OTHER_SERVICE,
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(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, "Прочее")
console.log("traffic-flow-brands.test.ts: ok")
+107
View File
@@ -0,0 +1,107 @@
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: "Amazon", category: "CDN" }],
[14618, { service: "Amazon", category: "CDN" }],
[8075, { service: "Microsoft", category: "CDN" }],
[13238, { service: "Yandex", category: "CDN" }],
[32590, { service: "Steam", 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"],
[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)
}
@@ -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()]
}
+746
View File
@@ -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")
+518
View File
@@ -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"
+54
View File
@@ -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,157 @@
import assert from "node:assert/strict"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowMapHops } 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,
} 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 {
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)
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: ok")
@@ -0,0 +1,216 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js"
import { servers, userInterfaceBindings } from "../db/schema.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.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 { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js"
export interface FlowMapHopsQuery {
minutes: number
serverId?: number
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
}
interface HopAcc {
fromId: string
fromLabel: string
toId: string
toLabel: string
kind: FlowMapHop["kind"]
iface?: string
bytes: number
bytesFwd: number
bytesRev: number
}
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,
}
}
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): 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>()
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")
}
}
}
const listener = getFlowListenerState()
return {
hops: [...hops.values()]
.map((a) => toHop(a, windowSec))
.sort((a, b) => b.bytes - a.bytes),
live: listener.bound,
rangeMinutes: q.minutes,
windowSec,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
excludeOverlayApplied: excludeOverlay,
}
}
@@ -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")
+418
View File
@@ -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")
+107
View File
@@ -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,103 @@
import assert from "node:assert/strict"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
enqueueRipeMisses,
flushRipeQueueForTests,
lookupRipeCached,
resetRipeCacheForTests,
ripeFetchCountForTests,
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)
console.log("traffic-flow-ripe.test.ts: ok")
+379
View File
@@ -0,0 +1,379 @@
import { sqliteDatabase } from "../db/index.js"
import { ipInCidrV4, 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[] = []
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
loaded = persistEnabled ? false : true
workerRunning = false
fetchCount = 0
enqueueEnabled = true
fetchImpl = globalThis.fetch.bind(globalThis)
}
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
mem.set(entry.prefix, { ...entry })
loaded = true
}
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 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 || ""
mem.set(r.prefix, {
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()
if (!trimmed) return null
if (isNonPublicIp(trimmed)) {
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
}
let best: FlowIpMeta | null = null
let bestLen = -1
for (const entry of mem.values()) {
if (!isFresh(entry)) continue
const parsed = parseCidrV4(entry.prefix)
if (!parsed) continue
if (!ipInCidrV4(trimmed, entry.prefix)) continue
if (parsed.prefixLen > bestLen) {
best = entry
bestLen = parsed.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(),
}
mem.set(prefix, entry)
persist(entry)
return entry
} catch {
const prefix = `${ip}/32`
const entry = negative(prefix)
mem.set(prefix, 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,143 @@
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,
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,
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 }
}
+49
View File
@@ -4,6 +4,7 @@ import {
bucketAvg,
buildTrafficFromSamples,
isLoopbackName,
mergeBuiltTraffic,
parseMonitorTraffic,
rateBpsFromDelta,
shouldIncludeIface,
@@ -73,4 +74,52 @@ const onceOnly = parseMonitorTraffic(
)
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")
+70 -9
View File
@@ -4,6 +4,7 @@ export const SERIES_POINTS = 60
export interface TrafficSampleLike {
interfaceName: string
peerPublicKey?: string
sampledAt: string
rxBytes: number
txBytes: number
@@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number {
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,
onlyInterface?: string | readonly string[],
peerPublicKey?: string,
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
@@ -113,13 +119,18 @@ export function buildTrafficFromSamples(
}
if (rows.length === 0) return empty
const byIface = new Map<string, TrafficSampleLike[]>()
const bySeries = new Map<string, TrafficSampleLike[]>()
for (const r of rows) {
const arr = byIface.get(r.interfaceName) ?? []
const peer = r.peerPublicKey ?? ""
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
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 }>()
@@ -128,17 +139,25 @@ export function buildTrafficFromSamples(
let txBytesDelta = 0
let sessions = 0
for (const [name, arr] of byIface) {
if (onlyInterface) {
if (name !== onlyInterface) continue
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 (!onlyInterface && (!last.running || last.disabled)) continue
if (!allowList && (!last.running || last.disabled)) continue
if (last.running && !last.disabled) sessions += 1
@@ -154,7 +173,7 @@ export function buildTrafficFromSamples(
const prev = sorted[i - 1]
const cur = sorted[i]
if (!prev || !cur) continue
if (!onlyInterface && (!cur.running || cur.disabled)) 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)
@@ -194,6 +213,48 @@ export function buildTrafficFromSamples(
}
}
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 },
+133
View File
@@ -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)
}
+12
View File
@@ -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"),
}
}
+3 -3
View File
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
export type WgExportIface = {
name: string
listenPort: number
listenPort?: number
mtu: number
comment?: string
enabled?: boolean
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
}
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
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, '\\"')}" \\`)
+47
View File
@@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul
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 }
+50
View File
@@ -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))
}
+1 -1
View File
@@ -15,5 +15,5 @@
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+4 -1
View File
@@ -35,6 +35,7 @@ import {
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
UsersIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
@@ -70,6 +71,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
@@ -102,7 +104,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -161,6 +163,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -36,6 +36,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: <UsersIcon />, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
@@ -1,167 +0,0 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
@@ -0,0 +1,150 @@
"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,
emptyHint,
}: {
rows: FlowTalkerDto[]
emptyHint?: string
}) {
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
() => [
{
id: "client",
accessorFn: (r) => r.clientName ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
cell: ({ row }) => <span className="text-xs">{row.original.clientName || "—"}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
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, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "src",
accessorKey: "src",
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.src}
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
</span>
),
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: "app",
accessorFn: (r) => r.application ?? r.protoName,
header: () => <span className="text-xs font-medium text-muted-foreground">App</span>,
cell: ({ row }) => (
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
<span>{row.original.application ?? row.original.protoName}</span>
{row.original.category || row.original.service ? (
<span className="text-[10px] text-muted-foreground truncate">
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
</span>
) : null}
</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: "en",
accessorFn: (r) => r.enName ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "plane",
accessorFn: (r) => r.plane ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">Плоскость</span>,
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.plane || "—"}</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={
emptyHint
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
}
/>
)
}
export { TrafficFlowsDataGrid }
+244
View File
@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
import {
CableIcon,
ChevronDownIcon,
ChevronRightIcon,
PencilIcon,
TrashIcon,
UsersIcon,
} from "lucide-react"
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}
>
{avatar}
</div>
)
}
interface UsersDataGridProps {
users: AppUser[]
serversCount: number
allSectionsCount?: number
isLoading?: boolean
onEdit: (user: AppUser) => void
onDelete: (user: AppUser) => void
}
function UsersDataGrid({
users,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
isLoading,
onEdit,
onDelete,
}: UsersDataGridProps) {
const columns = useMemo<ColumnDef<AppUser>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
),
cell: ({ row }) => {
const u = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Пользователь",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: AppUser) => (
<UsersExpandedDetail
user={row}
serversCount={serversCount}
allSectionsCount={allSectionsCount}
/>
),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
cell: ({ row }) => (
<Badge
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
size="sm"
>
{ROLE_LABEL[row.original.role]}
</Badge>
),
meta: {
headerTitle: "Роль",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "last",
accessorKey: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.last}</span>
),
meta: {
headerTitle: "Последний вход",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "ifaces",
accessorFn: (u) => u.bindings.length,
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
{row.original.bindings.length}
</span>
),
meta: {
headerTitle: "Интерфейсы",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "status",
accessorKey: "active",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-medium",
row.original.active ? "text-success" : "text-muted-foreground",
)}
>
{row.original.active ? "Активен" : "Заблокирован"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const u = row.original
return (
<div
className="flex items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(u)}
title="Удалить"
>
<TrashIcon className="size-3.5" />
</Button>
</div>
)
},
meta: {
headerTitle: "Действия",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[allSectionsCount, onDelete, onEdit, serversCount],
)
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={users.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { UsersDataGrid, type UsersDataGridProps }
@@ -0,0 +1,132 @@
"use client"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import {
ALL_SECTIONS,
groupBindingsByServer,
IFACE_TYPE_LABEL,
summarizeUserAccess,
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
other: "secondary",
}
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
interface UsersExpandedDetailProps {
user: AppUser
serversCount: number
allSectionsCount?: number
}
function UsersExpandedDetail({
user,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
}: UsersExpandedDetailProps) {
const access = summarizeUserAccess(user, serversCount, allSectionsCount)
const groups = groupBindingsByServer(user.bindings)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Разделы:{" "}
<span className="font-mono text-foreground">
{access.sectionsGranted}/{access.sectionsTotal}
</span>
</span>
<span className="text-muted-foreground">
Серверы:{" "}
<span className="font-mono text-foreground">
{access.serversGranted}/{access.serversTotal}
</span>
</span>
{access.writeKind === "full" ? (
<Badge variant="success-light" size="sm">Полный доступ</Badge>
) : access.writeKind === "none" ? (
<Badge variant="outline" size="sm">Только просмотр</Badge>
) : (
<span className="inline-flex flex-wrap items-center gap-1">
{access.writeSections.map((section) => (
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
))}
</span>
)}
</div>
{groups.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
) : (
groups.map((g) => {
const items = g.types.flatMap((tg) => tg.items)
return (
<div key={g.serverId}>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
{g.serverName}
</p>
<Flag code={g.serverCountry} size={12} />
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{items.map((b) => {
const meta = TYPE_ICON[b.interfaceType]
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
return (
<div
key={b.id}
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
<Icon />
</IconTile>
<div className="min-w-0">
<p className="text-xs font-mono font-medium leading-tight truncate">
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
? `${b.peerName || "peer"} · ${b.interfaceName}`
: b.interfaceName}
</p>
<div className="flex flex-wrap items-center gap-1 mt-0.5">
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
{IFACE_TYPE_LABEL[b.interfaceType]}
</Badge>
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
) : null}
{b.comment ? (
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{b.comment}
</span>
) : null}
</div>
</div>
</div>
)
})}
</div>
</div>
)
})
)}
</div>
)
}
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
+1
View File
@@ -46,6 +46,7 @@ function nearestCdnSize(px: number): number {
export function Flag({ code, size = 20, className }: FlagProps) {
if (!code) return null
const lower = code.toLowerCase()
if (!/^[a-z]{2}$/.test(lower)) return null
const name = countryName(code.toUpperCase())
const cdnSrc = nearestCdnSize(size)
const cdnSrc2x = nearestCdnSize(size * 2)
+3 -3
View File
@@ -60,10 +60,10 @@ function highlightLine(line: string): string {
return "text-foreground/90"
}
function CodeBlock({ code }: { code: string }) {
function CodeBlock({ code, className }: { code: string; className?: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
{lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}>
{line || " "}
@@ -281,4 +281,4 @@ function CodeExportSheet({
)
}
export { CodeExportSheet, downloadText }
export { CodeExportSheet, CodeBlock, downloadText }
+640
View File
@@ -0,0 +1,640 @@
"use client"
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid"
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
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 { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
import { FlowTrafficMap } from "@/components/traffic/flow-traffic-map"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
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} Б`
}
const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h", "30d"] as const
const RANGE_LABELS: Record<string, string> = {
"5m": "5м",
"15m": "15м",
"1h": "1ч",
"4h": "4ч",
"24h": "24ч",
"30d": "месяц",
}
function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
const W = 300
const H = height
const maxVal = Math.max(...rx, ...tx, 1) * 1.1
const xAt = (i: number) => (rx.length <= 1 ? 0 : (i / (rx.length - 1)) * W)
const yAt = (v: number) => H - (v / maxVal) * H
const area = (arr: number[]) => {
const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
return `M 0,${H} L ${pts} L ${W},${H} Z`
}
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-11" preserveAspectRatio="none">
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity="0.15" />
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.5" />
{tx.some((v) => v > 0) ? (
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.5" />
) : null}
</svg>
)
}
export function FlowEntityCardView({
card,
selected,
onClick,
}: {
card: FlowEntityCard
selected: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
selected ? "border-primary bg-primary/5" : "border-border bg-card",
card.status === "offline" && "opacity-60",
)}
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<StatusDot status={card.status} />
<span className="text-xs font-medium truncate">{card.name}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
{card.country !== "UN" ? <Flag code={card.country} /> : null}
{card.site}
</span>
</div>
<MiniAreaChart rx={card.rxSeries} tx={card.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-success" />
<span className="font-mono font-medium text-success">{fmtRate(card.rxNow)}</span>
</div>
<div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-info" />
<span className="font-mono font-medium text-info">{fmtRate(card.txNow)}</span>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<GitBranchIcon className="size-3" />{card.sessions}
</div>
</div>
</button>
)
}
type SessionFilter = {
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en"
value: string
label: string
}
function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean {
switch (filter.kind) {
case "application": return row.application === filter.value
case "category": return row.category === filter.value
case "service": return row.service === filter.value
case "asn": return String(row.dstAsn ?? "") === filter.value
case "country": return row.dstCountry === filter.value
case "protocol": return row.protoName === filter.value
case "source": return row.src === filter.value
case "destination": return row.dst === filter.value
case "iface": return row.inIface === filter.value
case "client": return (row.clientId || "unknown") === filter.value
case "en": return (row.enId || "") === filter.value
}
}
function FlowBreakdownGrid({
rows,
empty,
country,
onPick,
}: {
rows: FlowBreakdownRow[]
empty?: string
country?: boolean
onPick?: (row: FlowBreakdownRow) => void
}) {
const columns = useMemo<ColumnDef<FlowBreakdownRow>[]>(
() => [
{
id: "label",
accessorKey: "label",
header: () => <span className="text-xs font-medium text-muted-foreground">Имя</span>,
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5 text-sm font-medium truncate">
{country ? <Flag code={row.original.id} /> : null}
{row.original.label}
</span>
<div className="h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary"
style={{ width: `${Math.min(100, row.original.percent)}%` }}
/>
</div>
</div>
),
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "share",
accessorKey: "percent",
header: () => <span className="text-xs font-medium text-muted-foreground">Доля</span>,
cell: ({ row }) => (
<span className="text-xs tabular-nums">{row.original.percent.toFixed(1)}%</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_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[country],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={empty ?? "Нет данных за период"}
onRowClick={onPick}
/>
)
}
function FlowPathsGrid({
rows,
empty,
onPick,
}: {
rows: FlowPathRow[]
empty?: string
onPick?: (row: FlowPathRow) => void
}) {
const columns = useMemo<ColumnDef<FlowPathRow>[]>(
() => [
{
id: "client",
accessorKey: "clientName",
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
cell: ({ row }) => <span className="text-sm font-medium">{row.original.clientName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "ifaces",
accessorKey: "ifaces",
header: () => <span className="text-xs font-medium text-muted-foreground">Ifaces</span>,
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.ifaces}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "jh",
accessorKey: "serverName",
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
cell: ({ row }) => <span className="text-xs">{row.original.serverName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "in",
accessorKey: "inIface",
header: () => <span className="text-xs font-medium text-muted-foreground">In</span>,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.inIface}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "en",
accessorKey: "enName",
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</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">Dest</span>,
cell: ({ row }) => (
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
<span className="font-mono">{row.original.dst}</span>
<span className="text-[10px] text-muted-foreground truncate">
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
</span>
</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_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={empty ?? "Нет путей"}
onRowClick={onPick}
/>
)
}
export function FlowAnalyticsDetail({
card,
analytics,
range,
onRange,
selectedIface,
onIface,
dedup,
onDedup,
excludeMesh,
onExcludeMesh,
excludeOverlay,
onExcludeOverlay,
liveHint,
emptyHint,
}: {
card: FlowEntityCard | null
analytics: FlowAnalyticsDto | null
range: string
onRange: (r: string) => void
selectedIface: string
onIface: (name: string) => void
dedup: boolean
onDedup: (value: boolean) => void
excludeMesh: boolean
onExcludeMesh: (value: boolean) => void
excludeOverlay: boolean
onExcludeOverlay: (value: boolean) => void
liveHint?: string
emptyHint?: string
}) {
const [slice, setSlice] = useState("applications")
const [sessionFilter, setSessionFilter] = useState<SessionFilter | null>(null)
const rxNow = analytics ? analytics.bpsNow / 1_000_000 : (card?.rxNow ?? 0)
const bytes = analytics?.bytes ?? card?.bytes ?? 0
const sessionRows = (analytics?.conversationsList ?? []).filter((row) =>
sessionFilter ? talkerMatchesFilter(row, sessionFilter) : true,
)
function pickBreakdown(kind: SessionFilter["kind"], row: FlowBreakdownRow) {
setSessionFilter({ kind, value: row.id, label: row.label })
setSlice("sessions")
}
if (!card) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
Выберите сервер или клиента слева
</p>
)
}
return (
<>
<div className="flex items-start justify-between mb-3 gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<StatusDot status={card.status} />
<h2 className="text-base font-semibold truncate">{card.name}</h2>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
{card.country !== "UN" ? <Flag code={card.country} /> : null}
{card.site}
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2">
<Switch
id="flow-dedup"
checked={dedup}
onCheckedChange={onDedup}
/>
<Label htmlFor="flow-dedup" className="text-xs text-muted-foreground whitespace-nowrap">
Без дублей
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="flow-exclude-overlay"
checked={excludeOverlay}
onCheckedChange={onExcludeOverlay}
/>
<Label htmlFor="flow-exclude-overlay" className="text-xs text-muted-foreground whitespace-nowrap">
Без overlay
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="flow-exclude-mesh"
checked={excludeMesh}
onCheckedChange={onExcludeMesh}
/>
<Label htmlFor="flow-exclude-mesh" className="text-xs text-muted-foreground whitespace-nowrap">
Без mesh
</Label>
</div>
<div className="flex gap-1">
{RANGE_KEYS.map((r) => (
<button
key={r}
type="button"
onClick={() => onRange(r)}
className={cn(
"h-7 px-2 text-xs rounded border transition-colors",
range === r
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{RANGE_LABELS[r]}
</button>
))}
</div>
</div>
</div>
{analytics?.ifaces && analytics.ifaces.length > 0 ? (
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
<button
type="button"
onClick={() => onIface("__all__")}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
selectedIface === "__all__"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}
>
Все
</button>
{analytics.ifaces.map((iface) => {
const active = selectedIface === iface.name
return (
<button
key={`${iface.name}:${iface.index}`}
type="button"
onClick={() => onIface(iface.name)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}
>
{iface.name}
</button>
)
})}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">по iface, без дедупа</p>
</div>
) : null}
<TrafficRxTxChart
rx={analytics?.rxSeries ?? card.rxSeries}
tx={analytics?.txSeries ?? card.txSeries}
range={range}
/>
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость потоков"
items={([
{
id: "bps-now",
label: "Скорость сейчас",
value: fmtRate(rxNow),
hint: liveHint,
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "bytes",
label: "Payload",
value: formatBytes(bytes),
hint: "inner IPFIX",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "overlay",
label: "JH↔EN overlay",
value: fmtRate((analytics?.bpsOverlay ?? 0) / 1_000_000),
hint: analytics?.bytesOverlay ? formatBytes(analytics.bytesOverlay) : undefined,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "wire",
label: "Wire GRE",
value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000),
hint: "счётчик iface",
icon: <RouteIcon className="size-4" />,
iconClassName: "text-primary",
},
...(!excludeMesh
? [{
id: "mesh",
label: "Mesh",
value: formatBytes(analytics?.bytesMesh ?? 0),
hint: "клиент↔клиент",
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
}]
: []),
{
id: "flows",
label: "Сессии",
value: String(analytics?.conversations ?? card.sessions),
hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations
? `до дедупа ${analytics.conversationsRaw}`
: undefined,
icon: <GitBranchIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "uniq",
label: "Уник. src / dst",
value: `${analytics?.uniqueSrc ?? 0} / ${analytics?.uniqueDst ?? 0}`,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "category",
label: "Топ категория",
value: analytics?.topCategory ?? "—",
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
] satisfies KpiStatItem[])}
/>
</div>
<div className="mt-4 pt-4 border-t flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Аналитика потребления</p>
{analytics?.live ? <Badge variant="success-light" size="sm">live</Badge> : null}
</div>
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
<TabsList variant="line" className="flex flex-wrap h-auto">
<TabsTrigger value="applications">Приложения</TabsTrigger>
<TabsTrigger value="categories">Категории</TabsTrigger>
<TabsTrigger value="services">Сервисы</TabsTrigger>
<TabsTrigger value="asns">ASN</TabsTrigger>
<TabsTrigger value="countries">Страны</TabsTrigger>
<TabsTrigger value="map">Карта</TabsTrigger>
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
<TabsTrigger value="sources">Источники</TabsTrigger>
<TabsTrigger value="destinations">Назначения</TabsTrigger>
<TabsTrigger value="paths">Пути</TabsTrigger>
<TabsTrigger value="sessions">Сессии</TabsTrigger>
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
</TabsList>
<TabsContent value="applications">
<FlowBreakdownGrid rows={analytics?.applications ?? []} onPick={(row) => pickBreakdown("application", row)} />
</TabsContent>
<TabsContent value="categories">
<FlowBreakdownGrid rows={analytics?.categories ?? []} onPick={(row) => pickBreakdown("category", row)} />
</TabsContent>
<TabsContent value="services">
<FlowBreakdownGrid rows={analytics?.services ?? []} onPick={(row) => pickBreakdown("service", row)} />
</TabsContent>
<TabsContent value="asns">
<FlowBreakdownGrid rows={analytics?.asns ?? []} onPick={(row) => pickBreakdown("asn", row)} />
</TabsContent>
<TabsContent value="countries">
<FlowBreakdownGrid rows={analytics?.countries ?? []} country onPick={(row) => pickBreakdown("country", row)} />
</TabsContent>
<TabsContent value="map">
<FlowTrafficMap
edges={analytics?.mapEdges ?? []}
onSelectCountry={(iso) => {
setSessionFilter({ kind: "country", value: iso, label: iso })
setSlice("sessions")
}}
/>
</TabsContent>
<TabsContent value="protocols">
<FlowBreakdownGrid rows={analytics?.protocols ?? []} onPick={(row) => pickBreakdown("protocol", row)} />
</TabsContent>
<TabsContent value="sources">
<FlowBreakdownGrid rows={analytics?.sources ?? []} onPick={(row) => pickBreakdown("source", row)} />
</TabsContent>
<TabsContent value="destinations">
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
</TabsContent>
<TabsContent value="paths">
<FlowPathsGrid
rows={analytics?.paths ?? []}
empty="Нет путей за период"
onPick={(row) => {
setSessionFilter({
kind: "client",
value: row.clientId,
label: `${row.clientName}${row.enName || row.dst}`,
})
setSlice("sessions")
}}
/>
</TabsContent>
<TabsContent value="sessions">
{sessionFilter ? (
<div className="flex items-center gap-2 mb-2">
<GlobeIcon className="size-3.5 text-muted-foreground" />
<Badge variant="secondary" size="sm">Фильтр: {sessionFilter.label}</Badge>
<button
type="button"
className="text-xs text-primary"
onClick={() => setSessionFilter(null)}
>
сбросить
</button>
</div>
) : null}
<TrafficFlowsDataGrid
rows={sessionRows}
emptyHint={emptyHint ?? "Нет сессий по выбранному фильтру"}
/>
</TabsContent>
<TabsContent value="interfaces">
<FlowBreakdownGrid
rows={analytics?.interfaces ?? []}
empty="Нет данных по интерфейсам"
onPick={(row) => pickBreakdown("iface", row)}
/>
</TabsContent>
</Tabs>
</div>
</>
)
}
+229
View File
@@ -0,0 +1,229 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
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"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
InfoIcon,
} from "lucide-react"
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 [endpoint, setEndpoint] = useState("")
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("linux")
useEffect(() => {
if (!open) return
setResult(null)
setCopied(false)
setTab("linux")
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
setEndpoint(first?.host ?? "")
}, [open, jumpHosts])
function handleServerChange(id: string) {
setServerId(id)
const selected = jumpHosts.find((s) => String(s.id) === id)
if (selected) setEndpoint(selected.host)
}
const formats = result?.hostFiles ?? []
const active = formats.find((f) => f.id === tab) ?? formats[0]
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
const selectedLabel = selectedHost
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
: "Выберите сервер…"
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
async function handleSubmit() {
if (!serverId || !endpoint.trim()) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
} finally {
setBusy(false)
}
}
function handleCopy() {
const code = active?.code ?? ""
if (!code) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
toast.success("Скопировано")
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
Подключить jump-host
</SheetTitle>
<SheetDescription>
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<FormField label="Jump-host" required>
<Select
value={serverId || undefined}
onValueChange={(v) => handleServerChange(String(v ?? ""))}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="min-w-(--anchor-width)">
{jumpHosts.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="Публичный IP или DNS jump-host"
required
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="jh.example.com"
autoComplete="off"
/>
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
<InfoIcon />
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
<AlertDescription>
Скопируйте вкладку Linux и выполните от root. WG клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
</AlertDescription>
</Alert>
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
<span>{s}</span>
</li>
))}
</ul>
{formats.length > 0 && active ? (
<div className="flex min-h-0 flex-col gap-3">
<Tabs
value={tab}
onValueChange={(v) => {
setTab(String(v))
setCopied(false)
}}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
</ScrollArea>
</FramePanel>
</Frame>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadText(active.filename, active.code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
<Button type="button" size="sm" onClick={handleCopy}>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
Закрыть
</SheetClose>
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
{busy ? "Подключение…" : "Подключить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { FlowOverlaySheet }
+191
View File
@@ -0,0 +1,191 @@
"use client"
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowMapEdge } from "@mmapp/contracts/traffic-flow"
import { Frame, FramePanel } from "@/components/reui/frame"
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 { Flag } from "@/components/flag"
import { fmtRate } from "@/lib/fmt-rate"
const W = 640
const H = 280
const STROKES = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--color-success)",
"var(--chart-5)",
]
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 FlowTrafficMap({
edges,
onSelectCountry,
}: {
edges: FlowMapEdge[]
onSelectCountry?: (country: string) => void
}) {
const [hover, setHover] = useState<string | null>(null)
const layout = useMemo(() => {
const sources = [...new Map(edges.map((e) => [e.fromId, e])).values()]
const dests = [...new Map(edges.map((e) => [e.toCountry, e])).values()]
const srcY = (i: number) => sources.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, sources.length - 1)
const dstY = (i: number) => dests.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, dests.length - 1)
const srcPos = new Map(sources.map((s, i) => [s.fromId, { x: 88, y: srcY(i), label: s.fromLabel, country: s.fromCountry }]))
const dstPos = new Map(dests.map((d, i) => [d.toCountry, { x: 552, y: dstY(i), country: d.toCountry }]))
const maxBytes = Math.max(...edges.map((e) => e.bytes), 1)
return { srcPos, dstPos, maxBytes }
}, [edges])
const columns = useMemo<ColumnDef<FlowMapEdge>[]>(
() => [
{
id: "from",
accessorKey: "fromLabel",
header: () => <span className="text-xs font-medium text-muted-foreground">Источник</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
{row.original.fromCountry && row.original.fromCountry !== "UN"
? <Flag code={row.original.fromCountry} />
: null}
{row.original.fromLabel}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "to",
accessorKey: "toCountry",
header: () => <span className="text-xs font-medium text-muted-foreground">Назначение</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
{/^[a-z]{2}$/i.test(row.original.toCountry)
? <Flag code={row.original.toCountry} />
: null}
{row.original.toCountry}
{row.original.toAsn ? <span className="font-mono text-[10px] text-muted-foreground">AS{row.original.toAsn}</span> : null}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "cat",
accessorKey: "category",
header: () => <span className="text-xs font-medium text-muted-foreground">Категория</span>,
cell: ({ row }) => <span className="text-xs">{row.original.category}</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_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: edges,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, i) => `${row.fromId}-${row.toCountry}-${i}`,
})
if (!edges.length) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
Страны подтянутся из кэша RIPEstat
</p>
)
}
return (
<div className="flex flex-col gap-3">
<Frame>
<FramePanel className="p-3">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-[220px]" role="img" aria-label="Карта потоков откуда куда">
{edges.map((e, i) => {
const from = layout.srcPos.get(e.fromId)
const to = layout.dstPos.get(e.toCountry)
if (!from || !to) return null
const id = `${e.fromId}-${e.toCountry}`
const midX = (from.x + to.x) / 2
const d = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`
const sw = 1.25 + 7 * (e.bytes / layout.maxBytes)
const active = hover === id
return (
<path
key={id}
d={d}
fill="none"
stroke={STROKES[i % STROKES.length]}
strokeWidth={active ? sw + 1.5 : sw}
strokeOpacity={active ? 1 : 0.72}
className="cursor-pointer"
onMouseEnter={() => setHover(id)}
onMouseLeave={() => setHover(null)}
onClick={() => onSelectCountry?.(e.toCountry)}
>
<title>
{`${e.fromLabel}${e.toCountry} · ${e.category} · ${formatBytes(e.bytes)}`}
</title>
</path>
)
})}
{[...layout.srcPos.values()].map((n) => (
<g key={`s-${n.label}`}>
<circle cx={n.x} cy={n.y} r="7" className="fill-primary" />
<text x={n.x - 14} y={n.y + 4} textAnchor="end" className="fill-foreground text-[11px]">
{n.label}
</text>
</g>
))}
{[...layout.dstPos.values()].map((n) => (
<g key={`d-${n.country}`}>
<circle cx={n.x} cy={n.y} r="7" className="fill-chart-2" />
<text x={n.x + 14} y={n.y + 4} className="fill-foreground text-[11px]">
{n.country}
</text>
</g>
))}
</svg>
{hover ? (
<p className="text-[11px] text-muted-foreground mt-1">
Нажмите дугу, чтобы отфильтровать сессии по стране назначения
</p>
) : null}
</FramePanel>
</Frame>
<DataGridShell
table={table}
recordCount={edges.length}
emptyMessage="Нет рёбер с известной страной"
onRowClick={(row) => onSelectCountry?.(row.toCountry)}
/>
</div>
)
}
export { FlowTrafficMap }
@@ -0,0 +1,72 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
import { AlertCircleIcon, LoaderCircleIcon } from "lucide-react"
function formatDbFileBytes(n: number): string {
if (n < 1024) return `${n} Б`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} КБ`
return `${(n / (1024 * 1024)).toFixed(1)} МБ`
}
export function formatFlowPurgeResult(result: FlowPurgeDto): string {
const rows =
result.deleted.buckets +
result.deleted.minuteStats +
result.deleted.minuteDims +
result.deleted.dailyDims
const vacuumHint = result.vacuumed ? "" : " VACUUM не выполнен."
return `Удалено строк: ${rows}. Файл ${formatDbFileBytes(result.fileBytesBefore)}${formatDbFileBytes(result.fileBytesAfter)}.${vacuumHint}`
}
export function NetflowPurgeConfirm({
open,
busy,
onConfirm,
onCancel,
}: {
open: boolean
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Сбросить данные NetFlow?</AlertDialogTitle>
<AlertDialogDescription>
Будут удалены сессии и агрегаты (minute/daily) из SQLite, затем VACUUM.
Ключи WireGuard, пиры JH, настройки коллектора и кэш RIPE сохранятся.
На время операции приём IPFIX остановится.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={onConfirm}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Сброс…" : "Сбросить"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,266 @@
"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,
purgeTrafficFlowData,
putTrafficFlowSettings,
} from "@/shared/api/traffic-flow"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
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 [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusy, setPurgeBusy] = 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 handlePurgeConfirm() {
setPurgeBusy(true)
try {
const result = await purgeTrafficFlowData(backendUrl)
toast.success(formatFlowPurgeResult(result))
setPurgeOpen(false)
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
} finally {
setPurgeBusy(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 || purgeBusy} onClick={() => { void handleSave() }}>
Сохранить NetFlow
</Button>
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
<KeyRoundIcon className="size-4" />
Ключи хоста
</Button>
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
<DownloadIcon className="size-4" />
wg-quick / compose / firewall
</Button>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-destructive/30 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
<p className="text-xs text-muted-foreground mt-0.5">
Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает.
</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={busy || purgeBusy}
onClick={() => setPurgeOpen(true)}
>
Сбросить
</Button>
</div>
</OpsPanel>
<CodeExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
title="Файлы для хоста Docker MM"
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
formats={formats}
/>
<NetflowPurgeConfirm
open={purgeOpen}
busy={purgeBusy}
onConfirm={() => { void handlePurgeConfirm() }}
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
/>
</>
)
}
export { NetflowSettingsPanel }
+689
View File
@@ -0,0 +1,689 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Frame, FramePanel } from "@/components/reui/frame"
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { listInterfaceCatalog } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
bindingTitle,
catalogForServer,
defaultSections,
defaultServers,
IFACE_TYPE_LABEL,
PERM_COLOR,
PERM_OPTS,
ROLE_LABEL,
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
type Role,
type UserServerOption,
} from "@/lib/users"
import {
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
} from "lucide-react"
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
type CatalogPick = {
interfaceName: string
peerPublicKey: string
peerName?: string
type: InterfaceType
}
function pickKey(p: CatalogPick): string {
return `${p.interfaceName}\0${p.peerPublicKey}`
}
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
"Управление": <WrenchIcon className="size-3" />,
"Инструменты": <ShieldIcon className="size-3" />,
"Система": <ServerIcon className="size-3" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
>
{o.label}
</button>
))}
</div>
)
}
function emptyForm(serverList: UserServerOption[]): AppUserForm {
return {
name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"),
servers: defaultServers("viewer", serverList),
bindings: [],
}
}
function toForm(user: AppUser): AppUserForm {
return {
name: user.name, login: user.login, email: user.email, role: user.role, active: user.active,
sections: user.sections.map((s) => ({ ...s })),
servers: user.servers.map((s) => ({ ...s })),
bindings: user.bindings.map((b) => ({ ...b })),
}
}
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
}
interface UserSheetProps {
open: boolean
user: AppUser | null
users: AppUser[]
servers: UserServerOption[]
isLive: boolean
backendUrl: string
saving?: boolean
onSave: (form: AppUserForm) => void
onClose: () => void
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, onSave, onClose,
}: UserSheetProps) {
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
const [catalog, setCatalog] = useState<CatalogIface[]>([])
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
const [expandedWg, setExpandedWg] = useState<string | null>(null)
const [newComment, setNewComment] = useState("")
useEffect(() => {
if (!open) return
setForm(user ? toForm(user) : emptyForm(servers))
setSheetStep(1)
setErrors({})
setCatalogServerId(servers[0]?.id ?? "")
setSelectedPicks([])
setExpandedWg(null)
setNewComment("")
}, [open, user, servers])
useEffect(() => {
if (!open || !catalogServerId) {
setCatalog([])
return
}
if (!isLive) {
const occupancy = users.map((u) =>
u.id === user?.id ? { ...u, bindings: form.bindings } : u,
)
const withDraft = user
? occupancy
: [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
setCatalog(catalogForServer(catalogServerId, withDraft))
return
}
let cancelled = false
void listInterfaceCatalog(backendUrl, catalogServerId)
.then((ifaces) => { if (!cancelled) setCatalog(ifaces) })
.catch(() => { if (!cancelled) setCatalog([]) })
return () => { cancelled = true }
}, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings])
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
setForm((f) => ({ ...f, [k]: v }))
if (errors[k]) setErrors((e) => ({ ...e, [k]: undefined }))
}
const setRole = (role: Role) => {
setForm((f) => ({
...f,
role,
sections: defaultSections(role),
servers: servers.map((s) => ({ serverId: s.id, level: role === "admin" ? "write" : "read" })),
}))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
}
const submit = () => {
const email = form.email.trim()
const e: Partial<Record<keyof AppUserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!isValidEmail(email)) e.email = email ? "Укажите корректный email" : "Обязательное поле"
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
return
}
onSave({ ...form, email, login: email })
}
const isAdmin = form.role === "admin"
const catalogSrv = servers.find((s) => s.id === catalogServerId)
const addSelectedBindings = () => {
if (!catalogSrv || selectedPicks.length === 0) return
const existing = new Set(form.bindings.map(bindingDiffKey))
const next: InterfaceBinding[] = [...form.bindings]
for (const pick of selectedPicks) {
const key = bindingDiffKey({
serverId: catalogSrv.id,
interfaceName: pick.interfaceName,
peerPublicKey: pick.peerPublicKey,
})
if (existing.has(key)) continue
next.push({
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
userId: user?.id ?? "",
serverId: catalogSrv.id,
serverName: catalogSrv.name,
serverSite: catalogSrv.site,
serverCountry: catalogSrv.country,
interfaceName: pick.interfaceName,
interfaceType: pick.type,
peerPublicKey: pick.peerPublicKey || undefined,
peerName: pick.peerName,
comment: newComment.trim(),
})
}
setForm((f) => ({ ...f, bindings: next }))
setSelectedPicks([])
setNewComment("")
}
const removeBinding = (id: string) => {
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
}
const togglePick = (pick: CatalogPick) => {
setSelectedPicks((prev) => {
const key = pickKey(pick)
return prev.some((p) => pickKey(p) === key)
? prev.filter((p) => pickKey(p) !== key)
: [...prev, pick]
})
}
const alreadyBoundHere = useMemo(
() => new Set(
form.bindings
.filter((b) => b.serverId === catalogServerId)
.map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`),
),
[form.bindings, catalogServerId],
)
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
{isCreate ? "Профиль, права и привязка интерфейсов" : "Права доступа и привязка интерфейсов"}
</SheetDescription>
</SheetHeader>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Разделы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Интерфейсы</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Полное имя" required error={errors.name}>
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="i.ivanov@company.io"
type="email"
/>
</FormField>
<FormField label="Роль" required>
<SegmentedControl
value={form.role}
onChange={setRole}
options={[
{ value: "viewer", label: ROLE_LABEL.viewer },
{ value: "operator", label: ROLE_LABEL.operator },
{ value: "admin", label: ROLE_LABEL.admin },
]}
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col">
<div className="flex items-center gap-2 px-0 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUP_DEFS.map((group) => (
<div key={group.group}>
<div className="flex items-center gap-2 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{SECTION_GROUP_ICONS[group.group]}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
<div className="divide-y divide-border/60">
{group.items.map((section) => {
const perm = form.sections.find((s) => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</StepperContent>
<StepperContent value={3} className="flex flex-col">
<div className="flex items-center gap-2 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
</div>
<div className="divide-y divide-border/60">
{servers.map((srv) => {
const perm = form.servers.find((s) => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
{srv.status ? <StatusDot status={srv.status} /> : null}
{srv.country ? <Flag code={srv.country} size={14} /> : null}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</StepperContent>
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Ethernet и GRE целиком. WireGuard только пир (public-key).
</p>
<div className="py-3 flex flex-col gap-3 border-b">
<label className="text-[10px] text-muted-foreground">Сервер</label>
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
value={catalogServerId}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</select>
<Frame dense spacing="sm">
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
{catalog.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
)}
<div className="flex flex-col gap-0.5">
{catalog.map((iface) => {
const tile = IFACE_TILE[iface.type]
const Icon = tile.icon
if (iface.type === "wg") {
const open = expandedWg === iface.name
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
return (
<div key={iface.name} className="flex flex-col gap-0.5">
<Item
size="xs"
variant={open ? "muted" : "default"}
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
>
<ItemMedia>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
<Icon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
</ItemTitle>
</ItemContent>
<ItemActions>
{legacyMine ? (
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
) : null}
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
</ItemActions>
</Item>
{open ? (
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
{iface.peersError ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
) : null}
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
) : null}
{(iface.peers ?? []).map((peer) => {
const pick: CatalogPick = {
interfaceName: iface.name,
peerPublicKey: peer.publicKey,
peerName: peer.name,
type: "wg",
}
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
const disabled = taken || mine || legacyTaken
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
key={peer.publicKey}
size="xs"
variant={selected ? "muted" : "default"}
render={
<button
type="button"
disabled={disabled}
onClick={() => togglePick(pick)}
/>
}
className={cn(
"h-11 min-h-11 flex-nowrap rounded-md py-0",
selected && "ring-1 ring-border",
disabled && "cursor-not-allowed opacity-50",
)}
>
<ItemMedia>
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
<KeyRoundIcon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
{peer.latestHandshake ? (
<Badge variant="success-light" size="xs">handshake</Badge>
) : null}
</ItemTitle>
</ItemContent>
<ItemActions>
{taken ? (
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
) : mine ? (
<span className="text-[10px] text-muted-foreground">уже привязан</span>
) : selected ? (
<Badge variant="secondary" size="xs">Выбран</Badge>
) : null}
</ItemActions>
</Item>
)
})}
</div>
) : null}
</div>
)
}
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0`)
const disabled = taken || mine
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
key={iface.name}
size="xs"
variant={selected ? "muted" : "default"}
render={
<button
type="button"
disabled={disabled}
onClick={() => togglePick(pick)}
/>
}
className={cn(
"h-11 min-h-11 flex-nowrap rounded-md py-0",
selected && "ring-1 ring-border",
disabled && "cursor-not-allowed opacity-50",
)}
>
<ItemMedia>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
<Icon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
{IFACE_TYPE_LABEL[iface.type]}
</Badge>
</ItemTitle>
</ItemContent>
<ItemActions>
{taken ? (
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{iface.boundUserLogin}</span>
) : mine ? (
<span className="text-[10px] text-muted-foreground">уже привязан</span>
) : selected ? (
<Badge variant="secondary" size="xs">Выбран</Badge>
) : null}
</ItemActions>
</Item>
)
})}
</div>
</FramePanel>
</Frame>
<Input
className="h-8 text-xs"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
</Button>
</div>
<div className="flex flex-col gap-1.5 py-3">
{form.bindings.length === 0 && (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
)}
{form.bindings.map((b) => (
<div key={b.id} className="flex items-center gap-2 py-1">
<Flag code={b.serverCountry} size={12} />
<span className="font-mono text-xs truncate">{bindingTitle(b)}</span>
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
) : null}
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
</Button>
</div>
))}
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={saving} onClick={submit}>
{isCreate ? "Создать" : "Сохранить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { UserSheet, type UserSheetProps }
+5
View File
@@ -134,6 +134,11 @@ services:
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
# IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow
FLOW_LISTEN_HOST: "0.0.0.0"
# Сначала wg-quick@wg-flow (адрес 10.255.254.1), затем recreate backend.
ports:
- "10.255.254.1:4739:4739/udp"
volumes:
- ./data/mm:/app/data
networks:
+4
View File
@@ -5,8 +5,12 @@ services:
restart: unless-stopped
ports:
- "8000:8000"
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
# - "10.255.254.1:4739:4739/udp"
environment:
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
# Внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- /opt/mmapp/data:/app/data
labels:
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { useEffect, useState } from "react"
import type { FlowAnalyticsDto } from "@mmapp/contracts/traffic-flow"
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
import { flowQuery } from "@/shared/api/traffic-flow"
function parseSseBlock(block: string): { event: string; data: string } {
let event = "message"
const dataLines: string[] = []
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim()
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
}
return { event, data: dataLines.join("\n") }
}
export function useFlowLive(opts: {
enabled: boolean
backendUrl: string
range: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
}): { sample: FlowAnalyticsDto | null; error: string | null } {
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!opts.enabled) {
setSample(null)
setError(null)
return
}
const ac = new AbortController()
setSample(null)
setError(null)
const path = `/api/traffic/flow/live${flowQuery({
range: opts.range,
serverId: opts.serverId,
userId: opts.userId,
iface: opts.iface,
dedup: opts.dedup,
excludeMesh: opts.excludeMesh,
excludeOverlay: opts.excludeOverlay,
})}`
const url = resolveApiUrl(opts.backendUrl, path)
let buf = ""
void (async () => {
try {
const res = await fetch(url, {
headers: withAuthHeaders({ Accept: "text/event-stream" }),
signal: ac.signal,
credentials: "include",
})
if (!res.ok || !res.body) {
setError(`live HTTP ${res.status}`)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (!ac.signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split("\n\n")
buf = parts.pop() ?? ""
for (const raw of parts) {
if (!raw.trim() || raw.trim().startsWith(":")) continue
const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) {
const parsed = JSON.parse(ev.data) as FlowAnalyticsDto
setSample(parsed)
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
} else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string }
setError(parsed.error ?? "live error")
}
}
}
} catch (e) {
if (ac.signal.aborted) return
setError(e instanceof Error ? e.message : "live error")
}
})()
return () => ac.abort()
}, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay])
return { sample, error }
}
+5
View File
@@ -197,6 +197,9 @@ export function hasPermission(
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
@@ -241,6 +244,7 @@ export function permissionForPath(pathname: string): string | null {
) {
return "mm:network:read"
}
if (pathname.startsWith("/users")) return "mm:users:read"
if (pathname.startsWith("/settings")) return "mm:settings:admin"
return "mm:dashboard:read"
}
@@ -249,6 +253,7 @@ export function firstAllowedPath(): string {
const candidates = [
"/dashboard",
"/servers",
"/users",
"/filters",
"/uptime",
"/traffic",
+103
View File
@@ -0,0 +1,103 @@
import type { FlowMapHop } from "@mmapp/contracts/traffic-flow"
import { fmtRate } from "@/lib/fmt-rate"
export interface MatchedNetflowHop {
bps: number
bpsFwd: number
bpsRev: number
bytes: number
}
function ifaceNorm(s: string | undefined): string {
return (s ?? "").trim().toLowerCase()
}
function pairKey(a: string, b: string): string {
const x = String(a)
const y = String(b)
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
}
function mergeDirected(hops: FlowMapHop[], mapFromId: string): MatchedNetflowHop {
let bytes = 0
let bpsFwd = 0
let bpsRev = 0
const from = String(mapFromId)
for (const h of hops) {
bytes += h.bytes
if (h.fromId === from) {
bpsFwd += h.bpsFwd
bpsRev += h.bpsRev
} else {
bpsFwd += h.bpsRev
bpsRev += h.bpsFwd
}
}
return { bytes, bpsFwd, bpsRev, bps: bpsFwd + bpsRev }
}
export function hopHasRate(h: MatchedNetflowHop | undefined): h is MatchedNetflowHop {
return h != null && Number.isFinite(h.bps) && h.bps > 0
}
export function formatNetflowRate(hop: MatchedNetflowHop): string {
return fmtRate(hop.bps / 1_000_000)
}
export function formatNetflowDir(hop: MatchedNetflowHop): string {
return `${fmtRate(hop.bpsFwd / 1_000_000)}${fmtRate(hop.bpsRev / 1_000_000)}`
}
/** GRE: сначала имя интерфейса туннеля на любом конце, иначе пара узлов. */
export function matchNetflowForGreEdge(
edge: {
tunnel: { name: string }
fromServer: { id: string }
toServer: { id: string }
},
hops: FlowMapHop[],
): MatchedNetflowHop | undefined {
const name = ifaceNorm(edge.tunnel.name)
const fromId = String(edge.fromServer.id)
const toId = String(edge.toServer.id)
if (name) {
const ifaceHits = hops.filter((h) =>
h.kind === "iface"
&& ifaceNorm(h.iface) === name
&& (h.fromId === fromId || h.fromId === toId),
)
if (ifaceHits.length) return mergeDirected(ifaceHits, fromId)
const greNamed = hops.filter((h) =>
h.kind === "gre"
&& ifaceNorm(h.iface) === name
&& (h.fromId === fromId || h.fromId === toId || h.toId === fromId || h.toId === toId),
)
if (greNamed.length) return mergeDirected(greNamed, fromId)
}
const want = pairKey(fromId, toId)
const pairHits = hops.filter((h) =>
h.kind === "gre" && Boolean(h.toId) && pairKey(h.fromId, h.toId) === want,
)
if (pairHits.length) return mergeDirected(pairHits, fromId)
return undefined
}
/** WAN-аплинк HR: kind wan, иначе iface с тем же именем на homeId. */
export function matchNetflowForWan(
homeId: string,
wanIface: string,
hops: FlowMapHop[],
): MatchedNetflowHop | undefined {
const id = String(homeId)
const iface = ifaceNorm(wanIface)
if (!iface) return undefined
const wanHits = hops.filter((h) =>
h.kind === "wan" && h.fromId === id && ifaceNorm(h.iface) === iface,
)
if (wanHits.length) return mergeDirected(wanHits, id)
const ifaceHits = hops.filter((h) =>
h.kind === "iface" && h.fromId === id && ifaceNorm(h.iface) === iface,
)
if (ifaceHits.length) return mergeDirected(ifaceHits, id)
return undefined
}
+3
View File
@@ -1,3 +1,4 @@
import { INIT_USERS } from "@/lib/users"
import {
asns,
domains,
@@ -36,6 +37,7 @@ export function mockSidebarBadgesByUrl(): Record<string, string> {
"/ip-ranges": formatSidebarBadgeCount(ipRanges.length),
"/asns": formatSidebarBadgeCount(asns.length),
"/servers": formatSidebarBadgeCount(servers.length),
"/users": formatSidebarBadgeCount(INIT_USERS.length),
"/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/gre": formatSidebarBadgeCount(greTunnels.length),
@@ -54,4 +56,5 @@ export interface SidebarCountsDto {
recursiveRoutes: number
certificates?: number
wireguard?: number
users?: number
}
+356
View File
@@ -0,0 +1,356 @@
import { servers } from "@/lib/data"
import type { InterfaceType, PermLevel, AppUserRole as Role } from "@mmapp/contracts/users"
export type { InterfaceType, PermLevel, Role }
export interface SectionPerm { section: string; level: PermLevel }
export interface ServerPerm { serverId: string; level: PermLevel }
export interface InterfaceBinding {
id: string
userId: string
serverId: string
serverName: string
serverSite: string
serverCountry: string
interfaceName: string
interfaceType: InterfaceType
peerPublicKey?: string
peerName?: string
comment: string
}
export interface CatalogPeer {
publicKey: string
name: string
comment: string
allowedIps: string[]
latestHandshake?: string
boundUserId: string | null
boundUserLogin: string | null
}
export interface CatalogIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
boundUserId: string | null
boundUserLogin: string | null
peers?: CatalogPeer[]
peersError?: string
}
export interface AppUserForm {
name: string
login: string
email: string
role: Role
active: boolean
sections: SectionPerm[]
servers: ServerPerm[]
bindings: InterfaceBinding[]
}
export interface AppUser {
id: string
name: string
login: string
email: string
role: Role
last: string
avatar: string
active: boolean
sections: SectionPerm[]
servers: ServerPerm[]
bindings: InterfaceBinding[]
}
export interface UserServerOption {
id: string
name: string
host: string
site: string
country: string
status?: "online" | "offline" | "degraded" | null
}
export const IFACE_TYPE_LABEL: Record<InterfaceType, string> = {
ether: "Ethernet",
gre: "GRE",
wg: "WireGuard",
other: "Прочие",
}
export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "other"]
export const ROLE_LABEL: Record<Role, string> = {
admin: "Администратор",
operator: "Оператор",
viewer: "Наблюдатель",
}
export const ROLE_COLOR: Record<Role, string> = {
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
viewer: "bg-muted text-muted-foreground border border-border",
}
export const PERM_OPTS: { v: PermLevel; label: string }[] = [
{ v: "none", label: "Нет" },
{ v: "read", label: "Просмотр" },
{ v: "write", label: "Управление" },
]
export const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-success/10 text-success",
}
export const SECTION_GROUP_DEFS: { group: string; items: string[] }[] = [
{ group: "Обзор", items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
{ group: "Данные", items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", items: ["Оповещения", "Сбор данных", "Пользователи", "Настройки"] },
]
export const ALL_SECTIONS = SECTION_GROUP_DEFS.flatMap((g) => g.items)
export function defaultSections(role: Role): SectionPerm[] {
return ALL_SECTIONS.map((section) => {
let level: PermLevel = "none"
if (role === "admin") level = "write"
else if (role === "operator") {
level = ["Серверы", "Пользователи", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы", "Диагностика GRE", "Оптимизатор маршрутов", "OSPF"].includes(section)
? "write"
: "read"
} else if (role === "viewer") {
level = ["Настройки", "Терминал"].includes(section) ? "none" : "read"
}
return { section, level }
})
}
export function defaultServers(role: Role, serverList: { id: string }[] = servers): ServerPerm[] {
return serverList.map((s) => ({
serverId: s.id,
level: role === "admin" ? "write" : "read",
}))
}
export function userInitials(name: string): string {
return name.trim().split(/\s+/).map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
srv1: [
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "ether2", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "d.fedorov@company.io" },
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "i.petrov@company.io" },
{
name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
peers: [
{ publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak", comment: "", allowedIps: ["10.8.0.2/32"], boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ publicKey: "mockPeerKeyBBBB0123456789", name: "laptop-ak", comment: "", allowedIps: ["10.8.0.3/32"], boundUserId: null, boundUserLogin: null, latestHandshake: "12s" },
],
},
],
srv7: [
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "d.fedorov@company.io" },
{
name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
peers: [
{ publicKey: "mockPeerKeyLABB0123456789", name: "lab-peer", comment: "", allowedIps: ["10.9.0.2/32"], boundUserId: null, boundUserLogin: null },
],
},
],
}
export function bindingDiffKey(b: Pick<InterfaceBinding, "serverId" | "interfaceName" | "peerPublicKey">): string {
return `${b.serverId}::${b.interfaceName}::${b.peerPublicKey ?? ""}`
}
export function bindingTitle(b: Pick<InterfaceBinding, "interfaceName" | "interfaceType" | "peerName" | "peerPublicKey">): string {
if (b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)) {
return `${b.peerName || "peer"} · ${b.interfaceName}`
}
return b.interfaceName
}
function bind(
id: string,
userId: string,
serverId: string,
interfaceName: string,
interfaceType: InterfaceType,
comment: string,
peer?: { publicKey: string; name: string },
): InterfaceBinding {
const srv = servers.find((s) => s.id === serverId)
return {
id,
userId,
serverId,
serverName: srv?.name ?? serverId,
serverSite: srv?.site ?? "—",
serverCountry: srv?.country ?? "UN",
interfaceName,
interfaceType,
peerPublicKey: peer?.publicKey,
peerName: peer?.name,
comment,
}
}
export const INIT_USERS: AppUser[] = [
{
id: "u1", name: "Александр Коротаев", login: "a.korotaev@company.io", email: "a.korotaev@company.io",
role: "admin", last: "сейчас", avatar: "АК", active: true,
sections: defaultSections("admin"), servers: defaultServers("admin"),
bindings: [
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB", { publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak" }),
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
],
},
{
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov@company.io", email: "d.fedorov@company.io",
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
sections: defaultSections("operator"), servers: defaultServers("operator"),
bindings: [
bind("b7", "u2", "srv1", "gre-spb-branch", "gre", "Филиал SPB"),
bind("b8", "u2", "srv7", "gre-spb-branch", "gre", "Филиал LAB"),
],
},
{
id: "u3", name: "Мария Соколова", login: "m.sokolova@company.io", email: "m.sokolova@company.io",
role: "viewer", last: "вчера", avatar: "МС", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
bindings: [],
},
{
id: "u4", name: "Игорь Петров", login: "i.petrov@company.io", email: "i.petrov@company.io",
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
sections: defaultSections("operator"), servers: defaultServers("operator"),
bindings: [
bind("b9", "u4", "srv1", "gre-retail-01", "gre", "Магазин #1"),
],
},
]
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
const base = MOCK_IFACE_CATALOG[serverId] ?? []
return base.map((iface) => {
if (iface.type === "wg") {
const peers = (iface.peers ?? []).map((peer) => {
const owner = users.find((u) =>
u.bindings.some((b) =>
b.serverId === serverId
&& b.interfaceName === iface.name
&& (b.peerPublicKey ?? "") === peer.publicKey,
),
)
if (!owner) return { ...peer, boundUserId: null, boundUserLogin: null }
return { ...peer, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
})
const legacy = users.find((u) =>
u.bindings.some((b) =>
b.serverId === serverId
&& b.interfaceName === iface.name
&& !(b.peerPublicKey ?? ""),
),
)
return {
...iface,
boundUserId: legacy?.id ?? null,
boundUserLogin: legacy ? (legacy.email || legacy.login) : null,
peers,
}
}
const owner = users.find((u) =>
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name && !(b.peerPublicKey ?? "")),
)
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
})
}
export function groupBindingsByServer(bindings: InterfaceBinding[]): Array<{
serverId: string
serverName: string
serverSite: string
serverCountry: string
types: Array<{ type: InterfaceType; items: InterfaceBinding[] }>
}> {
const byServer = new Map<string, InterfaceBinding[]>()
for (const b of bindings) {
const arr = byServer.get(b.serverId) ?? []
arr.push(b)
byServer.set(b.serverId, arr)
}
return [...byServer.entries()].map(([serverId, items]) => {
const first = items[0]
const types = IFACE_TYPE_ORDER
.map((type) => ({ type, items: items.filter((i) => i.interfaceType === type) }))
.filter((g) => g.items.length > 0)
return {
serverId,
serverName: first?.serverName ?? serverId,
serverSite: first?.serverSite ?? "—",
serverCountry: first?.serverCountry ?? "UN",
types,
}
})
}
export type UserAccessWriteKind = "full" | "none" | "partial"
export interface UserAccessSummary {
sectionsGranted: number
sectionsTotal: number
serversGranted: number
serversTotal: number
writeKind: UserAccessWriteKind
writeSections: string[]
}
export function summarizeUserAccess(
user: Pick<AppUser, "role" | "sections" | "servers">,
serversTotal: number,
sectionsTotal = ALL_SECTIONS.length,
): UserAccessSummary {
const isAdmin = user.role === "admin"
const sectionsGranted = isAdmin
? sectionsTotal
: user.sections.filter((s) => s.level !== "none").length
const serversGranted = isAdmin
? serversTotal
: user.servers.filter((s) => s.level !== "none").length
const writeSections = isAdmin
? []
: user.sections.filter((s) => s.level === "write").map((s) => s.section)
const writeKind: UserAccessWriteKind = isAdmin
? "full"
: writeSections.length === 0
? "none"
: "partial"
return {
sectionsGranted,
sectionsTotal,
serversGranted,
serversTotal,
writeKind,
writeSections,
}
}
BIN
View File
Binary file not shown.
+8
View File
@@ -37,6 +37,14 @@
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
},
"./users": {
"types": "./dist/users.d.ts",
"default": "./dist/users.js"
},
"./traffic-flow": {
"types": "./dist/traffic-flow.d.ts",
"default": "./dist/traffic-flow.js"
}
},
"dependencies": {
+2
View File
@@ -4,3 +4,5 @@ export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
export * from "./users.js"
export * from "./traffic-flow.js"
+290
View File
@@ -0,0 +1,290 @@
import { z } from "zod"
export const flowHostPeerSchema = z.object({
serverId: z.number().int().positive(),
name: z.string(),
publicKey: z.string().min(1),
allowedIps: z.array(z.string().min(1)).min(1),
address: z.string().min(1),
endpoint: z.string().optional(),
})
export const trafficFlowSettingsDtoSchema = z.object({
enabled: z.boolean(),
collectorIp: z.string().min(1),
flowListenPort: z.number().int().positive(),
wgListenPort: z.number().int().positive(),
prefix: z.string().min(1),
publicEndpoint: z.string(),
hostPublicKey: z.string(),
hasHostPrivateKey: z.boolean(),
hubServerId: z.number().int().positive().nullable(),
retentionHours: z.number().int().positive(),
topN: z.number().int().positive(),
lastDatagramAt: z.string().nullable(),
lastExporterIp: z.string().nullable(),
lastError: z.string().nullable(),
packetsReceived: z.number().int().nonnegative(),
listenerBound: z.boolean(),
listenerAddress: z.string().nullable(),
peers: z.array(flowHostPeerSchema),
})
export const trafficFlowSettingsPatchSchema = z.object({
enabled: z.boolean().optional(),
collectorIp: z.string().min(1).optional(),
flowListenPort: z.number().int().positive().optional(),
wgListenPort: z.number().int().positive().optional(),
prefix: z.string().min(1).optional(),
publicEndpoint: z.string().optional(),
hubServerId: z.number().int().positive().nullable().optional(),
retentionHours: z.number().int().positive().optional(),
topN: z.number().int().positive().max(1000).optional(),
})
export const trafficFlowOverlayRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
publicEndpoint: z.string().optional(),
})
export const trafficFlowHostFileSchema = z.object({
id: z.string(),
label: z.string(),
filename: z.string(),
code: z.string(),
})
export const trafficFlowOverlayResultSchema = z.object({
ok: z.boolean(),
serverId: z.number().int(),
interfaceName: z.string(),
address: z.string(),
publicKey: z.string(),
linuxPeerBlock: z.string(),
trafficFlow: z.boolean(),
steps: z.array(z.string()),
hostFiles: z.array(trafficFlowHostFileSchema),
})
export const flowTalkerDtoSchema = z.object({
serverId: z.string(),
serverName: z.string(),
src: z.string(),
dst: z.string(),
proto: z.number().int(),
protoName: z.string(),
srcPort: z.number().int(),
dstPort: z.number().int(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
inIface: z.string(),
inIfaceIndex: z.string().optional(),
outIface: z.string().optional(),
nextHop: z.string().optional(),
application: z.string().optional(),
category: z.string().optional(),
service: z.string().optional(),
dstCountry: z.string().optional(),
dstAsn: z.number().int().optional(),
clientId: z.string().optional(),
clientName: z.string().optional(),
enId: z.string().optional(),
enName: z.string().optional(),
plane: z.string().optional(),
})
export const flowStatsDtoSchema = z.object({
exportersOnline: z.number().int().nonnegative(),
bytesPerMin: z.number().nonnegative(),
uniqueSrc: z.number().int().nonnegative(),
uniqueDst: z.number().int().nonnegative(),
topProto: z.string(),
talkers: z.array(flowTalkerDtoSchema),
lastExporterIp: z.string().nullable().optional(),
lastError: z.string().nullable().optional(),
packetsReceived: z.number().int().nonnegative().optional(),
lastDatagramAt: z.string().nullable().optional(),
listenerBound: z.boolean().optional(),
listenerAddress: z.string().nullable().optional(),
})
export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
export const flowBreakdownRowSchema = z.object({
id: z.string(),
label: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
percent: z.number().nonnegative(),
})
export const flowIfaceChipSchema = z.object({
name: z.string(),
index: z.string(),
bps: z.number().nonnegative(),
})
export const flowEntityCardSchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string(),
site: z.string(),
country: z.string(),
status: z.enum(["online", "offline", "degraded"]),
rxNow: z.number(),
txNow: z.number(),
sessions: z.number().int().nonnegative(),
rxSeries: z.array(z.number()),
txSeries: z.array(z.number()),
bytes: z.number().nonnegative(),
})
export const flowMapEdgeSchema = z.object({
fromId: z.string(),
fromLabel: z.string(),
fromCountry: z.string(),
toCountry: z.string(),
toAsn: z.number().int().nonnegative(),
category: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
export const flowPathRowSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string(),
ifaces: z.string(),
serverId: z.string(),
serverName: z.string(),
inIface: z.string(),
outIface: z.string(),
enId: z.string(),
enName: z.string(),
dst: z.string(),
service: z.string(),
category: z.string(),
plane: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
export const flowAnalyticsDtoSchema = z.object({
bpsNow: z.number().nonnegative(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
conversations: z.number().int().nonnegative(),
conversationsRaw: z.number().int().nonnegative().optional(),
uniqueSrc: z.number().int().nonnegative(),
uniqueDst: z.number().int().nonnegative(),
topProto: z.string(),
topCategory: z.string().optional(),
rxSeries: z.array(z.number()),
txSeries: z.array(z.number()),
applications: z.array(flowBreakdownRowSchema),
protocols: z.array(flowBreakdownRowSchema),
sources: z.array(flowBreakdownRowSchema),
destinations: z.array(flowBreakdownRowSchema),
interfaces: z.array(flowBreakdownRowSchema),
asns: z.array(flowBreakdownRowSchema).optional(),
countries: z.array(flowBreakdownRowSchema).optional(),
categories: z.array(flowBreakdownRowSchema).optional(),
services: z.array(flowBreakdownRowSchema).optional(),
mapEdges: z.array(flowMapEdgeSchema).optional(),
conversationsList: z.array(flowTalkerDtoSchema),
paths: z.array(flowPathRowSchema).optional(),
ifaces: z.array(flowIfaceChipSchema),
live: z.boolean(),
dedupApplied: z.boolean().optional(),
degraded: z.boolean().optional(),
bytesPayload: z.number().nonnegative().optional(),
bytesOverlay: z.number().nonnegative().optional(),
bytesMesh: z.number().nonnegative().optional(),
bytesWire: z.number().nonnegative().optional(),
bpsOverlay: z.number().nonnegative().optional(),
bpsWire: z.number().nonnegative().optional(),
excludeMeshApplied: z.boolean().optional(),
excludeOverlayApplied: z.boolean().optional(),
})
export const flowExportersDtoSchema = z.object({
exporters: z.array(flowEntityCardSchema),
lastExporterIp: z.string().nullable().optional(),
lastError: z.string().nullable().optional(),
packetsReceived: z.number().int().nonnegative().optional(),
lastDatagramAt: z.string().nullable().optional(),
listenerBound: z.boolean().optional(),
listenerAddress: z.string().nullable().optional(),
})
export const flowClientsDtoSchema = z.object({
clients: z.array(flowEntityCardSchema),
})
export const flowMonthlyDtoSchema = z.object({
month: z.string(),
bytes: z.number().nonnegative(),
countries: z.array(flowBreakdownRowSchema),
services: z.array(flowBreakdownRowSchema),
asns: z.array(flowBreakdownRowSchema),
})
export const flowPurgeDtoSchema = z.object({
ok: z.literal(true),
deleted: z.object({
buckets: z.number().int().nonnegative(),
minuteStats: z.number().int().nonnegative(),
minuteDims: z.number().int().nonnegative(),
dailyDims: z.number().int().nonnegative(),
}),
fileBytesBefore: z.number().int().nonnegative(),
fileBytesAfter: z.number().int().nonnegative(),
vacuumed: z.boolean(),
})
export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"])
export const flowMapHopDtoSchema = z.object({
fromId: z.string(),
fromLabel: z.string(),
toId: z.string(),
toLabel: z.string(),
kind: flowMapHopKindSchema,
iface: z.string().optional(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
bpsFwd: z.number().nonnegative(),
bpsRev: z.number().nonnegative(),
})
export const flowMapHopsDtoSchema = z.object({
hops: z.array(flowMapHopDtoSchema),
live: z.boolean(),
rangeMinutes: z.number().int().positive(),
windowSec: z.number().positive(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
excludeOverlayApplied: z.boolean(),
})
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
export type FlowIfaceChip = z.infer<typeof flowIfaceChipSchema>
export type FlowEntityCard = z.infer<typeof flowEntityCardSchema>
export type FlowMapEdge = z.infer<typeof flowMapEdgeSchema>
export type FlowPathRow = z.infer<typeof flowPathRowSchema>
export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema>
export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod"
export const appUserRoleSchema = z.enum(["admin", "operator", "viewer"])
export const permLevelSchema = z.enum(["none", "read", "write"])
export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "other"])
export const sectionPermSchema = z.object({
section: z.string().min(1),
level: permLevelSchema,
})
export const serverPermSchema = z.object({
serverId: z.string().min(1),
level: permLevelSchema,
})
export const userBindingSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
serverId: z.number().int().positive(),
serverName: z.string(),
serverSite: z.string(),
serverCountry: z.string(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema,
peerPublicKey: z.string().default(""),
peerName: z.string().default(""),
comment: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserReadSchema = z.object({
id: z.string().min(1),
name: z.string(),
login: z.string(),
email: z.string(),
role: appUserRoleSchema,
active: z.boolean(),
avatar: z.string(),
lastSeen: z.string().nullable(),
sections: z.array(sectionPermSchema),
servers: z.array(serverPermSchema),
bindings: z.array(userBindingSchema),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserCreateSchema = z.object({
name: z.string().min(1),
login: z.string().min(1),
email: z.string().min(1),
role: appUserRoleSchema.default("viewer"),
active: z.boolean().default(true),
avatar: z.string().optional(),
lastSeen: z.string().nullable().optional(),
sections: z.array(sectionPermSchema).default([]),
servers: z.array(serverPermSchema).default([]),
})
export const appUserUpdateSchema = appUserCreateSchema.partial()
export const appUserIdParamSchema = z.object({
id: z.string().min(1),
})
export const bindingIdParamSchema = z.object({
id: z.string().min(1),
bindingId: z.string().min(1),
})
export const userBindingCreateSchema = z.object({
serverId: z.coerce.number().int().positive(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema.optional(),
peerPublicKey: z.string().optional(),
peerName: z.string().optional(),
comment: z.string().optional(),
})
export const interfaceCatalogQuerySchema = z.object({
serverId: z.coerce.number().int().positive(),
})
export const catalogPeerSchema = z.object({
publicKey: z.string(),
name: z.string(),
comment: z.string(),
allowedIps: z.array(z.string()),
latestHandshake: z.string().optional(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
})
export const catalogInterfaceSchema = z.object({
name: z.string(),
type: interfaceTypeSchema,
running: z.boolean(),
disabled: z.boolean(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
peers: z.array(catalogPeerSchema).optional(),
peersError: z.string().optional(),
})
export const appUserListSchema = z.array(appUserReadSchema)
export type AppUserRole = z.infer<typeof appUserRoleSchema>
export type PermLevel = z.infer<typeof permLevelSchema>
export type InterfaceType = z.infer<typeof interfaceTypeSchema>
export type SectionPerm = z.infer<typeof sectionPermSchema>
export type ServerPerm = z.infer<typeof serverPermSchema>
export type UserBinding = z.infer<typeof userBindingSchema>
export type AppUserRead = z.infer<typeof appUserReadSchema>
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
export type CatalogPeer = z.infer<typeof catalogPeerSchema>
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
+144
View File
@@ -0,0 +1,144 @@
import type {
FlowAnalyticsDto,
FlowClientsDto,
FlowExportersDto,
FlowMapHopsDto,
FlowMonthlyDto,
FlowPurgeDto,
FlowStatsDto,
TrafficFlowHostFile,
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
} from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
export type { TrafficFlowHostFile }
export async function getTrafficFlowSettings(baseUrl: string): Promise<TrafficFlowSettingsDto> {
return requestJson<TrafficFlowSettingsDto>(baseUrl, "/api/traffic/flow/settings")
}
export async function putTrafficFlowSettings(
baseUrl: string,
patch: TrafficFlowSettingsPatch,
): Promise<{ ok: boolean; settings: TrafficFlowSettingsDto }> {
return requestJson(baseUrl, "/api/traffic/flow/settings", {
method: "PUT",
body: JSON.stringify(patch),
})
}
export async function generateTrafficFlowKeys(baseUrl: string): Promise<{
ok: boolean
created: boolean
publicKey: string
settings: TrafficFlowSettingsDto
}> {
return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" })
}
export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
return requestJson(baseUrl, "/api/traffic/flow/host-files")
}
export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({ serverId, publicEndpoint }),
})
}
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
}
function flowQuery(params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
}): string {
const q = new URLSearchParams()
if (params.range) q.set("range", params.range)
if (params.serverId) q.set("serverId", params.serverId)
if (params.userId) q.set("userId", params.userId)
if (params.iface && params.iface !== "__all__") q.set("iface", params.iface)
if (params.dedup === false) q.set("dedup", "0")
else if (params.dedup === true) q.set("dedup", "1")
if (params.excludeMesh === false) q.set("excludeMesh", "0")
else if (params.excludeMesh === true) q.set("excludeMesh", "1")
if (params.excludeOverlay === false) q.set("excludeOverlay", "0")
else if (params.excludeOverlay === true) q.set("excludeOverlay", "1")
const s = q.toString()
return s ? `?${s}` : ""
}
export async function getFlowExporters(baseUrl: string, range = "5m"): Promise<FlowExportersDto> {
return requestJson<FlowExportersDto>(baseUrl, `/api/traffic/flow/exporters?range=${encodeURIComponent(range)}`)
}
export async function getFlowClients(baseUrl: string, range = "5m"): Promise<FlowClientsDto> {
return requestJson<FlowClientsDto>(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`)
}
export async function getFlowMapHops(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
} = {},
): Promise<FlowMapHopsDto> {
return requestJson<FlowMapHopsDto>(baseUrl, `/api/traffic/flow/map-hops${flowQuery({
range: params.range ?? "5m",
serverId: params.serverId,
userId: params.userId,
iface: params.iface,
dedup: params.dedup,
excludeMesh: params.excludeMesh,
excludeOverlay: params.excludeOverlay,
})}`)
}
export async function getFlowAnalytics(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
},
): Promise<FlowAnalyticsDto> {
return requestJson<FlowAnalyticsDto>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`)
}
export async function getFlowMonthly(
baseUrl: string,
params: { month: string; serverId?: string },
): Promise<FlowMonthlyDto> {
const q = new URLSearchParams()
q.set("month", params.month)
if (params.serverId) q.set("serverId", params.serverId)
return requestJson<FlowMonthlyDto>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`)
}
export async function purgeTrafficFlowData(baseUrl: string): Promise<FlowPurgeDto> {
return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" })
}
export { flowQuery }
+107
View File
@@ -0,0 +1,107 @@
import {
appUserReadSchema,
catalogInterfaceSchema,
userBindingSchema,
type AppUserCreate,
type AppUserRead,
type AppUserUpdate,
type CatalogInterface,
type UserBinding,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import { z } from "zod"
import { requestJson } from "@/shared/api/http-client"
import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users"
const usersListPayload = z.object({ users: z.array(appUserReadSchema) })
const userPayload = z.object({ user: appUserReadSchema })
const bindingPayload = z.object({ binding: userBindingSchema })
const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) })
export function toFrontendBinding(b: UserBinding): InterfaceBinding {
return {
id: b.id,
userId: b.userId,
serverId: String(b.serverId),
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey || undefined,
peerName: b.peerName || undefined,
comment: b.comment,
}
}
export function toFrontendUser(u: AppUserRead): AppUser {
return {
id: u.id,
name: u.name,
login: u.login,
email: u.email,
role: u.role,
last: u.lastSeen ?? "—",
avatar: u.avatar,
active: u.active,
sections: u.sections,
servers: u.servers,
bindings: u.bindings.map(toFrontendBinding),
}
}
export async function listAppUsers(baseUrl: string): Promise<AppUser[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/users")
return usersListPayload.parse(payload).users.map(toFrontendUser)
}
export async function createAppUser(baseUrl: string, data: AppUserCreate): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, "/api/users", {
method: "POST",
body: JSON.stringify(data),
})
return toFrontendUser(userPayload.parse(payload).user)
}
export async function updateAppUser(baseUrl: string, id: string, data: AppUserUpdate): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
})
return toFrontendUser(userPayload.parse(payload).user)
}
export async function deleteAppUser(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/users/${id}`, { method: "DELETE" })
}
export async function createUserBinding(
baseUrl: string,
userId: string,
data: UserBindingCreate,
): Promise<InterfaceBinding> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${userId}/bindings`, {
method: "POST",
body: JSON.stringify(data),
})
return toFrontendBinding(bindingPayload.parse(payload).binding)
}
export async function deleteUserBinding(
baseUrl: string,
userId: string,
bindingId: string,
): Promise<void> {
await requestJson<void>(baseUrl, `/api/users/${userId}/bindings/${bindingId}`, { method: "DELETE" })
}
export async function listInterfaceCatalog(
baseUrl: string,
serverId: string,
): Promise<CatalogIface[]> {
const payload = await requestJson<unknown>(
baseUrl,
`/api/users/interface-catalog?serverId=${encodeURIComponent(serverId)}`,
)
return catalogPayload.parse(payload).interfaces as CatalogInterface[]
}
+1 -1
View File
File diff suppressed because one or more lines are too long