import { OperationTaskType, Prisma } from "@prisma/client"; import { Router } from "express"; import { z } from "zod"; import { HttpError } from "../lib/http-error"; import { prisma } from "../lib/prisma"; import { authorize, requireAuth } from "../middleware/auth"; import { addVmDisk, clusterUsageGraphs, deleteVm, migrateVm, nodeUsageGraphs, vmUsageGraphs, reinstallVm, reconfigureVmNetwork, restartVm, resumeVm, shutdownVm, startVm, stopVm, suspendVm, syncNodesAndVirtualMachines, updateVmConfiguration, vmConsoleTicket } from "../services/proxmox.service"; import { logAudit } from "../services/audit.service"; import { createOperationTask, markOperationTaskFailed, markOperationTaskRunning, markOperationTaskSuccess } from "../services/operations.service"; const router = Router(); const consoleTypeSchema = z.enum(["novnc", "spice", "xterm"]); const graphTimeframeSchema = z.enum(["hour", "day", "week", "month", "year"]); function vmRuntimeType(vm: { type: "QEMU" | "LXC" }) { return vm.type === "LXC" ? "lxc" : "qemu"; } function withUpid(payload: Prisma.InputJsonObject, upid?: string): Prisma.InputJsonObject { if (!upid) { return payload; } return { ...payload, upid }; } async function fetchVm(vmId: string) { const vm = await prisma.virtualMachine.findUnique({ where: { id: vmId } }); if (!vm) { throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); } return vm; } async function resolveConsoleProxyTarget(node: string, consoleType: "novnc" | "spice" | "xterm") { const setting = await prisma.setting.findUnique({ where: { key: "console_proxy" } }); const raw = setting?.value as | { mode?: "cluster" | "per_node"; cluster?: Record; nodes?: Record>; } | undefined; if (!raw) { return undefined; } const mode = raw.mode ?? "cluster"; if (mode === "per_node") { const nodeConfig = raw.nodes?.[node]; if (nodeConfig && typeof nodeConfig[consoleType] === "string") { return String(nodeConfig[consoleType]); } } if (raw.cluster && typeof raw.cluster[consoleType] === "string") { return String(raw.cluster[consoleType]); } return undefined; } router.post("/sync", requireAuth, authorize("node:manage"), async (req, res, next) => { try { const task = await createOperationTask({ taskType: OperationTaskType.SYSTEM_SYNC, requestedBy: req.user?.email, payload: { source: "manual_sync" } }); await markOperationTaskRunning(task.id); try { const result = await syncNodesAndVirtualMachines(); await markOperationTaskSuccess(task.id, { node_count: result.node_count }); await logAudit({ action: "proxmox_sync", resource_type: "NODE", actor_email: req.user!.email, actor_role: req.user!.role, details: { node_count: result.node_count, task_id: task.id }, ip_address: req.ip }); res.json({ ...result, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "Proxmox sync failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const actionSchema = z.object({ action: z.enum(["start", "stop", "restart", "shutdown", "suspend", "resume", "delete"]) }); router.post("/vms/:id/actions/:action", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const { action } = actionSchema.parse(req.params); const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const taskType = action === "delete" ? OperationTaskType.VM_DELETE : OperationTaskType.VM_POWER; const task = await createOperationTask({ taskType, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload: { action } }); await markOperationTaskRunning(task.id); let upid: string | undefined; try { if (action === "start") { upid = await startVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); } else if (action === "stop") { upid = await stopVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } }); } else if (action === "restart") { upid = await restartVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); } else if (action === "shutdown") { upid = await shutdownVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } }); } else if (action === "suspend") { upid = await suspendVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "PAUSED", proxmox_upid: upid } }); } else if (action === "resume") { upid = await resumeVm(vm.node, vm.vmid, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } }); } else { upid = await deleteVm(vm.node, vm.vmid, type); await prisma.virtualMachine.delete({ where: { id: vm.id } }); } const taskResult = withUpid( { vm_id: vm.id, action }, upid ); await markOperationTaskSuccess(task.id, taskResult, upid); await logAudit({ action: `vm_${action}`, resource_type: "VM", resource_id: vm.id, resource_name: vm.name, actor_email: req.user!.email, actor_role: req.user!.role, details: { ...taskResult, task_id: task.id }, ip_address: req.ip }); res.json({ success: true, action, upid, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "VM action failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const migrateSchema = z.object({ target_node: z.string().min(1) }); router.post("/vms/:id/migrate", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const payload = migrateSchema.parse(req.body); const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const task = await createOperationTask({ taskType: OperationTaskType.VM_MIGRATION, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload }); await markOperationTaskRunning(task.id); try { const upid = await migrateVm(vm.node, vm.vmid, payload.target_node, type); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { node: payload.target_node, status: "MIGRATING", proxmox_upid: upid } }); const migrationResult = withUpid( { vm_id: vm.id, from_node: vm.node, target_node: payload.target_node }, upid ); await markOperationTaskSuccess(task.id, migrationResult, upid); res.json({ success: true, upid, target_node: payload.target_node, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "VM migrate failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const configSchema = z .object({ hostname: z.string().min(1).optional(), iso_image: z.string().min(1).optional(), boot_order: z.string().min(1).optional(), ssh_public_key: z.string().min(10).optional(), qemu_guest_agent: z.boolean().optional() }) .refine((value) => Object.keys(value).length > 0, { message: "At least one configuration field is required" }); router.patch("/vms/:id/config", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const payload = configSchema.parse(req.body ?? {}); const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const config: Record = {}; if (payload.hostname) config.name = payload.hostname; if (payload.boot_order) config.boot = payload.boot_order; if (payload.ssh_public_key) config.sshkeys = payload.ssh_public_key; if (payload.iso_image && vm.type === "QEMU") config.ide2 = `${payload.iso_image},media=cdrom`; if (typeof payload.qemu_guest_agent === "boolean" && vm.type === "QEMU") { config.agent = payload.qemu_guest_agent ? 1 : 0; } const task = await createOperationTask({ taskType: OperationTaskType.VM_CONFIG, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload }); await markOperationTaskRunning(task.id); try { const upid = await updateVmConfiguration(vm.node, vm.vmid, type, config); const configResult = withUpid( { vm_id: vm.id, config: config as unknown as Prisma.InputJsonValue }, upid ); await markOperationTaskSuccess(task.id, configResult, upid); await logAudit({ action: "vm_config_update", resource_type: "VM", resource_id: vm.id, resource_name: vm.name, actor_email: req.user!.email, actor_role: req.user!.role, details: { config: config as unknown as Prisma.InputJsonValue, task_id: task.id, ...(upid ? { upid } : {}) }, ip_address: req.ip }); res.json({ success: true, upid, task_id: task.id, config_applied: config }); } catch (error) { const message = error instanceof Error ? error.message : "VM config update failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const networkSchema = z.object({ interface_name: z.string().optional(), bridge: z.string().min(1), vlan_tag: z.number().int().min(0).max(4094).optional(), rate_mbps: z.number().int().positive().optional(), firewall: z.boolean().optional(), ip_mode: z.enum(["dhcp", "static"]).default("dhcp"), ip_cidr: z.string().optional(), gateway: z.string().optional() }); router.patch("/vms/:id/network", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const payload = networkSchema.parse(req.body ?? {}); if (payload.ip_mode === "static" && !payload.ip_cidr) { throw new HttpError(400, "ip_cidr is required when ip_mode=static", "INVALID_NETWORK_PAYLOAD"); } const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const task = await createOperationTask({ taskType: OperationTaskType.VM_NETWORK, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload }); await markOperationTaskRunning(task.id); try { const networkInput: Parameters[3] = { interface_name: payload.interface_name, bridge: payload.bridge, vlan_tag: payload.vlan_tag, rate_mbps: payload.rate_mbps, firewall: payload.firewall, ip_mode: payload.ip_mode, ip_cidr: payload.ip_cidr, gateway: payload.gateway }; const upid = await reconfigureVmNetwork(vm.node, vm.vmid, type, networkInput); const networkResult = withUpid( { vm_id: vm.id, network: payload as unknown as Prisma.InputJsonValue }, upid ); await markOperationTaskSuccess(task.id, networkResult, upid); res.json({ success: true, upid, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "VM network update failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const diskSchema = z.object({ storage: z.string().min(1), size_gb: z.number().int().positive(), bus: z.enum(["scsi", "sata", "virtio", "ide"]).default("scsi"), mount_point: z.string().optional() }); router.post("/vms/:id/disks", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const payload = diskSchema.parse(req.body ?? {}); const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const task = await createOperationTask({ taskType: OperationTaskType.VM_CONFIG, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload }); await markOperationTaskRunning(task.id); try { const diskInput: Parameters[3] = { storage: payload.storage, size_gb: payload.size_gb, bus: payload.bus, mount_point: payload.mount_point }; const upid = await addVmDisk(vm.node, vm.vmid, type, diskInput); const diskResult = withUpid( { vm_id: vm.id, disk: payload as unknown as Prisma.InputJsonValue }, upid ); await markOperationTaskSuccess(task.id, diskResult, upid); res.status(201).json({ success: true, upid, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "VM disk attach failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); const reinstallSchema = z.object({ backup_before_reinstall: z.boolean().default(false), iso_image: z.string().optional(), ssh_public_key: z.string().optional() }); router.post("/vms/:id/reinstall", requireAuth, authorize("vm:update"), async (req, res, next) => { try { const payload = reinstallSchema.parse(req.body ?? {}); const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); if (payload.backup_before_reinstall) { await prisma.backup.create({ data: { vm_id: vm.id, vm_name: vm.name, node: vm.node, status: "PENDING", type: "FULL", schedule: "MANUAL", notes: "Auto-created before VM reinstall" } }); } const task = await createOperationTask({ taskType: OperationTaskType.VM_REINSTALL, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: req.user?.email, payload }); await markOperationTaskRunning(task.id); try { const upid = await reinstallVm(vm.node, vm.vmid, type, { iso_image: payload.iso_image, ssh_public_key: payload.ssh_public_key }); await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid ?? undefined } }); const reinstallResult = withUpid( { vm_id: vm.id, reinstall: payload as unknown as Prisma.InputJsonValue }, upid ); await markOperationTaskSuccess(task.id, reinstallResult, upid); res.json({ success: true, upid, task_id: task.id }); } catch (error) { const message = error instanceof Error ? error.message : "VM reinstall failed"; await markOperationTaskFailed(task.id, message); throw error; } } catch (error) { next(error); } }); router.get("/vms/:id/console", requireAuth, authorize("vm:read"), async (req, res, next) => { try { const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const consoleType = consoleTypeSchema.parse( typeof req.query.console_type === "string" ? req.query.console_type.toLowerCase() : "novnc" ); const ticket = await vmConsoleTicket(vm.node, vm.vmid, type, consoleType); const proxyTarget = await resolveConsoleProxyTarget(vm.node, consoleType); res.json({ ...ticket, console_type: consoleType, proxy_target: proxyTarget ?? null }); } catch (error) { next(error); } }); router.get("/vms/:id/usage-graphs", requireAuth, authorize("vm:read"), async (req, res, next) => { try { const vm = await fetchVm(req.params.id); const type = vmRuntimeType(vm); const timeframe = graphTimeframeSchema.parse( typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" ); const graph = await vmUsageGraphs(vm.node, vm.vmid, type, timeframe, { cpu_usage: vm.cpu_usage, ram_usage: vm.ram_usage, disk_usage: vm.disk_usage, network_in: vm.network_in, network_out: vm.network_out }); return res.json({ vm_id: vm.id, vm_name: vm.name, vm_type: vm.type, node: vm.node, timeframe: graph.timeframe, source: graph.source, summary: graph.summary, points: graph.points }); } catch (error) { return next(error); } }); router.get("/nodes/:id/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => { try { const node = await prisma.proxmoxNode.findFirst({ where: { OR: [{ id: req.params.id }, { hostname: req.params.id }, { name: req.params.id }] } }); if (!node) { throw new HttpError(404, "Node not found", "NODE_NOT_FOUND"); } const timeframe = graphTimeframeSchema.parse( typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" ); const graph = await nodeUsageGraphs(node.hostname, timeframe, { cpu_usage: node.cpu_usage, ram_used_mb: node.ram_used_mb, ram_total_mb: node.ram_total_mb, disk_used_gb: node.disk_used_gb, disk_total_gb: node.disk_total_gb }); return res.json({ node_id: node.id, node_name: node.name, node_hostname: node.hostname, timeframe: graph.timeframe, source: graph.source, summary: graph.summary, points: graph.points }); } catch (error) { return next(error); } }); router.get("/cluster/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => { try { const timeframe = graphTimeframeSchema.parse( typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day" ); const graph = await clusterUsageGraphs(timeframe); return res.json({ timeframe: graph.timeframe, source: graph.source, node_count: graph.node_count, nodes: graph.nodes, summary: graph.summary, points: graph.points }); } catch (error) { return next(error); } }); export default router;