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
+28
-22
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user