fix(vxlan): подтвердить достоверность опроса и экспорта раздела
This commit is contained in:
1 parent
6b068ac39a
commit
e2d81f9657
23 files changed
+689
-89
No files matched your search
@@ -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
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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) }] } }
|
||||
}))
|
||||
|
||||
Reference in new issue
Block a user