161 lines
6.3 KiB
TypeScript
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"))}
|