fix(netflow): защитить приватный экспорт и разделить результаты сохранения
This commit is contained in:
1 parent
4af4fdc544
commit
8b017bab3f
9 files changed
+120
-31
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ещё не приняты.
|
||||
@@ -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 и проверяет каждый переданный параметр в ответе; ошибки не запускают повторный запрос.
|
||||
|
||||
@@ -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 ещё не приняты.
|
||||
Reference in new issue
Block a user