From fea20456e44f8b35b6f039c5b09ab104a21ddb6a Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sun, 27 Sep 2026 00:09:00 +0200 Subject: [PATCH] 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 --- README.md | 5 +- agent/windows/README.md | 92 ++++++- agent/windows/install.ps1 | 123 +++++++++ agent/windows/report-tasks.ps1 | 335 +++++++++++++++++++++++++ agent/windows/uninstall.ps1 | 46 ++++ server/src/db/schema.ts | 2 +- server/src/routes/agentReport.ts | 3 +- server/src/routes/servers.ts | 2 +- server/src/routes/tasks.ts | 2 +- server/src/services/taskSync.ts | 6 +- web/src/api/client.ts | 4 +- web/src/components/ServerTaskTable.tsx | 1 + web/src/pages/Servers.tsx | 50 ++-- web/src/utils/agentCommands.ts | 47 ++++ 14 files changed, 675 insertions(+), 43 deletions(-) create mode 100644 agent/windows/install.ps1 create mode 100644 agent/windows/report-tasks.ps1 create mode 100644 agent/windows/uninstall.ps1 create mode 100644 web/src/utils/agentCommands.ts diff --git a/README.md b/README.md index b81767a..728885e 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ All modules from the original plan are built: DNS, cPanel, and Technitium; providers are configured in-app (not via env vars) and their credentials are encrypted at rest - **Servers** — cron/systemd tracking across Debian/Raspbian servers via a - lightweight push agent (`agent/linux/`), plus manual entries for things an - agent can't see (Docker jobs, backups). The Servers page itself just lists + lightweight push agent (`agent/linux/`), Windows scheduled-task tracking + 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; 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 diff --git a/agent/windows/README.md b/agent/windows/README.md index 6c9cf0a..62d6cd5 100644 --- a/agent/windows/README.md +++ b/agent/windows/README.md @@ -1,14 +1,82 @@ -# Windows agent (planned, not yet implemented) +# Windows agent -v1 of the Servers & Tasks module only supports Linux servers (cron + systemd -timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows -agent is a natural future addition and would follow the same contract as the -Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh): +Reports a Windows machine to Homelab Manager the same way the [Linux agent](../linux/) does: its +scheduled tasks, plus hostname, IP addresses, CPU / memory / disk usage and listening ports. It +runs as a scheduled task (as SYSTEM, every 15 minutes) and pushes to `POST /api/agent/report`, so +the machine never needs to be reachable from Homelab Manager. -- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command, - trigger description, next run time, enabled state). -- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"` - (the server and UI already treat `schedule_type` as an open string in storage; only the - `tasks` API and UI schedule-type filter would need the new value added). -- 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. +## Install + +1. In Homelab Manager, **Servers → Manage servers → Add a server**, choose **Windows** as the + operating system, and copy the install command shown once for the new token. +2. On the Windows machine, open **Windows PowerShell as administrator** and paste it. It looks like: + + ```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. diff --git a/agent/windows/install.ps1 b/agent/windows/install.ps1 new file mode 100644 index 0000000..84949cb --- /dev/null +++ b/agent/windows/install.ps1 @@ -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 } diff --git a/agent/windows/report-tasks.ps1 b/agent/windows/report-tasks.ps1 new file mode 100644 index 0000000..0ea957c --- /dev/null +++ b/agent/windows/report-tasks.ps1 @@ -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 } diff --git a/agent/windows/uninstall.ps1 b/agent/windows/uninstall.ps1 new file mode 100644 index 0000000..1540737 --- /dev/null +++ b/agent/windows/uninstall.ps1 @@ -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 diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index a0263f6..e07e95f 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -245,7 +245,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", { serverId: integer("server_id") .notNull() .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 name: text("name").notNull(), command: text("command"), diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts index 5ea29db..329e897 100644 --- a/server/src/routes/agentReport.ts +++ b/server/src/routes/agentReport.ts @@ -38,7 +38,7 @@ const reportSchema = z.object({ system: systemSchema.nullable().optional(), tasks: z.array( z.object({ - schedule_type: z.enum(["cron", "systemd_timer"]), + schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]), name: z.string().min(1), command: z.string().optional(), schedule_expression: z.string().optional(), @@ -72,6 +72,7 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => { await syncServerTasks(server.id, { hostname: parsed.data.hostname, + osType: parsed.data.os_type, system: parsed.data.system, tasks: parsed.data.tasks.map((t) => ({ scheduleType: t.schedule_type, diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index e4f87c5..f607c2c 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -26,7 +26,7 @@ serversRouter.use("/:id/ports", serverPortsRouter); const createServerSchema = z.object({ name: z.string().min(1).max(100), 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(), }); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 98f196c..82ee9c8 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -60,7 +60,7 @@ tasksRouter.get("/", asyncHandler(async (req, res) => { const manualTaskSchema = z.object({ 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), command: z.string().max(1000).optional(), scheduleExpression: z.string().max(200).optional(), diff --git a/server/src/services/taskSync.ts b/server/src/services/taskSync.ts index 2401861..ef16933 100644 --- a/server/src/services/taskSync.ts +++ b/server/src/services/taskSync.ts @@ -3,7 +3,7 @@ import { db } from "../db/client.js"; import { scheduledTasks, servers } from "../db/schema.js"; export interface IncomingTask { - scheduleType: "cron" | "systemd_timer"; + scheduleType: "cron" | "systemd_timer" | "windows_task"; name: string; command?: string; scheduleExpression?: string; @@ -23,6 +23,8 @@ export interface IncomingSystemInfo { export interface AgentReport { hostname?: string; + /** What the agent says it runs on. Only "linux" and "windows" are recorded; anything else is ignored. */ + osType?: string; system?: IncomingSystemInfo | null; tasks: IncomingTask[]; } @@ -100,6 +102,8 @@ export async function syncServerTasks(serverId: number, report: AgentReport) { .set({ lastSeenAt: now, ...(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?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}), ...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 80386cc..2b76192 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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), diff --git a/web/src/components/ServerTaskTable.tsx b/web/src/components/ServerTaskTable.tsx index b909266..a618879 100644 --- a/web/src/components/ServerTaskTable.tsx +++ b/web/src/components/ServerTaskTable.tsx @@ -10,6 +10,7 @@ import { downloadCsv } from "../utils/csv"; export const SCHEDULE_TYPE_LABELS: Record = { cron: "Cron jobs", systemd_timer: "systemd timers", + windows_task: "Windows scheduled tasks", docker: "Docker jobs", backup: "Backups", update: "Updates", diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx index 963ecaf..3f49691 100644 --- a/web/src/pages/Servers.tsx +++ b/web/src/pages/Servers.tsx @@ -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, type: string): CSSProperties { @@ -17,18 +18,6 @@ function typeBadgeStyle(colors: Record, 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("linux"); const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null); const [uninstallFor, setUninstallFor] = useState(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 }) {
-
+
setServerName(e.target.value)} />
-
+
+ + +
+
-

- Install the agent on the server (run as root — put sudo right after the pipe, not before curl): -

+

{AGENT_RUN_HINT[agentOsOf(newToken.server.osType)].install}

-
{installCommand(newToken.token, insecureAgent)}
- +
+                    {installCommand(agentOsOf(newToken.server.osType), window.location.origin, newToken.token, insecureAgent)}
+                  
+
+ {agentOsOf(newToken.server.osType) === "windows" && insecureAgent && ( +
+ This form is for Windows PowerShell 5.1, the one built into Windows. In PowerShell 7 the download step needs{" "} + -SkipCertificateCheck instead. +
+ )}
-

Run this on the server as root to stop and remove the agent (its entry here is kept):

+

{AGENT_RUN_HINT[agentOsOf(uninstallFor.osType)].uninstall}

-
{uninstallCommand(insecureAgent)}
- +
+                    {uninstallCommand(agentOsOf(uninstallFor.osType), window.location.origin, insecureAgent)}
+                  
+
diff --git a/web/src/utils/agentCommands.ts b/web/src/utils/agentCommands.ts new file mode 100644 index 0000000..27b91f8 --- /dev/null +++ b/web/src/utils/agentCommands.ts @@ -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 = { + 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"; +}