Files
Homelab-manager/agent/windows/report-tasks.ps1
T
bobbanandClaude Sonnet 5 fea20456e4 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>
2026-09-27 00:09:00 +02:00

336 lines
14 KiB
PowerShell

<#
.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 }