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>
400 lines
14 KiB
TypeScript
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();
|
|
}));
|