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