diff --git a/backend/src/routes/traffic-flow-settings.test.ts b/backend/src/routes/traffic-flow-settings.test.ts index 1e4a63c2..5cf0e9ef 100644 --- a/backend/src/routes/traffic-flow-settings.test.ts +++ b/backend/src/routes/traffic-flow-settings.test.ts @@ -14,7 +14,7 @@ await app.register(createTrafficFlowSettingsRoutes({ 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) +for(const bad of [{}, {ignored:true}, {flowListenPort:65536}, {wgListenPort:0}, {enabled:"false"}, {topN:1001}, {collectorIp:"not-an-ip"}, {collectorIp:"2001:db8::1"}, {prefix:"10.255.254.0/33"}, {publicEndpoint:"https://collector.test"}, {collectorIp:"$(fixture)"}, {publicEndpoint:"host.test:51821"}, {publicEndpoint:"host.test\nfixture"}]) 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) diff --git a/backend/src/routes/traffic-flow-settings.ts b/backend/src/routes/traffic-flow-settings.ts index 71815c40..49c9eefc 100644 --- a/backend/src/routes/traffic-flow-settings.ts +++ b/backend/src/routes/traffic-flow-settings.ts @@ -1,5 +1,4 @@ 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 = { @@ -10,10 +9,7 @@ type Dependencies = { 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)) +const patchSchema = trafficFlowSettingsPatchSchema.strict().refine(patch => Object.values(patch).some(value => value !== undefined)) export function createTrafficFlowSettingsRoutes(deps: Dependencies): FastifyPluginAsyncZod { return async app => { diff --git a/backend/src/services/traffic-flow-host-files.ts b/backend/src/services/traffic-flow-host-files.ts index b021f072..5b2bfdcd 100644 --- a/backend/src/services/traffic-flow-host-files.ts +++ b/backend/src/services/traffic-flow-host-files.ts @@ -1,11 +1,12 @@ import { generateNativeConf } from "./wireguard-config.js" import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js" -import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" +import { trafficFlowCollectorIpSchema, type TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow" const COMPOSE_DIR = "/opt/cdn-mm" export async function buildHostWgQuickConf(): Promise { const row = await getTrafficFlowSettingsRow() + trafficFlowCollectorIpSchema.parse(row.collectorIp) const peers = await listHostPeers() return generateNativeConf({ name: "wg-flow", @@ -25,6 +26,7 @@ export async function buildHostWgQuickConf(): Promise { export async function buildHostComposeOverride(): Promise { const row = await getTrafficFlowSettingsRow() + trafficFlowCollectorIpSchema.parse(row.collectorIp) return [ "# Docker Compose merge для /opt/cdn-mm", "# Не править docker-compose.yml. Traefik не трогать.", @@ -44,6 +46,7 @@ export async function buildHostComposeOverride(): Promise { export async function buildHostLinuxInstallSh(): Promise { const row = await getTrafficFlowSettingsRow() + trafficFlowCollectorIpSchema.parse(row.collectorIp) const conf = (await buildHostWgQuickConf()).replace(/\s+$/, "") + "\n" const override = await buildHostComposeOverride() const collector = row.collectorIp diff --git a/components/traffic/netflow-settings-panel.tsx b/components/traffic/netflow-settings-panel.tsx index a55aa579..a6222c57 100644 --- a/components/traffic/netflow-settings-panel.tsx +++ b/components/traffic/netflow-settings-panel.tsx @@ -133,7 +133,7 @@ function NetflowSettingsContent({ {needsReview ?

Проверьте журнал и состояние NetFlow. Повтор запустит новую операцию.

: null} - Ключи и UDP 4739 + Ключи и порт IPFIX Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0. @@ -204,7 +204,7 @@ function NetflowSettingsContent({

Туннель на сервере Docker MM

    {HOST_STEPS.map((s) => ( -
  1. {s}
  2. +
  3. {s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}
  4. ))}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index a5b27a8e..417d1382 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -264,3 +264,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.4.4.6: 10 NetFlow backend source E2E, 3 responsive, 6 снимков (320 и ПК просмотрены), регрессия GeoIP/collector прошли. Далее 11Б.3.4.4.7: общий IP/CIDR/DNS ввод/фактический порт и отказ конкурентному purge вместо очереди. Затем приёмка NetFlow и Evolution. Завершён 11Б.3.4.4.7.1: конкурентный purge получает 409 до удаления, неподтверждённый session lock соединения исключается из пула; DI/backend/build/lint/conflict E2E прошли. Реальный PG overlap не проверен. Далее 11Б.3.4.4.7.2 — общий IP/CIDR/DNS контракт и фактические порты. + +Завершён 11Б.3.4.4.7.2: общий NetFlow IP/CIDR/DNS/ports patch, draft и backend проверяют его; инструкция использует фактический порт. 14 E2E, unit/HTTP/JWT, lint и оба builds прошли. Далее 11Б.3.4.4.7.3 — согласованный host export/маска, затем приёмка NetFlow и Evolution. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bc367227..495df0e9 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.7.1; scheduler и GeoIP приняты локально; NetFlow API/backend/форма/source/adaptive проверены, далее общий сетевой ввод и Evolution | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.7.2; scheduler и GeoIP приняты локально; NetFlow API/backend/форма/source/adaptive проверены, далее общий сетевой ввод и Evolution | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.4.4.7.2 — единый сетевой ввод NetFlow (2026-10-03) + +- Общий contracts patch проверяет IPv4 коллектора, IPv4 CIDR (текущий allocator/host работает с IPv4), endpoint IP/DNS без протокола/порта, UDP 1–65535 и retention в диапазоне PG integer. Пустой endpoint допустим; корректные DNS/регистр/trailing dot/IPv6 endpoint сохраняются без подмены. Поддержка IPv6 overlay этим блоком не добавлена. +- Frontend draft и backend route используют тот же контракт, дубли port patch schemas убраны. Неверные строки/числа отклоняются до HTTP/DB, dirty остаётся. Host export дополнительно проверяет сохранённый collector IPv4 перед вставкой в shell/compose, существующие неподходящие записи требуют исправления. +- Инструкция IPFIX использует подтверждённый flowListenPort вместо фиксированного 4739; заголовок предупреждения больше не обещает default порт. CRUD/overlay allocation правила не менялись. +- Backend HTTP/JWT/key профиль, 2 form unit + 5 API unit, 14 form E2E, lint и backend/frontend production builds прошли. Реальный DNS/UDP/RouterOS/PG не проверен. +- Далее 11Б.3.4.4.7.3: экспорт wg-flow ещё задаёт /24 вместо сохранённой маски prefix и собирает файлы из нескольких чтений — нужна согласованная генерация из одного снимка. После этого локальная приёмка NetFlow и Evolution. + ## Блок 11Б.3.4.4.7.1 — отказ конкурентному purge NetFlow (2026-10-03) - Блокирующий advisory lock заменён для purge на pg_try_advisory_lock: второй запрос не становится новым удалением после завершения первого, сразу получает 409. Другие операции и общий withAdvisoryLock не менялись. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 4c76c708..2178c36e 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -192,3 +192,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.4.4.6: 10 NetFlow backend source E2E, 3 responsive, 6 снимков (320 и ПК просмотрены), регрессия GeoIP/collector прошли. Далее 11Б.3.4.4.7: общий IP/CIDR/DNS ввод/фактический порт и отказ конкурентному purge вместо очереди. Затем приёмка NetFlow и Evolution. Завершён 11Б.3.4.4.7.1: конкурентный purge получает 409 до удаления, неподтверждённый session lock соединения исключается из пула; DI/backend/build/lint/conflict E2E прошли. Реальный PG overlap не проверен. Далее 11Б.3.4.4.7.2 — общий IP/CIDR/DNS контракт и фактические порты. + +Завершён 11Б.3.4.4.7.2: общий NetFlow IP/CIDR/DNS/ports patch, draft и backend проверяют его; инструкция использует фактический порт. 14 E2E, unit/HTTP/JWT, lint и оба builds прошли. Далее 11Б.3.4.4.7.3 — согласованный host export/маска, затем приёмка NetFlow и Evolution. diff --git a/packages/contracts/src/traffic-flow.ts b/packages/contracts/src/traffic-flow.ts index f0a646d8..180b9e35 100644 --- a/packages/contracts/src/traffic-flow.ts +++ b/packages/contracts/src/traffic-flow.ts @@ -31,15 +31,24 @@ export const trafficFlowSettingsDtoSchema = z.object({ peers: z.array(flowHostPeerSchema), }) +// The current overlay allocator and host setup operate on IPv4. +export const trafficFlowCollectorIpSchema = z.ipv4("Укажите IPv4-адрес коллектора") +export const trafficFlowOverlayPrefixSchema = z.cidrv4("Укажите IPv4-префикс в формате CIDR") +export const trafficFlowPublicEndpointSchema = z.string().refine(value => { + if (value === "" || z.ipv4().safeParse(value).success || z.ipv6().safeParse(value).success) return true + const hostname = value.endsWith(".") ? value.slice(0, -1) : value + return hostname.length <= 253 && /[a-z]/i.test(hostname) && hostname.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) +}, "Endpoint: укажите IP или DNS-имя без порта и протокола") + export const trafficFlowSettingsPatchSchema = z.object({ enabled: z.boolean().optional(), - collectorIp: z.string().min(1).optional(), - flowListenPort: z.number().int().positive().optional(), - wgListenPort: z.number().int().positive().optional(), - prefix: z.string().min(1).optional(), - publicEndpoint: z.string().optional(), + collectorIp: trafficFlowCollectorIpSchema.optional(), + flowListenPort: z.number().int().positive().max(65535).optional(), + wgListenPort: z.number().int().positive().max(65535).optional(), + prefix: trafficFlowOverlayPrefixSchema.optional(), + publicEndpoint: trafficFlowPublicEndpointSchema.optional(), hubServerId: z.number().int().positive().nullable().optional(), - retentionHours: z.number().int().positive().optional(), + retentionHours: z.number().int().positive().max(2147483647).optional(), topN: z.number().int().positive().max(1000).optional(), mapServiceMinSharePct: z.number().min(0).max(100).optional(), }) diff --git a/shared/api/netflow-settings-form.test.ts b/shared/api/netflow-settings-form.test.ts index 169103d9..90ff6528 100644 --- a/shared/api/netflow-settings-form.test.ts +++ b/shared/api/netflow-settings-form.test.ts @@ -8,3 +8,10 @@ test("NetFlow form sends selected values without defaulting invalid neighbours", for(const raw of ["0","65536","4739x","4.5",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,flowListenPort:raw},new Set(["flowListenPort"]))) for(const raw of ["0","100.01","NaN",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,shareOn:true,mapServiceMinSharePct:raw},new Set(["mapServiceMinSharePct"]))) }) + +test("NetFlow form uses common network validation without altering valid endpoint",()=>{ + for(const value of ["10.255.254.1","192.0.2.1"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,collectorIp:value},new Set(["collectorIp"])),{collectorIp:value}) + for(const value of ["10.255.254.0/24","192.0.2.0/28"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,prefix:value},new Set(["prefix"])),{prefix:value}) + for(const value of ["","collector","Collector.EXAMPLE.test.","192.0.2.1","2001:db8::1"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,publicEndpoint:value},new Set(["publicEndpoint"])),{publicEndpoint:value}) + for(const [field,value] of [["collectorIp","300.1.1.1"],["collectorIp","2001:db8::1"],["prefix","10.0.0.0/33"],["publicEndpoint","https://host.test"],["publicEndpoint","host.test:51821"],["publicEndpoint","$(fixture)"],["publicEndpoint","bad_host.test"],["publicEndpoint","1.2.3.999"]] as const)assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,[field]:value},new Set([field]))) +}) diff --git a/shared/api/netflow-settings-form.ts b/shared/api/netflow-settings-form.ts index c83a5b78..08c48c40 100644 --- a/shared/api/netflow-settings-form.ts +++ b/shared/api/netflow-settings-form.ts @@ -1,4 +1,4 @@ -import type { TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow" +import { trafficFlowSettingsPatchSchema, type TrafficFlowSettingsDto, type TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow" export type NetflowDraft = { enabled: boolean; collectorIp: string; flowListenPort: string; wgListenPort: string; prefix: string; publicEndpoint: string; retentionHours: string; topN: string; shareOn: boolean; mapServiceMinSharePct: string } export const emptyNetflowDraft: NetflowDraft = { enabled: false, collectorIp: "", flowListenPort: "", wgListenPort: "", prefix: "", publicEndpoint: "", retentionHours: "", topN: "", shareOn: false, mapServiceMinSharePct: "" } export function netflowDraftFromSettings(data: TrafficFlowSettingsDto): NetflowDraft { @@ -11,7 +11,7 @@ export function netflowDraftPatch(draft: NetflowDraft, dirty: ReadonlySet max) throw new Error(label + ": укажите целое число от 1 до " + max) patch[field] = value @@ -22,5 +22,7 @@ export function netflowDraftPatch(draft: NetflowDraft, dirty: ReadonlySet 100)) throw new Error("Порог карты: укажите долю больше 0 и не более 100%") patch.mapServiceMinSharePct = draft.shareOn ? value : 0 } - return patch + const parsed = trafficFlowSettingsPatchSchema.safeParse(patch) + if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Укажите допустимые настройки NetFlow") + return parsed.data } diff --git a/shared/api/traffic-flow.ts b/shared/api/traffic-flow.ts index 3918db90..290c28f3 100644 --- a/shared/api/traffic-flow.ts +++ b/shared/api/traffic-flow.ts @@ -18,7 +18,7 @@ import { requestJson } from "@/shared/api/http-client" export type { TrafficFlowHostFile } const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow") -const flowPatchSchema = trafficFlowSettingsPatchSchema.extend({ flowListenPort: z.number().int().min(1).max(65535).optional(), wgListenPort: z.number().int().min(1).max(65535).optional() }).strict() +const flowPatchSchema = trafficFlowSettingsPatchSchema.strict() function flowConfirmed(schema: z.ZodType, raw: unknown): T { const parsed = schema.safeParse(raw) if (!parsed.success) throw new Error("Данные NetFlow не подтверждены: получен некорректный ответ сервера") diff --git a/tests/e2e/netflow-settings.spec.ts b/tests/e2e/netflow-settings.spec.ts index ecbf56ce..b73aa809 100644 --- a/tests/e2e/netflow-settings.spec.ts +++ b/tests/e2e/netflow-settings.spec.ts @@ -50,3 +50,10 @@ test("NetFlow: reader claims видят статус без edit/export/mutation }) test("NetFlow: конфликт purge известен и не отправляет повтор",async({page})=>{await setup(page);let posts=0;await page.route("**/api/traffic/flow/purge",r=>{posts++;return r.fulfill({status:409,json:{error:"Сброс NetFlow уже выполняется"}})});await open(page);await page.getByRole("button",{name:"Сбросить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Сбросить",exact:true}).click();await expect(page.getByText("Новый сброс не запущен: Сброс NetFlow уже выполняется. Проверьте журнал перед новым сбросом.",{exact:true})).toBeVisible();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeDisabled();expect(posts).toBe(1)}) + +test("NetFlow: сетевой ввод проверяется до HTTP и port инструкция отражает receipt",async({page})=>{ + await setup(page);const writes:object[]=[];await page.route("**/api/traffic/flow/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();const patch=r.request().postDataJSON();writes.push(patch);return r.fulfill({json:{ok:true,settings:{...settings,...patch}}})});await open(page) + const collector=page.getByRole("textbox",{name:"Collector IP",exact:true});await collector.fill("not-an-ip");await save(page).click();await expect(page.getByText("Укажите IPv4-адрес коллектора",{exact:true})).toBeVisible();expect(writes).toEqual([]) + await collector.fill("10.255.254.1");const endpoint=page.getByRole("textbox",{name:"Публичный endpoint хоста MM",exact:true});await endpoint.fill("https://host.test");await save(page).click();await expect(page.getByText("Endpoint: укажите IP или DNS-имя без порта и протокола",{exact:true})).toBeVisible();expect(writes).toEqual([]) + await endpoint.fill("Collector.EXAMPLE.test.");await field(page).fill("4740");await save(page).click();await expect(page.getByText("Сохранение настроек NetFlow подтверждено",{exact:true})).toBeVisible();expect(writes).toEqual([{collectorIp:"10.255.254.1",flowListenPort:4740,publicEndpoint:"Collector.EXAMPLE.test."}]);await expect(page.getByText(/Firewall: разрешите UDP listen WireGuard. UDP 4740/)).toBeVisible();await expect(page.getByText(/ss -ulnp.*4740/)).toBeVisible() +})