fix(api): проверять исходные шаблоны и план оптимизации OSPF

This commit is contained in:
Denozordec committed 2026-09-30 23:03:48 +07:00
1 parent c88e1400bf
commit 0ee7f02e87
7 files changed
+98 -22

No files matched your search

+3 -2
View File
@@ -24,8 +24,9 @@
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "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: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:live-maps && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
"test": "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:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
import {createOspfRoutes} from "./ospf.js"
import type {servers} from "../db/schema.js"
const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0}
let plan:unknown=[row],writes=0,enabled=true,exists=true,failWrite=false
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
await app.register(createOspfRoutes({
listEnabledServers:async()=>[],readCatalog:async()=>{throw new Error("unused")},
getOptimizationServer:async()=>exists?({id:1,name:"Роутер",host:"192.0.2.1",enabled} as typeof servers.$inferSelect):undefined,
buildOptimizationPlan:async()=>plan as typeof row[],
setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ")},
}),{prefix:"/api"})
const send=(preview=false,body:unknown={pingWeight:60})=>app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"+(preview?"/preview":""),payload:body})
const preview=await send(true);assert.equal(preview.statusCode,200);assert.equal(preview.json().changedCount,1);assert.equal(preview.json().pingWeight,60);assert.equal(writes,0)
const applied=await send();assert.equal(applied.statusCode,200);assert.equal(applied.json().optimizedCount,1);assert.equal(applied.json().applied.length,1);assert.equal(writes,1)
for(const invalid of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,id:""}]]){
plan=invalid;const before=writes;assert.equal((await send()).statusCode,500);assert.equal(writes,before)
}
plan=[row];enabled=false;assert.equal((await send()).statusCode,409);assert.equal((await send(true)).statusCode,409)
enabled=true;exists=false;assert.equal((await send()).statusCode,404);exists=true
const before=writes;assert.equal((await send(false,{pingWeight:101})).statusCode,400);assert.equal(writes,before)
failWrite=true;const failed=await send();assert.equal(failed.statusCode,500);assert.ok(failed.json().error.includes("Частичный сетевой отказ"))
failWrite=false;plan=[];assert.equal((await send()).json().optimizedCount,0)
await app.close();console.log("OSPF optimizer HTTP: invalid before write, readonly preview, disabled/missing/bad weight, network error and known empty; no DB/RouterOS")
+16 -19
View File
@@ -11,6 +11,7 @@ import type {
} from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod"
import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "../services/ospf-optimizer-plan.js"
import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js"
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
@@ -291,9 +292,7 @@ function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number
return { dlMbps: dl, ulMbps: ul }
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
function parsePingTimeMs(raw: string | undefined): number | null {
if (!raw) return null
@@ -350,10 +349,8 @@ type OspfRankedInterface = {
}
async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise<OspfRankedInterface[]> {
const { ifaceTemplates, areas, instances, neighbors } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
const { ifaceTemplates, neighbors } = await fetchServerOspf(server)
const editable = readEditableOspfTemplates(ifaceTemplates)
const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
const latestTraffic = await latestTrafficByInterface(server.id)
@@ -576,6 +573,9 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
interface OspfCatalogDependencies {
listEnabledServers: () => Promise<ServerRow[]>
readCatalog: (server: ServerRow) => Promise<OspfCatalogCollections>
getOptimizationServer?: (id:number)=>Promise<ServerRow | undefined>
buildOptimizationPlan?: (server:ServerRow,weight:number)=>Promise<OspfRankedInterface[]>
setTemplateCost?: (server:ServerRow,id:string,cost:number)=>Promise<void>
}
const catalogDefaults: OspfCatalogDependencies = {
listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)),
@@ -725,16 +725,14 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1))[0]
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
try {
const pingWeight = req.body.pingWeight
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
return reply.send({
serverId: server.id,
@@ -757,22 +755,21 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1))[0]
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
try {
const pingWeight = req.body.pingWeight
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
const client = MikrotikClient.fromServer(server)
const client=dependencies.setTemplateCost?null:MikrotikClient.fromServer(server)
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
const applied: Array<{ interface: string; from: number; to: number }> = []
for (const item of changed) {
await client.setOspfInterfaceTemplateCost(item.id, item.optimalCost)
if(dependencies.setTemplateCost) await dependencies.setTemplateCost(server,item.id,item.optimalCost)
else await client!.setOspfInterfaceTemplateCost(item.id,item.optimalCost)
applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost })
}
@@ -0,0 +1,12 @@
import assert from "node:assert/strict"
import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "./ospf-optimizer-plan.js"
const raw={".id":"*1",interfaces:"ether1",disabled:"false",cost:"10"}
assert.deepEqual(readEditableOspfTemplates([raw]),[{id:"*1",interface:"ether1",cost:10}])
assert.deepEqual(readEditableOspfTemplates([{...raw,disabled:"true",cost:undefined},{...raw,".id":"*2",interfaces:"*ref",cost:undefined}]),[])
assert.deepEqual(readEditableOspfTemplates([]),[])
for(const payload of [null,{},[null],[raw,raw],[{...raw,cost:undefined}],[{...raw,cost:"10junk"}],[{...raw,cost:"0"}],[{...raw,disabled:undefined}],[{...raw,interfaces:""}],[raw,{...raw,".id":"*2"}]])assert.throws(()=>readEditableOspfTemplates(payload))
const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0}
assert.equal(validateOspfOptimizationPlan([row])[0].pingMs,0)
assert.deepEqual(validateOspfOptimizationPlan([]),[])
for(const payload of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optimalCost:65536}],[{...row,pingMs:-1}],[{...row,score:NaN}],[row,{...row,id:"*2"}]])assert.throws(()=>validateOspfOptimizationPlan(payload))
console.log("OSPF optimizer plan: observed cost/disabled/id, ref/disabled skip, duplicate/invalid refusal, zero metrics")
@@ -0,0 +1,28 @@
import {z} from "zod"
import {observedBoolean,observedInteger} from "./ospf-observed-values.js"
const templateSchema=z.array(z.object({".id":z.string().trim().min(1),interfaces:z.string().optional(),cost:z.string().optional(),disabled:z.string().optional()}))
export function readEditableOspfTemplates(payload:unknown){
const parsed=templateSchema.safeParse(payload)
if(!parsed.success || new Set(parsed.data.map(row=>row[".id"])).size!==parsed.data.length)throw new Error("Недостоверный каталог шаблонов OSPF")
const names=new Set<string>()
return parsed.data.flatMap(row=>{
const disabled=observedBoolean(row.disabled)
if(disabled===null)throw new Error("Неизвестен disabled шаблона OSPF "+row[".id"])
if(disabled)return []
const name=row.interfaces?.trim()
if(!name)throw new Error("Неизвестен интерфейс шаблона OSPF "+row[".id"])
if(name.startsWith("*"))return []
const cost=observedInteger(row.cost,1,65535)
if(cost===null)throw new Error("Неизвестен или неподдерживаем cost шаблона OSPF "+row[".id"])
if(names.has(name))throw new Error("Несколько активных шаблонов OSPF для интерфейса "+name)
names.add(name)
return [{id:row[".id"],interface:name,cost}]
})
}
const measured=z.number().finite().nonnegative()
const rowSchema=z.object({id:z.string().trim().min(1),interface:z.string().trim().min(1),currentCost:z.number().int().min(1).max(65535),optimalCost:z.number().int().min(1).max(65535),score:z.number().finite(),pingMs:measured,dlMbps:measured,ulMbps:measured})
export function validateOspfOptimizationPlan(payload:unknown){
const parsed=z.array(rowSchema).safeParse(payload)
if(!parsed.success || new Set(parsed.data.map(row=>row.id)).size!==parsed.data.length || new Set(parsed.data.map(row=>row.interface)).size!==parsed.data.length)throw new Error("Недостоверный план оптимизации OSPF")
return parsed.data
}
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.32; подтверждённое OSPF apply и RBAC, далее backend provenance |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.33; backend проверяет cost/план OSPF, далее layout/контекст |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.33 — достоверность cost и плана OSPF (2026-09-30)
- Оптимизатор использует отдельный observed selector шаблонов: disabled/id/interface/currentCost должны быть достоверны. Cost не подставляется как 10, disabled не превращается в false. Missing/unsupported cost (включая 0 вне текущей write-policy 1–65535), дубликаты id и несколько активных шаблонов одного интерфейса отклоняются. Известные disabled и reference-интерфейсы сохраняют прежнее исключение из оптимизации.
- Весь ranked plan валидируется до первой записи; переполнение optimalCost, плохие метрики/идентификаторы не приводят к частичному apply. Отключённое устройство возвращает 409, неизвестное 404. Формула score, выбор проб и порядок сетевых изменений прежние.
- DI оптимизатора позволяет HTTP injection без БД/RouterOS. Проверены malformed plans до write, preview без write, success/empty, disabled/missing/bad weight и сетевой отказ. Unit selector проверяет unknown/default/duplicates и observed zero метрик.
- Проверки: test:ospf-optimizer, общий test:live-maps (включая OSPF/BGP/GRE/VXLAN compatibility), backend build прошли. Lint без ошибок; два прежних предупреждения unused and/measurePingByInterface остаются.
- Далее 10.34: адаптивность и keyboard/screen оптимизатора; затем URL/source context и различение observed/model метрик. Snapshot/traffic fallback и clamp значений в модели ещё сохранены и требуют отдельного отображения; весь раздел ещё не принят. Реальная RouterOS версия/DB не проверена. Последняя квота: 82% окна, 44% недели использовано. Локальный коммит без push.
## Блок 10.32 — подтверждение применения OSPF и права (2026-09-30)
- Apply валидирует существующий ospfOptimizeSchema, serverId и вес запроса. Успех по count/name без списка applied больше не принимается. Последний receipt привязан к контексту и времени получения браузером; отсутствие применения отображается «—».
+4
View File
@@ -180,3 +180,7 @@ Live таймерный успех убран; demo симуляция явно
## 10.32. Подтверждённое OSPF применение и RBAC
Strict receipt/server/weight, ref lock, readonly/disabled контекст и reread любого результата. 16 E2E, readonly повтор, TS/lint, обе production сборки и auth tests прошли. Specific OSPF rights согласованы: preview read, apply write; signed JWT со stub handlers без RouterOS/DB. Далее backend provenance: legacy cost/metric defaults ещё не приняты как observed данные.
## 10.33. Backend cost и валидация плана
Observed selector вместо default cost/disabled; неоднозначные шаблоны отклоняются до write. Полный план валидируется, disabled сервер 409. DI HTTP/unit без RouterOS/DB, live-maps regression/backend build прошли; lint два прежних unused warning. Ранжирование прежнее, model fallback/clamp ещё требуют отдельного отображения. Далее layout/контекст.