Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 | ||
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 |
+752
-20
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,8 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
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"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
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 [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
|
||||||
|
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
@@ -266,6 +270,20 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [backendUrl, dbRestoreFile, systemDbAvailable])
|
}, [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 renderContent = () => {
|
||||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||||
|
|
||||||
@@ -417,7 +435,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="База данных приложения"
|
title="База данных приложения"
|
||||||
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
|
description="Резервная копия SQLite бекенда и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
|
||||||
contentClassName="divide-y px-5"
|
contentClassName="divide-y px-5"
|
||||||
>
|
>
|
||||||
{!systemDbAvailable && (
|
{!systemDbAvailable && (
|
||||||
@@ -434,7 +452,7 @@ export default function SettingsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
onClick={() => { void handleSystemDatabaseBackup() }}
|
onClick={() => { void handleSystemDatabaseBackup() }}
|
||||||
>
|
>
|
||||||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||||
@@ -450,7 +468,7 @@ export default function SettingsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
onClick={() => setDbRestoreDialogOpen(true)}
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
@@ -461,6 +479,21 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</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>
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
@@ -893,6 +926,13 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<NetflowPurgeConfirm
|
||||||
|
open={dbPurgeOpen}
|
||||||
|
busy={dbPurgeBusy}
|
||||||
|
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
||||||
|
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
||||||
|
/>
|
||||||
|
|
||||||
<FileImportDialog
|
<FileImportDialog
|
||||||
open={dbRestoreDialogOpen}
|
open={dbRestoreDialogOpen}
|
||||||
onOpenChange={setDbRestoreDialogOpen}
|
onOpenChange={setDbRestoreDialogOpen}
|
||||||
|
|||||||
+272
-39
@@ -17,13 +17,13 @@ import { cn } from "@/lib/utils"
|
|||||||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||||
|
import { useFlowLive } from "@/hooks/use-flow-live"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { getTrafficFlows } from "@/shared/api/traffic-flow"
|
import { getFlowAnalytics, getFlowClients, getFlowExporters, getFlowMonthly, getTrafficFlows } from "@/shared/api/traffic-flow"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
|
|
||||||
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel"
|
||||||
import type { FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
import type { FlowAnalyticsDto, FlowEntityCard, FlowMonthlyDto, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,70 @@ function addSeries(a: number[], b: number[]): number[] {
|
|||||||
return a.map((v, i) => v + (b[i] ?? 0))
|
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 ───────────────────────────────────────────────────────────────
|
// ─── data model ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BoundIfaceTraffic {
|
interface BoundIfaceTraffic {
|
||||||
@@ -273,9 +337,9 @@ const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
|
|||||||
|
|
||||||
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
||||||
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
|
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м",
|
"5m": "5м",
|
||||||
"15m": "15м",
|
"15m": "15м",
|
||||||
"1h": "1ч",
|
"1h": "1ч",
|
||||||
@@ -284,6 +348,7 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
||||||
|
type FlowScope = "servers" | "users"
|
||||||
type SortField = "rx" | "tx" | "name" | "sessions"
|
type SortField = "rx" | "tx" | "name" | "sessions"
|
||||||
type SortDir = "asc" | "desc"
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
@@ -715,7 +780,7 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
|
|||||||
{ field: "rx", label: "RX" },
|
{ field: "rx", label: "RX" },
|
||||||
{ field: "tx", label: "TX" },
|
{ field: "tx", label: "TX" },
|
||||||
{ field: "name", label: "Имя" },
|
{ field: "name", label: "Имя" },
|
||||||
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users"] },
|
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function TrafficPage() {
|
export default function TrafficPage() {
|
||||||
@@ -740,6 +805,14 @@ export default function TrafficPage() {
|
|||||||
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
||||||
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
||||||
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
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 [overlayOpen, setOverlayOpen] = useState(false)
|
||||||
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||||||
const effectiveMode: GroupMode = groupMode
|
const effectiveMode: GroupMode = groupMode
|
||||||
@@ -749,6 +822,18 @@ export default function TrafficPage() {
|
|||||||
serverId: selectedId,
|
serverId: selectedId,
|
||||||
iface: selectedIface,
|
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 => {
|
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||||
return {
|
return {
|
||||||
@@ -793,7 +878,7 @@ export default function TrafficPage() {
|
|||||||
setLiveBusy(true)
|
setLiveBusy(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
try {
|
try {
|
||||||
const q = encodeURIComponent(targetRange)
|
const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
|
||||||
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
||||||
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
||||||
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
||||||
@@ -837,20 +922,63 @@ export default function TrafficPage() {
|
|||||||
setLiveBusy(true)
|
setLiveBusy(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
try {
|
try {
|
||||||
const stats = await getTrafficFlows(backendUrl, range)
|
const [stats, exporters, clients] = await Promise.all([
|
||||||
|
getTrafficFlows(backendUrl, range),
|
||||||
|
getFlowExporters(backendUrl, range),
|
||||||
|
getFlowClients(backendUrl, range),
|
||||||
|
])
|
||||||
setFlowStats(stats)
|
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) {
|
} catch (e) {
|
||||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
||||||
} finally {
|
} finally {
|
||||||
setLiveBusy(false)
|
setLiveBusy(false)
|
||||||
}
|
}
|
||||||
}, [isLive, backendUrl, range])
|
}, [isLive, backendUrl, range, flowScope])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || effectiveMode !== "flows") return
|
if (!isLive || effectiveMode !== "flows") return
|
||||||
void loadFlows()
|
void loadFlows()
|
||||||
}, [isLive, effectiveMode, 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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||||||
@@ -896,9 +1024,21 @@ export default function TrafficPage() {
|
|||||||
|
|
||||||
const handleModeChange = (next: GroupMode) => {
|
const handleModeChange = (next: GroupMode) => {
|
||||||
setGroupMode(next)
|
setGroupMode(next)
|
||||||
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
if (next === "servers") {
|
||||||
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
||||||
else if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
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")
|
setSortField("rx")
|
||||||
setSortDir("desc")
|
setSortDir("desc")
|
||||||
setSearch("")
|
setSearch("")
|
||||||
@@ -953,6 +1093,22 @@ export default function TrafficPage() {
|
|||||||
})
|
})
|
||||||
}, [sortField, sortDir, q, activeBoundIfaces])
|
}, [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 selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||||||
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
||||||
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
|
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
|
||||||
@@ -969,33 +1125,39 @@ export default function TrafficPage() {
|
|||||||
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
|
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 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 = [
|
const flowKpiItems = [
|
||||||
{
|
{
|
||||||
id: "exporters",
|
id: "exporters",
|
||||||
label: "Экспортёры",
|
label: "Экспортёры",
|
||||||
value: String(flowStats?.exportersOnline ?? 0),
|
value: String(flowExporters.length),
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bytes",
|
id: "bps",
|
||||||
label: "Байт/мин",
|
label: "Скорость",
|
||||||
value: flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—",
|
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" />,
|
icon: <ActivityIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "src",
|
id: "src",
|
||||||
label: "Уник. src",
|
label: "Уник. src",
|
||||||
value: String(flowStats?.uniqueSrc ?? 0),
|
value: String(displayedFlow?.uniqueSrc ?? flowStats?.uniqueSrc ?? 0),
|
||||||
icon: <ArrowUpIcon className="size-4" />,
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "proto",
|
id: "proto",
|
||||||
label: "Топ протокол",
|
label: "Топ протокол",
|
||||||
value: flowStats?.topProto ?? "—",
|
value: displayedFlow?.topProto ?? flowStats?.topProto ?? "—",
|
||||||
icon: <GitBranchIcon className="size-4" />,
|
icon: <GitBranchIcon className="size-4" />,
|
||||||
iconClassName: "text-warning",
|
iconClassName: "text-warning",
|
||||||
},
|
},
|
||||||
@@ -1081,41 +1243,112 @@ export default function TrafficPage() {
|
|||||||
{effectiveMode === "flows" ? (
|
{effectiveMode === "flows" ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground font-mono truncate min-w-0">
|
||||||
IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы.
|
{ingestLine ?? "IPFIX коллектор"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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">
|
<div className="flex gap-1">
|
||||||
{TRAFFIC_RANGE_KEYS.map((key) => (
|
<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
|
<button
|
||||||
key={key}
|
key={field}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRange(key)}
|
onClick={() => toggleSort(field)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||||
range === key
|
sortField === field
|
||||||
? "border-primary bg-primary/10 text-primary font-medium"
|
? "border-primary bg-primary/10 text-primary font-medium"
|
||||||
: "border-border text-muted-foreground hover:text-foreground",
|
: "border-border text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TRAFFIC_RANGE_LABELS[key]}
|
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
|
<div className="flex flex-col gap-2">
|
||||||
<PlusIcon className="size-4" />
|
{sortedFlowCards.map((card) => (
|
||||||
Подключить JH
|
<FlowEntityCardView
|
||||||
</Button>
|
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>
|
</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>
|
</div>
|
||||||
{liveError && (
|
|
||||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
|
||||||
{liveError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DataPageCard>
|
|
||||||
<TrafficFlowsDataGrid rows={flowStats?.talkers ?? []} />
|
|
||||||
</DataPageCard>
|
|
||||||
<FlowOverlaySheet
|
<FlowOverlaySheet
|
||||||
open={overlayOpen}
|
open={overlayOpen}
|
||||||
onOpenChange={setOverlayOpen}
|
onOpenChange={setOverlayOpen}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"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:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts",
|
"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-ip.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 && tsx src/services/sqlite-write-opt.test.ts",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+203
-7
@@ -7,11 +7,90 @@ import { drizzle } from "drizzle-orm/better-sqlite3"
|
|||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
import * as schema from "./schema.js"
|
import * as schema from "./schema.js"
|
||||||
|
|
||||||
const sqlite = new Database(env.DATABASE_PATH)
|
export const SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||||
|
/** ~16 MiB page cache (negative = KiB). */
|
||||||
|
export const SQLITE_CACHE_SIZE_KIB = 16_000
|
||||||
|
export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000
|
||||||
|
|
||||||
|
export interface SqliteWriteStats {
|
||||||
|
insert: number
|
||||||
|
update: number
|
||||||
|
delete: number
|
||||||
|
walCheckpoint: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeTrace: SqliteWriteStats | null = null
|
||||||
|
|
||||||
|
function classifyWriteSql(sql: string): keyof Omit<SqliteWriteStats, "walCheckpoint"> | null {
|
||||||
|
const head = sql.trimStart().slice(0, 12).toUpperCase()
|
||||||
|
if (head.startsWith("INSERT")) return "insert"
|
||||||
|
if (head.startsWith("UPDATE")) return "update"
|
||||||
|
if (head.startsWith("DELETE")) return "delete"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSqliteWriteTrace(handle: SqliteHandle): SqliteHandle {
|
||||||
|
const origPrepare = handle.prepare.bind(handle)
|
||||||
|
handle.prepare = ((sql: string) => {
|
||||||
|
const stmt = origPrepare(sql)
|
||||||
|
const kind = classifyWriteSql(sql)
|
||||||
|
if (!kind) return stmt
|
||||||
|
const origRun = stmt.run.bind(stmt)
|
||||||
|
stmt.run = ((...args: unknown[]) => {
|
||||||
|
if (writeTrace) writeTrace[kind] += 1
|
||||||
|
return origRun(...args)
|
||||||
|
}) as typeof stmt.run
|
||||||
|
return stmt
|
||||||
|
}) as typeof handle.prepare
|
||||||
|
|
||||||
|
const origExec = handle.exec.bind(handle)
|
||||||
|
handle.exec = ((sql: string) => {
|
||||||
|
if (writeTrace) {
|
||||||
|
for (const part of sql.split(";")) {
|
||||||
|
const kind = classifyWriteSql(part)
|
||||||
|
if (kind) writeTrace[kind] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origExec(sql)
|
||||||
|
}) as typeof handle.exec
|
||||||
|
|
||||||
|
const origPragma = handle.pragma.bind(handle)
|
||||||
|
handle.pragma = ((source: string, options?: { simple?: boolean }) => {
|
||||||
|
if (writeTrace && /wal_checkpoint/i.test(source)) writeTrace.walCheckpoint += 1
|
||||||
|
return origPragma(source, options as never)
|
||||||
|
}) as typeof handle.pragma
|
||||||
|
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countSqliteWrites<T>(fn: () => T): { result: T; stats: SqliteWriteStats } {
|
||||||
|
const stats: SqliteWriteStats = { insert: 0, update: 0, delete: 0, walCheckpoint: 0 }
|
||||||
|
writeTrace = stats
|
||||||
|
try {
|
||||||
|
return { result: fn(), stats }
|
||||||
|
} finally {
|
||||||
|
writeTrace = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
handle.pragma(`wal_autocheckpoint = ${SQLITE_WAL_AUTOCHECKPOINT_PAGES}`)
|
||||||
|
handle.pragma("temp_store = MEMORY")
|
||||||
|
handle.pragma(`cache_size = -${SQLITE_CACHE_SIZE_KIB}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSqlite(): SqliteHandle {
|
||||||
|
const handle = new Database(env.DATABASE_PATH)
|
||||||
|
applySqlitePragmas(handle)
|
||||||
|
return installSqliteWriteTrace(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sqlite = openSqlite()
|
||||||
|
|
||||||
// WAL mode for better concurrent read performance
|
|
||||||
sqlite.pragma("journal_mode = WAL")
|
|
||||||
sqlite.pragma("foreign_keys = ON")
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -134,6 +213,7 @@ CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
|||||||
hub_server_id INTEGER,
|
hub_server_id INTEGER,
|
||||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
top_n INTEGER NOT NULL DEFAULT 200,
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
map_service_min_share_pct REAL NOT NULL DEFAULT 5,
|
||||||
last_datagram_at TEXT,
|
last_datagram_at TEXT,
|
||||||
last_exporter_ip TEXT,
|
last_exporter_ip TEXT,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
@@ -155,13 +235,66 @@ CREATE TABLE IF NOT EXISTS flow_buckets (
|
|||||||
bytes INTEGER NOT NULL DEFAULT 0,
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
packets INTEGER NOT NULL DEFAULT 0,
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
in_iface TEXT NOT NULL DEFAULT '',
|
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
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||||
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port);
|
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
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
|
||||||
ON flow_buckets(server_id, bucket_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -769,6 +902,13 @@ 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);
|
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
{
|
||||||
|
const flowSettingsCols = sqlite.prepare(`PRAGMA table_info('traffic_flow_settings')`).all() as Array<{ name?: string }>
|
||||||
|
if (!flowSettingsCols.some((c) => c.name === "map_service_min_share_pct")) {
|
||||||
|
sqlite.exec(`ALTER TABLE traffic_flow_settings ADD COLUMN map_service_min_share_pct REAL NOT NULL DEFAULT 5`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||||
SELECT 1, 1, 15, 14
|
SELECT 1, 1, 15, 14
|
||||||
@@ -805,6 +945,38 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
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 }>
|
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||||
@@ -863,7 +1035,31 @@ if (backupEntryCount.c === 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema })
|
export let db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
/** Прямой доступ к 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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
|||||||
hubServerId: integer("hub_server_id"),
|
hubServerId: integer("hub_server_id"),
|
||||||
retentionHours: integer("retention_hours").notNull().default(24),
|
retentionHours: integer("retention_hours").notNull().default(24),
|
||||||
topN: integer("top_n").notNull().default(200),
|
topN: integer("top_n").notNull().default(200),
|
||||||
|
mapServiceMinSharePct: real("map_service_min_share_pct").notNull().default(5),
|
||||||
lastDatagramAt: text("last_datagram_at"),
|
lastDatagramAt: text("last_datagram_at"),
|
||||||
lastExporterIp: text("last_exporter_ip"),
|
lastExporterIp: text("last_exporter_ip"),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
@@ -182,6 +183,40 @@ export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
|||||||
updatedAt: text("updated_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", {
|
export const flowBuckets = sqliteTable("flow_buckets", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
serverId: integer("server_id")
|
serverId: integer("server_id")
|
||||||
@@ -196,12 +231,33 @@ export const flowBuckets = sqliteTable("flow_buckets", {
|
|||||||
bytes: integer("bytes").notNull().default(0),
|
bytes: integer("bytes").notNull().default(0),
|
||||||
packets: integer("packets").notNull().default(0),
|
packets: integer("packets").notNull().default(0),
|
||||||
inIface: text("in_iface").notNull().default(""),
|
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) => [
|
}, (t) => [
|
||||||
uniqueIndex("idx_flow_buckets_unique").on(
|
uniqueIndex("idx_flow_buckets_unique").on(
|
||||||
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort,
|
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", {
|
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
serverId: integer("server_id")
|
serverId: integer("server_id")
|
||||||
@@ -641,6 +697,8 @@ export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
|||||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||||
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||||
export type FlowBucketRow = typeof flowBuckets.$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 ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||||
|
|||||||
+44
-3
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance } from "fastify"
|
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||||
|
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||||
import { env } from "./config.js"
|
import { env } from "./config.js"
|
||||||
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||||
import serversRoutes from "./routes/servers.js"
|
import serversRoutes from "./routes/servers.js"
|
||||||
@@ -28,7 +29,10 @@ import wireguardRoutes from "./routes/wireguard.js"
|
|||||||
import firewallRoutes from "./routes/firewall.js"
|
import firewallRoutes from "./routes/firewall.js"
|
||||||
import usersRoutes from "./routes/users.js"
|
import usersRoutes from "./routes/users.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||||
|
|
||||||
|
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||||
|
eventLoopDelay.enable()
|
||||||
|
|
||||||
export async function buildApp(opts?: {
|
export async function buildApp(opts?: {
|
||||||
logger?: boolean
|
logger?: boolean
|
||||||
@@ -37,7 +41,7 @@ export async function buildApp(opts?: {
|
|||||||
const usePrettyLogger =
|
const usePrettyLogger =
|
||||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
bodyLimit: 512 * 1024 * 1024,
|
bodyLimit: 2 * 1024 * 1024,
|
||||||
requestTimeout: 10 * 60 * 1000,
|
requestTimeout: 10 * 60 * 1000,
|
||||||
logger:
|
logger:
|
||||||
opts?.logger === false
|
opts?.logger === false
|
||||||
@@ -59,6 +63,18 @@ export async function buildApp(opts?: {
|
|||||||
app.setValidatorCompiler(validatorCompiler)
|
app.setValidatorCompiler(validatorCompiler)
|
||||||
app.setSerializerCompiler(serializerCompiler)
|
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, {
|
await app.register(cors, {
|
||||||
origin: env.CORS_ORIGIN,
|
origin: env.CORS_ORIGIN,
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
@@ -70,6 +86,8 @@ export async function buildApp(opts?: {
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: process.env.APP_VERSION ?? "dev",
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
|
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
|
||||||
|
flowWorker: getFlowWorkerHealth(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.get("/api/auth/config", async () => ({
|
app.get("/api/auth/config", async () => ({
|
||||||
@@ -132,6 +150,29 @@ const isMain =
|
|||||||
if (isMain) {
|
if (isMain) {
|
||||||
try {
|
try {
|
||||||
const app = await buildApp()
|
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" })
|
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||||
console.log(
|
console.log(
|
||||||
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ assert.equal(
|
|||||||
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
||||||
"mm:traffic:read",
|
"mm:traffic:read",
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||||
|
"mm:traffic:write",
|
||||||
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
permissionForRequest("GET", "/api/unknown-thing"),
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
"mm:dashboard:read",
|
"mm:dashboard:read",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { desc, eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import { db } from "../../../db/index.js"
|
import { db } from "../../../db/index.js"
|
||||||
import { serverSnapshots, servers } from "../../../db/schema.js"
|
import { serverSnapshots, servers } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
export type ServerRow = typeof servers.$inferSelect
|
export type ServerRow = typeof servers.$inferSelect
|
||||||
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
@@ -17,6 +18,7 @@ export function createServerRow(
|
|||||||
values: Omit<typeof servers.$inferInsert, "id">,
|
values: Omit<typeof servers.$inferInsert, "id">,
|
||||||
): ServerRow {
|
): ServerRow {
|
||||||
const [inserted] = db.insert(servers).values(values).returning().all()
|
const [inserted] = db.insert(servers).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,13 @@ export function updateServerRowById(
|
|||||||
values: Partial<ServerRow>,
|
values: Partial<ServerRow>,
|
||||||
): ServerRow {
|
): ServerRow {
|
||||||
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteServerRowById(id: number): void {
|
export function deleteServerRowById(id: number): void {
|
||||||
db.delete(servers).where(eq(servers.id, id)).run()
|
db.delete(servers).where(eq(servers.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm"
|
import { and, count, eq } from "drizzle-orm"
|
||||||
import { db } from "../../../db/index.js"
|
import { db } from "../../../db/index.js"
|
||||||
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
export type AppUserRow = typeof appUsers.$inferSelect
|
export type AppUserRow = typeof appUsers.$inferSelect
|
||||||
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||||
@@ -19,6 +20,7 @@ export function getUserRowByLogin(login: string): AppUserRow | undefined {
|
|||||||
|
|
||||||
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
||||||
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +29,13 @@ export function updateUserRowById(
|
|||||||
values: Partial<AppUserRow>,
|
values: Partial<AppUserRow>,
|
||||||
): AppUserRow {
|
): AppUserRow {
|
||||||
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUserRowById(id: string): void {
|
export function deleteUserRowById(id: string): void {
|
||||||
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBindingRows(): BindingRow[] {
|
export function listBindingRows(): BindingRow[] {
|
||||||
@@ -65,13 +69,15 @@ export function getBindingByServerIfacePeer(
|
|||||||
|
|
||||||
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
||||||
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBindingRowById(id: string): void {
|
export function deleteBindingRowById(id: string): void {
|
||||||
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countUserRows(): number {
|
export function countUserRows(): number {
|
||||||
return db.select().from(appUsers).all().length
|
return db.select({ n: count() }).from(appUsers).all()[0]?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { count } from "drizzle-orm"
|
||||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
@@ -11,27 +12,22 @@ import {
|
|||||||
} from "../db/schema.js"
|
} from "../db/schema.js"
|
||||||
import { listUsers } from "../modules/users/service/users-service.js"
|
import { listUsers } from "../modules/users/service/users-service.js"
|
||||||
|
|
||||||
|
function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): number {
|
||||||
|
return db.select({ n: count() }).from(table).all()[0]?.n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/sidebar-counts", async (_req, reply) => {
|
app.get("/sidebar-counts", async (_req, reply) => {
|
||||||
const [
|
const serversTotal = tableCount(servers)
|
||||||
serversTotal,
|
const filterRulesTotal = tableCount(filterRules)
|
||||||
filterRulesTotal,
|
const uptimeProbesTotal = tableCount(uptimeProbes)
|
||||||
uptimeProbesTotal,
|
const uptimeSpeedProbesTotal = tableCount(uptimeSpeedProbes)
|
||||||
uptimeSpeedProbesTotal,
|
const recursiveRoutesTotal = tableCount(recursiveRoutes)
|
||||||
recursiveRoutesTotal,
|
const [certificatesTotal, wireguardTotal] = await Promise.all([
|
||||||
certificatesTotal,
|
|
||||||
wireguardTotal,
|
|
||||||
usersTotal,
|
|
||||||
] = await Promise.all([
|
|
||||||
Promise.resolve(db.select().from(servers).all().length),
|
|
||||||
Promise.resolve(db.select().from(filterRules).all().length),
|
|
||||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
|
||||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
|
||||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
|
||||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||||
countWireGuardInterfaces().catch(() => 0),
|
countWireGuardInterfaces().catch(() => 0),
|
||||||
Promise.resolve(listUsers().length),
|
|
||||||
])
|
])
|
||||||
|
const usersTotal = listUsers().length
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
servers: serversTotal,
|
servers: serversTotal,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import { env } from "../config.js"
|
||||||
import {
|
import {
|
||||||
trafficFlowOverlayRequestSchema,
|
trafficFlowOverlayRequestSchema,
|
||||||
trafficFlowSettingsPatchSchema,
|
trafficFlowSettingsPatchSchema,
|
||||||
@@ -12,11 +13,39 @@ import {
|
|||||||
} from "../services/traffic-flow-settings.js"
|
} from "../services/traffic-flow-settings.js"
|
||||||
import {
|
import {
|
||||||
getFlowListenerState,
|
getFlowListenerState,
|
||||||
listFlowTalkers,
|
purgeTrafficFlowStore,
|
||||||
startTrafficFlowListener,
|
startTrafficFlowListener,
|
||||||
|
listFlowTalkers,
|
||||||
} from "../services/traffic-flow-ingest.js"
|
} 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 { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.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 {
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
switch ((range ?? "5m").toLowerCase()) {
|
switch ((range ?? "5m").toLowerCase()) {
|
||||||
@@ -25,10 +54,44 @@ function rangeToMinutes(range: string | undefined): number {
|
|||||||
case "1h": return 60
|
case "1h": return 60
|
||||||
case "4h": return 240
|
case "4h": return 240
|
||||||
case "24h": return 1440
|
case "24h": return 1440
|
||||||
|
case "30d": return 1440
|
||||||
default: return 5
|
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) {
|
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||||
const q = req.query as { range?: string }
|
const q = req.query as { range?: string }
|
||||||
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||||
@@ -58,6 +121,28 @@ async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/traffic/flow/settings", async (_req, reply) => {
|
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||||
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||||
@@ -89,11 +174,123 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send({ files: listTrafficFlowHostFiles() })
|
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.post("/traffic/flow-overlay", applyOverlayHandler)
|
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||||
|
|
||||||
app.get("/traffic/flow", sendFlowTalkers)
|
app.get("/traffic/flow", sendFlowTalkers)
|
||||||
app.get("/traffic/flows", 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
|
export default trafficFlowRoutes
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.all()[0]
|
.all()[0]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.payloadJson === payloadJson) return
|
||||||
db.update(alertEnginePrevLive)
|
db.update(alertEnginePrevLive)
|
||||||
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
||||||
.where(eq(alertEnginePrevLive.kind, kind))
|
.where(eq(alertEnginePrevLive.kind, kind))
|
||||||
.run()
|
.run()
|
||||||
} else {
|
return
|
||||||
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
|
||||||
}
|
}
|
||||||
|
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { db, sqliteDatabase } from "../db/index.js"
|
|
||||||
import { alertBgpPeerSamples, alertGreTunnelSamples } from "../db/schema.js"
|
|
||||||
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
|
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
|
||||||
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
|
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
|
||||||
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||||
@@ -12,8 +10,8 @@ export function getGreBgpSnapshotCollectorState(): { running: boolean } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Один опрос GRE + BGP по включённым серверам и запись строк в SQLite для `buildSignalSnapshot`.
|
* Один опрос GRE + BGP по включённым серверам.
|
||||||
* Движок оповещений больше не дублирует эти REST-запросы.
|
* Снимок для алертов живёт в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`).
|
||||||
*/
|
*/
|
||||||
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
|
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
|
||||||
const sampledAt = new Date().toISOString()
|
const sampledAt = new Date().toISOString()
|
||||||
@@ -62,32 +60,8 @@ export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnap
|
|||||||
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
|
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
|
||||||
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
|
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
|
||||||
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
|
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
|
||||||
|
snapshot.greWritten = greRows.length
|
||||||
db.transaction((tx) => {
|
snapshot.bgpWritten = bgpRows.length
|
||||||
for (const r of greRows) {
|
|
||||||
tx.insert(alertGreTunnelSamples).values({
|
|
||||||
sampledAt,
|
|
||||||
targetLabel: r.targetLabel,
|
|
||||||
status: r.status,
|
|
||||||
}).run()
|
|
||||||
snapshot.greWritten += 1
|
|
||||||
}
|
|
||||||
for (const s of bgpRows) {
|
|
||||||
tx.insert(alertBgpPeerSamples).values({
|
|
||||||
sampledAt,
|
|
||||||
peerKey: bgpPeerAlertKey(s),
|
|
||||||
state: s.state,
|
|
||||||
}).run()
|
|
||||||
snapshot.bgpWritten += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sqliteDatabase
|
|
||||||
.prepare(`DELETE FROM alert_gre_tunnel_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
|
||||||
.run()
|
|
||||||
sqliteDatabase
|
|
||||||
.prepare(`DELETE FROM alert_bgp_peer_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
|
||||||
.run()
|
|
||||||
|
|
||||||
if (errors.length) snapshot.errors = errors
|
if (errors.length) snapshot.errors = errors
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import type {
|
|||||||
FirewallFamily, FirewallTable,
|
FirewallFamily, FirewallTable,
|
||||||
} from "../types/server.js"
|
} 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 ─────────────────────────────────────────────────────────
|
// ── connection params ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MikrotikConnectParams {
|
export interface MikrotikConnectParams {
|
||||||
@@ -52,7 +62,7 @@ function rosRequest(
|
|||||||
const req = lib.request(options, (res) => {
|
const req = lib.request(options, (res) => {
|
||||||
let body = ""
|
let body = ""
|
||||||
res.setEncoding("utf8")
|
res.setEncoding("utf8")
|
||||||
res.on("data", (chunk: string) => { body += chunk })
|
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
@@ -135,7 +145,7 @@ function rosPost(
|
|||||||
req = lib.request(options, (res) => {
|
req = lib.request(options, (res) => {
|
||||||
let buf = ""
|
let buf = ""
|
||||||
res.setEncoding("utf8")
|
res.setEncoding("utf8")
|
||||||
res.on("data", (chunk: string) => { buf += chunk })
|
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
settle(() => {
|
settle(() => {
|
||||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
@@ -189,7 +199,7 @@ function rosPut(
|
|||||||
const req = lib.request(options, (res) => {
|
const req = lib.request(options, (res) => {
|
||||||
let buf = ""
|
let buf = ""
|
||||||
res.setEncoding("utf8")
|
res.setEncoding("utf8")
|
||||||
res.on("data", (chunk: string) => { buf += chunk })
|
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
@@ -239,7 +249,7 @@ function rosDelete(
|
|||||||
const req = lib.request(options, (res) => {
|
const req = lib.request(options, (res) => {
|
||||||
let body = ""
|
let body = ""
|
||||||
res.setEncoding("utf8")
|
res.setEncoding("utf8")
|
||||||
res.on("data", (chunk: string) => { body += chunk })
|
res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
@@ -289,7 +299,7 @@ function rosPatch(
|
|||||||
const req = lib.request(options, (res) => {
|
const req = lib.request(options, (res) => {
|
||||||
let buf = ""
|
let buf = ""
|
||||||
res.setEncoding("utf8")
|
res.setEncoding("utf8")
|
||||||
res.on("data", (chunk: string) => { buf += chunk })
|
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { SnapshotInsert } from "../db/schema.js"
|
|||||||
import type { SnapshotRead } from "../types/server.js"
|
import type { SnapshotRead } from "../types/server.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,11 +65,13 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
|||||||
rawIpAddresses: JSON.stringify(addresses),
|
rawIpAddresses: JSON.stringify(addresses),
|
||||||
} satisfies Partial<SnapshotInsert>)
|
} satisfies Partial<SnapshotInsert>)
|
||||||
|
|
||||||
// Keep server.name in sync with RouterOS identity
|
if ((identity.name || "") !== (server.name || "")) {
|
||||||
db.update(servers)
|
db.update(servers)
|
||||||
.set({ name: identity.name, updatedAt: now })
|
.set({ name: identity.name, updatedAt: now })
|
||||||
.where(eq(servers.id, serverId))
|
.where(eq(servers.id, serverId))
|
||||||
.run()
|
.run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log but don't throw — we still persist the offline snapshot
|
// Log but don't throw — we still persist the offline snapshot
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
|
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
|
||||||
*
|
*
|
||||||
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
|
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
|
||||||
* `gre_bgp` → `alert_gre_tunnel_samples` / `alert_bgp_peer_samples`. После успешного завершения джоб
|
* `gre_bgp` → snapshot в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`). После успешного завершения джоб
|
||||||
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
|
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
|
||||||
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
|
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
|
||||||
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
|
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { desc, eq, lt } from "drizzle-orm"
|
import { desc, eq, lt } from "drizzle-orm"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { schedulerRuns } from "../db/schema.js"
|
import { events, schedulerRuns } from "../db/schema.js"
|
||||||
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,20 @@ const timers = new Map<string, ReturnType<typeof setInterval>>()
|
|||||||
|
|
||||||
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
|
||||||
|
"traffic",
|
||||||
|
"servers_rest_ping",
|
||||||
|
"uptime_resources",
|
||||||
|
"uptime_ping",
|
||||||
|
"uptime_speed",
|
||||||
|
"gre_bgp",
|
||||||
|
"alert_engine",
|
||||||
|
])
|
||||||
|
|
||||||
|
export function shouldAppendSchedulerOkEvent(jobKey: SchedulerJobKey): boolean {
|
||||||
|
return !QUIET_SCHEDULER_OK_JOBS.has(jobKey)
|
||||||
|
}
|
||||||
|
|
||||||
function newRunId(): string {
|
function newRunId(): string {
|
||||||
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
}
|
}
|
||||||
@@ -84,18 +98,21 @@ function appendSchedulerRun(row: {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
result?: SchedulerRunSnapshot | null
|
result?: SchedulerRunSnapshot | null
|
||||||
}) {
|
}) {
|
||||||
db.insert(schedulerRuns).values({
|
|
||||||
id: newRunId(),
|
|
||||||
jobKey: row.jobKey,
|
|
||||||
startedAt: row.startedAt,
|
|
||||||
finishedAt: row.finishedAt,
|
|
||||||
status: row.status,
|
|
||||||
error: row.error,
|
|
||||||
durationMs: row.durationMs,
|
|
||||||
resultJson: row.result ? JSON.stringify(row.result) : null,
|
|
||||||
}).run()
|
|
||||||
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
|
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
|
||||||
db.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schedulerRuns).values({
|
||||||
|
id: newRunId(),
|
||||||
|
jobKey: row.jobKey,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
finishedAt: row.finishedAt,
|
||||||
|
status: row.status,
|
||||||
|
error: row.error,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
resultJson: row.result ? JSON.stringify(row.result) : null,
|
||||||
|
}).run()
|
||||||
|
tx.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
||||||
|
tx.delete(events).where(lt(events.createdAt, cutoff)).run()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||||
@@ -159,19 +176,21 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result: snapshot ?? null,
|
result: snapshot ?? null,
|
||||||
})
|
})
|
||||||
appendEvent({
|
if (shouldAppendSchedulerOkEvent(jobKey)) {
|
||||||
level: "info",
|
appendEvent({
|
||||||
eventType: "scheduler.job.ok",
|
level: "info",
|
||||||
sourceModule: "scheduler",
|
eventType: "scheduler.job.ok",
|
||||||
title: "Задача планировщика завершена",
|
sourceModule: "scheduler",
|
||||||
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
title: "Задача планировщика завершена",
|
||||||
entityType: "job",
|
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
||||||
entityId: jobKey,
|
entityType: "job",
|
||||||
payload: {
|
entityId: jobKey,
|
||||||
startedAt: startedIso,
|
payload: {
|
||||||
finishedAt: finishedIso,
|
startedAt: startedIso,
|
||||||
},
|
finishedAt: finishedIso,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
jobKey === "traffic" ||
|
jobKey === "traffic" ||
|
||||||
jobKey === "servers_rest_ping" ||
|
jobKey === "servers_rest_ping" ||
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { countSqliteWrites, sqliteDatabase } from "../db/index.js"
|
||||||
|
import { events } from "../db/schema.js"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import {
|
||||||
|
attachEngineSqlite,
|
||||||
|
bumpPacketMeta,
|
||||||
|
configureEngine,
|
||||||
|
flushPending,
|
||||||
|
ingestParsedFlowsForServerForTests,
|
||||||
|
resetEngineForTests,
|
||||||
|
setEngineError,
|
||||||
|
} from "./traffic-flow-engine.js"
|
||||||
|
import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js"
|
||||||
|
import { shouldAppendSchedulerOkEvent, executeSchedulerJob } from "./scheduler.js"
|
||||||
|
import { savePrevLiveMap, loadPrevLiveMap } from "./alert-engine/prev-live-store.js"
|
||||||
|
import {
|
||||||
|
invalidateFlowCatalogCache,
|
||||||
|
loadFlowTopology,
|
||||||
|
seedFlowTopologyForTests,
|
||||||
|
} from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
attachEngineSqlite(sqliteDatabase)
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
|
||||||
|
const idle1 = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(idle1.stats.walCheckpoint, 0)
|
||||||
|
assert.ok(idle1.stats.update >= 1, "first idle flush persists listener stats")
|
||||||
|
|
||||||
|
const idle2 = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(idle2.stats.update, 0, "unchanged listener stats skip UPDATE")
|
||||||
|
assert.equal(idle2.stats.walCheckpoint, 0)
|
||||||
|
|
||||||
|
bumpPacketMeta("203.0.113.9")
|
||||||
|
const changed = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(changed.stats.update, 1, "changed packets persist once")
|
||||||
|
assert.equal(changed.stats.walCheckpoint, 0)
|
||||||
|
|
||||||
|
setEngineError("boom")
|
||||||
|
const errWrite = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(errWrite.stats.update, 1)
|
||||||
|
setEngineError("")
|
||||||
|
flushPending()
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
configureEngine({ topN: 20 })
|
||||||
|
ingestParsedFlowsForServerForTests(9, [{
|
||||||
|
src: "10.1.1.1",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 40000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 100,
|
||||||
|
packets: 1,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "",
|
||||||
|
}])
|
||||||
|
const withData = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(withData.stats.walCheckpoint, 0, "flush with data must not TRUNCATE WAL")
|
||||||
|
assert.ok(withData.stats.insert >= 1, "flow upsert writes")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
const plan = sqliteDatabase.prepare(`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT id FROM flow_buckets WHERE bucket_at >= ? ORDER BY bytes DESC LIMIT 100
|
||||||
|
`).all("2000-01-01T00:00:00.000Z") as Array<{ detail?: string }>
|
||||||
|
const planText = plan.map((p) => String(p.detail ?? "")).join(" | ")
|
||||||
|
assert.ok(planText.length > 0, "EXPLAIN QUERY PLAN returned rows")
|
||||||
|
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
const topoA = loadFlowTopology()
|
||||||
|
const topoB = loadFlowTopology()
|
||||||
|
assert.equal(topoA, topoB, "topology cache returns same object")
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
const topoC = loadFlowTopology()
|
||||||
|
assert.notEqual(topoA, topoC, "invalidate rebuilds topology")
|
||||||
|
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("traffic"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("alert_engine"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("gre_bgp"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("backups"), true)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("certificates_renew"), true)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("internet_path"), true)
|
||||||
|
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
const prevSame = countSqliteWrites(() => {
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
})
|
||||||
|
assert.equal(prevSame.stats.update, 0)
|
||||||
|
assert.equal(prevSame.stats.insert, 0)
|
||||||
|
savePrevLiveMap("gre", { a: "down" })
|
||||||
|
assert.equal(loadPrevLiveMap("gre").a, "down")
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
|
||||||
|
const greBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||||
|
const bgpBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||||
|
await collectGreBgpSnapshotOnce()
|
||||||
|
const greAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||||
|
const bgpAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||||
|
assert.equal(greAfter.n, greBefore.n)
|
||||||
|
assert.equal(bgpAfter.n, bgpBefore.n)
|
||||||
|
|
||||||
|
const oldId = `evt-old-${randomUUID()}`
|
||||||
|
db.insert(events).values({
|
||||||
|
id: oldId,
|
||||||
|
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
eventType: "test.retention",
|
||||||
|
sourceModule: "system",
|
||||||
|
title: "old",
|
||||||
|
message: "old",
|
||||||
|
}).run()
|
||||||
|
const eventsBefore = sqliteDatabase.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||||
|
).get() as { n: number }
|
||||||
|
await executeSchedulerJob("alert_engine")
|
||||||
|
const oldGone = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM events WHERE id = ?`).get(oldId) as { n: number }
|
||||||
|
assert.equal(oldGone.n, 0, "events older than 30 days are purged")
|
||||||
|
const eventsAfter = sqliteDatabase.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||||
|
).get() as { n: number }
|
||||||
|
assert.equal(eventsAfter.n, eventsBefore.n, "quiet jobs do not append scheduler.job.ok")
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
console.log("sqlite-write-opt.test.ts: ok")
|
||||||
|
console.log("EXPLAIN listStoredFlowRows:", planText)
|
||||||
@@ -4,16 +4,19 @@ import os from "node:os"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
import { env } from "../config.js"
|
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 { refreshScheduler, stopScheduler } from "./scheduler.js"
|
||||||
|
import {
|
||||||
|
reattachFlowSqlite,
|
||||||
|
startTrafficFlowListener,
|
||||||
|
stopTrafficFlowListener,
|
||||||
|
} from "./traffic-flow-ingest.js"
|
||||||
|
|
||||||
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
|
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
|
||||||
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
type SqliteHandle = InstanceType<typeof Database>
|
type SqliteHandle = InstanceType<typeof Database>
|
||||||
|
|
||||||
let operationInFlight = false
|
|
||||||
|
|
||||||
function fmtTimestamp(date = new Date()): string {
|
function fmtTimestamp(date = new Date()): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0")
|
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())}`
|
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> {
|
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||||
if (operationInFlight) {
|
beginSqliteExclusiveOp()
|
||||||
throw new Error("Операция с базой данных уже выполняется")
|
stopTrafficFlowListener()
|
||||||
}
|
|
||||||
operationInFlight = true
|
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn()
|
||||||
} finally {
|
} finally {
|
||||||
|
startTrafficFlowListener()
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
operationInFlight = false
|
endSqliteExclusiveOp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void>
|
|||||||
await writeFile(tempPath, buffer)
|
await writeFile(tempPath, buffer)
|
||||||
source = new Database(tempPath, { readonly: true, fileMustExist: true })
|
source = new Database(tempPath, { readonly: true, fileMustExist: true })
|
||||||
await source.backup(resolveDatabasePath())
|
await source.backup(resolveDatabasePath())
|
||||||
|
reopenSqlite()
|
||||||
|
reattachFlowSqlite()
|
||||||
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
||||||
} finally {
|
} finally {
|
||||||
source?.close()
|
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 { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
||||||
|
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
|
||||||
|
|
||||||
interface RosIfaceTraffic {
|
interface RosIfaceTraffic {
|
||||||
|
".id"?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
ifindex?: string
|
||||||
running?: string
|
running?: string
|
||||||
disabled?: string
|
disabled?: string
|
||||||
"rx-byte"?: string
|
"rx-byte"?: string
|
||||||
@@ -139,6 +142,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(srv)
|
const client = MikrotikClient.fromServer(srv)
|
||||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||||
|
rememberServerIfaces(srv.id, ifaces)
|
||||||
const prevWave = readPreviousWave(srv.id)
|
const prevWave = readPreviousWave(srv.id)
|
||||||
const nowMs = Date.parse(now)
|
const nowMs = Date.parse(now)
|
||||||
let sumRxMbps = 0
|
let sumRxMbps = 0
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
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()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "173.194.151.65",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 57182,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "104.18.35.51",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 53880,
|
||||||
|
bytes: 3_000,
|
||||||
|
packets: 4,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "2",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
const rev = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||||
|
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
|
||||||
|
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
|
||||||
|
assert.equal(google?.service, "Google")
|
||||||
|
assert.equal(google?.category, "Веб")
|
||||||
|
assert.equal(cf?.service, "Cloudflare")
|
||||||
|
assert.equal(cf?.category, "CDN")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
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,696 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db, sqliteDatabase } from "../db/index.js"
|
||||||
|
import { appUsers, 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 { flowDataEpoch, 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 { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
|
import {
|
||||||
|
enGreIfaceNames,
|
||||||
|
getServerCatalog,
|
||||||
|
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 key = analyticsQueryKey(q)
|
||||||
|
const now = Date.now()
|
||||||
|
if (analyticsCache && analyticsCache.key === key && now - analyticsCache.at < ANALYTICS_CACHE_TTL_MS) {
|
||||||
|
return analyticsCache.dto
|
||||||
|
}
|
||||||
|
const dto = buildFlowAnalyticsUncached(q)
|
||||||
|
analyticsCache = { key, at: now, dto }
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowAnalyticsCacheForTests(): void {
|
||||||
|
analyticsCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyticsQueryKey(q: FlowAnalyticsQuery): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
epoch: flowDataEpoch(),
|
||||||
|
minutes: q.minutes,
|
||||||
|
serverId: q.serverId ?? null,
|
||||||
|
userId: q.userId ?? null,
|
||||||
|
iface: q.iface ?? null,
|
||||||
|
dedup: q.dedup !== false,
|
||||||
|
excludeMesh: q.excludeMesh !== false,
|
||||||
|
excludeOverlay: q.excludeOverlay !== false,
|
||||||
|
skipHeavy: Boolean(q.skipHeavy),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANALYTICS_CACHE_TTL_MS = 2000
|
||||||
|
let analyticsCache: { key: string; at: number; dto: FlowAnalyticsDto } | null = null
|
||||||
|
|
||||||
|
function buildFlowAnalyticsUncached(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 catalog = getServerCatalog()
|
||||||
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
|
const countryById = new Map([...catalog.byId].map(([id, s]) => [id, s.country]))
|
||||||
|
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 peers = 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 peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
||||||
|
peers.add(peer)
|
||||||
|
const app = applicationName(r.proto, r.dstPort, r.srcPort)
|
||||||
|
const ripe = lookupRipeCached(peer)
|
||||||
|
const classified = classifyFlowDst(peer, 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(peers)
|
||||||
|
|
||||||
|
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 catalog = getServerCatalog()
|
||||||
|
const emptySeries = Array(60).fill(0) as number[]
|
||||||
|
const exporters = catalog.list
|
||||||
|
.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,
|
||||||
|
subtitle: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
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,42 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
brandByAsn,
|
||||||
|
countryFromHolder,
|
||||||
|
lookupBrand,
|
||||||
|
OTHER_SERVICE,
|
||||||
|
isNamedInternetService,
|
||||||
|
mapServiceNodeId,
|
||||||
|
resolveRipeCountry,
|
||||||
|
} from "./traffic-flow-brands.js"
|
||||||
|
|
||||||
|
assert.equal(resolveRipeCountry("?", 13335, "CLOUDFLARENET, US"), "US")
|
||||||
|
assert.equal(resolveRipeCountry("EU", 13335, ""), "US")
|
||||||
|
assert.equal(resolveRipeCountry("?", 0, "CLOUDFLARENET, US"), "US")
|
||||||
|
assert.equal(countryFromHolder("CLOUDFLARENET, US"), "US")
|
||||||
|
assert.equal(resolveRipeCountry("NL", 0, ""), "NL")
|
||||||
|
assert.equal(resolveRipeCountry("?", 0, ""), "")
|
||||||
|
|
||||||
|
assert.equal(brandByAsn(13335)?.service, "Cloudflare")
|
||||||
|
assert.equal(brandByAsn(13335)?.category, "CDN")
|
||||||
|
assert.equal(brandByAsn(15169)?.service, "Google")
|
||||||
|
assert.equal(brandByAsn(15169)?.category, "Веб")
|
||||||
|
assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube")
|
||||||
|
assert.equal(brandByAsn(32590)?.service, "Steam")
|
||||||
|
assert.equal(brandByAsn(32590)?.category, "Игры")
|
||||||
|
assert.equal(brandByAsn(16509)?.service, "AWS")
|
||||||
|
assert.equal(brandByAsn(57976)?.service, "Blizzard")
|
||||||
|
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
||||||
|
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
|
||||||
|
assert.equal(lookupBrand("104.18.35.51", 0)?.service, "Cloudflare")
|
||||||
|
assert.equal(lookupBrand("173.194.151.65", 0)?.service, "Google")
|
||||||
|
assert.equal(lookupBrand("8.8.8.8", 0)?.service, "Google")
|
||||||
|
assert.equal(lookupBrand("203.0.113.9", 64500), null)
|
||||||
|
assert.equal(OTHER_SERVICE, "Прочее")
|
||||||
|
assert.equal(isNamedInternetService("Google", "Веб"), true)
|
||||||
|
assert.equal(isNamedInternetService("Прочее", "Прочее"), false)
|
||||||
|
assert.equal(isNamedInternetService("GRE", "Туннель"), false)
|
||||||
|
assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
||||||
|
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||||
|
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
||||||
|
|
||||||
|
console.log("traffic-flow-brands.test.ts: ok")
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||||
|
|
||||||
|
export const OTHER_SERVICE = "Прочее"
|
||||||
|
|
||||||
|
export interface BrandHit {
|
||||||
|
service: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASN_BRANDS = new Map<number, BrandHit>([
|
||||||
|
[13335, { service: "Cloudflare", category: "CDN" }],
|
||||||
|
[209242, { service: "Cloudflare", category: "CDN" }],
|
||||||
|
[54113, { service: "Fastly", category: "CDN" }],
|
||||||
|
[20940, { service: "Akamai", category: "CDN" }],
|
||||||
|
[16509, { service: "AWS", category: "CDN" }],
|
||||||
|
[14618, { service: "AWS", category: "CDN" }],
|
||||||
|
[8075, { service: "Microsoft", category: "CDN" }],
|
||||||
|
[13238, { service: "Yandex", category: "CDN" }],
|
||||||
|
[32590, { service: "Steam", category: "Игры" }],
|
||||||
|
[57976, { service: "Blizzard", category: "Игры" }],
|
||||||
|
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
||||||
|
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
||||||
|
[15169, { service: "Google", category: "Веб" }],
|
||||||
|
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
||||||
|
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
||||||
|
[401115, { service: "ChatGPT", category: "ИИ" }],
|
||||||
|
[49544, { service: "Discord", category: "Голос" }],
|
||||||
|
[62041, { service: "Telegram", category: "Голос" }],
|
||||||
|
[59930, { service: "Telegram", category: "Голос" }],
|
||||||
|
[211157, { service: "Telegram", category: "Голос" }],
|
||||||
|
[32934, { service: "Meta", category: "CDN" }],
|
||||||
|
[396986, { service: "TikTok", category: "Видео / стриминг" }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||||
|
[13335, "US"],
|
||||||
|
[209242, "US"],
|
||||||
|
[54113, "US"],
|
||||||
|
[20940, "US"],
|
||||||
|
[16509, "US"],
|
||||||
|
[14618, "US"],
|
||||||
|
[8075, "US"],
|
||||||
|
[15169, "US"],
|
||||||
|
[32590, "US"],
|
||||||
|
[57976, "US"],
|
||||||
|
[2906, "US"],
|
||||||
|
[40027, "US"],
|
||||||
|
[36040, "US"],
|
||||||
|
[46489, "US"],
|
||||||
|
[401115, "US"],
|
||||||
|
[49544, "US"],
|
||||||
|
[32934, "US"],
|
||||||
|
[13238, "RU"],
|
||||||
|
[62041, "NL"],
|
||||||
|
[59930, "NL"],
|
||||||
|
[211157, "NL"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
|
||||||
|
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
|
||||||
|
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
|
||||||
|
|
||||||
|
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||||
|
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||||
|
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
|
||||||
|
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||||
|
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: CLOUDFLARE },
|
||||||
|
{ cidr: "8.8.8.0/24", prefixLen: 24, hit: GOOGLE },
|
||||||
|
{ cidr: "8.8.4.0/24", prefixLen: 24, hit: GOOGLE },
|
||||||
|
{ cidr: "173.194.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||||
|
{ cidr: "172.217.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||||
|
{ cidr: "74.125.0.0/16", prefixLen: 16, hit: GOOGLE },
|
||||||
|
{ cidr: "142.250.0.0/15", prefixLen: 15, hit: GOOGLE },
|
||||||
|
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
|
||||||
|
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||||
|
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||||
|
|
||||||
|
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||||
|
|
||||||
|
export function isIsoCountry(code: string): boolean {
|
||||||
|
const c = String(code ?? "").trim().toUpperCase()
|
||||||
|
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIsoCountry(code: string): string {
|
||||||
|
const c = String(code ?? "").trim().toUpperCase()
|
||||||
|
return isIsoCountry(c) ? c : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `CLOUDFLARENET, US` → `US`. */
|
||||||
|
export function countryFromHolder(holder: string): string {
|
||||||
|
const m = String(holder ?? "").trim().match(/,\s*([A-Za-z]{2})\s*$/)
|
||||||
|
return m?.[1] ? normalizeIsoCountry(m[1]) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countryForAsn(asn: number): string {
|
||||||
|
if (!asn) return ""
|
||||||
|
return ASN_HQ_COUNTRY.get(asn) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRipeCountry(country: string, asn: number, holder: string): string {
|
||||||
|
return normalizeIsoCountry(country) || countryFromHolder(holder) || countryForAsn(asn)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandByAsn(asn: number): BrandHit | null {
|
||||||
|
if (!asn) return null
|
||||||
|
return ASN_BRANDS.get(asn) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandByCidr(ip: string): BrandHit | null {
|
||||||
|
for (const row of CIDR_BRANDS) {
|
||||||
|
if (parseCidrV4(row.cidr) && ipInCidrV4(ip, row.cidr)) return row.hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||||
|
return brandByCidr(ip) || brandByAsn(asn)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIP_MAP_SERVICES = new Set([
|
||||||
|
OTHER_SERVICE,
|
||||||
|
"GRE",
|
||||||
|
"ESP",
|
||||||
|
"WireGuard",
|
||||||
|
"DNS",
|
||||||
|
"SSH",
|
||||||
|
"BGP",
|
||||||
|
])
|
||||||
|
|
||||||
|
const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"])
|
||||||
|
|
||||||
|
/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */
|
||||||
|
export function isNamedInternetService(service: string, category: string): boolean {
|
||||||
|
const s = service.trim()
|
||||||
|
const c = category.trim()
|
||||||
|
if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapServiceNodeId(label: string): string {
|
||||||
|
const slug = label
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
return `svc:${slug || "unknown"}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 googleCidr = classifyFlowDst("173.194.151.65", 6, 57182, 443, null)
|
||||||
|
assert.equal(googleCidr.service, "Google")
|
||||||
|
assert.equal(googleCidr.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,797 @@
|
|||||||
|
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, pruneRipeSqlite } from "./traffic-flow-ripe.js"
|
||||||
|
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||||
|
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||||
|
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||||
|
import { pickInternetPeer } from "./traffic-flow-ip.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 lastPassiveCheckpointAt = Date.now()
|
||||||
|
let dataEpoch = 0
|
||||||
|
let lastPersistedStats: {
|
||||||
|
packetsReceived: number
|
||||||
|
lastDatagramAt: string | null
|
||||||
|
lastExporterIp: string | null
|
||||||
|
lastError: string
|
||||||
|
} | null = null
|
||||||
|
let exporterCtx: ExporterResolveCtx | null = null
|
||||||
|
|
||||||
|
const PRUNE_MS = 5 * 60_000
|
||||||
|
const LIVE_WINDOW_MS = 15 * 60_000
|
||||||
|
const PASSIVE_CHECKPOINT_MS = 60_000
|
||||||
|
|
||||||
|
function bumpDataEpoch(): void {
|
||||||
|
dataEpoch += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowDataEpoch(): number {
|
||||||
|
return dataEpoch
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (flows.length) bumpDataEpoch()
|
||||||
|
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 peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort)
|
||||||
|
const ripe = lookupRipeCached(peer)
|
||||||
|
if (peer && !ripe) ripeMisses.push(peer)
|
||||||
|
const classified = classifyFlowDst(peer, 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): boolean {
|
||||||
|
if (
|
||||||
|
lastPersistedStats
|
||||||
|
&& lastPersistedStats.packetsReceived === packetsReceived
|
||||||
|
&& lastPersistedStats.lastDatagramAt === lastDatagramAt
|
||||||
|
&& lastPersistedStats.lastExporterIp === lastExporterIp
|
||||||
|
&& lastPersistedStats.lastError === lastError
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
lastPersistedStats = {
|
||||||
|
packetsReceived,
|
||||||
|
lastDatagramAt,
|
||||||
|
lastExporterIp,
|
||||||
|
lastError,
|
||||||
|
}
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePassiveCheckpoint(handle: SqliteHandle): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastPassiveCheckpointAt < PASSIVE_CHECKPOINT_MS) return
|
||||||
|
lastPassiveCheckpointAt = now
|
||||||
|
try {
|
||||||
|
handle.pragma("wal_checkpoint(PASSIVE)")
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pruneRipeSqlite(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
maybePassiveCheckpoint(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
|
||||||
|
bumpDataEpoch()
|
||||||
|
} 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)
|
||||||
|
maybePassiveCheckpoint(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
lastPassiveCheckpointAt = Date.now()
|
||||||
|
lastPersistedStats = null
|
||||||
|
bumpDataEpoch()
|
||||||
|
pendingCap = MAX_PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingSizeForTests(): number {
|
||||||
|
return pending.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function droppedForTests(): number {
|
||||||
|
return dropped
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
SQLITE_BUSY_TIMEOUT_MS,
|
||||||
|
SQLITE_CACHE_SIZE_KIB,
|
||||||
|
SQLITE_WAL_AUTOCHECKPOINT_PAGES,
|
||||||
|
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)
|
||||||
|
|
||||||
|
function pragmaNum(name: string): number {
|
||||||
|
const rows = sqliteDatabase.pragma(name) as Array<Record<string, number>>
|
||||||
|
const row = Array.isArray(rows) ? rows[0] : rows
|
||||||
|
return Number(Object.values(row ?? {})[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(pragmaNum("wal_autocheckpoint"), SQLITE_WAL_AUTOCHECKPOINT_PAGES)
|
||||||
|
assert.equal(pragmaNum("cache_size"), -SQLITE_CACHE_SIZE_KIB)
|
||||||
|
assert.equal(pragmaNum("temp_store"), 2)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -2,12 +2,13 @@ import { generateNativeConf } from "./wireguard-config.js"
|
|||||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||||
|
|
||||||
export function buildHostWgQuickConf(): string {
|
export function buildHostWgQuickConf(): string {
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
const peers = listHostPeers()
|
const peers = listHostPeers()
|
||||||
return generateNativeConf({
|
return generateNativeConf({
|
||||||
name: "wg-flow",
|
name: "wg-flow",
|
||||||
listenPort: row.wgListenPort,
|
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
privateKey: row.hostPrivateKey || undefined,
|
privateKey: row.hostPrivateKey || undefined,
|
||||||
address: `${row.collectorIp}/24`,
|
address: `${row.collectorIp}/24`,
|
||||||
@@ -16,55 +17,97 @@ export function buildHostWgQuickConf(): string {
|
|||||||
publicKey: p.publicKey,
|
publicKey: p.publicKey,
|
||||||
allowedIps: p.allowedIps,
|
allowedIps: p.allowedIps,
|
||||||
comment: p.name,
|
comment: p.name,
|
||||||
|
endpoint: p.endpoint,
|
||||||
|
persistentKeepalive: p.endpoint ? 25 : undefined,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostComposeSnippet(): string {
|
export function buildHostComposeOverride(): string {
|
||||||
const row = getTrafficFlowSettingsRow()
|
|
||||||
return `# IPFIX listener: публиковать UDP только на WG-адресе хоста, не на 0.0.0.0
|
|
||||||
# Поднимите wg-quick@wg-flow, затем раскомментируйте ports у backend.
|
|
||||||
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
ports:
|
|
||||||
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
|
|
||||||
environment:
|
|
||||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHostNftSnippet(): string {
|
|
||||||
const row = getTrafficFlowSettingsRow()
|
|
||||||
return `# Firewall хоста Docker MM (nftables). UDP ${row.flowListenPort} наружу НЕ открывать.
|
|
||||||
table inet filter {
|
|
||||||
chain input {
|
|
||||||
type filter hook input priority 0;
|
|
||||||
iifname "wg-flow" udp dport ${row.flowListenPort} accept
|
|
||||||
udp dport ${row.wgListenPort} accept comment "WireGuard handshake"
|
|
||||||
udp dport ${row.flowListenPort} drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ufw (если используете):
|
|
||||||
# ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'
|
|
||||||
# ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHostUfwSnippet(): string {
|
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
return [
|
return [
|
||||||
`ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`,
|
"# Docker Compose merge для /opt/cdn-mm",
|
||||||
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
"# Не править 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")
|
].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[] {
|
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||||
return [
|
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: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||||
{ id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() },
|
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||||
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
|
||||||
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,181 +1,340 @@
|
|||||||
import { createSocket, type Socket } from "node:dgram"
|
import { Worker } from "node:worker_threads"
|
||||||
import { desc, eq, gte, sql } from "drizzle-orm"
|
import { existsSync, statSync } from "node:fs"
|
||||||
import { db } from "../db/index.js"
|
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 { flowBuckets, servers } from "../db/schema.js"
|
||||||
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
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 {
|
import {
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
recordFlowListenerError,
|
listHostPeers,
|
||||||
recordFlowPacket,
|
resetFlowIngestCounters,
|
||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { getServerCatalog } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
|
export type { PendingFlowRow }
|
||||||
|
|
||||||
export interface FlowListenerState {
|
export interface FlowListenerState {
|
||||||
bound: boolean
|
bound: boolean
|
||||||
address: string | null
|
address: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket: Socket | null = 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 state: FlowListenerState = { bound: false, address: null }
|
||||||
const pending = new Map<string, {
|
let wantListen = false
|
||||||
serverId: number
|
|
||||||
bucketAt: string
|
attachEngineSqlite(sqliteDatabase)
|
||||||
flow: ParsedFlow
|
|
||||||
bytes: number
|
function workerFileUrl(): URL {
|
||||||
packets: number
|
const ts = import.meta.url.includes(".ts")
|
||||||
}>()
|
return new URL(
|
||||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
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 {
|
export function getFlowListenerState(): FlowListenerState {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function minuteBucketIso(at = Date.now()): string {
|
export function getFlowWorkerHealth(): FlowWorkerHealth {
|
||||||
const d = new Date(at)
|
const hb = lastHeartbeat
|
||||||
d.setSeconds(0, 0)
|
const mem = getEngineStats()
|
||||||
return d.toISOString()
|
return {
|
||||||
}
|
alive: Boolean(worker) && (hb?.workerAlive ?? false),
|
||||||
|
bound: state.bound,
|
||||||
function resolveServerId(exporterIp: string): number | null {
|
pendingSize: hb?.pendingSize ?? mem.pendingSize,
|
||||||
const exact = db.select().from(servers).where(eq(servers.mgmtTunnelIp, exporterIp)).limit(1).all()[0]
|
dropped: hb?.dropped ?? mem.dropped,
|
||||||
return exact ? exact.id : null
|
packetsReceived: hb?.packetsReceived ?? mem.packetsReceived,
|
||||||
}
|
|
||||||
|
|
||||||
function queueFlows(exporterIp: string, flows: ParsedFlow[]) {
|
|
||||||
const serverId = resolveServerId(exporterIp)
|
|
||||||
if (serverId == null) return
|
|
||||||
const bucketAt = minuteBucketIso()
|
|
||||||
for (const flow of flows) {
|
|
||||||
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}`
|
|
||||||
const prev = pending.get(key)
|
|
||||||
if (prev) {
|
|
||||||
prev.bytes += flow.bytes
|
|
||||||
prev.packets += flow.packets
|
|
||||||
} else {
|
|
||||||
pending.set(key, {
|
|
||||||
serverId,
|
|
||||||
bucketAt,
|
|
||||||
flow: { ...flow },
|
|
||||||
bytes: flow.bytes,
|
|
||||||
packets: flow.packets,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPending() {
|
export function getFlowRuntimeCounters() {
|
||||||
if (pending.size === 0) return
|
|
||||||
const settings = getTrafficFlowSettingsRow()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
const topN = Math.max(20, settings.topN)
|
const hb = lastHeartbeat
|
||||||
const cutoff = new Date(Date.now() - settings.retentionHours * 3600_000).toISOString()
|
return {
|
||||||
const rows = [...pending.values()]
|
packetsReceived: hb?.packetsReceived ?? settings.packetsReceived,
|
||||||
pending.clear()
|
lastExporterIp: hb?.lastExporterIp ?? settings.lastExporterIp ?? null,
|
||||||
|
lastError: (hb?.lastError ?? settings.lastError) || null,
|
||||||
for (const row of rows) {
|
lastDatagramAt: hb?.lastDatagramAt ?? settings.lastDatagramAt ?? null,
|
||||||
try {
|
dropped: hb?.dropped ?? 0,
|
||||||
db.insert(flowBuckets).values({
|
|
||||||
serverId: row.serverId,
|
|
||||||
bucketAt: row.bucketAt,
|
|
||||||
src: row.flow.src || "0.0.0.0",
|
|
||||||
dst: row.flow.dst || "0.0.0.0",
|
|
||||||
proto: row.flow.proto,
|
|
||||||
srcPort: row.flow.srcPort,
|
|
||||||
dstPort: row.flow.dstPort,
|
|
||||||
bytes: row.bytes,
|
|
||||||
packets: row.packets,
|
|
||||||
inIface: row.flow.inIface,
|
|
||||||
}).onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
flowBuckets.serverId,
|
|
||||||
flowBuckets.bucketAt,
|
|
||||||
flowBuckets.src,
|
|
||||||
flowBuckets.dst,
|
|
||||||
flowBuckets.proto,
|
|
||||||
flowBuckets.srcPort,
|
|
||||||
flowBuckets.dstPort,
|
|
||||||
],
|
|
||||||
set: {
|
|
||||||
bytes: sql`${flowBuckets.bytes} + excluded.bytes`,
|
|
||||||
packets: sql`${flowBuckets.packets} + excluded.packets`,
|
|
||||||
},
|
|
||||||
}).run()
|
|
||||||
} catch {
|
|
||||||
// ignore single-row failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
|
|
||||||
|
|
||||||
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
|
|
||||||
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
|
|
||||||
if (!latest) return
|
|
||||||
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
|
|
||||||
const byServer = new Map<number, typeof latestRows>()
|
|
||||||
for (const r of latestRows) {
|
|
||||||
const list = byServer.get(r.serverId) ?? []
|
|
||||||
list.push(r)
|
|
||||||
byServer.set(r.serverId, list)
|
|
||||||
}
|
|
||||||
for (const list of byServer.values()) {
|
|
||||||
if (list.length <= topN) continue
|
|
||||||
list.sort((a, b) => b.bytes - a.bytes)
|
|
||||||
for (const d of list.slice(topN)) {
|
|
||||||
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMessage(msg: Buffer, rinfo: { address: string }) {
|
|
||||||
try {
|
|
||||||
const flows = parseFlowPacket(msg, rinfo.address)
|
|
||||||
recordFlowPacket(rinfo.address)
|
|
||||||
if (flows.length) queueFlows(rinfo.address, flows)
|
|
||||||
} catch (e) {
|
|
||||||
recordFlowListenerError(e instanceof Error ? e.message : String(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopTrafficFlowListener() {
|
|
||||||
if (flushTimer) {
|
|
||||||
clearInterval(flushTimer)
|
|
||||||
flushTimer = null
|
|
||||||
}
|
|
||||||
flushPending()
|
|
||||||
if (socket) {
|
|
||||||
try { socket.close() } catch { /* ignore */ }
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
state = { bound: false, address: null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startTrafficFlowListener() {
|
export function startTrafficFlowListener() {
|
||||||
stopTrafficFlowListener()
|
stopTrafficFlowListener()
|
||||||
const settings = getTrafficFlowSettingsRow()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
|
wantListen = false
|
||||||
state = { bound: false, address: null }
|
state = { bound: false, address: null }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
wantListen = true
|
||||||
const port = settings.flowListenPort
|
spawnWorker()
|
||||||
const sock = createSocket("udp4")
|
}
|
||||||
sock.on("error", (err) => {
|
|
||||||
recordFlowListenerError(err.message)
|
export function stopTrafficFlowListener() {
|
||||||
state = { bound: false, address: null }
|
wantListen = false
|
||||||
})
|
stopWorkerProcess()
|
||||||
sock.on("message", onMessage)
|
try {
|
||||||
sock.bind(port, host, () => {
|
flushPending()
|
||||||
state = { bound: true, address: `${host}:${port}` }
|
} catch {
|
||||||
recordFlowListenerError("")
|
/* ignore */
|
||||||
})
|
}
|
||||||
socket = sock
|
state = { bound: false, address: null }
|
||||||
flushTimer = setInterval(flushPending, 15_000)
|
}
|
||||||
|
|
||||||
|
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 {
|
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||||
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
const rows = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, rangeStart)).all()
|
const runtime = getFlowRuntimeCounters()
|
||||||
const serverRows = db.select().from(servers).all()
|
const rows = listFlowRowsForWindow(minutes)
|
||||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
const catalog = getServerCatalog()
|
||||||
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||||
const protoBytes = new Map<number, number>()
|
const protoBytes = new Map<number, number>()
|
||||||
const srcs = new Set<string>()
|
const srcs = new Set<string>()
|
||||||
@@ -183,7 +342,8 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
const exporters = new Set<number>()
|
const exporters = new Set<number>()
|
||||||
let totalBytes = 0
|
let totalBytes = 0
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
|
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 prev = agg.get(key)
|
||||||
const bytes = r.bytes
|
const bytes = r.bytes
|
||||||
totalBytes += bytes
|
totalBytes += bytes
|
||||||
@@ -208,7 +368,9 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
bytes,
|
bytes,
|
||||||
packets: r.packets,
|
packets: r.packets,
|
||||||
bps: 0,
|
bps: 0,
|
||||||
inIface: r.inIface,
|
inIface: resolved.name,
|
||||||
|
inIfaceIndex: resolved.index,
|
||||||
|
application: applicationName(r.proto, r.dstPort, r.srcPort),
|
||||||
rawBytes: bytes,
|
rawBytes: bytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -217,7 +379,7 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
const talkers = [...agg.values()]
|
const talkers = [...agg.values()]
|
||||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||||
.sort((a, b) => b.bytes - a.bytes)
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
.slice(0, getTrafficFlowSettingsRow().topN)
|
.slice(0, settings.topN)
|
||||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||||
let topProto = "—"
|
let topProto = "—"
|
||||||
let topProtoBytes = 0
|
let topProtoBytes = 0
|
||||||
@@ -234,10 +396,124 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
uniqueDst: dsts.size,
|
uniqueDst: dsts.size,
|
||||||
topProto,
|
topProto,
|
||||||
talkers,
|
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: ParsedFlow[]) {
|
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
|
||||||
queueFlows(exporterIp, flows)
|
applyExporterCtxFromDb()
|
||||||
|
const serverId = resolveServerId(exporterIp)
|
||||||
|
if (serverId == null) return
|
||||||
|
queueParsedFlows(serverId, flows)
|
||||||
|
rollFlowRings()
|
||||||
flushPending()
|
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,25 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { isNonPublicIp, pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
|
|
||||||
|
assert.equal(isNonPublicIp("10.200.100.53"), true)
|
||||||
|
assert.equal(isNonPublicIp("173.194.151.65"), false)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
pickInternetPeer("173.194.151.65", "10.200.100.53", 443, 57182),
|
||||||
|
"173.194.151.65",
|
||||||
|
"reverse IPFIX: Google:443 → RFC1918",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
pickInternetPeer("10.200.100.53", "104.18.35.51", 53880, 443),
|
||||||
|
"104.18.35.51",
|
||||||
|
"client → Cloudflare:443",
|
||||||
|
)
|
||||||
|
assert.equal(pickInternetPeer("10.100.1.17", "8.8.8.8", 51234, 443), "8.8.8.8")
|
||||||
|
assert.equal(
|
||||||
|
pickInternetPeer("1.1.1.1", "8.8.8.8", 443, 51234),
|
||||||
|
"1.1.1.1",
|
||||||
|
"оба публичные — сторона с well-known портом",
|
||||||
|
)
|
||||||
|
assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2")
|
||||||
|
|
||||||
|
console.log("traffic-flow-ip.test.ts: ok")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** 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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интернет-сторона потока: у IPFIX сервис часто в src (Google:443 → RFC1918:ephemeral).
|
||||||
|
* Классифицировать этот IP, не слепой dst.
|
||||||
|
*/
|
||||||
|
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
|
||||||
|
const srcPub = !isNonPublicIp(src)
|
||||||
|
const dstPub = !isNonPublicIp(dst)
|
||||||
|
if (srcPub && !dstPub) return src
|
||||||
|
if (dstPub && !srcPub) return dst
|
||||||
|
if (srcPub && dstPub) {
|
||||||
|
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
|
||||||
|
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
|
||||||
|
if (srcWk && !dstWk) return src
|
||||||
|
if (dstWk && !srcWk) return dst
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -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,417 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||||
|
import {
|
||||||
|
ingestParsedFlowsForServerForTests,
|
||||||
|
resetFlowRingsForTests,
|
||||||
|
} from "./traffic-flow-ingest.js"
|
||||||
|
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
|
||||||
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
|
import {
|
||||||
|
disableRipeEnqueueForTests,
|
||||||
|
disableRipePersistForTests,
|
||||||
|
resetRipeCacheForTests,
|
||||||
|
seedRipeCacheForTests,
|
||||||
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
disableCatalogFetchForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
|
||||||
|
const topo: FlowTopology = {
|
||||||
|
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
|
||||||
|
clientByIface: new Map([["7|gre-client", {
|
||||||
|
userId: "u1",
|
||||||
|
login: "alice",
|
||||||
|
name: "Alice",
|
||||||
|
serverId: 7,
|
||||||
|
interfaceName: "gre-client",
|
||||||
|
}]]),
|
||||||
|
enNodes: [{ id: 9, name: "NSK-EN", hosts: ["198.51.100.1"] }],
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
wanIfaces: new Map([[3, new Set(["ether1-rt"])]]),
|
||||||
|
plane: {
|
||||||
|
clientIfaceNames: new Set(["gre-client"]),
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
|
])
|
||||||
|
rememberServerIfaces(3, [
|
||||||
|
{ ".id": "*1", name: "ether1-rt" },
|
||||||
|
])
|
||||||
|
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 47,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
bytes: 5_000_000,
|
||||||
|
packets: 4000,
|
||||||
|
inIface: "3",
|
||||||
|
outIface: "3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "10.100.1.18",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 50000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 8000,
|
||||||
|
packets: 8,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.255.254.1",
|
||||||
|
dst: "10.255.254.2",
|
||||||
|
proto: 17,
|
||||||
|
srcPort: 4739,
|
||||||
|
dstPort: 2055,
|
||||||
|
bytes: 400,
|
||||||
|
packets: 2,
|
||||||
|
inIface: "10",
|
||||||
|
outIface: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(3, [
|
||||||
|
{
|
||||||
|
src: "192.168.1.10",
|
||||||
|
dst: "8.8.4.4",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 40000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 3000,
|
||||||
|
packets: 4,
|
||||||
|
inIface: "1",
|
||||||
|
outIface: "1",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const def = buildFlowMapHops({ minutes: 5 })
|
||||||
|
assert.equal(def.excludeOverlayApplied, true)
|
||||||
|
assert.equal(def.excludeMeshApplied, true)
|
||||||
|
assert.equal(def.dedupApplied, true)
|
||||||
|
assert.equal(def.windowSec, 300)
|
||||||
|
|
||||||
|
const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||||||
|
assert.ok(payloadGre, "payload JH→EN hop")
|
||||||
|
assert.equal(payloadGre.bytes, 12_000)
|
||||||
|
assert.equal(payloadGre.bps, (12_000 * 8) / 300)
|
||||||
|
assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300)
|
||||||
|
assert.equal(payloadGre.iface, "gre-jh-en")
|
||||||
|
|
||||||
|
const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7")
|
||||||
|
assert.ok(greIface)
|
||||||
|
assert.equal(greIface.bytes, 12_000)
|
||||||
|
assert.equal(greIface.bpsFwd, (12_000 * 8) / 300)
|
||||||
|
|
||||||
|
assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded")
|
||||||
|
assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded")
|
||||||
|
const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||||
|
assert.ok(clientIngress, "payload ingress on client iface")
|
||||||
|
assert.equal(clientIngress.bytes, 12_000)
|
||||||
|
|
||||||
|
const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt")
|
||||||
|
assert.ok(wan, "WAN hop from home-router")
|
||||||
|
assert.equal(wan.bytes, 3000)
|
||||||
|
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
|
||||||
|
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
|
||||||
|
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
|
||||||
|
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||||
|
assert.ok(meshIface && meshIface.bytes >= 20_000)
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-map-hops.test.ts: hops ok")
|
||||||
|
|
||||||
|
function googleRipe() {
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "8.8.8.0/24",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: 37.4,
|
||||||
|
lng: -122.1,
|
||||||
|
holder: "GOOGLE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFlow(dst: string, bytes: number) {
|
||||||
|
return {
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst,
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes,
|
||||||
|
packets: Math.max(1, Math.round(bytes / 1200)),
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 600),
|
||||||
|
payloadFlow("203.0.113.50", 9400),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const six = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
assert.equal(six.totalBytes, 10_000)
|
||||||
|
const google = six.services?.find((s) => s.id === "svc:google")
|
||||||
|
assert.ok(google, "Google ≥ 5%")
|
||||||
|
assert.ok(google.share >= 0.05)
|
||||||
|
const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
|
||||||
|
assert.ok(googleEdge)
|
||||||
|
assert.equal(googleEdge.clientName, "Alice")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 400),
|
||||||
|
payloadFlow("203.0.113.50", 9600),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const four = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
assert.equal(four.totalBytes, 10_000)
|
||||||
|
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const off = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
|
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 47,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
bytes: 9_000,
|
||||||
|
packets: 90,
|
||||||
|
inIface: "3",
|
||||||
|
outIface: "3",
|
||||||
|
},
|
||||||
|
payloadFlow("203.0.113.50", 1000),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
|
||||||
|
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "173.194.151.65",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 57182,
|
||||||
|
bytes: 9_000,
|
||||||
|
packets: 90,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "104.18.35.51",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 53880,
|
||||||
|
bytes: 1_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const rev = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
|
assert.ok(rev.services?.some((s) => s.id === "svc:google"), "реверс Google:443 → 10.x")
|
||||||
|
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
|
||||||
|
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*1", name: "SWE-VEESP" },
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 500),
|
||||||
|
{
|
||||||
|
src: "173.194.151.65",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 57182,
|
||||||
|
bytes: 8_000,
|
||||||
|
packets: 80,
|
||||||
|
inIface: "1",
|
||||||
|
outIface: "1",
|
||||||
|
nextHop: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const wan = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
|
const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google")
|
||||||
|
assert.ok(googleEdge, "Google с WAN JH")
|
||||||
|
assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH")
|
||||||
|
assert.ok(!(wan.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
|
||||||
|
const viaGre = wan.serviceEdges?.find((e) => e.toId === "svc:google")
|
||||||
|
assert.ok(viaGre?.clients?.some((c) => c.name === "Alice") || viaGre?.clientName === "Alice")
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*1", name: "SWE-VEESP" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "173.194.151.65",
|
||||||
|
dst: "10.200.100.53",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 443,
|
||||||
|
dstPort: 57182,
|
||||||
|
bytes: 9_000,
|
||||||
|
packets: 80,
|
||||||
|
inIface: "1",
|
||||||
|
outIface: "1",
|
||||||
|
nextHop: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const wanOnly = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
|
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google")
|
||||||
|
assert.ok(googleEdge, "Google WAN без GRE payload")
|
||||||
|
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
|
||||||
|
assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис")
|
||||||
|
assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
|
||||||
|
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:google")
|
||||||
|
assert.ok(googlePath, "путь WAN Google")
|
||||||
|
assert.equal(googlePath.viaId, "7", "via = JH exporter")
|
||||||
|
assert.equal(googlePath.enId, "9", "якорь EN")
|
||||||
|
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { userInterfaceBindings } from "../db/schema.js"
|
||||||
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
|
import {
|
||||||
|
isNamedInternetService,
|
||||||
|
lookupBrand,
|
||||||
|
mapServiceNodeId,
|
||||||
|
} from "./traffic-flow-brands.js"
|
||||||
|
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||||
|
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||||
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||||
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
|
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||||
|
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||||
|
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog } from "./traffic-flow-topology.js"
|
||||||
|
import { flowDataEpoch } from "./traffic-flow-engine.js"
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||||
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
|
const HOPS_CACHE_TTL_MS = 2000
|
||||||
|
|
||||||
|
export interface FlowMapHopsQuery {
|
||||||
|
minutes: number
|
||||||
|
serverId?: number
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
|
/** Переопределение порога (тесты). Иначе из настроек NetFlow. */
|
||||||
|
minSharePct?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HopAcc {
|
||||||
|
fromId: string
|
||||||
|
fromLabel: string
|
||||||
|
toId: string
|
||||||
|
toLabel: string
|
||||||
|
kind: FlowMapHop["kind"]
|
||||||
|
iface?: string
|
||||||
|
bytes: number
|
||||||
|
bytesFwd: number
|
||||||
|
bytesRev: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientAcc {
|
||||||
|
name: string
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FromAcc {
|
||||||
|
bytes: number
|
||||||
|
clients: Map<string, ClientAcc>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DstAcc {
|
||||||
|
bytes: number
|
||||||
|
proto: number
|
||||||
|
dstPort: number
|
||||||
|
srcPort: number
|
||||||
|
fromBytes: Map<string, FromAcc>
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
|
||||||
|
const id = client?.userId || "—"
|
||||||
|
const name = client?.name || "—"
|
||||||
|
const prev = clients.get(id)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += bytes
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clients.set(id, { name, bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void {
|
||||||
|
const prev = acc.fromBytes.get(exporterId)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += bytes
|
||||||
|
bumpClient(prev.clients, bytes, client)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clients = new Map<string, ClientAcc>()
|
||||||
|
bumpClient(clients, bytes, client)
|
||||||
|
acc.fromBytes.set(exporterId, { bytes, clients })
|
||||||
|
}
|
||||||
|
|
||||||
|
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
|
||||||
|
|
||||||
|
export function resetFlowMapHopsCacheForTests(): void {
|
||||||
|
hopsCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampMapServiceMinSharePct(n: unknown): number {
|
||||||
|
const v = typeof n === "number" ? n : Number(n)
|
||||||
|
if (!Number.isFinite(v)) return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
|
||||||
|
return Math.min(100, Math.max(0, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
epoch: flowDataEpoch(),
|
||||||
|
minutes: q.minutes,
|
||||||
|
serverId: q.serverId ?? null,
|
||||||
|
userId: q.userId ?? null,
|
||||||
|
iface: q.iface ?? null,
|
||||||
|
dedup: q.dedup !== false,
|
||||||
|
excludeMesh: q.excludeMesh !== false,
|
||||||
|
excludeOverlay: q.excludeOverlay !== false,
|
||||||
|
minSharePct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
|
||||||
|
if (!userId) return null
|
||||||
|
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||||
|
const allow = new Map<number, Set<string>>()
|
||||||
|
for (const b of binds) {
|
||||||
|
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||||
|
set.add(b.interfaceName)
|
||||||
|
allow.set(b.serverId, set)
|
||||||
|
}
|
||||||
|
return allow
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceUsable(name: string): boolean {
|
||||||
|
return Boolean(name) && name !== "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(acc: Map<string, HopAcc>, key: string, seed: Omit<HopAcc, "bytes" | "bytesFwd" | "bytesRev">, bytes: number, dir: "fwd" | "rev" | "both"): void {
|
||||||
|
const prev = acc.get(key)
|
||||||
|
const addFwd = dir === "fwd" || dir === "both" ? bytes : 0
|
||||||
|
const addRev = dir === "rev" || dir === "both" ? bytes : 0
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += bytes
|
||||||
|
prev.bytesFwd += addFwd
|
||||||
|
prev.bytesRev += addRev
|
||||||
|
if (seed.iface && !prev.iface) prev.iface = seed.iface
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc.set(key, {
|
||||||
|
...seed,
|
||||||
|
bytes,
|
||||||
|
bytesFwd: addFwd,
|
||||||
|
bytesRev: addRev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHop(a: HopAcc, windowSec: number): FlowMapHop {
|
||||||
|
return {
|
||||||
|
fromId: a.fromId,
|
||||||
|
fromLabel: a.fromLabel,
|
||||||
|
toId: a.toId,
|
||||||
|
toLabel: a.toLabel,
|
||||||
|
kind: a.kind,
|
||||||
|
...(a.iface ? { iface: a.iface } : {}),
|
||||||
|
bytes: a.bytes,
|
||||||
|
bps: (a.bytes * 8) / windowSec,
|
||||||
|
bpsFwd: (a.bytesFwd * 8) / windowSec,
|
||||||
|
bpsRev: (a.bytesRev * 8) / windowSec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
|
||||||
|
function classifyMapDstLite(
|
||||||
|
dst: string,
|
||||||
|
proto: number,
|
||||||
|
dstPort: number,
|
||||||
|
srcPort: number,
|
||||||
|
ripe: FlowIpMeta | null,
|
||||||
|
): { service: string; category: string } | null {
|
||||||
|
if (proto === 47 || proto === 50) return null
|
||||||
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
|
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||||
|
if (/youtube/i.test(ripe?.holder ?? "")) {
|
||||||
|
return { service: "YouTube", category: "Видео / стриминг" }
|
||||||
|
}
|
||||||
|
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
||||||
|
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||||
|
return brand
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMinSharePct(q: FlowMapHopsQuery): number {
|
||||||
|
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
|
||||||
|
try {
|
||||||
|
const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
|
||||||
|
return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
|
||||||
|
const windowSec = Math.max(60, q.minutes * 60)
|
||||||
|
const raw = listFlowRowsForWindow(q.minutes)
|
||||||
|
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||||
|
const catalog = getServerCatalog()
|
||||||
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
|
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
|
const excludeMesh = q.excludeMesh !== false
|
||||||
|
const excludeOverlay = q.excludeOverlay !== false
|
||||||
|
const topo = loadFlowTopology()
|
||||||
|
|
||||||
|
const matched = []
|
||||||
|
for (const r of raw) {
|
||||||
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||||
|
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||||
|
const plane = classifyFlowPlane({
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
inIface: resolved.name,
|
||||||
|
outIface: outResolved.name,
|
||||||
|
}, topo.plane)
|
||||||
|
if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
|
||||||
|
matched.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||||
|
const hops = new Map<string, HopAcc>()
|
||||||
|
const dstAcc = new Map<string, DstAcc>()
|
||||||
|
const jhToEn = new Map<number, number>()
|
||||||
|
const enIds = new Set(topo.enNodes.map((n) => n.id))
|
||||||
|
let totalBytes = 0
|
||||||
|
|
||||||
|
for (const r of working) {
|
||||||
|
const inRes = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const outRes = resolveIfaceName(r.serverId, r.outIface)
|
||||||
|
const inName = inRes.name
|
||||||
|
const outName = outRes.name
|
||||||
|
const fromId = String(r.serverId)
|
||||||
|
const fromLabel = nameById.get(r.serverId) ?? fromId
|
||||||
|
const wanSet = topo.wanIfaces.get(r.serverId)
|
||||||
|
|
||||||
|
const inOk = ifaceUsable(inName)
|
||||||
|
const outOk = ifaceUsable(outName)
|
||||||
|
const sameIface = inOk && outOk && inName.toLowerCase() === outName.toLowerCase()
|
||||||
|
if (sameIface) {
|
||||||
|
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
} else {
|
||||||
|
if (inOk) {
|
||||||
|
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "rev")
|
||||||
|
}
|
||||||
|
if (outOk) {
|
||||||
|
bump(hops, `iface|${fromId}|${outName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: outName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enOut = ifaceUsable(outName) ? resolveEn(topo, r.nextHop, outName) : null
|
||||||
|
const enIn = ifaceUsable(inName) ? resolveEn(topo, "", inName) : null
|
||||||
|
const en = (enOut && enOut.id !== r.serverId ? enOut : null)
|
||||||
|
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
||||||
|
if (en) {
|
||||||
|
jhToEn.set(r.serverId, en.id)
|
||||||
|
const toId = String(en.id)
|
||||||
|
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
||||||
|
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
|
||||||
|
bump(hops, `gre|${fromId}|${toId}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId,
|
||||||
|
toLabel: en.name,
|
||||||
|
kind: "gre",
|
||||||
|
iface: greIface,
|
||||||
|
}, r.bytes, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wanSet?.size) {
|
||||||
|
if (ifaceUsable(inName) && wanSet.has(inName)) {
|
||||||
|
bump(hops, `wan|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "wan",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "rev")
|
||||||
|
}
|
||||||
|
if (ifaceUsable(outName) && wanSet.has(outName) && outName.toLowerCase() !== inName.toLowerCase()) {
|
||||||
|
bump(hops, `wan|${fromId}|${outName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "wan",
|
||||||
|
iface: outName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes += r.bytes
|
||||||
|
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
|
||||||
|
const client = resolveClient(topo, r.serverId, inName)
|
||||||
|
const prevDst = dstAcc.get(peer)
|
||||||
|
if (prevDst) {
|
||||||
|
prevDst.bytes += r.bytes
|
||||||
|
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
|
||||||
|
} else {
|
||||||
|
const acc: DstAcc = {
|
||||||
|
bytes: r.bytes,
|
||||||
|
proto: r.proto,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
fromBytes: new Map(),
|
||||||
|
}
|
||||||
|
bumpFrom(acc, String(r.serverId), r.bytes, client)
|
||||||
|
dstAcc.set(peer, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
|
||||||
|
const svcEdges = new Map<string, {
|
||||||
|
fromId: string
|
||||||
|
toId: string
|
||||||
|
bytes: number
|
||||||
|
bytesFwd: number
|
||||||
|
bytesRev: number
|
||||||
|
clients: Map<string, string>
|
||||||
|
}>()
|
||||||
|
const svcPaths = new Map<string, {
|
||||||
|
clientId: string
|
||||||
|
clientName: string
|
||||||
|
viaId: string
|
||||||
|
viaName: string
|
||||||
|
enId: string
|
||||||
|
enName: string
|
||||||
|
serviceId: string
|
||||||
|
bytes: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const h of hops.values()) {
|
||||||
|
if (h.kind !== "gre" || !h.toId) continue
|
||||||
|
const from = Number(h.fromId)
|
||||||
|
const to = Number(h.toId)
|
||||||
|
if (!Number.isFinite(from) || !Number.isFinite(to)) continue
|
||||||
|
if (enIds.has(to) && !enIds.has(from)) jhToEn.set(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const soleEnId = topo.enNodes.length === 1 ? String(topo.enNodes[0]!.id) : null
|
||||||
|
|
||||||
|
function anchorEnId(exporterId: string): string | null {
|
||||||
|
const n = Number(exporterId)
|
||||||
|
if (enIds.has(n)) return exporterId
|
||||||
|
const mapped = jhToEn.get(n)
|
||||||
|
if (mapped != null) return String(mapped)
|
||||||
|
if (soleEnId) return soleEnId
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeName(id: string): string {
|
||||||
|
const n = Number(id)
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
const fromDb = nameById.get(n)
|
||||||
|
if (fromDb) return fromDb
|
||||||
|
}
|
||||||
|
const en = topo.enNodes.find((node) => String(node.id) === id)
|
||||||
|
if (en?.name) return en.name
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dst, acc] of dstAcc) {
|
||||||
|
const ripe = lookupRipeCached(dst)
|
||||||
|
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
|
||||||
|
if (!classified) continue
|
||||||
|
const toId = mapServiceNodeId(classified.service)
|
||||||
|
const prevSvc = svcTotals.get(toId)
|
||||||
|
if (prevSvc) prevSvc.bytes += acc.bytes
|
||||||
|
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes })
|
||||||
|
for (const [exporterId, from] of acc.fromBytes) {
|
||||||
|
const fromId = anchorEnId(exporterId)
|
||||||
|
if (!fromId) continue
|
||||||
|
const edgeKey = `${fromId}|${toId}`
|
||||||
|
const prevEdge = svcEdges.get(edgeKey)
|
||||||
|
const namedClients = new Map<string, string>()
|
||||||
|
for (const [id, c] of from.clients) {
|
||||||
|
if (id !== "—") namedClients.set(id, c.name)
|
||||||
|
}
|
||||||
|
if (prevEdge) {
|
||||||
|
prevEdge.bytes += from.bytes
|
||||||
|
prevEdge.bytesFwd += from.bytes
|
||||||
|
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
||||||
|
} else {
|
||||||
|
svcEdges.set(edgeKey, {
|
||||||
|
fromId,
|
||||||
|
toId,
|
||||||
|
bytes: from.bytes,
|
||||||
|
bytesFwd: from.bytes,
|
||||||
|
bytesRev: 0,
|
||||||
|
clients: namedClients,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const enName = nodeName(fromId)
|
||||||
|
const viaName = nodeName(exporterId)
|
||||||
|
for (const [clientId, c] of from.clients) {
|
||||||
|
const pathKey = `${clientId}|${exporterId}|${fromId}|${toId}`
|
||||||
|
const prevPath = svcPaths.get(pathKey)
|
||||||
|
if (prevPath) {
|
||||||
|
prevPath.bytes += c.bytes
|
||||||
|
} else {
|
||||||
|
svcPaths.set(pathKey, {
|
||||||
|
clientId,
|
||||||
|
clientName: c.name,
|
||||||
|
viaId: exporterId,
|
||||||
|
viaName,
|
||||||
|
enId: fromId,
|
||||||
|
enName,
|
||||||
|
serviceId: toId,
|
||||||
|
bytes: c.bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minShare = minSharePct / 100
|
||||||
|
let services: FlowMapService[] = [...svcTotals.entries()]
|
||||||
|
.map(([id, s]) => ({
|
||||||
|
id,
|
||||||
|
label: s.label,
|
||||||
|
category: s.category,
|
||||||
|
bytes: s.bytes,
|
||||||
|
bps: (s.bytes * 8) / windowSec,
|
||||||
|
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
if (minSharePct > 0) {
|
||||||
|
services = services.filter((s) => s.share >= minShare)
|
||||||
|
}
|
||||||
|
services = services.slice(0, MAP_SERVICE_NODE_CAP)
|
||||||
|
const keepSvc = new Set(services.map((s) => s.id))
|
||||||
|
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||||
|
.filter((e) => keepSvc.has(e.toId))
|
||||||
|
.map((e) => {
|
||||||
|
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
|
||||||
|
const first = clients[0]
|
||||||
|
return {
|
||||||
|
fromId: e.fromId,
|
||||||
|
toId: e.toId,
|
||||||
|
bytes: e.bytes,
|
||||||
|
bps: (e.bytes * 8) / windowSec,
|
||||||
|
bpsFwd: (e.bytesFwd * 8) / windowSec,
|
||||||
|
bpsRev: (e.bytesRev * 8) / windowSec,
|
||||||
|
...(first ? { clientId: first.id, clientName: first.name } : {}),
|
||||||
|
...(clients.length ? { clients } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
|
||||||
|
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
|
||||||
|
.filter((p) => keepSvc.has(p.serviceId))
|
||||||
|
.map((p) => ({
|
||||||
|
clientId: p.clientId,
|
||||||
|
clientName: p.clientName,
|
||||||
|
viaId: p.viaId,
|
||||||
|
viaName: p.viaName,
|
||||||
|
enId: p.enId,
|
||||||
|
enName: p.enName,
|
||||||
|
serviceId: p.serviceId,
|
||||||
|
bytes: p.bytes,
|
||||||
|
bps: (p.bytes * 8) / windowSec,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.bps - a.bps)
|
||||||
|
|
||||||
|
const listener = getFlowListenerState()
|
||||||
|
return {
|
||||||
|
hops: [...hops.values()]
|
||||||
|
.map((a) => toHop(a, windowSec))
|
||||||
|
.sort((a, b) => a.bytes === b.bytes ? 0 : b.bytes - a.bytes),
|
||||||
|
live: listener.bound,
|
||||||
|
rangeMinutes: q.minutes,
|
||||||
|
windowSec,
|
||||||
|
totalBytes,
|
||||||
|
services,
|
||||||
|
serviceEdges,
|
||||||
|
servicePaths,
|
||||||
|
mapServiceMinSharePct: minSharePct,
|
||||||
|
dedupApplied: wantDedup,
|
||||||
|
excludeMeshApplied: excludeMesh,
|
||||||
|
excludeOverlayApplied: excludeOverlay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
|
||||||
|
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||||
|
const minSharePct = resolveMinSharePct(q)
|
||||||
|
const key = hopsQueryKey(q, minSharePct)
|
||||||
|
const now = Date.now()
|
||||||
|
if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
|
||||||
|
return hopsCache.dto
|
||||||
|
}
|
||||||
|
const dto = buildFlowMapHopsUncached(q, minSharePct)
|
||||||
|
hopsCache = { key, at: now, dto }
|
||||||
|
return dto
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"
|
|||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers } from "../db/schema.js"
|
import { servers } from "../db/schema.js"
|
||||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||||
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
||||||
import {
|
import {
|
||||||
asRosArray,
|
asRosArray,
|
||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
toRosBody,
|
toRosBody,
|
||||||
} from "./wireguard-ros.js"
|
} from "./wireguard-ros.js"
|
||||||
import {
|
import {
|
||||||
|
enableTrafficFlowIngest,
|
||||||
ensureHostKeys,
|
ensureHostKeys,
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
updateTrafficFlowSettings,
|
|
||||||
upsertHostPeer,
|
upsertHostPeer,
|
||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
|
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
const IFACE_NAME = "wg-flow"
|
const IFACE_NAME = "wg-flow"
|
||||||
const JH_LISTEN_PORT = 13232
|
const JH_LISTEN_PORT = 13232
|
||||||
@@ -42,11 +44,13 @@ export function allocateOverlayAddress(prefix: string, collectorIp: string, serv
|
|||||||
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function linuxPeerBlock(publicKey: string, address: string, comment: string): string {
|
function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string {
|
||||||
return [
|
return [
|
||||||
`[Peer]`,
|
`[Peer]`,
|
||||||
`PublicKey = ${publicKey}`,
|
`PublicKey = ${publicKey}`,
|
||||||
`AllowedIPs = ${address}/32`,
|
`AllowedIPs = ${address}/32`,
|
||||||
|
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
|
||||||
|
`PersistentKeepalive = 25`,
|
||||||
comment ? `# ${comment}` : "",
|
comment ? `# ${comment}` : "",
|
||||||
].filter(Boolean).join("\n")
|
].filter(Boolean).join("\n")
|
||||||
}
|
}
|
||||||
@@ -91,51 +95,74 @@ async function ensureWgInputAccept(client: MikrotikClient, listenPort: number):
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listFlowInterfaces(client: MikrotikClient): Promise<string> {
|
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
||||||
const ifaces = asRosArray<{ name?: string; type?: string; disabled?: string }>(await client.get("/interface"))
|
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
||||||
const names = ifaces
|
|
||||||
.filter((i) => {
|
async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
|
||||||
if ((i.disabled ?? "false") === "true") return false
|
const body = toRosBody({
|
||||||
const name = i.name ?? ""
|
bytes: "yes",
|
||||||
if (!name || name === IFACE_NAME || /^lo/i.test(name)) return false
|
packets: "yes",
|
||||||
const type = (i.type ?? "").toLowerCase()
|
"src-address": "yes",
|
||||||
return type.includes("ether") || type.includes("gre") || type === "vlan"
|
"dst-address": "yes",
|
||||||
})
|
protocol: "yes",
|
||||||
.map((i) => i.name ?? "")
|
"src-port": "yes",
|
||||||
.filter(Boolean)
|
"dst-port": "yes",
|
||||||
.slice(0, 8)
|
"in-interface": "yes",
|
||||||
return names.join(",") || "all"
|
"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> {
|
async function ensureTrafficFlow(
|
||||||
const interfaces = await listFlowInterfaces(client)
|
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 {
|
try {
|
||||||
await client.patch("/ip/traffic-flow", toRosBody({
|
await ensureIpfixFields(client)
|
||||||
enabled: "yes",
|
|
||||||
interfaces,
|
|
||||||
"active-flow-timeout": "1m",
|
|
||||||
"inactive-flow-timeout": "15s",
|
|
||||||
}))
|
|
||||||
} catch {
|
} catch {
|
||||||
await client.put("/ip/traffic-flow", toRosBody({
|
/* поля IPFIX опциональны на старых ROS */
|
||||||
enabled: "yes",
|
|
||||||
interfaces,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||||
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||||
const body = toRosBody({
|
const targetBody = toRosBody({
|
||||||
"dst-address": collectorIp,
|
"dst-address": collectorIp,
|
||||||
|
"src-address": FLOW_TARGET_SRC_AUTO,
|
||||||
port: String(port),
|
port: String(port),
|
||||||
version: "ipfix",
|
version: "ipfix",
|
||||||
})
|
})
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const id = rosRowId(existing)
|
const targetId = rosRowId(existing)
|
||||||
if (id) await patchRosPath(client, `/ip/traffic-flow/target/${encodeURIComponent(id)}`, body)
|
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await client.put("/ip/traffic-flow/target", body)
|
await client.put("/ip/traffic-flow/target", targetBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usablePublicHost(raw: string | undefined): string {
|
export function usablePublicHost(raw: string | undefined): string {
|
||||||
@@ -155,30 +182,22 @@ export async function applyFlowOverlay(
|
|||||||
): Promise<TrafficFlowOverlayResult> {
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
const steps: string[] = []
|
const steps: string[] = []
|
||||||
const keys = ensureHostKeys()
|
const keys = ensureHostKeys()
|
||||||
let settings = getTrafficFlowSettingsRow()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||||
if (!hostPublicKey) {
|
if (!hostPublicKey) {
|
||||||
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpointHost = (
|
|
||||||
opts?.publicEndpoint?.trim()
|
|
||||||
|| settings.publicEndpoint.trim()
|
|
||||||
|| usablePublicHost(opts?.requestHost)
|
|
||||||
).trim()
|
|
||||||
if (!endpointHost) {
|
|
||||||
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = getEnabledServerById(String(serverIdRaw))
|
const server = getEnabledServerById(String(serverIdRaw))
|
||||||
if (!server || !server.enabled) {
|
if (!server || !server.enabled) {
|
||||||
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpointHost !== settings.publicEndpoint.trim()) {
|
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
|
||||||
updateTrafficFlowSettings({ publicEndpoint: endpointHost })
|
if (!endpointHost) {
|
||||||
settings = getTrafficFlowSettingsRow()
|
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
|
||||||
}
|
}
|
||||||
|
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
|
||||||
|
|
||||||
const taken = new Set(
|
const taken = new Set(
|
||||||
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||||
@@ -218,19 +237,23 @@ export async function applyFlowOverlay(
|
|||||||
interface: IFACE_NAME,
|
interface: IFACE_NAME,
|
||||||
"public-key": hostPublicKey,
|
"public-key": hostPublicKey,
|
||||||
"allowed-address": `${settings.collectorIp}/32`,
|
"allowed-address": `${settings.collectorIp}/32`,
|
||||||
"endpoint-address": endpointHost,
|
|
||||||
"endpoint-port": String(settings.wgListenPort),
|
|
||||||
"persistent-keepalive": "25",
|
|
||||||
comment: "MM traffic-flow collector",
|
comment: "MM traffic-flow collector",
|
||||||
name: "mm-collector",
|
name: "mm-collector",
|
||||||
}
|
}
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
await putWireguardPeer(client, peerBody)
|
await putWireguardPeer(client, peerBody)
|
||||||
steps.push("Добавлен пир на pubkey хоста MM")
|
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
|
||||||
} else {
|
} else {
|
||||||
const id = rosRowId(peer)
|
const id = rosRowId(peer)
|
||||||
if (id) await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
|
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
|
||||||
steps.push("Пир хоста MM обновлён")
|
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 routeDst = `${settings.collectorIp}/32`
|
||||||
@@ -253,7 +276,7 @@ export async function applyFlowOverlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||||
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix`)
|
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
|
||||||
|
|
||||||
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||||
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||||
@@ -266,6 +289,7 @@ export async function applyFlowOverlay(
|
|||||||
mgmtTunnelIp: address,
|
mgmtTunnelIp: address,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}).where(eq(servers.id, server.id)).run()
|
}).where(eq(servers.id, server.id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
|
||||||
upsertHostPeer({
|
upsertHostPeer({
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
@@ -273,15 +297,21 @@ export async function applyFlowOverlay(
|
|||||||
publicKey,
|
publicKey,
|
||||||
allowedIps: [`${address}/32`],
|
allowedIps: [`${address}/32`],
|
||||||
address,
|
address,
|
||||||
|
endpoint: peerEndpoint,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
enableTrafficFlowIngest()
|
||||||
|
startTrafficFlowListener()
|
||||||
|
refreshFlowExporterMap()
|
||||||
|
steps.push("Коллектор IPFIX на MM включён")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
interfaceName: IFACE_NAME,
|
interfaceName: IFACE_NAME,
|
||||||
address,
|
address,
|
||||||
publicKey,
|
publicKey,
|
||||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
|
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
||||||
trafficFlow: true,
|
trafficFlow: true,
|
||||||
steps,
|
steps,
|
||||||
hostFiles: listTrafficFlowHostFiles(),
|
hostFiles: listTrafficFlowHostFiles(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
|
import { parseFlowPacket, protoName, resetFlowTemplatesForTests, templateExporterCountForTests } from "./traffic-flow-parse.js"
|
||||||
import { allocateOverlayAddress } from "./traffic-flow-overlay.js"
|
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
|
||||||
|
|
||||||
function netflowV5One(): Buffer {
|
function netflowV5One(): Buffer {
|
||||||
const buf = Buffer.alloc(24 + 48)
|
const buf = Buffer.alloc(24 + 48)
|
||||||
@@ -24,6 +24,7 @@ assert.equal(flows[0]?.src, "10.1.1.8")
|
|||||||
assert.equal(flows[0]?.dst, "8.8.8.8")
|
assert.equal(flows[0]?.dst, "8.8.8.8")
|
||||||
assert.equal(flows[0]?.proto, 6)
|
assert.equal(flows[0]?.proto, 6)
|
||||||
assert.equal(flows[0]?.bytes, 1500)
|
assert.equal(flows[0]?.bytes, 1500)
|
||||||
|
assert.equal(flows[0]?.inIface, "1")
|
||||||
assert.equal(protoName(6), "TCP")
|
assert.equal(protoName(6), "TCP")
|
||||||
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
||||||
|
|
||||||
@@ -31,11 +32,173 @@ 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", 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(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||||
|
|
||||||
import { usablePublicHost } from "./traffic-flow-overlay.js"
|
|
||||||
assert.equal(usablePublicHost("localhost:8000"), "")
|
assert.equal(usablePublicHost("localhost:8000"), "")
|
||||||
assert.equal(usablePublicHost("127.0.0.1"), "")
|
assert.equal(usablePublicHost("127.0.0.1"), "")
|
||||||
assert.equal(usablePublicHost("192.168.1.10"), "")
|
assert.equal(usablePublicHost("192.168.1.10"), "")
|
||||||
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
||||||
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
|
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")
|
console.log("traffic-flow-parse.test.ts: ok")
|
||||||
|
|||||||
@@ -7,6 +7,50 @@ export interface ParsedFlow {
|
|||||||
bytes: number
|
bytes: number
|
||||||
packets: number
|
packets: number
|
||||||
inIface: string
|
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 {
|
interface FieldSpec {
|
||||||
@@ -18,12 +62,72 @@ interface Template {
|
|||||||
fields: FieldSpec[]
|
fields: FieldSpec[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_TEMPLATE_EXPORTERS = 256
|
||||||
const templatesByExporter = new Map<string, Map<number, Template>>()
|
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 {
|
function ipv4(buf: Buffer, offset: number): string {
|
||||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
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 {
|
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||||
if (length === 1) return buf.readUInt8(offset)
|
if (length === 1) return buf.readUInt8(offset)
|
||||||
if (length === 2) return buf.readUInt16BE(offset)
|
if (length === 2) return buf.readUInt16BE(offset)
|
||||||
@@ -44,7 +148,7 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
|||||||
const out: ParsedFlow[] = []
|
const out: ParsedFlow[] = []
|
||||||
let off = 24
|
let off = 24
|
||||||
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||||
out.push({
|
out.push(normalizeParsedFlow({
|
||||||
src: ipv4(buf, off),
|
src: ipv4(buf, off),
|
||||||
dst: ipv4(buf, off + 4),
|
dst: ipv4(buf, off + 4),
|
||||||
packets: buf.readUInt32BE(off + 16),
|
packets: buf.readUInt32BE(off + 16),
|
||||||
@@ -53,7 +157,8 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
|||||||
dstPort: buf.readUInt16BE(off + 34),
|
dstPort: buf.readUInt16BE(off + 34),
|
||||||
proto: buf.readUInt8(off + 38),
|
proto: buf.readUInt8(off + 38),
|
||||||
inIface: String(buf.readUInt16BE(off + 12)),
|
inIface: String(buf.readUInt16BE(off + 12)),
|
||||||
})
|
outIface: String(buf.readUInt16BE(off + 14)),
|
||||||
|
}))
|
||||||
off += 48
|
off += 48
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -61,7 +166,7 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
|||||||
|
|
||||||
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||||
let off = setStart + 4
|
let off = setStart + 4
|
||||||
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
const map = templatesForExporter(exporter)
|
||||||
while (off + 4 <= setEnd) {
|
while (off + 4 <= setEnd) {
|
||||||
const templateId = buf.readUInt16BE(off)
|
const templateId = buf.readUInt16BE(off)
|
||||||
const fieldCount = buf.readUInt16BE(off + 2)
|
const fieldCount = buf.readUInt16BE(off + 2)
|
||||||
@@ -87,7 +192,12 @@ function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, se
|
|||||||
templatesByExporter.set(exporter, map)
|
templatesByExporter.set(exporter, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { flow: ParsedFlow; next: number } | null {
|
function recordFromFields(
|
||||||
|
fields: FieldSpec[],
|
||||||
|
buf: Buffer,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
): { flow: ParsedFlow; next: number } | null {
|
||||||
let off = offset
|
let off = offset
|
||||||
let src = ""
|
let src = ""
|
||||||
let dst = ""
|
let dst = ""
|
||||||
@@ -97,40 +207,129 @@ function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { f
|
|||||||
let bytes = 0
|
let bytes = 0
|
||||||
let packets = 0
|
let packets = 0
|
||||||
let inIface = ""
|
let inIface = ""
|
||||||
|
let outIface = ""
|
||||||
|
let ifaceName = ""
|
||||||
|
let nextHop = ""
|
||||||
|
let flowStartMs = 0
|
||||||
|
let flowEndMs = 0
|
||||||
|
let natSrc = ""
|
||||||
|
let natDst = ""
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
if (off + f.length > buf.length) return null
|
const field = consumeField(buf, off, f.length, limit)
|
||||||
|
if (!field) return null
|
||||||
|
const { data } = field
|
||||||
switch (f.type) {
|
switch (f.type) {
|
||||||
case 8:
|
case 8:
|
||||||
if (f.length === 4) src = ipv4(buf, off)
|
if (data.length === 4) src = ipv4(data, 0)
|
||||||
break
|
break
|
||||||
case 12:
|
case 12:
|
||||||
if (f.length === 4) dst = ipv4(buf, off)
|
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
|
break
|
||||||
case 4:
|
case 4:
|
||||||
proto = readUint(buf, off, f.length)
|
proto = readUint(data, 0, data.length)
|
||||||
break
|
break
|
||||||
case 7:
|
case 7:
|
||||||
srcPort = readUint(buf, off, f.length)
|
srcPort = readUint(data, 0, data.length)
|
||||||
break
|
break
|
||||||
case 11:
|
case 11:
|
||||||
dstPort = readUint(buf, off, f.length)
|
dstPort = readUint(data, 0, data.length)
|
||||||
break
|
break
|
||||||
case 1:
|
case 1:
|
||||||
bytes = readUint(buf, off, f.length)
|
bytes = readUint(data, 0, data.length)
|
||||||
break
|
break
|
||||||
case 2:
|
case 2:
|
||||||
packets = readUint(buf, off, f.length)
|
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
|
break
|
||||||
case 10:
|
case 10:
|
||||||
inIface = String(readUint(buf, off, f.length))
|
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
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
off += f.length
|
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
|
||||||
}
|
}
|
||||||
if (!src && !dst) return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
|
||||||
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||||
@@ -148,16 +347,7 @@ function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
|||||||
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||||
} else if (setId >= 256) {
|
} else if (setId >= 256) {
|
||||||
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||||
if (tpl) {
|
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||||
let recOff = off + 4
|
|
||||||
while (recOff + 1 < setEnd) {
|
|
||||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
|
||||||
if (!parsed) break
|
|
||||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
|
||||||
if (parsed.next <= recOff) break
|
|
||||||
recOff = parsed.next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
off = setEnd
|
off = setEnd
|
||||||
}
|
}
|
||||||
@@ -169,7 +359,7 @@ function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
|||||||
const count = buf.readUInt16BE(2)
|
const count = buf.readUInt16BE(2)
|
||||||
let off = 20
|
let off = 20
|
||||||
const out: ParsedFlow[] = []
|
const out: ParsedFlow[] = []
|
||||||
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
const map = templatesForExporter(exporter)
|
||||||
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||||
const setId = buf.readUInt16BE(off)
|
const setId = buf.readUInt16BE(off)
|
||||||
const setLen = buf.readUInt16BE(off + 2)
|
const setLen = buf.readUInt16BE(off + 2)
|
||||||
@@ -191,16 +381,7 @@ function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
|||||||
templatesByExporter.set(exporter, map)
|
templatesByExporter.set(exporter, map)
|
||||||
} else if (setId >= 256) {
|
} else if (setId >= 256) {
|
||||||
const tpl = map.get(setId)
|
const tpl = map.get(setId)
|
||||||
if (tpl) {
|
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||||
let recOff = off + 4
|
|
||||||
while (recOff + 1 < setEnd) {
|
|
||||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
|
||||||
if (!parsed) break
|
|
||||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
|
||||||
if (parsed.next <= recOff) break
|
|
||||||
recOff = parsed.next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
off = setEnd
|
off = setEnd
|
||||||
}
|
}
|
||||||
@@ -231,3 +412,7 @@ export function protoName(proto: number): string {
|
|||||||
export function resetFlowTemplatesForTests() {
|
export function resetFlowTemplatesForTests() {
|
||||||
templatesByExporter.clear()
|
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, ?)
|
||||||
|
`).run(new Date().toISOString())
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||||
|
`).run()
|
||||||
|
|
||||||
|
const result = await purgeTrafficFlowStore()
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.deleted.buckets, 1)
|
||||||
|
assert.equal(result.deleted.minuteStats, 1)
|
||||||
|
assert.equal(result.deleted.minuteDims, 1)
|
||||||
|
assert.equal(result.deleted.dailyDims, 1)
|
||||||
|
assert.equal(count("flow_buckets"), 0)
|
||||||
|
assert.equal(count("flow_minute_stats"), 0)
|
||||||
|
assert.equal(count("flow_minute_dims"), 0)
|
||||||
|
assert.equal(count("flow_daily_dims"), 0)
|
||||||
|
assert.equal(count("flow_ip_meta"), 1)
|
||||||
|
assert.equal(count("servers"), 1)
|
||||||
|
assert.equal(getFlowRuntimeCounters().packetsReceived, 0)
|
||||||
|
assert.equal(getFlowRuntimeCounters().lastExporterIp, null)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
sqliteDatabase.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-purge.test.ts: ok")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
disableRipeEnqueueForTests,
|
||||||
|
disableRipePersistForTests,
|
||||||
|
enqueueRipeMisses,
|
||||||
|
flushRipeQueueForTests,
|
||||||
|
lookupRipeCached,
|
||||||
|
resetRipeCacheForTests,
|
||||||
|
ripeFetchCountForTests,
|
||||||
|
ripeLastCandidateCountForTests,
|
||||||
|
seedRipeCacheForTests,
|
||||||
|
setRipeFetchForTests,
|
||||||
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
disableRipePersistForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
|
||||||
|
assert.equal(lookupRipeCached("10.1.1.8")?.ok, false)
|
||||||
|
assert.equal(lookupRipeCached("192.168.0.1")?.ok, false)
|
||||||
|
assert.equal(lookupRipeCached("100.64.1.2")?.ok, false)
|
||||||
|
assert.equal(ripeFetchCountForTests(), 0)
|
||||||
|
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "1.2.3.0/24",
|
||||||
|
asn: 64500,
|
||||||
|
country: "NL",
|
||||||
|
lat: 52.3,
|
||||||
|
lng: 4.9,
|
||||||
|
holder: "TEST",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(lookupRipeCached("1.2.3.10")?.country, "NL")
|
||||||
|
assert.equal(lookupRipeCached("1.2.3.10")?.asn, 64500)
|
||||||
|
assert.equal(ripeFetchCountForTests(), 0)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
setRipeFetchForTests(async (input) => {
|
||||||
|
const url = String(input)
|
||||||
|
const body = url.includes("network-info")
|
||||||
|
? { data: { prefix: "8.8.8.0/24", asns: ["15169"] } }
|
||||||
|
: url.includes("maxmind-geo-lite")
|
||||||
|
? { data: { located_resources: [{ locations: [{ country: "US", latitude: 37.4, longitude: -122.1 }] }] } }
|
||||||
|
: { data: { holder: "GOOGLE" } }
|
||||||
|
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||||
|
})
|
||||||
|
enqueueRipeMisses(["8.8.8.8"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(lookupRipeCached("8.8.8.8")?.country, "US")
|
||||||
|
assert.equal(lookupRipeCached("8.8.8.10")?.prefix, "8.8.8.0/24")
|
||||||
|
const afterFirst = ripeFetchCountForTests()
|
||||||
|
assert.ok(afterFirst >= 2)
|
||||||
|
enqueueRipeMisses(["8.8.8.10"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(ripeFetchCountForTests(), afterFirst)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
setRipeFetchForTests(async () => {
|
||||||
|
throw new Error("timeout")
|
||||||
|
})
|
||||||
|
enqueueRipeMisses(["203.0.113.50"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
const neg = lookupRipeCached("203.0.113.50")
|
||||||
|
assert.equal(neg?.ok, false)
|
||||||
|
const afterNeg = ripeFetchCountForTests()
|
||||||
|
enqueueRipeMisses(["203.0.113.50"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(ripeFetchCountForTests(), afterNeg)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "1.1.1.0/24",
|
||||||
|
asn: 13335,
|
||||||
|
country: "?",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "CLOUDFLARENET, US",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(lookupRipeCached("1.1.1.1")?.country, "US")
|
||||||
|
assert.ok(lookupRipeCached("1.1.1.1")?.country !== "?")
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
setRipeFetchForTests(async (input) => {
|
||||||
|
const url = String(input)
|
||||||
|
const body = url.includes("network-info")
|
||||||
|
? { data: { prefix: "1.0.0.0/24", asns: ["13335"] } }
|
||||||
|
: url.includes("maxmind-geo-lite")
|
||||||
|
? { data: { located_resources: [{ locations: [{ country: "?" }] }] } }
|
||||||
|
: { data: { holder: "CLOUDFLARENET, US" } }
|
||||||
|
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||||
|
})
|
||||||
|
enqueueRipeMisses(["1.0.0.1"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(lookupRipeCached("1.0.0.1")?.country, "US")
|
||||||
|
assert.equal(lookupRipeCached("1.0.0.1")?.asn, 13335)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
for (let i = 0; i < 3000; i++) {
|
||||||
|
const o2 = Math.floor(i / 256)
|
||||||
|
const o3 = i % 256
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: `203.${o2}.${o3}.0/24`,
|
||||||
|
asn: 64500,
|
||||||
|
country: "NL",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "NOISE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "8.8.8.0/24",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "GOOGLE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(lookupRipeCached("8.8.8.8")?.asn, 15169)
|
||||||
|
assert.ok(
|
||||||
|
ripeLastCandidateCountForTests() < 8,
|
||||||
|
`index should not scan all prefixes, got ${ripeLastCandidateCountForTests()}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("traffic-flow-ripe.test.ts: ok")
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { sqliteDatabase } from "../db/index.js"
|
||||||
|
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||||
|
import { resolveRipeCountry } from "./traffic-flow-brands.js"
|
||||||
|
|
||||||
|
export interface FlowIpMeta {
|
||||||
|
prefix: string
|
||||||
|
asn: number
|
||||||
|
country: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
holder: string
|
||||||
|
ok: boolean
|
||||||
|
fetchedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIT_TTL_MS = 24 * 60 * 60_000
|
||||||
|
const NEG_TTL_MS = 6 * 60 * 60_000
|
||||||
|
const MAX_NEW_PREFIX_PER_MIN = 30
|
||||||
|
const MAX_QUEUE = 90
|
||||||
|
const CONCURRENCY = 3
|
||||||
|
const RIPE_BASE = "https://stat.ripe.net/data"
|
||||||
|
const UA = "MikrotikManager-flow/1.0"
|
||||||
|
|
||||||
|
const mem = new Map<string, FlowIpMeta>()
|
||||||
|
const asnHolder = new Map<number, { holder: string; fetchedAt: number }>()
|
||||||
|
const inflight = new Map<string, Promise<FlowIpMeta | null>>()
|
||||||
|
const queue: string[] = []
|
||||||
|
const queued = new Set<string>()
|
||||||
|
const recentFetches: number[] = []
|
||||||
|
|
||||||
|
interface RipeIndexed {
|
||||||
|
entry: FlowIpMeta
|
||||||
|
net: number
|
||||||
|
mask: number
|
||||||
|
prefixLen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /24 → кандидаты с prefixLen ≥ 24. Более широкие префиксы — в `wideIndex`. */
|
||||||
|
const v24Index = new Map<number, RipeIndexed[]>()
|
||||||
|
const wideIndex: RipeIndexed[] = []
|
||||||
|
let lastCandidateCount = 0
|
||||||
|
|
||||||
|
let persistEnabled = true
|
||||||
|
let enqueueEnabled = true
|
||||||
|
let loaded = false
|
||||||
|
let workerRunning = false
|
||||||
|
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
|
||||||
|
let fetchCount = 0
|
||||||
|
|
||||||
|
export function disableRipePersistForTests(): void {
|
||||||
|
persistEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableRipeEnqueueForTests(): void {
|
||||||
|
enqueueEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRipeCacheForTests(): void {
|
||||||
|
mem.clear()
|
||||||
|
asnHolder.clear()
|
||||||
|
inflight.clear()
|
||||||
|
queue.length = 0
|
||||||
|
queued.clear()
|
||||||
|
recentFetches.length = 0
|
||||||
|
v24Index.clear()
|
||||||
|
wideIndex.length = 0
|
||||||
|
lastCandidateCount = 0
|
||||||
|
loaded = persistEnabled ? false : true
|
||||||
|
workerRunning = false
|
||||||
|
fetchCount = 0
|
||||||
|
enqueueEnabled = true
|
||||||
|
fetchImpl = globalThis.fetch.bind(globalThis)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
|
||||||
|
remember(entry)
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько CIDR смотрели в последнем lookup (для теста индекса /24). */
|
||||||
|
export function ripeLastCandidateCountForTests(): number {
|
||||||
|
return lastCandidateCount
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRipeFetchForTests(fn: typeof fetch): void {
|
||||||
|
fetchImpl = fn
|
||||||
|
fetchCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ripeFetchCountForTests(): number {
|
||||||
|
return fetchCount
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushRipeQueueForTests(timeoutMs = 4000): Promise<void> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (!queue.length && !inflight.size && !workerRunning) return
|
||||||
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ttlMs(ok: boolean): number {
|
||||||
|
return ok ? HIT_TTL_MS : NEG_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFresh(entry: FlowIpMeta): boolean {
|
||||||
|
return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unindexPrefix(prefix: string): void {
|
||||||
|
const parsed = parseCidrV4(prefix)
|
||||||
|
if (!parsed) return
|
||||||
|
if (parsed.prefixLen >= 24) {
|
||||||
|
const key = parsed.net >>> 8
|
||||||
|
const list = v24Index.get(key)
|
||||||
|
if (!list) return
|
||||||
|
const next = list.filter((row) => row.entry.prefix !== prefix)
|
||||||
|
if (next.length) v24Index.set(key, next)
|
||||||
|
else v24Index.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = wideIndex.findIndex((row) => row.entry.prefix === prefix)
|
||||||
|
if (idx >= 0) wideIndex.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexEntry(entry: FlowIpMeta): void {
|
||||||
|
const parsed = parseCidrV4(entry.prefix)
|
||||||
|
if (!parsed) return
|
||||||
|
const row: RipeIndexed = {
|
||||||
|
entry,
|
||||||
|
net: parsed.net,
|
||||||
|
mask: parsed.mask,
|
||||||
|
prefixLen: parsed.prefixLen,
|
||||||
|
}
|
||||||
|
if (parsed.prefixLen >= 24) {
|
||||||
|
const key = parsed.net >>> 8
|
||||||
|
const list = v24Index.get(key)
|
||||||
|
if (list) list.push(row)
|
||||||
|
else v24Index.set(key, [row])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wideIndex.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(entry: FlowIpMeta): void {
|
||||||
|
const prev = mem.get(entry.prefix)
|
||||||
|
if (prev) unindexPrefix(prev.prefix)
|
||||||
|
mem.set(entry.prefix, entry)
|
||||||
|
indexEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSqlite(): void {
|
||||||
|
if (loaded || !persistEnabled) {
|
||||||
|
loaded = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
try {
|
||||||
|
const rows = sqliteDatabase.prepare(`
|
||||||
|
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
|
||||||
|
FROM flow_ip_meta
|
||||||
|
`).all() as Array<{
|
||||||
|
prefix: string
|
||||||
|
asn: number | null
|
||||||
|
country: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
holder: string
|
||||||
|
ok: number
|
||||||
|
fetched_at: string
|
||||||
|
}>
|
||||||
|
for (const r of rows) {
|
||||||
|
const fetchedAt = Date.parse(r.fetched_at)
|
||||||
|
const asn = Number(r.asn ?? 0) || 0
|
||||||
|
const holder = r.holder || ""
|
||||||
|
remember({
|
||||||
|
prefix: r.prefix,
|
||||||
|
asn,
|
||||||
|
country: resolveRipeCountry(r.country || "", asn, holder) || "—",
|
||||||
|
lat: r.lat == null ? null : Number(r.lat),
|
||||||
|
lng: r.lng == null ? null : Number(r.lng),
|
||||||
|
holder,
|
||||||
|
ok: r.ok !== 0,
|
||||||
|
fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const asns = sqliteDatabase.prepare(`SELECT asn, holder, fetched_at FROM flow_asn_meta`).all() as Array<{
|
||||||
|
asn: number
|
||||||
|
holder: string
|
||||||
|
fetched_at: string
|
||||||
|
}>
|
||||||
|
for (const a of asns) {
|
||||||
|
const fetchedAt = Date.parse(a.fetched_at)
|
||||||
|
asnHolder.set(a.asn, { holder: a.holder || "", fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* table may not exist in isolated tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(entry: FlowIpMeta): void {
|
||||||
|
if (!persistEnabled) return
|
||||||
|
try {
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_ip_meta (prefix, asn, country, lat, lng, holder, ok, fetched_at)
|
||||||
|
VALUES (@prefix, @asn, @country, @lat, @lng, @holder, @ok, @fetchedAt)
|
||||||
|
ON CONFLICT(prefix) DO UPDATE SET
|
||||||
|
asn=excluded.asn, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
|
||||||
|
holder=excluded.holder, ok=excluded.ok, fetched_at=excluded.fetched_at
|
||||||
|
`).run({
|
||||||
|
prefix: entry.prefix,
|
||||||
|
asn: entry.asn,
|
||||||
|
country: entry.country,
|
||||||
|
lat: entry.lat,
|
||||||
|
lng: entry.lng,
|
||||||
|
holder: entry.holder,
|
||||||
|
ok: entry.ok ? 1 : 0,
|
||||||
|
fetchedAt: new Date(entry.fetchedAt).toISOString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore persist errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAsn(asn: number, holder: string): void {
|
||||||
|
if (!persistEnabled || !asn) return
|
||||||
|
try {
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_asn_meta (asn, holder, fetched_at)
|
||||||
|
VALUES (@asn, @holder, @fetchedAt)
|
||||||
|
ON CONFLICT(asn) DO UPDATE SET holder=excluded.holder, fetched_at=excluded.fetched_at
|
||||||
|
`).run({
|
||||||
|
asn,
|
||||||
|
holder,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет просроченный RIPE-кэш с диска (hit 24h / negative 6h). */
|
||||||
|
export function pruneRipeSqlite(nowMs = Date.now()): void {
|
||||||
|
if (!persistEnabled) return
|
||||||
|
try {
|
||||||
|
const hitCutoff = new Date(nowMs - HIT_TTL_MS).toISOString()
|
||||||
|
const negCutoff = new Date(nowMs - NEG_TTL_MS).toISOString()
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok != 0 AND fetched_at < ?`).run(hitCutoff)
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok = 0 AND fetched_at < ?`).run(negCutoff)
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_asn_meta WHERE fetched_at < ?`).run(hitCutoff)
|
||||||
|
} catch {
|
||||||
|
/* table may not exist in isolated tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function negative(prefix: string): FlowIpMeta {
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
asn: 0,
|
||||||
|
country: "—",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "",
|
||||||
|
ok: false,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupRipeCached(ip: string): FlowIpMeta | null {
|
||||||
|
loadSqlite()
|
||||||
|
const trimmed = String(ip ?? "").trim()
|
||||||
|
lastCandidateCount = 0
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (isNonPublicIp(trimmed)) {
|
||||||
|
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
|
||||||
|
}
|
||||||
|
const addr = ipv4ToInt(trimmed)
|
||||||
|
if (addr == null) return null
|
||||||
|
const bucket = v24Index.get(addr >>> 8)
|
||||||
|
const candidates = bucket ? bucket.concat(wideIndex) : wideIndex
|
||||||
|
lastCandidateCount = candidates.length
|
||||||
|
let best: FlowIpMeta | null = null
|
||||||
|
let bestLen = -1
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (!isFresh(row.entry)) continue
|
||||||
|
if (((addr & row.mask) >>> 0) !== row.net) continue
|
||||||
|
if (row.prefixLen > bestLen) {
|
||||||
|
best = row.entry
|
||||||
|
bestLen = row.prefixLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
? { ...best, country: resolveRipeCountry(best.country, best.asn, best.holder) || "—" }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ripeJson(path: string, resource: string): Promise<unknown> {
|
||||||
|
fetchCount += 1
|
||||||
|
const url = `${RIPE_BASE}/${path}/data.json?resource=${encodeURIComponent(resource)}`
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 12_000)
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, {
|
||||||
|
headers: { Accept: "application/json", "User-Agent": UA },
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.json()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrefix(data: unknown): string {
|
||||||
|
const d = data as { data?: { prefix?: string } }
|
||||||
|
return String(d?.data?.prefix ?? "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAsns(data: unknown): number {
|
||||||
|
const d = data as { data?: { asns?: unknown } }
|
||||||
|
const raw = d?.data?.asns
|
||||||
|
const first = Array.isArray(raw) ? raw[0] : raw
|
||||||
|
const n = Number.parseInt(String(first ?? "").replace(/^AS/i, ""), 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGeo(data: unknown): { country: string; lat: number | null; lng: number | null } {
|
||||||
|
const d = data as {
|
||||||
|
data?: {
|
||||||
|
located_resources?: Array<{
|
||||||
|
locations?: Array<{ country?: string; latitude?: number; longitude?: number }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loc = d?.data?.located_resources?.[0]?.locations?.[0]
|
||||||
|
const country = resolveRipeCountry(String(loc?.country ?? ""), 0, "")
|
||||||
|
const lat = loc?.latitude == null ? null : Number(loc.latitude)
|
||||||
|
const lng = loc?.longitude == null ? null : Number(loc.longitude)
|
||||||
|
return {
|
||||||
|
country: country || "—",
|
||||||
|
lat: Number.isFinite(lat) ? lat : null,
|
||||||
|
lng: Number.isFinite(lng) ? lng : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHolder(data: unknown): string {
|
||||||
|
const d = data as { data?: { holder?: string } }
|
||||||
|
return String(d?.data?.holder ?? "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowNewPrefix(): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
while (recentFetches.length && now - recentFetches[0]! > 60_000) recentFetches.shift()
|
||||||
|
return recentFetches.length < MAX_NEW_PREFIX_PER_MIN
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
|
||||||
|
const cached = lookupRipeCached(ip)
|
||||||
|
if (cached) return cached
|
||||||
|
const pending = inflight.get(ip)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const job = (async () => {
|
||||||
|
if (!allowNewPrefix()) return null
|
||||||
|
recentFetches.push(Date.now())
|
||||||
|
try {
|
||||||
|
const net = await ripeJson("network-info", ip)
|
||||||
|
const prefix = pickPrefix(net) || `${ip}/32`
|
||||||
|
const existing = mem.get(prefix)
|
||||||
|
if (existing && isFresh(existing)) return existing
|
||||||
|
const asn = pickAsns(net)
|
||||||
|
let geo = { country: "—", lat: null as number | null, lng: null as number | null }
|
||||||
|
try {
|
||||||
|
geo = pickGeo(await ripeJson("maxmind-geo-lite", prefix))
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
let holder = asnHolder.get(asn)?.holder ?? ""
|
||||||
|
if (asn && (!holder || Date.now() - (asnHolder.get(asn)?.fetchedAt ?? 0) > HIT_TTL_MS)) {
|
||||||
|
try {
|
||||||
|
holder = pickHolder(await ripeJson("as-overview", `AS${asn}`))
|
||||||
|
asnHolder.set(asn, { holder, fetchedAt: Date.now() })
|
||||||
|
persistAsn(asn, holder)
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const country = resolveRipeCountry(geo.country, asn, holder)
|
||||||
|
const entry: FlowIpMeta = {
|
||||||
|
prefix,
|
||||||
|
asn,
|
||||||
|
country: country || "—",
|
||||||
|
lat: geo.lat,
|
||||||
|
lng: geo.lng,
|
||||||
|
holder,
|
||||||
|
ok: Boolean(asn || country),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
remember(entry)
|
||||||
|
persist(entry)
|
||||||
|
return entry
|
||||||
|
} catch {
|
||||||
|
const prefix = `${ip}/32`
|
||||||
|
const entry = negative(prefix)
|
||||||
|
remember(entry)
|
||||||
|
persist(entry)
|
||||||
|
return entry
|
||||||
|
} finally {
|
||||||
|
inflight.delete(ip)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inflight.set(ip, job)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
if (workerRunning) return
|
||||||
|
workerRunning = true
|
||||||
|
try {
|
||||||
|
while (queue.length) {
|
||||||
|
const batch: string[] = []
|
||||||
|
while (batch.length < CONCURRENCY && queue.length) {
|
||||||
|
const ip = queue.shift()
|
||||||
|
if (!ip) break
|
||||||
|
queued.delete(ip)
|
||||||
|
if (lookupRipeCached(ip)) continue
|
||||||
|
if (ipv4ToInt(ip) == null && !ip.includes(":")) continue
|
||||||
|
batch.push(ip)
|
||||||
|
}
|
||||||
|
if (!batch.length) {
|
||||||
|
if (!allowNewPrefix()) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await Promise.all(batch.map((ip) => resolveIp(ip)))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workerRunning = false
|
||||||
|
if (queue.length) void runWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP / SSE never await this — cache miss is filled on a later tick. */
|
||||||
|
export function enqueueRipeMisses(ips: Iterable<string>): void {
|
||||||
|
if (!enqueueEnabled) return
|
||||||
|
loadSqlite()
|
||||||
|
for (const raw of ips) {
|
||||||
|
if (queue.length >= MAX_QUEUE) break
|
||||||
|
const ip = String(raw ?? "").trim()
|
||||||
|
if (!ip || isNonPublicIp(ip)) continue
|
||||||
|
if (lookupRipeCached(ip)) continue
|
||||||
|
if (queued.has(ip) || inflight.has(ip)) continue
|
||||||
|
queued.add(ip)
|
||||||
|
queue.push(ip)
|
||||||
|
}
|
||||||
|
if (queue.length) void runWorker()
|
||||||
|
}
|
||||||
@@ -4,10 +4,42 @@ import { trafficFlowSettings } from "../db/schema.js"
|
|||||||
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||||
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||||
|
|
||||||
|
let settingsRowCache: ReturnType<typeof readSettingsRow> | null = null
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateTrafficFlowSettingsCache(): void {
|
||||||
|
settingsRowCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettingsRow() {
|
||||||
|
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrafficFlowSettingsRow() {
|
||||||
|
if (settingsRowCache) return settingsRowCache
|
||||||
|
const row = readSettingsRow()
|
||||||
|
if (row) {
|
||||||
|
settingsRowCache = 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()
|
||||||
|
settingsRowCache = readSettingsRow()
|
||||||
|
return settingsRowCache!
|
||||||
|
}
|
||||||
|
|
||||||
function parsePeers(raw: string): FlowHostPeer[] {
|
function parsePeers(raw: string): FlowHostPeer[] {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
@@ -20,23 +52,6 @@ function parsePeers(raw: string): FlowHostPeer[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export function toTrafficFlowSettingsDto(
|
||||||
listener: { bound: boolean; address: string | null },
|
listener: { bound: boolean; address: string | null },
|
||||||
): TrafficFlowSettingsDto {
|
): TrafficFlowSettingsDto {
|
||||||
@@ -53,6 +68,7 @@ export function toTrafficFlowSettingsDto(
|
|||||||
hubServerId: row.hubServerId ?? null,
|
hubServerId: row.hubServerId ?? null,
|
||||||
retentionHours: row.retentionHours,
|
retentionHours: row.retentionHours,
|
||||||
topN: row.topN,
|
topN: row.topN,
|
||||||
|
mapServiceMinSharePct: Number(row.mapServiceMinSharePct ?? 5),
|
||||||
lastDatagramAt: row.lastDatagramAt ?? null,
|
lastDatagramAt: row.lastDatagramAt ?? null,
|
||||||
lastExporterIp: row.lastExporterIp ?? null,
|
lastExporterIp: row.lastExporterIp ?? null,
|
||||||
lastError: row.lastError || null,
|
lastError: row.lastError || null,
|
||||||
@@ -75,8 +91,12 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
|||||||
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
||||||
retentionHours: patch.retentionHours ?? row.retentionHours,
|
retentionHours: patch.retentionHours ?? row.retentionHours,
|
||||||
topN: patch.topN ?? row.topN,
|
topN: patch.topN ?? row.topN,
|
||||||
|
mapServiceMinSharePct: patch.mapServiceMinSharePct == null
|
||||||
|
? row.mapServiceMinSharePct
|
||||||
|
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
return getTrafficFlowSettingsRow()
|
return getTrafficFlowSettingsRow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +111,7 @@ export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
|||||||
hostPrivateKey: keys.privateKey,
|
hostPrivateKey: keys.privateKey,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
return { publicKey: keys.publicKey, created: true }
|
return { publicKey: keys.publicKey, created: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +123,7 @@ export function upsertHostPeer(peer: FlowHostPeer) {
|
|||||||
peersJson: JSON.stringify(peers),
|
peersJson: JSON.stringify(peers),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordFlowPacket(exporterIp: string) {
|
export function recordFlowPacket(exporterIp: string) {
|
||||||
@@ -110,9 +132,9 @@ export function recordFlowPacket(exporterIp: string) {
|
|||||||
lastDatagramAt: nowIso(),
|
lastDatagramAt: nowIso(),
|
||||||
lastExporterIp: exporterIp,
|
lastExporterIp: exporterIp,
|
||||||
packetsReceived: row.packetsReceived + 1,
|
packetsReceived: row.packetsReceived + 1,
|
||||||
lastError: "",
|
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordFlowListenerError(message: string) {
|
export function recordFlowListenerError(message: string) {
|
||||||
@@ -120,8 +142,28 @@ export function recordFlowListenerError(message: string) {
|
|||||||
lastError: message,
|
lastError: message,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableTrafficFlowIngest() {
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listHostPeers(): FlowHostPeer[] {
|
export function listHostPeers(): FlowHostPeer[] {
|
||||||
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
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()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerCatalogEntry {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
country: string
|
||||||
|
host: string
|
||||||
|
type: string
|
||||||
|
site: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG_TTL_MS = 5_000
|
||||||
|
|
||||||
|
let seeded: FlowTopology | null = null
|
||||||
|
let topologyCache: { at: number; topo: FlowTopology } | null = null
|
||||||
|
let serverCatalogCache: { at: number; list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } | null = null
|
||||||
|
|
||||||
|
export function invalidateFlowCatalogCache(): void {
|
||||||
|
topologyCache = null
|
||||||
|
serverCatalogCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } {
|
||||||
|
const now = Date.now()
|
||||||
|
if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) {
|
||||||
|
return serverCatalogCache
|
||||||
|
}
|
||||||
|
const rows = db.select().from(servers).all()
|
||||||
|
const list: ServerCatalogEntry[] = rows.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || s.host,
|
||||||
|
country: (s.country || "").toUpperCase() || "UN",
|
||||||
|
host: s.host,
|
||||||
|
type: s.type,
|
||||||
|
site: s.site || "—",
|
||||||
|
}))
|
||||||
|
const byId = new Map(list.map((s) => [s.id, s]))
|
||||||
|
serverCatalogCache = { at: now, list, byId }
|
||||||
|
return serverCatalogCache
|
||||||
|
}
|
||||||
|
|
||||||
|
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 now = Date.now()
|
||||||
|
if (topologyCache && now - topologyCache.at < CATALOG_TTL_MS) return topologyCache.topo
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topo: FlowTopology = {
|
||||||
|
clientIfaces,
|
||||||
|
clientByIface,
|
||||||
|
enNodes,
|
||||||
|
enHosts,
|
||||||
|
jhHosts,
|
||||||
|
wanIfaces,
|
||||||
|
plane: {
|
||||||
|
clientIfaceNames: allClientNames,
|
||||||
|
enHosts,
|
||||||
|
jhHosts,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
topologyCache = { at: Date.now(), topo }
|
||||||
|
return topo
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
||||||
|
seeded = topo
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
|
|||||||
|
|
||||||
export type WgExportIface = {
|
export type WgExportIface = {
|
||||||
name: string
|
name: string
|
||||||
listenPort: number
|
listenPort?: number
|
||||||
mtu: number
|
mtu: number
|
||||||
comment?: string
|
comment?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
|
|||||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||||
}
|
}
|
||||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
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}`)
|
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
lines.push(`/interface wireguard add \\`)
|
lines.push(`/interface wireguard add \\`)
|
||||||
lines.push(` name=${iface.name} \\`)
|
lines.push(` name=${iface.name} \\`)
|
||||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
|
||||||
lines.push(` mtu=${iface.mtu} \\`)
|
lines.push(` mtu=${iface.mtu} \\`)
|
||||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,28 @@ function formatBytes(n: number): string {
|
|||||||
return `${n} Б`
|
return `${n} Б`
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
function TrafficFlowsDataGrid({
|
||||||
|
rows,
|
||||||
|
emptyHint,
|
||||||
|
}: {
|
||||||
|
rows: FlowTalkerDto[]
|
||||||
|
emptyHint?: string
|
||||||
|
}) {
|
||||||
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
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",
|
id: "server",
|
||||||
accessorKey: "serverName",
|
accessorKey: "serverName",
|
||||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "src",
|
id: "src",
|
||||||
@@ -53,6 +66,22 @@ function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
|||||||
),
|
),
|
||||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
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",
|
id: "proto",
|
||||||
accessorKey: "protoName",
|
accessorKey: "protoName",
|
||||||
@@ -74,6 +103,20 @@ function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
|||||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</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 },
|
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",
|
id: "iface",
|
||||||
accessorKey: "inIface",
|
accessorKey: "inIface",
|
||||||
@@ -96,7 +139,10 @@ function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
|||||||
<DataGridShell
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={rows.length}
|
recordCount={rows.length}
|
||||||
emptyMessage="Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
emptyMessage={
|
||||||
|
emptyHint
|
||||||
|
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function nearestCdnSize(px: number): number {
|
|||||||
export function Flag({ code, size = 20, className }: FlagProps) {
|
export function Flag({ code, size = 20, className }: FlagProps) {
|
||||||
if (!code) return null
|
if (!code) return null
|
||||||
const lower = code.toLowerCase()
|
const lower = code.toLowerCase()
|
||||||
|
if (!/^[a-z]{2}$/.test(lower)) return null
|
||||||
const name = countryName(code.toUpperCase())
|
const name = countryName(code.toUpperCase())
|
||||||
const cdnSrc = nearestCdnSize(size)
|
const cdnSrc = nearestCdnSize(size)
|
||||||
const cdnSrc2x = nearestCdnSize(size * 2)
|
const cdnSrc2x = nearestCdnSize(size * 2)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
function slug(label: string): string {
|
||||||
|
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericCloud({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M7.5 18h9.2A4.3 4.3 0 0 0 21 13.8a4.2 4.2 0 0 0-3.7-4.2A6.1 6.1 0 0 0 6.2 11 3.8 3.8 0 0 0 3 14.7 3.7 3.7 0 0 0 6.8 18Z"
|
||||||
|
fill="#38bdf8"
|
||||||
|
opacity="0.92"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandSvg({ children, size }: { children: ReactNode; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: number }) {
|
||||||
|
switch (slug(label)) {
|
||||||
|
case "cloudflare":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M6.2 15.4h12.4c1.6 0 2.6-1.1 2.4-2.4-.2-1.4-1.4-2.1-2.8-2.1-.3-2.4-2.3-4.1-4.8-4.1-1.9 0-3.5 1-4.4 2.5-.4-.2-.9-.3-1.4-.3-1.7 0-3.1 1.3-3.2 3-.1 1.8 1.3 3.4 3.2 3.4Z" fill="#F38020" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "google":
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden>
|
||||||
|
<path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 13 4 4 13 4 24s8.9 20 20 20c11 0 20-9 20-20 0-1.3-.1-2.7-.4-3.9z" />
|
||||||
|
<path fill="#FF3D00" d="M6.3 14.7 12.9 19.5C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" />
|
||||||
|
<path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" />
|
||||||
|
<path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C36.9 39.2 44 34 44 24c0-1.3-.1-2.7-.4-3.9z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case "aws":
|
||||||
|
case "amazon":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M6.2 8.2 12 5.4l5.8 2.8v3.4L12 14.6 6.2 11.6Z" fill="#232F3E" />
|
||||||
|
<path d="M5.2 15.6c3.6 2.6 9.8 2.7 13.6 0" fill="none" stroke="#FF9900" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "steam":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#1b2838" />
|
||||||
|
<circle cx="8.2" cy="14.4" r="3.1" fill="#66c0f4" />
|
||||||
|
<circle cx="15.4" cy="9.2" r="3.6" fill="#c7d5e0" />
|
||||||
|
<circle cx="15.4" cy="9.2" r="1.5" fill="#1b2838" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "blizzard":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M6 5h7.4c3 0 4.8 1.6 4.8 4.1 0 1.8-1 3.1-2.6 3.7 2 .5 3.2 2 3.2 4.1 0 2.8-2.1 4.6-5.6 4.6H6Z" fill="#00AEFF" />
|
||||||
|
<path d="M9.2 8.2h3.4c1.2 0 1.8.6 1.8 1.5s-.6 1.5-1.8 1.5H9.2Zm0 5.2h3.8c1.3 0 2 .6 2 1.6s-.7 1.6-2 1.6H9.2Z" fill="#06121f" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "youtube":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect x="2" y="6" width="20" height="12" rx="3" fill="#FF0000" />
|
||||||
|
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "netflix":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M6 3h3.2l5.6 18H11.6Z" fill="#E50914" />
|
||||||
|
<path d="M14.8 3H18v18h-3.2Z" fill="#B81D24" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "microsoft":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<rect x="3" y="3" width="8" height="8" fill="#F25022" />
|
||||||
|
<rect x="13" y="3" width="8" height="8" fill="#7FBA00" />
|
||||||
|
<rect x="3" y="13" width="8" height="8" fill="#00A4EF" />
|
||||||
|
<rect x="13" y="13" width="8" height="8" fill="#FFB900" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "meta":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M4 14.5c1.8-4.2 4-7.5 6.4-7.5 1.6 0 2.5 1.3 4.6 6.3 1.4 3.4 2.2 4.7 3.4 4.7 1.8 0 3.6-2.6 4.6-5" fill="none" stroke="#0081FB" strokeWidth="2.2" strokeLinecap="round" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "telegram":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#229ED9" />
|
||||||
|
<path d="M7.2 12.1 16.8 8.4 15 16.2l-3.1-1.8-1.6 1.6-.2-2.6Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "discord":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M7.2 5.8 8.6 4.6c2.1.8 4.2 1.2 6.4 1.2h.8L17 5.8c1.8 2.4 2.6 5.4 2.4 8.6-1.6 1.2-3.3 2.1-5.2 2.6L13 15.2c.7-.2 1.3-.6 1.8-1.1-2 .9-4.2.9-6.2 0 .5.5 1.1.9 1.8 1.1L8.8 17c-1.9-.5-3.6-1.4-5.2-2.6C3.4 11.2 4.2 8.2 6 5.8Z" fill="#5865F2" />
|
||||||
|
<circle cx="9.2" cy="11.2" r="1.2" fill="#fff" />
|
||||||
|
<circle cx="14.8" cy="11.2" r="1.2" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "twitch":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M5 4h14v10.2l-4 4H11l-2.2 2.2H7.2V18.2H5Z" fill="#9146FF" />
|
||||||
|
<path d="M7.4 6.4h1.8v5.2H7.4Zm4 0h1.8v5.2H11.4Z" fill="#fff" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
case "tiktok":
|
||||||
|
return (
|
||||||
|
<BrandSvg size={size}>
|
||||||
|
<path d="M14.2 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H14.2Z" fill="#25F4EE" />
|
||||||
|
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
|
||||||
|
</BrandSvg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return <GenericCloud size={size} />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,10 +60,10 @@ function highlightLine(line: string): string {
|
|||||||
return "text-foreground/90"
|
return "text-foreground/90"
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeBlock({ code }: { code: string }) {
|
function CodeBlock({ code, className }: { code: string; className?: string }) {
|
||||||
const lines = code.length ? code.split("\n") : [""]
|
const lines = code.length ? code.split("\n") : [""]
|
||||||
return (
|
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) => (
|
{lines.map((line, i) => (
|
||||||
<span key={i} className={cn("block", highlightLine(line))}>
|
<span key={i} className={cn("block", highlightLine(line))}>
|
||||||
{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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,17 +5,25 @@ import { toast } from "sonner"
|
|||||||
import { FormField } from "@/components/form-kit"
|
import { FormField } from "@/components/form-kit"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { downloadText } from "@/components/reui-kit/code-export-sheet"
|
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { applyTrafficFlowOverlay, getTrafficFlowSettings } from "@/shared/api/traffic-flow"
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -45,33 +53,31 @@ function FlowOverlaySheet({
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [tab, setTab] = useState("wg-quick")
|
const [tab, setTab] = useState("linux")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setCopied(false)
|
setCopied(false)
|
||||||
setTab("wg-quick")
|
setTab("linux")
|
||||||
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
const first = jumpHosts[0]
|
||||||
void getTrafficFlowSettings(backendUrl)
|
const nextId = first ? String(first.id) : ""
|
||||||
.then((s) => setEndpoint(s.publicEndpoint))
|
setServerId(nextId)
|
||||||
.catch(() => setEndpoint(""))
|
setEndpoint(first?.host ?? "")
|
||||||
}, [open, jumpHosts, backendUrl])
|
}, [open, jumpHosts])
|
||||||
|
|
||||||
const formats = useMemo((): TrafficFlowHostFile[] => {
|
function handleServerChange(id: string) {
|
||||||
if (!result) return []
|
setServerId(id)
|
||||||
return [
|
const selected = jumpHosts.find((s) => String(s.id) === id)
|
||||||
...result.hostFiles,
|
if (selected) setEndpoint(selected.host)
|
||||||
{
|
}
|
||||||
id: "peer",
|
|
||||||
label: "[Peer]",
|
|
||||||
filename: "wg-flow-peer.conf",
|
|
||||||
code: result.linuxPeerBlock,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [result])
|
|
||||||
|
|
||||||
|
const formats = result?.hostFiles ?? []
|
||||||
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
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
|
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -80,7 +86,7 @@ function FlowOverlaySheet({
|
|||||||
try {
|
try {
|
||||||
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
||||||
setResult(res)
|
setResult(res)
|
||||||
setTab(res.hostFiles[0]?.id ?? "peer")
|
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||||
toast.success(`wg-flow на ${res.address}`)
|
toast.success(`wg-flow на ${res.address}`)
|
||||||
onDone?.(res)
|
onDone?.(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -102,57 +108,68 @@ function FlowOverlaySheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
|
<SheetContent
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
side="right"
|
||||||
<SheetTitle>Подключить jump-host</SheetTitle>
|
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>
|
<SheetDescription>
|
||||||
Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall).
|
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||||
<FormField label="Jump-host" required>
|
<FormField label="Jump-host" required>
|
||||||
<select
|
<Select
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
|
value={serverId || undefined}
|
||||||
value={serverId}
|
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
||||||
onChange={(e) => setServerId(e.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="">Выберите сервер…</option>
|
<SelectTrigger className="h-9 w-full min-w-0">
|
||||||
{jumpHosts.map((s) => (
|
<SelectValue>{selectedLabel}</SelectValue>
|
||||||
<option key={s.id} value={s.id}>
|
</SelectTrigger>
|
||||||
{s.name || s.host} ({s.host})
|
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||||
</option>
|
{jumpHosts.map((s) => (
|
||||||
))}
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
</select>
|
{s.name || s.host} ({s.host})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label="Публичный IP или DNS хоста Docker MM"
|
label="Публичный IP или DNS jump-host"
|
||||||
required
|
required
|
||||||
hint="Откуда JH стучится на WG listen (51821). Хост с wg-quick, не контейнер backend."
|
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={endpoint}
|
value={endpoint}
|
||||||
onChange={(e) => setEndpoint(e.target.value)}
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
placeholder="203.0.113.10"
|
placeholder="jh.example.com"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="flex flex-col gap-4 min-h-0">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
<Alert>
|
<Alert variant="success">
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0.
|
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{result.steps.map((s) => (
|
{result.steps.map((s) => (
|
||||||
<li key={s}>{s}</li>
|
<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>
|
</ul>
|
||||||
{formats.length > 0 && active ? (
|
{formats.length > 0 && active ? (
|
||||||
<div className="flex flex-col gap-3 min-h-0">
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -163,17 +180,17 @@ function FlowOverlaySheet({
|
|||||||
>
|
>
|
||||||
<TabsList className="h-9 w-full">
|
<TabsList className="h-9 w-full">
|
||||||
{formats.map((f) => (
|
{formats.map((f) => (
|
||||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-1.5 text-xs sm:text-sm">
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||||
{f.label}
|
{f.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Frame dense className="flex min-h-0 flex-col">
|
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
|
||||||
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||||
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all min-h-[12rem]">
|
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
|
||||||
{active.code}
|
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
|
||||||
</pre>
|
</ScrollArea>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -196,8 +213,10 @@ function FlowOverlaySheet({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<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 variant="outline" />}>Закрыть</SheetClose>
|
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||||
{busy ? "Подключение…" : "Подключить"}
|
{busy ? "Подключение…" : "Подключить"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
generateTrafficFlowKeys,
|
generateTrafficFlowKeys,
|
||||||
getTrafficFlowHostFiles,
|
getTrafficFlowHostFiles,
|
||||||
getTrafficFlowSettings,
|
getTrafficFlowSettings,
|
||||||
|
purgeTrafficFlowData,
|
||||||
putTrafficFlowSettings,
|
putTrafficFlowSettings,
|
||||||
} from "@/shared/api/traffic-flow"
|
} from "@/shared/api/traffic-flow"
|
||||||
|
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||||
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
||||||
|
|
||||||
const HOST_STEPS = [
|
const HOST_STEPS = [
|
||||||
@@ -45,7 +47,11 @@ function NetflowSettingsPanel({
|
|||||||
const [endpoint, setEndpoint] = useState("")
|
const [endpoint, setEndpoint] = useState("")
|
||||||
const [retention, setRetention] = useState("24")
|
const [retention, setRetention] = useState("24")
|
||||||
const [topN, setTopN] = useState("200")
|
const [topN, setTopN] = useState("200")
|
||||||
|
const [shareOn, setShareOn] = useState(true)
|
||||||
|
const [sharePct, setSharePct] = useState("5")
|
||||||
const [ingestOn, setIngestOn] = useState(false)
|
const [ingestOn, setIngestOn] = useState(false)
|
||||||
|
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||||
|
const [purgeBusy, setPurgeBusy] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -58,6 +64,9 @@ function NetflowSettingsPanel({
|
|||||||
setEndpoint(s.publicEndpoint)
|
setEndpoint(s.publicEndpoint)
|
||||||
setRetention(String(s.retentionHours))
|
setRetention(String(s.retentionHours))
|
||||||
setTopN(String(s.topN))
|
setTopN(String(s.topN))
|
||||||
|
const pct = Number(s.mapServiceMinSharePct ?? 5)
|
||||||
|
setShareOn(pct > 0)
|
||||||
|
setSharePct(String(pct > 0 ? pct : 5))
|
||||||
setIngestOn(s.enabled)
|
setIngestOn(s.enabled)
|
||||||
}, [backendUrl, enabled])
|
}, [backendUrl, enabled])
|
||||||
|
|
||||||
@@ -79,6 +88,9 @@ function NetflowSettingsPanel({
|
|||||||
publicEndpoint: endpoint,
|
publicEndpoint: endpoint,
|
||||||
retentionHours: Number.parseInt(retention, 10) || 24,
|
retentionHours: Number.parseInt(retention, 10) || 24,
|
||||||
topN: Number.parseInt(topN, 10) || 200,
|
topN: Number.parseInt(topN, 10) || 200,
|
||||||
|
mapServiceMinSharePct: shareOn
|
||||||
|
? Math.min(100, Math.max(1, Number.parseFloat(sharePct) || 5))
|
||||||
|
: 0,
|
||||||
})
|
})
|
||||||
setSettings(res.settings)
|
setSettings(res.settings)
|
||||||
toast.success("Настройки NetFlow сохранены")
|
toast.success("Настройки NetFlow сохранены")
|
||||||
@@ -102,6 +114,20 @@ function NetflowSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function handleExport() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +201,29 @@ function NetflowSettingsPanel({
|
|||||||
<FormField label="Top-N разговоров">
|
<FormField label="Top-N разговоров">
|
||||||
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<div className="sm:col-span-2 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FormToggle
|
||||||
|
checked={shareOn}
|
||||||
|
onChange={(on) => {
|
||||||
|
setShareOn(on)
|
||||||
|
if (on && (!sharePct || sharePct === "0")) setSharePct("5")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Порог доли на карте</span>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="Минимум % окна"
|
||||||
|
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={sharePct}
|
||||||
|
onChange={(e) => setSharePct(e.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
disabled={!shareOn}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -196,18 +245,35 @@ function NetflowSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
|
<Button size="sm" disabled={busy || purgeBusy} onClick={() => { void handleSave() }}>
|
||||||
Сохранить NetFlow
|
Сохранить NetFlow
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
|
||||||
<KeyRoundIcon className="size-4" />
|
<KeyRoundIcon className="size-4" />
|
||||||
Ключи хоста
|
Ключи хоста
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
|
||||||
<DownloadIcon className="size-4" />
|
<DownloadIcon className="size-4" />
|
||||||
wg-quick / compose / firewall
|
wg-quick / compose / firewall
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</OpsPanel>
|
||||||
|
|
||||||
<CodeExportSheet
|
<CodeExportSheet
|
||||||
@@ -217,6 +283,13 @@ function NetflowSettingsPanel({
|
|||||||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||||
formats={formats}
|
formats={formats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NetflowPurgeConfirm
|
||||||
|
open={purgeOpen}
|
||||||
|
busy={purgeBusy}
|
||||||
|
onConfirm={() => { void handlePurgeConfirm() }}
|
||||||
|
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ services:
|
|||||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-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:
|
volumes:
|
||||||
- ./data/mm:/app/data
|
- ./data/mm:/app/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,9 +35,14 @@ export function greTunnelProbe(t: GreTunnel): TunnelProbe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const W = 1060
|
const W = 1240
|
||||||
const H = 580
|
const H = 580
|
||||||
const MARGIN = 72
|
const MARGIN = 72
|
||||||
|
const SERVICE_COL_W = 150
|
||||||
|
|
||||||
|
/** Карточка конечного сервиса на карте (центр = позиция узла). */
|
||||||
|
export const MAP_SERVICE_NODE_W = 86
|
||||||
|
export const MAP_SERVICE_NODE_H = 58
|
||||||
|
|
||||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
export const NETWORK_MAP_PIPELINE_Y = 300
|
||||||
@@ -68,7 +73,9 @@ function layerOfServer(s: Server): number | null {
|
|||||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||||
*/
|
*/
|
||||||
export const NETWORK_MAP_LAYOUT_REVISION = 6
|
export const NETWORK_MAP_W = W
|
||||||
|
export const NETWORK_MAP_H = H
|
||||||
|
export const NETWORK_MAP_LAYOUT_REVISION = 7
|
||||||
|
|
||||||
export interface WanJhEdge {
|
export interface WanJhEdge {
|
||||||
homeId: string
|
homeId: string
|
||||||
@@ -254,7 +261,7 @@ export function computeNetworkMapLayout(
|
|||||||
const nodePos: Record<string, { x: number; y: number }> = {}
|
const nodePos: Record<string, { x: number; y: number }> = {}
|
||||||
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
||||||
|
|
||||||
const span = W - 2 * MARGIN
|
const span = W - 2 * MARGIN - SERVICE_COL_W
|
||||||
const laneGap = Math.min(44, span * 0.04)
|
const laneGap = Math.min(44, span * 0.04)
|
||||||
const laneW = (span - 2 * laneGap) / 3
|
const laneW = (span - 2 * laneGap) / 3
|
||||||
|
|
||||||
@@ -425,6 +432,28 @@ export function computeNetworkMapLayout(
|
|||||||
return { nodePos, wanSatPos }
|
return { nodePos, wanSatPos }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Колонка конечных сервисов справа от EN. */
|
||||||
|
export function placeServiceNodes(
|
||||||
|
serviceIds: string[],
|
||||||
|
enPositions: Array<{ x: number; y: number }>,
|
||||||
|
): Record<string, { x: number; y: number }> {
|
||||||
|
const out: Record<string, { x: number; y: number }> = {}
|
||||||
|
if (serviceIds.length === 0) return out
|
||||||
|
const minY = MARGIN + 70
|
||||||
|
const maxY = H - 72
|
||||||
|
const x = W - MARGIN - SERVICE_COL_W / 2
|
||||||
|
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
|
||||||
|
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
|
||||||
|
const n = serviceIds.length
|
||||||
|
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
|
||||||
|
const span = gap * (n - 1)
|
||||||
|
const start = clamp(centerY - span / 2, minY, maxY - span)
|
||||||
|
serviceIds.forEach((id, i) => {
|
||||||
|
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||||
@@ -803,3 +832,42 @@ export function buildGreMapEdges(
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрезать отрезок центр круга → центр прямоугольника по ободу круга и AABB карточки.
|
||||||
|
* Пунктир EN→сервис визуально упирается в край, как GRE под кругами узлов.
|
||||||
|
*/
|
||||||
|
export function clipSegmentCircleToRect(
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
r: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
hw: number,
|
||||||
|
hh: number,
|
||||||
|
pad = 1.5,
|
||||||
|
): { x1: number; y1: number; x2: number; y2: number } {
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dy = y2 - y1
|
||||||
|
const len = Math.hypot(dx, dy)
|
||||||
|
if (len < 1e-6) return { x1, y1, x2, y2 }
|
||||||
|
const ux = dx / len
|
||||||
|
const uy = dy / len
|
||||||
|
const sx = x1 + ux * (r + pad)
|
||||||
|
const sy = y1 + uy * (r + pad)
|
||||||
|
const absDx = Math.abs(dx)
|
||||||
|
const absDy = Math.abs(dy)
|
||||||
|
const u = Math.min(
|
||||||
|
absDx < 1e-9 ? 1 : (hw + pad) / absDx,
|
||||||
|
absDy < 1e-9 ? 1 : (hh + pad) / absDy,
|
||||||
|
)
|
||||||
|
const uu = Math.min(Math.max(u, 0), 0.48)
|
||||||
|
const ex = x2 - dx * uu
|
||||||
|
const ey = y2 - dy * uu
|
||||||
|
if ((ex - sx) * dx + (ey - sy) * dy <= 0) {
|
||||||
|
const mx = (x1 + x2) / 2
|
||||||
|
const my = (y1 + y2) / 2
|
||||||
|
return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 }
|
||||||
|
}
|
||||||
|
return { x1: sx, y1: sy, x2: ex, y2: ey }
|
||||||
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Generated
+15
@@ -14259,6 +14259,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
|
"version": "16.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||||
|
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const flowHostPeerSchema = z.object({
|
|||||||
publicKey: z.string().min(1),
|
publicKey: z.string().min(1),
|
||||||
allowedIps: z.array(z.string().min(1)).min(1),
|
allowedIps: z.array(z.string().min(1)).min(1),
|
||||||
address: z.string().min(1),
|
address: z.string().min(1),
|
||||||
|
endpoint: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const trafficFlowSettingsDtoSchema = z.object({
|
export const trafficFlowSettingsDtoSchema = z.object({
|
||||||
@@ -20,6 +21,7 @@ export const trafficFlowSettingsDtoSchema = z.object({
|
|||||||
hubServerId: z.number().int().positive().nullable(),
|
hubServerId: z.number().int().positive().nullable(),
|
||||||
retentionHours: z.number().int().positive(),
|
retentionHours: z.number().int().positive(),
|
||||||
topN: z.number().int().positive(),
|
topN: z.number().int().positive(),
|
||||||
|
mapServiceMinSharePct: z.number().min(0).max(100),
|
||||||
lastDatagramAt: z.string().nullable(),
|
lastDatagramAt: z.string().nullable(),
|
||||||
lastExporterIp: z.string().nullable(),
|
lastExporterIp: z.string().nullable(),
|
||||||
lastError: z.string().nullable(),
|
lastError: z.string().nullable(),
|
||||||
@@ -39,6 +41,7 @@ export const trafficFlowSettingsPatchSchema = z.object({
|
|||||||
hubServerId: z.number().int().positive().nullable().optional(),
|
hubServerId: z.number().int().positive().nullable().optional(),
|
||||||
retentionHours: z.number().int().positive().optional(),
|
retentionHours: z.number().int().positive().optional(),
|
||||||
topN: z.number().int().positive().max(1000).optional(),
|
topN: z.number().int().positive().max(1000).optional(),
|
||||||
|
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const trafficFlowOverlayRequestSchema = z.object({
|
export const trafficFlowOverlayRequestSchema = z.object({
|
||||||
@@ -78,6 +81,19 @@ export const flowTalkerDtoSchema = z.object({
|
|||||||
packets: z.number().nonnegative(),
|
packets: z.number().nonnegative(),
|
||||||
bps: z.number().nonnegative(),
|
bps: z.number().nonnegative(),
|
||||||
inIface: z.string(),
|
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({
|
export const flowStatsDtoSchema = z.object({
|
||||||
@@ -87,6 +103,12 @@ export const flowStatsDtoSchema = z.object({
|
|||||||
uniqueDst: z.number().int().nonnegative(),
|
uniqueDst: z.number().int().nonnegative(),
|
||||||
topProto: z.string(),
|
topProto: z.string(),
|
||||||
talkers: z.array(flowTalkerDtoSchema),
|
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 FlowHostPeer = z.infer<typeof flowHostPeerSchema>
|
||||||
@@ -94,5 +116,221 @@ export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema
|
|||||||
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
||||||
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
||||||
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
|
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 flowMapServiceDtoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
share: z.number().min(0).max(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowMapServiceEdgeDtoSchema = z.object({
|
||||||
|
fromId: z.string(),
|
||||||
|
toId: z.string(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
bpsFwd: z.number().nonnegative(),
|
||||||
|
bpsRev: z.number().nonnegative(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientName: z.string().optional(),
|
||||||
|
clients: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
})).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowMapServicePathDtoSchema = z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
clientName: z.string(),
|
||||||
|
viaId: z.string(),
|
||||||
|
viaName: z.string(),
|
||||||
|
enId: z.string(),
|
||||||
|
enName: z.string(),
|
||||||
|
serviceId: z.string(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowMapHopsDtoSchema = z.object({
|
||||||
|
hops: z.array(flowMapHopDtoSchema),
|
||||||
|
live: z.boolean(),
|
||||||
|
rangeMinutes: z.number().int().positive(),
|
||||||
|
windowSec: z.number().positive(),
|
||||||
|
totalBytes: z.number().nonnegative().optional(),
|
||||||
|
services: z.array(flowMapServiceDtoSchema).optional(),
|
||||||
|
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
|
||||||
|
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
|
||||||
|
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
|
||||||
|
dedupApplied: z.boolean(),
|
||||||
|
excludeMeshApplied: z.boolean(),
|
||||||
|
excludeOverlayApplied: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||||
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
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 FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
||||||
|
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
||||||
|
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
|
||||||
|
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
FlowAnalyticsDto,
|
||||||
|
FlowClientsDto,
|
||||||
|
FlowExportersDto,
|
||||||
|
FlowMapHopsDto,
|
||||||
|
FlowMonthlyDto,
|
||||||
|
FlowPurgeDto,
|
||||||
FlowStatsDto,
|
FlowStatsDto,
|
||||||
TrafficFlowHostFile,
|
TrafficFlowHostFile,
|
||||||
TrafficFlowOverlayResult,
|
TrafficFlowOverlayResult,
|
||||||
@@ -50,3 +56,89 @@ export async function applyTrafficFlowOverlay(
|
|||||||
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
|
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
|
||||||
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
|
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 }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user