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

+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
}