Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 |
@@ -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 записей; раскройте строку для полей и текста ошибки."
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
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"
|
||||
|
||||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
||||
@@ -148,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)
|
||||
@@ -266,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
|
||||
|
||||
@@ -417,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 && (
|
||||
@@ -434,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" />}
|
||||
@@ -450,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" />
|
||||
@@ -461,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
|
||||
@@ -893,6 +926,13 @@ export default function SettingsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<NetflowPurgeConfirm
|
||||
open={dbPurgeOpen}
|
||||
busy={dbPurgeBusy}
|
||||
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
||||
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
||||
/>
|
||||
|
||||
<FileImportDialog
|
||||
open={dbRestoreDialogOpen}
|
||||
onOpenChange={setDbRestoreDialogOpen}
|
||||
|
||||
+407
-46
@@ -11,13 +11,20 @@ import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||
import { useFlowLive } from "@/hooks/use-flow-live"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { getFlowAnalytics, getFlowClients, getFlowExporters, getFlowMonthly, getTrafficFlows } from "@/shared/api/traffic-flow"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
||||
import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel"
|
||||
import type { FlowAnalyticsDto, FlowEntityCard, FlowMonthlyDto, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
IFACE_TYPE_LABEL,
|
||||
@@ -43,6 +50,70 @@ function addSeries(a: number[], b: number[]): number[] {
|
||||
return a.map((v, i) => v + (b[i] ?? 0))
|
||||
}
|
||||
|
||||
function flowIngestLine(stats: FlowStatsDto | null): string | null {
|
||||
if (!stats) return null
|
||||
const listener = stats.listenerBound
|
||||
? (stats.listenerAddress ?? "слушает")
|
||||
: "не слушает"
|
||||
const last = stats.lastDatagramAt
|
||||
? new Date(stats.lastDatagramAt).toLocaleString("ru-RU")
|
||||
: "—"
|
||||
const exporter = stats.lastExporterIp ? ` · ${stats.lastExporterIp}` : ""
|
||||
const err = stats.lastError ? ` · ${stats.lastError}` : ""
|
||||
return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}`
|
||||
}
|
||||
|
||||
function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): string | undefined {
|
||||
if (!stats) return undefined
|
||||
if (stats.lastError) return stats.lastError
|
||||
if (stats.packetsReceived) {
|
||||
return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но сессии ещё не записаны.`
|
||||
}
|
||||
if (stats.listenerBound === false && !collectorAlive) {
|
||||
return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически."
|
||||
}
|
||||
if (stats.listenerBound || collectorAlive) {
|
||||
return "Коллектор жив, IPFIX ещё не доходит. На jump-host у target Src должен быть 0.0.0.0 (авто)."
|
||||
}
|
||||
return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow."
|
||||
}
|
||||
|
||||
function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto {
|
||||
const emptySeries = Array(60).fill(0) as number[]
|
||||
return {
|
||||
bpsNow: 0,
|
||||
bytes: m.bytes,
|
||||
packets: 0,
|
||||
conversations: 0,
|
||||
conversationsRaw: 0,
|
||||
uniqueSrc: 0,
|
||||
uniqueDst: 0,
|
||||
topProto: "—",
|
||||
topCategory: "—",
|
||||
rxSeries: emptySeries,
|
||||
txSeries: emptySeries,
|
||||
applications: [],
|
||||
protocols: [],
|
||||
sources: [],
|
||||
destinations: [],
|
||||
interfaces: [],
|
||||
asns: m.asns,
|
||||
countries: m.countries,
|
||||
categories: [],
|
||||
services: m.services,
|
||||
mapEdges: [],
|
||||
conversationsList: [],
|
||||
paths: [],
|
||||
ifaces: [],
|
||||
live: false,
|
||||
degraded: false,
|
||||
bytesPayload: m.bytes,
|
||||
bytesOverlay: 0,
|
||||
bytesMesh: 0,
|
||||
bytesWire: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BoundIfaceTraffic {
|
||||
@@ -53,6 +124,8 @@ interface BoundIfaceTraffic {
|
||||
userName: string
|
||||
interfaceName: string
|
||||
interfaceType: InterfaceType
|
||||
peerPublicKey?: string
|
||||
peerName?: string
|
||||
comment: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
@@ -141,6 +214,11 @@ function hashSeed(s: string): number {
|
||||
return Math.abs(h)
|
||||
}
|
||||
|
||||
function boundIfaceLabel(c: Pick<BoundIfaceTraffic, "interfaceName" | "interfaceType" | "peerName">): string {
|
||||
if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}`
|
||||
return c.interfaceName
|
||||
}
|
||||
|
||||
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
||||
return INIT_USERS.flatMap((u) =>
|
||||
u.bindings.map((b) => {
|
||||
@@ -149,13 +227,15 @@ function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
||||
const rxNow = offline ? 0 : 12 + (seed % 140)
|
||||
const txNow = offline ? 0 : 8 + (seed % 110)
|
||||
return {
|
||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`,
|
||||
bindingId: b.id,
|
||||
userId: u.id,
|
||||
userLogin: u.login,
|
||||
userName: u.name,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
comment: b.comment,
|
||||
serverId: b.serverId,
|
||||
serverName: b.serverName,
|
||||
@@ -257,9 +337,9 @@ const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
|
||||
|
||||
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
||||
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
|
||||
type Range = (typeof TRAFFIC_RANGE_KEYS)[number]
|
||||
type Range = (typeof TRAFFIC_RANGE_KEYS)[number] | "30d"
|
||||
|
||||
const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
||||
const TRAFFIC_RANGE_LABELS: Record<(typeof TRAFFIC_RANGE_KEYS)[number], string> = {
|
||||
"5m": "5м",
|
||||
"15m": "15м",
|
||||
"1h": "1ч",
|
||||
@@ -267,7 +347,8 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
||||
"24h": "24ч",
|
||||
}
|
||||
|
||||
type GroupMode = "servers" | "users" | "ifaces"
|
||||
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
||||
type FlowScope = "servers" | "users"
|
||||
type SortField = "rx" | "tx" | "name" | "sessions"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
@@ -397,7 +478,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<StatusDot status={c.status} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{c.interfaceName}</p>
|
||||
<p className="text-xs font-mono font-medium truncate">{boundIfaceLabel(c)}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,7 +516,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
|
||||
<StatusDot status={c.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-semibold">{c.interfaceName}</span>
|
||||
<span className="text-xs font-mono font-semibold">{boundIfaceLabel(c)}</span>
|
||||
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
||||
{showServer && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
@@ -652,7 +733,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
|
||||
<DetailHeader range={range} setRange={setRange}>
|
||||
<StatusDot status={sel.status} />
|
||||
<div className="leading-tight min-w-0">
|
||||
<h2 className="text-base font-mono font-semibold">{sel.interfaceName}</h2>
|
||||
<h2 className="text-base font-mono font-semibold">{boundIfaceLabel(sel)}</h2>
|
||||
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
||||
</div>
|
||||
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
||||
@@ -692,13 +773,14 @@ const GROUP_MODES: Array<{ mode: GroupMode; icon: ReactNode; label: string }> =
|
||||
{ mode: "servers", icon: <ServerIcon className="size-3" />, label: "Серверы" },
|
||||
{ mode: "users", icon: <UsersIcon className="size-3" />, label: "Клиенты" },
|
||||
{ mode: "ifaces", icon: <CableIcon className="size-3" />, label: "Интерфейсы" },
|
||||
{ mode: "flows", icon: <GitBranchIcon className="size-3" />, label: "Потоки" },
|
||||
]
|
||||
|
||||
const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [
|
||||
{ field: "rx", label: "RX" },
|
||||
{ field: "tx", label: "TX" },
|
||||
{ field: "name", label: "Имя" },
|
||||
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users"] },
|
||||
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] },
|
||||
]
|
||||
|
||||
export default function TrafficPage() {
|
||||
@@ -722,6 +804,17 @@ export default function TrafficPage() {
|
||||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||||
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
||||
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
||||
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
|
||||
const [flowExporters, setFlowExporters] = useState<FlowEntityCard[]>([])
|
||||
const [flowClients, setFlowClients] = useState<FlowEntityCard[]>([])
|
||||
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
|
||||
const [flowIface, setFlowIface] = useState("__all__")
|
||||
const [flowDedup, setFlowDedup] = useState(true)
|
||||
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
|
||||
const [flowExcludeOverlay, setFlowExcludeOverlay] = useState(true)
|
||||
const [overlayOpen, setOverlayOpen] = useState(false)
|
||||
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||||
const effectiveMode: GroupMode = groupMode
|
||||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||||
@@ -729,6 +822,18 @@ export default function TrafficPage() {
|
||||
serverId: selectedId,
|
||||
iface: selectedIface,
|
||||
})
|
||||
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
|
||||
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
|
||||
enabled: flowLiveEnabled,
|
||||
backendUrl,
|
||||
range,
|
||||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
userId: flowScope === "users" ? selectedId : undefined,
|
||||
iface: flowIface,
|
||||
dedup: flowDedup,
|
||||
excludeMesh: flowExcludeMesh,
|
||||
excludeOverlay: flowExcludeOverlay,
|
||||
})
|
||||
|
||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||
return {
|
||||
@@ -773,7 +878,7 @@ export default function TrafficPage() {
|
||||
setLiveBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
const q = encodeURIComponent(targetRange)
|
||||
const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
|
||||
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
||||
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
||||
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
||||
@@ -812,6 +917,73 @@ export default function TrafficPage() {
|
||||
void loadLiveTraffic(range)
|
||||
}, [isLive, range, loadLiveTraffic])
|
||||
|
||||
const loadFlows = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setLiveBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
const [stats, exporters, clients] = await Promise.all([
|
||||
getTrafficFlows(backendUrl, range),
|
||||
getFlowExporters(backendUrl, range),
|
||||
getFlowClients(backendUrl, range),
|
||||
])
|
||||
setFlowStats(stats)
|
||||
setFlowExporters(exporters.exporters)
|
||||
setFlowClients(clients.clients)
|
||||
setSelectedId((prev) => {
|
||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||
if (list.some((x) => x.id === prev)) return prev
|
||||
return list[0]?.id ?? ""
|
||||
})
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
||||
} finally {
|
||||
setLiveBusy(false)
|
||||
}
|
||||
}, [isLive, backendUrl, range, flowScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || effectiveMode !== "flows") return
|
||||
void loadFlows()
|
||||
}, [isLive, effectiveMode, loadFlows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || effectiveMode !== "flows" || !selectedId) {
|
||||
setFlowAnalytics(null)
|
||||
return
|
||||
}
|
||||
if (range === "5m") {
|
||||
setFlowAnalytics(null)
|
||||
return
|
||||
}
|
||||
if (range === "30d") {
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
void getFlowMonthly(backendUrl, {
|
||||
month,
|
||||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
|
||||
return
|
||||
}
|
||||
void getFlowAnalytics(backendUrl, {
|
||||
range,
|
||||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
userId: flowScope === "users" ? selectedId : undefined,
|
||||
iface: flowIface,
|
||||
dedup: flowDedup,
|
||||
excludeMesh: flowExcludeMesh,
|
||||
excludeOverlay: flowExcludeOverlay,
|
||||
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
|
||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
setFlowIface("__all__")
|
||||
}, [selectedId, flowScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||||
const activeUserTraffic = isLive ? liveUsers : userTraffic
|
||||
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
|
||||
@@ -852,9 +1024,21 @@ export default function TrafficPage() {
|
||||
|
||||
const handleModeChange = (next: GroupMode) => {
|
||||
setGroupMode(next)
|
||||
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
||||
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
||||
else setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
||||
if (next === "servers") {
|
||||
setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
||||
if (range === "30d") setRange("1h")
|
||||
} else if (next === "users") {
|
||||
setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
||||
if (range === "30d") setRange("1h")
|
||||
} else if (next === "ifaces") {
|
||||
setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
||||
if (range === "30d") setRange("1h")
|
||||
} else if (next === "flows") {
|
||||
setFlowScope("servers")
|
||||
setFlowIface("__all__")
|
||||
setRange("5m")
|
||||
setSelectedId(flowExporters[0]?.id ?? "")
|
||||
}
|
||||
setSortField("rx")
|
||||
setSortDir("desc")
|
||||
setSearch("")
|
||||
@@ -897,6 +1081,7 @@ export default function TrafficPage() {
|
||||
return [...activeBoundIfaces]
|
||||
.filter(c => !q
|
||||
|| c.interfaceName.toLowerCase().includes(q)
|
||||
|| (c.peerName ?? "").toLowerCase().includes(q)
|
||||
|| c.comment.toLowerCase().includes(q)
|
||||
|| c.userLogin.toLowerCase().includes(q))
|
||||
.sort((a, b) => {
|
||||
@@ -908,6 +1093,22 @@ export default function TrafficPage() {
|
||||
})
|
||||
}, [sortField, sortDir, q, activeBoundIfaces])
|
||||
|
||||
const flowCards = flowScope === "users" ? flowClients : flowExporters
|
||||
const sortedFlowCards = useMemo(() => {
|
||||
return [...flowCards]
|
||||
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q) || c.site.toLowerCase().includes(q))
|
||||
.sort((a, b) => {
|
||||
let v = 0
|
||||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||||
else if (sortField === "name") v = a.name.localeCompare(b.name)
|
||||
else if (sortField === "sessions") v = a.sessions - b.sessions
|
||||
return sortDir === "desc" ? -v : v
|
||||
})
|
||||
}, [flowCards, q, sortField, sortDir])
|
||||
const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null
|
||||
const displayedFlow = flowLiveSample ?? flowAnalytics
|
||||
|
||||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||||
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
||||
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
|
||||
@@ -924,6 +1125,74 @@ export default function TrafficPage() {
|
||||
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
|
||||
|
||||
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
||||
const ingestLine = flowIngestLine(flowStats)
|
||||
const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
|
||||
const flowError = liveError
|
||||
|| (flowLiveError && !(collectorAlive && /live HTTP 500/.test(flowLiveError)) ? flowLiveError : null)
|
||||
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||
|
||||
const flowKpiItems = [
|
||||
{
|
||||
id: "exporters",
|
||||
label: "Экспортёры",
|
||||
value: String(flowExporters.length),
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "bps",
|
||||
label: "Скорость",
|
||||
value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"),
|
||||
hint: displayedFlow?.live ? "live" : undefined,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
label: "Уник. src",
|
||||
value: String(displayedFlow?.uniqueSrc ?? flowStats?.uniqueSrc ?? 0),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
label: "Топ протокол",
|
||||
value: displayedFlow?.topProto ?? flowStats?.topProto ?? "—",
|
||||
icon: <GitBranchIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]
|
||||
|
||||
const counterKpiItems = [
|
||||
{
|
||||
id: "rx",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(totalRx),
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(totalTx),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "peak-rx",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(peakRx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "peak-tx",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(peakTx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -934,7 +1203,10 @@ export default function TrafficPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLiveTraffic(range) }}
|
||||
onClick={() => {
|
||||
if (effectiveMode === "flows") void loadFlows()
|
||||
else void loadLiveTraffic(range)
|
||||
}}
|
||||
disabled={isLive && liveBusy}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
@@ -965,40 +1237,128 @@ export default function TrafficPage() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(totalRx),
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(totalTx),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "peak-rx",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(peakRx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "peak-tx",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(peakTx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
|
||||
{effectiveMode === "flows" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate min-w-0">
|
||||
{ingestLine ?? "IPFIX коллектор"}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
|
||||
<PlusIcon className="size-4" />
|
||||
Подключить JH
|
||||
</Button>
|
||||
</div>
|
||||
{flowError && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{flowError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFlowScope("servers"); setFlowIface("__all__") }}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||
flowScope === "servers"
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Серверы
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFlowScope("users"); setFlowIface("__all__") }}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||
flowScope === "users"
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Клиенты
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<span className="text-[10px] text-muted-foreground self-center mr-0.5">Сортировка:</span>
|
||||
{visibleSortFields.map(({ field, label }) => (
|
||||
<button
|
||||
key={field}
|
||||
type="button"
|
||||
onClick={() => toggleSort(field)}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||
sortField === field
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedFlowCards.map((card) => (
|
||||
<FlowEntityCardView
|
||||
key={card.id}
|
||||
card={card}
|
||||
selected={card.id === (selFlowCard?.id ?? selectedId)}
|
||||
onClick={() => setSelectedId(card.id)}
|
||||
/>
|
||||
))}
|
||||
{sortedFlowCards.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Frame dense className="w-full flex flex-col">
|
||||
<FramePanel className="flex-1 px-5 pb-5 pt-5">
|
||||
<FlowAnalyticsDetail
|
||||
card={selFlowCard}
|
||||
analytics={displayedFlow}
|
||||
range={range}
|
||||
onRange={(r) => setRange(r as Range)}
|
||||
selectedIface={flowIface}
|
||||
onIface={setFlowIface}
|
||||
dedup={flowDedup}
|
||||
onDedup={setFlowDedup}
|
||||
excludeMesh={flowExcludeMesh}
|
||||
onExcludeMesh={setFlowExcludeMesh}
|
||||
excludeOverlay={flowExcludeOverlay}
|
||||
onExcludeOverlay={setFlowExcludeOverlay}
|
||||
liveHint={displayedFlow?.live ? "live" : undefined}
|
||||
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
<FlowOverlaySheet
|
||||
open={overlayOpen}
|
||||
onOpenChange={setOverlayOpen}
|
||||
servers={catalogServers}
|
||||
backendUrl={backendUrl}
|
||||
onDone={() => { void loadFlows() }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
{/* ── left panel ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@@ -1117,6 +1477,7 @@ export default function TrafficPage() {
|
||||
</Frame>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
@@ -106,19 +107,21 @@ export default function UsersPage() {
|
||||
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((b) => `${b.serverId}::${b.interfaceName}`))
|
||||
const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const))
|
||||
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(`${b.serverId}::${b.interfaceName}`)) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+241
-7
@@ -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,
|
||||
@@ -533,18 +640,80 @@ CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
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)
|
||||
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) {
|
||||
@@ -611,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")) {
|
||||
@@ -656,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
|
||||
@@ -692,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'`)
|
||||
@@ -750,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 })
|
||||
}
|
||||
|
||||
+107
-1
@@ -32,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'))`),
|
||||
@@ -158,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),
|
||||
@@ -570,11 +670,13 @@ export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
|
||||
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").on(t.serverId, t.interfaceName),
|
||||
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||
])
|
||||
|
||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||
@@ -592,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
|
||||
|
||||
+48
-2
@@ -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"
|
||||
@@ -27,6 +29,10 @@ 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
|
||||
@@ -35,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
|
||||
@@ -57,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"],
|
||||
@@ -68,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 () => ({
|
||||
@@ -93,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" })
|
||||
@@ -112,8 +133,10 @@ export async function buildApp(opts?: {
|
||||
|
||||
if (opts?.startScheduler !== false) {
|
||||
refreshScheduler()
|
||||
startTrafficFlowListener()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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")
|
||||
@@ -29,12 +30,14 @@ CREATE TABLE user_interface_bindings (
|
||||
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)
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -55,8 +58,33 @@ assert.throws(
|
||||
"один интерфейс на сервере — один пользователь",
|
||||
)
|
||||
|
||||
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, 0, "каскад: привязки удаляются вместе с пользователем")
|
||||
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||
|
||||
console.log("users bindings unique+cascade tests ok")
|
||||
|
||||
@@ -4,23 +4,32 @@ import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from
|
||||
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", running: "false", 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, 3)
|
||||
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)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
||||
|
||||
export function mapRosInterfaceType(raw: string | undefined | null): InterfaceType {
|
||||
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||
const t = String(raw ?? "").trim().toLowerCase()
|
||||
if (t === "ether" || t === "ethernet") return "ether"
|
||||
if (t === "gre") return "gre"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -34,7 +40,7 @@ export function parseRawInterfaces(json: string | null | undefined): ParsedRosIf
|
||||
if (!name) continue
|
||||
out.push({
|
||||
name,
|
||||
type: mapRosInterfaceType(String(rec.type ?? "")),
|
||||
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||
running: asBool(rec.running),
|
||||
disabled: asBool(rec.disabled),
|
||||
})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -46,9 +46,10 @@ export function getBindingRowById(id: string): BindingRow | undefined {
|
||||
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function getBindingByServerIface(
|
||||
export function getBindingByServerIfacePeer(
|
||||
serverId: number,
|
||||
interfaceName: string,
|
||||
peerPublicKey = "",
|
||||
): BindingRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
@@ -56,6 +57,7 @@ export function getBindingByServerIface(
|
||||
.where(and(
|
||||
eq(userInterfaceBindings.serverId, serverId),
|
||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||
))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
createUserRow,
|
||||
deleteBindingRowById,
|
||||
deleteUserRowById,
|
||||
getBindingByServerIface,
|
||||
getBindingByServerIfacePeer,
|
||||
getBindingRowById,
|
||||
getUserRowById,
|
||||
getUserRowByLogin,
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
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(
|
||||
@@ -80,6 +86,8 @@ function toBindingDto(row: BindingRow): UserBinding {
|
||||
serverCountry: meta.country,
|
||||
interfaceName: row.interfaceName,
|
||||
interfaceType: row.interfaceType,
|
||||
peerPublicKey: row.peerPublicKey ?? "",
|
||||
peerName: row.peerName ?? "",
|
||||
comment: row.comment,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -179,11 +187,29 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||
const ifaceName = input.interfaceName.trim()
|
||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||
const taken = getBindingByServerIface(input.serverId, ifaceName)
|
||||
if (taken) {
|
||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
||||
}
|
||||
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({
|
||||
@@ -192,6 +218,8 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
||||
serverId: input.serverId,
|
||||
interfaceName: ifaceName,
|
||||
interfaceType: type,
|
||||
peerPublicKey,
|
||||
peerName,
|
||||
comment: (input.comment ?? "").trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -199,7 +227,12 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
||||
return toBindingDto(row)
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -220,7 +253,7 @@ function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
|
||||
return found?.type ?? "other"
|
||||
}
|
||||
|
||||
export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
||||
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)
|
||||
|
||||
@@ -238,13 +271,14 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
||||
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))
|
||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||
const seen = new Set<string>()
|
||||
ifaces = []
|
||||
for (const r of rows) {
|
||||
@@ -252,7 +286,7 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
||||
seen.add(r.interfaceName)
|
||||
ifaces.push({
|
||||
name: r.interfaceName,
|
||||
type: mapRosInterfaceType(""),
|
||||
type: mapRosInterfaceType("", r.interfaceName),
|
||||
running: Boolean(r.running),
|
||||
disabled: Boolean(r.disabled),
|
||||
})
|
||||
@@ -262,18 +296,47 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
||||
|
||||
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 bind = bindings.find((b) => b.interfaceName === iface.name)
|
||||
const owner = bind ? usersById.get(bind.userId) : undefined
|
||||
return {
|
||||
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: bind?.userId ?? null,
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -39,7 +39,7 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}, async (req, reply) => {
|
||||
const q = req.query as { serverId: number }
|
||||
try {
|
||||
return reply.send({ interfaces: listInterfaceCatalog(q.serverId) })
|
||||
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import type Database from "better-sqlite3"
|
||||
import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
|
||||
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
|
||||
export const TICK_MS = 2_000
|
||||
export const RING_LEN = 60
|
||||
export const MAX_PENDING = 50_000
|
||||
export const DAILY_ASN_TOP = 500
|
||||
export const DAILY_RETENTION_DAYS = 396
|
||||
export const MINUTE_RETENTION_HOURS = 48
|
||||
|
||||
let pendingCap = MAX_PENDING
|
||||
|
||||
export interface PendingFlowRow {
|
||||
serverId: number
|
||||
bucketAt: string
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
bytes: number
|
||||
packets: number
|
||||
inIface: string
|
||||
outIface: string
|
||||
nextHop: string
|
||||
flowStartMs: number
|
||||
flowEndMs: number
|
||||
}
|
||||
|
||||
export interface EngineStats {
|
||||
packetsReceived: number
|
||||
lastExporterIp: string | null
|
||||
lastError: string
|
||||
lastDatagramAt: string | null
|
||||
dropped: number
|
||||
rowsStored: number
|
||||
pendingSize: number
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
serverId: number
|
||||
bucketAt: string
|
||||
flow: ParsedFlow
|
||||
bytes: number
|
||||
packets: number
|
||||
}
|
||||
|
||||
interface MinuteRollup {
|
||||
bytes: number
|
||||
packets: number
|
||||
srcs: Set<string>
|
||||
dsts: Set<string>
|
||||
conversations: number
|
||||
}
|
||||
|
||||
interface DimAcc {
|
||||
bytes: number
|
||||
packets: number
|
||||
}
|
||||
|
||||
export interface ExporterResolveCtx {
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Map<string, number>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Map<string, number>
|
||||
}
|
||||
|
||||
let sqliteRef: SqliteHandle | null = null
|
||||
let topN = 200
|
||||
let retentionHours = 24
|
||||
|
||||
const pending = new Map<string, PendingEntry>()
|
||||
const recent = new Map<string, PendingFlowRow>()
|
||||
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
|
||||
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
|
||||
const minuteRollup = new Map<string, MinuteRollup>()
|
||||
const minuteDims = new Map<string, DimAcc>()
|
||||
|
||||
let packetsReceived = 0
|
||||
let lastExporterIp: string | null = null
|
||||
let lastError = ""
|
||||
let lastDatagramAt: string | null = null
|
||||
let dropped = 0
|
||||
let rowsStored = 0
|
||||
let lastFlushUsedTransaction = false
|
||||
let lastPruneAt = 0
|
||||
let exporterCtx: ExporterResolveCtx | null = null
|
||||
|
||||
const PRUNE_MS = 5 * 60_000
|
||||
const LIVE_WINDOW_MS = 15 * 60_000
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function minuteBucketIso(at = Date.now()): string {
|
||||
const d = new Date(at)
|
||||
d.setSeconds(0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function dayKey(bucketAt: string): string {
|
||||
return bucketAt.slice(0, 10)
|
||||
}
|
||||
|
||||
export const RING_PAYLOAD = "__all__"
|
||||
export const RING_OVERLAY = "__overlay__"
|
||||
export const RING_MESH = "__mesh__"
|
||||
|
||||
function ringKey(serverId: number, iface: string): string {
|
||||
return `${serverId}\0${iface || RING_PAYLOAD}`
|
||||
}
|
||||
|
||||
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string {
|
||||
return `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
|
||||
}
|
||||
|
||||
function rowKey(row: PendingFlowRow): string {
|
||||
return `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
|
||||
}
|
||||
|
||||
function rollupKey(serverId: number, bucketAt: string): string {
|
||||
return `${serverId}\0${bucketAt}`
|
||||
}
|
||||
|
||||
function dimKey(serverId: number, bucketAt: string, dim: string, key: string): string {
|
||||
return `${serverId}\0${bucketAt}\0${dim}\0${key}`
|
||||
}
|
||||
|
||||
function bumpTick(key: string, inBytes: number, outBytes: number): void {
|
||||
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||
prev.inBytes += inBytes
|
||||
prev.outBytes += outBytes
|
||||
tickAccum.set(key, prev)
|
||||
}
|
||||
|
||||
function addToTick(serverId: number, flow: ParsedFlow, bytes: number): void {
|
||||
const plane = classifyFlowPlaneLite(flow)
|
||||
if (plane === "mgmt") return
|
||||
const bucket = plane === "overlay" ? RING_OVERLAY : plane === "client_mesh" ? RING_MESH : RING_PAYLOAD
|
||||
bumpTick(ringKey(serverId, bucket), bytes, 0)
|
||||
if (flow.inIface) bumpTick(ringKey(serverId, flow.inIface), bytes, 0)
|
||||
if (flow.outIface && flow.outIface !== flow.inIface) bumpTick(ringKey(serverId, flow.outIface), 0, bytes)
|
||||
}
|
||||
|
||||
function emptyRing(): { inBps: number[]; outBps: number[] } {
|
||||
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
|
||||
}
|
||||
|
||||
function bumpDim(serverId: number, bucketAt: string, dim: string, key: string, bytes: number, packets: number): void {
|
||||
if (!key) return
|
||||
const k = dimKey(serverId, bucketAt, dim, key)
|
||||
const prev = minuteDims.get(k)
|
||||
if (prev) {
|
||||
prev.bytes += bytes
|
||||
prev.packets += packets
|
||||
return
|
||||
}
|
||||
minuteDims.set(k, { bytes, packets })
|
||||
}
|
||||
|
||||
function bumpRollup(serverId: number, bucketAt: string, flow: ParsedFlow, bytes: number, packets: number): void {
|
||||
const k = rollupKey(serverId, bucketAt)
|
||||
let acc = minuteRollup.get(k)
|
||||
if (!acc) {
|
||||
acc = { bytes: 0, packets: 0, srcs: new Set(), dsts: new Set(), conversations: 0 }
|
||||
minuteRollup.set(k, acc)
|
||||
}
|
||||
acc.bytes += bytes
|
||||
acc.packets += packets
|
||||
if (flow.src) acc.srcs.add(flow.src)
|
||||
if (flow.dst) acc.dsts.add(flow.dst)
|
||||
acc.conversations += 1
|
||||
}
|
||||
|
||||
export function attachEngineSqlite(handle: SqliteHandle): void {
|
||||
sqliteRef = handle
|
||||
}
|
||||
|
||||
export function setPendingCapForTests(n: number | null): void {
|
||||
pendingCap = n == null ? MAX_PENDING : Math.max(1, n)
|
||||
}
|
||||
|
||||
export function configureEngine(opts: { topN?: number; retentionHours?: number }): void {
|
||||
if (opts.topN != null) topN = Math.max(20, opts.topN)
|
||||
if (opts.retentionHours != null) retentionHours = Math.max(1, opts.retentionHours)
|
||||
}
|
||||
|
||||
export function setExporterResolveCtx(ctx: ExporterResolveCtx | null): void {
|
||||
exporterCtx = ctx
|
||||
}
|
||||
|
||||
export function resolveServerId(exporterIp: string): number | null {
|
||||
if (!exporterCtx) return null
|
||||
return pickServerIdForExporter({
|
||||
exporterIp,
|
||||
overlayPrefix: exporterCtx.overlayPrefix,
|
||||
byTunnelIp: exporterCtx.byTunnelIp,
|
||||
peers: exporterCtx.peers,
|
||||
hostIps: exporterCtx.hostIps,
|
||||
})
|
||||
}
|
||||
|
||||
export function bumpPacketMeta(exporterIp: string): void {
|
||||
packetsReceived += 1
|
||||
lastExporterIp = exporterIp
|
||||
lastDatagramAt = nowIso()
|
||||
}
|
||||
|
||||
export function setEngineError(message: string): void {
|
||||
lastError = message
|
||||
}
|
||||
|
||||
export function getEngineStats(): EngineStats {
|
||||
return {
|
||||
packetsReceived,
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
lastDatagramAt,
|
||||
dropped,
|
||||
rowsStored,
|
||||
pendingSize: pending.size,
|
||||
}
|
||||
}
|
||||
|
||||
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||
const bucketAt = minuteBucketIso()
|
||||
const ripeMisses: string[] = []
|
||||
for (const raw of flows) {
|
||||
const flow = normalizeParsedFlow(raw)
|
||||
addToTick(serverId, flow, flow.bytes)
|
||||
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
|
||||
const ripe = lookupRipeCached(flow.dst)
|
||||
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
|
||||
const classified = classifyFlowDst(flow.dst, flow.proto, flow.dstPort, flow.srcPort, ripe)
|
||||
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
|
||||
const country = ripe?.ok && isIsoCountry(ripe.country)
|
||||
? ripe.country
|
||||
: (ripe?.ok ? "" : "unknown")
|
||||
const asnKey = ripe?.ok && ripe.asn ? String(ripe.asn) : "unknown"
|
||||
bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "category", classified.category, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets)
|
||||
if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets)
|
||||
bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets)
|
||||
|
||||
const key = pendingKey(serverId, bucketAt, flow)
|
||||
const prev = pending.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += flow.bytes
|
||||
prev.packets += flow.packets
|
||||
if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface
|
||||
if (flow.nextHop && !prev.flow.nextHop) prev.flow.nextHop = flow.nextHop
|
||||
if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) {
|
||||
prev.flow.flowStartMs = flow.flowStartMs
|
||||
}
|
||||
if (flow.flowEndMs > (prev.flow.flowEndMs ?? 0)) prev.flow.flowEndMs = flow.flowEndMs
|
||||
continue
|
||||
}
|
||||
if (pending.size >= pendingCap) {
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
pending.set(key, {
|
||||
serverId,
|
||||
bucketAt,
|
||||
flow: { ...flow },
|
||||
bytes: flow.bytes,
|
||||
packets: flow.packets,
|
||||
})
|
||||
}
|
||||
if (ripeMisses.length) enqueueRipeMisses(ripeMisses)
|
||||
}
|
||||
|
||||
export function ingestDatagram(msg: Buffer, exporterIp: string): boolean {
|
||||
bumpPacketMeta(exporterIp)
|
||||
const flows = parseFlowPacket(msg, exporterIp)
|
||||
if (!flows.length) return true
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) {
|
||||
setEngineError(
|
||||
`IPFIX от ${exporterIp}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
setEngineError("")
|
||||
maybeRefreshIfaces(serverId)
|
||||
queueParsedFlows(serverId, flows)
|
||||
return true
|
||||
}
|
||||
|
||||
function toPendingRow(row: PendingEntry): PendingFlowRow {
|
||||
const flow = normalizeParsedFlow(row.flow)
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
bucketAt: row.bucketAt,
|
||||
src: flow.src || "0.0.0.0",
|
||||
dst: flow.dst || "0.0.0.0",
|
||||
proto: flow.proto,
|
||||
srcPort: flow.srcPort,
|
||||
dstPort: flow.dstPort,
|
||||
bytes: row.bytes,
|
||||
packets: row.packets,
|
||||
inIface: flow.inIface,
|
||||
outIface: flow.outIface,
|
||||
nextHop: flow.nextHop,
|
||||
flowStartMs: flow.flowStartMs,
|
||||
flowEndMs: flow.flowEndMs,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
|
||||
const key = rowKey(row)
|
||||
const prev = map.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += row.bytes
|
||||
prev.packets += row.packets
|
||||
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||
return
|
||||
}
|
||||
map.set(key, { ...row })
|
||||
}
|
||||
|
||||
function pruneRecent(sinceMs = Date.now() - LIVE_WINDOW_MS): void {
|
||||
const cutoff = new Date(sinceMs).toISOString()
|
||||
for (const [key, row] of recent) {
|
||||
if (row.bucketAt < cutoff) recent.delete(key)
|
||||
}
|
||||
while (recent.size > MAX_PENDING) {
|
||||
const first = recent.keys().next().value
|
||||
if (first == null) break
|
||||
recent.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
export function peekPendingFlows(): PendingFlowRow[] {
|
||||
return [...pending.values()].map(toPendingRow)
|
||||
}
|
||||
|
||||
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
const merged = new Map<string, PendingFlowRow>()
|
||||
for (const row of recent.values()) {
|
||||
if (row.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, row)
|
||||
}
|
||||
for (const row of peekPendingFlows()) {
|
||||
if (row.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, row)
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function rollFlowRings(): void {
|
||||
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
|
||||
const sec = TICK_MS / 1000
|
||||
for (const key of keys) {
|
||||
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||
tickAccum.delete(key)
|
||||
const inBps = (acc.inBytes * 8) / sec
|
||||
const outBps = (acc.outBytes * 8) / sec
|
||||
let ring = rings.get(key)
|
||||
if (!ring) {
|
||||
ring = emptyRing()
|
||||
rings.set(key, ring)
|
||||
}
|
||||
ring.inBps.push(inBps)
|
||||
ring.inBps.shift()
|
||||
ring.outBps.push(outBps)
|
||||
ring.outBps.shift()
|
||||
const silent = ring.inBps.every((v) => v === 0) && ring.outBps.every((v) => v === 0)
|
||||
if (silent && !tickAccum.has(key)) rings.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRingMbps(serverId: number, iface = RING_PAYLOAD): {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
} {
|
||||
const ring = rings.get(ringKey(serverId, iface))
|
||||
const scale = 1_000_000
|
||||
if (!ring) {
|
||||
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
|
||||
}
|
||||
return {
|
||||
rx: ring.inBps.map((b) => b / scale),
|
||||
tx: ring.outBps.map((b) => b / scale),
|
||||
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
|
||||
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotRings(): Array<{ key: string; inBps: number[]; outBps: number[] }> {
|
||||
return [...rings.entries()].map(([key, ring]) => ({
|
||||
key,
|
||||
inBps: [...ring.inBps],
|
||||
outBps: [...ring.outBps],
|
||||
}))
|
||||
}
|
||||
|
||||
export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; outBps: number[] }>): void {
|
||||
rings.clear()
|
||||
for (const row of rows) {
|
||||
rings.set(row.key, { inBps: row.inBps, outBps: row.outBps })
|
||||
}
|
||||
}
|
||||
|
||||
function persistListenerStats(handle: SqliteHandle): void {
|
||||
handle.prepare(`
|
||||
UPDATE traffic_flow_settings
|
||||
SET packets_received = @packetsReceived,
|
||||
last_datagram_at = @lastDatagramAt,
|
||||
last_exporter_ip = @lastExporterIp,
|
||||
last_error = @lastError,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
packetsReceived,
|
||||
lastDatagramAt,
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
updatedAt: nowIso(),
|
||||
})
|
||||
}
|
||||
|
||||
function upsertMinuteAndDaily(handle: SqliteHandle): void {
|
||||
const upsertMinute = handle.prepare(`
|
||||
INSERT INTO flow_minute_stats (
|
||||
server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations
|
||||
) VALUES (
|
||||
@serverId, @bucketAt, @bytes, @packets, @uniqueSrc, @uniqueDst, @conversations
|
||||
)
|
||||
ON CONFLICT(server_id, bucket_at) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets,
|
||||
unique_src = MAX(unique_src, excluded.unique_src),
|
||||
unique_dst = MAX(unique_dst, excluded.unique_dst),
|
||||
conversations = conversations + excluded.conversations
|
||||
`)
|
||||
const upsertDim = handle.prepare(`
|
||||
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
|
||||
VALUES (@serverId, @bucketAt, @dim, @key, @bytes, @packets)
|
||||
ON CONFLICT(server_id, bucket_at, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
const upsertDaily = handle.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (@serverId, @day, @dim, @key, @bytes, @packets)
|
||||
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
|
||||
const tx = handle.transaction(() => {
|
||||
for (const [k, acc] of minuteRollup) {
|
||||
const [serverIdRaw, bucketAt] = k.split("\0")
|
||||
upsertMinute.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
bucketAt,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
uniqueSrc: acc.srcs.size,
|
||||
uniqueDst: acc.dsts.size,
|
||||
conversations: acc.conversations,
|
||||
})
|
||||
}
|
||||
for (const [k, acc] of minuteDims) {
|
||||
const [serverIdRaw, bucketAt, dim, key] = k.split("\0")
|
||||
upsertDim.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
bucketAt,
|
||||
dim,
|
||||
key,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
})
|
||||
if (dim === "country" || dim === "service" || dim === "asn") {
|
||||
upsertDaily.run({
|
||||
serverId: Number(serverIdRaw),
|
||||
day: dayKey(bucketAt ?? ""),
|
||||
dim,
|
||||
key,
|
||||
bytes: acc.bytes,
|
||||
packets: acc.packets,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
tx()
|
||||
minuteRollup.clear()
|
||||
minuteDims.clear()
|
||||
}
|
||||
|
||||
function capDailyAsn(handle: SqliteHandle): void {
|
||||
const today = nowIso().slice(0, 10)
|
||||
const rows = handle.prepare(`
|
||||
SELECT server_id AS serverId, key, bytes, packets
|
||||
FROM flow_daily_dims
|
||||
WHERE day = ? AND dim = 'asn'
|
||||
ORDER BY server_id, bytes DESC
|
||||
`).all(today) as Array<{ serverId: number; key: string; bytes: number; packets: number }>
|
||||
const byServer = new Map<number, typeof rows>()
|
||||
for (const row of rows) {
|
||||
const list = byServer.get(row.serverId) ?? []
|
||||
list.push(row)
|
||||
byServer.set(row.serverId, list)
|
||||
}
|
||||
const del = handle.prepare(`
|
||||
DELETE FROM flow_daily_dims WHERE server_id = ? AND day = ? AND dim = 'asn' AND key = ?
|
||||
`)
|
||||
const upsertOther = handle.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (?, ?, 'asn', 'other', ?, ?)
|
||||
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets
|
||||
`)
|
||||
for (const [serverId, list] of byServer) {
|
||||
if (list.length <= DAILY_ASN_TOP) continue
|
||||
let otherBytes = 0
|
||||
let otherPackets = 0
|
||||
for (const row of list.slice(DAILY_ASN_TOP)) {
|
||||
if (row.key === "other") continue
|
||||
otherBytes += row.bytes
|
||||
otherPackets += row.packets
|
||||
del.run(serverId, today, row.key)
|
||||
}
|
||||
if (otherBytes > 0) upsertOther.run(serverId, today, otherBytes, otherPackets)
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStored(handle: SqliteHandle): void {
|
||||
const now = Date.now()
|
||||
if (now - lastPruneAt < PRUNE_MS) return
|
||||
lastPruneAt = now
|
||||
const flowCutoff = new Date(now - retentionHours * 3600_000).toISOString()
|
||||
const minuteCutoff = new Date(now - MINUTE_RETENTION_HOURS * 3600_000).toISOString()
|
||||
const dailyCutoff = new Date(now - DAILY_RETENTION_DAYS * 86400_000).toISOString().slice(0, 10)
|
||||
handle.prepare(`DELETE FROM flow_buckets WHERE bucket_at < ?`).run(flowCutoff)
|
||||
handle.prepare(`DELETE FROM flow_minute_stats WHERE bucket_at < ?`).run(minuteCutoff)
|
||||
handle.prepare(`DELETE FROM flow_minute_dims WHERE bucket_at < ?`).run(minuteCutoff)
|
||||
handle.prepare(`DELETE FROM flow_daily_dims WHERE day < ?`).run(dailyCutoff)
|
||||
|
||||
const keep = Math.max(20, topN)
|
||||
try {
|
||||
handle.prepare(`
|
||||
DELETE FROM flow_buckets WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
|
||||
) AS rn
|
||||
FROM flow_buckets
|
||||
) ranked WHERE rn > ?
|
||||
)
|
||||
`).run(keep)
|
||||
} catch {
|
||||
const buckets = handle.prepare(`
|
||||
SELECT DISTINCT server_id AS serverId, bucket_at AS bucketAt FROM flow_buckets
|
||||
`).all() as Array<{ serverId: number; bucketAt: string }>
|
||||
for (const b of buckets) {
|
||||
const rows = handle.prepare(`
|
||||
SELECT id, bytes FROM flow_buckets
|
||||
WHERE server_id = ? AND bucket_at = ?
|
||||
ORDER BY bytes DESC
|
||||
`).all(b.serverId, b.bucketAt) as Array<{ id: number; bytes: number }>
|
||||
for (const extra of rows.slice(keep)) {
|
||||
handle.prepare(`DELETE FROM flow_buckets WHERE id = ?`).run(extra.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
||||
const keep = Math.max(20, topN)
|
||||
const groups = new Map<string, PendingFlowRow[]>()
|
||||
for (const row of rows) {
|
||||
const k = `${row.serverId}\0${row.bucketAt}`
|
||||
const list = groups.get(k) ?? []
|
||||
list.push(row)
|
||||
groups.set(k, list)
|
||||
}
|
||||
const out: PendingFlowRow[] = []
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => b.bytes - a.bytes)
|
||||
out.push(...list.slice(0, keep))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function flushPending(): void {
|
||||
pruneRecent()
|
||||
rollFlowRings()
|
||||
const handle = sqliteRef
|
||||
if (!handle) {
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
persistListenerStats(handle)
|
||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
||||
pruneStored(handle)
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
const rows = topNPending([...pending.values()].map(toPendingRow))
|
||||
pending.clear()
|
||||
for (const row of rows) mergeInto(recent, row)
|
||||
|
||||
const upsertFlow = handle.prepare(`
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||
) VALUES (
|
||||
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs
|
||||
)
|
||||
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
DO UPDATE SET
|
||||
bytes = bytes + excluded.bytes,
|
||||
packets = packets + excluded.packets,
|
||||
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE out_iface END,
|
||||
next_hop = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE next_hop END,
|
||||
flow_start_ms = CASE
|
||||
WHEN excluded.flow_start_ms > 0 AND (flow_start_ms = 0 OR excluded.flow_start_ms < flow_start_ms)
|
||||
THEN excluded.flow_start_ms ELSE flow_start_ms END,
|
||||
flow_end_ms = MAX(flow_end_ms, excluded.flow_end_ms)
|
||||
`)
|
||||
lastFlushUsedTransaction = false
|
||||
try {
|
||||
const tx = handle.transaction((batch: PendingFlowRow[]) => {
|
||||
for (const r of batch) {
|
||||
upsertFlow.run({
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
}
|
||||
})
|
||||
tx(rows)
|
||||
lastFlushUsedTransaction = true
|
||||
rowsStored += rows.length
|
||||
} catch {
|
||||
for (const r of rows) {
|
||||
try {
|
||||
upsertFlow.run({
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
rowsStored += 1
|
||||
} catch {
|
||||
/* ignore single-row failures */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
upsertMinuteAndDaily(handle)
|
||||
capDailyAsn(handle)
|
||||
} catch {
|
||||
/* rollup best-effort */
|
||||
}
|
||||
pruneStored(handle)
|
||||
try {
|
||||
handle.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
return lastFlushUsedTransaction
|
||||
}
|
||||
|
||||
export function flushPendingForTests(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function onEngineTick(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]): void {
|
||||
queueParsedFlows(serverId, flows)
|
||||
rollFlowRings()
|
||||
}
|
||||
|
||||
export function resetEngineForTests(): void {
|
||||
pending.clear()
|
||||
recent.clear()
|
||||
tickAccum.clear()
|
||||
rings.clear()
|
||||
minuteRollup.clear()
|
||||
minuteDims.clear()
|
||||
packetsReceived = 0
|
||||
lastExporterIp = null
|
||||
lastError = ""
|
||||
lastDatagramAt = null
|
||||
dropped = 0
|
||||
rowsStored = 0
|
||||
lastFlushUsedTransaction = false
|
||||
lastPruneAt = 0
|
||||
pendingCap = MAX_PENDING
|
||||
}
|
||||
|
||||
export function pendingSizeForTests(): number {
|
||||
return pending.size
|
||||
}
|
||||
|
||||
export function droppedForTests(): number {
|
||||
return dropped
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { SQLITE_BUSY_TIMEOUT_MS, sqliteDatabase } from "../db/index.js"
|
||||
import {
|
||||
MAX_FLOW_LIVE_SUBSCRIBERS,
|
||||
resetFlowLiveSlotsForTests,
|
||||
tryAcquireFlowLiveSlot,
|
||||
releaseFlowLiveSlot,
|
||||
} from "../routes/traffic-flow.js"
|
||||
|
||||
const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: number }>
|
||||
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
|
||||
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
|
||||
|
||||
resetFlowLiveSlotsForTests()
|
||||
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
|
||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||
}
|
||||
assert.equal(tryAcquireFlowLiveSlot(), false)
|
||||
releaseFlowLiveSlot()
|
||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||
resetFlowLiveSlotsForTests()
|
||||
|
||||
console.log("traffic-flow-hardening.test.ts: ok")
|
||||
@@ -0,0 +1,113 @@
|
||||
import { generateNativeConf } from "./wireguard-config.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||
|
||||
export function buildHostWgQuickConf(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = listHostPeers()
|
||||
return generateNativeConf({
|
||||
name: "wg-flow",
|
||||
mtu: 1420,
|
||||
privateKey: row.hostPrivateKey || undefined,
|
||||
address: `${row.collectorIp}/24`,
|
||||
comment: "MikrotikManager traffic-flow collector",
|
||||
peers: peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
comment: p.name,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.endpoint ? 25 : undefined,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHostComposeOverride(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return [
|
||||
"# Docker Compose merge для /opt/cdn-mm",
|
||||
"# Не править docker-compose.yml. Traefik не трогать.",
|
||||
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
|
||||
"# затем: docker compose up -d --force-recreate backend",
|
||||
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
|
||||
"",
|
||||
"services:",
|
||||
" backend:",
|
||||
" environment:",
|
||||
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
|
||||
" ports:",
|
||||
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function buildHostLinuxInstallSh(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
|
||||
const override = buildHostComposeOverride()
|
||||
const collector = row.collectorIp
|
||||
const flowPort = row.flowListenPort
|
||||
|
||||
return `#!/usr/bin/env bash
|
||||
# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend.
|
||||
# Запуск: sudo bash install-wg-flow.sh
|
||||
set -euo pipefail
|
||||
|
||||
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "Запустите от root: sudo bash $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COLLECTOR_IP="${collector}"
|
||||
FLOW_PORT="${flowPort}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR}"
|
||||
|
||||
if ! command -v wg >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
|
||||
fi
|
||||
|
||||
install -d -m 700 /etc/wireguard
|
||||
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
|
||||
${conf}WGEOF
|
||||
chmod 600 /etc/wireguard/wg-flow.conf
|
||||
|
||||
systemctl enable --now wg-quick@wg-flow
|
||||
echo "=== wg show wg-flow ==="
|
||||
wg show wg-flow
|
||||
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
|
||||
ip -4 addr show dev wg-flow
|
||||
|
||||
if [[ ! -d "\$COMPOSE_DIR" ]]; then
|
||||
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
|
||||
${override}OVEOF
|
||||
|
||||
cd "\$COMPOSE_DIR"
|
||||
docker compose up -d --force-recreate backend
|
||||
|
||||
echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ==="
|
||||
ss -ulnp | grep -E "\${FLOW_PORT}" || true
|
||||
echo "=== PortBindings mmapp-backend ==="
|
||||
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
|
||||
echo "=== handshake (keepalive 25s к JH:13232) ==="
|
||||
wg show wg-flow
|
||||
|
||||
# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0.
|
||||
# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend
|
||||
|
||||
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
|
||||
`
|
||||
}
|
||||
|
||||
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||
return [
|
||||
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
|
||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
resolveIfaceName,
|
||||
rosIdToIfIndex,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
|
||||
assert.equal(rosIdToIfIndex("*A"), 10)
|
||||
assert.equal(rosIdToIfIndex("*D"), 13)
|
||||
assert.equal(rosIdToIfIndex("*2"), 2)
|
||||
assert.equal(rosIdToIfIndex("*9"), 9)
|
||||
assert.equal(rosIdToIfIndex("0"), 0)
|
||||
assert.equal(rosIdToIfIndex(""), null)
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "ether1" },
|
||||
{ ".id": "*A", name: "wg-flow" },
|
||||
{ ".id": "*D", name: "bridge" },
|
||||
])
|
||||
assert.equal(resolveIfaceName(7, "2").name, "ether1")
|
||||
assert.equal(resolveIfaceName(7, "10").name, "wg-flow")
|
||||
assert.equal(resolveIfaceName(7, "13").name, "bridge")
|
||||
assert.equal(resolveIfaceName(7, "0").name, "—")
|
||||
assert.equal(resolveIfaceName(7, "ether1").name, "ether1")
|
||||
assert.equal(resolveIfaceName(7, "99").name, "#99")
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(8, [
|
||||
{ ifindex: "10", ".id": "*12", name: "gre1" },
|
||||
])
|
||||
assert.equal(rosIdToIfIndex("*12"), 18)
|
||||
assert.equal(resolveIfaceName(8, "10").name, "gre1")
|
||||
assert.equal(resolveIfaceName(8, "18").name, "gre1")
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
assert.equal(shouldRefreshIfaces(9), true)
|
||||
rememberServerIfaces(9, [{ ".id": "*2", name: "ether1" }])
|
||||
assert.equal(shouldRefreshIfaces(9), false)
|
||||
resetIfaceCacheForTests()
|
||||
markIfaceRefreshAttempt(9)
|
||||
assert.equal(shouldRefreshIfaces(9), false)
|
||||
|
||||
assert.equal(applicationName(6, 443), "HTTPS")
|
||||
assert.equal(applicationName(17, 53), "DNS")
|
||||
assert.equal(applicationName(6, 22), "SSH")
|
||||
assert.equal(applicationName(17, 51820), "WireGuard")
|
||||
assert.equal(applicationName(6, 179), "BGP")
|
||||
|
||||
const allow = new Map<number, Set<string>>([[7, new Set(["ether1", "wg-flow"])]])
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", {}, allow), true)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "bridge", {}, allow), false)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "ether1" }, allow), true)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "wg-flow" }, allow), false)
|
||||
assert.equal(flowRowMatchesFilter({ serverId: 8, inIface: "2" }, "ether1", { serverId: 7 }, null), false)
|
||||
|
||||
console.log("traffic-flow-ifaces.test.ts: ok")
|
||||
@@ -0,0 +1,56 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import {
|
||||
rememberServerIfaces,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
type RosIfaceIndexRow,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
export {
|
||||
ifaceCacheFresh,
|
||||
ifaceCacheHas,
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
resolveIfaceName,
|
||||
rosIdToIfIndex,
|
||||
shouldRefreshIfaces,
|
||||
markIfaceRefreshAttempt,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
const inflight = new Set<number>()
|
||||
let refreshIfacesImpl: (serverId: number, force?: boolean) => Promise<void> = refreshServerIfacesInner
|
||||
|
||||
async function refreshServerIfacesInner(serverId: number, force = false): Promise<void> {
|
||||
if (inflight.has(serverId)) return
|
||||
if (!force && !shouldRefreshIfaces(serverId)) return
|
||||
inflight.add(serverId)
|
||||
try {
|
||||
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!row) return
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const ifaces = await client.get<RosIfaceIndexRow[]>("/interface")
|
||||
rememberServerIfaces(serverId, Array.isArray(ifaces) ? ifaces : [])
|
||||
} catch {
|
||||
/* keep previous cache */
|
||||
} finally {
|
||||
markIfaceRefreshAttempt(serverId)
|
||||
inflight.delete(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> {
|
||||
return refreshIfacesImpl(serverId, force)
|
||||
}
|
||||
|
||||
export function maybeRefreshIfaces(serverId: number): boolean {
|
||||
if (!shouldRefreshIfaces(serverId)) return false
|
||||
void refreshIfacesImpl(serverId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function setRefreshIfacesForTests(fn: typeof refreshServerIfacesInner | null): void {
|
||||
refreshIfacesImpl = fn ?? refreshServerIfacesInner
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface RosIfaceIndexRow {
|
||||
".id"?: string
|
||||
name?: string
|
||||
ifindex?: string
|
||||
}
|
||||
|
||||
const cache = new Map<number, Map<number, string>>()
|
||||
const fetchedAt = new Map<number, number>()
|
||||
const lastAttempt = new Map<number, number>()
|
||||
|
||||
export const IFACE_CACHE_TTL_MS = 60_000
|
||||
|
||||
/** RouterOS `.id` (`*A`) → SNMP ifIndex (10). */
|
||||
export function rosIdToIfIndex(id: string | undefined | null): number | null {
|
||||
if (!id) return null
|
||||
const raw = String(id).trim()
|
||||
const hex = raw.startsWith("*") ? raw.slice(1) : raw
|
||||
if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return null
|
||||
const n = parseInt(hex, 16)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function rememberServerIfaces(serverId: number, rows: RosIfaceIndexRow[]): void {
|
||||
const map = new Map<number, string>()
|
||||
for (const row of rows) {
|
||||
const name = String(row.name ?? "").trim()
|
||||
if (!name) continue
|
||||
const fromProp = Number.parseInt(String(row.ifindex ?? ""), 10)
|
||||
const fromId = rosIdToIfIndex(row[".id"])
|
||||
if (Number.isFinite(fromProp) && fromProp > 0) map.set(fromProp, name)
|
||||
if (fromId != null && fromId > 0) map.set(fromId, name)
|
||||
}
|
||||
cache.set(serverId, map)
|
||||
fetchedAt.set(serverId, Date.now())
|
||||
}
|
||||
|
||||
export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } {
|
||||
const trimmed = String(indexOrName ?? "").trim()
|
||||
if (!trimmed || trimmed === "0") return { name: "—", index: trimmed }
|
||||
if (!/^\d+$/.test(trimmed)) return { name: trimmed, index: "" }
|
||||
const idx = Number(trimmed)
|
||||
const name = cache.get(serverId)?.get(idx)
|
||||
if (name) return { name, index: trimmed }
|
||||
return { name: `#${trimmed}`, index: trimmed }
|
||||
}
|
||||
|
||||
export function ifaceCacheHas(serverId: number): boolean {
|
||||
return cache.has(serverId)
|
||||
}
|
||||
|
||||
export function ifaceCacheFresh(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
|
||||
const prev = fetchedAt.get(serverId) ?? 0
|
||||
return Boolean(prev && Date.now() - prev < ttlMs && cache.has(serverId))
|
||||
}
|
||||
|
||||
/** Не ходить в REST, пока кэш жив или с момента последней попытки не прошёл TTL. */
|
||||
export function shouldRefreshIfaces(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
|
||||
if (ifaceCacheFresh(serverId, ttlMs)) return false
|
||||
const attempted = lastAttempt.get(serverId) ?? 0
|
||||
return !(attempted && Date.now() - attempted < ttlMs)
|
||||
}
|
||||
|
||||
export function markIfaceRefreshAttempt(serverId: number, at = Date.now()): void {
|
||||
lastAttempt.set(serverId, at)
|
||||
}
|
||||
|
||||
export function resetIfaceCacheForTests(): void {
|
||||
cache.clear()
|
||||
fetchedAt.clear()
|
||||
lastAttempt.clear()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
markIfaceRefreshAttempt,
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
shouldRefreshIfaces,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
import {
|
||||
applyHeartbeatForTests,
|
||||
flushPendingForTests,
|
||||
getFlowListenerState,
|
||||
getFlowRuntimeCounters,
|
||||
getFlowWorkerHealth,
|
||||
ingestParsedFlowsForServerForTests,
|
||||
lastFlushUsedTransactionForTests,
|
||||
maybeRefreshIfaces,
|
||||
peekPendingFlows,
|
||||
resetFlowRingsForTests,
|
||||
setPendingCapForTests,
|
||||
setRefreshIfacesForTests,
|
||||
setWantListenForTests,
|
||||
simulateWorkerExitForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
|
||||
import { sqliteDatabase } from "../db/index.js"
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
resetFlowRingsForTests()
|
||||
|
||||
let refreshCalls = 0
|
||||
setRefreshIfacesForTests(async () => {
|
||||
refreshCalls += 1
|
||||
})
|
||||
|
||||
rememberServerIfaces(1, [{ ".id": "*A", name: "wg-flow" }])
|
||||
assert.equal(shouldRefreshIfaces(1), false)
|
||||
assert.equal(maybeRefreshIfaces(1), false)
|
||||
assert.equal(refreshCalls, 0)
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
assert.equal(shouldRefreshIfaces(2), true)
|
||||
assert.equal(maybeRefreshIfaces(2), true)
|
||||
assert.equal(refreshCalls, 1)
|
||||
|
||||
markIfaceRefreshAttempt(2)
|
||||
assert.equal(shouldRefreshIfaces(2), false)
|
||||
assert.equal(maybeRefreshIfaces(2), false)
|
||||
assert.equal(refreshCalls, 1)
|
||||
|
||||
assert.equal(lastFlushUsedTransactionForTests(), false)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
setPendingCapForTests(3)
|
||||
const many = Array.from({ length: 6 }, (_, i) => ({
|
||||
src: `10.1.1.${i + 1}`,
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 50000 + i,
|
||||
dstPort: 443,
|
||||
bytes: 1000,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}))
|
||||
ingestParsedFlowsForServerForTests(9, many)
|
||||
assert.equal(pendingSizeForTests(), 3)
|
||||
assert.equal(droppedForTests(), 3)
|
||||
assert.equal(peekPendingFlows().length, 3)
|
||||
setPendingCapForTests(null)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
configureEngine({ topN: 20 })
|
||||
const talkers = Array.from({ length: 25 }, (_, i) => ({
|
||||
src: `10.2.1.${i + 1}`,
|
||||
dst: "1.1.1.1",
|
||||
proto: 6,
|
||||
srcPort: 40000 + i,
|
||||
dstPort: 443,
|
||||
bytes: 1000 + i,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}))
|
||||
ingestParsedFlowsForServerForTests(9, talkers)
|
||||
flushPendingForTests()
|
||||
const stored = sqliteDatabase.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flow_buckets WHERE server_id = 9
|
||||
`).get() as { n: number }
|
||||
assert.ok(stored.n <= 20, `expected topN cap, got ${stored.n}`)
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
|
||||
|
||||
applyHeartbeatForTests({
|
||||
bound: true,
|
||||
address: "127.0.0.1:4739",
|
||||
packetsReceived: 42,
|
||||
lastExporterIp: "10.255.254.3",
|
||||
lastError: "",
|
||||
lastDatagramAt: new Date().toISOString(),
|
||||
pendingSize: 1,
|
||||
dropped: 0,
|
||||
rowsStored: 1,
|
||||
workerAlive: true,
|
||||
rings: [],
|
||||
})
|
||||
assert.equal(getFlowListenerState().bound, true)
|
||||
assert.equal(getFlowRuntimeCounters().packetsReceived, 42)
|
||||
assert.equal(getFlowWorkerHealth().alive, false)
|
||||
setWantListenForTests(true)
|
||||
assert.equal(simulateWorkerExitForTests(), 1)
|
||||
assert.equal(getFlowListenerState().bound, false)
|
||||
setWantListenForTests(false)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
setRefreshIfacesForTests(null)
|
||||
|
||||
console.log("traffic-flow-ingest.test.ts: ok")
|
||||
@@ -0,0 +1,518 @@
|
||||
import { Worker } from "node:worker_threads"
|
||||
import { existsSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { gte, sql } from "drizzle-orm"
|
||||
import { beginSqliteExclusiveOp, db, endSqliteExclusiveOp, sqliteDatabase } from "../db/index.js"
|
||||
import { env } from "../config.js"
|
||||
import { flowBuckets, servers } from "../db/schema.js"
|
||||
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
|
||||
import {
|
||||
attachEngineSqlite,
|
||||
applyRingSnapshot,
|
||||
configureEngine,
|
||||
flushPending,
|
||||
getEngineStats,
|
||||
getRingMbps as engineGetRingMbps,
|
||||
ingestParsedFlowsForServerForTests as engineIngestForServer,
|
||||
lastFlushUsedTransactionForTests as engineLastFlushTx,
|
||||
listLiveFlowRows as engineListLive,
|
||||
peekPendingFlows,
|
||||
queueParsedFlows,
|
||||
resetEngineForTests,
|
||||
resolveServerId,
|
||||
rollFlowRings,
|
||||
setExporterResolveCtx,
|
||||
type PendingFlowRow,
|
||||
} from "./traffic-flow-engine.js"
|
||||
import {
|
||||
getTrafficFlowSettingsRow,
|
||||
listHostPeers,
|
||||
resetFlowIngestCounters,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
|
||||
export type { PendingFlowRow }
|
||||
|
||||
export interface FlowListenerState {
|
||||
bound: boolean
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export interface FlowWorkerHealth {
|
||||
alive: boolean
|
||||
bound: boolean
|
||||
pendingSize: number
|
||||
dropped: number
|
||||
packetsReceived: number
|
||||
}
|
||||
|
||||
let worker: Worker | null = null
|
||||
let restartTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let restartAttempts = 0
|
||||
let lastHeartbeat: CollectorHeartbeat | null = null
|
||||
let state: FlowListenerState = { bound: false, address: null }
|
||||
let wantListen = false
|
||||
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
|
||||
function workerFileUrl(): URL {
|
||||
const ts = import.meta.url.includes(".ts")
|
||||
return new URL(
|
||||
ts ? "./traffic-flow-collector-worker.ts" : "./traffic-flow-collector-worker.js",
|
||||
import.meta.url,
|
||||
)
|
||||
}
|
||||
|
||||
export function buildExporterMapPayload(): ExporterMapPayload {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const rows = db.select({
|
||||
id: servers.id,
|
||||
host: servers.host,
|
||||
mgmtTunnelIp: servers.mgmtTunnelIp,
|
||||
}).from(servers).all()
|
||||
const byTunnelIp: Array<[string, number]> = []
|
||||
const hostIps: Array<[string, number]> = []
|
||||
for (const row of rows) {
|
||||
if (row.mgmtTunnelIp) byTunnelIp.push([row.mgmtTunnelIp, row.id])
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.push([row.host, row.id])
|
||||
}
|
||||
return {
|
||||
overlayPrefix: settings.prefix,
|
||||
byTunnelIp,
|
||||
peers: listHostPeers(),
|
||||
hostIps,
|
||||
}
|
||||
}
|
||||
|
||||
function applyExporterCtxFromDb(): void {
|
||||
const payload = buildExporterMapPayload()
|
||||
setExporterResolveCtx({
|
||||
overlayPrefix: payload.overlayPrefix,
|
||||
byTunnelIp: new Map(payload.byTunnelIp),
|
||||
peers: payload.peers,
|
||||
hostIps: new Map(payload.hostIps),
|
||||
})
|
||||
}
|
||||
|
||||
function postToWorker(msg: MainToWorker): void {
|
||||
worker?.postMessage(msg)
|
||||
}
|
||||
|
||||
function handleWorkerMessage(msg: WorkerToMain): void {
|
||||
if (msg.type === "heartbeat") {
|
||||
lastHeartbeat = msg.payload
|
||||
state = { bound: msg.payload.bound, address: msg.payload.address }
|
||||
applyRingSnapshot(msg.payload.rings)
|
||||
restartAttempts = 0
|
||||
return
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
lastHeartbeat = lastHeartbeat
|
||||
? { ...lastHeartbeat, lastError: msg.payload.message, workerAlive: true }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function spawnWorker(): void {
|
||||
stopWorkerProcess()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
configureEngine({ topN: settings.topN, retentionHours: settings.retentionHours })
|
||||
applyExporterCtxFromDb()
|
||||
const w = new Worker(workerFileUrl(), { execArgv: process.execArgv })
|
||||
w.on("message", (msg: WorkerToMain) => handleWorkerMessage(msg))
|
||||
w.on("error", (err) => {
|
||||
state = { bound: false, address: null }
|
||||
lastHeartbeat = lastHeartbeat
|
||||
? { ...lastHeartbeat, workerAlive: false, lastError: err.message, bound: false }
|
||||
: {
|
||||
bound: false,
|
||||
address: null,
|
||||
packetsReceived: 0,
|
||||
lastExporterIp: null,
|
||||
lastError: err.message,
|
||||
lastDatagramAt: null,
|
||||
pendingSize: 0,
|
||||
dropped: 0,
|
||||
rowsStored: 0,
|
||||
workerAlive: false,
|
||||
rings: [],
|
||||
}
|
||||
})
|
||||
w.on("exit", (code) => {
|
||||
worker = null
|
||||
state = { bound: false, address: null }
|
||||
if (!wantListen) return
|
||||
const delay = Math.min(30_000, 1000 * 2 ** restartAttempts)
|
||||
restartAttempts += 1
|
||||
restartTimer = setTimeout(() => {
|
||||
if (wantListen) spawnWorker()
|
||||
}, delay)
|
||||
void code
|
||||
})
|
||||
worker = w
|
||||
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
||||
postToWorker({
|
||||
type: "start",
|
||||
payload: {
|
||||
dbPath: env.DATABASE_PATH,
|
||||
listenHost: host,
|
||||
listenPort: settings.flowListenPort,
|
||||
topN: settings.topN,
|
||||
retentionHours: settings.retentionHours,
|
||||
exporterMap: buildExporterMapPayload(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function stopWorkerProcess(): void {
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
if (worker) {
|
||||
try {
|
||||
postToWorker({ type: "stop" })
|
||||
void worker.terminate()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
worker = null
|
||||
}
|
||||
}
|
||||
|
||||
export function reattachFlowSqlite(): void {
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
}
|
||||
|
||||
export function applyHeartbeatForTests(payload: CollectorHeartbeat): void {
|
||||
handleWorkerMessage({ type: "heartbeat", payload })
|
||||
}
|
||||
|
||||
export function simulateWorkerExitForTests(): number {
|
||||
worker = null
|
||||
state = { bound: false, address: null }
|
||||
lastHeartbeat = lastHeartbeat ? { ...lastHeartbeat, workerAlive: false, bound: false } : null
|
||||
if (!wantListen) return restartAttempts
|
||||
restartAttempts += 1
|
||||
return restartAttempts
|
||||
}
|
||||
|
||||
export function setWantListenForTests(value: boolean): void {
|
||||
wantListen = value
|
||||
}
|
||||
|
||||
export function getFlowListenerState(): FlowListenerState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function getFlowWorkerHealth(): FlowWorkerHealth {
|
||||
const hb = lastHeartbeat
|
||||
const mem = getEngineStats()
|
||||
return {
|
||||
alive: Boolean(worker) && (hb?.workerAlive ?? false),
|
||||
bound: state.bound,
|
||||
pendingSize: hb?.pendingSize ?? mem.pendingSize,
|
||||
dropped: hb?.dropped ?? mem.dropped,
|
||||
packetsReceived: hb?.packetsReceived ?? mem.packetsReceived,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFlowRuntimeCounters() {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const hb = lastHeartbeat
|
||||
return {
|
||||
packetsReceived: hb?.packetsReceived ?? settings.packetsReceived,
|
||||
lastExporterIp: hb?.lastExporterIp ?? settings.lastExporterIp ?? null,
|
||||
lastError: (hb?.lastError ?? settings.lastError) || null,
|
||||
lastDatagramAt: hb?.lastDatagramAt ?? settings.lastDatagramAt ?? null,
|
||||
dropped: hb?.dropped ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function startTrafficFlowListener() {
|
||||
stopTrafficFlowListener()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
if (!settings.enabled) {
|
||||
wantListen = false
|
||||
state = { bound: false, address: null }
|
||||
return
|
||||
}
|
||||
wantListen = true
|
||||
spawnWorker()
|
||||
}
|
||||
|
||||
export function stopTrafficFlowListener() {
|
||||
wantListen = false
|
||||
stopWorkerProcess()
|
||||
try {
|
||||
flushPending()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
state = { bound: false, address: null }
|
||||
}
|
||||
|
||||
export function refreshFlowExporterMap(): void {
|
||||
applyExporterCtxFromDb()
|
||||
postToWorker({ type: "updateExporterMap", payload: buildExporterMapPayload() })
|
||||
}
|
||||
|
||||
export function getRingMbps(serverId: number, iface = "__all__") {
|
||||
return engineGetRingMbps(serverId, iface)
|
||||
}
|
||||
|
||||
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
|
||||
const key = `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
|
||||
const prev = map.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += row.bytes
|
||||
prev.packets += row.packets
|
||||
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||
return
|
||||
}
|
||||
map.set(key, { ...row })
|
||||
}
|
||||
|
||||
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
if (worker && lastHeartbeat?.workerAlive) {
|
||||
return listStoredFlowRows(sinceIso)
|
||||
}
|
||||
return engineListLive(sinceIso)
|
||||
}
|
||||
|
||||
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const cap = Math.max(20, settings.topN) * 60
|
||||
const stored = db.select().from(flowBuckets)
|
||||
.where(gte(flowBuckets.bucketAt, sinceIso))
|
||||
.orderBy(sql`${flowBuckets.bytes} DESC`)
|
||||
.limit(cap)
|
||||
.all()
|
||||
const merged = new Map<string, PendingFlowRow>()
|
||||
for (const r of stored) {
|
||||
mergeInto(merged, {
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface ?? "",
|
||||
nextHop: r.nextHop ?? "",
|
||||
flowStartMs: r.flowStartMs ?? 0,
|
||||
flowEndMs: r.flowEndMs ?? 0,
|
||||
})
|
||||
}
|
||||
if (!worker) {
|
||||
for (const p of peekPendingFlows()) {
|
||||
if (p.bucketAt < sinceIso) continue
|
||||
mergeInto(merged, p)
|
||||
}
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export function listFlowRowsForWindow(minutes: number): PendingFlowRow[] {
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
if (minutes <= 15 && !worker) return listLiveFlowRows(sinceIso)
|
||||
return listStoredFlowRows(sinceIso)
|
||||
}
|
||||
|
||||
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const runtime = getFlowRuntimeCounters()
|
||||
const rows = listFlowRowsForWindow(minutes)
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||
const protoBytes = new Map<number, number>()
|
||||
const srcs = new Set<string>()
|
||||
const dsts = new Set<string>()
|
||||
const exporters = new Set<number>()
|
||||
let totalBytes = 0
|
||||
for (const r of rows) {
|
||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
|
||||
const prev = agg.get(key)
|
||||
const bytes = r.bytes
|
||||
totalBytes += bytes
|
||||
srcs.add(r.src)
|
||||
dsts.add(r.dst)
|
||||
exporters.add(r.serverId)
|
||||
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
|
||||
if (prev) {
|
||||
prev.rawBytes += bytes
|
||||
prev.bytes += bytes
|
||||
prev.packets += r.packets
|
||||
} else {
|
||||
agg.set(key, {
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
protoName: protoName(r.proto),
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes,
|
||||
packets: r.packets,
|
||||
bps: 0,
|
||||
inIface: resolved.name,
|
||||
inIfaceIndex: resolved.index,
|
||||
application: applicationName(r.proto, r.dstPort, r.srcPort),
|
||||
rawBytes: bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
const windowSec = Math.max(60, minutes * 60)
|
||||
const talkers = [...agg.values()]
|
||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, settings.topN)
|
||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||
let topProto = "—"
|
||||
let topProtoBytes = 0
|
||||
for (const [p, b] of protoBytes) {
|
||||
if (b > topProtoBytes) {
|
||||
topProtoBytes = b
|
||||
topProto = protoName(p)
|
||||
}
|
||||
}
|
||||
return {
|
||||
exportersOnline: exporters.size,
|
||||
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
|
||||
uniqueSrc: srcs.size,
|
||||
uniqueDst: dsts.size,
|
||||
topProto,
|
||||
talkers,
|
||||
lastExporterIp: runtime.lastExporterIp,
|
||||
lastError: runtime.lastError,
|
||||
packetsReceived: runtime.packetsReceived,
|
||||
lastDatagramAt: runtime.lastDatagramAt,
|
||||
listenerBound: state.bound,
|
||||
listenerAddress: state.address,
|
||||
}
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
|
||||
applyExporterCtxFromDb()
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) return
|
||||
queueParsedFlows(serverId, flows)
|
||||
rollFlowRings()
|
||||
flushPending()
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
||||
engineIngestForServer(serverId, flows)
|
||||
}
|
||||
|
||||
export function resetFlowRingsForTests() {
|
||||
resetEngineForTests()
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
lastHeartbeat = null
|
||||
wantListen = false
|
||||
restartAttempts = 0
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
return engineLastFlushTx()
|
||||
}
|
||||
|
||||
export function flushPendingForTests(): void {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
function tableCount(name: string): number {
|
||||
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
function dbFileBytes(): number {
|
||||
const resolved = path.resolve(process.cwd(), env.DATABASE_PATH)
|
||||
if (!existsSync(resolved)) return 0
|
||||
return statSync(resolved).size
|
||||
}
|
||||
|
||||
async function stopWorkerProcessAsync(): Promise<void> {
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = null
|
||||
}
|
||||
if (!worker) return
|
||||
const current = worker
|
||||
worker = null
|
||||
try {
|
||||
current.postMessage({ type: "stop" })
|
||||
await current.terminate()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаляет сессии, minute/daily rollup и сжимает SQLite. Ключи WG и пиры JH не трогает. */
|
||||
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
||||
beginSqliteExclusiveOp()
|
||||
try {
|
||||
wantListen = false
|
||||
await stopWorkerProcessAsync()
|
||||
resetEngineForTests()
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
lastHeartbeat = null
|
||||
state = { bound: false, address: null }
|
||||
const fileBytesBefore = dbFileBytes()
|
||||
const deleted = {
|
||||
buckets: tableCount("flow_buckets"),
|
||||
minuteStats: tableCount("flow_minute_stats"),
|
||||
minuteDims: tableCount("flow_minute_dims"),
|
||||
dailyDims: tableCount("flow_daily_dims"),
|
||||
}
|
||||
sqliteDatabase.exec(`
|
||||
DELETE FROM flow_buckets;
|
||||
DELETE FROM flow_minute_stats;
|
||||
DELETE FROM flow_minute_dims;
|
||||
DELETE FROM flow_daily_dims;
|
||||
`)
|
||||
resetFlowIngestCounters()
|
||||
try {
|
||||
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
let vacuumed = false
|
||||
try {
|
||||
sqliteDatabase.exec("VACUUM")
|
||||
vacuumed = true
|
||||
} catch {
|
||||
vacuumed = false
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
deleted,
|
||||
fileBytesBefore,
|
||||
fileBytesAfter: dbFileBytes(),
|
||||
vacuumed,
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
startTrafficFlowListener()
|
||||
} catch {
|
||||
/* ingest мог остаться выключенным */
|
||||
}
|
||||
endSqliteExclusiveOp()
|
||||
}
|
||||
}
|
||||
|
||||
export { peekPendingFlows }
|
||||
export { setPendingCapForTests } from "./traffic-flow-engine.js"
|
||||
export { maybeRefreshIfaces, setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
|
||||
@@ -0,0 +1,54 @@
|
||||
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
|
||||
|
||||
export function ipv4ToInt(ip: string): number | null {
|
||||
const parts = String(ip ?? "").trim().split(".")
|
||||
if (parts.length !== 4) return null
|
||||
let n = 0
|
||||
for (const p of parts) {
|
||||
if (!/^\d+$/.test(p)) return null
|
||||
const o = Number(p)
|
||||
if (o < 0 || o > 255) return null
|
||||
n = ((n << 8) >>> 0) + o
|
||||
}
|
||||
return n >>> 0
|
||||
}
|
||||
|
||||
export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLen: number } | null {
|
||||
const raw = String(cidr ?? "").trim()
|
||||
const [ip, lenRaw] = raw.split("/")
|
||||
const addr = ipv4ToInt(ip ?? "")
|
||||
const prefixLen = Number.parseInt(lenRaw ?? "", 10)
|
||||
if (addr == null || !Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return null
|
||||
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||
return { net: (addr & mask) >>> 0, mask, prefixLen }
|
||||
}
|
||||
|
||||
export function ipInCidrV4(ip: string, cidr: string): boolean {
|
||||
const addr = ipv4ToInt(ip)
|
||||
const parsed = parseCidrV4(cidr)
|
||||
if (addr == null || !parsed) return false
|
||||
return ((addr & parsed.mask) >>> 0) === parsed.net
|
||||
}
|
||||
|
||||
export function isNonPublicIp(ip: string): boolean {
|
||||
const trimmed = String(ip ?? "").trim()
|
||||
if (!trimmed) return true
|
||||
if (trimmed.includes(":")) {
|
||||
const lower = trimmed.toLowerCase()
|
||||
return lower === "::1" || lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd") || lower === "::"
|
||||
}
|
||||
const n = ipv4ToInt(trimmed)
|
||||
if (n == null) return true
|
||||
const inRange = (cidr: string) => ipInCidrV4(trimmed, cidr)
|
||||
return (
|
||||
inRange("0.0.0.0/8")
|
||||
|| inRange("10.0.0.0/8")
|
||||
|| inRange("127.0.0.0/8")
|
||||
|| inRange("169.254.0.0/16")
|
||||
|| inRange("172.16.0.0/12")
|
||||
|| inRange("192.168.0.0/16")
|
||||
|| inRange("100.64.0.0/10")
|
||||
|| inRange("224.0.0.0/4")
|
||||
|| inRange("255.255.255.255/32")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bareIpv4,
|
||||
ipInCidr,
|
||||
isNatMasqueradeExporter,
|
||||
normalizeExporterIp,
|
||||
pickServerIdForExporter,
|
||||
} from "./traffic-flow-map-exporter.js"
|
||||
|
||||
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
|
||||
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
|
||||
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
|
||||
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
|
||||
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
|
||||
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
|
||||
|
||||
const byTunnel = new Map([["10.255.254.3", 7]])
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "10.255.254.3",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
|
||||
hostIps: new Map(),
|
||||
}), 7)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "172.18.0.2",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
|
||||
peers: [
|
||||
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
|
||||
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
|
||||
],
|
||||
hostIps: new Map(),
|
||||
}), null)
|
||||
|
||||
assert.equal(pickServerIdForExporter({
|
||||
exporterIp: "94.142.140.141",
|
||||
overlayPrefix: "10.255.254.0/24",
|
||||
byTunnelIp: byTunnel,
|
||||
peers: [],
|
||||
hostIps: new Map([["94.142.140.141", 7]]),
|
||||
}), 7)
|
||||
|
||||
console.log("traffic-flow-map-exporter.test.ts: ok")
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface OverlayPeerRef {
|
||||
serverId: number
|
||||
address: string
|
||||
allowedIps: string[]
|
||||
}
|
||||
|
||||
export function normalizeExporterIp(ip: string): string {
|
||||
const trimmed = ip.trim()
|
||||
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function bareIpv4(value: string): string {
|
||||
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
|
||||
return raw
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip: string): number | null {
|
||||
const parts = ip.split(".")
|
||||
if (parts.length !== 4) return null
|
||||
const n = parts.map((x) => Number(x))
|
||||
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
|
||||
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
|
||||
}
|
||||
|
||||
export function ipInCidr(ip: string, cidr: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
const [base, bitsRaw] = cidr.split("/")
|
||||
const bits = Number(bitsRaw ?? 32)
|
||||
const a = ipv4ToInt(host)
|
||||
const b = ipv4ToInt(bareIpv4(base ?? ""))
|
||||
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
||||
return (a & mask) === (b & mask)
|
||||
}
|
||||
|
||||
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
|
||||
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
|
||||
const host = bareIpv4(ip)
|
||||
if (!host) return false
|
||||
if (ipInCidr(host, overlayPrefix)) return false
|
||||
return ipInCidr(host, "10.0.0.0/8")
|
||||
|| ipInCidr(host, "172.16.0.0/12")
|
||||
|| ipInCidr(host, "192.168.0.0/16")
|
||||
|| ipInCidr(host, "127.0.0.0/8")
|
||||
}
|
||||
|
||||
export function pickServerIdForExporter(opts: {
|
||||
exporterIp: string
|
||||
overlayPrefix: string
|
||||
byTunnelIp: Map<string, number>
|
||||
peers: OverlayPeerRef[]
|
||||
hostIps: Map<string, number>
|
||||
}): number | null {
|
||||
const exporter = bareIpv4(opts.exporterIp)
|
||||
if (!exporter) return null
|
||||
|
||||
const exact = opts.byTunnelIp.get(exporter)
|
||||
if (exact != null) return exact
|
||||
|
||||
for (const [ip, id] of opts.byTunnelIp) {
|
||||
if (bareIpv4(ip) === exporter) return id
|
||||
}
|
||||
|
||||
for (const peer of opts.peers) {
|
||||
if (bareIpv4(peer.address) === exporter) return peer.serverId
|
||||
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
|
||||
return peer.serverId
|
||||
}
|
||||
}
|
||||
|
||||
const byHost = opts.hostIps.get(exporter)
|
||||
if (byHost != null) return byHost
|
||||
|
||||
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
|
||||
|
||||
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
|
||||
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
|
||||
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
|
||||
if (peerIds.length === 1) return peerIds[0] ?? null
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
@@ -0,0 +1,418 @@
|
||||
export interface ParsedFlow {
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
bytes: number
|
||||
packets: number
|
||||
inIface: string
|
||||
outIface: string
|
||||
nextHop: string
|
||||
flowStartMs: number
|
||||
flowEndMs: number
|
||||
natSrc: string
|
||||
natDst: string
|
||||
}
|
||||
|
||||
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
|
||||
|
||||
export function emptyParsedFlow(): ParsedFlow {
|
||||
return {
|
||||
src: "",
|
||||
dst: "",
|
||||
proto: 0,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 0,
|
||||
packets: 0,
|
||||
inIface: "",
|
||||
outIface: "",
|
||||
nextHop: "",
|
||||
flowStartMs: 0,
|
||||
flowEndMs: 0,
|
||||
natSrc: "",
|
||||
natDst: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
|
||||
return {
|
||||
...emptyParsedFlow(),
|
||||
...flow,
|
||||
nextHop: flow.nextHop ?? "",
|
||||
flowStartMs: flow.flowStartMs ?? 0,
|
||||
flowEndMs: flow.flowEndMs ?? 0,
|
||||
natSrc: flow.natSrc ?? "",
|
||||
natDst: flow.natDst ?? "",
|
||||
inIface: flow.inIface ?? "",
|
||||
outIface: flow.outIface ?? "",
|
||||
srcPort: flow.srcPort ?? 0,
|
||||
dstPort: flow.dstPort ?? 0,
|
||||
packets: flow.packets ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldSpec {
|
||||
type: number
|
||||
length: number
|
||||
}
|
||||
|
||||
interface Template {
|
||||
fields: FieldSpec[]
|
||||
}
|
||||
|
||||
const MAX_TEMPLATE_EXPORTERS = 256
|
||||
const templatesByExporter = new Map<string, Map<number, Template>>()
|
||||
|
||||
function templatesForExporter(exporter: string): Map<number, Template> {
|
||||
const existing = templatesByExporter.get(exporter)
|
||||
if (existing) {
|
||||
templatesByExporter.delete(exporter)
|
||||
templatesByExporter.set(exporter, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Map<number, Template>()
|
||||
templatesByExporter.set(exporter, created)
|
||||
while (templatesByExporter.size > MAX_TEMPLATE_EXPORTERS) {
|
||||
const oldest = templatesByExporter.keys().next().value
|
||||
if (oldest == null || oldest === exporter) break
|
||||
templatesByExporter.delete(oldest)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
function ipv4(buf: Buffer, offset: number): string {
|
||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||
}
|
||||
|
||||
function ipv6(buf: Buffer, offset: number): string {
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
|
||||
return parts.join(":")
|
||||
}
|
||||
|
||||
const VAR_LEN = 0xffff
|
||||
|
||||
function consumeField(
|
||||
buf: Buffer,
|
||||
off: number,
|
||||
length: number,
|
||||
limit: number,
|
||||
): { data: Buffer; next: number } | null {
|
||||
if (length === VAR_LEN) {
|
||||
if (off >= limit) return null
|
||||
const first = buf[off]!
|
||||
if (first < 255) {
|
||||
const end = off + 1 + first
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 1, end), next: end }
|
||||
}
|
||||
if (off + 3 > limit) return null
|
||||
const len = buf.readUInt16BE(off + 1)
|
||||
const end = off + 3 + len
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off + 3, end), next: end }
|
||||
}
|
||||
const end = off + length
|
||||
if (end > limit) return null
|
||||
return { data: buf.subarray(off, end), next: end }
|
||||
}
|
||||
|
||||
function fixedRecordSize(fields: FieldSpec[]): number | null {
|
||||
let n = 0
|
||||
for (const f of fields) {
|
||||
if (f.length === VAR_LEN) return null
|
||||
n += f.length
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) return buf.readUInt8(offset)
|
||||
if (length === 2) return buf.readUInt16BE(offset)
|
||||
if (length === 4) return buf.readUInt32BE(offset)
|
||||
if (length === 8) {
|
||||
const big = buf.readBigUInt64BE(offset)
|
||||
const n = Number(big)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
let v = 0
|
||||
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
|
||||
return v >>> 0
|
||||
}
|
||||
|
||||
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
||||
if (buf.length < 24) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
const out: ParsedFlow[] = []
|
||||
let off = 24
|
||||
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||
out.push(normalizeParsedFlow({
|
||||
src: ipv4(buf, off),
|
||||
dst: ipv4(buf, off + 4),
|
||||
packets: buf.readUInt32BE(off + 16),
|
||||
bytes: buf.readUInt32BE(off + 20),
|
||||
srcPort: buf.readUInt16BE(off + 32),
|
||||
dstPort: buf.readUInt16BE(off + 34),
|
||||
proto: buf.readUInt8(off + 38),
|
||||
inIface: String(buf.readUInt16BE(off + 12)),
|
||||
outIface: String(buf.readUInt16BE(off + 14)),
|
||||
}))
|
||||
off += 48
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||
let off = setStart + 4
|
||||
const map = templatesForExporter(exporter)
|
||||
while (off + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(off)
|
||||
const fieldCount = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (setId === 3) {
|
||||
// options template: skip scope count
|
||||
if (off + 2 > setEnd) break
|
||||
off += 2
|
||||
}
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
|
||||
const type = buf.readUInt16BE(off)
|
||||
const length = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (type & 0x8000) {
|
||||
if (off + 4 > setEnd) break
|
||||
off += 4
|
||||
}
|
||||
fields.push({ type: type & 0x7fff, length })
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
}
|
||||
|
||||
function recordFromFields(
|
||||
fields: FieldSpec[],
|
||||
buf: Buffer,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): { flow: ParsedFlow; next: number } | null {
|
||||
let off = offset
|
||||
let src = ""
|
||||
let dst = ""
|
||||
let proto = 0
|
||||
let srcPort = 0
|
||||
let dstPort = 0
|
||||
let bytes = 0
|
||||
let packets = 0
|
||||
let inIface = ""
|
||||
let outIface = ""
|
||||
let ifaceName = ""
|
||||
let nextHop = ""
|
||||
let flowStartMs = 0
|
||||
let flowEndMs = 0
|
||||
let natSrc = ""
|
||||
let natDst = ""
|
||||
for (const f of fields) {
|
||||
const field = consumeField(buf, off, f.length, limit)
|
||||
if (!field) return null
|
||||
const { data } = field
|
||||
switch (f.type) {
|
||||
case 8:
|
||||
if (data.length === 4) src = ipv4(data, 0)
|
||||
break
|
||||
case 12:
|
||||
if (data.length === 4) dst = ipv4(data, 0)
|
||||
break
|
||||
case 27:
|
||||
if (data.length === 16 && !src) src = ipv6(data, 0)
|
||||
break
|
||||
case 28:
|
||||
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
||||
break
|
||||
case 15:
|
||||
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||
break
|
||||
case 18:
|
||||
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||
break
|
||||
case 62:
|
||||
if (data.length === 16 && !nextHop) nextHop = ipv6(data, 0)
|
||||
break
|
||||
case 225:
|
||||
if (data.length === 4) {
|
||||
natSrc = ipv4(data, 0)
|
||||
if (!src) src = natSrc
|
||||
}
|
||||
break
|
||||
case 226:
|
||||
if (data.length === 4) {
|
||||
natDst = ipv4(data, 0)
|
||||
if (!dst) dst = natDst
|
||||
}
|
||||
break
|
||||
case 4:
|
||||
proto = readUint(data, 0, data.length)
|
||||
break
|
||||
case 7:
|
||||
srcPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 11:
|
||||
dstPort = readUint(data, 0, data.length)
|
||||
break
|
||||
case 1:
|
||||
bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 2:
|
||||
packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 85:
|
||||
if (!bytes) bytes = readUint(data, 0, data.length)
|
||||
break
|
||||
case 86:
|
||||
if (!packets) packets = readUint(data, 0, data.length)
|
||||
break
|
||||
case 10:
|
||||
inIface = String(readUint(data, 0, data.length))
|
||||
break
|
||||
case 14:
|
||||
outIface = String(readUint(data, 0, data.length))
|
||||
break
|
||||
case 21:
|
||||
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 22:
|
||||
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 150:
|
||||
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length) * 1000
|
||||
break
|
||||
case 151:
|
||||
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length) * 1000
|
||||
break
|
||||
case 152:
|
||||
flowStartMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 153:
|
||||
flowEndMs = readUint(data, 0, data.length)
|
||||
break
|
||||
case 82:
|
||||
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
off = field.next
|
||||
}
|
||||
if (ifaceName && !inIface) inIface = ifaceName
|
||||
return {
|
||||
flow: normalizeParsedFlow({
|
||||
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst,
|
||||
}),
|
||||
next: off,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataRecords(
|
||||
tpl: Template,
|
||||
buf: Buffer,
|
||||
recOff: number,
|
||||
setEnd: number,
|
||||
out: ParsedFlow[],
|
||||
) {
|
||||
const size = fixedRecordSize(tpl.fields)
|
||||
while (recOff + 1 < setEnd) {
|
||||
if (size != null && recOff + size > setEnd) break
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
|
||||
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 16) return []
|
||||
const total = buf.readUInt16BE(2)
|
||||
const end = Math.min(buf.length, total)
|
||||
let off = 16
|
||||
const out: ParsedFlow[] = []
|
||||
while (off + 4 <= end) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > end) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 2 || setId === 3) {
|
||||
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 20) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
let off = 20
|
||||
const out: ParsedFlow[] = []
|
||||
const map = templatesForExporter(exporter)
|
||||
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > buf.length) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 0) {
|
||||
let tOff = off + 4
|
||||
while (tOff + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(tOff)
|
||||
const fieldCount = buf.readUInt16BE(tOff + 2)
|
||||
tOff += 4
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
|
||||
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
|
||||
tOff += 4
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = map.get(setId)
|
||||
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
|
||||
if (buf.length < 2) return []
|
||||
const version = buf.readUInt16BE(0)
|
||||
if (version === 5) return parseNetflowV5(buf)
|
||||
if (version === 9) return parseNetflowV9(buf, exporterIp)
|
||||
if (version === 10) return parseIpfix(buf, exporterIp)
|
||||
return []
|
||||
}
|
||||
|
||||
export function protoName(proto: number): string {
|
||||
switch (proto) {
|
||||
case 1: return "ICMP"
|
||||
case 6: return "TCP"
|
||||
case 17: return "UDP"
|
||||
case 47: return "GRE"
|
||||
case 50: return "ESP"
|
||||
case 89: return "OSPF"
|
||||
default: return String(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFlowTemplatesForTests() {
|
||||
templatesByExporter.clear()
|
||||
}
|
||||
|
||||
export function templateExporterCountForTests(): number {
|
||||
return templatesByExporter.size
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
classifyFlowPlane,
|
||||
classifyFlowPlaneLite,
|
||||
flowBps,
|
||||
shouldKeepPlane,
|
||||
} from "./traffic-flow-planes.js"
|
||||
|
||||
const youtubeInner = {
|
||||
src: "10.100.1.17",
|
||||
dst: "173.194.160.163",
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
inIface: "gre-client",
|
||||
outIface: "NSK-SERVHOST-RTK",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(youtubeInner), "payload")
|
||||
assert.equal(classifyFlowPlane(youtubeInner), "payload")
|
||||
|
||||
const greOverlay = {
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
inIface: "ether1",
|
||||
outIface: "NSK-SERVHOST-RTK",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(greOverlay), "overlay")
|
||||
|
||||
const espOverlay = { ...greOverlay, proto: 50 }
|
||||
assert.equal(classifyFlowPlaneLite(espOverlay), "overlay")
|
||||
|
||||
const mesh = {
|
||||
src: "10.100.1.17",
|
||||
dst: "10.100.1.18",
|
||||
proto: 6,
|
||||
srcPort: 50000,
|
||||
dstPort: 443,
|
||||
inIface: "gre-a",
|
||||
outIface: "gre-b",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(mesh), "client_mesh")
|
||||
|
||||
const mgmt = {
|
||||
src: "10.255.254.2",
|
||||
dst: "10.255.254.1",
|
||||
proto: 17,
|
||||
srcPort: 4739,
|
||||
dstPort: 4739,
|
||||
inIface: "wg-flow",
|
||||
outIface: "",
|
||||
}
|
||||
assert.equal(classifyFlowPlaneLite(mgmt), "mgmt")
|
||||
assert.equal(classifyFlowPlaneLite({ ...youtubeInner, outIface: "wg-flow" }), "payload")
|
||||
assert.equal(shouldKeepPlane("mgmt", {}), false)
|
||||
assert.equal(shouldKeepPlane("overlay", {}), false)
|
||||
assert.equal(shouldKeepPlane("client_mesh", {}), false)
|
||||
assert.equal(shouldKeepPlane("payload", {}), true)
|
||||
assert.equal(shouldKeepPlane("overlay", { excludeOverlay: false }), true)
|
||||
assert.equal(shouldKeepPlane("client_mesh", { excludeMesh: false }), true)
|
||||
|
||||
assert.equal(flowBps(1500, 1_000, 2_000, 300), (1500 * 8) / 1)
|
||||
assert.equal(flowBps(1500, 0, 0, 300), (1500 * 8) / 300)
|
||||
|
||||
const publicJhEn = {
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 6,
|
||||
srcPort: 1000,
|
||||
dstPort: 443,
|
||||
inIface: "ether1",
|
||||
outIface: "gre-en",
|
||||
}
|
||||
assert.equal(classifyFlowPlane(publicJhEn, {
|
||||
clientIfaceNames: new Set(["gre-client"]),
|
||||
enHosts: new Set(["198.51.100.1"]),
|
||||
jhHosts: new Set(["203.0.113.10"]),
|
||||
}), "overlay")
|
||||
|
||||
console.log("traffic-flow-planes.test.ts: ok")
|
||||
@@ -0,0 +1,107 @@
|
||||
export type FlowPlane = "payload" | "client_mesh" | "overlay" | "mgmt"
|
||||
|
||||
export const PLANE_LABEL: Record<FlowPlane, string> = {
|
||||
payload: "Интернет",
|
||||
client_mesh: "Клиенты",
|
||||
overlay: "JH↔EN",
|
||||
mgmt: "mgmt",
|
||||
}
|
||||
|
||||
const WG_PORTS = new Set([51820, 13232, 51821])
|
||||
const FLOW_PORTS = new Set([4739, 2055])
|
||||
|
||||
export function isRfc1918(ip: string): boolean {
|
||||
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||
const [a, b] = parts
|
||||
if (a === 10) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 172 && b != null && b >= 16 && b <= 31) return true
|
||||
if (a === 100 && b != null && b >= 64 && b <= 127) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isPublicV4(ip: string): boolean {
|
||||
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||
const a = parts[0] ?? 0
|
||||
if (a === 0 || a === 127 || a >= 224) return false
|
||||
return !isRfc1918(ip)
|
||||
}
|
||||
|
||||
export function isTunnelProto(proto: number, srcPort: number, dstPort: number): boolean {
|
||||
if (proto === 47 || proto === 50) return true
|
||||
if (proto === 17 && (WG_PORTS.has(srcPort) || WG_PORTS.has(dstPort))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function ifaceLooksMgmt(name: string): boolean {
|
||||
const n = name.trim().toLowerCase()
|
||||
return n === "wg-flow" || n.endsWith("/wg-flow") || n.includes("wg-flow")
|
||||
}
|
||||
|
||||
export interface PlaneFlowInput {
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
inIface: string
|
||||
outIface?: string
|
||||
}
|
||||
|
||||
/** Быстрая классификация без топологии — для live ring на ingest. */
|
||||
export function classifyFlowPlaneLite(flow: PlaneFlowInput): FlowPlane {
|
||||
if (ifaceLooksMgmt(flow.inIface)) return "mgmt"
|
||||
if (flow.proto === 17 && (FLOW_PORTS.has(flow.srcPort) || FLOW_PORTS.has(flow.dstPort))) return "mgmt"
|
||||
if (isTunnelProto(flow.proto, flow.srcPort, flow.dstPort)) return "overlay"
|
||||
if (isRfc1918(flow.src) && isRfc1918(flow.dst)) return "client_mesh"
|
||||
return "payload"
|
||||
}
|
||||
|
||||
export interface PlaneTopology {
|
||||
clientIfaceNames: Set<string>
|
||||
enHosts: Set<string>
|
||||
jhHosts: Set<string>
|
||||
}
|
||||
|
||||
function hostHit(ip: string, hosts: Set<string>): boolean {
|
||||
return Boolean(ip) && hosts.has(ip)
|
||||
}
|
||||
|
||||
export function classifyFlowPlane(
|
||||
flow: PlaneFlowInput,
|
||||
topo?: PlaneTopology | null,
|
||||
): FlowPlane {
|
||||
const lite = classifyFlowPlaneLite(flow)
|
||||
if (!topo) return lite
|
||||
if (lite === "mgmt") return "mgmt"
|
||||
if (lite === "overlay") return "overlay"
|
||||
const srcEn = hostHit(flow.src, topo.enHosts) || hostHit(flow.src, topo.jhHosts)
|
||||
const dstEn = hostHit(flow.dst, topo.enHosts) || hostHit(flow.dst, topo.jhHosts)
|
||||
if (srcEn && dstEn && isPublicV4(flow.src) && isPublicV4(flow.dst)) return "overlay"
|
||||
if (lite === "client_mesh") {
|
||||
const inClient = topo.clientIfaceNames.has(flow.inIface)
|
||||
const outClient = Boolean(flow.outIface && topo.clientIfaceNames.has(flow.outIface))
|
||||
if (inClient || outClient || (isRfc1918(flow.src) && isRfc1918(flow.dst))) return "client_mesh"
|
||||
}
|
||||
return "payload"
|
||||
}
|
||||
|
||||
export function shouldKeepPlane(
|
||||
plane: FlowPlane,
|
||||
opts: { excludeMesh?: boolean; excludeOverlay?: boolean },
|
||||
): boolean {
|
||||
if (plane === "mgmt") return false
|
||||
if (opts.excludeMesh !== false && plane === "client_mesh") return false
|
||||
if (opts.excludeOverlay !== false && plane === "overlay") return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function flowBps(bytes: number, startMs: number, endMs: number, windowSec: number): number {
|
||||
if (startMs > 0 && endMs > startMs) {
|
||||
const sec = Math.max(1, (endMs - startMs) / 1000)
|
||||
return (bytes * 8) / sec
|
||||
}
|
||||
return (bytes * 8) / Math.max(1, windowSec)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "mm-flow-purge-"))
|
||||
process.env.DATABASE_PATH = path.join(dir, "test.db")
|
||||
|
||||
const { sqliteDatabase } = await import("../db/index.js")
|
||||
const {
|
||||
getFlowRuntimeCounters,
|
||||
purgeTrafficFlowStore,
|
||||
stopTrafficFlowListener,
|
||||
} = await import("./traffic-flow-ingest.js")
|
||||
|
||||
function count(name: string): number {
|
||||
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
try {
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO servers (name, host) VALUES ('purge-test', '127.0.0.1')
|
||||
`).run()
|
||||
const serverId = Number(
|
||||
(sqliteDatabase.prepare(`SELECT id FROM servers WHERE name = 'purge-test'`).get() as { id: number }).id,
|
||||
)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', '10.0.0.1', '8.8.8.8', 6, 50000, 443, 100, 1, 'wg-flow')
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_minute_stats (server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', 100, 1, 1, 1, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
|
||||
VALUES (?, '2026-01-01T00:00:00.000Z', 'country', 'RU', 100, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||
VALUES (?, '2026-01-01', 'country', 'RU', 100, 1)
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
|
||||
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, '2026-01-01T00:00:00.000Z')
|
||||
`).run()
|
||||
sqliteDatabase.prepare(`
|
||||
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||
`).run()
|
||||
|
||||
const result = await purgeTrafficFlowStore()
|
||||
stopTrafficFlowListener()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.deleted.buckets, 1)
|
||||
assert.equal(result.deleted.minuteStats, 1)
|
||||
assert.equal(result.deleted.minuteDims, 1)
|
||||
assert.equal(result.deleted.dailyDims, 1)
|
||||
assert.equal(count("flow_buckets"), 0)
|
||||
assert.equal(count("flow_minute_stats"), 0)
|
||||
assert.equal(count("flow_minute_dims"), 0)
|
||||
assert.equal(count("flow_daily_dims"), 0)
|
||||
assert.equal(count("flow_ip_meta"), 1)
|
||||
assert.equal(count("servers"), 1)
|
||||
assert.equal(getFlowRuntimeCounters().packetsReceived, 0)
|
||||
assert.equal(getFlowRuntimeCounters().lastExporterIp, null)
|
||||
} finally {
|
||||
try {
|
||||
sqliteDatabase.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log("traffic-flow-purge.test.ts: ok")
|
||||
@@ -0,0 +1,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")
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -97,4 +97,29 @@ const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, [
|
||||
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")
|
||||
|
||||
@@ -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 | readonly string[],
|
||||
peerPublicKey?: string,
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
@@ -113,11 +119,12 @@ 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)
|
||||
@@ -132,12 +139,20 @@ export function buildTrafficFromSamples(
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const [name, arr] of byIface) {
|
||||
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]
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface BoundIfaceTrafficDto {
|
||||
userName: string
|
||||
interfaceName: string
|
||||
interfaceType: string
|
||||
peerPublicKey: string
|
||||
peerName: string
|
||||
comment: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
@@ -77,22 +79,6 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
||||
return users.map((user) => {
|
||||
const parts: BuiltTrafficSeries[] = []
|
||||
const interfaces: BoundIfaceTrafficDto[] = []
|
||||
const byServer = new Map<number, string[]>()
|
||||
for (const b of user.bindings) {
|
||||
const arr = byServer.get(b.serverId) ?? []
|
||||
arr.push(b.interfaceName)
|
||||
byServer.set(b.serverId, arr)
|
||||
}
|
||||
|
||||
for (const [serverId, names] of byServer) {
|
||||
let rows = sampleCache.get(serverId)
|
||||
if (!rows) {
|
||||
rows = readServerSamplesInRange(serverId, sinceIso)
|
||||
sampleCache.set(serverId, rows)
|
||||
}
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, names)
|
||||
parts.push(built)
|
||||
}
|
||||
|
||||
for (const b of user.bindings) {
|
||||
let rows = sampleCache.get(b.serverId)
|
||||
@@ -100,19 +86,25 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
||||
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||
sampleCache.set(b.serverId, rows)
|
||||
}
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
|
||||
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
|
||||
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}`,
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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, '\\"')}" \\`)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -15,5 +15,5 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type AppUser,
|
||||
type InterfaceType,
|
||||
} from "@/lib/users"
|
||||
import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
|
||||
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||
ether: "outline",
|
||||
@@ -87,23 +87,29 @@ function UsersExpandedDetail({
|
||||
<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 = meta.icon
|
||||
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", meta.className)}>
|
||||
<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.interfaceName}
|
||||
{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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
+229
-59
@@ -19,12 +19,23 @@ import {
|
||||
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,
|
||||
@@ -43,10 +54,28 @@ import {
|
||||
type UserServerOption,
|
||||
} from "@/lib/users"
|
||||
import {
|
||||
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
|
||||
TrashIcon, WrenchIcon,
|
||||
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" />,
|
||||
@@ -124,7 +153,8 @@ function UserSheet({
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
||||
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
||||
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
||||
const [selectedNames, setSelectedNames] = useState<string[]>([])
|
||||
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
||||
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||
const [newComment, setNewComment] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,7 +163,8 @@ function UserSheet({
|
||||
setSheetStep(1)
|
||||
setErrors({})
|
||||
setCatalogServerId(servers[0]?.id ?? "")
|
||||
setSelectedNames([])
|
||||
setSelectedPicks([])
|
||||
setExpandedWg(null)
|
||||
setNewComment("")
|
||||
}, [open, user, servers])
|
||||
|
||||
@@ -212,29 +243,32 @@ function UserSheet({
|
||||
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
||||
|
||||
const addSelectedBindings = () => {
|
||||
if (!catalogSrv || selectedNames.length === 0) return
|
||||
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
|
||||
if (!catalogSrv || selectedPicks.length === 0) return
|
||||
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||
const next: InterfaceBinding[] = [...form.bindings]
|
||||
for (const name of selectedNames) {
|
||||
const key = `${catalogSrv.id}::${name}`
|
||||
for (const pick of selectedPicks) {
|
||||
const key = bindingDiffKey({
|
||||
serverId: catalogSrv.id,
|
||||
interfaceName: pick.interfaceName,
|
||||
peerPublicKey: pick.peerPublicKey,
|
||||
})
|
||||
if (existing.has(key)) continue
|
||||
const iface = catalog.find((c) => c.name === name)
|
||||
if (!iface) continue
|
||||
if (iface.boundUserId && iface.boundUserId !== user?.id) continue
|
||||
next.push({
|
||||
id: `pending-${catalogSrv.id}-${name}`,
|
||||
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: name,
|
||||
interfaceType: iface.type,
|
||||
interfaceName: pick.interfaceName,
|
||||
interfaceType: pick.type,
|
||||
peerPublicKey: pick.peerPublicKey || undefined,
|
||||
peerName: pick.peerName,
|
||||
comment: newComment.trim(),
|
||||
})
|
||||
}
|
||||
setForm((f) => ({ ...f, bindings: next }))
|
||||
setSelectedNames([])
|
||||
setSelectedPicks([])
|
||||
setNewComment("")
|
||||
}
|
||||
|
||||
@@ -242,15 +276,26 @@ function UserSheet({
|
||||
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
||||
}
|
||||
|
||||
const toggleName = (name: string) => {
|
||||
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
|
||||
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)),
|
||||
() => 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">
|
||||
@@ -411,7 +456,7 @@ function UserSheet({
|
||||
|
||||
<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">
|
||||
@@ -419,56 +464,178 @@ function UserSheet({
|
||||
<select
|
||||
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
||||
value={catalogServerId}
|
||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
|
||||
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>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5 max-h-48 overflow-y-auto">
|
||||
{catalog.length === 0 && (
|
||||
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
|
||||
)}
|
||||
{catalog.map((iface) => {
|
||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(iface.name)
|
||||
const disabled = taken || mine
|
||||
return (
|
||||
<label
|
||||
key={iface.name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-0.5 text-xs",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={selectedNames.includes(iface.name)}
|
||||
onChange={() => toggleName(iface.name)}
|
||||
className="rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="font-mono">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
|
||||
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
|
||||
</Badge>
|
||||
{taken && (
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
|
||||
)}
|
||||
{mine && !taken && (
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<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={selectedNames.length === 0} onClick={addSelectedBindings}>
|
||||
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||
<PlusIcon className="size-3.5" />Привязать
|
||||
</Button>
|
||||
</div>
|
||||
@@ -480,8 +647,11 @@ function UserSheet({
|
||||
{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">{b.interfaceName}</span>
|
||||
<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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+78
-13
@@ -15,9 +15,32 @@ export interface InterfaceBinding {
|
||||
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
|
||||
@@ -43,15 +66,6 @@ export interface AppUser {
|
||||
bindings: InterfaceBinding[]
|
||||
}
|
||||
|
||||
export interface CatalogIface {
|
||||
name: string
|
||||
type: InterfaceType
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
boundUserId: string | null
|
||||
boundUserLogin: string | null
|
||||
}
|
||||
|
||||
export interface UserServerOption {
|
||||
id: string
|
||||
name: string
|
||||
@@ -138,17 +152,39 @@ export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
|
||||
{ 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: "u1", boundUserLogin: "a.korotaev@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 },
|
||||
{
|
||||
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,
|
||||
@@ -156,6 +192,7 @@ function bind(
|
||||
interfaceName: string,
|
||||
interfaceType: InterfaceType,
|
||||
comment: string,
|
||||
peer?: { publicKey: string; name: string },
|
||||
): InterfaceBinding {
|
||||
const srv = servers.find((s) => s.id === serverId)
|
||||
return {
|
||||
@@ -167,6 +204,8 @@ function bind(
|
||||
serverCountry: srv?.country ?? "UN",
|
||||
interfaceName,
|
||||
interfaceType,
|
||||
peerPublicKey: peer?.publicKey,
|
||||
peerName: peer?.name,
|
||||
comment,
|
||||
}
|
||||
}
|
||||
@@ -180,7 +219,7 @@ export const INIT_USERS: AppUser[] = [
|
||||
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"),
|
||||
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", "Склад"),
|
||||
],
|
||||
@@ -213,8 +252,34 @@ export const INIT_USERS: AppUser[] = [
|
||||
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),
|
||||
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 }
|
||||
|
||||
BIN
Binary file not shown.
@@ -41,6 +41,10 @@
|
||||
"./users": {
|
||||
"types": "./dist/users.d.ts",
|
||||
"default": "./dist/users.js"
|
||||
},
|
||||
"./traffic-flow": {
|
||||
"types": "./dist/traffic-flow.d.ts",
|
||||
"default": "./dist/traffic-flow.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./certificates.js"
|
||||
export * from "./backups.js"
|
||||
export * from "./wireguard.js"
|
||||
export * from "./users.js"
|
||||
export * from "./traffic-flow.js"
|
||||
|
||||
@@ -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>
|
||||
@@ -23,6 +23,8 @@ export const userBindingSchema = z.object({
|
||||
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(),
|
||||
@@ -71,6 +73,8 @@ 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(),
|
||||
})
|
||||
|
||||
@@ -78,6 +82,16 @@ 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,
|
||||
@@ -85,6 +99,8 @@ export const catalogInterfaceSchema = z.object({
|
||||
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)
|
||||
@@ -99,4 +115,5 @@ 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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -28,6 +28,8 @@ export function toFrontendBinding(b: UserBinding): InterfaceBinding {
|
||||
serverCountry: b.serverCountry,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey || undefined,
|
||||
peerName: b.peerName || undefined,
|
||||
comment: b.comment,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user