export type FlowPlane = "payload" | "client_mesh" | "overlay" | "mgmt" export const PLANE_LABEL: Record = { payload: "Интернет", client_mesh: "Клиенты", overlay: "JH↔EN", mgmt: "mgmt", } const WG_PORTS = new Set([51820, 13232, 51821]) const FLOW_PORTS = new Set([4739, 2055]) export function isRfc1918(ip: string): boolean { const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10)) if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false const [a, b] = parts if (a === 10) return true if (a === 192 && b === 168) return true if (a === 172 && b != null && b >= 16 && b <= 31) return true if (a === 100 && b != null && b >= 64 && b <= 127) return true return false } export function isPublicV4(ip: string): boolean { const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10)) if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false const a = parts[0] ?? 0 if (a === 0 || a === 127 || a >= 224) return false return !isRfc1918(ip) } export function isTunnelProto(proto: number, srcPort: number, dstPort: number): boolean { if (proto === 47 || proto === 50) return true if (proto === 17 && (WG_PORTS.has(srcPort) || WG_PORTS.has(dstPort))) return true return false } function ifaceLooksMgmt(name: string): boolean { const n = name.trim().toLowerCase() return n === "wg-flow" || n.endsWith("/wg-flow") || n.includes("wg-flow") } export interface PlaneFlowInput { src: string dst: string proto: number srcPort: number dstPort: number inIface: string outIface?: string } /** Быстрая классификация без топологии — для live ring на ingest. */ export function classifyFlowPlaneLite(flow: PlaneFlowInput): FlowPlane { if (ifaceLooksMgmt(flow.inIface)) return "mgmt" if (flow.proto === 17 && (FLOW_PORTS.has(flow.srcPort) || FLOW_PORTS.has(flow.dstPort))) return "mgmt" if (isTunnelProto(flow.proto, flow.srcPort, flow.dstPort)) return "overlay" if (isRfc1918(flow.src) && isRfc1918(flow.dst)) return "client_mesh" return "payload" } export interface PlaneTopology { clientIfaceNames: Set enHosts: Set jhHosts: Set } function hostHit(ip: string, hosts: Set): boolean { return Boolean(ip) && hosts.has(ip) } export function classifyFlowPlane( flow: PlaneFlowInput, topo?: PlaneTopology | null, ): FlowPlane { const lite = classifyFlowPlaneLite(flow) if (!topo) return lite if (lite === "mgmt") return "mgmt" if (lite === "overlay") return "overlay" const srcEn = hostHit(flow.src, topo.enHosts) || hostHit(flow.src, topo.jhHosts) const dstEn = hostHit(flow.dst, topo.enHosts) || hostHit(flow.dst, topo.jhHosts) if (srcEn && dstEn && isPublicV4(flow.src) && isPublicV4(flow.dst)) return "overlay" if (lite === "client_mesh") { const inClient = topo.clientIfaceNames.has(flow.inIface) const outClient = Boolean(flow.outIface && topo.clientIfaceNames.has(flow.outIface)) if (inClient || outClient || (isRfc1918(flow.src) && isRfc1918(flow.dst))) return "client_mesh" } return "payload" } export function shouldKeepPlane( plane: FlowPlane, opts: { excludeMesh?: boolean; excludeOverlay?: boolean }, ): boolean { if (plane === "mgmt") return false if (opts.excludeMesh !== false && plane === "client_mesh") return false if (opts.excludeOverlay !== false && plane === "overlay") return false return true } export function flowBps(bytes: number, startMs: number, endMs: number, windowSec: number): number { if (startMs > 0 && endMs > startMs) { const sec = Math.max(1, (endMs - startMs) / 1000) return (bytes * 8) / sec } return (bytes * 8) / Math.max(1, windowSec) }