Files
MikrotikManager/backend/src/services/traffic-flow-map-hops.test.ts
T
Denozordec 0fdd2fc1e1
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
feat(traffic-flow): раскрытие сервисов страны на карте сети
- Контракт: 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

1001 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} 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,
resetFlowMapHopsCacheForTests,
} from "./traffic-flow-map-hops.js"
import { withPgOrSkip } from "../test/pg.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
{
const googleOnly = pickMapServices(
[{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }],
5,
)
assert.equal(googleOnly.length, 1)
assert.equal(googleOnly[0]?.share, 1)
const twoNamed = pickMapServices(
[
{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 },
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 },
],
5,
)
assert.equal(twoNamed.length, 2)
const tinyTail = pickMapServices(
[
{ id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 },
...Array.from({ length: 9 }, (_, i) => ({
id: `svc:t${i}`,
label: `T${i}`,
category: "Веб",
bytes: 100,
bps: 0,
share: 0.01,
})),
],
5,
)
assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES)
assert.equal(tinyTail.at(-1)?.id, "svc:t6")
const allOff = pickMapServices(
Array.from({ length: 25 }, (_, i) => ({
id: `svc:n${i}`,
label: `N${i}`,
category: "Веб",
bytes: 25 - i,
bps: 0,
share: 0.04,
})),
0,
)
assert.equal(allOff.length, MAP_SERVICE_NODE_CAP)
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)
}
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
const topo: FlowTopology = {
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
clientByIface: new Map([["7|gre-client", {
userId: "u1",
login: "alice",
name: "Alice",
serverId: 7,
interfaceName: "gre-client",
}]]),
enNodes: [{ id: 9, name: "NSK-EN", hosts: ["198.51.100.1"] }],
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
wanIfaces: new Map([[3, new Set(["ether1-rt"])]]),
plane: {
clientIfaceNames: new Set(["gre-client"]),
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
},
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
{ ".id": "*A", name: "wg-flow" },
])
rememberServerIfaces(3, [
{ ".id": "*1", name: "ether1-rt" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 5_000_000,
packets: 4000,
inIface: "3",
outIface: "3",
},
{
src: "10.100.1.17",
dst: "10.100.1.18",
proto: 6,
srcPort: 50000,
dstPort: 443,
bytes: 8000,
packets: 8,
inIface: "2",
outIface: "2",
},
{
src: "10.255.254.1",
dst: "10.255.254.2",
proto: 17,
srcPort: 4739,
dstPort: 2055,
bytes: 400,
packets: 2,
inIface: "10",
outIface: "",
},
])
ingestParsedFlowsForServerForTests(3, [
{
src: "192.168.1.10",
dst: "8.8.4.4",
proto: 6,
srcPort: 40000,
dstPort: 443,
bytes: 3000,
packets: 4,
inIface: "1",
outIface: "1",
},
])
try {
resetFlowMapHopsCacheForTests()
const def = await buildFlowMapHops({ minutes: 5 })
assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true)
assert.equal(def.dedupApplied, true)
assert.equal(def.windowSec, 300)
const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(payloadGre, "payload JH→EN hop")
assert.equal(payloadGre.bytes, 12_000)
assert.equal(payloadGre.bps, (12_000 * 8) / 300)
assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300)
assert.equal(payloadGre.iface, "gre-jh-en")
const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7")
assert.ok(greIface)
assert.equal(greIface.bytes, 12_000)
assert.equal(greIface.bpsFwd, (12_000 * 8) / 300)
assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded")
assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded")
const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
assert.ok(clientIngress, "payload ingress on client iface")
assert.equal(clientIngress.bytes, 12_000)
const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt")
assert.ok(wan, "WAN hop from home-router")
assert.equal(wan.bytes, 3000)
resetFlowMapHopsCacheForTests()
const withAll = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
assert.ok(meshIface && meshIface.bytes >= 20_000)
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: hops ok")
function googleRipe() {
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: 37.4,
lng: -122.1,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
}
function seedRipeAsn(ip: string, asn: number, holder: string) {
seedRipeCacheForTests({
prefix: `${ip}/32`,
asn,
country: "US",
lat: 37.4,
lng: -122.1,
holder,
ok: true,
fetchedAt: Date.now(),
})
}
function payloadFlow(dst: string, bytes: number) {
return {
src: "10.100.1.17",
dst,
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes,
packets: Math.max(1, Math.round(bytes / 1200)),
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
}
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 600),
payloadFlow("203.0.113.50", 9400),
])
try {
resetFlowMapHopsCacheForTests()
const six = 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(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()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 400),
payloadFlow("203.0.113.50", 9600),
])
try {
resetFlowMapHopsCacheForTests()
const four = 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")
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")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 400),
payloadFlow("104.18.35.51", 400),
payloadFlow("203.0.113.50", 9200),
])
try {
resetFlowMapHopsCacheForTests()
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(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()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP")
ingestParsedFlowsForServerForTests(7, [
payloadFlow("162.254.192.71", 2000),
])
try {
resetFlowMapHopsCacheForTests()
const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
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" },
{ ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" },
{ ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" },
{ ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" },
{ ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" },
{ ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" },
{ ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" },
{ ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" },
]
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 top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const ids = new Set((top.services ?? []).map((s) => s.id))
assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте")
assert.ok(ids.has("svc:google"))
for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id)
assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт")
assert.ok(!ids.has("svc:riot"))
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 9_000,
packets: 90,
inIface: "3",
outIface: "3",
},
payloadFlow("203.0.113.50", 1000),
])
try {
resetFlowMapHopsCacheForTests()
const greOnly = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
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()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 9_000,
packets: 90,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
{
src: "104.18.35.51",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 53880,
bytes: 1_000,
packets: 10,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
])
try {
resetFlowMapHopsCacheForTests()
const rev = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
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:youtube" && e.fromId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*1", name: "SWE-VEESP" },
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 500),
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 8_000,
packets: 80,
inIface: "1",
outIface: "1",
nextHop: "",
},
])
try {
resetFlowMapHopsCacheForTests()
const wan = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google с WAN JH")
assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH")
assert.ok(!(wan.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
const viaGre = wan.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(viaGre?.clients?.some((c) => c.name === "Alice") || viaGre?.clientName === "Alice")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*1", name: "SWE-VEESP" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 9_000,
packets: 80,
inIface: "1",
outIface: "1",
nextHop: "",
},
])
try {
resetFlowMapHopsCacheForTests()
const wanOnly = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
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: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, "скорость на пути клиента")
} 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" },
])
rememberServerIfaces(9, [
{ ".id": "*1", name: "ether1" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [payloadFlow("8.8.8.8", 12_000)])
ingestParsedFlowsForServerForTests(9, [{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "1",
outIface: "1",
nextHop: "",
}])
try {
resetFlowMapHopsCacheForTests()
const dual = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const gre = dual.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(gre, "GRE JH→EN сохранён")
assert.equal(gre.bytes, 12_000)
const googlePaths = (dual.servicePaths ?? []).filter((p) => p.serviceId === "svc:google")
assert.equal(googlePaths.length, 1, "один путь без копии EN")
assert.equal(googlePaths[0]?.clientId, "u1")
assert.equal(googlePaths[0]?.viaId, "7")
const googleEdge = dual.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.bytes, 12_000)
assert.equal(googleEdge.bps, googlePaths[0]?.bps)
} 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", 8_000),
{
src: "8.8.8.8",
dst: "10.100.1.17",
proto: 6,
srcPort: 443,
dstPort: 51234,
bytes: 4_000,
packets: 8,
inIface: "3",
outIface: "2",
nextHop: "",
},
])
try {
resetFlowMapHopsCacheForTests()
const bothDir = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const gre = bothDir.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(gre, "GRE-hop при fwd/rev")
const googlePaths = (bothDir.servicePaths ?? []).filter((p) => p.serviceId === "svc:google")
assert.equal(googlePaths.length, 1, "fwd+rev — один клиент")
assert.equal(googlePaths[0]?.clientId, "u1")
assert.ok(!(bothDir.servicePaths ?? []).some((p) => p.serviceId === "svc:google" && p.clientId === "—"))
const googleEdge = bothDir.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.bytes, 12_000)
assert.equal(googleEdge.bps, googlePaths[0]?.bps)
} 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 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")