fix(netflow): защитить приватный экспорт и разделить результаты сохранения

This commit is contained in:
Denozordec committed 2026-10-03 12:31:51 +07:00
1 parent 4af4fdc544
commit 8b017bab3f
9 files changed
+120 -31

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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" },
{
@@ -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")
@@ -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")
@@ -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<TrafficFlowSettingsDto>
update: (patch: TrafficFlowSettingsPatch) => Promise<void>
startListener: () => Promise<void>
ensureKeys: () => Promise<{ publicKey: string; created: boolean }>
hasPrivateKey: () => Promise<boolean>
hostFiles: () => Promise<TrafficFlowHostFile[]>
}
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() }
})
}
}
+9 -30
View File
@@ -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<void> {
}
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 {
+2
View File
@@ -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 ещё не приняты.
+8 -1
View File
@@ -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 и проверяет каждый переданный параметр в ответе; ошибки не запускают повторный запрос.
+2
View File
@@ -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 ещё не приняты.