638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
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<string, unknown>;
|
|
nodes?: Record<string, Record<string, unknown>>;
|
|
}
|
|
| 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<string, string | number | boolean> = {};
|
|
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<typeof reconfigureVmNetwork>[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<typeof addVmDisk>[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;
|