fix(netflow): согласовать проверку сетевых параметров формы и сервера

This commit is contained in:
Denozordec committed 2026-10-03 12:59:05 +07:00
1 parent 32b7812169
commit b80fa042d5
12 files changed
+56 -20

No files matched your search

@@ -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)
+1 -5
View File
@@ -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<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))
const patchSchema = trafficFlowSettingsPatchSchema.strict().refine(patch => Object.values(patch).some(value => value !== undefined))
export function createTrafficFlowSettingsRoutes(deps: Dependencies): FastifyPluginAsyncZod {
return async app => {
@@ -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<string> {
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<string> {
export async function buildHostComposeOverride(): Promise<string> {
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<string> {
export async function buildHostLinuxInstallSh(): Promise<string> {
const row = await getTrafficFlowSettingsRow()
trafficFlowCollectorIpSchema.parse(row.collectorIp)
const conf = (await buildHostWgQuickConf()).replace(/\s+$/, "") + "\n"
const override = await buildHostComposeOverride()
const collector = row.collectorIp
@@ -133,7 +133,7 @@ function NetflowSettingsContent({
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал и состояние NetFlow. Повтор запустит новую операцию.</p><Button size="sm" variant="outline" aria-label="Разрешить новую операцию NetFlow" disabled={disabled || readBusy} onClick={() => { setNeedsReview(false);setOpError(null) }}>Разрешить повтор NetFlow</Button></div> : null}
<Alert>
<InfoIcon />
<AlertTitle>Ключи и UDP 4739</AlertTitle>
<AlertTitle>Ключи и порт IPFIX</AlertTitle>
<AlertDescription>
Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
</AlertDescription>
@@ -204,7 +204,7 @@ function NetflowSettingsContent({
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s}</li>
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
))}
</ol>
</div>
+2
View File
@@ -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.
+9 -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.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 не менялись.
+2
View File
@@ -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.
+15 -6
View File
@@ -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(),
})
+7
View File
@@ -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])))
})
+5 -3
View File
@@ -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<keyof
if ((field !== "publicEndpoint" && !value) || /[\r\n]/.test(value)) throw new Error("Укажите корректное значение: " + field)
patch[field] = value
}
for (const [field, label, max] of [["flowListenPort","UDP IPFIX",65535],["wgListenPort","WG listen",65535],["retentionHours","Хранение (часов)",Number.MAX_SAFE_INTEGER],["topN","Top-N разговоров",1000]] as const) if (dirty.has(field)) {
for (const [field, label, max] of [["flowListenPort","UDP IPFIX",65535],["wgListenPort","WG listen",65535],["retentionHours","Хранение (часов)",2147483647],["topN","Top-N разговоров",1000]] as const) if (dirty.has(field)) {
const raw = draft[field], value = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(label + ": укажите целое число от 1 до " + max)
patch[field] = value
@@ -22,5 +22,7 @@ export function netflowDraftPatch(draft: NetflowDraft, dirty: ReadonlySet<keyof
if (draft.shareOn && (!/^\d+(?:\.\d+)?$/.test(draft.mapServiceMinSharePct) || !Number.isFinite(value) || value <= 0 || value > 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
}
+1 -1
View File
@@ -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<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Данные NetFlow не подтверждены: получен некорректный ответ сервера")
+7
View File
@@ -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()
})