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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user