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

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