Files
MikrotikManager/backend/src/services/vxlan-live.ts
T

161 lines
6.3 KiB
TypeScript

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"
import { MikrotikClient } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosVxlan {
".id"?: string
name?: string
vni?: string
port?: string
"local-address"?: string
"vtep-address"?: string
running?: string
disabled?: string
comment?: string
l2mtu?: string
mtu?: string
arp?: string
"arp-proxy"?: string
"mac-learning"?: string
learning?: string
}
interface RosVxlanVtep {
".id"?: string
interface?: string
"remote-ip"?: string
}
export type VxlanTunnelLive = {
id: string
rosId: string
name: string
vni: number
port: number
dstPort: number
serverId: string
vtepIp: string
remoteVteps: string[]
l2mtu: number
mtu: number
arpProxy: boolean
arpMode: "disabled" | "enabled" | "local-proxy-arp" | "proxy-arp" | "reply-only"
macLearning: boolean
comment: string
enabled: boolean
status: "up" | "down"
}
function rosYes(v: unknown): boolean {
return v === true || v === "true" || v === "yes"
}
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,
): VxlanTunnelLive {
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 || (v.interface ?? "").trim() === rosId)
.map((v) => (v["remote-ip"] ?? "").trim())
.filter(Boolean)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
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: readInteger(vx.l2mtu, "l2mtu", 1, 65535),
mtu: readInteger(vx.mtu, "mtu", 1, 65535),
arpProxy: arpMode === "proxy-arp" || arpMode === "local-proxy-arp",
arpMode,
macLearning: readFlag(vx["mac-learning"] ?? vx.learning, "learning"),
comment: vx.comment ?? "",
enabled: !disabled,
status: !disabled && running ? "up" : "down",
}
}
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 устройства")
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.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) }] } }
}))
return { tunnels: results.flatMap(result => result.tunnels), failures: results.flatMap(result => result.failures) }
}
export async function listVxlanResult() {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
return pollVxlanServers(enabledServers)
}
export async function listVxlanTunnels(): Promise<VxlanTunnelLive[]> {
return (await listVxlanResult()).tunnels
}
export async function listVxlanTunnelsForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
return fetchVxlanForServer(server)
}
export async function countVxlanTunnels():Promise<number|undefined>{return boundedCompleteCount(()=>listVxlanResult(),value=>completeCollectionCount(value,"tunnels"))}