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>
91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
import { Router } from "express";
|
|
import { z } from "zod";
|
|
import { db } from "../db/client.js";
|
|
import { hashToken } from "../services/tokens.js";
|
|
import { syncServerTasks } from "../services/taskSync.js";
|
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
|
|
|
export const agentReportRouter = Router();
|
|
|
|
const listeningPortSchema = z.object({
|
|
protocol: z.enum(["tcp", "udp"]),
|
|
port: z.number().int().min(1).max(65535),
|
|
address: z.string().max(100),
|
|
process: z.string().max(100).optional(),
|
|
});
|
|
|
|
const systemSchema = z.object({
|
|
ip_addresses: z.array(z.string()).optional(),
|
|
cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(),
|
|
memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(),
|
|
disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(),
|
|
// Deliberately lenient: one odd line from `ss` must never cost the agent its whole report (tasks included),
|
|
// so entries are validated one by one and bad ones dropped rather than failing the request.
|
|
listening_ports: z
|
|
.array(z.unknown())
|
|
.max(5000)
|
|
.optional()
|
|
.transform((entries) => entries?.flatMap((e) => {
|
|
const parsed = listeningPortSchema.safeParse(e);
|
|
return parsed.success ? [parsed.data] : [];
|
|
})),
|
|
});
|
|
|
|
const reportSchema = z.object({
|
|
hostname: z.string().max(255).optional(),
|
|
os_type: z.string().optional(),
|
|
reported_at: z.string().optional(),
|
|
system: systemSchema.nullable().optional(),
|
|
tasks: z.array(
|
|
z.object({
|
|
schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]),
|
|
name: z.string().min(1),
|
|
command: z.string().optional(),
|
|
schedule_expression: z.string().optional(),
|
|
source: z.string().optional(),
|
|
enabled: z.boolean().optional(),
|
|
next_run_at: z.string().optional(),
|
|
metadata: z.unknown().optional(),
|
|
}),
|
|
),
|
|
});
|
|
|
|
agentReportRouter.post("/", asyncHandler(async (req, res) => {
|
|
const authHeader = req.header("authorization") ?? "";
|
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
if (!match) {
|
|
return res.status(401).json({ error: "missing_token" });
|
|
}
|
|
|
|
const tokenHash = hashToken(match[1]);
|
|
const server = await db.query.servers.findFirst({
|
|
where: (s, { eq }) => eq(s.apiTokenHash, tokenHash),
|
|
});
|
|
if (!server) {
|
|
return res.status(401).json({ error: "invalid_token" });
|
|
}
|
|
|
|
const parsed = reportSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
|
}
|
|
|
|
await syncServerTasks(server.id, {
|
|
hostname: parsed.data.hostname,
|
|
osType: parsed.data.os_type,
|
|
system: parsed.data.system,
|
|
tasks: parsed.data.tasks.map((t) => ({
|
|
scheduleType: t.schedule_type,
|
|
name: t.name,
|
|
command: t.command,
|
|
scheduleExpression: t.schedule_expression,
|
|
source: t.source,
|
|
enabled: t.enabled,
|
|
nextRunAt: t.next_run_at,
|
|
metadata: t.metadata,
|
|
})),
|
|
});
|
|
|
|
res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length });
|
|
}));
|