fix(routes): подтвердить применение и восстановление рекурсивных маршрутов
This commit is contained in:
1 parent
36c6a21ca5
commit
5b3133ce73
19 files changed
+659
-28
No files matched your search
@@ -23,7 +23,7 @@
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
|
||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
|
||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||
import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recursive-routes.js"
|
||||
import { RecursiveApplyError } from "../services/recursive-apply.js"
|
||||
const row = { id: "one", dstAddress: "10.0.0.0/24", gateway: "192.0.2.1", distance: 0, scope: 0, targetScope: 0, routingTable: "main", checkGateway: "ping", country: "", comment: "Маршрут", disabled: false }
|
||||
const server = { id: 1, name: "Тест" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
|
||||
const server = { id: 1, name: "Тест", enabled: true, type: "home-router" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
|
||||
let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
|
||||
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
|
||||
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
|
||||
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
|
||||
@@ -12,12 +14,13 @@ const app = Fastify()
|
||||
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createRecursiveRoutes({
|
||||
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
||||
getRoutes: async () => stored,
|
||||
readRaw: async () => { if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
||||
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
||||
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
||||
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
|
||||
apply: async (device, rows, source) => {
|
||||
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
|
||||
if (fail) throw new Error("Тест частичного применения")
|
||||
if (applyError) throw applyError
|
||||
stored = rows as typeof row[]; return { pushed: rows.length, deleted: 0 }
|
||||
},
|
||||
getRevision: async () => {
|
||||
@@ -32,6 +35,7 @@ const invalid = [
|
||||
{ serverId: 1, routes: [{ ...row, distance: -1 }] }, { serverId: 1, routes: [{ ...row, scope: 256 }] },
|
||||
{ serverId: 1, routes: [{ ...row, targetScope: 1.5 }] }, { serverId: 1, routes: [{ ...row, gateway: "" }] },
|
||||
{ serverId: 1, routes: [row, row] }, { serverId: 1, routes: [row], source: "rollback" },
|
||||
...[{...row,dstAddress:"999.1.1.1/24"},{...row,dstAddress:"10.0.0.0/33"},{...row,gateway:"ether1"},{...row,gateway:"192.0.2.1%"},{...row,checkGateway:"anything"}].map(route=>({serverId:1,routes:[route]})),
|
||||
]
|
||||
for (const body of invalid) {
|
||||
const result = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: body })
|
||||
@@ -83,6 +87,22 @@ rawFailure=false;rawPayload=[]
|
||||
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
|
||||
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
|
||||
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
|
||||
for (const type of ["home-router", "exit-node"] as const) {
|
||||
server.enabled=false;server.type=type
|
||||
const counts={writes,rawReads,savedSnapshots}
|
||||
for(const url of ["/recursive-routes/catalog?serverId=1","/recursive-routes/gateways/catalog?serverId=1","/recursive-routes?serverId=1","/recursive-routes/gateways?serverId=1"])assert.equal((await app.inject({method:"GET",url:"/api"+url})).statusCode,409)
|
||||
assert.equal((await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[]}})).statusCode,409)
|
||||
assert.equal((await app.inject({method:"POST",url:"/api/recursive-routes/revisions/rev/restore",payload:{serverId:1}})).statusCode,409)
|
||||
assert.deepEqual({writes,rawReads,savedSnapshots},counts)
|
||||
}
|
||||
server.enabled=true
|
||||
for(const state of ["partial","unknown","applied"] as const){
|
||||
applyError=new RecursiveApplyError("Сбой операции",{state,confirmedWrites:1,createdIds:["*1"],deletedIds:[],needsReview:true,metadataRecorded:false})
|
||||
for(const restore of [false,true]){const r=await app.inject({method:restore?"POST":"PUT",url:restore?"/api/recursive-routes/revisions/rev/restore":"/api/recursive-routes",payload:restore?{serverId:1}:{serverId:1,routes:[]}});assert.equal(r.statusCode,502);assert.equal(r.json().state,state);assert.equal(r.json().confirmedWrites,1);assert.equal(r.json().ok,undefined)}
|
||||
}
|
||||
applyError=undefined;lateReadFailure=true
|
||||
const late=await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[row]}});assert.equal(late.statusCode,502);assert.equal(late.json().state,"applied");assert.equal(late.json().metadataRecorded,true)
|
||||
lateReadFailure=false
|
||||
await app.close()
|
||||
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
|
||||
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
@@ -10,7 +11,6 @@ import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { managedRecursiveComment } from "../managed-markers.js"
|
||||
import {
|
||||
mapRosManagedRoutes,
|
||||
planRecursiveApply,
|
||||
userRecursiveComment,
|
||||
} from "../services/config-apply-plan.js"
|
||||
import {
|
||||
@@ -123,10 +123,11 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
||||
}
|
||||
|
||||
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
||||
await db.transaction(async tx => {
|
||||
await tx.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
||||
if (routes.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
await db.insert(recursiveRoutes).values(
|
||||
await tx.insert(recursiveRoutes).values(
|
||||
routes.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
@@ -144,6 +145,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function applyRecursiveToServer(
|
||||
@@ -152,16 +154,7 @@ async function applyRecursiveToServer(
|
||||
source: ConfigRevisionSource,
|
||||
): Promise<{ pushed: number; deleted: number }> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
||||
const { deleteIds } = planRecursiveApply(existing)
|
||||
|
||||
for (const route of routes) {
|
||||
await client.post("/ip/route", toRouterPayload(route))
|
||||
}
|
||||
for (const id of deleteIds) {
|
||||
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
return executeRecursiveApply(client, routes.map(toRouterPayload), async () => {
|
||||
await replaceDbRoutes(server.id, routes)
|
||||
await appendRevisionIfChanged({
|
||||
serverId: server.id,
|
||||
@@ -169,11 +162,18 @@ async function applyRecursiveToServer(
|
||||
source,
|
||||
payload: canonicalRecursiveRoutes(routes),
|
||||
})
|
||||
return { pushed: routes.length, deleted: deleteIds.length }
|
||||
})
|
||||
}
|
||||
|
||||
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||||
|
||||
function recursiveFailureReceipt(error: unknown, result?: { pushed: number; deleted: number }) {
|
||||
if (error instanceof RecursiveApplyError) return error.receipt
|
||||
// Native operations and metadata completed; a later cache read can still fail.
|
||||
if (result) return { state: "applied", confirmedWrites: result.pushed + result.deleted, needsReview: true, metadataRecorded: true }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface RecursiveRouteDependencies {
|
||||
getServer: (id: number) => Promise<ServerRow | undefined>
|
||||
readRaw: (server: ServerRow) => Promise<unknown>
|
||||
@@ -201,6 +201,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
|
||||
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
|
||||
await deps.saveObserved(serverId, routes)
|
||||
@@ -214,6 +215,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
|
||||
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
|
||||
} catch (error) {
|
||||
@@ -227,6 +229,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
@@ -257,6 +260,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
|
||||
const cached = await deps.getRoutes(serverId)
|
||||
try {
|
||||
@@ -290,14 +294,17 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
const body = parsed.data, serverId = body.serverId
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
const routes = body.routes.map(r => ({ ...r, comment: userRecursiveComment(r.comment) }))
|
||||
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
|
||||
let result: { pushed: number; deleted: number } | undefined
|
||||
try {
|
||||
const result = await deps.apply(server, routes, source)
|
||||
result = await deps.apply(server, routes, source)
|
||||
const stored = await deps.getRoutes(serverId)
|
||||
return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
const receipt = recursiveFailureReceipt(err, result)
|
||||
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId, ...receipt })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -323,15 +330,18 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!parsedRoutes.success) return reply.status(409).send({ error: "Версия содержит некорректные маршруты; восстановление не выполнено" })
|
||||
const server = await deps.getServer(rev.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
const routes: RecursiveRouteDto[] = parsedRoutes.data.map((row, index) => ({
|
||||
...row, id: `rev-${index}`, comment: userRecursiveComment(row.comment),
|
||||
}))
|
||||
let result: { pushed: number; deleted: number } | undefined
|
||||
try {
|
||||
const result = await deps.apply(server, routes, "rollback")
|
||||
result = await deps.apply(server, routes, "rollback")
|
||||
const stored = await deps.getRoutes(server.id)
|
||||
return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
const receipt = recursiveFailureReceipt(err, result)
|
||||
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId: server.id, ...receipt })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "./recursive-apply.js"
|
||||
const owned = { ".id": "*old", static: "true", "dst-address": "198.18.88.0/24", gateway: "192.0.2.1", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", "check-gateway": "none", disabled: "false", comment: "MikrotikManager:recursive test" }
|
||||
let rows: unknown = [owned, { ".id": "*foreign", comment: "Keep default route" }], events: string[] = [], puts = 0
|
||||
let failAt = 0, rejection: Error = new Error("ACK потерян"), badAck = false, failDelete = false, failPersist = false
|
||||
const client: Pick<MikrotikClient,"get"|"put"|"delete"> = {
|
||||
get: async <T>() => rows as T,
|
||||
put: async <T>() => { puts++; events.push("put:"+puts); if(puts===failAt)throw rejection; return (badAck?{}:{".id":"*new"+puts}) as T },
|
||||
delete: async path => { events.push("delete:"+path); if(failDelete)throw new MikrotikError(403,path,"rejected") },
|
||||
}
|
||||
const persist=async()=>{events.push("persist");if(failPersist)throw Error("История недоступна")}
|
||||
const reset=()=>{events=[];puts=0;failAt=0;badAck=false;failDelete=false;failPersist=false;rows=[owned,{".id":"*foreign",comment:"Keep default route"}]}
|
||||
const payloads=[{"dst-address":"198.18.88.1/32"},{"dst-address":"198.18.88.2/32"}]
|
||||
reset();assert.deepEqual(await executeRecursiveApply(client,payloads,persist),{pushed:2,deleted:1});assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old","persist"])
|
||||
for(const invalid of [{},[owned,owned],[{...owned,static:undefined}],[{...owned,blackhole:"true"}]]){reset();rows=invalid;await assert.rejects(executeRecursiveApply(client,payloads,persist));assert.deepEqual(events,[])}
|
||||
async function expectState(state:string,confirmed:number){await assert.rejects(executeRecursiveApply(client,payloads,persist),(e:unknown)=>{assert(e instanceof RecursiveApplyError);assert.equal(e.receipt.state,state);assert.equal(e.receipt.confirmedWrites,confirmed);assert.equal(e.receipt.metadataRecorded,false);return true})}
|
||||
reset();failAt=1;rejection=new MikrotikError(401,"/ip/route","denied");await expectState("rejected",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failAt=2;rejection=new MikrotikError(403,"/ip/route","denied");await expectState("partial",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();failAt=2;rejection=new Error("ACK второго PUT потерян");await expectState("unknown",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();badAck=true;await expectState("unknown",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failDelete=true;await expectState("partial",2);assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old"])
|
||||
reset();failPersist=true;await expectState("applied",3)
|
||||
reset();rows=[];assert.deepEqual(await executeRecursiveApply(client,[],persist),{pushed:0,deleted:0});assert.deepEqual(events,["persist"])
|
||||
console.log("Recursive apply: strict ownership before PUT, foreign retention, partial/unknown/rejected/applied, no retry/delete after rejection")
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { parseRecursiveCatalog } from "./recursive-route-catalog.js"
|
||||
|
||||
export class RecursiveApplyError extends Error {
|
||||
constructor(message: string, public readonly receipt: {
|
||||
state: "partial" | "unknown" | "rejected" | "applied"
|
||||
confirmedWrites: number
|
||||
createdIds: string[]
|
||||
deletedIds: string[]
|
||||
needsReview: boolean
|
||||
metadataRecorded: boolean
|
||||
}) { super(message); this.name = "RecursiveApplyError" }
|
||||
}
|
||||
|
||||
export async function executeRecursiveApply(
|
||||
client: Pick<MikrotikClient, "get" | "put" | "delete">,
|
||||
payloads: Record<string, string>[],
|
||||
persist: () => Promise<void>,
|
||||
) {
|
||||
// The editor replaces every owned row: reject unsupported ownership before any write.
|
||||
const existing = parseRecursiveCatalog(await client.get<unknown>("/ip/route"))
|
||||
const createdIds: string[] = [], deletedIds: string[] = []
|
||||
let nativeComplete = false
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const result = await client.put<unknown>("/ip/route", payload)
|
||||
if (!result || typeof result !== "object" || !(".id" in result) || typeof result[".id"] !== "string" || !result[".id"].trim()) throw new Error("RouterOS не подтвердил ID созданного маршрута")
|
||||
createdIds.push(result[".id"])
|
||||
}
|
||||
for (const route of existing) {
|
||||
await client.delete("/ip/route/" + encodeURIComponent(route.id))
|
||||
deletedIds.push(route.id)
|
||||
}
|
||||
nativeComplete = true
|
||||
await persist()
|
||||
return { pushed: createdIds.length, deleted: deletedIds.length }
|
||||
} catch (error) {
|
||||
const rejected = error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500
|
||||
const confirmedWrites = createdIds.length + deletedIds.length
|
||||
const state = nativeComplete ? "applied" : rejected ? (confirmedWrites ? "partial" : "rejected") : "unknown"
|
||||
throw new RecursiveApplyError(`Подтверждено записей RouterOS: ${confirmedWrites}. Обновите каталог перед повторной попыткой. ${String(error)}`, {
|
||||
state, confirmedWrites, createdIds, deletedIds, needsReview: state !== "rejected", metadataRecorded: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { z } from "zod"
|
||||
import { isIP } from "node:net"
|
||||
const destination = z.string().trim().refine(value => {
|
||||
const [address, prefix, extra] = value.split("/")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (prefix === undefined || /^\d+$/.test(prefix) && Number(prefix) <= 32)
|
||||
}, "Нужен IPv4 адрес или префикс")
|
||||
const gateway = z.string().trim().refine(value => {
|
||||
const [address, iface, extra] = value.split("%")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (iface === undefined || Boolean(iface) && !/\s/.test(iface))
|
||||
}, "Нужен IPv4 шлюз с необязательным интерфейсом")
|
||||
const serverId = z.union([z.number().int().positive(), z.string().trim().regex(/^\d+$/)])
|
||||
.transform(Number).refine(value => Number.isSafeInteger(value) && value > 0)
|
||||
export const recursiveTargetSchema = z.object({ serverId })
|
||||
export const recursiveInputRowSchema = z.object({
|
||||
id: z.string().optional().default(""),
|
||||
dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1),
|
||||
dstAddress: destination, gateway,
|
||||
distance: z.number().int().min(0).max(255),
|
||||
scope: z.number().int().min(0).max(255).nullable(),
|
||||
targetScope: z.number().int().min(0).max(255).nullable(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.string(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.enum(["none", "arp", "ping", "bfd"]),
|
||||
country: z.string(), comment: z.string(), disabled: z.boolean(),
|
||||
})
|
||||
export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema)
|
||||
|
||||
Reference in new issue
Block a user