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:
bobbanandClaude Sonnet 5 committed 2026-09-27 00:09:00 +02:00
1 parent ae64cb345c
commit fea20456e4
14 files changed
+675 -43

No files matched your search

+2 -2
View File
@@ -399,7 +399,7 @@ export interface ServerUpdateInput {
hideProxmoxLink?: boolean;
}
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
export type ScheduleType = "cron" | "systemd_timer" | "windows_task" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
export interface TaskRecord {
id: number;
@@ -912,7 +912,7 @@ export const api = {
},
servers: {
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
create: (data: { name: string; hostname?: string; description?: string }) =>
create: (data: { name: string; hostname?: string; description?: string; osType?: "linux" | "windows" }) =>
request<{ server: ServerRecord; token: string }>("/api/servers", {
method: "POST",
body: JSON.stringify(data),
+1
View File
@@ -10,6 +10,7 @@ import { downloadCsv } from "../utils/csv";
export const SCHEDULE_TYPE_LABELS: Record<string, string> = {
cron: "Cron jobs",
systemd_timer: "systemd timers",
windows_task: "Windows scheduled tasks",
docker: "Docker jobs",
backup: "Backups",
update: "Updates",
+28 -22
View File
@@ -9,6 +9,7 @@ import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv";
import { TagBadges } from "../components/ServerTags";
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
import { tagBadge, useTagColors } from "../utils/tags";
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
@@ -17,18 +18,6 @@ function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProper
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
}
function installCommand(token: string, insecure: boolean): string {
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
const envVars = insecure
? `API_URL=${window.location.origin} API_TOKEN=${token} API_INSECURE=true`
: `API_URL=${window.location.origin} API_TOKEN=${token}`;
return `curl ${curlFlags} ${window.location.origin}/agent/linux/install.sh | sudo ${envVars} bash`;
}
function uninstallCommand(insecure: boolean): string {
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
return `curl ${curlFlags} ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
}
export default function Servers({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
@@ -49,6 +38,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
const [serverName, setServerName] = useState("");
const [serverHostname, setServerHostname] = useState("");
const [serverDescription, setServerDescription] = useState("");
const [serverOs, setServerOs] = useState<AgentOs>("linux");
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
const [insecureAgent, setInsecureAgent] = useState(false);
@@ -72,6 +62,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
name: serverName,
hostname: serverHostname || undefined,
description: serverDescription || undefined,
osType: serverOs,
});
setNewToken(result);
setServerName("");
@@ -154,7 +145,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
</div>
<form onSubmit={createServer}>
<div className="card-body row g-3">
<div className="col-md-4">
<div className="col-md-3">
<label className="form-label">Server name</label>
<input
className="form-control"
@@ -164,7 +155,14 @@ export default function Servers({ user }: { user: CurrentUser }) {
onChange={(e) => setServerName(e.target.value)}
/>
</div>
<div className="col-md-4">
<div className="col-md-2">
<label className="form-label">Operating system</label>
<select className="form-select" value={serverOs} onChange={(e) => setServerOs(e.target.value as AgentOs)}>
<option value="linux">Linux</option>
<option value="windows">Windows</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Hostname</label>
<input
className="form-control"
@@ -213,13 +211,19 @@ export default function Servers({ user }: { user: CurrentUser }) {
This Homelab Manager instance uses a self-signed certificate (skip TLS verification on the agent)
</span>
</label>
<p className="text-secondary">
Install the agent on the server (run as root — put sudo right after the pipe, not before curl):
</p>
<p className="text-secondary">{AGENT_RUN_HINT[agentOsOf(newToken.server.osType)].install}</p>
<div className="input-group">
<pre className="form-control text-wrap mb-0">{installCommand(newToken.token, insecureAgent)}</pre>
<CopyButton text={installCommand(newToken.token, insecureAgent)} />
<pre className="form-control text-wrap mb-0">
{installCommand(agentOsOf(newToken.server.osType), window.location.origin, newToken.token, insecureAgent)}
</pre>
<CopyButton text={installCommand(agentOsOf(newToken.server.osType), window.location.origin, newToken.token, insecureAgent)} />
</div>
{agentOsOf(newToken.server.osType) === "windows" && insecureAgent && (
<div className="text-secondary small mt-2">
This form is for Windows PowerShell 5.1, the one built into Windows. In PowerShell 7 the download step needs{" "}
<code>-SkipCertificateCheck</code> instead.
</div>
)}
</div>
<div className="card-footer">
<button className="btn" onClick={() => setNewToken(null)}>
@@ -235,10 +239,12 @@ export default function Servers({ user }: { user: CurrentUser }) {
<h3 className="card-title">Uninstall agent from {uninstallFor.name}</h3>
</div>
<div className="card-body">
<p className="text-secondary">Run this on the server as root to stop and remove the agent (its entry here is kept):</p>
<p className="text-secondary">{AGENT_RUN_HINT[agentOsOf(uninstallFor.osType)].uninstall}</p>
<div className="input-group">
<pre className="form-control text-wrap mb-0">{uninstallCommand(insecureAgent)}</pre>
<CopyButton text={uninstallCommand(insecureAgent)} />
<pre className="form-control text-wrap mb-0">
{uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)}
</pre>
<CopyButton text={uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)} />
</div>
</div>
<div className="card-footer">
+47
View File
@@ -0,0 +1,47 @@
export type AgentOs = "linux" | "windows";
/** What the agent's install script needs and why: shown next to the command so nobody has to guess how to run it. */
export const AGENT_RUN_HINT: Record<AgentOs, { install: string; uninstall: string }> = {
linux: {
install: "Install the agent on the server (run as root — put sudo right after the pipe, not before curl):",
uninstall: "Run this on the server as root to stop and remove the agent (its entry here is kept):",
},
windows: {
install:
"Install the agent on the Windows machine. Paste this into an elevated Windows PowerShell (right-click → Run as administrator):",
uninstall:
"Paste this into an elevated Windows PowerShell on the machine to stop and remove the agent (its entry here is kept):",
},
};
const TLS12 = "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12";
const TRUST_ALL = "[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }";
/** Windows PowerShell 5.1 one-liner: TLS 1.2 on, optional self-signed override, then download and run the installer. */
function windowsPrefix(insecure: boolean): string {
return insecure ? `${TLS12}; ${TRUST_ALL}` : TLS12;
}
export function installCommand(os: AgentOs, origin: string, token: string, insecure: boolean): string {
if (os === "windows") {
const env = insecure
? `$env:API_URL = '${origin}'; $env:API_TOKEN = '${token}'; $env:API_INSECURE = 'true'`
: `$env:API_URL = '${origin}'; $env:API_TOKEN = '${token}'`;
return `${windowsPrefix(insecure)}; ${env}; iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))`;
}
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
const envVars = insecure ? `API_URL=${origin} API_TOKEN=${token} API_INSECURE=true` : `API_URL=${origin} API_TOKEN=${token}`;
return `curl ${curlFlags} ${origin}/agent/linux/install.sh | sudo ${envVars} bash`;
}
export function uninstallCommand(os: AgentOs, origin: string, insecure: boolean): string {
if (os === "windows") {
return `${windowsPrefix(insecure)}; iex ((New-Object Net.WebClient).DownloadString('${origin}/agent/windows/uninstall.ps1'))`;
}
const curlFlags = insecure ? "-fsSL -k" : "-fsSL";
return `curl ${curlFlags} ${origin}/agent/linux/uninstall.sh | sudo bash`;
}
export function agentOsOf(osType: string): AgentOs {
return osType === "windows" ? "windows" : "linux";
}