Compare commits

..
8 Commits
Author SHA1 Message Date
Denozordec 0fdd2fc1e1 feat(traffic-flow): раскрытие сервисов страны на карте сети
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup.
- Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны.
- Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются.
- Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса.
- Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
2026-09-12 12:47:30 +07:00
DenozordecandCursor f15a7348db feat(traffic-flow): enhance country handling in flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced country-specific handling in traffic flow analytics, including new mappings for country nodes and edges.
- Added mock data for countries and their respective service paths to improve visualization in the Network Map.
- Updated the `countryName` function to utilize `Intl.DisplayNames` for better localization of country names.
- Enhanced tests to validate country handling and ensure accurate representation in flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:38:37 +07:00
DenozordecandCursor f3c846201c feat(traffic-flow): enhance Instagram service handling and classification
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m34s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Added support for Instagram in traffic flow resolution and classification, including new test cases to validate its behavior.
- Introduced `INSTAGRAM` brand handling in the traffic flow logic, ensuring accurate service identification for Instagram-related traffic.
- Updated existing tests to cover various scenarios involving Instagram, including IP resolution and service categorization.
- Enhanced the service brand icon component to include an Instagram icon for better visual representation.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:20:16 +07:00
DenozordecandCursor ee9804f1bb feat(traffic-flow): enhance flow analytics and IP handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m29s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Introduced `canonicalIp` function to standardize IP address formats across the application, improving consistency in flow processing.
- Updated traffic flow analytics to utilize new endpoint resolution logic, enhancing accuracy in traffic classification.
- Enhanced tests for traffic flow analytics and IP handling, ensuring comprehensive coverage for new functionalities.
- Improved traffic flow destination resolution with additional test cases for various internet services, including Google and Fastly.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:51:23 +07:00
DenozordecandCursor 4ce6169d14 feat(traffic-flow): enhance traffic classification and map display
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m16s
Docker images / frontend-image (push) Successful in 2m42s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m30s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Added a new state variable `mapAsnLoaded` to track ASN loading status in the NetworkMapPage.
- Updated the page to conditionally display a message when ASN data is not loaded, improving user feedback.
- Enhanced traffic classification tests to include additional scenarios for Google and YouTube services, ensuring comprehensive coverage.
- Introduced a new function `pickMapInternetDest` to improve destination selection logic in traffic flow processing.
- Updated the traffic flow destination classification to handle new conditions and improve accuracy.

Co-authored-by: Cursor <[email protected]>
2026-09-12 01:07:26 +07:00
DenozordecandCursor 60a0970d73 feat(traffic-flow): enhance traffic flow analytics and overlay configuration
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m42s
Docker images / frontend-image (push) Successful in 3m22s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Updated the traffic flow analytics to include new state variables for named bytes, total bytes, and window seconds, improving data granularity.
- Modified the NetworkMapPage to display classified traffic data, including a new section for showing classified and total bytes.
- Enhanced the applyFlowOverlay function to support a new option for disabling GRE fast path, allowing for more flexible traffic flow configurations.
- Added a toggle in the FlowOverlaySheet component to enable or disable the GRE fast path setting, improving user control over traffic processing.
- Updated tests to validate the new functionalities and ensure accurate traffic flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 00:03:13 +07:00
DenozordecandCursor fc29dcede7 feat(traffic-flow): add NAT fields to flow processing and analytics
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m22s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m51s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced new fields for NAT source and destination IPs, as well as their respective ports, in the flow data model.
- Updated database schema and migration scripts to accommodate the new NAT fields in the `flow_buckets` table.
- Enhanced flow analytics and processing functions to utilize the new NAT fields, improving accuracy in traffic flow analysis.
- Added tests to validate the handling of NAT data in various scenarios, ensuring robustness in flow processing.

Co-authored-by: Cursor <[email protected]>
2026-09-11 23:01:48 +07:00
DenozordecandCursor 5f774ce26e feat(gre): enhance GRE tunnel status handling and IPsec configuration
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m1s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced a new `greStatusMeta` function to streamline status retrieval and default handling.
- Updated GRE tunnel command generation to improve IPsec configuration handling, including optional fields for encryption and authentication algorithms.
- Enhanced the GRE tunnels data grid to display IPsec settings more clearly, including handling cases where certain parameters may be undefined.
- Refactored status display logic in the GRE page to utilize the new status meta function for consistency.

Co-authored-by: Cursor <[email protected]>
2026-09-11 15:39:32 +07:00
44 changed files with 2620 additions and 383 deletions
+17 -10
View File
@@ -68,6 +68,10 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
down: { label: "Down", dot: "bg-red-500" },
}
function greStatusMeta(status: GreStatus | undefined) {
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
}
// ─── RouterOS code generator ─────────────────────────────────────────────────
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
@@ -103,10 +107,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` address=${t.localInnerIp} \\`)
lines.push(` interface=${t.name}`)
// IPsec manual equivalent
if (t.ipsec) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
// IPsec: live CHR имеет только ipsec-secret; proposal — у моков/формы
if (t.ipsec?.encAlg && t.ipsec.authAlg) {
const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2"
const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "none"
lines.push("")
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
@@ -123,13 +127,16 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`)
lines.push(` lifetime=${t.ipsec.lifetime ?? "1d"}`)
lines.push("")
lines.push(`/ip ipsec policy add \\`)
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`)
} else if (t.ipsec) {
lines.push("")
lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret")
}
return lines.join("\n")
@@ -138,7 +145,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
// ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
const s = greStatusMeta(status)
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} />
@@ -737,7 +744,7 @@ export default function GrePage() {
<div className="flex flex-wrap gap-2">
{poolTunnels.map((t, tunnelIndex) => (
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
<span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
<span className="font-mono font-medium">{t.name}</span>
<span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
@@ -798,8 +805,8 @@ export default function GrePage() {
codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
<span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
{greStatusMeta(codePreviewTunnel.status).label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
@@ -814,7 +821,7 @@ export default function GrePage() {
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"}
</span>
</>
) : null}
+490 -26
View File
@@ -27,7 +27,9 @@ import {
findServerByGreRemote,
greSourceWanIndexOnMap,
greTunnelProbe,
placeCountryServiceNodes,
placeServiceNodes,
SERVICE_COL_W,
type GreMapEdge,
type WanJhEdge,
} from "@/lib/network-map-layout"
@@ -55,8 +57,9 @@ import {
matchNetflowForWan,
type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
@@ -69,7 +72,7 @@ import {
import { cn } from "@/lib/utils"
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
import Link from "next/link"
import { Flag } from "@/components/flag"
import { Flag, countryName } from "@/components/flag"
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
@@ -285,6 +288,12 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]
const MOCK_MAP_COUNTRIES: FlowMapService[] = [
{ id: "cc:us", label: "US", category: "Страна", bytes: 22_000_000, bps: 8_800_000, share: 0.38 },
{ id: "cc:nl", label: "NL", category: "Страна", bytes: 14_000_000, bps: 5_600_000, share: 0.31 },
{ id: "cc:de", label: "DE", category: "Страна", bytes: 9_000_000, bps: 3_600_000, share: 0.21 },
]
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
@@ -293,6 +302,14 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_COUNTRY_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "cc:us", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:us", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv2", toId: "cc:nl", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:nl", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv3", toId: "cc:de", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
@@ -301,6 +318,56 @@ const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
]
const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:nl", bytes: 9_000_000, bps: 3_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:nl", bytes: 5_000_000, bps: 2_000_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
]
/** Доли — от байтов страны cc:us (22M из моков выше). */
const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [
{
countryId: "cc:us",
services: [
{ id: "cc:us|svc:google", label: "Google", category: "Веб", bytes: 12_000_000, bps: 4_800_000, share: 12 / 22 },
{ id: "cc:us|svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 7_000_000, bps: 2_800_000, share: 7 / 22 },
{ id: "cc:us|svc:aws", label: "AWS", category: "CDN", bytes: 3_000_000, bps: 1_200_000, share: 3 / 22 },
],
edges: [
{ fromId: "cc:us", toId: "cc:us|svc:google", bytes: 12_000_000, bps: 4_800_000, bpsFwd: 9_000_000, bpsRev: 3_000_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "cc:us", toId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000, bpsFwd: 5_250_000, bpsRev: 1_750_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "cc:us", toId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000, bpsFwd: 2_250_000, bpsRev: 750_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
],
paths: [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:google", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:google", bytes: 4_000_000, bps: 1_600_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000 },
],
},
]
const DEST_MODE_KEY = "mm-network-map-dest-mode"
type DestMode = "services" | "countries"
function readDestMode(): DestMode {
if (typeof window === "undefined") return "services"
try {
return sessionStorage.getItem(DEST_MODE_KEY) === "countries" ? "countries" : "services"
} catch {
return "services"
}
}
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
if (!node) return fallback
if (mode !== "countries") return node.label
if (node.id === "cc:other" || node.label === "Прочее") return "Прочее"
return countryName(node.label)
}
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
}
@@ -738,6 +805,10 @@ function ServiceNode({
isSel,
isVis,
isDragged,
destMode,
iso,
dim,
shareLabel,
onClick,
onMouseDown,
}: {
@@ -748,20 +819,27 @@ function ServiceNode({
isSel: boolean
isVis: boolean
isDragged: boolean
destMode: DestMode
iso?: string
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
dim?: boolean
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
shareLabel?: string
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return (
<g
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? 1 : 0.08}
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
{isSel && (
<rect
x={-bw / 2 - 6}
@@ -786,7 +864,9 @@ function ServiceNode({
strokeWidth={isSel ? 2.2 : 1.4}
/>
<g transform="translate(-11,-24)" pointerEvents="none">
<ServiceBrandIcon label={label} size={22} />
{flagIso
? <CountryFlagSvg iso={flagIso} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : label} size={22} />}
</g>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
{label}
@@ -804,6 +884,7 @@ function ServicePathList({
services,
highlight,
viaMode,
destMode,
onToggle,
}: {
paths: FlowMapServicePath[]
@@ -811,6 +892,7 @@ function ServicePathList({
services: FlowMapService[]
highlight: { viaId: string; enId: string; serviceId: string } | null
viaMode: "via" | "service"
destMode: DestMode
onToggle: (p: FlowMapServicePath) => void
}) {
if (paths.length === 0) {
@@ -823,13 +905,14 @@ function ServicePathList({
const via = servers.find((s) => s.id === p.viaId)
const en = servers.find((s) => s.id === p.enId)
const svc = services.find((s) => s.id === p.serviceId)
const destLabel = destDisplayLabel(svc, destMode, p.serviceId)
const label = formatServicePathLabel(p, viaMode, {
viaName: via?.name,
viaSite: via?.site,
enName: en?.name,
serviceLabel: svc?.label,
serviceLabel: destLabel,
})
const title = formatServicePathTitle(label, svc?.label ?? p.serviceId)
const title = formatServicePathTitle(label, destLabel)
const active = Boolean(
highlight
&& highlight.viaId === p.viaId
@@ -1099,7 +1182,16 @@ export default function NetworkMapPage() {
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState<FlowMapCountryServiceGroup[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
const [mapNamedBytes, setMapNamedBytes] = useState(0)
const [mapTotalBytes, setMapTotalBytes] = useState(0)
const [mapWindowSec, setMapWindowSec] = useState(300)
const [mapAsnLoaded, setMapAsnLoaded] = useState(true)
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -1195,7 +1287,14 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapNamedBytes(0)
setMapTotalBytes(0)
setMapCountryLoaded(true)
setDataError(null)
})
return
@@ -1224,8 +1323,40 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
const [destMode, setDestModeState] = useState<DestMode>("services")
useEffect(() => {
queueMicrotask(() => setDestModeState(readDestMode()))
}, [])
function setDestMode(mode: DestMode) {
setDestModeState(mode)
setSelectedService(null)
setExpandedCountryId(null)
setHighlightedPath(null)
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
}
const expandedCountryGroup = useMemo(
() => destMode === "countries" && expandedCountryId
? mapCountryServiceGroups.find((g) => g.countryId === expandedCountryId) ?? null
: null,
[destMode, expandedCountryId, mapCountryServiceGroups],
)
const nestedServices = useMemo(() => expandedCountryGroup?.services ?? [], [expandedCountryGroup])
const liveSelectedService = selectedService
? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService)
? (
(destMode === "countries" ? mapCountries : mapServices)
.find((s) => s.id === selectedService.id)
?? nestedServices.find((s) => s.id === selectedService.id)
?? selectedService
)
: null
const selectedNestedService = liveSelectedService
&& nestedServices.some((s) => s.id === liveSelectedService.id)
? liveSelectedService
: null
const expandedCountry = expandedCountryId
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
: null
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
@@ -1277,7 +1408,12 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapCountryLoaded(true)
})
return
}
@@ -1287,6 +1423,10 @@ export default function NetworkMapPage() {
setMapServices([])
setMapServiceEdges([])
setMapServicePaths([])
setMapCountries([])
setMapCountryEdges([])
setMapCountryPaths([])
setMapCountryServiceGroups([])
})
return
}
@@ -1302,7 +1442,16 @@ export default function NetworkMapPage() {
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
setMapCountries(res.countries ?? [])
setMapCountryEdges(res.countryEdges ?? [])
setMapCountryPaths(res.countryPaths ?? [])
setMapCountryServiceGroups(res.countryServiceGroups ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
setMapNamedBytes(res.namedBytes ?? 0)
setMapTotalBytes(res.totalBytes ?? 0)
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
if (res.windowSec) setMapWindowSec(res.windowSec)
})
.catch((err: unknown) => {
if (cancelled) return
@@ -1505,9 +1654,13 @@ export default function NetworkMapPage() {
return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : []
const destNodes = destMode === "countries" ? mapCountries : mapServices
const destEdges = destMode === "countries" ? mapCountryEdges : mapServiceEdges
const destPaths = destMode === "countries" ? mapCountryPaths : mapServicePaths
const visibleMapServices = showServices ? destNodes : []
const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
? drawableServiceEdges(visibleMapServices, destEdges, mapServers, greEdges, nodePosById)
: []
const nodes = mapServers
@@ -1529,8 +1682,24 @@ export default function NetworkMapPage() {
.map((s) => nodePosById[s.id])
.filter((p): p is { x: number; y: number } => Boolean(p)),
)
// Раскрытая страна: колонка стран уходит влево, правый x занимает столбец её сервисов.
const countryColShift = expandedCountryGroup ? SERVICE_COL_W : 0
const autoDestPos = countryColShift
? Object.fromEntries(
Object.entries(autoServicePos).map(([id, p]) => [id, { x: p.x - countryColShift, y: p.y }]),
)
: autoServicePos
const servicePosById = Object.fromEntries(
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoDestPos[s.id]!]),
)
const autoNestedPos = placeCountryServiceNodes(
nestedServices.map((s) => s.id),
expandedCountryId ? servicePosById[expandedCountryId] ?? autoDestPos[expandedCountryId] : undefined,
)
const nestedPosById = Object.fromEntries(
nestedServices
.map((s) => [s.id, servicePositions[s.id] ?? autoNestedPos[s.id]] as const)
.filter((entry): entry is readonly [string, { x: number; y: number }] => Boolean(entry[1])),
)
// ── Refs ─────────────────────────────────────────────────────────────────────
@@ -1615,6 +1784,7 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
@@ -1712,6 +1882,7 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
}
@@ -1746,6 +1917,34 @@ export default function NetworkMapPage() {
}
// ── Side panel ────────────────────────────────────────────────────────────
useEffect(() => {
if (
expandedCountryId
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
) {
queueMicrotask(() => setExpandedCountryId(null))
}
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
useEffect(() => {
if (!selectedService) return
const stillVisible =
destNodes.some((s) => s.id === selectedService.id)
|| nestedServices.some((s) => s.id === selectedService.id)
if (!stillVisible) {
queueMicrotask(() => {
setSelectedService(null)
setHighlightedPath(null)
})
}
}, [destMode, destNodes, nestedServices, selectedService])
useEffect(() => {
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
}, [showServices])
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
useEffect(() => {
queueMicrotask(() => setServicePositions({}))
}, [expandedCountryId])
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
@@ -1760,6 +1959,10 @@ export default function NetworkMapPage() {
setSelWanIdx(null)
setHighlightedPath(null)
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
// Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает.
if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) {
setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id))
}
}
function selectWan(s: Server, wanIdx: number) {
setSelectedGreEdge(null)
@@ -1909,6 +2112,27 @@ export default function NetworkMapPage() {
)}
</div>
{/* Dest overlay: services vs countries */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
<button
key={b.value}
onClick={() => setDestMode(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
destMode === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{b.label}
</button>
))}
</div>
{/* Layers dropdown */}
<div className="relative">
<button
@@ -1927,7 +2151,7 @@ export default function NetworkMapPage() {
{([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
{ key: "showServices", label: "Назначения", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1954,9 +2178,14 @@ export default function NetworkMapPage() {
))}
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0
? `Порог доли сервиса${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
</p>
{destMode === "countries" && !mapCountryLoaded && (
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
GeoIP Country не загружен, страны из RIPE-кэша
</p>
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1">
<button
@@ -2273,6 +2502,32 @@ export default function NetworkMapPage() {
{visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={destDisplayLabel(svc, destMode)}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
{expandedCountryGroup && nestedServices.map((svc) => {
const pos = nestedPosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
@@ -2283,6 +2538,8 @@ export default function NetworkMapPage() {
isSel={selectedService?.id === svc.id}
isVis
isDragged={draggedSvcId === svc.id}
destMode="services"
shareLabel="трафика страны"
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
@@ -2322,15 +2579,16 @@ export default function NetworkMapPage() {
&& highlightedPath.serviceId === edge.toId,
)
const pathDim = Boolean(highlightedPath) && !pathHit
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId))
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId || expandedCountryId === edge.toId))
const countryDim = !hl && Boolean(expandedCountryGroup) && edge.toId !== expandedCountryId
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
const pathTitle = `${clientLabel}${enName}${destDisplayLabel(svc, destMode, edge.toId)}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
opacity={pathDim ? 0.12 : hl ? 1 : countryDim ? 0.35 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
@@ -2376,6 +2634,81 @@ export default function NetworkMapPage() {
)
})}
{/* ── Раскрытая страна → её сервисы ── */}
{expandedCountryGroup?.edges.map((edge) => {
const from = servicePosById[edge.fromId]
const to = nestedPosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const clipped = clipSegmentCircleToRect(
from.x,
from.y,
MAP_SERVICE_NODE_W / 2,
to.x,
to.y,
MAP_SERVICE_NODE_W / 2,
MAP_SERVICE_NODE_H / 2,
)
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
const hl = selectedService?.id === edge.toId
const svc = nestedServices.find((s) => s.id === edge.toId)
const country = mapCountries.find((s) => s.id === edge.fromId)
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${destDisplayLabel(country, "countries", edge.fromId)}${edge.toId.split("|")[1] ?? edge.toId}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={hl ? 1 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#22d3ee"
strokeWidth={hopHasRate(hop) ? 2 : 1.3}
strokeDasharray="4 4"
opacity="0.9"
pointerEvents="none"
/>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#00000000"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={(ev) => {
ev.stopPropagation()
if (svc) selectService(svc)
}}
/>
{showAnimDots && hopHasRate(hop) && (
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const hit = nestedServices.find((s) => s.id === edge.toId)
if (hit) selectService(hit)
}}
/>
)}
</g>
)
})}
{/* ── Hover tooltip ── */}
{hoveredNode && !isDragging && (
<SvgTooltip n={hoveredNode} />
@@ -2414,7 +2747,9 @@ export default function NetworkMapPage() {
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
{destMode === "countries" ? "Страна" : "Сервис"}
</text>
</g>
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
@@ -2478,7 +2813,7 @@ export default function NetworkMapPage() {
satPos={effectiveSatPos}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
servicePos={servicePosById}
servicePos={{ ...servicePosById, ...nestedPosById }}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
/>
@@ -2713,16 +3048,87 @@ export default function NetworkMapPage() {
})()}
</div>
</>
) : selectedNestedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={selectedNestedService.label} size={22} />
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">
{selectedNestedService.label}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}
</p>
</div>
<button
type="button"
onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля страны</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedNestedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{serviceSharePct(selectedNestedService.bytes / mapTotalBytes)}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
{formatNetflowRate({
bytes: selectedNestedService.bytes,
bps: selectedNestedService.bps,
bpsFwd: selectedNestedService.bps,
bpsRev: 0,
})}
</span>
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<ServicePathList
paths={(expandedCountryGroup?.paths ?? [])
.filter((p) => p.serviceId === selectedNestedService.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={nestedServices}
highlight={highlightedPath}
viaMode="via"
destMode="services"
onToggle={togglePathHighlight}
/>
</div>
</div>
</>
) : liveSelectedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={liveSelectedService.label} size={22} />
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
? <Flag code={liveSelectedService.label} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : liveSelectedService.label} size={22} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{liveSelectedService.label}</p>
<p className="font-mono font-semibold text-sm truncate">
{destDisplayLabel(liveSelectedService, destMode)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {liveSelectedService.category}
{destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
</p>
</div>
<button
@@ -2739,6 +3145,38 @@ export default function NetworkMapPage() {
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(liveSelectedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Классифицировано</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{formatNetflowRate({
bytes: mapNamedBytes,
bps: (mapNamedBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
{" из "}
{formatNetflowRate({
bytes: mapTotalBytes,
bps: (mapTotalBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
</span>
</div>
)}
{!mapAsnLoaded && destMode === "services" && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
</div>
)}
{destMode === "countries" && !mapCountryLoaded && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoIP Country</span>
<span className="text-xs font-mono font-medium text-amber-500">RIPE-кэш</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
@@ -2751,12 +3189,36 @@ export default function NetworkMapPage() {
</span>
</div>
</div>
{expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Сервисы в стране</p>
<div className="flex flex-col gap-1">
{expandedCountryGroup.services.map((svc) => (
<button
key={svc.id}
type="button"
onClick={() => selectService(svc)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
selectedService?.id === svc.id ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)}
>
<span className="flex items-center gap-1.5 min-w-0">
<ServiceBrandIcon label={svc.label} size={14} />
<span className="font-mono truncate">{svc.label}</span>
</span>
<span className="font-mono text-cyan-400 tabular-nums shrink-0">{serviceSharePct(svc.share)}</span>
</button>
))}
</div>
</div>
)}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<div className="flex flex-col gap-3">
{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
const enPaths = mapServicePaths
const enPaths = destPaths
.filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId)
.slice()
.sort((a, b) => b.bps - a.bps)
@@ -2771,9 +3233,10 @@ export default function NetworkMapPage() {
<ServicePathList
paths={enPaths}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="via"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
@@ -3012,7 +3475,7 @@ export default function NetworkMapPage() {
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={mapServicePaths
paths={destPaths
.filter((p) => (
selected.type === "exit-node"
? p.enId === selected.id
@@ -3021,9 +3484,10 @@ export default function NetworkMapPage() {
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={mapServices}
services={destNodes}
highlight={highlightedPath}
viaMode="service"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
@@ -0,0 +1,7 @@
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
-- Needed to rebuild facts with the same internet dest as the network map.
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
+14 -1
View File
@@ -9,6 +9,8 @@ if (!(await withPgOrSkip())) {
process.exit(0)
}
await applySqlMigrations(pool)
{
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
assert.ok(rows[0])
@@ -114,13 +116,17 @@ if (!(await withPgOrSkip())) {
SELECT column_name, udt_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'flow_buckets'
AND column_name IN ('src', 'dst', 'next_hop', 'proto')
AND column_name IN ('src', 'dst', 'next_hop', 'proto', 'nat_src', 'nat_dst', 'nat_src_port', 'nat_dst_port')
`)
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
assert.equal(by.src, "inet")
assert.equal(by.dst, "inet")
assert.equal(by.next_hop, "inet")
assert.equal(by.proto, "int2")
assert.equal(by.nat_src, "inet")
assert.equal(by.nat_dst, "inet")
assert.equal(by.nat_src_port, "int4")
assert.equal(by.nat_dst_port, "int4")
}
{
@@ -179,6 +185,13 @@ if (!(await withPgOrSkip())) {
assert.equal(by.section, "text")
}
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0007_flow_buckets_nat'`,
)
assert.equal(mig.rows.length, 1, "0007 применена")
}
{
const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
+4
View File
@@ -266,6 +266,10 @@ export const flowBuckets = pgTable("flow_buckets", {
nextHop: inet("next_hop"),
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
natSrc: inet("nat_src"),
natDst: inet("nat_dst"),
natSrcPort: integer("nat_src_port").notNull().default(0),
natDstPort: integer("nat_dst_port").notNull().default(0),
}, (t) => [
primaryKey({
name: "flow_buckets_pkey",
+1
View File
@@ -125,6 +125,7 @@ const TABLES: TableCopy[] = [
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
["flow_start_ms", "int"], ["flow_end_ms", "int"],
["nat_src", "inet"], ["nat_dst", "inet"], ["nat_src_port", "int"], ["nat_dst_port", "int"],
]},
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
+1
View File
@@ -113,6 +113,7 @@ async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
const result = await applyFlowOverlay(parsed.data.serverId, {
publicEndpoint: parsed.data.publicEndpoint,
requestHost: requestPublicHost(req),
disableGreFastPath: parsed.data.disableGreFastPath,
})
return reply.send(result)
} catch (e) {
@@ -388,8 +388,8 @@ try {
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]?.service, "YouTube")
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")
@@ -416,14 +416,44 @@ try {
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedRipeCacheForTests({
prefix: "74.125.0.0/16",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "104.18.0.0/16",
asn: 13335,
country: "US",
lat: null,
lng: null,
holder: "CLOUDFLARENET",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "146.75.0.0/16",
asn: 54113,
country: "US",
lat: null,
lng: null,
holder: "FASTLY",
ok: true,
fetchedAt: Date.now(),
})
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
ingestParsedFlowsForServerForTests(7, [
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
src: "74.125.104.196/32",
dst: "10.200.100.53/32",
proto: 17,
srcPort: 443,
dstPort: 57182,
dstPort: 62598,
bytes: 12_000,
packets: 10,
inIface: "2",
@@ -440,15 +470,40 @@ try {
inIface: "2",
outIface: "2",
},
{
src: "146.75.118.132/32",
dst: "10.200.100.53/32",
proto: 6,
srcPort: 80,
dstPort: 35026,
bytes: 4_000,
packets: 5,
inIface: "2",
outIface: "2",
},
])
try {
const rev = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
const google = rev.conversationsList.find((r) => r.src === "74.125.104.196")
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
assert.equal(google?.service, "Google")
assert.equal(google?.category, "Веб")
const fastly = rev.conversationsList.find((r) => r.src === "146.75.118.132")
assert.equal(google?.service, "YouTube")
assert.equal(google?.category, "Видео / стриминг")
assert.equal(google?.internetPeer, "74.125.104.196")
assert.equal(google?.internetPeerPort, 443)
assert.equal(google?.clientIp, "10.200.100.53")
assert.equal(google?.direction, "to_client")
assert.equal(google?.dstAsn, 15169)
assert.equal(google?.dstCountry, "US")
assert.ok(!String(google?.src).includes("/"), "DTO src без /32")
assert.equal(cf?.service, "Cloudflare")
assert.equal(cf?.category, "CDN")
assert.equal(fastly?.service, "Fastly")
assert.equal(fastly?.dstAsn, 54113)
assert.equal(fastly?.dstCountry, "US")
assert.ok(rev.asns?.some((r) => r.id === "54113"))
assert.ok(rev.countries?.some((r) => r.id === "US"))
assert.ok(!rev.services?.every((s) => s.label === "Прочее"), "сервисы не схлопнуты в Прочее")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
+12 -3
View File
@@ -260,9 +260,14 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
serverId: r.serverId,
inIface: resolved.name,
topo,
natSrc: r.natSrc,
natDst: r.natDst,
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
})
const ep = destMeta.endpoints
if (destMeta.dest) peers.add(destMeta.dest)
const app = applicationName(r.proto, r.dstPort, r.srcPort)
const app = applicationName(r.proto, ep.peerPort || r.dstPort, ep.otherPort || r.srcPort)
const ripe = destMeta.ripe
const classified = destMeta.classified
bump(applications, app, r.bytes, r.packets)
@@ -308,8 +313,8 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
conv.set(ckey, {
serverId: String(r.serverId),
serverName: nameById.get(r.serverId) ?? String(r.serverId),
src: r.src,
dst: r.dst,
src: ep.packetSrc || r.src,
dst: ep.packetDst || r.dst,
proto: r.proto,
protoName: protoName(r.proto),
srcPort: r.srcPort,
@@ -328,6 +333,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
dstAsn: ripe?.asn || undefined,
clientId: client?.userId,
clientName: client?.name,
clientIp: ep.clientIp || undefined,
internetPeer: ep.internetPeer || undefined,
internetPeerPort: ep.peerPort || undefined,
direction: ep.direction,
enId: en ? String(en.id) : undefined,
enName: en?.name,
plane,
@@ -8,6 +8,8 @@ import {
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
mapCountryNodeId,
mapCountryServiceNodeId,
resolveFlowBrand,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -32,6 +34,8 @@ 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("64.233.161.1", 0)?.service, "Google")
assert.equal(lookupBrand("142.250.1.10", 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, "Прочее")
@@ -41,6 +45,14 @@ assert.equal(isNamedInternetService("GRE", "Туннель"), false)
assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(mapServiceNodeId("Прочее"), "svc:other")
assert.equal(mapCountryNodeId("US"), "cc:us")
assert.equal(mapCountryNodeId("nl"), "cc:nl")
assert.equal(mapCountryNodeId(""), "cc:other")
assert.equal(mapCountryNodeId("Прочее"), "cc:other")
assert.equal(mapCountryNodeId("EU"), "cc:other")
assert.equal(mapCountryServiceNodeId("cc:us", "svc:google"), "cc:us|svc:google")
assert.equal(mapCountryServiceNodeId("cc:other", "svc:other"), "cc:other|svc:other")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
@@ -70,6 +82,39 @@ assert.equal(isSteamGamePort(6, 443, 50000), false)
assert.equal(resolveFlowBrand("104.18.35.51", 32590, "VALVE-CORPORATION", 6, 443, 1)?.service, "Cloudflare")
assert.equal(resolveFlowBrand("203.0.113.9", 32590, "", 17, 27015, 50000)?.service, "Steam")
assert.equal(resolveFlowBrand("8.8.8.8", 15169, "GOOGLE", 6, 443, 51234)?.service, "Google")
assert.equal(resolveFlowBrand("173.194.160.163", 15169, "GOOGLE", 6, 443, 51234)?.service, "YouTube")
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 17, 443, 50000)?.service, "YouTube")
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 6, 80, 50000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 53, 53000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 443, 50000)?.service, "YouTube")
assert.equal(brandByAsn(32934)?.service, "Meta")
assert.equal(lookupBrand("157.240.12.52", 0)?.service, "Meta")
assert.equal(lookupBrand("57.144.22.192", 0)?.service, "Meta")
assert.equal(brandByHolder("Instagram LLC")?.service, "Instagram")
assert.equal(mapServiceNodeId("Instagram"), "svc:instagram")
assert.equal(isNamedInternetService("Instagram", "Видео / стриминг"), true)
assert.equal(
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 443, 51234)?.service,
"Instagram",
"HTTPS на Meta front → Instagram, как YouTube на Google",
)
assert.equal(
resolveFlowBrand("57.144.22.192", 0, "", 17, 443, 50000)?.service,
"Instagram",
"cdninstagram CIDR :443 без ASN → Instagram",
)
assert.equal(
resolveFlowBrand("157.240.12.52", 32934, "FACEBOOK", 6, 80, 50000)?.service,
"Meta",
":80 на Meta остаётся Meta",
)
assert.equal(
resolveFlowBrand("157.240.1.1", 54115, "WHATSAPP", 6, 443, 1)?.service,
"Meta",
"AS54115 WhatsApp не становится Instagram",
)
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
+73 -4
View File
@@ -39,6 +39,7 @@ const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
const BEGET: BrandHit = { service: "Beget", ...CDN }
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
const META: BrandHit = { service: "Meta", ...CDN }
const INSTAGRAM: BrandHit = { service: "Instagram", ...VIDEO }
const GOOGLE: BrandHit = { service: "Google", ...WEB }
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
@@ -174,12 +175,37 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ 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: "64.233.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "66.102.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "66.249.64.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "72.14.192.0/18", prefixLen: 18, hit: GOOGLE },
{ cidr: "108.177.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "209.85.128.0/17", prefixLen: 17, hit: GOOGLE },
{ cidr: "216.58.192.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "216.239.32.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
{ cidr: "31.13.64.0/18", prefixLen: 18, hit: META },
{ cidr: "57.141.0.0/16", prefixLen: 16, hit: META },
{ cidr: "57.142.0.0/15", prefixLen: 15, hit: META },
{ cidr: "57.144.0.0/14", prefixLen: 14, hit: META },
{ cidr: "57.148.0.0/15", prefixLen: 15, hit: META },
{ cidr: "66.220.144.0/20", prefixLen: 20, hit: META },
{ cidr: "69.63.176.0/20", prefixLen: 20, hit: META },
{ cidr: "69.171.224.0/19", prefixLen: 19, hit: META },
{ cidr: "74.119.76.0/22", prefixLen: 22, hit: META },
{ cidr: "129.134.0.0/16", prefixLen: 16, hit: META },
{ cidr: "157.240.0.0/16", prefixLen: 16, hit: META },
{ cidr: "173.252.64.0/18", prefixLen: 18, hit: META },
{ cidr: "179.60.192.0/22", prefixLen: 22, hit: META },
{ cidr: "185.60.216.0/22", prefixLen: 22, hit: META },
{ cidr: "199.201.64.0/22", prefixLen: 22, hit: META },
{ cidr: "204.15.20.0/22", prefixLen: 22, hit: META },
].sort((a, b) => b.prefixLen - a.prefixLen)
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
{ re: /youtube/i, hit: YOUTUBE },
{ re: /instagram/i, hit: INSTAGRAM },
{ re: /valve|\bsteam\b/i, hit: STEAM },
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
{ re: /openai/i, hit: CHATGPT },
@@ -196,6 +222,19 @@ const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
const STEAM_ASN = 32590
const GOOGLE_FRONT_ASN = new Set([15169, 396982])
/** AS32934 / AS63293 — Meta front (Facebook + Instagram CDN). AS54115 — WhatsApp, не Instagram. */
const META_FRONT_ASN = new Set([32934, 63293])
const WHATSAPP_ASN = 54115
function isGooglePublicDns(ip: string): boolean {
return ipInCidrV4(ip, "8.8.8.0/24") || ipInCidrV4(ip, "8.8.4.0/24")
}
function isHttpsOrQuic(proto: number, dstPort: number, srcPort: number): boolean {
if (proto !== 6 && proto !== 17) return false
return dstPort === 443 || srcPort === 443
}
export function isIsoCountry(code: string): boolean {
const c = String(code ?? "").trim().toUpperCase()
@@ -256,9 +295,19 @@ export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
function isGoogleFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
return GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google"
}
function isInstagramFront(asn: number, cidrBrand: BrandHit | null, asnBrand: BrandHit | null): boolean {
if (asn === WHATSAPP_ASN) return false
return META_FRONT_ASN.has(asn) || cidrBrand?.service === "Meta" || asnBrand?.service === "Meta"
}
/**
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
* Holder (YouTube / Instagram и др.) бьёт остальные CIDR/ASN.
* HTTPS/QUIC на Google front YouTube (кроме 8.8.8.8); на Meta front Instagram (кроме WhatsApp ASN).
* Порты Steam только AS32590 и не выше Cloudflare CIDR.
*/
export function resolveFlowBrand(
@@ -273,7 +322,14 @@ export function resolveFlowBrand(
if (cidrBrand?.service === "Cloudflare") return cidrBrand
const holderBrand = brandByHolder(holder)
if (holderBrand) return holderBrand
const fromLookup = cidrBrand || brandByAsn(asn)
const asnBrand = brandByAsn(asn)
if (!isGooglePublicDns(ip) && isHttpsOrQuic(proto, dstPort, srcPort) && isGoogleFront(asn, cidrBrand, asnBrand)) {
return YOUTUBE
}
if (isHttpsOrQuic(proto, dstPort, srcPort) && isInstagramFront(asn, cidrBrand, asnBrand)) {
return INSTAGRAM
}
const fromLookup = cidrBrand || asnBrand
if (fromLookup) return fromLookup
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
return null
@@ -300,10 +356,23 @@ export function isNamedInternetService(service: string, category: string): boole
}
export function mapServiceNodeId(label: string): string {
const slug = label
.trim()
const raw = label.trim()
if (raw === OTHER_SERVICE) return "svc:other"
const slug = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}`
}
/** `US` → `cc:us`; неизвестная / пустая → `cc:other`. */
export function mapCountryNodeId(code: string): string {
const iso = normalizeIsoCountry(code)
if (!iso) return "cc:other"
return `cc:${iso.toLowerCase()}`
}
/** id сервиса внутри страны: `cc:us|svc:google` — Google в US и NL не смешиваются в одном payload. */
export function mapCountryServiceNodeId(countryId: string, serviceId: string): string {
return `${countryId}|${serviceId}`
}
@@ -33,10 +33,10 @@ const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
ok: true,
fetchedAt: Date.now(),
})
assert.equal(google.service, "Google")
assert.equal(google.category, "Веб")
assert.equal(google.service, "YouTube")
assert.equal(google.category, "Видео / стриминг")
const googleCidr = classifyFlowDst("173.194.151.65", 6, 57182, 443, null)
const googleCidr = classifyFlowDst("173.194.151.65", 6, 80, 50000, null)
assert.equal(googleCidr.service, "Google")
assert.equal(googleCidr.category, "Веб")
@@ -115,12 +115,90 @@ const googleCloud = classifyFlowDst("203.0.113.43", 6, 443, 1, {
ok: true,
fetchedAt: Date.now(),
})
assert.equal(googleCloud.service, "Google")
assert.equal(googleCloud.category, "Веб")
assert.equal(googleCloud.service, "YouTube")
assert.equal(googleCloud.category, "Видео / стриминг")
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
assert.equal(gre.service, "GRE")
assert.equal(gre.category, "Туннель")
const greIgnore = classifyFlowDst("8.8.8.8", 47, 0, 0, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
}, { ignoreTunnelProto: true })
assert.equal(greIgnore.service, "Google")
const dnsGoogle = classifyFlowDst("8.8.8.8", 17, 53, 53000, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(dnsGoogle.service, "Google")
assert.notEqual(dnsGoogle.service, "Прочее")
const ipv6Yt = classifyFlowDst("2001:4860:4860::8888", 17, 443, 50000, {
prefix: "2001:4860:4860::8888/128",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(ipv6Yt.service, "YouTube")
const instagram = classifyFlowDst("157.240.12.52", 6, 443, 62598, {
prefix: "157.240.0.0/16",
asn: 32934,
country: "US",
lat: null,
lng: null,
holder: "FACEBOOK",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(instagram.service, "Instagram")
assert.equal(instagram.category, "Видео / стриминг")
assert.notEqual(instagram.service, "Meta")
const metaHttp = classifyFlowDst("157.240.12.52", 6, 80, 50000, {
prefix: "157.240.0.0/16",
asn: 32934,
country: "US",
lat: null,
lng: null,
holder: "FACEBOOK",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(metaHttp.service, "Meta")
assert.equal(metaHttp.category, "CDN")
const igHolder = classifyFlowDst("203.0.113.80", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 64503,
country: "US",
lat: null,
lng: null,
holder: "Instagram LLC",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(igHolder.service, "Instagram")
const igCidr = classifyFlowDst("57.144.22.192", 17, 443, 50000, null)
assert.equal(igCidr.service, "Instagram")
assert.equal(igCidr.category, "Видео / стриминг")
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
assert.equal(esp.category, "Туннель")
assert.equal(applicationName(17, 443, 50000), "QUIC")
@@ -48,7 +48,7 @@ export function seedFlowCatalogForTests(input: {
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
const p = purpose.toLowerCase()
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
if (/streaming|youtube|netflix|twitch|video|spotify|instagram/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
if (/voip|discord|zoom/.test(p)) return "Голос"
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
@@ -73,9 +73,12 @@ export function classifyFlowDst(
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
opts?: { ignoreTunnelProto?: boolean },
): FlowClassification {
if (proto === 47) return { service: "GRE", category: "Туннель" }
if (proto === 50) return { service: "ESP", category: "Туннель" }
if (!opts?.ignoreTunnelProto) {
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)
+153 -6
View File
@@ -4,6 +4,7 @@ import {
resetEngineForTests,
} from "./traffic-flow-engine.js"
import { factsSnapshotForTests } from "./traffic-flow-facts.js"
import { classifyInternetBrand, mapInternetBrand, resolveInternetDest } from "./traffic-flow-dest.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
@@ -88,19 +89,165 @@ ingestParsedFlowsForServerForTests(1, [
inIface: "gre-client",
outIface: "ether1",
},
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 9_000,
packets: 90,
inIface: "NSK-SERVHOST-RTK",
outIface: "NSK-SERVHOST-RTK",
},
{
src: "10.200.100.53",
dst: "10.200.100.1",
proto: 6,
srcPort: 53880,
dstPort: 443,
bytes: 70,
packets: 1,
inIface: "gre-client",
outIface: "ether1",
natDst: "8.8.8.8",
natDstPort: 443,
},
])
const facts = factsSnapshotForTests()
const total = facts.reduce((s, r) => s + r.bytes, 0)
const asnBytes = facts.reduce((s, r) => s + r.bytes, 0)
assert.equal(total, 150)
assert.equal(asnBytes, 150, "unique bytes = SUM dest ASN")
assert.equal(total, 120, "unique = Google payload + NAT, без overlay/пустого dest")
assert.equal(facts.some((r) => r.asn === 12389), false, "ASN клиента не в кубе")
assert.equal(facts.some((r) => r.service === "GRE"), false, "GRE не сервис unique")
const google = facts.find((r) => r.asn === 15169)
assert.ok(google)
assert.equal(google.bytes, 50)
const other = facts.filter((r) => r.asn === 0).reduce((s, r) => s + r.bytes, 0)
assert.equal(other, 100)
assert.equal(google.bytes, 120)
assert.equal(facts.filter((r) => r.asn === 0).reduce((s, r) => s + r.bytes, 0), 0)
assert.equal(classifyInternetBrand("8.8.8.8", 47, 0, 0, null), null, "GRE не бренд")
assert.equal(classifyInternetBrand("8.8.8.8", 6, 443, 51234, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})?.service, "Google")
assert.equal(mapInternetBrand("203.0.113.50", 6, 443, 51234, null).service, "Прочее")
assert.equal(mapInternetBrand("8.8.8.8", 47, 0, 0, null).service, "Прочее")
assert.equal(mapInternetBrand("8.8.8.8", 6, 443, 51234, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
}).service, "Google")
assert.equal(classifyInternetBrand("8.8.8.8", 17, 53, 53000, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})?.service, "Google")
assert.equal(mapInternetBrand("8.8.8.8", 17, 53, 53000, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
}).service, "Google")
assert.equal(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, {
prefix: "2001:4860:4860::8888/128",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
}).service, "YouTube")
assert.equal(mapInternetBrand("64.233.161.1", 17, 443, 50000, null).service, "YouTube")
assert.equal(mapInternetBrand("142.250.1.10", 6, 443, 1, null).service, "YouTube")
{
const meta = resolveInternetDest({
src: "74.125.104.196/32",
dst: "10.200.100.53/32",
proto: 17,
srcPort: 443,
dstPort: 62598,
serverId: 1,
inIface: "gre-client",
topo,
})
assert.equal(meta.dest, "74.125.104.196")
assert.equal(meta.classified.service, "YouTube")
assert.notEqual(meta.classified.service, "Прочее")
assert.equal(meta.endpoints.direction, "to_client")
assert.equal(meta.endpoints.clientIp, "10.200.100.53")
assert.equal(meta.asn, 0)
}
{
seedRipeCacheForTests({
prefix: "146.75.0.0/16",
asn: 54113,
country: "US",
lat: null,
lng: null,
holder: "FASTLY",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "3.174.0.0/16",
asn: 16509,
country: "US",
lat: null,
lng: null,
holder: "AMAZON-AES",
ok: true,
fetchedAt: Date.now(),
})
const fastly = resolveInternetDest({
src: "146.75.118.132/32",
dst: "10.200.100.53/32",
proto: 6,
srcPort: 80,
dstPort: 35026,
serverId: 1,
inIface: "gre-client",
topo,
})
assert.equal(fastly.classified.service, "Fastly")
assert.equal(fastly.asn, 54113)
assert.equal(fastly.country, "US")
const aws = resolveInternetDest({
src: "3.174.2.35/32",
dst: "10.200.100.53/32",
proto: 6,
srcPort: 443,
dstPort: 43726,
serverId: 1,
inIface: "gre-client",
topo,
})
assert.equal(aws.classified.service, "AWS")
assert.equal(aws.asn, 16509)
}
resetEngineForTests()
seedFlowTopologyForTests(null)
+85 -14
View File
@@ -1,8 +1,13 @@
import { isIsoCountry } from "./traffic-flow-brands.js"
import { applicationName } from "./traffic-flow-apps.js"
import { isIsoCountry, isNamedInternetService, OTHER_SERVICE, resolveFlowBrand } from "./traffic-flow-brands.js"
import { classifyFlowDst, type FlowClassification } from "./traffic-flow-classify.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { pickInternetDest, type InternetDestCtx } from "./traffic-flow-ip.js"
import {
resolveFlowEndpoints,
type FlowEndpoints,
type InternetDestCtx,
} from "./traffic-flow-ip.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import {
flowOursHosts,
@@ -16,20 +21,68 @@ export interface InternetDestMeta {
classified: FlowClassification
country: string
asn: number
endpoints: FlowEndpoints
}
export function destCtxForIface(
topo: FlowTopology | null | undefined,
serverId: number,
inIface: string,
nat?: Pick<InternetDestCtx, "natSrc" | "natDst" | "natSrcPort" | "natDstPort">,
): InternetDestCtx {
const name = canonicalFactIface(serverId, inIface) || String(inIface ?? "").trim()
return {
ours: flowOursHosts(topo),
boundClient: Boolean(topo && name && resolveClient(topo, serverId, name)),
boundClient: Boolean(
topo && name && (
resolveClient(topo, serverId, name)
|| topo.clientIfaces.get(serverId)?.has(name)
),
),
natSrc: nat?.natSrc,
natDst: nat?.natDst,
natSrcPort: nat?.natSrcPort,
natDstPort: nat?.natDstPort,
}
}
const OTHER_BRAND: FlowClassification = { service: OTHER_SERVICE, category: OTHER_SERVICE }
function isTunnelProto(proto: number, dstPort: number, srcPort: number): boolean {
if (proto === 47 || proto === 50) return true
return applicationName(proto, dstPort, srcPort) === "WireGuard"
}
/** Бренд интернет-dest: ASN/CIDR до skip DNS. GRE/ESP/WG — не сервис. */
export function classifyInternetBrand(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): FlowClassification | null {
if (isTunnelProto(proto, dstPort, srcPort)) return null
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (brand && isNamedInternetService(brand.service, brand.category)) return brand
const app = applicationName(proto, dstPort, srcPort)
if (app === "DNS" || app === "SSH" || app === "BGP") return null
return null
}
/** Тот же классификатор, что аналитика (GeoLite2 ASN + catalog). Туннель → Прочее. */
export function mapInternetBrand(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): FlowClassification {
if (isTunnelProto(proto, dstPort, srcPort)) return OTHER_BRAND
const classified = classifyFlowDst(dst, proto, dstPort, srcPort, ripe, { ignoreTunnelProto: true })
if (isNamedInternetService(classified.service, classified.category)) return classified
return OTHER_BRAND
}
export function resolveInternetDest(opts: {
src: string
dst: string
@@ -39,22 +92,40 @@ export function resolveInternetDest(opts: {
serverId: number
inIface: string
topo?: FlowTopology | null
natSrc?: string
natDst?: string
natSrcPort?: number
natDstPort?: number
}): InternetDestMeta {
const dest = pickInternetDest(
opts.src,
opts.dst,
opts.srcPort,
opts.dstPort,
destCtxForIface(opts.topo, opts.serverId, opts.inIface),
)
const ripe = dest ? resolveFlowIp(dest) : null
const classified = classifyFlowDst(dest || opts.dst, opts.proto, opts.dstPort, opts.srcPort, ripe)
const ctx = destCtxForIface(opts.topo, opts.serverId, opts.inIface, {
natSrc: opts.natSrc,
natDst: opts.natDst,
natSrcPort: opts.natSrcPort,
natDstPort: opts.natDstPort,
})
const endpoints = resolveFlowEndpoints({
src: opts.src,
dst: opts.dst,
srcPort: opts.srcPort,
dstPort: opts.dstPort,
ctx,
})
const dest = endpoints.internetPeer
if (!dest) {
return { dest: "", ripe: null, classified, country: "", asn: 0 }
return { dest: "", ripe: null, classified: OTHER_BRAND, country: "", asn: 0, endpoints }
}
const ripe = resolveFlowIp(dest)
const classified = classifyFlowDst(
dest,
opts.proto,
endpoints.peerPort,
endpoints.otherPort,
ripe,
{ ignoreTunnelProto: true },
)
const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country
: (ripe?.ok ? "" : "unknown")
const asn = ripe?.ok && ripe.asn ? ripe.asn : 0
return { dest, ripe, classified, country, asn }
return { dest, ripe, classified, country, asn, endpoints }
}
+73 -12
View File
@@ -9,6 +9,7 @@ import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
import { canonicalIp } from "./traffic-flow-ip.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import {
getServerCatalog,
@@ -61,15 +62,19 @@ export interface PendingFlowRow {
nextHop: string
flowStartMs: number
flowEndMs: number
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}
function inetOrNull(value: string | null | undefined): string | null {
const s = String(value ?? "").trim()
const s = canonicalIp(value)
return s.length > 0 ? s : null
}
export function isValidFlowInet(value: string): boolean {
const s = value.trim()
const s = canonicalIp(value)
if (!s) return false
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
if (v4) {
@@ -90,17 +95,32 @@ function clampProto(n: number): number {
return Math.max(0, Math.min(255, Math.trunc(n)))
}
function clampPort(n: number): number {
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(65535, Math.trunc(n)))
}
function sanitizeNatIp(value: string | null | undefined): string {
const s = canonicalIp(value)
if (!s || s === "0.0.0.0") return ""
return isValidFlowInet(s) ? s : ""
}
function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
const src = (r.src || "").trim() || "0.0.0.0"
const dst = (r.dst || "").trim() || "0.0.0.0"
const src = canonicalIp(r.src) || "0.0.0.0"
const dst = canonicalIp(r.dst) || "0.0.0.0"
if (!isValidFlowInet(src) || !isValidFlowInet(dst)) return null
const next = inetOrNull(r.nextHop)
const next = inetOrNull(canonicalIp(r.nextHop))
return {
...r,
src,
dst,
nextHop: next && isValidFlowInet(next) ? next : "",
proto: clampProto(r.proto),
natSrc: sanitizeNatIp(r.natSrc),
natDst: sanitizeNatIp(r.natDst),
natSrcPort: clampPort(r.natSrcPort),
natDstPort: clampPort(r.natDstPort),
}
}
@@ -120,6 +140,10 @@ function flowUpsertParams(r: PendingFlowRow) {
nextHop: inetOrNull(r.nextHop),
flowStartMs: r.flowStartMs,
flowEndMs: r.flowEndMs,
natSrc: inetOrNull(r.natSrc),
natDst: inetOrNull(r.natDst),
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
}
}
@@ -360,6 +384,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
serverId,
inIface: flow.inIface,
topo,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
})
const ripe = destMeta.ripe
if (destMeta.dest && !ripe) ripeMisses.push(destMeta.dest)
@@ -385,6 +413,11 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
src: flow.src,
dst: flow.dst,
topo,
dest: destMeta.dest,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
})) {
bumpFlowFact({
serverId,
@@ -405,6 +438,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
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.natSrc && !prev.flow.natSrc) prev.flow.natSrc = flow.natSrc
if (flow.natDst && !prev.flow.natDst) prev.flow.natDst = flow.natDst
if (flow.natSrcPort && !prev.flow.natSrcPort) prev.flow.natSrcPort = flow.natSrcPort
if (flow.natDstPort && !prev.flow.natDstPort) prev.flow.natDstPort = flow.natDstPort
if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) {
prev.flow.flowStartMs = flow.flowStartMs
}
@@ -460,6 +497,10 @@ function toPendingRow(row: PendingEntry): PendingFlowRow {
nextHop: flow.nextHop,
flowStartMs: flow.flowStartMs,
flowEndMs: flow.flowEndMs,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
}
}
@@ -471,6 +512,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
if (row.natSrc && !prev.natSrc) prev.natSrc = row.natSrc
if (row.natDst && !prev.natDst) prev.natDst = row.natDst
if (row.natSrcPort && !prev.natSrcPort) prev.natSrcPort = row.natSrcPort
if (row.natDstPort && !prev.natDstPort) prev.natDstPort = row.natDstPort
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return
@@ -779,7 +824,7 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
await pool.query({
text: `
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
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, nat_src, nat_dst, nat_src_port, nat_dst_port
)
SELECT *
FROM UNNEST(
@@ -796,8 +841,12 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
$11::text[],
$12::inet[],
$13::bigint[],
$14::bigint[]
) AS t(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)
$14::bigint[],
$15::inet[],
$16::inet[],
$17::int[],
$18::int[]
) AS t(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, nat_src, nat_dst, nat_src_port, nat_dst_port)
ON CONFLICT (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET
bytes = flow_buckets.bytes + excluded.bytes,
@@ -807,7 +856,11 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
flow_start_ms = CASE
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms),
nat_src = COALESCE(excluded.nat_src, flow_buckets.nat_src),
nat_dst = COALESCE(excluded.nat_dst, flow_buckets.nat_dst),
nat_src_port = CASE WHEN excluded.nat_src_port > 0 THEN excluded.nat_src_port ELSE flow_buckets.nat_src_port END,
nat_dst_port = CASE WHEN excluded.nat_dst_port > 0 THEN excluded.nat_dst_port ELSE flow_buckets.nat_dst_port END
`,
values: [
rows.map((r) => r.serverId),
@@ -824,15 +877,19 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
rows.map((r) => inetOrNull(r.nextHop)),
rows.map((r) => r.flowStartMs),
rows.map((r) => r.flowEndMs),
rows.map((r) => inetOrNull(r.natSrc)),
rows.map((r) => inetOrNull(r.natDst)),
rows.map((r) => r.natSrcPort),
rows.map((r) => r.natDstPort),
],
})
}
const FLOW_UPSERT_SQL = `
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
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, nat_src, nat_dst, nat_src_port, nat_dst_port
) VALUES (
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs, @natSrc, @natDst, @natSrcPort, @natDstPort
)
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET
@@ -843,7 +900,11 @@ const FLOW_UPSERT_SQL = `
flow_start_ms = CASE
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms),
nat_src = COALESCE(excluded.nat_src, flow_buckets.nat_src),
nat_dst = COALESCE(excluded.nat_dst, flow_buckets.nat_dst),
nat_src_port = CASE WHEN excluded.nat_src_port > 0 THEN excluded.nat_src_port ELSE flow_buckets.nat_src_port END,
nat_dst_port = CASE WHEN excluded.nat_dst_port > 0 THEN excluded.nat_dst_port ELSE flow_buckets.nat_dst_port END
`
async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
@@ -145,6 +145,63 @@ const enWan = shouldWriteFlowFact({
})
assert.equal(enWan, true, "WAN payload на EN — да")
const emptyDest = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "gre-client",
outIface: "ether1",
proto: 6,
srcPort: 51234,
dstPort: 443,
src: "95.167.1.10",
dst: "10.200.100.53",
topo: topo(),
})
assert.equal(emptyDest, false, "пустой интернет-dest не в facts")
const jhToEnHosts = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "ether1",
proto: 6,
srcPort: 0,
dstPort: 0,
src: "203.0.113.10",
dst: "198.51.100.1",
topo: topo(),
})
assert.equal(jhToEnHosts, false, "JH↔EN hosts не dest")
const overlayNamed = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "NSK-SERVHOST-RTK",
outIface: "NSK-SERVHOST-RTK",
proto: 47,
srcPort: 0,
dstPort: 0,
src: "203.0.113.10",
dst: "198.51.100.1",
topo: typed,
})
assert.equal(overlayNamed, false, "overlay proto 47 на NSK-SERVHOST-RTK не в facts")
const natPayload = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "gre-client",
outIface: "ether1",
proto: 6,
srcPort: 53880,
dstPort: 443,
src: "10.200.100.53",
dst: "10.200.100.1",
natDst: "8.8.8.8",
natDstPort: 443,
topo: topo(),
})
assert.equal(natPayload, true, "NAT Google на client GRE — да")
seedFlowTopologyForTests(null)
resetIfaceCacheForTests()
console.log("traffic-flow-facts-filter.test.ts: ok")
@@ -1,8 +1,10 @@
import { mapRosInterfaceType } from "../modules/users/iface-type.js"
import { STATISTICS_DUP_MARK, STATISTICS_WAN_MARK } from "@mmapp/contracts/statistics"
import { destCtxForIface } from "./traffic-flow-dest.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { classifyFlowPlane } from "./traffic-flow-planes.js"
import { resolveClient, type FlowTopology } from "./traffic-flow-topology.js"
import { pickInternetDest, isLocalIp } from "./traffic-flow-ip.js"
import { classifyFlowPlane, isTunnelProto } from "./traffic-flow-planes.js"
import { flowOursHosts, resolveClient, type FlowTopology } from "./traffic-flow-topology.js"
const JUNK_IFACE = new Set(["", "0", "—", "__unknown__", "wg-flow"])
@@ -82,6 +84,11 @@ export function shouldWriteFlowFact(opts: {
src: string
dst: string
topo?: FlowTopology | null
dest?: string
natSrc?: string
natDst?: string
natSrcPort?: number
natDstPort?: number
}): boolean {
const inName = canonicalFactIface(opts.serverId, opts.inIface) || String(opts.inIface ?? "").trim()
if (isJunkFactIface(inName) || isJunkFactIface(opts.inIface)) return false
@@ -96,7 +103,25 @@ export function shouldWriteFlowFact(opts: {
inIface: inName,
outIface: outName || undefined,
}, opts.topo?.plane)
if (plane !== "payload") return false
if (plane === "mgmt") return false
if (plane === "overlay" || isTunnelProto(opts.proto, opts.srcPort, opts.dstPort)) return false
const dest = opts.dest !== undefined
? opts.dest
: pickInternetDest(
opts.src,
opts.dst,
opts.srcPort,
opts.dstPort,
destCtxForIface(opts.topo, opts.serverId, inName, {
natSrc: opts.natSrc,
natDst: opts.natDst,
natSrcPort: opts.natSrcPort,
natDstPort: opts.natDstPort,
}),
)
if (!dest) return false
const ours = flowOursHosts(opts.topo)
if (isLocalIp(dest, ours) || ours.has(dest)) return false
if (opts.serverType === "exit-node" && opts.topo) {
const client =
resolveClient(opts.topo, opts.serverId, inName)
@@ -3,6 +3,7 @@ import { dbQuery } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { pool } from "../db/index.js"
import { applySqlMigrations } from "../db/migrate.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import {
disableRipeEnqueueForTests,
@@ -19,6 +20,8 @@ if (!(await withPgOrSkip())) {
process.exit(0)
}
await applySqlMigrations(pool)
const nServers = (await dbQuery<{ n: number }>(`SELECT COUNT(*)::int AS n FROM servers`)).rows[0]?.n ?? 0
if (nServers > 10) {
console.warn("traffic-flow-facts-rebuild.test.ts: skip (не пустая БД)")
@@ -119,10 +122,10 @@ try {
`, [serverId])
const byAsn = new Map(rows.rows.map((r) => [Number(r.asn), Number(r.bytes)]))
const total = [...byAsn.values()].reduce((s, n) => s + n, 0)
assert.equal(total, 150)
assert.equal(total, 50)
assert.equal(byAsn.get(12389), undefined, "ASN клиента не в hour facts")
assert.equal(byAsn.get(15169), 50)
assert.equal(byAsn.get(0), 100)
assert.equal(byAsn.get(0), undefined)
} finally {
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
@@ -69,10 +69,16 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
packets: number
inIface: string
outIface: string
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}>(`
SELECT server_id AS "serverId", bucket_at AS "bucketAt",
host(src) AS src, host(dst) AS dst, proto, src_port AS "srcPort", dst_port AS "dstPort",
bytes, packets, in_iface AS "inIface", COALESCE(out_iface, '') AS "outIface"
bytes, packets, in_iface AS "inIface", COALESCE(out_iface, '') AS "outIface",
COALESCE(host(nat_src), '') AS "natSrc", COALESCE(host(nat_dst), '') AS "natDst",
COALESCE(nat_src_port, 0) AS "natSrcPort", COALESCE(nat_dst_port, 0) AS "natDstPort"
FROM flow_buckets
ORDER BY bucket_at, server_id
LIMIT ? OFFSET ?
@@ -81,6 +87,20 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
for (const row of rows) {
buckets += 1
const serverType = catalog.byId.get(row.serverId)?.type
const destMeta = resolveInternetDest({
src: row.src,
dst: row.dst,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
serverId: row.serverId,
inIface: row.inIface,
topo,
natSrc: row.natSrc,
natDst: row.natDst,
natSrcPort: Number(row.natSrcPort) || 0,
natDstPort: Number(row.natDstPort) || 0,
})
if (!shouldWriteFlowFact({
serverId: row.serverId,
serverType,
@@ -92,17 +112,12 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
src: row.src,
dst: row.dst,
topo,
dest: destMeta.dest,
natSrc: row.natSrc,
natDst: row.natDst,
natSrcPort: Number(row.natSrcPort) || 0,
natDstPort: Number(row.natDstPort) || 0,
})) continue
const destMeta = resolveInternetDest({
src: row.src,
dst: row.dst,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
serverId: row.serverId,
inIface: row.inIface,
topo,
})
bumpFlowFact({
serverId: row.serverId,
bucketAt: hourFromBucket(row.bucketAt),
@@ -21,6 +21,7 @@ import {
minuteDimsSnapshotForTests,
} from "./traffic-flow-engine.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { mapInternetBrand } from "./traffic-flow-dest.js"
import { disableGeoipDbForTests } from "./geoip-settings.js"
import {
collectGeoipUpdateOnce,
@@ -89,6 +90,7 @@ setGeoipReadersForTests({
const hit = resolveFlowIp("8.8.8.8")
assert.equal(hit?.country, "US")
assert.equal(hit?.asn, 15169)
assert.equal(resolveFlowIp("8.8.8.8/32")?.asn, 15169, "GeoIP по inet::text /32")
assert.equal(hit?.holder, "GOOGLE")
assert.equal(hit?.ok, true)
@@ -102,6 +104,21 @@ assert.equal(lookupGeoip("6.6.6.6")?.country, "US")
const classified = classifyFlowDst("8.8.8.8", 6, 443, 51504, hit)
assert.equal(classified.service, "Google")
setGeoipReadersForTests({
country: fakeCountryReader({ "8.8.8.8": "US", "2001:4860:4860::8888": "US" }),
asn: fakeAsnReader({
"8.8.8.8": { asn: 15169, org: "GOOGLE" },
"2001:4860:4860::8888": { asn: 15169, org: "GOOGLE" },
"64.233.161.1": { asn: 15169, org: "GOOGLE" },
}),
})
const v6meta = resolveFlowIp("2001:4860:4860::8888")
assert.equal(v6meta?.asn, 15169)
assert.equal(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, v6meta).service, "YouTube")
assert.notEqual(mapInternetBrand("2001:4860:4860::8888", 17, 443, 50000, v6meta).service, "Прочее")
const cidrYt = mapInternetBrand("64.233.161.1", 17, 443, 50000, resolveFlowIp("64.233.161.1"))
assert.equal(cidrYt.service, "YouTube")
// ── движок: dims country/asn наполняются из geoip-ридеров ────────────────────
resetEngineForTests()
ingestParsedFlowsForServerForTests(1, [{
+2 -2
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { open, type AsnResponse, type CountryResponse, type Reader } from "maxmind"
import { isNonPublicIp } from "./traffic-flow-ip.js"
import { canonicalIp, isNonPublicIp } from "./traffic-flow-ip.js"
import { isIsoCountry, resolveRipeCountry } from "./traffic-flow-brands.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
@@ -117,7 +117,7 @@ function safeAsn(reader: Reader<AsnResponse>, ip: string): { asn: number; holder
* (ok=true когда есть страна или ASN; null данных нет, пусть пробует RIPE).
*/
export function lookupGeoip(ip: string): FlowIpMeta | null {
const trimmed = String(ip ?? "").trim()
const trimmed = canonicalIp(ip)
if (!trimmed) return null
if (isNonPublicIp(trimmed)) return negativeMeta(trimmed)
const { country: countryReader, asn: asnReader } = readers
@@ -15,6 +15,7 @@ import {
lastFlushUsedTransactionForTests,
maybeRefreshIfaces,
peekPendingFlows,
capFlowRowsPerServerBucket,
resetFlowRingsForTests,
setPendingCapForTests,
setRefreshIfacesForTests,
@@ -71,6 +72,7 @@ setPendingCapForTests(null)
assert.equal(isValidFlowInet("10.0.0.1"), true)
assert.equal(isValidFlowInet("8.8.8.8"), true)
assert.equal(isValidFlowInet("8.8.8.8/32"), true)
assert.equal(isValidFlowInet("0:0:0:0:0:0:0:1"), true)
assert.equal(isValidFlowInet("not-an-ip"), false)
assert.equal(isValidFlowInet("999.1.1.1"), false)
@@ -146,4 +148,20 @@ resetFlowRingsForTests()
resetIfaceCacheForTests()
setRefreshIfacesForTests(null)
{
const minute0 = "2026-01-01T00:00:00.000Z"
const minute1 = "2026-01-01T00:01:00.000Z"
const rows: Array<{ serverId: number; bucketAt: string; bytes: number; id: string }> = []
for (let i = 1; i <= 25; i++) {
rows.push({ serverId: 1, bucketAt: minute0, bytes: i, id: `a${i}` })
rows.push({ serverId: 2, bucketAt: minute0, bytes: i, id: `b${i}` })
}
rows.push({ serverId: 1, bucketAt: minute1, bytes: 1, id: "a-min-other-minute" })
const capped = capFlowRowsPerServerBucket(rows, 20)
assert.equal(capped.filter((r) => r.serverId === 1 && r.bucketAt === minute0).length, 20)
assert.equal(capped.filter((r) => r.serverId === 2).length, 20)
assert.ok(capped.some((r) => r.id === "a-min-other-minute"), "другая минута не режется глобальным top-N")
assert.ok(!capped.some((r) => r.id === "a1" || r.id === "b1"), "мелкие 5-tuple сервера выпадают только в своём bucket")
}
console.log("traffic-flow-ingest.test.ts: ok")
+90 -23
View File
@@ -1,8 +1,7 @@
import { Worker } from "node:worker_threads"
import { gte, sql } from "drizzle-orm"
import { db, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
import { db, dbAll, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
import { dropExpiredPartitions } from "../db/partitions.js"
import { flowBuckets, servers } from "../db/schema.js"
import { servers } from "../db/schema.js"
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
@@ -30,6 +29,7 @@ import {
} from "./traffic-flow-settings.js"
import { applicationName } from "./traffic-flow-apps.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { canonicalIp } from "./traffic-flow-ip.js"
import { getServerCatalog } from "./traffic-flow-topology.js"
export type { PendingFlowRow }
@@ -288,6 +288,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
if (row.natSrc && !prev.natSrc) prev.natSrc = row.natSrc
if (row.natDst && !prev.natDst) prev.natDst = row.natDst
if (row.natSrcPort && !prev.natSrcPort) prev.natSrcPort = row.natSrcPort
if (row.natDstPort && !prev.natDstPort) prev.natDstPort = row.natDstPort
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return
@@ -295,6 +299,32 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
map.set(key, { ...row })
}
/** Top-N разговоров на (server_id, bucket_at), как prune persist — не глобальный ORDER BY bytes. */
export function capFlowRowsPerServerBucket<T extends { serverId: number; bucketAt: string; bytes: number }>(
rows: T[],
keep: number,
): T[] {
const cap = Math.max(20, keep)
const groups = new Map<string, T[]>()
for (const row of rows) {
const k = `${row.serverId}\0${row.bucketAt}`
const list = groups.get(k)
if (list) list.push(row)
else groups.set(k, [row])
}
const out: T[] = []
for (const list of groups.values()) {
list.sort((a, b) => b.bytes - a.bytes)
out.push(...list.slice(0, cap))
}
return out
}
function isoBucketAt(v: unknown): string {
if (v instanceof Date) return v.toISOString()
return String(v ?? "")
}
export async function listLiveFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
if (worker && lastHeartbeat?.workerAlive) {
return await listStoredFlowRows(sinceIso)
@@ -302,30 +332,67 @@ export async function listLiveFlowRows(sinceIso: string): Promise<PendingFlowRow
return engineListLive(sinceIso)
}
interface StoredBucketRow {
server_id: number
bucket_at: string | Date
src: string
dst: string
proto: number
src_port: number
dst_port: number
bytes: number
packets: number
in_iface: string
out_iface: string | null
next_hop: string | null
flow_start_ms: number | null
flow_end_ms: number | null
nat_src: string | null
nat_dst: string | null
nat_src_port: number | null
nat_dst_port: number | null
}
export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
const settings = await getTrafficFlowSettingsRow()
const cap = Math.max(20, settings.topN) * 60
const stored = await db.select().from(flowBuckets)
.where(gte(flowBuckets.bucketAt, sinceIso))
.orderBy(sql`${flowBuckets.bytes} DESC`)
.limit(cap)
const keep = Math.max(20, settings.topN)
const stored = await dbAll<StoredBucketRow>(`
SELECT
server_id, bucket_at, COALESCE(host(src), '') AS src, COALESCE(host(dst), '') AS dst, proto,
src_port, dst_port, bytes, packets, in_iface, out_iface,
COALESCE(host(next_hop), '') AS next_hop, flow_start_ms, flow_end_ms,
COALESCE(host(nat_src), '') AS nat_src, COALESCE(host(nat_dst), '') AS nat_dst, nat_src_port, nat_dst_port
FROM (
SELECT fb.*,
ROW_NUMBER() OVER (
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
) AS rn
FROM flow_buckets fb
WHERE bucket_at >= $1
) ranked
WHERE rn <= $2
`, [sinceIso, keep])
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,
serverId: Number(r.server_id),
bucketAt: isoBucketAt(r.bucket_at),
src: canonicalIp(r.src),
dst: canonicalIp(r.dst),
proto: Number(r.proto) || 0,
srcPort: Number(r.src_port) || 0,
dstPort: Number(r.dst_port) || 0,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
inIface: r.in_iface ?? "",
outIface: r.out_iface ?? "",
nextHop: canonicalIp(r.next_hop ?? ""),
flowStartMs: Number(r.flow_start_ms) || 0,
flowEndMs: Number(r.flow_end_ms) || 0,
natSrc: canonicalIp(r.nat_src ?? ""),
natDst: canonicalIp(r.nat_dst ?? ""),
natSrcPort: Number(r.nat_src_port) || 0,
natDstPort: Number(r.nat_dst_port) || 0,
})
}
if (!worker) {
@@ -334,7 +401,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
mergeInto(merged, p)
}
}
return [...merged.values()]
return capFlowRowsPerServerBucket([...merged.values()], keep)
}
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
+99 -1
View File
@@ -1,14 +1,33 @@
import assert from "node:assert/strict"
import { isNonPublicIp, pickInternetDest, pickInternetPeer } from "./traffic-flow-ip.js"
import {
canonicalIp,
isNonPublicIp,
pickInternetDest,
pickInternetPeer,
pickMapInternetDest,
resolveFlowEndpoints,
} from "./traffic-flow-ip.js"
assert.equal(canonicalIp("74.125.104.196/32"), "74.125.104.196")
assert.equal(canonicalIp("10.200.100.53/32"), "10.200.100.53")
assert.equal(canonicalIp("::ffff:8.8.8.8"), "8.8.8.8")
assert.equal(canonicalIp("2001:4860:4860::8888/128"), "2001:4860:4860::8888")
assert.equal(isNonPublicIp("10.200.100.53"), true)
assert.equal(isNonPublicIp("10.200.100.53/32"), true)
assert.equal(isNonPublicIp("173.194.151.65"), false)
assert.equal(isNonPublicIp("74.125.104.196/32"), false, "PG inet::text не делает Google приватным")
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("74.125.104.196/32", "10.200.100.53/32", 443, 62598),
"74.125.104.196",
"inet::text /32 reverse Google",
)
assert.equal(
pickInternetPeer("10.200.100.53", "104.18.35.51", 53880, 443),
"104.18.35.51",
@@ -56,5 +75,84 @@ assert.equal(
"8.8.8.8",
"ours как dst: dest = публичный src",
)
assert.equal(
pickInternetDest("203.0.113.10", "198.51.100.1", 0, 0, { ours }),
"",
"JH ours → EN ours: dest нет",
)
assert.equal(
pickInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
...client,
natDst: "8.8.8.8",
natDstPort: 443,
}),
"8.8.8.8",
"RFC1918 + NAT Google",
)
assert.equal(
pickInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
...client,
natDst: "0.0.0.0",
}),
"",
"NAT 0.0.0.0 не dest",
)
assert.equal(
pickMapInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
...client,
natDst: "8.8.8.8",
natDstPort: 443,
}),
"8.8.8.8",
"карта: RFC1918 + NAT Google",
)
assert.equal(
pickMapInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, client),
"",
"карта: RFC1918 без NAT → Прочее",
)
assert.equal(
pickInternetDest("173.194.151.65", "10.200.100.53", 12345, 57182, client),
"173.194.151.65",
"реверс googlevideo не :443 — публичный src",
)
assert.equal(
pickMapInternetDest("173.194.151.65", "10.200.100.53", 12345, 57182, client),
"173.194.151.65",
"карта: реверс googlevideo не :443 — всё равно публичный src",
)
assert.equal(
pickMapInternetDest("203.0.113.10", "198.51.100.1", 0, 0, { ours }),
"",
"карта: JH ours → EN ours всё ещё не dest",
)
{
const ep = resolveFlowEndpoints({
src: "74.125.104.196/32",
dst: "10.200.100.53/32",
srcPort: 443,
dstPort: 62598,
})
assert.equal(ep.internetPeer, "74.125.104.196")
assert.equal(ep.peerPort, 443)
assert.equal(ep.clientIp, "10.200.100.53")
assert.equal(ep.direction, "to_client")
assert.equal(ep.packetSrc, "74.125.104.196")
assert.equal(ep.packetDst, "10.200.100.53")
}
{
const ep = resolveFlowEndpoints({
src: "10.200.100.53",
dst: "104.18.35.51",
srcPort: 53880,
dstPort: 443,
})
assert.equal(ep.internetPeer, "104.18.35.51")
assert.equal(ep.peerPort, 443)
assert.equal(ep.clientIp, "10.200.100.53")
assert.equal(ep.direction, "from_client")
}
console.log("traffic-flow-ip.test.ts: ok")
+163 -27
View File
@@ -1,7 +1,25 @@
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
/**
* Host-семантика PostgreSQL `host(inet)`: снимает `/32` `/128`, `::ffff:`.
* IPFIX 5-tuple не меняем только канонический вид адреса.
*/
export function canonicalIp(raw: string | undefined | null): string {
let t = String(raw ?? "").trim()
if (!t) return ""
const zone = t.indexOf("%")
if (zone >= 0) t = t.slice(0, zone)
if (t.toLowerCase().startsWith("::ffff:")) t = t.slice(7)
const slash = t.lastIndexOf("/")
if (slash >= 0) {
const plen = t.slice(slash + 1)
if (/^\d+$/.test(plen)) t = t.slice(0, slash)
}
return t.trim()
}
export function ipv4ToInt(ip: string): number | null {
const parts = String(ip ?? "").trim().split(".")
const parts = canonicalIp(ip).split(".")
if (parts.length !== 4) return null
let n = 0
for (const p of parts) {
@@ -26,12 +44,12 @@ export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLe
export function ipInCidrV4(ip: string, cidr: string): boolean {
const addr = ipv4ToInt(ip)
const parsed = parseCidrV4(cidr)
if (addr == null || !parsed) return false
if (addr == null || parsed == null) return false
return ((addr & parsed.mask) >>> 0) === parsed.net
}
export function isNonPublicIp(ip: string): boolean {
const trimmed = String(ip ?? "").trim()
const trimmed = canonicalIp(ip)
if (!trimmed) return true
if (trimmed.includes(":")) {
const lower = trimmed.toLowerCase()
@@ -55,61 +73,179 @@ export function isNonPublicIp(ip: string): boolean {
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
export function isUnspecifiedIp(ip: string): boolean {
const t = canonicalIp(ip)
if (!t) return true
const lower = t.toLowerCase()
return t === "0.0.0.0" || lower === "::" || lower === "::0"
}
function usableIp(ip: string | undefined): string {
const t = canonicalIp(ip)
return isUnspecifiedIp(t) ? "" : t
}
export interface InternetDestCtx {
/** WAN IP узлов сети (EN/JH) — не интернет-назначение. */
ours?: ReadonlySet<string>
/** Ingress с bound GRE/WG клиента: dest = нелокальный IP, не ASN клиента. */
boundClient?: boolean
/** IPFIX postNAT (IANA 225/226). */
natSrc?: string
natDst?: string
/** IPFIX postNAPT ports (IANA 227/228). */
natSrcPort?: number
natDstPort?: number
}
export function isLocalIp(ip: string, ours?: ReadonlySet<string>): boolean {
if (isNonPublicIp(ip)) return true
return Boolean(ours?.has(String(ip ?? "").trim()))
const host = canonicalIp(ip)
if (isUnspecifiedIp(host) || isNonPublicIp(host)) return true
if (!ours || ours.size === 0) return false
if (ours.has(host) || ours.has(String(ip ?? "").trim())) return true
for (const o of ours) {
if (canonicalIp(o) === host) return true
}
return false
}
/**
* Интернет-назначение потока для ASN/страны/сервиса.
* Пустая строка dest нет (не GeoIP IP клиента).
* Пустая строка dest нет (не GeoIP IP клиента / GRE-пира).
*
* boundClient: download CDNoverlay берём публичный src даже без :80/:443
* (googlevideo). Client-ISP overlay:well-known не dest (ASN клиента).
*/
export function pickInternetDest(
src: string,
dst: string,
srcRaw: string,
dstRaw: string,
srcPort: number,
dstPort: number,
ctx?: InternetDestCtx,
): string {
const ours = ctx?.ours
const srcLocal = isLocalIp(src, ours)
const dstLocal = isLocalIp(dst, ours)
const srcPub = !srcLocal
const dstPub = !dstLocal
const src = usableIp(srcRaw)
const dst = usableIp(dstRaw)
const natSrc = usableIp(ctx?.natSrc)
const natDst = usableIp(ctx?.natDst)
const internet = (ip: string) => Boolean(ip) && !isLocalIp(ip, ours)
const dstIp = internet(dst) ? dst : (internet(natDst) ? natDst : "")
const srcIp = internet(src) ? src : (internet(natSrc) ? natSrc : "")
const dstPortEff = internet(dst) ? dstPort : (internet(natDst) ? (ctx?.natDstPort || dstPort) : dstPort)
const srcPortEff = internet(src) ? srcPort : (internet(natSrc) ? (ctx?.natSrcPort || srcPort) : srcPort)
if (ctx?.boundClient) {
if (dstPub) return dst
if (srcPub && dstLocal) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
if (srcWk && !dstWk) return src
return ""
if (dstIp) return dstIp
if (srcIp) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPortEff)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPortEff)
if (!srcWk && dstWk) return ""
return srcIp
}
return ""
}
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
if (srcIp && !dstIp) return srcIp
if (dstIp && !srcIp) return dstIp
if (srcIp && dstIp) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPortEff)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPortEff)
if (srcWk && !dstWk) return srcIp
if (dstWk && !srcWk) return dstIp
return dstIp
}
if (src && dst && ours?.has(src) && ours.has(dst)) return ""
return dst
}
/**
* Dest для карты = тот же internet peer, что аналитика/факты.
* (исторически отдельный fallback без well-known trap теперь в pickInternetDest.)
*/
export function pickMapInternetDest(
srcRaw: string,
dstRaw: string,
srcPort: number,
dstPort: number,
ctx?: InternetDestCtx,
): string {
return pickInternetDest(srcRaw, dstRaw, srcPort, dstPort, ctx)
}
/**
* Интернет-сторона потока без топологии: у IPFIX сервис часто в src (Google:443 RFC1918).
* Для куба статистики используйте pickInternetDest.
*/
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
return pickInternetDest(src, dst, srcPort, dstPort) || dst
return pickInternetDest(src, dst, srcPort, dstPort) || ""
}
export type FlowDirection = "to_client" | "from_client" | "transit"
export interface FlowEndpoints {
packetSrc: string
packetDst: string
internetPeer: string
peerPort: number
otherPort: number
clientIp: string
direction: FlowDirection
}
function sameHost(a: string, b: string | undefined): boolean {
const x = canonicalIp(a)
const y = canonicalIp(b)
return Boolean(x) && x === y
}
/** Роли концов IPFIX-пакета. 5-tuple не переворачивается. */
export function resolveFlowEndpoints(opts: {
src: string
dst: string
srcPort: number
dstPort: number
ctx?: InternetDestCtx
}): FlowEndpoints {
const packetSrc = usableIp(opts.src)
const packetDst = usableIp(opts.dst)
const internetPeer = pickInternetDest(opts.src, opts.dst, opts.srcPort, opts.dstPort, opts.ctx)
const ours = opts.ctx?.ours
const srcLocal = Boolean(packetSrc) && isLocalIp(packetSrc, ours)
const dstLocal = Boolean(packetDst) && isLocalIp(packetDst, ours)
let peerPort = 0
let otherPort = 0
if (internetPeer) {
if (sameHost(internetPeer, packetSrc) || sameHost(internetPeer, opts.ctx?.natSrc)) {
peerPort = opts.srcPort
otherPort = opts.dstPort
} else if (sameHost(internetPeer, packetDst) || sameHost(internetPeer, opts.ctx?.natDst)) {
peerPort = opts.dstPort
otherPort = opts.srcPort
} else {
peerPort = opts.ctx?.natDstPort || opts.dstPort
otherPort = opts.srcPort
}
}
let clientIp = ""
if (srcLocal && !dstLocal) clientIp = packetSrc
else if (dstLocal && !srcLocal) clientIp = packetDst
else if (srcLocal) clientIp = packetSrc
else if (dstLocal) clientIp = packetDst
let direction: FlowDirection = "transit"
if (internetPeer && clientIp) {
direction = sameHost(internetPeer, packetSrc) ? "to_client" : "from_client"
}
return {
packetSrc,
packetDst,
internetPeer,
peerPort,
otherPort,
clientIp,
direction,
}
}
@@ -6,6 +6,8 @@ import {
} from "./traffic-flow-ingest.js"
import {
buildFlowMapHops,
MAP_COUNTRY_SERVICE_MIN_NODES,
MAP_COUNTRY_SERVICE_NODE_CAP,
MAP_SERVICE_MIN_NODES,
MAP_SERVICE_NODE_CAP,
pickMapServices,
@@ -70,6 +72,21 @@ import {
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
}
{
const leak = pickMapServices(
[
{ id: "svc:other", label: "Прочее", category: "Прочее", bytes: 19_000, bps: 0, share: 19 / 30 },
{ id: "svc:google", label: "Google", category: "Веб", bytes: 11_000, bps: 0, share: 11 / 30 },
],
5,
)
assert.equal(leak.reduce((n, s) => n + s.bytes, 0), 30_000)
const google = leak.find((s) => s.id === "svc:google")
assert.ok(google)
assert.ok(google.share < 0.4, "доля от окна хопа, не от named-only")
assert.ok(leak.some((s) => s.id === "svc:other"), "дыра видна как Прочее")
}
if (!(await withPgOrSkip())) {
console.log("traffic-flow-map-hops.test.ts: skip")
process.exit(0)
@@ -279,12 +296,27 @@ try {
resetFlowMapHopsCacheForTests()
const six = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(six.totalBytes, 10_000)
assert.equal(six.namedBytes, 600)
assert.equal(six.unclassifiedBytes, 9400)
const google = six.services?.find((s) => s.id === "svc:google")
const otherSix = six.services?.find((s) => s.id === "svc:other")
assert.ok(google, "Google ≥ 5%")
assert.ok(google.share >= 0.05)
assert.ok(otherSix, "остаток — Прочее")
assert.ok(google.share >= 0.05 && google.share < 0.1)
assert.ok(otherSix.share >= 0.9)
const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
const us = six.countries?.find((s) => s.id === "cc:us")
assert.ok(us, "Google ripe country US")
assert.equal(us.label, "US")
const usEdge = six.countryEdges?.find((e) => e.toId === "cc:us" && e.fromId === "9")
assert.ok(usEdge)
assert.ok(!(six.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")), "страны не смешиваются с svc:*")
const usPath = six.countryPaths?.find((p) => p.serviceId === "cc:us" && p.enId === "9")
assert.ok(usPath)
assert.equal(usPath.clientName, "Alice")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -309,9 +341,14 @@ try {
resetFlowMapHopsCacheForTests()
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000)
assert.equal(four.namedBytes, 400)
assert.equal(four.unclassifiedBytes, 9600)
const googleFour = four.services?.find((s) => s.id === "svc:google")
assert.ok(googleFour, "единственный бренд виден при 4% от окна")
assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1")
const otherFour = four.services?.find((s) => s.id === "svc:other")
assert.ok(googleFour, "бренд виден при 4% от окна (MIN_NODES)")
assert.ok(otherFour, "Прочее держит остаток окна")
assert.ok(googleFour.share < 0.1, "доля от totalBytes, не от named")
assert.ok(otherFour.share > 0.9)
resetFlowMapHopsCacheForTests()
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
@@ -340,10 +377,13 @@ try {
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const googleTwo = two.services?.find((s) => s.id === "svc:google")
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
const otherTwo = two.services?.find((s) => s.id === "svc:other")
assert.ok(googleTwo, "Google среди брендов")
assert.ok(cfTwo, "Cloudflare среди брендов")
assert.ok(googleTwo.share >= 0.05)
assert.ok(cfTwo.share >= 0.05)
assert.ok(otherTwo, "Прочее")
assert.ok(googleTwo.share > 0.03 && googleTwo.share < 0.05)
assert.ok(cfTwo.share > 0.03 && cfTwo.share < 0.05)
assert.ok(otherTwo.share > 0.9)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -373,6 +413,43 @@ try {
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("64.233.161.1", 10_000),
payloadFlow("203.0.113.50", 20_000),
])
try {
resetFlowMapHopsCacheForTests()
const leak = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const hop = leak.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(hop, "GRE hop JH→EN")
assert.equal(hop.bytes, 30_000)
assert.equal(leak.totalBytes, 30_000)
assert.equal(leak.namedBytes, 10_000)
assert.equal(leak.unclassifiedBytes, 20_000)
const yt = leak.services?.find((s) => s.id === "svc:youtube")
const other = leak.services?.find((s) => s.id === "svc:other")
assert.ok(yt, "64.233:443 без RIPE → YouTube")
assert.ok(other, "dest без бренда → Прочее")
assert.equal(yt.bytes, 10_000)
assert.equal(other.bytes, 20_000)
assert.ok(Math.abs(yt.share - 10 / 30) < 0.01)
assert.ok(Math.abs(other.share - 20 / 30) < 0.01)
assert.equal((leak.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), hop.bytes)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
@@ -450,6 +527,72 @@ try {
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 4_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: 2_000_000,
packets: 400,
inIface: "3",
outIface: "3",
},
{
src: "10.200.100.53",
dst: "10.200.100.1",
proto: 6,
srcPort: 53880,
dstPort: 443,
bytes: 3_000,
packets: 8,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
natDst: "8.8.8.8",
natDstPort: 443,
},
])
try {
resetFlowMapHopsCacheForTests()
const path = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false, minSharePct: 0 })
const hop = path.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(hop, "hop JH→EN")
const google = path.services?.find((s) => s.id === "svc:google")
assert.ok(google, "сервис Google")
assert.equal(google.bytes, 7_000)
assert.ok(!(path.services ?? []).some((s) => s.label === "GRE"), "GRE не dest")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
@@ -488,9 +631,9 @@ ingestParsedFlowsForServerForTests(7, [
try {
resetFlowMapHopsCacheForTests()
const rev = await 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:youtube"), "реверс googlevideo:443 → YouTube")
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"))
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:youtube" && e.fromId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
@@ -566,13 +709,13 @@ ingestParsedFlowsForServerForTests(7, [
try {
resetFlowMapHopsCacheForTests()
const wanOnly = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google WAN без GRE payload")
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:youtube")
assert.ok(googleEdge, "YouTube 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")
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:youtube")
assert.ok(googlePath, "путь WAN YouTube")
assert.equal(googlePath.viaId, "7", "via = JH exporter")
assert.equal(googlePath.enId, "9", "якорь EN")
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
@@ -678,4 +821,180 @@ try {
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
seedRipeCacheForTests({
prefix: "185.45.12.0/24",
asn: 13335,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "CLOUDFLARENET, NL",
ok: true,
fetchedAt: Date.now(),
})
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
payloadFlow("185.45.12.10", 5000),
])
try {
resetFlowMapHopsCacheForTests()
const split = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const us = split.countries?.find((s) => s.id === "cc:us")
const nl = split.countries?.find((s) => s.id === "cc:nl")
assert.ok(us, "US из ripe Google")
assert.ok(nl, "NL из ripe Cloudflare")
assert.equal(us.bytes, 5000)
assert.equal(nl.bytes, 5000)
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:us" && e.fromId === "9"))
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:nl" && e.fromId === "9"))
assert.ok(!(split.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")))
assert.ok(split.serviceEdges?.some((e) => e.toId === "svc:google"))
assert.ok(!(split.serviceEdges ?? []).some((e) => e.toId.startsWith("cc:")))
assert.ok(split.countryPaths?.some((p) => p.serviceId === "cc:nl" && p.enId === "9"))
} 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" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 600),
payloadFlow("203.0.113.50", 9400),
])
try {
resetFlowMapHopsCacheForTests()
const nested = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usGroup, "группа сервисов cc:us")
const nestedGoogle = usGroup.services.find((s) => s.id === "cc:us|svc:google")
assert.ok(nestedGoogle, "cc:us|svc:google в группе")
assert.equal(nestedGoogle.label, "Google")
assert.ok(Math.abs(nestedGoogle.share - 1) < 0.01, "доля от байтов страны (600/600), не окна")
const nestedEdge = usGroup.edges.find((e) => e.toId === "cc:us|svc:google")
assert.ok(nestedEdge)
assert.equal(nestedEdge.fromId, "cc:us", "ребро вложенного слоя: страна → сервис")
assert.equal(nestedEdge.bytes, 600)
assert.ok(!usGroup.edges.some((e) => e.fromId.startsWith("svc:")), "fromId вложенных рёбер не svc:*")
const nestedPath = usGroup.paths.find((p) => p.serviceId === "cc:us|svc:google")
assert.ok(nestedPath)
assert.equal(nestedPath.enId, "9", "путь держит реальный EN для подсветки HR→JH→EN")
assert.equal(nestedPath.clientName, "Alice")
const otherGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:other")
assert.ok(otherGroup, "группа cc:other (Прочее-страна)")
assert.ok(otherGroup.services.some((s) => s.id === "cc:other|svc:other"))
} 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" },
])
googleRipe()
seedRipeCacheForTests({
prefix: "185.45.12.0/24",
asn: 13335,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "CLOUDFLARENET, NL",
ok: true,
fetchedAt: Date.now(),
})
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
payloadFlow("185.45.12.10", 5000),
])
try {
resetFlowMapHopsCacheForTests()
const nestedSplit = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:us")
const nlNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:nl")
assert.ok(usNested && nlNested, "группы у обеих стран")
assert.ok(usNested.services.every((s) => s.id.startsWith("cc:us|")), "сервисы US только cc:us|*")
assert.ok(nlNested.services.every((s) => s.id.startsWith("cc:nl|")), "сервисы NL только cc:nl|*")
assert.ok(usNested.services.some((s) => s.id === "cc:us|svc:google"), "Google в US")
assert.ok(nlNested.services.some((s) => s.id === "cc:nl|svc:cloudflare"), "Cloudflare в NL")
assert.ok(!nlNested.services.some((s) => s.id === "cc:us|svc:google"), "Google US не утек в NL")
assert.ok(usNested.edges.every((e) => e.fromId === "cc:us"))
assert.ok(nlNested.edges.every((e) => e.fromId === "cc:nl"))
} 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" },
])
googleRipe()
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
])
try {
resetFlowMapHopsCacheForTests()
const nestedTop = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const usTop = nestedTop.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usTop)
assert.equal(
usTop.services.length,
MAP_COUNTRY_SERVICE_MIN_NODES,
"мелкий хвост держится минимумом узлов внутри страны",
)
assert.ok(usTop.services.some((s) => s.id === "cc:us|svc:google"))
resetFlowMapHopsCacheForTests()
const nestedAll = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const usAll = nestedAll.countryServiceGroups?.find((g) => g.countryId === "cc:us")
assert.ok(usAll)
assert.equal(usAll.services.length, MAP_COUNTRY_SERVICE_NODE_CAP, "cap вложенного слоя = 8")
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:epic"), "ранг 9+ скрыт")
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:riot"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok")
+292 -161
View File
@@ -1,21 +1,18 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import type { FlowMapCountryServiceGroup, 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,
mapServiceNodeId,
resolveFlowBrand,
} from "./traffic-flow-brands.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapCountryServiceNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { dedupFlowRowsAcrossExporters, 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 { destCtxForIface } from "./traffic-flow-dest.js"
import { pickInternetDest } from "./traffic-flow-ip.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { destCtxForIface, mapInternetBrand } from "./traffic-flow-dest.js"
import { resolveFlowEndpoints } from "./traffic-flow-ip.js"
import { geoipReadersStatus, resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog, type FlowTopology } from "./traffic-flow-topology.js"
import { flowDataEpoch } from "./traffic-flow-engine.js"
@@ -24,6 +21,11 @@ export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
export const MAP_SERVICE_MIN_NODES = 8
/** Cap сервисов внутри раскрытой страны (база доли — байты страны, не окна). */
export const MAP_COUNTRY_SERVICE_NODE_CAP = 8
/** Минимум узлов-сервисов внутри страны, даже если доля ниже порога. */
export const MAP_COUNTRY_SERVICE_MIN_NODES = 4
export const MAP_COUNTRY_CATEGORY = "Страна"
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery {
@@ -68,6 +70,32 @@ interface DstAcc {
fromBytes: Map<string, FromAcc>
}
interface DestTotal {
label: string
category: string
bytes: number
}
interface DestEdgeAcc {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}
interface DestPathAcc {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
const id = client?.userId || "—"
const name = client?.name || "—"
@@ -103,13 +131,195 @@ export function clampMapServiceMinSharePct(n: unknown): number {
return Math.min(100, Math.max(0, v))
}
/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
export function pickMapServices(
ranked: FlowMapService[],
minSharePct: number,
cap: number = MAP_SERVICE_NODE_CAP,
minNodes: number = MAP_SERVICE_MIN_NODES,
): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, cap)
const minShare = minSharePct / 100
return ranked
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
.slice(0, MAP_SERVICE_NODE_CAP)
.filter((s, i) => s.share >= minShare || i < minNodes)
.slice(0, cap)
}
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
const prev = totals.get(id)
if (prev) {
prev.bytes += bytes
return
}
totals.set(id, { label, category, bytes })
}
function bumpDestFrom(
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
toId: string,
from: FromAcc,
exporterId: string,
fromId: string,
enName: string,
viaName: string,
/** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */
edgeFromId: string = fromId,
): void {
const edgeKey = `${edgeFromId}|${toId}`
const prevEdge = edges.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 {
edges.set(edgeKey, {
fromId: edgeFromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = paths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
paths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
}
function finalizeDestLayer(
totals: Map<string, DestTotal>,
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
windowSec: number,
minSharePct: number,
shareBase: number,
cap: number = MAP_SERVICE_NODE_CAP,
minNodes: number = MAP_SERVICE_MIN_NODES,
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
const nodes = pickMapServices(
[...totals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
cap,
minNodes,
)
const keep = new Set(nodes.map((s) => s.id))
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
.filter((e) => keep.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 outPaths: FlowMapServicePath[] = [...paths.values()]
.filter((p) => keep.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)
return { nodes, edges: outEdges, paths: outPaths }
}
/** Группы сервисов по странам: только страны, прошедшие отбор слоя стран; доля — от байтов страны, не окна. */
function buildCountryServiceGroups(
nestedTotals: Map<string, DestTotal>,
nestedEdges: Map<string, DestEdgeAcc>,
nestedPaths: Map<string, DestPathAcc>,
countryNodes: FlowMapService[],
windowSec: number,
minSharePct: number,
): FlowMapCountryServiceGroup[] {
const groups: FlowMapCountryServiceGroup[] = []
for (const country of countryNodes) {
const prefix = `${country.id}|`
const totals = new Map<string, DestTotal>()
const edges = new Map<string, DestEdgeAcc>()
const paths = new Map<string, DestPathAcc>()
for (const [id, t] of nestedTotals) {
if (id.startsWith(prefix)) totals.set(id, t)
}
if (totals.size === 0) continue
for (const [key, e] of nestedEdges) {
if (e.toId.startsWith(prefix)) edges.set(key, e)
}
for (const [key, p] of nestedPaths) {
if (p.serviceId.startsWith(prefix)) paths.set(key, p)
}
const out = finalizeDestLayer(
totals,
edges,
paths,
windowSec,
minSharePct,
country.bytes,
MAP_COUNTRY_SERVICE_NODE_CAP,
MAP_COUNTRY_SERVICE_MIN_NODES,
)
groups.push({ countryId: country.id, services: out.nodes, edges: out.edges, paths: out.paths })
}
return groups
}
function countryDestFromRipe(ripe: FlowIpMeta | null, destKey: string): { id: string; label: string; category: string } {
if (!destKey || destKey === "__other__" || !ripe?.ok) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
const iso = resolveRipeCountry(ripe.country, ripe.asn, ripe.holder)
if (!iso) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
return { id: mapCountryNodeId(iso), label: iso, category: MAP_COUNTRY_CATEGORY }
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
@@ -186,22 +396,6 @@ function toHop(a: HopAcc, windowSec: number): FlowMapHop {
}
}
/** Имя бренда без каталога 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
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try {
@@ -213,6 +407,7 @@ async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> {
}
async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Promise<FlowMapHopsDto> {
refreshFlowCatalogInBackground()
const windowSec = Math.max(60, q.minutes * 60)
const raw = await listFlowRowsForWindow(q.minutes)
const allow = q.userId ? await userIfaceAllow(q.userId) : null
@@ -355,16 +550,22 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const inName = resolveIfaceName(r.serverId, r.inIface).name
const outName = resolveIfaceName(r.serverId, r.outIface).name
totalBytes += r.bytes
const dest = pickInternetDest(
r.src,
r.dst,
r.srcPort,
r.dstPort,
destCtxForIface(topo, r.serverId, inName),
)
if (!dest) continue
const ep = resolveFlowEndpoints({
src: r.src,
dst: r.dst,
srcPort: r.srcPort,
dstPort: r.dstPort,
ctx: destCtxForIface(topo, r.serverId, inName, {
natSrc: r.natSrc,
natDst: r.natDst,
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
}),
})
const dest = ep.internetPeer
const destKey = dest || "__other__"
const client = resolveMapClient(topo, r.serverId, inName, outName)
const prevDst = dstAcc.get(dest)
const prevDst = dstAcc.get(destKey)
if (prevDst) {
prevDst.bytes += r.bytes
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
@@ -372,34 +573,24 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const acc: DstAcc = {
bytes: r.bytes,
proto: r.proto,
dstPort: r.dstPort,
srcPort: r.srcPort,
dstPort: ep.peerPort || r.dstPort,
srcPort: ep.otherPort || r.srcPort,
fromBytes: new Map(),
}
bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(dest, acc)
dstAcc.set(destKey, 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
}>()
const svcTotals = new Map<string, DestTotal>()
const svcEdges = new Map<string, DestEdgeAcc>()
const svcPaths = new Map<string, DestPathAcc>()
const ccTotals = new Map<string, DestTotal>()
const ccEdges = new Map<string, DestEdgeAcc>()
const ccPaths = new Map<string, DestPathAcc>()
const nestedTotals = new Map<string, DestTotal>()
const nestedEdges = new Map<string, DestEdgeAcc>()
const nestedPaths = new Map<string, DestPathAcc>()
for (const h of hops.values()) {
if (h.kind !== "gre" || !h.toId) continue
@@ -432,113 +623,45 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
for (const [dst, acc] of dstAcc) {
const ripe = resolveFlowIp(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 })
const ripe = dst && dst !== "__other__" ? resolveFlowIp(dst) : null
const classified = dst && dst !== "__other__"
? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
: { service: OTHER_SERVICE, category: OTHER_SERVICE }
const svcId = mapServiceNodeId(classified.service)
bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
const country = countryDestFromRipe(ripe, dst)
bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes)
const nestedId = mapCountryServiceNodeId(country.id, svcId)
bumpDestTotal(nestedTotals, nestedId, classified.service, classified.category, 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}|${fromId}|${toId}`
const prevPath = svcPaths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
svcPaths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
bumpDestFrom(nestedEdges, nestedPaths, nestedId, from, exporterId, fromId, enName, viaName, country.id)
}
}
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
const services = pickMapServices(
[...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
const namedBytes = [...svcTotals.values()]
.filter((s) => isNamedInternetService(s.label, s.category))
.reduce((n, s) => n + s.bytes, 0)
const unclassifiedBytes = Math.max(0, totalBytes - namedBytes)
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
const countryServiceGroups = buildCountryServiceGroups(
nestedTotals,
nestedEdges,
nestedPaths,
countriesOut.nodes,
windowSec,
minSharePct,
)
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()
const geo = geoipReadersStatus()
return {
hops: [...hops.values()]
.map((a) => toHop(a, windowSec))
@@ -547,9 +670,17 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
rangeMinutes: q.minutes,
windowSec,
totalBytes,
services,
serviceEdges,
servicePaths,
namedBytes,
unclassifiedBytes,
asnLoaded: geo.asnLoaded,
countryLoaded: geo.countryLoaded,
services: servicesOut.nodes,
serviceEdges: servicesOut.edges,
servicePaths: servicesOut.paths,
countries: countriesOut.nodes,
countryEdges: countriesOut.edges,
countryPaths: countriesOut.paths,
countryServiceGroups,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
+38 -2
View File
@@ -114,6 +114,8 @@ async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
"last-forwarded": "yes",
"nat-src-address": "yes",
"nat-dst-address": "yes",
"nat-src-port": "yes",
"nat-dst-port": "yes",
})
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/ipfix"))
const id = rows[0] ? rosRowId(rows[0]) : ""
@@ -124,6 +126,9 @@ async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
await client.post("/ip/traffic-flow/ipfix/set", body)
}
/** Максимум flows в RAM (docs: overflow обрезает новые 5-tuple). */
export const FLOW_CACHE_ENTRIES = "256k"
async function ensureTrafficFlow(
client: MikrotikClient,
collectorIp: string,
@@ -132,6 +137,7 @@ async function ensureTrafficFlow(
const body = toRosBody({
enabled: "yes",
interfaces: "all",
"cache-entries": FLOW_CACHE_ENTRIES,
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
})
@@ -165,6 +171,23 @@ async function ensureTrafficFlow(
await client.put("/ip/traffic-flow/target", targetBody)
}
/** GRE allow-fast-path=no: inner пакеты идут через CPU и попадают в Traffic Flow. Нагрузка на CPU. */
export async function ensureGreSlowPath(client: MikrotikClient): Promise<number> {
const rows = asRosArray<Record<string, unknown>>(await client.get("/interface/gre"))
let patched = 0
for (const row of rows) {
const id = rosRowId(row)
if (!id) continue
const current = String(row["allow-fast-path"] ?? "true").toLowerCase()
if (current === "false" || current === "no") continue
await patchRosPath(client, `/interface/gre/${encodeRosId(id)}`, toRosBody({
"allow-fast-path": "no",
}))
patched += 1
}
return patched
}
export function usablePublicHost(raw: string | undefined): string {
if (!raw) return ""
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
@@ -178,7 +201,7 @@ export function usablePublicHost(raw: string | undefined): string {
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string },
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = await ensureHostKeys()
@@ -273,7 +296,20 @@ export async function applyFlowOverlay(
}
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto, cache ${FLOW_CACHE_ENTRIES})`)
if (opts?.disableGreFastPath) {
try {
const n = await ensureGreSlowPath(client)
steps.push(
n > 0
? `GRE allow-fast-path=no (${n}) — inner IPFIX через CPU`
: "GRE already allow-fast-path=no",
)
} catch {
steps.push("GRE allow-fast-path не изменён (нет /interface/gre)")
}
}
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { parseFlowPacket, protoName, resetFlowTemplatesForTests, templateExporterCountForTests } from "./traffic-flow-parse.js"
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
import { allocateOverlayAddress, FLOW_CACHE_ENTRIES, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
function netflowV5One(): Buffer {
const buf = Buffer.alloc(24 + 48)
@@ -38,6 +38,7 @@ assert.equal(usablePublicHost("192.168.1.10"), "")
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
assert.equal(FLOW_CACHE_ENTRIES, "256k")
resetFlowTemplatesForTests()
{
@@ -182,6 +183,95 @@ resetFlowTemplatesForTests()
assert.equal(extra[0]?.bytes, 1500)
}
resetFlowTemplatesForTests()
{
const fieldSpecs: Array<[number, number]> = [
[8, 4],
[12, 4],
[225, 4],
[226, 4],
[227, 2],
[228, 2],
[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] = 200; data[d + 2] = 100; data[d + 3] = 53; d += 4
data[d] = 10; data[d + 1] = 200; data[d + 2] = 100; data[d + 3] = 1; d += 4
data[d] = 0; data[d + 1] = 0; data[d + 2] = 0; data[d + 3] = 0; d += 4
data[d] = 8; data[d + 1] = 8; data[d + 2] = 8; data[d + 3] = 8; d += 4
data.writeUInt16BE(53880, d); d += 2
data.writeUInt16BE(443, d); d += 2
data.writeUInt32BE(900, d)
parseFlowPacket(tpl, "10.255.254.9")
const nat = parseFlowPacket(data, "10.255.254.9")
assert.equal(nat.length, 1)
assert.equal(nat[0]?.src, "10.200.100.53")
assert.equal(nat[0]?.dst, "10.200.100.1")
assert.equal(nat[0]?.natSrc, "0.0.0.0")
assert.equal(nat[0]?.natDst, "8.8.8.8")
assert.equal(nat[0]?.natSrcPort, 53880)
assert.equal(nat[0]?.natDstPort, 443)
assert.equal(nat[0]?.bytes, 900)
}
resetFlowTemplatesForTests()
{
const fieldSpecs: Array<[number, number]> = [
[225, 4],
[12, 4],
[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] = 0; data[d + 1] = 0; data[d + 2] = 0; data[d + 3] = 0; d += 4
data[d] = 8; data[d + 1] = 8; data[d + 2] = 8; data[d + 3] = 8; d += 4
data.writeUInt32BE(10, d)
parseFlowPacket(tpl, "10.255.254.10")
const zeroNat = parseFlowPacket(data, "10.255.254.10")
assert.equal(zeroNat[0]?.src, "")
assert.equal(zeroNat[0]?.natSrc, "0.0.0.0")
assert.equal(zeroNat[0]?.dst, "8.8.8.8")
}
resetFlowTemplatesForTests()
{
const tpl = Buffer.alloc(16 + 16 + 20)
+30 -6
View File
@@ -1,3 +1,5 @@
import { canonicalIp } from "./traffic-flow-ip.js"
export interface ParsedFlow {
src: string
dst: string
@@ -13,6 +15,8 @@ export interface ParsedFlow {
flowEndMs: number
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
@@ -33,6 +37,8 @@ export function emptyParsedFlow(): ParsedFlow {
flowEndMs: 0,
natSrc: "",
natDst: "",
natSrcPort: 0,
natDstPort: 0,
}
}
@@ -40,11 +46,15 @@ export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
return {
...emptyParsedFlow(),
...flow,
nextHop: flow.nextHop ?? "",
src: canonicalIp(flow.src) || flow.src || "",
dst: canonicalIp(flow.dst) || flow.dst || "",
nextHop: canonicalIp(flow.nextHop ?? ""),
flowStartMs: flow.flowStartMs ?? 0,
flowEndMs: flow.flowEndMs ?? 0,
natSrc: flow.natSrc ?? "",
natDst: flow.natDst ?? "",
natSrc: canonicalIp(flow.natSrc ?? ""),
natDst: canonicalIp(flow.natDst ?? ""),
natSrcPort: flow.natSrcPort ?? 0,
natDstPort: flow.natDstPort ?? 0,
inIface: flow.inIface ?? "",
outIface: flow.outIface ?? "",
srcPort: flow.srcPort ?? 0,
@@ -86,6 +96,12 @@ function ipv4(buf: Buffer, offset: number): string {
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
}
function usableIpfixIp(ip: string): boolean {
const t = String(ip ?? "").trim()
if (!t) return false
return t !== "0.0.0.0" && t.toLowerCase() !== "::" && t.toLowerCase() !== "::0"
}
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))
@@ -214,6 +230,8 @@ function recordFromFields(
let flowEndMs = 0
let natSrc = ""
let natDst = ""
let natSrcPort = 0
let natDstPort = 0
for (const f of fields) {
const field = consumeField(buf, off, f.length, limit)
if (!field) return null
@@ -243,15 +261,21 @@ function recordFromFields(
case 225:
if (data.length === 4) {
natSrc = ipv4(data, 0)
if (!src) src = natSrc
if (!usableIpfixIp(src) && usableIpfixIp(natSrc)) src = natSrc
}
break
case 226:
if (data.length === 4) {
natDst = ipv4(data, 0)
if (!dst) dst = natDst
if (!usableIpfixIp(dst) && usableIpfixIp(natDst)) dst = natDst
}
break
case 227:
natSrcPort = readUint(data, 0, data.length)
break
case 228:
natDstPort = readUint(data, 0, data.length)
break
case 4:
proto = readUint(data, 0, data.length)
break
@@ -308,7 +332,7 @@ function recordFromFields(
if (ifaceName && !inIface) inIface = ifaceName
return {
flow: normalizeParsedFlow({
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst,
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst, natSrcPort, natDstPort,
}),
next: off,
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { dbAll, dbQuery } from "../db/index.js"
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { canonicalIp, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { resolveRipeCountry } from "./traffic-flow-brands.js"
export interface FlowIpMeta {
@@ -264,7 +264,7 @@ function negative(prefix: string): FlowIpMeta {
export function lookupRipeCached(ip: string): FlowIpMeta | null {
loadSqlite()
const trimmed = String(ip ?? "").trim()
const trimmed = canonicalIp(ip)
lastCandidateCount = 0
if (!trimmed) return null
if (isNonPublicIp(trimmed)) {
@@ -2,6 +2,7 @@ import { db, dbAll } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
import { mapRosInterfaceType, parseRawInterfaces } from "../modules/users/iface-type.js"
import { canonicalIp } from "./traffic-flow-ip.js"
import type { PlaneTopology } from "./traffic-flow-planes.js"
export interface FlowClientBinding {
@@ -176,10 +177,12 @@ export function flowOursHosts(topo: FlowTopology | null | undefined): Set<string
const ours = new Set<string>()
if (!topo) return ours
for (const h of topo.enHosts) {
if (h) ours.add(h)
const ip = canonicalIp(h)
if (ip) ours.add(ip)
}
for (const h of topo.jhHosts) {
if (h) ours.add(h)
const ip = canonicalIp(h)
if (ip) ours.add(ip)
}
return ours
}
@@ -75,7 +75,7 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
}
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={cn("size-1.5 rounded-full", s.dot)} />
@@ -208,14 +208,21 @@ function GreTunnelsDataGrid({
cell: ({ row }) => {
const t = row.original
if (!t.ipsec) return <span className="text-xs text-muted-foreground"></span>
const enc = t.ipsec.encAlg ? ENC_LABELS[t.ipsec.encAlg] : undefined
const auth = t.ipsec.authAlg ? AUTH_LABELS[t.ipsec.authAlg] : undefined
const dh = t.ipsec.dhGroup ? DH_LABELS[t.ipsec.dhGroup] : undefined
const ike = t.ipsec.ikeVersion ? IKE_LABELS[t.ipsec.ikeVersion] : undefined
if (!enc && !auth && !dh && !ike) {
return <span className="text-xs text-muted-foreground">PSK · auto</span>
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
{[enc, auth].filter(Boolean).join(" / ") || "PSK"}
</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
{t.ipsec.pfs && " · PFS"}
{[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
{t.ipsec.pfs ? " · PFS" : ""}
</span>
</div>
)
@@ -3,6 +3,7 @@
import { useMemo } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
@@ -19,6 +20,40 @@ function formatBytes(n: number): string {
return `${n} Б`
}
function formatEndpoint(ip: string | undefined, port: number | undefined): string {
const host = String(ip ?? "").trim()
if (!host) return "—"
return port ? `${host}:${port}` : host
}
function packetTuple(row: FlowTalkerDto): string {
const src = formatEndpoint(row.src, row.srcPort)
const dst = formatEndpoint(row.dst, row.dstPort)
return `${src}${dst}`
}
function FlowDirectionMark({ direction }: { direction: FlowTalkerDto["direction"] }) {
if (direction === "to_client") {
return (
<span className="inline-flex text-muted-foreground" title="Download: интернет → клиент">
<ArrowDownIcon className="size-3" />
</span>
)
}
if (direction === "from_client") {
return (
<span className="inline-flex text-muted-foreground" title="Upload: клиент → интернет">
<ArrowUpIcon className="size-3" />
</span>
)
}
return (
<span className="inline-flex text-muted-foreground" title="Транзит">
<MinusIcon className="size-3" />
</span>
)
}
function TrafficFlowsDataGrid({
rows,
emptyHint,
@@ -30,9 +65,16 @@ function TrafficFlowsDataGrid({
() => [
{
id: "client",
accessorFn: (r) => r.clientName ?? "",
accessorFn: (r) => r.clientName || r.clientIp || "",
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
cell: ({ row }) => <span className="text-xs">{row.original.clientName || "—"}</span>,
cell: ({ row }) => (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs truncate">{row.original.clientName || "—"}</span>
{row.original.clientIp ? (
<span className="font-mono text-[10px] text-muted-foreground truncate">{row.original.clientIp}</span>
) : null}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
@@ -43,27 +85,26 @@ function TrafficFlowsDataGrid({
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "src",
accessorKey: "src",
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.src}
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
</span>
),
id: "direction",
accessorFn: (r) => r.direction ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">Напр.</span>,
cell: ({ row }) => <FlowDirectionMark direction={row.original.direction} />,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.dst}
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
</span>
),
id: "internet",
accessorFn: (r) => r.internetPeer ?? r.dst,
header: () => <span className="text-xs font-medium text-muted-foreground">Интернет</span>,
cell: ({ row }) => {
const r = row.original
const label = formatEndpoint(r.internetPeer, r.internetPeerPort)
const tuple = packetTuple(r)
return (
<span className="font-mono text-xs truncate max-w-[220px]" title={tuple}>
{label}
</span>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
+29 -2
View File
@@ -20,9 +20,29 @@ const COUNTRY_NAMES: Record<string, string> = {
HK: "Гонконг",
}
/** Country name in Russian (fallback to code) */
let regionNames: Intl.DisplayNames | null | undefined
function regionDisplayName(iso: string): string | undefined {
try {
if (regionNames === undefined) {
regionNames = typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["ru"], { type: "region" })
: null
}
return regionNames?.of(iso) ?? undefined
} catch {
return undefined
}
}
/** Country name in Russian (fallback to ISO code) */
export function countryName(code: string): string {
return COUNTRY_NAMES[code.toUpperCase()] ?? code
const iso = code.toUpperCase()
if (!iso) return code
if (COUNTRY_NAMES[iso]) return COUNTRY_NAMES[iso]
const intl = regionDisplayName(iso)
if (intl && intl !== iso) return intl
return iso
}
interface FlagProps {
@@ -39,6 +59,13 @@ function nearestCdnSize(px: number): number {
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
}
/** CDN URL for SVG `<image href>` (flagcdn widths only). */
export function flagCdnUrl(code: string, size = 40): string | null {
const lower = code.toLowerCase()
if (!/^[a-z]{2}$/.test(lower)) return null
return `https://flagcdn.com/w${nearestCdnSize(size)}/${lower}.png`
}
/**
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
* Source: https://flagcdn.com — free CDN, no API key needed.
@@ -0,0 +1,17 @@
"use client"
import { flagCdnUrl } from "@/components/flag"
export function CountryFlagSvg({ iso, size = 22 }: { iso: string; size?: number }) {
const url = flagCdnUrl(iso, size)
if (!url) return null
const h = Math.round(size * 0.75)
return (
<image
href={url}
width={size}
height={h}
preserveAspectRatio="xMidYMid meet"
/>
)
}
@@ -74,6 +74,14 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
</BrandSvg>
)
case "instagram":
return (
<BrandSvg size={size}>
<rect x="3" y="3" width="18" height="18" rx="5" fill="#E4405F" />
<circle cx="12" cy="12.2" r="4.1" fill="none" stroke="#fff" strokeWidth="1.8" />
<circle cx="16.3" cy="7.7" r="1.15" fill="#fff" />
</BrandSvg>
)
case "netflix":
return (
<BrandSvg size={size}>
+15 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { FormField, FormToggle } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
@@ -54,12 +54,14 @@ function FlowOverlaySheet({
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("linux")
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
useEffect(() => {
if (!open) return
setResult(null)
setCopied(false)
setTab("linux")
setDisableGreFastPath(false)
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
@@ -84,7 +86,9 @@ function FlowOverlaySheet({
if (!serverId || !endpoint.trim()) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim(), {
disableGreFastPath,
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
@@ -151,6 +155,15 @@ function FlowOverlaySheet({
autoComplete="off"
/>
</FormField>
<FormField
label="GRE slow-path (IPFIX inner)"
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
>
<div className="flex items-center gap-2">
<FormToggle checked={disableGreFastPath} onChange={setDisableGreFastPath} />
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
</div>
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
+7 -6
View File
@@ -531,12 +531,13 @@ export type DscpMode = "inherit" | number
export interface GreIpsec {
secret: string // ipsec-secret → auto-creates peer+policy+proposal
encAlg: IpsecEncAlg // proposal enc-algorithms
authAlg: IpsecAuthAlg // proposal auth-algorithms
dhGroup: IpsecDhGroup // proposal pfs-group / peer dh-group
ikeVersion: IkeVersion // peer exchange-mode
lifetime: string // proposal lifetime (e.g. "1d 00:00:00")
pfs: boolean // perfect forward secrecy
/** Live CHR отдаёт только secret; proposal-поля есть у моков / формы */
encAlg?: IpsecEncAlg
authAlg?: IpsecAuthAlg
dhGroup?: IpsecDhGroup
ikeVersion?: IkeVersion
lifetime?: string
pfs?: boolean
}
export interface GreTunnel {
+21
View File
@@ -39,6 +39,7 @@ const W = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
export { SERVICE_COL_W }
/** Карточка конечного сервиса на карте (центр = позиция узла). */
export const MAP_SERVICE_NODE_W = 86
@@ -454,6 +455,26 @@ export function placeServiceNodes(
return out
}
/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */
export function placeCountryServiceNodes(
serviceIds: string[],
parentPos: { x: number; y: number } | undefined,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0 || !parentPos) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const n = serviceIds.length
const gap = Math.min(80, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(parentPos.y - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(parentPos.y, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
* Отдельного ICMP по ребру нет это не замер линии, а сумма каталожных latency концов.
+23
View File
@@ -47,6 +47,8 @@ export const trafficFlowSettingsPatchSchema = z.object({
export const trafficFlowOverlayRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
publicEndpoint: z.string().optional(),
/** GRE allow-fast-path=no: inner IPFIX через CPU. По умолчанию выкл (нагрузка на CPU). */
disableGreFastPath: z.boolean().optional(),
})
export const trafficFlowHostFileSchema = z.object({
@@ -91,6 +93,10 @@ export const flowTalkerDtoSchema = z.object({
dstAsn: z.number().int().optional(),
clientId: z.string().optional(),
clientName: z.string().optional(),
clientIp: z.string().optional(),
internetPeer: z.string().optional(),
internetPeerPort: z.number().int().optional(),
direction: z.enum(["to_client", "from_client", "transit"]).optional(),
enId: z.string().optional(),
enName: z.string().optional(),
plane: z.string().optional(),
@@ -310,15 +316,31 @@ export const flowMapServicePathDtoSchema = z.object({
bps: z.number().nonnegative(),
})
/** Сервисы внутри одной страны: id вида `cc:us|svc:google`, рёбра от `countryId`. */
export const flowMapCountryServiceGroupDtoSchema = z.object({
countryId: z.string(),
services: z.array(flowMapServiceDtoSchema),
edges: z.array(flowMapServiceEdgeDtoSchema),
paths: z.array(flowMapServicePathDtoSchema),
})
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(),
namedBytes: z.number().nonnegative().optional(),
unclassifiedBytes: z.number().nonnegative().optional(),
asnLoaded: z.boolean().optional(),
countryLoaded: z.boolean().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
countries: z.array(flowMapServiceDtoSchema).optional(),
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
countryServiceGroups: z.array(flowMapCountryServiceGroupDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
@@ -343,4 +365,5 @@ 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 FlowMapCountryServiceGroup = z.infer<typeof flowMapCountryServiceGroupDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+6 -1
View File
@@ -46,10 +46,15 @@ export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
opts?: { disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({ serverId, publicEndpoint }),
body: JSON.stringify({
serverId,
publicEndpoint,
...(opts?.disableGreFastPath ? { disableGreFastPath: true } : {}),
}),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long