fix(netflow): согласовать проверку сетевых параметров формы и сервера
This commit is contained in:
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 +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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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 не менялись.
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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])))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 не подтверждены: получен некорректный ответ сервера")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user