From 8b017bab3f7bed15cf5a8b6890ba0f90d2d2c7e2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 12:31:51 +0700 Subject: [PATCH] =?UTF-8?q?fix(netflow):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B0=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D1=8B?= =?UTF-8?q?=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 1 + backend/src/lib/permissions.ts | 1 + .../src/plugins/auth.netflow-settings.test.ts | 24 +++++++++++ .../src/routes/traffic-flow-settings.test.ts | 32 +++++++++++++++ backend/src/routes/traffic-flow-settings.ts | 41 +++++++++++++++++++ backend/src/routes/traffic-flow.ts | 39 ++++-------------- docs/ux-ui-plan.md | 2 + docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-11b.md | 2 + 9 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 backend/src/plugins/auth.netflow-settings.test.ts create mode 100644 backend/src/routes/traffic-flow-settings.test.ts create mode 100644 backend/src/routes/traffic-flow-settings.ts diff --git a/backend/package.json b/backend/package.json index 9474b79d..ceca816a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates", + "test:netflow-settings": "tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts", "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index 1daf12de..9ad13982 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -42,6 +42,7 @@ type Rule = { } const RULES: Rule[] = [ + { methods: ["GET"], match: path => path === "/api/traffic/flow/host-files", permission: "mm:traffic:write" }, { methods: ["GET"], match: path => path === "/api/geoip", permission: "mm:traffic:read" }, { methods: ["PUT", "POST"], match: path => path === "/api/geoip" || path === "/api/geoip/update", permission: "mm:traffic:write" }, { diff --git a/backend/src/plugins/auth.netflow-settings.test.ts b/backend/src/plugins/auth.netflow-settings.test.ts new file mode 100644 index 00000000..bf3a8dbc --- /dev/null +++ b/backend/src/plugins/auth.netflow-settings.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { SignJWT } from "jose" +process.env.AUTH_REQUIRED = "true" +process.env.AUTH_JWT_SECRET = "netflow-test-secret" +process.env.AUTH_ISSUER = "https://auth.test.local" +process.env.NODE_ENV = "test" +const { default: authPlugin, requireAuth } = await import("./auth.js") +const { permissionForRequest } = await import("../lib/permissions.js") +assert.equal(permissionForRequest("GET","/api/traffic/flow/host-files?download=true"),"mm:traffic:write") +assert.equal(permissionForRequest("GET","/api/traffic/flow/settings"),"mm:traffic:read") +const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth) +let invoked=0 +for(const [method,url] of [["GET","/api/traffic/flow/settings"],["GET","/api/traffic/flow/host-files"],["POST","/api/traffic/flow/settings/generate-keys"],["PUT","/api/traffic/flow/settings"]] as const) app.route({method,url,handler:async()=>{invoked++;return {ok:true}}}) +for(const permission of ["mm:dashboard:read","mm:traffic:read","mm:traffic:write","mm:traffic:admin","mm:settings:admin"]){ + const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("netflow-test-secret")) + for(const [method,url] of [["GET","/api/traffic/flow/settings"],["GET","/api/traffic/flow/host-files"],["POST","/api/traffic/flow/settings/generate-keys"],["PUT","/api/traffic/flow/settings"]] as const){ + const before=invoked;const response=await app.inject({method,url,headers:{authorization:"Bearer "+token}}) + const allowed=permission==="mm:traffic:write"||permission==="mm:traffic:admin"||(permission==="mm:traffic:read"&&url.endsWith("/settings")&&method==="GET") + assert.equal(response.statusCode,allowed?200:403,permission+" "+method+" "+url);assert.equal(invoked,before+(allowed?1:0)) + } +} +assert.equal((await app.inject({method:"GET",url:"/api/traffic/flow/host-files"})).statusCode,401) +await app.close();console.log("NetFlow JWT gate: private export requires traffic write, settings remain readable") diff --git a/backend/src/routes/traffic-flow-settings.test.ts b/backend/src/routes/traffic-flow-settings.test.ts new file mode 100644 index 00000000..1e4a63c2 --- /dev/null +++ b/backend/src/routes/traffic-flow-settings.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js" +import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow" +let settings: TrafficFlowSettingsDto = { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 0, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] } +let updates = 0, starts = 0, keys = 0, exports = 0, failWrite = false, failRead = false, failStart = false +const app = Fastify() +await app.register(createTrafficFlowSettingsRoutes({ + read: async () => { if(failRead) throw Error("read unavailable"); return settings }, + update: async patch => { updates++; if(failWrite) throw Error("write unavailable"); settings = {...settings,...patch} }, + startListener: async () => { starts++; if(failStart) throw Error("listener unavailable") }, + ensureKeys: async () => { keys++; const created = !settings.hasHostPrivateKey; settings = {...settings, hostPublicKey: "fixture-public",hasHostPrivateKey:true}; return {publicKey:settings.hostPublicKey,created} }, + hasPrivateKey: async () => settings.hasHostPrivateKey, + hostFiles: async () => { exports++; return [{id:"wg",label:"WG",filename:"wg-flow.conf",code:"fixture-only"}] }, +}), {prefix:"/api"}) +const put = (payload:unknown) => app.inject({method:"PUT",url:"/api/traffic/flow/settings",payload}) +for(const bad of [{}, {ignored:true}, {flowListenPort:65536}, {wgListenPort:0}, {enabled:"false"}, {topN:1001}]) assert.equal((await put(bad)).statusCode,400) +assert.equal(updates,0); assert.equal(starts,0) +assert.equal((await app.inject({method:"GET",url:"/api/traffic/flow/host-files"})).statusCode,409) +assert.equal(keys,0); assert.equal(exports,0) +let response = await put({mapServiceMinSharePct:0,enabled:false}) +assert.equal(response.statusCode,200);assert.equal(response.json().ok,true);assert.equal(response.json().settings.mapServiceMinSharePct,0) +failStart = true; response = await put({flowListenPort:4740}) +assert.equal(response.statusCode,200);assert.equal(response.json().settings.flowListenPort,4740);assert.equal(response.json().listenerError,"listener unavailable") +assert.equal(updates,2);assert.equal(starts,2) +failWrite = true; response = await put({flowListenPort:4741}); assert.equal(response.statusCode,500);assert.equal(starts,2);assert.equal(settings.flowListenPort,4740) +failWrite = false;failStart = false;failRead = true;response = await put({flowListenPort:4742});assert.equal(response.statusCode,500);assert.equal(settings.flowListenPort,4742);assert.equal(updates,4) +failRead = false +response = await app.inject({method:"POST",url:"/api/traffic/flow/settings/generate-keys"});assert.equal(response.json().created,true) +response = await app.inject({method:"POST",url:"/api/traffic/flow/settings/generate-keys"});assert.equal(response.json().created,false) +response = await app.inject({method:"GET",url:"/api/traffic/flow/host-files"});assert.equal(response.statusCode,200);assert.equal(keys,2);assert.equal(exports,1) +await app.close();console.log("NetFlow settings HTTP: strict patch, persistence/listener outcomes, read failure unknown, export GET never creates keys") diff --git a/backend/src/routes/traffic-flow-settings.ts b/backend/src/routes/traffic-flow-settings.ts new file mode 100644 index 00000000..71815c40 --- /dev/null +++ b/backend/src/routes/traffic-flow-settings.ts @@ -0,0 +1,41 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { z } from "zod" +import { trafficFlowSettingsPatchSchema, type TrafficFlowSettingsDto, type TrafficFlowSettingsPatch, type TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" + +type Dependencies = { + read: () => Promise + update: (patch: TrafficFlowSettingsPatch) => Promise + startListener: () => Promise + ensureKeys: () => Promise<{ publicKey: string; created: boolean }> + hasPrivateKey: () => Promise + hostFiles: () => Promise +} +const patchSchema = trafficFlowSettingsPatchSchema.extend({ + flowListenPort: z.number().int().min(1).max(65535).optional(), + wgListenPort: z.number().int().min(1).max(65535).optional(), +}).strict().refine(patch => Object.values(patch).some(value => value !== undefined)) + +export function createTrafficFlowSettingsRoutes(deps: Dependencies): FastifyPluginAsyncZod { + return async app => { + app.get("/traffic/flow/settings", async () => deps.read()) + app.put("/traffic/flow/settings", async (req, reply) => { + const parsed = patchSchema.safeParse(req.body) + if (!parsed.success) return reply.status(400).send({ error: "Укажите допустимые настройки NetFlow", details: parsed.error.flatten() }) + await deps.update(parsed.data) + let listenerError: string | undefined + try { await deps.startListener() } catch (error) { + listenerError = error instanceof Error ? error.message : String(error) + } + // The listener starts asynchronously. This receipt confirms persistence, not UDP readiness. + return { ok: true, settings: await deps.read(), ...(listenerError !== undefined ? { listenerError } : {}) } + }) + app.post("/traffic/flow/settings/generate-keys", async () => { + const result = await deps.ensureKeys() + return { ok: true, ...result, settings: await deps.read() } + }) + app.get("/traffic/flow/host-files", async (_req, reply) => { + if (!await deps.hasPrivateKey()) return reply.status(409).send({ error: "Сначала создайте ключи хоста NetFlow" }) + return { files: await deps.hostFiles() } + }) + } +} diff --git a/backend/src/routes/traffic-flow.ts b/backend/src/routes/traffic-flow.ts index 12bc93f5..69186e44 100644 --- a/backend/src/routes/traffic-flow.ts +++ b/backend/src/routes/traffic-flow.ts @@ -3,8 +3,8 @@ import type { FastifyReply, FastifyRequest } from "fastify" import { env } from "../config.js" import { trafficFlowOverlayRequestSchema, - trafficFlowSettingsPatchSchema, } from "@mmapp/contracts/traffic-flow" +import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js" import { ensureHostKeys, getTrafficFlowSettingsRow, @@ -146,35 +146,14 @@ function sleep(ms: number, signal: AbortSignal): Promise { } const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => { - app.get("/traffic/flow/settings", async (_req, reply) => { - return reply.send(await toTrafficFlowSettingsDto(getFlowListenerState())) - }) - - app.put("/traffic/flow/settings", async (req, reply) => { - const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {}) - if (!parsed.success) { - return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) - } - await updateTrafficFlowSettings(parsed.data) - await startTrafficFlowListener() - return reply.send({ ok: true, settings: await toTrafficFlowSettingsDto(getFlowListenerState()) }) - }) - - app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => { - const result = await ensureHostKeys() - return reply.send({ - ok: true, - created: result.created, - publicKey: result.publicKey, - settings: await toTrafficFlowSettingsDto(getFlowListenerState()), - }) - }) - - app.get("/traffic/flow/host-files", async (_req, reply) => { - const row = await getTrafficFlowSettingsRow() - if (!row.hostPrivateKey) await ensureHostKeys() - return reply.send({ files: await listTrafficFlowHostFiles() }) - }) + await app.register(createTrafficFlowSettingsRoutes({ + read: () => toTrafficFlowSettingsDto(getFlowListenerState()), + update: async patch => { await updateTrafficFlowSettings(patch) }, + startListener: startTrafficFlowListener, + ensureKeys: ensureHostKeys, + hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey), + hostFiles: listTrafficFlowHostFiles, + })) app.post("/traffic/flow/purge", async (_req, reply) => { try { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index d0df230e..d14bd52c 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -252,3 +252,5 @@ REST запрос → ожидание → подтверждённый output / Продолжение 2026-10-03: завершён 11Б.3.4.4.1 (строгие NetFlow API, 5 unit и 3 collector/probes E2E, typecheck/ESLint). Следующий блок — 11Б.3.4.4.2: backend экспорт/права и подтверждение сохранения отдельно от запуска listener. Purge и форма ещё требуют доработки. + +Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 3f1c354d..c36be8fc 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.1; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.3.4.4.2 — backend NetFlow и приватный экспорт (2026-10-03) + +- GET host-files больше не вызывает ensureHostKeys: без приватного ключа возвращает 409 с предложением явно создать ключи. Экспорт содержит приватный ключ, поэтому требует traffic:write; обычные settings GET остаются traffic:read. AUTH_REQUIRED=false и остальные правила не менялись. +- Четыре маршрута settings/keys/export выделены в DI plugin. PUT отклоняет пустой patch, неизвестные поля и порты вне 1–65535 до изменения; подтверждение сохранения возвращает отдельный listenerError при последующем сбое запуска. Фактическая UDP готовность остаётся listenerBound, асинхронный запуск её не обещает. Неудача read после write возвращает 500/неизвестный результат без повторного write. +- HTTP inject и подписанные JWT проверили доступ до handler, GET без генерации, отказ записи, ошибку запуска, неизвестный read и created true/false. Backend build, ESLint и permissions unit прошли. DI tests не доказывают реальный PG/worker/Portal. +- Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочная запись, затем атомарность и частичные результаты purge. UI ещё не показывает новый listenerError и не принят; это следующий блок формы. + ## Блок 11Б.3.4.4.1 — строгие ответы NetFlow (2026-10-03) - Settings GET/PUT, генерация ключей, экспорт и purge проверяют фактический DTO и буквальное подтверждение. PUT отправляет только валидный непустой patch и проверяет каждый переданный параметр в ответе; ошибки не запускают повторный запрос. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index c946fbfa..f604a0d0 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -180,3 +180,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Продолжение 2026-10-03: завершён 11Б.3.4.4.1 (строгие NetFlow API, 5 unit и 3 collector/probes E2E, typecheck/ESLint). Следующий блок — 11Б.3.4.4.2: backend экспорт/права и подтверждение сохранения отдельно от запуска listener. Purge и форма ещё требуют доработки. + +Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты.