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>
This commit is contained in:
1 parent
ae64cb345c
commit
fea20456e4
14 files changed
+675
-43
No files matched your search
@@ -245,7 +245,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'windows_task' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
|
||||
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
|
||||
name: text("name").notNull(),
|
||||
command: text("command"),
|
||||
|
||||
@@ -38,7 +38,7 @@ const reportSchema = z.object({
|
||||
system: systemSchema.nullable().optional(),
|
||||
tasks: z.array(
|
||||
z.object({
|
||||
schedule_type: z.enum(["cron", "systemd_timer"]),
|
||||
schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]),
|
||||
name: z.string().min(1),
|
||||
command: z.string().optional(),
|
||||
schedule_expression: z.string().optional(),
|
||||
@@ -72,6 +72,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
|
||||
|
||||
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,
|
||||
|
||||
@@ -26,7 +26,7 @@ serversRouter.use("/:id/ports", serverPortsRouter);
|
||||
const createServerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
hostname: z.string().max(255).optional(),
|
||||
osType: z.literal("linux").default("linux"),
|
||||
osType: z.enum(["linux", "windows"]).default("linux"),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ tasksRouter.get("/", asyncHandler(async (req, res) => {
|
||||
|
||||
const manualTaskSchema = z.object({
|
||||
serverId: z.number().int(),
|
||||
scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]),
|
||||
scheduleType: z.enum(["cron", "systemd_timer", "windows_task", "docker", "backup", "update", "n8n_workflow", "manual"]),
|
||||
name: z.string().min(1).max(200),
|
||||
command: z.string().max(1000).optional(),
|
||||
scheduleExpression: z.string().max(200).optional(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "../db/client.js";
|
||||
import { scheduledTasks, servers } from "../db/schema.js";
|
||||
|
||||
export interface IncomingTask {
|
||||
scheduleType: "cron" | "systemd_timer";
|
||||
scheduleType: "cron" | "systemd_timer" | "windows_task";
|
||||
name: string;
|
||||
command?: string;
|
||||
scheduleExpression?: string;
|
||||
@@ -23,6 +23,8 @@ export interface IncomingSystemInfo {
|
||||
|
||||
export interface AgentReport {
|
||||
hostname?: string;
|
||||
/** What the agent says it runs on. Only "linux" and "windows" are recorded; anything else is ignored. */
|
||||
osType?: string;
|
||||
system?: IncomingSystemInfo | null;
|
||||
tasks: IncomingTask[];
|
||||
}
|
||||
@@ -100,6 +102,8 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
.set({
|
||||
lastSeenAt: now,
|
||||
...(report.hostname ? { hostname: report.hostname } : {}),
|
||||
// An agent knows what it runs on better than whoever registered the server did.
|
||||
...(report.osType === "linux" || report.osType === "windows" ? { osType: report.osType } : {}),
|
||||
...(system?.ip_addresses ? { ipAddresses: JSON.stringify(system.ip_addresses) } : {}),
|
||||
...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}),
|
||||
...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}),
|
||||
|
||||
Reference in new issue
Block a user