import { Router } from "express"; import { eq, and, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; import { servers, serverLinks, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { generateApiToken } from "../services/tokens.js"; import { recordAudit } from "../services/audit.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; import { serverPortsRouter } from "./serverPorts.js"; import { InvalidTagError, normalizeTags, parseTags } from "../services/serverTags.js"; export const serversRouter = Router(); /** A server row as the API returns it: no token hash, and tags as an array rather than the stored JSON. */ function publicServer(row: T) { const { apiTokenHash: _hash, tags, ...rest } = row; return { ...rest, tags: parseTags(tags) }; } serversRouter.use(requireAuth); serversRouter.use("/:id/ports", serverPortsRouter); const createServerSchema = z.object({ name: z.string().min(1).max(100), hostname: z.string().max(255).optional(), osType: z.enum(["linux", "windows"]).default("linux"), description: z.string().max(500).optional(), }); serversRouter.get("/", asyncHandler(async (_req, res) => { const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] }); res.json({ servers: rows.map(publicServer), }); })); serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = createServerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const { token, prefix, hash } = generateApiToken(); const [created] = await db .insert(servers) .values({ name: parsed.data.name, hostname: parsed.data.hostname, osType: parsed.data.osType, description: parsed.data.description, apiTokenHash: hash, apiTokenPrefix: prefix, }) .returning(); await recordAudit({ actor: req.currentUser!, category: "server", action: "create", targetType: "server", targetId: created.id, detail: { name: created.name }, }); const serverOut = publicServer(created); // The full token is only ever shown once, at creation time. res.status(201).json({ server: serverOut, token }); })); serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const { token, prefix, hash } = generateApiToken(); const [updated] = await db .update(servers) .set({ apiTokenHash: hash, apiTokenPrefix: prefix }) .where(eq(servers.id, id)) .returning(); if (!updated) return res.status(404).json({ error: "not_found" }); await recordAudit({ actor: req.currentUser!, category: "server", action: "rotate_token", targetType: "server", targetId: id, detail: { name: updated.name }, }); const serverOut = publicServer(updated); res.json({ server: serverOut, token }); })); const updateServerSchema = z .object({ name: z.string().min(1).max(100).optional(), hostname: z.string().max(255).optional(), description: z.string().max(500).optional(), proxmoxIntegrationId: z.number().int().nullable().optional(), proxmoxNode: z.string().nullable().optional(), proxmoxGuestType: z.enum(["qemu", "lxc"]).nullable().optional(), proxmoxVmid: z.number().int().nullable().optional(), hideProxmoxLink: z.boolean().optional(), }) .refine( (data) => { const proxmoxFields = [data.proxmoxIntegrationId, data.proxmoxNode, data.proxmoxGuestType, data.proxmoxVmid]; if (proxmoxFields.every((f) => f === undefined)) return true; const allNull = proxmoxFields.every((f) => f === null); const allSet = proxmoxFields.every((f) => f !== undefined && f !== null); return allNull || allSet; }, { message: "proxmoxIntegrationId/proxmoxNode/proxmoxGuestType/proxmoxVmid must be set together or all cleared to null" }, ); serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const parsed = updateServerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); if (!existing) return res.status(404).json({ error: "not_found" }); if (parsed.data.proxmoxIntegrationId) { const [integration] = await db .select() .from(integrations) .where(eq(integrations.id, parsed.data.proxmoxIntegrationId)) .limit(1); if (!integration || integration.type !== "proxmox") { return res.status(400).json({ error: "invalid_proxmox_integration" }); } } const [updated] = await db.update(servers).set(parsed.data).where(eq(servers.id, id)).returning(); await recordAudit({ actor: req.currentUser!, category: "server", action: "update", targetType: "server", targetId: id, detail: { name: updated.name }, }); const serverOut = publicServer(updated); res.json({ server: serverOut }); })); serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const [server] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); if (!server) return res.status(404).json({ error: "not_found" }); let ipAddresses: string[] = []; let hardware: Record; if (server.proxmoxIntegrationId && server.proxmoxNode && server.proxmoxGuestType && server.proxmoxVmid !== null) { const loaded = await loadIntegrationConfig(server.proxmoxIntegrationId); if (!loaded || loaded.integration.type !== "proxmox") { hardware = { source: "proxmox", error: "The linked Proxmox integration no longer exists or has changed type." }; } else { try { const adapter = createProxmoxAdapter(loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean }); const detail = await adapter.getGuestDetail( server.proxmoxNode, server.proxmoxGuestType as ProxmoxGuestType, server.proxmoxVmid, ); ipAddresses = detail.ipAddresses; hardware = { source: "proxmox", integrationId: loaded.integration.id, integrationName: loaded.integration.name, status: detail.status, cpuCores: detail.cpuCores, cpuUsagePercent: detail.cpuUsagePercent, memTotalBytes: detail.memoryBytes, memUsedBytes: detail.memUsedBytes, diskBytes: detail.diskBytes, disks: detail.disks, uptime: detail.uptime, guestAgentAvailable: detail.guestAgentAvailable, }; } catch (err) { hardware = { source: "proxmox", error: err instanceof Error ? err.message : String(err) }; } } } else if (server.cpuModel || server.cpuCores || server.memTotalBytes || server.disks) { ipAddresses = server.ipAddresses ? JSON.parse(server.ipAddresses) : []; hardware = { source: "agent", cpuModel: server.cpuModel, cpuCores: server.cpuCores, cpuLoadPercent: server.cpuLoadPercent, memTotalBytes: server.memTotalBytes, memUsedBytes: server.memUsedBytes, disks: server.disks ? JSON.parse(server.disks) : [], }; } else { hardware = { source: "none" }; } let dnsMatches: { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string }[] = []; if (ipAddresses.length > 0) { dnsMatches = await db .select({ recordName: dnsRecordsCache.name, recordType: dnsRecordsCache.type, ip: dnsRecordsCache.content, zoneName: dnsZonesCache.zoneName, providerName: dnsProviders.name, providerType: dnsProviders.providerType, }) .from(dnsRecordsCache) .innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id)) .leftJoin( dnsZonesCache, and(eq(dnsZonesCache.providerId, dnsRecordsCache.providerId), eq(dnsZonesCache.zoneId, dnsRecordsCache.zoneId)), ) .where(inArray(dnsRecordsCache.content, ipAddresses)); } const links = await db .select({ id: serverLinks.id, label: serverLinks.label, url: serverLinks.url }) .from(serverLinks) .where(eq(serverLinks.serverId, id)) .orderBy(serverLinks.label); const { apiTokenHash: _apiTokenHash, tags: rawTags, ipAddresses: _rawIpAddresses, disks: _rawDisks, cpuModel: _cpuModel, cpuCores: _cpuCores, cpuLoadPercent: _cpuLoadPercent, memTotalBytes: _memTotalBytes, memUsedBytes: _memUsedBytes, listeningPorts: _listeningPorts, lastPortScan: _lastPortScan, ...serverOut } = server; res.json({ server: { ...serverOut, tags: parseTags(rawTags) }, ipAddresses, hardware, dnsMatches, links }); })); const tagsSchema = z.object({ tags: z.array(z.string().max(100)).max(50) }); // Tags are lightweight labels, edited by operators like port notes and admin links — not admin-only like renaming a server. serversRouter.put("/:id/tags", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const parsed = tagsSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", message: "Tags must be a list of text.", details: parsed.error.flatten() }); } let tags: string[]; try { tags = normalizeTags(parsed.data.tags); } catch (err) { if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message }); throw err; } const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1); if (!existing) return res.status(404).json({ error: "not_found" }); await db.update(servers).set({ tags: tags.length > 0 ? JSON.stringify(tags) : null }).where(eq(servers.id, id)); await recordAudit({ actor: req.currentUser!, category: "server", action: "set_tags", targetType: "server", targetId: id, detail: { name: existing.name, before: parseTags(existing.tags), after: tags }, }); res.json({ tags }); })); const linkSchema = z.object({ label: z.string().min(1).max(60), url: z .string() .url() .refine((u) => u.startsWith("http://") || u.startsWith("https://"), { message: "URL must start with http:// or https://" }), }); serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (req, res) => { const serverId = Number(req.params.id); if (!Number.isInteger(serverId)) return res.status(400).json({ error: "invalid_id" }); const parsed = linkSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1); if (!server) return res.status(404).json({ error: "not_found" }); const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning(); await recordAudit({ actor: req.currentUser!, category: "server", action: "add_link", targetType: "server", targetId: serverId, detail: { label: created.label, url: created.url }, }); res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } }); })); serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => { const serverId = Number(req.params.id); const linkId = Number(req.params.linkId); if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" }); const parsed = linkSchema.partial().safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const [updated] = await db .update(serverLinks) .set(parsed.data) .where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId))) .returning(); if (!updated) return res.status(404).json({ error: "not_found" }); await recordAudit({ actor: req.currentUser!, category: "server", action: "update_link", targetType: "server", targetId: serverId, detail: { label: updated.label, url: updated.url }, }); res.json({ link: { id: updated.id, label: updated.label, url: updated.url } }); })); serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => { const serverId = Number(req.params.id); const linkId = Number(req.params.linkId); if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" }); const deleted = await db .delete(serverLinks) .where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId))) .returning(); if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); await recordAudit({ actor: req.currentUser!, category: "server", action: "remove_link", targetType: "server", targetId: serverId, detail: { label: deleted[0].label }, }); res.status(204).end(); })); serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const deleted = await db.delete(servers).where(eq(servers.id, id)).returning(); if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); await recordAudit({ actor: req.currentUser!, category: "server", action: "delete", targetType: "server", targetId: id, detail: { name: deleted[0].name }, }); res.status(204).end(); }));