From f0dc5acfd332ce8163c378504e093b2d358fc7cc Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 6 Sep 2026 22:58:31 +0700 Subject: [PATCH] =?UTF-8?q?fix(traffic):=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20JH=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20WG=20=D0=B8=20=D0=BF=D0=B0=D1=82=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20traffic-flow=20=D0=BF=D0=BE=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232. Co-authored-by: Cursor --- .../src/services/traffic-flow-host-files.ts | 24 +++-- backend/src/services/traffic-flow-overlay.ts | 93 ++++++++----------- components/traffic/flow-overlay-sheet.tsx | 30 +++--- deploy/docker-compose.cdn-mm.yml | 4 + packages/contracts/src/traffic-flow.ts | 1 + 5 files changed, 71 insertions(+), 81 deletions(-) diff --git a/backend/src/services/traffic-flow-host-files.ts b/backend/src/services/traffic-flow-host-files.ts index 68a2b34..5a51810 100644 --- a/backend/src/services/traffic-flow-host-files.ts +++ b/backend/src/services/traffic-flow-host-files.ts @@ -16,46 +16,44 @@ export function buildHostWgQuickConf(): string { publicKey: p.publicKey, allowedIps: p.allowedIps, comment: p.name, + endpoint: p.endpoint, + persistentKeepalive: p.endpoint ? 25 : undefined, })), }) } export function buildHostComposeSnippet(): string { const row = getTrafficFlowSettingsRow() - return `# IPFIX listener: публиковать UDP только на WG-адресе хоста, не на 0.0.0.0 -# Поднимите wg-quick@wg-flow, затем раскомментируйте ports у backend. + return `# Вставить в /opt/cdn-mm/docker-compose.yml под services.backend +# На хосте сначала: wg-quick up wg-flow (адрес ${row.collectorIp}) +# затем: docker compose up -d backend +# Traefik не трогать. UDP ${row.flowListenPort} не публиковать на 0.0.0.0. -services: - backend: - ports: - - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp" environment: FLOW_LISTEN_HOST: "0.0.0.0" + ports: + - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp" ` } export function buildHostNftSnippet(): string { const row = getTrafficFlowSettingsRow() - return `# Firewall хоста Docker MM (nftables). UDP ${row.flowListenPort} наружу НЕ открывать. + return `# Firewall хоста Docker MM. WG — клиент к JH:13232 (исходящий). +# UDP ${row.flowListenPort} наружу НЕ открывать. table inet filter { chain input { type filter hook input priority 0; iifname "wg-flow" udp dport ${row.flowListenPort} accept - udp dport ${row.wgListenPort} accept comment "WireGuard handshake" udp dport ${row.flowListenPort} drop } } - -# ufw (если используете): -# ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow' -# ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public' ` } export function buildHostUfwSnippet(): string { const row = getTrafficFlowSettingsRow() return [ - `ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`, + `# WG клиент: входящий listen не нужен`, `ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`, ].join("\n") } diff --git a/backend/src/services/traffic-flow-overlay.ts b/backend/src/services/traffic-flow-overlay.ts index 04f7923..6a2215f 100644 --- a/backend/src/services/traffic-flow-overlay.ts +++ b/backend/src/services/traffic-flow-overlay.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm" import { db } from "../db/index.js" import { servers } from "../db/schema.js" import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow" -import { MikrotikClient, MikrotikError } from "./mikrotik.js" +import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js" import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js" import { asRosArray, @@ -16,7 +16,6 @@ import { import { ensureHostKeys, getTrafficFlowSettingsRow, - updateTrafficFlowSettings, upsertHostPeer, } from "./traffic-flow-settings.js" import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js" @@ -42,11 +41,13 @@ export function allocateOverlayAddress(prefix: string, collectorIp: string, serv throw new Error("Нет свободных адресов в префиксе wg-flow") } -function linuxPeerBlock(publicKey: string, address: string, comment: string): string { +function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string { return [ `[Peer]`, `PublicKey = ${publicKey}`, `AllowedIPs = ${address}/32`, + `Endpoint = ${endpoint}:${JH_LISTEN_PORT}`, + `PersistentKeepalive = 25`, comment ? `# ${comment}` : "", ].filter(Boolean).join("\n") } @@ -91,51 +92,34 @@ async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): return true } -async function listFlowInterfaces(client: MikrotikClient): Promise { - const ifaces = asRosArray<{ name?: string; type?: string; disabled?: string }>(await client.get("/interface")) - const names = ifaces - .filter((i) => { - if ((i.disabled ?? "false") === "true") return false - const name = i.name ?? "" - if (!name || name === IFACE_NAME || /^lo/i.test(name)) return false - const type = (i.type ?? "").toLowerCase() - return type.includes("ether") || type.includes("gre") || type === "vlan" - }) - .map((i) => i.name ?? "") - .filter(Boolean) - .slice(0, 8) - return names.join(",") || "all" -} - async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, port: number): Promise { - const interfaces = await listFlowInterfaces(client) - try { - await client.patch("/ip/traffic-flow", toRosBody({ - enabled: "yes", - interfaces, - "active-flow-timeout": "1m", - "inactive-flow-timeout": "15s", - })) - } catch { - await client.put("/ip/traffic-flow", toRosBody({ - enabled: "yes", - interfaces, - })) + const body = toRosBody({ + enabled: "yes", + interfaces: "all", + "active-flow-timeout": "1m", + "inactive-flow-timeout": "15s", + }) + const rows = asRosArray>(await client.get("/ip/traffic-flow")) + const id = rows[0] ? rosRowId(rows[0]) : "" + if (id) { + await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body) + } else { + await client.post("/ip/traffic-flow/set", body) } const targets = asRosArray>(await client.get("/ip/traffic-flow/target")) const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp) - const body = toRosBody({ + const targetBody = toRosBody({ "dst-address": collectorIp, port: String(port), version: "ipfix", }) if (existing) { - const id = rosRowId(existing) - if (id) await patchRosPath(client, `/ip/traffic-flow/target/${encodeURIComponent(id)}`, body) + const targetId = rosRowId(existing) + if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody) return } - await client.put("/ip/traffic-flow/target", body) + await client.put("/ip/traffic-flow/target", targetBody) } export function usablePublicHost(raw: string | undefined): string { @@ -155,30 +139,22 @@ export async function applyFlowOverlay( ): Promise { const steps: string[] = [] const keys = ensureHostKeys() - let settings = getTrafficFlowSettingsRow() + const settings = getTrafficFlowSettingsRow() const hostPublicKey = settings.hostPublicKey || keys.publicKey if (!hostPublicKey) { throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 }) } - const endpointHost = ( - opts?.publicEndpoint?.trim() - || settings.publicEndpoint.trim() - || usablePublicHost(opts?.requestHost) - ).trim() - if (!endpointHost) { - throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 }) - } - const server = getEnabledServerById(String(serverIdRaw)) if (!server || !server.enabled) { throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 }) } - if (endpointHost !== settings.publicEndpoint.trim()) { - updateTrafficFlowSettings({ publicEndpoint: endpointHost }) - settings = getTrafficFlowSettingsRow() + const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim() + if (!endpointHost) { + throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 }) } + const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}` const taken = new Set( db.select({ ip: servers.mgmtTunnelIp }).from(servers).all() @@ -218,19 +194,23 @@ export async function applyFlowOverlay( interface: IFACE_NAME, "public-key": hostPublicKey, "allowed-address": `${settings.collectorIp}/32`, - "endpoint-address": endpointHost, - "endpoint-port": String(settings.wgListenPort), - "persistent-keepalive": "25", comment: "MM traffic-flow collector", name: "mm-collector", } if (!peer) { await putWireguardPeer(client, peerBody) - steps.push("Добавлен пир на pubkey хоста MM") + steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)") } else { const id = rosRowId(peer) - if (id) await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody) - steps.push("Пир хоста MM обновлён") + const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim()) + if (hadEndpoint && id) { + await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`) + await putWireguardPeer(client, peerBody) + steps.push("Пир пересоздан как сервер (endpoint снят)") + } else if (id) { + await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody) + steps.push("Пир хоста MM обновлён") + } } const routeDst = `${settings.collectorIp}/32` @@ -273,6 +253,7 @@ export async function applyFlowOverlay( publicKey, allowedIps: [`${address}/32`], address, + endpoint: peerEndpoint, }) return { @@ -281,7 +262,7 @@ export async function applyFlowOverlay( interfaceName: IFACE_NAME, address, publicKey, - linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host), + linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost), trafficFlow: true, steps, hostFiles: listTrafficFlowHostFiles(), diff --git a/components/traffic/flow-overlay-sheet.tsx b/components/traffic/flow-overlay-sheet.tsx index 2d4cf61..8f8fa19 100644 --- a/components/traffic/flow-overlay-sheet.tsx +++ b/components/traffic/flow-overlay-sheet.tsx @@ -13,7 +13,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" -import { applyTrafficFlowOverlay, getTrafficFlowSettings } from "@/shared/api/traffic-flow" +import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow" import type { ServerRead } from "@mmapp/contracts/servers" import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow" import { @@ -52,11 +52,17 @@ function FlowOverlaySheet({ setResult(null) setCopied(false) setTab("wg-quick") - setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "") - void getTrafficFlowSettings(backendUrl) - .then((s) => setEndpoint(s.publicEndpoint)) - .catch(() => setEndpoint("")) - }, [open, jumpHosts, backendUrl]) + const first = jumpHosts[0] + const nextId = first ? String(first.id) : "" + setServerId(nextId) + setEndpoint(first?.host ?? "") + }, [open, jumpHosts]) + + function handleServerChange(id: string) { + setServerId(id) + const selected = jumpHosts.find((s) => String(s.id) === id) + if (selected) setEndpoint(selected.host) + } const formats = useMemo((): TrafficFlowHostFile[] => { if (!result) return [] @@ -106,7 +112,7 @@ function FlowOverlaySheet({ Подключить jump-host - Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall). + Создаст wg-flow на выбранном MikroTik (сервер, listen 13232) и сразу выдаст wg-quick / compose для Linux-хоста Docker MM (клиент).
@@ -114,7 +120,7 @@ function FlowOverlaySheet({ setEndpoint(e.target.value)} - placeholder="203.0.113.10" + placeholder="jh.example.com" autoComplete="off" /> @@ -143,7 +149,7 @@ function FlowOverlaySheet({ Ключи и UDP 4739 - Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0. + Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP хоста, не на 0.0.0.0 контейнера.
    diff --git a/deploy/docker-compose.cdn-mm.yml b/deploy/docker-compose.cdn-mm.yml index 54585b5..3000443 100644 --- a/deploy/docker-compose.cdn-mm.yml +++ b/deploy/docker-compose.cdn-mm.yml @@ -134,6 +134,10 @@ services: AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env} AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} + # IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow + FLOW_LISTEN_HOST: "0.0.0.0" + # 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 1c7131b..036c6db 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -6,6 +6,7 @@ export const flowHostPeerSchema = z.object({ publicKey: z.string().min(1), allowedIps: z.array(z.string().min(1)).min(1), address: z.string().min(1), + endpoint: z.string().optional(), }) export const trafficFlowSettingsDtoSchema = z.object({