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
@@ -49,8 +49,9 @@ All modules from the original plan are built:
|
|||||||
DNS, cPanel, and Technitium; providers are configured in-app (not via env
|
DNS, cPanel, and Technitium; providers are configured in-app (not via env
|
||||||
vars) and their credentials are encrypted at rest
|
vars) and their credentials are encrypted at rest
|
||||||
- **Servers** — cron/systemd tracking across Debian/Raspbian servers via a
|
- **Servers** — cron/systemd tracking across Debian/Raspbian servers via a
|
||||||
lightweight push agent (`agent/linux/`), plus manual entries for things an
|
lightweight push agent (`agent/linux/`), Windows scheduled-task tracking
|
||||||
agent can't see (Docker jobs, backups). The Servers page itself just lists
|
through a PowerShell agent (`agent/windows/`, see its README), plus manual
|
||||||
|
entries for things an agent can't see (Docker jobs, backups). The Servers page itself just lists
|
||||||
registered servers and (admin-only) adds new ones / issues agent tokens;
|
registered servers and (admin-only) adds new ones / issues agent tokens;
|
||||||
clicking a server opens its detail page with CPU/RAM/disk status, IP
|
clicking a server opens its detail page with CPU/RAM/disk status, IP
|
||||||
addresses, matching DNS names (looked up from the DNS module's cache), and
|
addresses, matching DNS names (looked up from the DNS module's cache), and
|
||||||
|
|||||||
+80
-12
@@ -1,14 +1,82 @@
|
|||||||
# Windows agent (planned, not yet implemented)
|
# Windows agent
|
||||||
|
|
||||||
v1 of the Servers & Tasks module only supports Linux servers (cron + systemd
|
Reports a Windows machine to Homelab Manager the same way the [Linux agent](../linux/) does: its
|
||||||
timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows
|
scheduled tasks, plus hostname, IP addresses, CPU / memory / disk usage and listening ports. It
|
||||||
agent is a natural future addition and would follow the same contract as the
|
runs as a scheduled task (as SYSTEM, every 15 minutes) and pushes to `POST /api/agent/report`, so
|
||||||
Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh):
|
the machine never needs to be reachable from Homelab Manager.
|
||||||
|
|
||||||
- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command,
|
## Install
|
||||||
trigger description, next run time, enabled state).
|
|
||||||
- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"`
|
1. In Homelab Manager, **Servers → Manage servers → Add a server**, choose **Windows** as the
|
||||||
(the server and UI already treat `schedule_type` as an open string in storage; only the
|
operating system, and copy the install command shown once for the new token.
|
||||||
`tasks` API and UI schedule-type filter would need the new value added).
|
2. On the Windows machine, open **Windows PowerShell as administrator** and paste it. It looks like:
|
||||||
- Ship as a scheduled task (naturally) or a small Windows service that runs on a timer,
|
|
||||||
configured via the same `API_URL` / `API_TOKEN` environment variables as the Linux agent.
|
```powershell
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
|
||||||
|
iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer downloads the agent to `C:\ProgramData\HomelabManager\`, stores the URL and token
|
||||||
|
there in `agent.json` (readable only by SYSTEM and Administrators), registers a scheduled task named
|
||||||
|
**Homelab Manager Agent**, and sends a first report so you see straight away whether it worked.
|
||||||
|
|
||||||
|
If Homelab Manager uses a **self-signed certificate**, tick the box in the Servers page: the command
|
||||||
|
then also skips certificate checks for the download and sets `API_INSECURE`, which makes the agent
|
||||||
|
skip them for every report. Only do that on a trusted LAN. That form is for Windows PowerShell 5.1
|
||||||
|
(the one built into Windows); in PowerShell 7 use `-SkipCertificateCheck` for the download step.
|
||||||
|
|
||||||
|
Set `INTERVAL_MINUTES` before installing to report more or less often (default 15).
|
||||||
|
|
||||||
|
## What it reports
|
||||||
|
|
||||||
|
- **Scheduled tasks** — name, what they run (program and arguments), a readable description of their
|
||||||
|
triggers ("Daily at 02:00", "Weekly on Mon, Wed at 03:00", "At logon", "…, repeating every 15 min"),
|
||||||
|
next run time and whether they're enabled. They show up under *Windows scheduled tasks*. Microsoft's
|
||||||
|
own tasks (the `\Microsoft\` folder — several hundred) are left out; set `INCLUDE_MICROSOFT_TASKS=true`
|
||||||
|
(environment variable, or `"includeMicrosoftTasks": true` in `agent.json`) to report them too.
|
||||||
|
- **System** — IPv4 addresses (not loopback or 169.254.x), CPU model, cores and current processor load,
|
||||||
|
memory, and every fixed disk (`C:`, `D:`, …).
|
||||||
|
- **Listening ports** — TCP listeners and UDP endpoints with the program that owns them, so they appear
|
||||||
|
on the server's Ports card, including services bound to localhost only.
|
||||||
|
|
||||||
|
Unlike the Linux agent's CPU figure (a load average), the Windows one is the processor's actual
|
||||||
|
current load.
|
||||||
|
|
||||||
|
## Try it without installing
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
|
||||||
|
.\report-tasks.ps1 -DryRun # prints the JSON it would send, sends nothing
|
||||||
|
.\report-tasks.ps1 # sends one report
|
||||||
|
```
|
||||||
|
|
||||||
|
Works in Windows PowerShell 5.1 and PowerShell 7, with or without administrator rights.
|
||||||
|
|
||||||
|
## Check on it
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ScheduledTaskInfo -TaskName 'Homelab Manager Agent' # LastRunTime, LastTaskResult (0 = fine)
|
||||||
|
Start-ScheduledTask -TaskName 'Homelab Manager Agent' # run it now
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
In an elevated Windows PowerShell (the Servers page shows the exact command for that server):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
iex ((New-Object Net.WebClient).DownloadString('https://homelab.example.lan/agent/windows/uninstall.ps1'))
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes the scheduled task, the agent script and `agent.json`. The server's entry and history in
|
||||||
|
Homelab Manager are kept — delete it from the Servers page if you no longer want it tracked.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Written for Windows 10 / Windows Server 2016 or newer. Older releases are untested; the repeating trigger
|
||||||
|
is created without an end date, which very old Task Scheduler versions may not accept.
|
||||||
|
- The scripts in this folder are deliberately plain ASCII: they're downloaded as text, and Windows
|
||||||
|
PowerShell 5.1 reads a file without a byte-order mark as ANSI, so anything else would be garbled.
|
||||||
|
- Task names, commands and ports are sent to your Homelab Manager server; see its Privacy page for what
|
||||||
|
it stores.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Installs the Homelab Manager agent on Windows as a scheduled task that runs as SYSTEM.
|
||||||
|
#
|
||||||
|
# Run in an ELEVATED Windows PowerShell (Run as administrator), with your server's URL and this
|
||||||
|
# server's token from the Servers page:
|
||||||
|
#
|
||||||
|
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
# $env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
|
||||||
|
# iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
|
||||||
|
#
|
||||||
|
# If Homelab Manager uses a self-signed certificate, also set API_INSECURE (this skips certificate checks
|
||||||
|
# for every request the agent makes - only on a trusted LAN) and skip the check for the download itself,
|
||||||
|
# since fetching this very script hits the same certificate:
|
||||||
|
#
|
||||||
|
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
# [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||||
|
# $env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'; $env:API_INSECURE = 'true'
|
||||||
|
# iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
|
||||||
|
#
|
||||||
|
# Optional: INTERVAL_MINUTES (default 15).
|
||||||
|
#
|
||||||
|
# NOTE: keep this file plain ASCII (it is downloaded as text).
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$script:TaskName = 'Homelab Manager Agent'
|
||||||
|
$script:InstallDir = Join-Path $env:ProgramData 'HomelabManager'
|
||||||
|
|
||||||
|
function Test-Administrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
return ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Truthy([string]$Value) { return ($Value -match '^(1|true|yes)$') }
|
||||||
|
|
||||||
|
# Downloads a file. Windows PowerShell 5.1 needs TLS 1.2 switched on and takes the self-signed-certificate override through
|
||||||
|
# ServicePointManager; PowerShell 7 ignores that setting and has its own switch.
|
||||||
|
function Save-Url([string]$Url, [string]$Path, [bool]$Insecure) {
|
||||||
|
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||||
|
$params = @{ Uri = $Url; OutFile = $Path }
|
||||||
|
if ($Insecure) { $params['SkipCertificateCheck'] = $true }
|
||||||
|
Invoke-WebRequest @params
|
||||||
|
return
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ($Insecure) { [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } }
|
||||||
|
$client = New-Object System.Net.WebClient
|
||||||
|
try { $client.DownloadFile($Url, $Path) } finally { $client.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extra principals (as "*SID:(F)") allowed to write the credentials file. Empty in real use - an elevated installer already
|
||||||
|
# holds Administrators - and only there so a test running without elevation can still write to its own temporary file.
|
||||||
|
$script:ExtraConfigGrants = @()
|
||||||
|
|
||||||
|
# The credentials file holds the token, so only SYSTEM and Administrators may read it. Identified by SID so this works on any Windows language.
|
||||||
|
function Save-AgentConfig([string]$Path, [string]$ApiUrl, [string]$ApiToken, [bool]$Insecure) {
|
||||||
|
$config = [ordered]@{ apiUrl = $ApiUrl; apiToken = $ApiToken; insecure = $Insecure }
|
||||||
|
# Write with restrictive permissions already in place, so the token is never readable by anyone else, even briefly.
|
||||||
|
[System.IO.File]::WriteAllText($Path, '', (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
$grants = @('*S-1-5-18:(F)', '*S-1-5-32-544:(F)') + @($script:ExtraConfigGrants)
|
||||||
|
& icacls.exe $Path /inheritance:r /grant:r $grants | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Couldn't restrict permissions on $Path (icacls exit $LASTEXITCODE)." }
|
||||||
|
[System.IO.File]::WriteAllText($Path, (ConvertTo-Json -InputObject $config), (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
# The pieces of the scheduled task, built but not registered.
|
||||||
|
function New-AgentTaskParts([string]$AgentScript, [int]$IntervalMinutes) {
|
||||||
|
$argument = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f $AgentScript
|
||||||
|
$repeat = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||||
|
$boot = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$boot.Delay = 'PT2M' # let the network come up before the first report
|
||||||
|
return @{
|
||||||
|
Action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument
|
||||||
|
Triggers = @($repeat, $boot)
|
||||||
|
Principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-Agent {
|
||||||
|
if (-not (Test-Administrator)) {
|
||||||
|
throw 'This installer must be run as Administrator (it registers a scheduled task that runs as SYSTEM). Open PowerShell with "Run as administrator" and try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = ([string]$env:API_URL).TrimEnd('/')
|
||||||
|
$apiToken = [string]$env:API_TOKEN
|
||||||
|
if (-not $apiUrl) { throw 'Set API_URL to your Homelab Manager URL, e.g. $env:API_URL = ''https://homelab.example.lan''' }
|
||||||
|
if (-not $apiToken) { throw 'Set API_TOKEN to the per-server token from the Servers page, e.g. $env:API_TOKEN = ''hlm_xxx''' }
|
||||||
|
$insecure = Test-Truthy $env:API_INSECURE
|
||||||
|
|
||||||
|
$interval = 15
|
||||||
|
if ($env:INTERVAL_MINUTES) {
|
||||||
|
if (-not ([int]::TryParse($env:INTERVAL_MINUTES, [ref]$interval)) -or $interval -lt 1 -or $interval -gt 1440) {
|
||||||
|
throw 'INTERVAL_MINUTES must be a whole number from 1 to 1440.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Installing Homelab Manager agent from $apiUrl ..."
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $script:InstallDir | Out-Null
|
||||||
|
$agentScript = Join-Path $script:InstallDir 'homelab-manager-agent.ps1'
|
||||||
|
Save-Url "$apiUrl/agent/windows/report-tasks.ps1" $agentScript $insecure
|
||||||
|
|
||||||
|
Save-AgentConfig (Join-Path $script:InstallDir 'agent.json') $apiUrl $apiToken $insecure
|
||||||
|
|
||||||
|
# Reinstalling replaces the task rather than failing on it.
|
||||||
|
if (Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue) {
|
||||||
|
Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false
|
||||||
|
}
|
||||||
|
$parts = New-AgentTaskParts $agentScript $interval
|
||||||
|
$null = Register-ScheduledTask -TaskName $script:TaskName -Action $parts.Action -Trigger $parts.Triggers -Principal $parts.Principal -Settings $parts.Settings -Description 'Reports scheduled tasks and system information to Homelab Manager.'
|
||||||
|
|
||||||
|
Write-Output 'Installed. Running an initial report now...'
|
||||||
|
& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $agentScript
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning "The initial report failed (see above). The agent is installed and will keep trying every $interval minute(s) - check API_URL and API_TOKEN."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Done. The agent reports every $interval minute(s) through the '$script:TaskName' scheduled task."
|
||||||
|
Write-Output "To remove it later, run in an elevated PowerShell: iex ((New-Object Net.WebClient).DownloadString('$apiUrl/agent/windows/uninstall.ps1'))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dot-sourcing (for tests) defines the functions without running anything.
|
||||||
|
if ($MyInvocation.InvocationName -ne '.') { Install-Agent }
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Collects scheduled tasks and basic system information on this Windows host and
|
||||||
|
POSTs them to the Homelab Manager API.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Intended to run as SYSTEM on a schedule (see install.ps1), but can be run by
|
||||||
|
hand for testing:
|
||||||
|
|
||||||
|
$env:API_URL='https://homelab.example.lan'; $env:API_TOKEN='hlm_xxx'
|
||||||
|
.\report-tasks.ps1 -DryRun
|
||||||
|
|
||||||
|
Configuration comes from the API_URL / API_TOKEN / API_INSECURE environment
|
||||||
|
variables, or else from %ProgramData%\HomelabManager\agent.json (written by
|
||||||
|
install.ps1). Works in Windows PowerShell 5.1 and PowerShell 7.
|
||||||
|
|
||||||
|
Set API_INSECURE=true if Homelab Manager uses a self-signed certificate. That
|
||||||
|
skips certificate checks for every request this agent makes - only do it on a
|
||||||
|
trusted LAN.
|
||||||
|
|
||||||
|
Microsoft's built-in scheduled tasks (the \Microsoft\ folder, several hundred
|
||||||
|
of them) are left out; set INCLUDE_MICROSOFT_TASKS=true to report them too.
|
||||||
|
|
||||||
|
NOTE: keep this file plain ASCII. It is downloaded as text and Windows
|
||||||
|
PowerShell 5.1 reads a file without a BOM as ANSI, so anything else garbles.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$script:DefaultConfigPath = Join-Path $env:ProgramData 'HomelabManager\agent.json'
|
||||||
|
|
||||||
|
# ---- configuration ------------------------------------------------------------
|
||||||
|
|
||||||
|
function Get-AgentConfig {
|
||||||
|
$config = @{ ApiUrl = $null; ApiToken = $null; Insecure = $false; IncludeMicrosoftTasks = $false }
|
||||||
|
|
||||||
|
$path = if ($env:HLM_CONFIG) { $env:HLM_CONFIG } else { $script:DefaultConfigPath }
|
||||||
|
if (Test-Path -LiteralPath $path) {
|
||||||
|
$file = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
|
||||||
|
if ($file.apiUrl) { $config.ApiUrl = [string]$file.apiUrl }
|
||||||
|
if ($file.apiToken) { $config.ApiToken = [string]$file.apiToken }
|
||||||
|
if ($null -ne $file.insecure) { $config.Insecure = [bool]$file.insecure }
|
||||||
|
if ($null -ne $file.includeMicrosoftTasks) { $config.IncludeMicrosoftTasks = [bool]$file.includeMicrosoftTasks }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Environment variables win over the file, like the Linux agent.
|
||||||
|
if ($env:API_URL) { $config.ApiUrl = $env:API_URL }
|
||||||
|
if ($env:API_TOKEN) { $config.ApiToken = $env:API_TOKEN }
|
||||||
|
if ($env:API_INSECURE) { $config.Insecure = $env:API_INSECURE -match '^(1|true|yes)$' }
|
||||||
|
if ($env:INCLUDE_MICROSOFT_TASKS) { $config.IncludeMicrosoftTasks = $env:INCLUDE_MICROSOFT_TASKS -match '^(1|true|yes)$' }
|
||||||
|
|
||||||
|
if ($config.ApiUrl) { $config.ApiUrl = $config.ApiUrl.TrimEnd('/') }
|
||||||
|
return $config
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- describing scheduled tasks -------------------------------------------------
|
||||||
|
|
||||||
|
# "PT15M" -> "15 min", "P1D" -> "1 day", "PT1H30M" -> "1 h 30 min". Returns $null for empty/unparseable.
|
||||||
|
function Convert-IsoDuration([string]$Iso) {
|
||||||
|
if (-not $Iso) { return $null }
|
||||||
|
$m = [regex]::Match($Iso, '^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$')
|
||||||
|
if (-not $m.Success) { return $null }
|
||||||
|
$parts = @()
|
||||||
|
if ($m.Groups[1].Success) { $parts += ('{0} day{1}' -f $m.Groups[1].Value, $(if ($m.Groups[1].Value -eq '1') { '' } else { 's' })) }
|
||||||
|
if ($m.Groups[2].Success) { $parts += ('{0} h' -f $m.Groups[2].Value) }
|
||||||
|
if ($m.Groups[3].Success) { $parts += ('{0} min' -f $m.Groups[3].Value) }
|
||||||
|
if ($m.Groups[4].Success) { $parts += ('{0} s' -f $m.Groups[4].Value) }
|
||||||
|
if ($parts.Count -eq 0) { return $null }
|
||||||
|
return ($parts -join ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
# StartBoundary is "2026-01-01T02:00:00" (local time, sometimes with an offset). Returns @{ Date = 'yyyy-MM-dd'; Time = 'HH:mm' }.
|
||||||
|
function Split-Boundary([string]$Boundary) {
|
||||||
|
$m = [regex]::Match([string]$Boundary, '^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})')
|
||||||
|
if (-not $m.Success) { return @{ Date = $null; Time = $null } }
|
||||||
|
return @{ Date = $m.Groups[1].Value; Time = $m.Groups[2].Value }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Days of the week as Task Scheduler's bitmask stores them. (A list of pairs, not a dictionary keyed by number: indexing a
|
||||||
|
# dictionary with an integer reads by position in some PowerShell types, which would silently pick the wrong day.)
|
||||||
|
$script:DayBits = @(
|
||||||
|
@{ Bit = 1; Name = 'Sun' }, @{ Bit = 2; Name = 'Mon' }, @{ Bit = 4; Name = 'Tue' }, @{ Bit = 8; Name = 'Wed' },
|
||||||
|
@{ Bit = 16; Name = 'Thu' }, @{ Bit = 32; Name = 'Fri' }, @{ Bit = 64; Name = 'Sat' }
|
||||||
|
)
|
||||||
|
|
||||||
|
# One trigger as a sentence, e.g. "Daily at 02:00, repeating every 15 min".
|
||||||
|
function Describe-Trigger($Trigger) {
|
||||||
|
$kind = [string]$Trigger.CimClass.CimClassName
|
||||||
|
$at = (Split-Boundary $Trigger.StartBoundary)
|
||||||
|
$time = $at.Time
|
||||||
|
$text = switch -Regex ($kind) {
|
||||||
|
'DailyTrigger$' {
|
||||||
|
$n = [int]$Trigger.DaysInterval
|
||||||
|
$when = if ($time) { " at $time" } else { '' }
|
||||||
|
if ($n -gt 1) { "Every $n days$when" } else { "Daily$when" }
|
||||||
|
}
|
||||||
|
'WeeklyTrigger$' {
|
||||||
|
$days = @()
|
||||||
|
foreach ($d in $script:DayBits) { if ([int]$Trigger.DaysOfWeek -band $d.Bit) { $days += $d.Name } }
|
||||||
|
$n = [int]$Trigger.WeeksInterval
|
||||||
|
$prefix = if ($n -gt 1) { "Every $n weeks" } else { 'Weekly' }
|
||||||
|
$on = if ($days.Count -gt 0) { ' on ' + ($days -join ', ') } else { '' }
|
||||||
|
$when = if ($time) { " at $time" } else { '' }
|
||||||
|
"$prefix$on$when"
|
||||||
|
}
|
||||||
|
'MonthlyDOWTrigger$' { $when = if ($time) { " at $time" } else { '' }; "Monthly (by weekday)$when" }
|
||||||
|
'MonthlyTrigger$' {
|
||||||
|
$dayNumbers = @()
|
||||||
|
for ($i = 0; $i -lt 31; $i++) { if ([int64]$Trigger.DaysOfMonth -band ([int64]1 -shl $i)) { $dayNumbers += ($i + 1) } }
|
||||||
|
$when = if ($time) { " at $time" } else { '' }
|
||||||
|
$on = if ($dayNumbers.Count -gt 0) { ' on day ' + ($dayNumbers -join ', ') } else { '' }
|
||||||
|
"Monthly$on$when"
|
||||||
|
}
|
||||||
|
'TimeTrigger$' { if ($at.Date) { "Once at $($at.Date) $time" } else { 'Once' } }
|
||||||
|
'BootTrigger$' { 'At startup' }
|
||||||
|
'LogonTrigger$' { 'At logon' }
|
||||||
|
'IdleTrigger$' { 'When idle' }
|
||||||
|
'EventTrigger$' { 'On an event' }
|
||||||
|
'SessionStateChangeTrigger$' { 'On session state change' }
|
||||||
|
'RegistrationTrigger$' { 'When the task is created' }
|
||||||
|
default { 'Custom trigger' }
|
||||||
|
}
|
||||||
|
|
||||||
|
$interval = $null
|
||||||
|
if ($Trigger.Repetition -and $Trigger.Repetition.Interval) { $interval = Convert-IsoDuration ([string]$Trigger.Repetition.Interval) }
|
||||||
|
if ($interval) { $text = "$text, repeating every $interval" }
|
||||||
|
return $text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Describe-Triggers($Triggers) {
|
||||||
|
$list = @($Triggers | Where-Object { $_ })
|
||||||
|
if ($list.Count -eq 0) { return '(no trigger - run manually)' }
|
||||||
|
return (($list | ForEach-Object { Describe-Trigger $_ }) -join '; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Describe-Actions($Actions) {
|
||||||
|
$parts = @()
|
||||||
|
foreach ($a in @($Actions | Where-Object { $_ })) {
|
||||||
|
$kind = [string]$a.CimClass.CimClassName
|
||||||
|
if ($kind -match 'ExecAction$' -or $a.Execute) {
|
||||||
|
$argText = if ($a.Arguments) { ' ' + $a.Arguments } else { '' }
|
||||||
|
$parts += ([string]$a.Execute + $argText).Trim()
|
||||||
|
} elseif ($a.ClassId) {
|
||||||
|
$parts += "COM handler $($a.ClassId)"
|
||||||
|
} elseif ($kind) {
|
||||||
|
$parts += ($kind -replace '^MSFT_Task', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ($parts -join ' ; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- collecting tasks -----------------------------------------------------------
|
||||||
|
|
||||||
|
function Get-ReportedTasks([bool]$IncludeMicrosoft) {
|
||||||
|
$out = @()
|
||||||
|
foreach ($task in @(Get-ScheduledTask)) {
|
||||||
|
if (-not $IncludeMicrosoft -and $task.TaskPath -like '\Microsoft\*') { continue }
|
||||||
|
|
||||||
|
$info = $null
|
||||||
|
try { $info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath } catch { }
|
||||||
|
|
||||||
|
$entry = [ordered]@{
|
||||||
|
schedule_type = 'windows_task'
|
||||||
|
name = ('{0}{1}' -f $task.TaskPath, $task.TaskName)
|
||||||
|
command = Describe-Actions $task.Actions
|
||||||
|
schedule_expression = Describe-Triggers $task.Triggers
|
||||||
|
source = [string]$task.TaskPath
|
||||||
|
enabled = ($task.State -ne 'Disabled')
|
||||||
|
}
|
||||||
|
# Windows reports "never" as a date in 1999 (or year 1), not as an empty value.
|
||||||
|
if ($info -and $info.NextRunTime -and $info.NextRunTime.Year -gt 2000) {
|
||||||
|
$entry['next_run_at'] = $info.NextRunTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||||
|
}
|
||||||
|
$meta = [ordered]@{ state = [string]$task.State; run_as = [string]$task.Principal.UserId }
|
||||||
|
if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {
|
||||||
|
$meta['last_run_at'] = $info.LastRunTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||||
|
$meta['last_result'] = [int64]$info.LastTaskResult
|
||||||
|
}
|
||||||
|
$entry['metadata'] = $meta
|
||||||
|
$out += [pscustomobject]$entry
|
||||||
|
}
|
||||||
|
# No leading comma: callers wrap the call in @(...), and returning an array wrapped in another array would
|
||||||
|
# turn every task into one nested item.
|
||||||
|
return $out
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- collecting system info -----------------------------------------------------
|
||||||
|
|
||||||
|
function Get-Fqdn {
|
||||||
|
$name = [System.Net.Dns]::GetHostName()
|
||||||
|
try {
|
||||||
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
|
||||||
|
if ($cs.PartOfDomain -and $cs.Domain -and ($name -notlike '*.*')) { return ("$name.$($cs.Domain)").ToLowerInvariant() }
|
||||||
|
} catch { }
|
||||||
|
return $name.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ListeningPorts {
|
||||||
|
$names = @{}
|
||||||
|
foreach ($p in Get-Process -ErrorAction SilentlyContinue) { $names[[int]$p.Id] = $p.ProcessName }
|
||||||
|
$processOf = { param($id) if ($id -eq 0 -or $id -eq 4) { 'System' } elseif ($names.ContainsKey([int]$id)) { $names[[int]$id] } else { '' } }
|
||||||
|
$clean = { param($addr) ([string]$addr) -replace '%.*$', '' } # drop an IPv6 zone id ("fe80::1%12")
|
||||||
|
|
||||||
|
$seen = @{}
|
||||||
|
$rows = @()
|
||||||
|
foreach ($c in @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue)) {
|
||||||
|
$row = [ordered]@{ protocol = 'tcp'; port = [int]$c.LocalPort; address = (& $clean $c.LocalAddress); process = (& $processOf $c.OwningProcess) }
|
||||||
|
$key = "tcp|$($row.port)|$($row.address)"
|
||||||
|
if (-not $seen.ContainsKey($key)) { $seen[$key] = 1; $rows += [pscustomobject]$row }
|
||||||
|
}
|
||||||
|
foreach ($u in @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue)) {
|
||||||
|
$row = [ordered]@{ protocol = 'udp'; port = [int]$u.LocalPort; address = (& $clean $u.LocalAddress); process = (& $processOf $u.OwningProcess) }
|
||||||
|
$key = "udp|$($row.port)|$($row.address)"
|
||||||
|
if (-not $seen.ContainsKey($key)) { $seen[$key] = 1; $rows += [pscustomobject]$row }
|
||||||
|
}
|
||||||
|
return @($rows | Where-Object { $_.port -ge 1 -and $_.port -le 65535 } | Select-Object -First 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SystemInfo {
|
||||||
|
$ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.AddressState -eq 'Preferred' } |
|
||||||
|
ForEach-Object { $_.IPAddress } | Select-Object -Unique)
|
||||||
|
|
||||||
|
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
|
||||||
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem
|
||||||
|
$totalBytes = [int64]$os.TotalVisibleMemorySize * 1024
|
||||||
|
$freeBytes = [int64]$os.FreePhysicalMemory * 1024
|
||||||
|
|
||||||
|
# Unlike the Linux agent's load average, this is the processor's actual current load.
|
||||||
|
$load = ($cpus | Where-Object { $null -ne $_.LoadPercentage } | Measure-Object -Property LoadPercentage -Average).Average
|
||||||
|
$cores = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||||
|
|
||||||
|
$disks = @()
|
||||||
|
foreach ($d in @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3')) {
|
||||||
|
if (-not $d.Size) { continue } # an unformatted or unavailable volume
|
||||||
|
$disks += [pscustomobject][ordered]@{ mount = [string]$d.DeviceID; size_bytes = [int64]$d.Size; used_bytes = [int64]($d.Size - $d.FreeSpace) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ordered]@{
|
||||||
|
ip_addresses = @($ips)
|
||||||
|
cpu = [ordered]@{ model = [string]($cpus[0].Name).Trim(); cores = [int]$cores; load_percent = $(if ($null -ne $load) { [math]::Round([double]$load, 1) } else { $null }) }
|
||||||
|
memory = [ordered]@{ total_bytes = $totalBytes; used_bytes = ($totalBytes - $freeBytes) }
|
||||||
|
disks = @($disks)
|
||||||
|
listening_ports = @(Get-ListeningPorts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- sending --------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Send-Report($Config, [string]$Json) {
|
||||||
|
$body = [System.Text.Encoding]::UTF8.GetBytes($Json)
|
||||||
|
$params = @{
|
||||||
|
Uri = "$($Config.ApiUrl)/api/agent/report"
|
||||||
|
Method = 'Post'
|
||||||
|
Headers = @{ Authorization = "Bearer $($Config.ApiToken)" }
|
||||||
|
Body = $body
|
||||||
|
ContentType = 'application/json; charset=utf-8'
|
||||||
|
TimeoutSec = 60
|
||||||
|
}
|
||||||
|
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||||
|
if ($Config.Insecure) { $params['SkipCertificateCheck'] = $true }
|
||||||
|
} else {
|
||||||
|
# Windows PowerShell 5.1: modern TLS is off by default, and there is no per-request switch for self-signed certificates.
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ($Config.Insecure) { [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } }
|
||||||
|
$params['UseBasicParsing'] = $true
|
||||||
|
}
|
||||||
|
return Invoke-RestMethod @params
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- main -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# A plain one-line message on stderr: Write-Error would bury it in a stack trace on every manual run.
|
||||||
|
function Stop-Agent([string]$Message) {
|
||||||
|
[Console]::Error.WriteLine($Message)
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Agent {
|
||||||
|
$config = Get-AgentConfig
|
||||||
|
if (-not $config.ApiUrl -or -not $config.ApiToken) {
|
||||||
|
Stop-Agent "API_URL and API_TOKEN must be set (environment variables, or $script:DefaultConfigPath)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$tasks = @()
|
||||||
|
try {
|
||||||
|
$tasks = @(Get-ReportedTasks $config.IncludeMicrosoftTasks)
|
||||||
|
} catch {
|
||||||
|
# Still worth reporting the hardware and ports if tasks can't be read.
|
||||||
|
Write-Warning "Collecting scheduled tasks failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hardware and network facts are best-effort, like the Linux agent: a quirk on one host must not cost the whole report.
|
||||||
|
$system = $null
|
||||||
|
try {
|
||||||
|
$system = Get-SystemInfo
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Collecting hardware/network info failed - reporting tasks without it. ($($_.Exception.Message))"
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [ordered]@{
|
||||||
|
hostname = Get-Fqdn
|
||||||
|
os_type = 'windows'
|
||||||
|
reported_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||||
|
system = $system
|
||||||
|
tasks = @($tasks)
|
||||||
|
}
|
||||||
|
$json = ConvertTo-Json -InputObject $payload -Depth 8 -Compress
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
ConvertTo-Json -InputObject $payload -Depth 8
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$null = Send-Report $config $json
|
||||||
|
} catch {
|
||||||
|
$detail = ''
|
||||||
|
try {
|
||||||
|
if ($_.Exception.Response) {
|
||||||
|
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
|
||||||
|
$detail = ' ' + $reader.ReadToEnd()
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
Stop-Agent "Report failed: $($_.Exception.Message)$detail"
|
||||||
|
}
|
||||||
|
Write-Output "Reported $(@($tasks).Count) task(s) successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dot-sourcing (for tests) defines the functions without running anything.
|
||||||
|
if ($MyInvocation.InvocationName -ne '.') { Invoke-Agent }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Removes the Homelab Manager agent from this Windows machine: deletes its scheduled task, the installed
|
||||||
|
# script, and its credentials file.
|
||||||
|
#
|
||||||
|
# Run in an ELEVATED Windows PowerShell (Run as administrator):
|
||||||
|
#
|
||||||
|
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
# iex ((New-Object Net.WebClient).DownloadString('https://homelab.example.lan/agent/windows/uninstall.ps1'))
|
||||||
|
#
|
||||||
|
# (With a self-signed certificate, also run
|
||||||
|
# [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||||
|
# first.)
|
||||||
|
#
|
||||||
|
# NOTE: keep this file plain ASCII (it is downloaded as text).
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$script:TaskName = 'Homelab Manager Agent'
|
||||||
|
$script:InstallDir = Join-Path $env:ProgramData 'HomelabManager'
|
||||||
|
|
||||||
|
function Uninstall-Agent {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'This uninstaller must be run as Administrator. Open PowerShell with "Run as administrator" and try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output 'Removing Homelab Manager agent...'
|
||||||
|
|
||||||
|
if (Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue) {
|
||||||
|
Stop-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue
|
||||||
|
Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only the files this agent installed - never the folder wholesale, in case something else was put beside them.
|
||||||
|
foreach ($name in 'homelab-manager-agent.ps1', 'agent.json') {
|
||||||
|
$path = Join-Path $script:InstallDir $name
|
||||||
|
if (Test-Path -LiteralPath $path) { [System.IO.File]::Delete($path) }
|
||||||
|
}
|
||||||
|
if ((Test-Path -LiteralPath $script:InstallDir) -and -not (Get-ChildItem -LiteralPath $script:InstallDir -Force)) {
|
||||||
|
[System.IO.Directory]::Delete($script:InstallDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output 'Done. The agent no longer runs or reports from this machine.'
|
||||||
|
Write-Output 'Its entry (and task history) in Homelab Manager is untouched - delete it from the Servers page if you no longer want it tracked.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Uninstall-Agent
|
||||||
@@ -245,7 +245,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
|||||||
serverId: integer("server_id")
|
serverId: integer("server_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => servers.id, { onDelete: "cascade" }),
|
.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
|
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const reportSchema = z.object({
|
|||||||
system: systemSchema.nullable().optional(),
|
system: systemSchema.nullable().optional(),
|
||||||
tasks: z.array(
|
tasks: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
schedule_type: z.enum(["cron", "systemd_timer"]),
|
schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
schedule_expression: z.string().optional(),
|
schedule_expression: z.string().optional(),
|
||||||
@@ -72,6 +72,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
await syncServerTasks(server.id, {
|
await syncServerTasks(server.id, {
|
||||||
hostname: parsed.data.hostname,
|
hostname: parsed.data.hostname,
|
||||||
|
osType: parsed.data.os_type,
|
||||||
system: parsed.data.system,
|
system: parsed.data.system,
|
||||||
tasks: parsed.data.tasks.map((t) => ({
|
tasks: parsed.data.tasks.map((t) => ({
|
||||||
scheduleType: t.schedule_type,
|
scheduleType: t.schedule_type,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ serversRouter.use("/:id/ports", serverPortsRouter);
|
|||||||
const createServerSchema = z.object({
|
const createServerSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
hostname: z.string().max(255).optional(),
|
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(),
|
description: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ tasksRouter.get("/", asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
const manualTaskSchema = z.object({
|
const manualTaskSchema = z.object({
|
||||||
serverId: z.number().int(),
|
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),
|
name: z.string().min(1).max(200),
|
||||||
command: z.string().max(1000).optional(),
|
command: z.string().max(1000).optional(),
|
||||||
scheduleExpression: z.string().max(200).optional(),
|
scheduleExpression: z.string().max(200).optional(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "../db/client.js";
|
|||||||
import { scheduledTasks, servers } from "../db/schema.js";
|
import { scheduledTasks, servers } from "../db/schema.js";
|
||||||
|
|
||||||
export interface IncomingTask {
|
export interface IncomingTask {
|
||||||
scheduleType: "cron" | "systemd_timer";
|
scheduleType: "cron" | "systemd_timer" | "windows_task";
|
||||||
name: string;
|
name: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
scheduleExpression?: string;
|
scheduleExpression?: string;
|
||||||
@@ -23,6 +23,8 @@ export interface IncomingSystemInfo {
|
|||||||
|
|
||||||
export interface AgentReport {
|
export interface AgentReport {
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
/** What the agent says it runs on. Only "linux" and "windows" are recorded; anything else is ignored. */
|
||||||
|
osType?: string;
|
||||||
system?: IncomingSystemInfo | null;
|
system?: IncomingSystemInfo | null;
|
||||||
tasks: IncomingTask[];
|
tasks: IncomingTask[];
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,8 @@ export async function syncServerTasks(serverId: number, report: AgentReport) {
|
|||||||
.set({
|
.set({
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
...(report.hostname ? { hostname: report.hostname } : {}),
|
...(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?.ip_addresses ? { ipAddresses: JSON.stringify(system.ip_addresses) } : {}),
|
||||||
...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}),
|
...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}),
|
||||||
...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}),
|
...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}),
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ export interface ServerUpdateInput {
|
|||||||
hideProxmoxLink?: boolean;
|
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 {
|
export interface TaskRecord {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -912,7 +912,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
servers: {
|
servers: {
|
||||||
list: () => request<{ servers: ServerRecord[] }>("/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", {
|
request<{ server: ServerRecord; token: string }>("/api/servers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { downloadCsv } from "../utils/csv";
|
|||||||
export const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
export const SCHEDULE_TYPE_LABELS: Record<string, string> = {
|
||||||
cron: "Cron jobs",
|
cron: "Cron jobs",
|
||||||
systemd_timer: "systemd timers",
|
systemd_timer: "systemd timers",
|
||||||
|
windows_task: "Windows scheduled tasks",
|
||||||
docker: "Docker jobs",
|
docker: "Docker jobs",
|
||||||
backup: "Backups",
|
backup: "Backups",
|
||||||
update: "Updates",
|
update: "Updates",
|
||||||
|
|||||||
+28
-22
@@ -9,6 +9,7 @@ import { usePagination } from "../hooks/usePagination";
|
|||||||
import Pagination from "../components/Pagination";
|
import Pagination from "../components/Pagination";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { TagBadges } from "../components/ServerTags";
|
import { TagBadges } from "../components/ServerTags";
|
||||||
|
import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands";
|
||||||
import { tagBadge, useTagColors } from "../utils/tags";
|
import { tagBadge, useTagColors } from "../utils/tags";
|
||||||
|
|
||||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
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` };
|
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 }) {
|
export default function Servers({ user }: { user: CurrentUser }) {
|
||||||
const isAdmin = user.role === "admin";
|
const isAdmin = user.role === "admin";
|
||||||
@@ -49,6 +38,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
|||||||
const [serverName, setServerName] = useState("");
|
const [serverName, setServerName] = useState("");
|
||||||
const [serverHostname, setServerHostname] = useState("");
|
const [serverHostname, setServerHostname] = useState("");
|
||||||
const [serverDescription, setServerDescription] = useState("");
|
const [serverDescription, setServerDescription] = useState("");
|
||||||
|
const [serverOs, setServerOs] = useState<AgentOs>("linux");
|
||||||
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
|
||||||
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
|
const [uninstallFor, setUninstallFor] = useState<ServerRecord | null>(null);
|
||||||
const [insecureAgent, setInsecureAgent] = useState(false);
|
const [insecureAgent, setInsecureAgent] = useState(false);
|
||||||
@@ -72,6 +62,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
|||||||
name: serverName,
|
name: serverName,
|
||||||
hostname: serverHostname || undefined,
|
hostname: serverHostname || undefined,
|
||||||
description: serverDescription || undefined,
|
description: serverDescription || undefined,
|
||||||
|
osType: serverOs,
|
||||||
});
|
});
|
||||||
setNewToken(result);
|
setNewToken(result);
|
||||||
setServerName("");
|
setServerName("");
|
||||||
@@ -154,7 +145,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={createServer}>
|
<form onSubmit={createServer}>
|
||||||
<div className="card-body row g-3">
|
<div className="card-body row g-3">
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
<label className="form-label">Server name</label>
|
<label className="form-label">Server name</label>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -164,7 +155,14 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
|||||||
onChange={(e) => setServerName(e.target.value)}
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<label className="form-label">Hostname</label>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
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)
|
This Homelab Manager instance uses a self-signed certificate (skip TLS verification on the agent)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-secondary">
|
<p className="text-secondary">{AGENT_RUN_HINT[agentOsOf(newToken.server.osType)].install}</p>
|
||||||
Install the agent on the server (run as root — put sudo right after the pipe, not before curl):
|
|
||||||
</p>
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<pre className="form-control text-wrap mb-0">{installCommand(newToken.token, insecureAgent)}</pre>
|
<pre className="form-control text-wrap mb-0">
|
||||||
<CopyButton text={installCommand(newToken.token, insecureAgent)} />
|
{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>
|
</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>
|
||||||
<div className="card-footer">
|
<div className="card-footer">
|
||||||
<button className="btn" onClick={() => setNewToken(null)}>
|
<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>
|
<h3 className="card-title">Uninstall agent from {uninstallFor.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<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">
|
<div className="input-group">
|
||||||
<pre className="form-control text-wrap mb-0">{uninstallCommand(insecureAgent)}</pre>
|
<pre className="form-control text-wrap mb-0">
|
||||||
<CopyButton text={uninstallCommand(insecureAgent)} />
|
{uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)}
|
||||||
|
</pre>
|
||||||
|
<CopyButton text={uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-footer">
|
<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