fix(vxlan): подтвердить достоверность опроса и экспорта раздела

This commit is contained in:
Denozordec committed 2026-10-09 10:38:24 +07:00
1 parent 6b068ac39a
commit e2d81f9657
23 files changed
+689 -89

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@
"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",
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && 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:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.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:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && 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:scheduler && 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 && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createVxlanRoutes } from "./vxlan.js"
import type { servers } from "../db/schema.js"
const devices = [
{ id: 1, enabled: true, type: "home-router" },
{ id: 2, enabled: false, type: "home-router" },
{ id: 3, enabled: false, type: "exit-node" },
] as typeof servers.$inferSelect[]
let reads = 0, fail = false
const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
await app.register(createVxlanRoutes({
listResult: async () => ({ tunnels: [], failures: [] }),
findServer: async id => devices.find(device => device.id === id),
readServer: async () => { reads++; if (fail) throw new Error("offline"); return [] },
}), { prefix: "/api" })
for (const id of [2, 3]) {
const response = await app.inject({ method: "GET", url: `/api/servers/${id}/vxlan` })
assert.equal(response.statusCode, 409); assert.match(response.json().error, /выключен/)
}
assert.equal(reads, 0)
assert.equal((await app.inject({ method: "GET", url: "/api/servers/999/vxlan" })).statusCode, 404)
assert.equal((await app.inject({ method: "GET", url: "/api/servers/0/vxlan" })).statusCode, 400)
assert.equal(reads, 0)
const enabled = await app.inject({ method: "GET", url: "/api/servers/1/vxlan" })
assert.equal(enabled.statusCode, 200); assert.deepEqual(enabled.json(), { tunnels: [] }); assert.equal(reads, 1)
fail = true
assert.equal((await app.inject({ method: "GET", url: "/api/servers/1/vxlan" })).statusCode, 502)
assert.equal((await app.inject({ method: "GET", url: "/api/vxlan" })).statusCode, 200)
await app.close()
console.log("VXLAN HTTP: disabled home/exit no poll, invalid/missing, empty and upstream failure passed")
+30 -17
View File
@@ -5,22 +5,35 @@ import { servers } from "../db/schema.js"
import { listVxlanResult, listVxlanTunnelsForServer } from "../services/vxlan-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const vxlanRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/vxlan", async (_req, reply) => {
return reply.send(await listVxlanResult())
})
app.get("/servers/:id/vxlan", { schema: { params: ServerIdParamSchema } }, 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]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const tunnels = await listVxlanTunnelsForServer(server)
return reply.send({ tunnels })
} catch {
return reply.status(502).send({ error: "Не удалось опросить VXLAN на сервере" })
}
})
type ServerRow = typeof servers.$inferSelect
interface VxlanDependencies {
listResult: typeof listVxlanResult
findServer: (id: number) => Promise<ServerRow | undefined>
readServer: typeof listVxlanTunnelsForServer
}
const defaults: VxlanDependencies = {
listResult: listVxlanResult,
findServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
readServer: listVxlanTunnelsForServer,
}
export function createVxlanRoutes(dependencies: VxlanDependencies = defaults): FastifyPluginAsyncZod {
return async (app) => {
app.get("/vxlan", async (_req, reply) => {
return reply.send(await dependencies.listResult())
})
export default vxlanRoutes
app.get("/servers/:id/vxlan", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = await dependencies.findServer(params.id)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
if (!server.enabled) return reply.status(409).send({ error: "Сервер выключен. Опрос VXLAN не выполнялся" })
try {
const tunnels = await dependencies.readServer(server)
return reply.send({ tunnels })
} catch {
return reply.status(502).send({ error: "Не удалось опросить VXLAN на сервере" })
}
})
}
}
export default createVxlanRoutes()
+24 -3
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mapVxlanRow, pollVxlanServers } from "./vxlan-live.js"
import { mapVxlanRow, mapVxlanSnapshot, pollVxlanServers, listVxlanTunnelsForServer } from "./vxlan-live.js"
const server = {
id: 7,
@@ -20,6 +20,7 @@ const row = mapVxlanRow(
running: "true",
disabled: "false",
l2mtu: "1500",
mtu: "1400",
"mac-learning": "true",
"arp-proxy": "true",
comment: "overlay",
@@ -55,12 +56,32 @@ assert.equal(failed.tunnels.length, 0)
assert.equal(failed.failures.length, 1)
console.log("vxlan polling: mixed/empty/all-failed ok")
const complete = { ".id": "*4", name: "vx", vni: "10010", port: "4789", l2mtu: "65535", mtu: "1400", disabled: "false", running: "true", arp: "enabled", learning: "yes" }
for (const arp of ["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"] as const) {
const mapped = mapVxlanRow(server, { ".id": "*4", name: "vx", arp, learning: "no" }, [], 0)
const mapped = mapVxlanRow(server, { ...complete, arp, learning: "no" }, [], 0)
assert.equal(mapped.arpMode, arp)
assert.equal(mapped.arpProxy, arp === "proxy-arp" || arp === "local-proxy-arp")
assert.equal(mapped.macLearning, false)
}
console.log("vxlan: exact ARP modes and RouterOS no-learning passed")
assert.equal(mapVxlanRow(server, { arp: "enabled", "arp-proxy": "true" }, [], 0).arpProxy, false)
assert.equal(mapVxlanRow(server, { ...complete, arp: "enabled", "arp-proxy": "true" }, [], 0).arpProxy, false)
for (const field of [".id", "name", "vni", "port", "l2mtu", "mtu", "disabled", "running", "arp", "learning"]) {
const incomplete = { ...complete } as Record<string, string>
delete incomplete[field]
assert.throws(() => mapVxlanSnapshot(server, [incomplete], []), /Некорректный снимок/)
}
for (const bad of [{ vni: "12junk" }, { vni: "0" }, { port: "0" }, { port: "65536" }, { l2mtu: "unknown" }, { running: "unknown" }]) {
assert.throws(() => mapVxlanSnapshot(server, [{ ...complete, ...bad }], []), /Некорректный снимок/)
}
assert.deepEqual(mapVxlanSnapshot(server, [complete], [{ interface: "*4", "remote-ip": "192.0.2.2" }])[0].remoteVteps, ["192.0.2.2"])
for (const vtep of [{ interface: "missing", "remote-ip": "192.0.2.2" }, { interface: "vx", "remote-ip": "invalid" }]) {
assert.throws(() => mapVxlanSnapshot(server, [complete], [vtep]), /статический VTEP/)
}
assert.throws(() => mapVxlanSnapshot(server, [complete, complete], []), /повторяющиеся/)
let disabledReads = 0
assert.deepEqual(await pollVxlanServers([{ ...server, enabled: false }], async () => { disabledReads++; return [row] }), { tunnels: [], failures: [] })
assert.equal(disabledReads, 0)
await assert.rejects(listVxlanTunnelsForServer({ ...server, enabled: false }), /выключен/)
console.log("VXLAN: incomplete snapshots, VTEP identities and disabled no-poll passed")
+48 -20
View File
@@ -1,4 +1,5 @@
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
import { isIP } from "node:net"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
@@ -17,6 +18,7 @@ interface RosVxlan {
disabled?: string
comment?: string
l2mtu?: string
mtu?: string
arp?: string
"arp-proxy"?: string
"mac-learning"?: string
@@ -40,6 +42,7 @@ export type VxlanTunnelLive = {
vtepIp: string
remoteVteps: string[]
l2mtu: number
mtu: number
arpProxy: boolean
arpMode: "disabled" | "enabled" | "local-proxy-arp" | "proxy-arp" | "reply-only"
macLearning: boolean
@@ -48,45 +51,55 @@ export type VxlanTunnelLive = {
status: "up" | "down"
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
function rosYes(v: unknown): boolean {
return v === true || v === "true" || v === "yes"
}
function parseIntSafe(v: string | undefined, fallback: number): number {
const n = Number.parseInt(v ?? "", 10)
return Number.isFinite(n) ? n : fallback
function readInteger(value: unknown, field: string, min: number, max: number): number {
const n = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : typeof value === "number" ? value : NaN
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`Некорректный снимок VXLAN: ${field}`)
return n
}
function readFlag(value: unknown, field: string): boolean {
if (![true, false, "true", "false", "yes", "no"].includes(value as boolean | string)) throw new Error(`Некорректный снимок VXLAN: ${field}`)
return rosYes(value)
}
export function mapVxlanRow(
server: ServerRow,
vx: RosVxlan,
vteps: RosVxlanVtep[],
idx: number,
_idx: number,
): VxlanTunnelLive {
const name = (vx.name ?? "").trim() || `vxlan-${idx + 1}`
const rosId = String(vx[".id"] ?? name)
const disabled = rosYes(vx.disabled)
const running = rosYes(vx.running)
const port = parseIntSafe(vx.port, 8472)
const arpMode: VxlanTunnelLive["arpMode"] = (["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"] as const).find(mode => mode === vx.arp) ?? (rosYes(vx["arp-proxy"]) ? "proxy-arp" : "enabled")
const name = typeof vx?.name === "string" ? vx.name.trim() : ""
const rosId = typeof vx?.[".id"] === "string" ? vx[".id"].trim() : ""
if (!name || !rosId) throw new Error("Некорректный снимок VXLAN: имя или ID интерфейса")
const disabled = readFlag(vx.disabled, "disabled")
const running = readFlag(vx.running, "running")
const port = readInteger(vx.port, "port", 1, 65535)
const explicitArp = (["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"] as const).find(mode => mode === vx.arp)
if (vx.arp !== undefined && !explicitArp) throw new Error("Некорректный снимок VXLAN: arp")
const arpMode: VxlanTunnelLive["arpMode"] = explicitArp ?? (readFlag(vx["arp-proxy"], "arp-proxy") ? "proxy-arp" : "enabled")
const remoteVteps = vteps
.filter((v) => (v.interface ?? "").trim() === name)
.filter((v) => (v.interface ?? "").trim() === name || (v.interface ?? "").trim() === rosId)
.map((v) => (v["remote-ip"] ?? "").trim())
.filter(Boolean)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
vni: parseIntSafe(vx.vni, 0),
vni: readInteger(vx.vni, "vni", 1, 16777215),
port: 0,
dstPort: port,
serverId: String(server.id),
vtepIp: (vx["local-address"] ?? vx["vtep-address"] ?? "").trim(),
remoteVteps,
l2mtu: parseIntSafe(vx.l2mtu, 1500),
l2mtu: readInteger(vx.l2mtu, "l2mtu", 1, 65535),
mtu: readInteger(vx.mtu, "mtu", 1, 65535),
arpProxy: arpMode === "proxy-arp" || arpMode === "local-proxy-arp",
arpMode,
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning != null ? rosYes(vx.learning) : true,
macLearning: readFlag(vx["mac-learning"] ?? vx.learning, "learning"),
comment: vx.comment ?? "",
enabled: !disabled,
status: !disabled && running ? "up" : "down",
@@ -94,22 +107,37 @@ export function mapVxlanRow(
}
async function fetchVxlanForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
if (!server.enabled) throw new Error("Сервер выключен. Опрос VXLAN не выполнялся")
const client = MikrotikClient.fromServer(server)
const [vxRaw, vtepRaw] = await Promise.all([
client.get<RosVxlan[]>("/interface/vxlan"),
client.get<RosVxlanVtep[]>("/interface/vxlan/vteps"),
])
if (!Array.isArray(vxRaw) || !Array.isArray(vtepRaw)) throw new Error("Некорректный ответ VXLAN устройства")
const list = vxRaw
const vteps = vtepRaw
return list.map((vx, idx) => mapVxlanRow(server, vx, vteps, idx))
return mapVxlanSnapshot(server, vxRaw, vtepRaw)
}
export function mapVxlanSnapshot(server: ServerRow, list: RosVxlan[], vteps: RosVxlanVtep[]): VxlanTunnelLive[] {
if (vteps.some(vtep => !vtep || typeof vtep.interface !== "string" || !isIP(vtep["remote-ip"] ?? ""))) {
throw new Error("Некорректный снимок VXLAN: статический VTEP")
}
const mapped = list.map((vx, idx) => mapVxlanRow(server, vx, vteps, idx))
if (new Set(mapped.map(row => row.rosId)).size !== mapped.length || new Set(mapped.map(row => row.name)).size !== mapped.length) {
throw new Error("Некорректный снимок VXLAN: повторяющиеся интерфейсы")
}
for (const vtep of vteps) {
if (!vtep || !mapped.some(row => row.name === vtep.interface || row.rosId === vtep.interface) || !isIP(vtep["remote-ip"] ?? "")) {
throw new Error("Некорректный снимок VXLAN: статический VTEP")
}
}
return mapped
}
export async function pollVxlanServers(
serverRows: ServerRow[],
fetchServer: (server: ServerRow) => Promise<VxlanTunnelLive[]> = fetchVxlanForServer,
): Promise<{ tunnels: VxlanTunnelLive[]; failures: Array<{ serverId: string; serverName: string; error: string }> }> {
const results = await Promise.all(serverRows.map(async server => {
const results = await Promise.all(serverRows.filter(server => server.enabled !== false).map(async server => {
try { return { tunnels: await fetchServer(server), failures: [] } }
catch (error) { return { tunnels: [], failures: [{ serverId: String(server.id), serverName: server.name, error: error instanceof Error ? error.message : String(error) }] } }
}))