From cf68b59b3f4a7f960df028c5be95c9f0c485704f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 7 Sep 2026 00:37:12 +0700 Subject: [PATCH] =?UTF-8?q?fix(traffic):=20=D1=81=D0=B1=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20src-address=20Traffic=20Flow?= =?UTF-8?q?=20=D0=B2=20=D0=B0=D0=B2=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target с адресом wg-flow помечался invalid, IPFIX не уходил на коллектор. Co-authored-by: Cursor --- app/(main)/traffic/page.tsx | 46 +++++++++++++++---- .../src/services/traffic-flow-host-files.ts | 12 ++--- backend/src/services/traffic-flow-ingest.ts | 3 ++ backend/src/services/traffic-flow-overlay.ts | 16 +++++-- .../src/services/traffic-flow-parse.test.ts | 4 +- backend/src/services/traffic-flow-settings.ts | 7 +++ deploy/docker-compose.cdn-mm.yml | 5 +- packages/contracts/src/traffic-flow.ts | 3 ++ 8 files changed, 72 insertions(+), 24 deletions(-) diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 6e65f3a..6d5d37a 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -50,6 +50,31 @@ function addSeries(a: number[], b: number[]): number[] { return a.map((v, i) => v + (b[i] ?? 0)) } +function flowIngestLine(stats: FlowStatsDto | null): string | null { + if (!stats) return null + const listener = stats.listenerBound + ? (stats.listenerAddress ?? "слушает") + : "не слушает" + const last = stats.lastDatagramAt + ? new Date(stats.lastDatagramAt).toLocaleString("ru-RU") + : "—" + const exporter = stats.lastExporterIp ? ` · ${stats.lastExporterIp}` : "" + const err = stats.lastError ? ` · ${stats.lastError}` : "" + return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}` +} + +function flowEmptyHint(stats: FlowStatsDto | null): string | undefined { + if (!stats) return undefined + if (stats.lastError) return stats.lastError + if (stats.packetsReceived) { + return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но разговоры ещё не записаны.` + } + if (stats.listenerBound === false) { + return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически." + } + return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow." +} + // ─── data model ─────────────────────────────────────────────────────────────── interface BoundIfaceTraffic { @@ -969,6 +994,7 @@ export default function TrafficPage() { const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0) const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode)) + const ingestLine = flowIngestLine(flowStats) const flowKpiItems = [ { @@ -1081,9 +1107,16 @@ export default function TrafficPage() { {effectiveMode === "flows" ? (
-

- IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы. -

+
+

+ IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы. +

+ {ingestLine ? ( +

+ {ingestLine} +

+ ) : null} +
{TRAFFIC_RANGE_KEYS.map((key) => ( @@ -1116,12 +1149,7 @@ export default function TrafficPage() { "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF' ${override}OVEOF cd "\$COMPOSE_DIR" -docker compose up -d backend +docker compose up -d --force-recreate backend -echo "=== UDP \${FLOW_PORT} на хосте ===" +echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ===" ss -ulnp | grep -E "\${FLOW_PORT}" || true echo "=== PortBindings mmapp-backend ===" docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend echo "=== handshake (keepalive 25s к JH:13232) ===" wg show wg-flow -# ufw: исходящий WG не открывать; 4739 на WAN не публиковать -if command -v ufw >/dev/null 2>&1; then - ufw deny "\${FLOW_PORT}/udp" comment 'ipfix-not-public' || true -fi +# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0. +# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0." ` diff --git a/backend/src/services/traffic-flow-ingest.ts b/backend/src/services/traffic-flow-ingest.ts index f7bfa31..40f8e62 100644 --- a/backend/src/services/traffic-flow-ingest.ts +++ b/backend/src/services/traffic-flow-ingest.ts @@ -265,6 +265,9 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto { lastExporterIp: settings.lastExporterIp ?? null, lastError: settings.lastError || null, packetsReceived: settings.packetsReceived, + lastDatagramAt: settings.lastDatagramAt ?? null, + listenerBound: state.bound, + listenerAddress: state.address, } } diff --git a/backend/src/services/traffic-flow-overlay.ts b/backend/src/services/traffic-flow-overlay.ts index a4794f7..fd0c02a 100644 --- a/backend/src/services/traffic-flow-overlay.ts +++ b/backend/src/services/traffic-flow-overlay.ts @@ -14,10 +14,12 @@ import { toRosBody, } from "./wireguard-ros.js" import { + enableTrafficFlowIngest, ensureHostKeys, getTrafficFlowSettingsRow, upsertHostPeer, } from "./traffic-flow-settings.js" +import { startTrafficFlowListener } from "./traffic-flow-ingest.js" import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js" const IFACE_NAME = "wg-flow" @@ -92,11 +94,13 @@ async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): return true } +/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */ +export const FLOW_TARGET_SRC_AUTO = "0.0.0.0" + async function ensureTrafficFlow( client: MikrotikClient, collectorIp: string, port: number, - srcAddress: string, ): Promise { const body = toRosBody({ enabled: "yes", @@ -116,7 +120,7 @@ async function ensureTrafficFlow( const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp) const targetBody = toRosBody({ "dst-address": collectorIp, - "src-address": srcAddress, + "src-address": FLOW_TARGET_SRC_AUTO, port: String(port), version: "ipfix", }) @@ -238,8 +242,8 @@ export async function applyFlowOverlay( steps.push("Firewall input WG уже есть") } - await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort, address) - steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src ${address})`) + await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort) + steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`) const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false }) const created = listed.interfaces.find((i) => i.name === IFACE_NAME) @@ -262,6 +266,10 @@ export async function applyFlowOverlay( endpoint: peerEndpoint, }) + enableTrafficFlowIngest() + startTrafficFlowListener() + steps.push("Коллектор IPFIX на MM включён") + return { ok: true, serverId: server.id, diff --git a/backend/src/services/traffic-flow-parse.test.ts b/backend/src/services/traffic-flow-parse.test.ts index 29ba034..1cfc30d 100644 --- a/backend/src/services/traffic-flow-parse.test.ts +++ b/backend/src/services/traffic-flow-parse.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js" -import { allocateOverlayAddress } from "./traffic-flow-overlay.js" +import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js" function netflowV5One(): Buffer { const buf = Buffer.alloc(24 + 48) @@ -31,12 +31,12 @@ const taken = new Set(["10.255.254.2"]) assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3") assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3") -import { usablePublicHost } from "./traffic-flow-overlay.js" assert.equal(usablePublicHost("localhost:8000"), "") assert.equal(usablePublicHost("127.0.0.1"), "") assert.equal(usablePublicHost("192.168.1.10"), "") assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com") assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10") +assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0") resetFlowTemplatesForTests() { diff --git a/backend/src/services/traffic-flow-settings.ts b/backend/src/services/traffic-flow-settings.ts index 8196144..19f8795 100644 --- a/backend/src/services/traffic-flow-settings.ts +++ b/backend/src/services/traffic-flow-settings.ts @@ -121,6 +121,13 @@ export function recordFlowListenerError(message: string) { }).where(eq(trafficFlowSettings.id, 1)).run() } +export function enableTrafficFlowIngest() { + db.update(trafficFlowSettings).set({ + enabled: true, + updatedAt: nowIso(), + }).where(eq(trafficFlowSettings.id, 1)).run() +} + export function listHostPeers(): FlowHostPeer[] { return parsePeers(getTrafficFlowSettingsRow().peersJson) } diff --git a/deploy/docker-compose.cdn-mm.yml b/deploy/docker-compose.cdn-mm.yml index 3000443..b9421e2 100644 --- a/deploy/docker-compose.cdn-mm.yml +++ b/deploy/docker-compose.cdn-mm.yml @@ -136,8 +136,9 @@ services: AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} # IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow FLOW_LISTEN_HOST: "0.0.0.0" - # ports: - # - "10.255.254.1:4739:4739/udp" + # Сначала wg-quick@wg-flow (адрес 10.255.254.1), затем recreate backend. + ports: + - "10.255.254.1:4739:4739/udp" volumes: - ./data/mm:/app/data networks: diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index e8e91f9..36f24e4 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -91,6 +91,9 @@ export const flowStatsDtoSchema = z.object({ lastExporterIp: z.string().nullable().optional(), lastError: z.string().nullable().optional(), packetsReceived: z.number().int().nonnegative().optional(), + lastDatagramAt: z.string().nullable().optional(), + listenerBound: z.boolean().optional(), + listenerAddress: z.string().nullable().optional(), }) export type FlowHostPeer = z.infer