diff --git a/backend/package.json b/backend/package.json index ceca816a..2e00ef99 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +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:netflow-settings": "tsx src/services/traffic-flow-key-execution.test.ts && 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/services/traffic-flow-key-execution.test.ts b/backend/src/services/traffic-flow-key-execution.test.ts new file mode 100644 index 00000000..ba886e92 --- /dev/null +++ b/backend/src/services/traffic-flow-key-execution.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import { ensureTrafficFlowHostKeys } from "./traffic-flow-key-execution.js" +let pair = {publicKey:"",privateKey:""},generated=0,writes=0 +let release!:()=>void +const bothRead = new Promise(resolve=>{release=resolve}) +let initialReads=0 +const deps = { + read:async()=>{const snapshot={...pair}; if(initialReads<2){initialReads++;if(initialReads===2)release();await bothRead} return snapshot}, + generate:()=>{generated++;return {publicKey:"public-"+generated,privateKey:"private-"+generated}}, + installIfMissing:async(keys:typeof pair)=>{writes++;if(pair.publicKey&&pair.privateKey)return false;pair=keys;return true}, +} +const results=await Promise.all([ensureTrafficFlowHostKeys(deps),ensureTrafficFlowHostKeys(deps)]) +assert.equal(generated,2);assert.equal(writes,2);assert.equal(results.filter(r=>r.created).length,1) +assert.equal(results[0].publicKey,results[1].publicKey);assert.equal(results[0].publicKey,pair.publicKey) +const before={...pair};assert.equal((await ensureTrafficFlowHostKeys(deps)).created,false);assert.deepEqual(pair,before);assert.equal(writes,2) +await assert.rejects(ensureTrafficFlowHostKeys({read:async()=>({publicKey:"",privateKey:""}),generate:deps.generate,installIfMissing:async()=>true}),/не подтверждено/) +let called=0 +await assert.rejects(ensureTrafficFlowHostKeys({read:async()=>({publicKey:"",privateKey:""}),generate:deps.generate,installIfMissing:async()=>{called++;throw Error("DB failure")}}),/DB failure/) +assert.equal(called,1);console.log("NetFlow key execution: concurrent callers share winner, existing pair unchanged, unknown/failure never retried") diff --git a/backend/src/services/traffic-flow-key-execution.ts b/backend/src/services/traffic-flow-key-execution.ts new file mode 100644 index 00000000..15a2ae5d --- /dev/null +++ b/backend/src/services/traffic-flow-key-execution.ts @@ -0,0 +1,15 @@ +type KeyPair = { publicKey: string; privateKey: string } +type Dependencies = { + read: () => Promise + generate: () => KeyPair + installIfMissing: (keys: KeyPair) => Promise +} +/** A conditional database write selects the winner across processes; no existing pair is rotated. */ +export async function ensureTrafficFlowHostKeys(deps: Dependencies): Promise<{ publicKey: string; created: boolean }> { + const existing = await deps.read() + if (existing.publicKey && existing.privateKey) return { publicKey: existing.publicKey, created: false } + const created = await deps.installIfMissing(deps.generate()) + const confirmed = await deps.read() + if (!confirmed.publicKey || !confirmed.privateKey) throw new Error("Создание ключей NetFlow не подтверждено") + return { publicKey: confirmed.publicKey, created } +} diff --git a/backend/src/services/traffic-flow-settings.ts b/backend/src/services/traffic-flow-settings.ts index 700abeb2..f2817504 100644 --- a/backend/src/services/traffic-flow-settings.ts +++ b/backend/src/services/traffic-flow-settings.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm" +import { and, eq, or } from "drizzle-orm" import { db } from "../db/index.js" import { parseJsonArray } from "../db/json.js" import { trafficFlowSettings } from "../db/schema.js" import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow" +import { ensureTrafficFlowHostKeys } from "./traffic-flow-key-execution.js" import { generateWireGuardKeyPair } from "./wg-keys.js" type SettingsRow = NonNullable>> @@ -38,7 +39,7 @@ export async function getTrafficFlowSettingsRow(): Promise { prefix: "10.255.254.0/24", createdAt: now, updatedAt: now, - }) + }).onConflictDoNothing() row = await readSettingsRow() settingsRowCache = row! return settingsRowCache @@ -79,39 +80,31 @@ export async function toTrafficFlowSettingsDto( } export async function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) { - const row = await getTrafficFlowSettingsRow() - await db.update(trafficFlowSettings).set({ - enabled: patch.enabled ?? row.enabled, - collectorIp: patch.collectorIp ?? row.collectorIp, - flowListenPort: patch.flowListenPort ?? row.flowListenPort, - wgListenPort: patch.wgListenPort ?? row.wgListenPort, - prefix: patch.prefix ?? row.prefix, - publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint, - hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId, - retentionHours: patch.retentionHours ?? row.retentionHours, - topN: patch.topN ?? row.topN, - mapServiceMinSharePct: patch.mapServiceMinSharePct == null - ? row.mapServiceMinSharePct - : Math.min(100, Math.max(0, patch.mapServiceMinSharePct)), - updatedAt: nowIso(), - }).where(eq(trafficFlowSettings.id, 1)) + await getTrafficFlowSettingsRow() + // Do not copy untouched fields from a potentially stale cache into a concurrent update. + await db.update(trafficFlowSettings).set({ ...patch, updatedAt: nowIso() }).where(eq(trafficFlowSettings.id, 1)) invalidateTrafficFlowSettingsCache() return await getTrafficFlowSettingsRow() } export async function ensureHostKeys(): Promise<{ publicKey: string; created: boolean }> { - const row = await getTrafficFlowSettingsRow() - if (row.hostPublicKey && row.hostPrivateKey) { - return { publicKey: row.hostPublicKey, created: false } - } - const keys = generateWireGuardKeyPair() - await db.update(trafficFlowSettings).set({ - hostPublicKey: keys.publicKey, - hostPrivateKey: keys.privateKey, - updatedAt: nowIso(), - }).where(eq(trafficFlowSettings.id, 1)) - invalidateTrafficFlowSettingsCache() - return { publicKey: keys.publicKey, created: true } + await getTrafficFlowSettingsRow() + return ensureTrafficFlowHostKeys({ + read: async () => { + invalidateTrafficFlowSettingsCache() + const row = await getTrafficFlowSettingsRow() + return { publicKey: row.hostPublicKey, privateKey: row.hostPrivateKey } + }, + generate: generateWireGuardKeyPair, + installIfMissing: async keys => { + const rows = await db.update(trafficFlowSettings).set({ + hostPublicKey: keys.publicKey, hostPrivateKey: keys.privateKey, updatedAt: nowIso(), + }).where(and(eq(trafficFlowSettings.id, 1), or(eq(trafficFlowSettings.hostPublicKey, ""), eq(trafficFlowSettings.hostPrivateKey, "")))) + .returning({ id: trafficFlowSettings.id }) + invalidateTrafficFlowSettingsCache() + return rows.length === 1 + }, + }) } export async function upsertHostPeer(peer: FlowHostPeer) { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index d14bd52c..22861f11 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -254,3 +254,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 ещё не приняты. + +Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index c36be8fc..71842658 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.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.3.4.4.3 — конкурентные настройки и ключи NetFlow (2026-10-03) + +- Settings UPDATE изменяет только поля patch, не переносит соседние настройки из старого cache. Первичная вставка singleton использует onConflictDoNothing, параллельная инициализация не падает из-за дубликата. +- Генерация ключей использует условный UPDATE только при неполной паре и fresh read после записи. Конкурирующий запрос получает public key победителя с created=false; существующая полная пара не вращается. Ошибка записи/неподтверждённая пара не повторяются автоматически. +- DI race test принудительно синхронизирует два исходных чтения: оба кандидата созданы, установлен один, оба ответа показывают одну пару. HTTP/JWT профиль, backend build и ESLint прошли. Атомарное условие SQL проверено типами/ревью, реальная межпроцессная гонка PG не проверена локальными mock tests. +- Далее 11Б.3.4.4.4: атомарный purge и независимые ошибки обслуживания/событий; затем форма NetFlow. + ## Блок 11Б.3.4.4.2 — backend NetFlow и приватный экспорт (2026-10-03) - GET host-files больше не вызывает ensureHostKeys: без приватного ключа возвращает 409 с предложением явно создать ключи. Экспорт содержит приватный ключ, поэтому требует traffic:write; обычные settings GET остаются traffic:read. AUTH_REQUIRED=false и остальные правила не менялись. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index f604a0d0..f0fdfabe 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -182,3 +182,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 ещё не приняты. + +Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.