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:
2026-09-27 00:09:00 +02:00
co-authored by Claude Sonnet 5
parent ae64cb345c
commit fea20456e4
14 changed files with 675 additions and 43 deletions
+3 -2
View File
@@ -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
View File
@@ -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.
+123
View File
@@ -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 }
+335
View File
@@ -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 }
+46
View File
@@ -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
+1 -1
View File
@@ -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"),
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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(),
}); });
+1 -1
View File
@@ -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(),
+5 -1
View File
@@ -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 } : {}),
+2 -2
View File
@@ -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),
+1
View File
@@ -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
View File
@@ -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">
+47
View File
@@ -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";
}