Files
Homelab-manager/server/src/routes/servers.ts
T
bobbanandClaude Sonnet 5 fea20456e4 Add a Windows agent (PowerShell)
Reports a Windows machine the way the Linux agent does, replacing the
"planned" stub in agent/windows: scheduled tasks plus hostname, IPv4
addresses, CPU model/cores/current load, memory, every fixed disk, and
TCP/UDP listening ports with the owning process (which feed the Ports
card, localhost-only listeners included).

Scripts (plain ASCII by design -- they are downloaded as text and Windows
PowerShell 5.1 reads BOM-less files as ANSI):
- report-tasks.ps1: collects and POSTs to /api/agent/report. Works in
  Windows PowerShell 5.1 and PowerShell 7. -DryRun prints the JSON.
  Microsoft's own \Microsoft\ tasks (hundreds) are left out unless
  INCLUDE_MICROSOFT_TASKS is set. Triggers are turned into readable text
  ("Weekly on Mon, Wed at 03:00", "At logon", "..., repeating every 15 min").
  Self-signed certificates work via API_INSECURE on both PowerShell
  versions (they need different mechanisms).
- install.ps1: elevated only; downloads the agent to ProgramData, writes
  agent.json with permissions locked to SYSTEM and Administrators *before*
  the token goes in, and registers a SYSTEM scheduled task (every 15 min
  plus at startup with a 2 min delay). Reinstalling replaces the task.
- uninstall.ps1: removes the task and only the files the agent installed.

Server: accepts schedule_type "windows_task"; a server can be registered
as Windows (Add a server has an operating system choice); an agent's
reported os_type ("linux"/"windows", anything else ignored) corrects the
stored one. The Servers page shows the right install and uninstall
command for each OS (Windows PowerShell 5.1 one-liners, with a self-signed
variant and a note about PowerShell 7), and Windows tasks are labelled
"Windows scheduled tasks". The Linux commands are unchanged.

Verified on this Windows machine, in both PowerShell 5.1 and 7:
- Real dry runs found and fixed bugs before anything shipped: tasks and
  ports came out as one nested item (return , $out wrapped twice), integer
  keys in an ordered dictionary index by position (wrong weekday names),
  and generic "Trigger" labels.
- End to end against the real agent-report router: HTTP, self-signed HTTPS
  refused by default and accepted with API_INSECURE, wrong token gives a
  clear one-line error and exit 1, and Swedish letters plus a euro sign
  survive JSON -> UTF-8 -> HTTP -> SQLite.
- 35 checks on trigger/action/duration descriptions, 20 on the installer's
  building blocks (task parts built but not registered, credentials file
  content and ACL, download over HTTP and self-signed HTTPS), 18 on the
  server rules, and the generated one-liners run through PowerShell's
  parser. The documented one-liners were run through iex and stop at the
  administrator check without changing anything.
- Found that PowerShell 7 ignores the ServicePointManager certificate
  override, so the installer's own download now uses -SkipCertificateCheck
  there.

NOT verified: the elevated install itself. Registering a SYSTEM scheduled
task needs elevation and changes the machine, so it was not run: the task
registration, that the repeating trigger really runs indefinitely, and
the agent running as SYSTEM under Task Scheduler have not been exercised.
Windows 10 / Server 2016 or newer is assumed; older is untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 00:09:00 +02:00

400 lines
14 KiB
TypeScript

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<T extends { apiTokenHash: string; tags: string | null }>(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<string, unknown>;
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();
}));