<# .SYNOPSIS Installs (or removes) the TranTech Monitor Agent on this server - one file, nothing else to download. .DESCRIPTION Default (install) mode redeems a short-lived enrolment code (generated by an admin in the portal) via POST /api/agent/register, stores the returned server credential locally - encrypted at rest with Windows Data Protection API (machine scope, so it can be read by the SYSTEM account the Scheduled Task runs as, but not exported to another machine) - writes out the agent's monitoring loop next to it, and registers a Scheduled Task that starts the agent at boot and restarts it automatically if it ever exits unexpectedly. Once running, the agent reports CPU, memory, uptime, disk, network and error-log samples, plus RDP session activity (logon/logoff/disconnect/reconnect events, a live quser snapshot including per-user idle time, and failed logon attempts, for the portal's staff session tracking). It samples every minute and sends the buffered samples to /api/agent/report every 15 minutes, aligned to :00/:15/:30/:45 so the whole fleet reports together and the portal's database can sleep in between. It also reports its own version, so an admin can trigger a self-update from the portal (a "Push update" button per server) instead of re-running this installer by hand on every machine - the agent downloads and applies the new loop on its next heartbeat after being flagged. Also registers an Add/Remove Programs entry ("TranTech Monitor Agent") whose Uninstall button runs this same script with -Uninstall. Pass -Uninstall to remove the Scheduled Task, the Add/Remove Programs entry, and all local agent files from this server instead of installing. Run elevated (Administrator). The enrolment code is single-use and expires 15 minutes after it's generated in the portal. .PARAMETER EnrolmentCode The one-time code shown by the portal when an admin creates an enrolment. Required unless -Uninstall is passed. .PARAMETER PortalUrl Base URL of the monitoring portal. .PARAMETER InstallDir Where to install the agent and its local config/credential. .PARAMETER Label Hostname reported to the portal for this server. Defaults to the local computer name. .PARAMETER Uninstall Remove the Scheduled Task and -InstallDir instead of installing. .EXAMPLE .\Install-MonitorAgent.ps1 -EnrolmentCode "abc123..." .\Install-MonitorAgent.ps1 -Uninstall #> param( [string]$EnrolmentCode, [string]$PortalUrl = "https://monitoring.trantech.au", [string]$InstallDir = "C:\ProgramData\TranTechMonitorAgent", [string]$Label = $env:COMPUTERNAME, [switch]$Uninstall ) # Bump together with the $AgentVersion literal inside $agentScript below and # src/core.mjs's AGENT_VERSION whenever the embedded loop changes meaningfully - these # three are the only source of truth for "is this server's agent current." $InstallerVersion = "1.3.0" $ErrorActionPreference = 'Stop' $taskName = "TranTech Monitor Agent" $uninstallKeyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\TranTechMonitorAgent" function Test-IsElevated { $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object System.Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } if (-not (Test-IsElevated)) { Write-Error "Re-run this script as Administrator - it manages a SYSTEM Scheduled Task." exit 1 } if ($Uninstall) { if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $taskName -Confirm:$false Write-Host "Scheduled Task '$taskName' removed." } else { Write-Host "Scheduled Task '$taskName' was not found - nothing to remove there." } if (Test-Path $uninstallKeyPath) { Remove-Item -Path $uninstallKeyPath -Recurse -Force } # Remove the install directory last - the installer's own copy of this script # (invoked from Add/Remove Programs) lives inside it while this is running. if (Test-Path $InstallDir) { Remove-Item -Path $InstallDir -Recurse -Force Write-Host "Removed $InstallDir." } else { Write-Host "$InstallDir was not found - nothing to remove there." } Write-Host "" Write-Host "Reminder: this only stops the agent on this machine. Revoke the server in the portal" Write-Host "(Servers > Revoke) if it should no longer be able to send heartbeats." exit 0 } if (-not $EnrolmentCode) { Write-Error "-EnrolmentCode is required to install (or pass -Uninstall to remove the agent)." exit 1 } # The monitoring loop itself, written out to $InstallDir at install time and run by the # Scheduled Task below. Single-quoted here-string - everything inside is literal, not # expanded by this installer's own variables. $agentScript = @' <# .SYNOPSIS Reports this server's health to the TranTech Server Monitoring portal on a fixed schedule, for as long as it keeps running. .DESCRIPTION Written out and started by Install-MonitorAgent.ps1 as a Scheduled Task running as SYSTEM. Loads the server credential written at install time (encrypted with machine-scoped DPAPI), then loops: collect a snapshot of CPU/memory/uptime/disk/network plus any new error-log entries, RDP session events (logon/logoff/disconnect/reconnect and failed logon attempts, read from the TerminalServices-LocalSessionManager and Security event logs since the last loop iteration) and a live quser snapshot (including each session's idle time) every minute into an in-memory buffer, and POST the whole buffer to /api/agent/report every 15 minutes (the portal can retune both intervals in its response). Reports are aligned to wall-clock boundaries so every server's lands within the same ~30 seconds - the portal's Neon database only scales to zero after 5 idle minutes, so staggered or frequent reports would keep it awake around the clock and exhaust its free-tier compute allowance. A failed report keeps the buffer and retries at the next boundary. Running as SYSTEM already grants the Security-log access session events need. Not meant to be run manually outside of testing - the installer takes care of wiring this up. If the portal reports the agent credential as revoked, the loop stops and the task exits cleanly (no restart) rather than retrying forever. If the portal flags this server for an update (admin clicked "Push update"), the loop downloads the latest version of itself, validates it, overwrites this file, and exits non-zero so the Scheduled Task's crash-restart relaunches it running the new code. .PARAMETER InstallDir Where config.json and credential.dat (written by the installer) live. #> param( [string]$InstallDir = "C:\ProgramData\TranTechMonitorAgent" ) # Bump together with Install-MonitorAgent.ps1's own $InstallerVersion (outside this # here-string) and src/core.mjs's AGENT_VERSION whenever this loop changes # meaningfully - reported every heartbeat so the portal can tell which servers are # current and offer a Push update. $AgentVersion = '1.3.0' $ScheduledTaskName = 'TranTech Monitor Agent' $ErrorActionPreference = 'Stop' $configPath = Join-Path $InstallDir "config.json" $credentialPath = Join-Path $InstallDir "credential.dat" $destScriptPath = Join-Path $InstallDir "MonitorAgent.ps1" if (-not (Test-Path $configPath) -or -not (Test-Path $credentialPath)) { Write-Error "Agent is not enrolled - $configPath / $credentialPath not found. Run Install-MonitorAgent.ps1 first." exit 1 } $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json $portalUrl = $config.portalUrl.TrimEnd('/') # config.json from pre-1.3.0 installs carries intervalSeconds=15 (the old heartbeat # cadence) - deliberately ignored now; the portal's report response is authoritative. $sampleSeconds = if ($config.sampleSeconds -gt 0) { [int]$config.sampleSeconds } else { 60 } $reportSeconds = if ($config.reportSeconds -gt 0) { [int]$config.reportSeconds } else { 900 } # Most samples a single report may carry (matches reportInput's max in src/core.mjs) - # oldest are dropped beyond this during a long portal outage. $maxBufferedSamples = 300 Add-Type -AssemblyName System.Security $protectedBytes = [System.IO.File]::ReadAllBytes($credentialPath) $credentialBytes = [System.Security.Cryptography.ProtectedData]::Unprotect( $protectedBytes, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) $credential = [System.Text.Encoding]::UTF8.GetString($credentialBytes) function Get-SafeMetric { param([double]$Value, [double]$Max = [double]::PositiveInfinity) if ([double]::IsNaN($Value) -or [double]::IsInfinity($Value) -or $Value -lt 0) { return 0 } return [Math]::Round([Math]::Min($Value, $Max), 2) } # --- RDP session activity (ported from Get-RDPSessionReport.ps1's event correlation) --- $reasonMap = @{ 0 = "No additional information" 1 = "Client connection broken (network drop)" 2 = "Client-initiated logoff" 3 = "Idle session limit reached (matches an idle-timeout GPO)" 4 = "Active/disconnected session limit reached (matches a session-limit GPO)" 5 = "Admin disconnected the session" 6 = "Admin logged off the session" 11 = "Client closed the RDP window / disconnected" } # Best-effort text for Security log 4625 (failed logon) SubStatus codes - Microsoft # documents these but doesn't expose them anywhere queryable at runtime. $failureReasonMap = @{ '0xC000006A' = 'Incorrect password' '0xC0000064' = 'Username does not exist' '0xC0000234' = 'Account locked out' '0xC0000072' = 'Account disabled' '0xC0000193' = 'Account expired' '0xC0000071' = 'Password expired' '0xC0000070' = 'Restricted logon hours or workstation' '0xC0000224' = 'Password change required' } function Get-EventDataByName { param($XmlEvent, [string]$Name) $node = $XmlEvent.Event.EventData.Data | Where-Object { $_.Name -eq $Name } if ($node) { return $node.'#text' } return $null } function Parse-LsmMessage { param([string]$Message, [int]$EventId) $data = [ordered]@{ User = $null; SessionId = $null; Client = $null; ReasonCode = $null } if (-not $Message) { return $data } if ($Message -match 'User:\s*([^\r\n]+)') { $data.User = $Matches[1].Trim() } if ($Message -match 'Session ID:\s*(\d+)') { $data.SessionId = $Matches[1] } if ($Message -match 'Source Network Address:\s*([^\r\n]+)') { $data.Client = $Matches[1].Trim() } if ($EventId -eq 39 -and $Message -match 'Session\s+(\d+)\s+has been disconnected by session\s+(\d+)') { $data.SessionId = $Matches[1] } if ($EventId -eq 40 -and $Message -match 'Session\s+(\d+)\s+has been disconnected,\s*reason code\s+(\d+)') { $data.SessionId = $Matches[1] $data.ReasonCode = [int64]$Matches[2] } return $data } # Reads new TerminalServices-LocalSessionManager (21/23/24/25/39/40) and Security (4778/4779) # events since the last heartbeat and returns them tagged with a (source, recordId) pair the # portal uses to de-duplicate on ingest - a retried heartbeat re-sending the same window is a # harmless no-op insert server-side. function Get-SessionEvents { param([datetime]$Since) $rows = New-Object System.Collections.Generic.List[object] try { $lsmEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational' Id = 21, 23, 24, 25, 39, 40 StartTime = $Since } -ErrorAction Stop } catch { $lsmEvents = @() } $lsmRows = New-Object System.Collections.Generic.List[object] foreach ($evt in $lsmEvents) { $parsed = Parse-LsmMessage -Message $evt.Message -EventId $evt.Id $type = switch ($evt.Id) { 21 { 'Logon' } 23 { 'Logoff' } 24 { 'Disconnect' } 25 { 'Reconnect' } 39 { 'DisconnectedByOther' } 40 { 'DisconnectReason' } default { $null } } if (-not $type) { continue } $reasonCode = $null; $reasonText = $null if ($type -eq 'DisconnectReason' -and $null -ne $parsed.ReasonCode) { $reasonCode = $parsed.ReasonCode if ($reasonCode -ge [int]::MinValue -and $reasonCode -le [int]::MaxValue) { $reasonText = $reasonMap[[int]$reasonCode] } if (-not $reasonText) { $reasonText = "Reason code $reasonCode (not in local map)" } } $lsmRows.Add([pscustomobject]@{ RecordId = $evt.RecordId; EventId = $evt.Id; Type = $type; Time = $evt.TimeCreated User = $parsed.User; SessionId = $parsed.SessionId; Client = $parsed.Client ReasonCode = $reasonCode; ReasonText = $reasonText }) } # Fold Disconnect-reason (40) rows into the matching Disconnect (24) row that shares a # session ID within a few seconds - same correlation Get-RDPSessionReport.ps1 uses. $reasonRows = @($lsmRows | Where-Object { $_.Type -eq 'DisconnectReason' }) foreach ($row in $lsmRows) { if ($row.Type -eq 'DisconnectReason') { continue } if ($row.Type -eq 'Disconnect') { $match = $reasonRows | Where-Object { $_.SessionId -eq $row.SessionId -and [Math]::Abs(($_.Time - $row.Time).TotalSeconds) -le 5 } | Select-Object -First 1 if ($match) { $row.ReasonCode = $match.ReasonCode; $row.ReasonText = $match.ReasonText } } $rows.Add([ordered]@{ source = 'LSM'; recordId = "$($row.RecordId)"; windowsEventId = $row.EventId; type = $row.Type user = $row.User; sessionId = $row.SessionId; client = $row.Client reasonCode = $row.ReasonCode; reasonText = $row.ReasonText occurredAt = $row.Time.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }) } try { # 4778/4779: RDP session reconnect/disconnect. 4625: any failed logon attempt # (RDP or otherwise) - a security signal, not session activity, but the same # log/window/de-dup plumbing applies so it rides along in the same query. $secEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4778, 4779, 4625; StartTime = $Since } -ErrorAction Stop } catch { $secEvents = @() } foreach ($evt in $secEvents) { $xml = [xml]$evt.ToXml() if ($evt.Id -eq 4625) { $user = Get-EventDataByName -XmlEvent $xml -Name 'TargetUserName' $client = Get-EventDataByName -XmlEvent $xml -Name 'IpAddress' if (-not $client -or $client -eq '-') { $client = Get-EventDataByName -XmlEvent $xml -Name 'WorkstationName' } $subStatus = Get-EventDataByName -XmlEvent $xml -Name 'SubStatus' $reasonText = if ($subStatus -and $failureReasonMap.ContainsKey($subStatus)) { $failureReasonMap[$subStatus] } elseif ($subStatus) { "Failed logon (status $subStatus)" } else { 'Failed logon' } $rows.Add([ordered]@{ source = 'Security'; recordId = "$($evt.RecordId)"; windowsEventId = $evt.Id; type = 'FailedLogon' user = $user; sessionId = $null; client = $client reasonCode = $null; reasonText = $reasonText occurredAt = $evt.TimeCreated.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }) continue } $user = Get-EventDataByName -XmlEvent $xml -Name 'AccountName' $sessionName = Get-EventDataByName -XmlEvent $xml -Name 'SessionName' $client = Get-EventDataByName -XmlEvent $xml -Name 'ClientAddress' if (-not $client) { $client = Get-EventDataByName -XmlEvent $xml -Name 'ClientName' } $type = if ($evt.Id -eq 4778) { 'Reconnect' } else { 'Disconnect' } $rows.Add([ordered]@{ source = 'Security'; recordId = "$($evt.RecordId)"; windowsEventId = $evt.Id; type = $type user = $user; sessionId = $sessionName; client = $client reasonCode = $null; reasonText = $null occurredAt = $evt.TimeCreated.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }) } # .ToArray(), not @($rows) - wrapping a System.Collections.Generic.List directly in the # array subexpression operator throws "Argument types do not match" on some Windows # PowerShell 5.1 builds, empty or not. @() around a *pipeline* expression (elsewhere in # this file) is unaffected - it's specifically wrapping an already-built List that breaks. return $rows.ToArray() } # quser is Windows' own live session list - ground truth for who's actually connected right # now (same reasoning Get-RDPSessionReport.ps1 uses it for, in preference to inferring state # from the event log, which can go stale if a session ends without a clean Logoff/Disconnect). # quser's IDLE TIME column is one of: "." (under a minute), plain minutes, "H:MM", or # "D+HH:MM" for anything over a day - and is locale-dependent enough that a value we # can't make sense of should just come through as $null rather than a wrong number. function Get-IdleMinutes { param([string]$Raw) if (-not $Raw) { return $null } $Raw = $Raw.Trim() if (-not $Raw -or $Raw -eq '.' -or $Raw -eq 'none') { return 0 } if ($Raw -match '^(\d+)\+(\d+):(\d+)$') { return ([int]$Matches[1] * 1440 + [int]$Matches[2] * 60 + [int]$Matches[3]) } if ($Raw -match '^(\d+):(\d+)$') { return ([int]$Matches[1] * 60 + [int]$Matches[2]) } if ($Raw -match '^(\d+)$') { return [int]$Matches[1] } return $null } function Get-LiveSessions { try { $raw = & quser.exe 2>$null } catch { return @() } if (-not $raw -or $raw.Count -lt 2) { return @() } $header = $raw[0] $idxUser = $header.IndexOf('USERNAME') $idxSession = $header.IndexOf('SESSIONNAME') $idxState = $header.IndexOf('STATE') $idxIdle = $header.IndexOf('IDLE TIME') $idxLogon = $header.IndexOf('LOGON TIME') if ($idxUser -lt 0 -or $idxState -lt 0) { return @() } $result = New-Object System.Collections.Generic.List[object] for ($i = 1; $i -lt $raw.Count; $i++) { $line = $raw[$i] if (-not $line.Trim()) { continue } # The current/querying session is marked with a leading '>' instead of a space - # swap it for a space so the column offsets from the header still line up. $line = ' ' + $line.Substring(1) $userEnd = if ($idxSession -gt $idxUser) { $idxSession } else { $idxState } if ($userEnd -gt $line.Length) { $userEnd = $line.Length } $user = $line.Substring($idxUser, [Math]::Max(0, $userEnd - $idxUser)).Trim() if (-not $user) { continue } $sessEnd = if ($idxState -gt $idxSession) { $idxState } else { $line.Length } if ($sessEnd -gt $line.Length) { $sessEnd = $line.Length } $sessionName = $line.Substring($idxSession, [Math]::Max(0, $sessEnd - $idxSession)).Trim() $stateEnd = if ($idxIdle -gt $idxState) { $idxIdle } else { $line.Length } if ($stateEnd -gt $line.Length) { $stateEnd = $line.Length } $state = $line.Substring($idxState, [Math]::Max(0, $stateEnd - $idxState)).Trim() $idleMinutes = $null if ($idxIdle -ge 0) { $idleEnd = if ($idxLogon -gt $idxIdle) { $idxLogon } else { $line.Length } if ($idleEnd -gt $line.Length) { $idleEnd = $line.Length } if ($idleEnd -gt $idxIdle) { $idleMinutes = Get-IdleMinutes -Raw ($line.Substring($idxIdle, $idleEnd - $idxIdle)) } } $result.Add([ordered]@{ user = $user sessionName = if ($sessionName) { $sessionName } else { $null } state = if ($state -eq 'Active') { 'Active' } else { 'Disc' } idleMinutes = $idleMinutes }) } return $result.ToArray() } function Get-HealthSample { param([datetime]$Since) $os = Get-CimInstance Win32_OperatingSystem $memoryUsedPercent = Get-SafeMetric -Value ((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100) -Max 100 $uptimeSeconds = Get-SafeMetric -Value ((Get-Date) - $os.LastBootUpTime).TotalSeconds $cpuPercent = $null try { $cpuCounter = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name='_Total'" if ($cpuCounter) { $cpuPercent = Get-SafeMetric -Value $cpuCounter.PercentProcessorTime -Max 100 } } catch { Write-Warning "CPU counter unavailable: $($_.Exception.Message)" } $disks = @(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $_.Size -gt 0 } | ForEach-Object { [ordered]@{ name = $_.DeviceID totalBytes = [double]$_.Size freeBytes = [double]$_.FreeSpace } }) $network = @() try { $network = @(Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface | Where-Object { $_.Name -notmatch 'Loopback|isatap|Teredo' } | ForEach-Object { [ordered]@{ name = $_.Name bytesReceivedPerSecond = Get-SafeMetric -Value $_.BytesReceivedPersec bytesSentPerSecond = Get-SafeMetric -Value $_.BytesSentPersec } }) } catch { Write-Warning "Network counters unavailable: $($_.Exception.Message)" } $errors = @() try { $recentErrors = Get-WinEvent -FilterHashtable @{ LogName = 'System', 'Application'; Level = 1, 2; StartTime = $Since } -MaxEvents 20 -ErrorAction Stop $errors = @($recentErrors | ForEach-Object { $line = "$($_.ProviderName): $($_.Message)" -replace '\s+', ' ' if ($line.Length -gt 120) { $line = $line.Substring(0, 120) } $line }) } catch [System.Exception] { if ($_.Exception -isnot [System.Diagnostics.Eventing.Reader.EventLogNotFoundException] -and $_.CategoryInfo.Category -ne 'ObjectNotFound') { Write-Warning "Error-log read failed: $($_.Exception.Message)" } } # @() at the call site, not just inside the functions - PowerShell's pipeline output # collapses a single-item (or empty) array back to a scalar/$null across a function # return boundary unless the caller re-wraps it, which would send the portal a bare # object (or null) instead of a one-element/empty JSON array. $sessionEvents = @(Get-SessionEvents -Since $Since) $liveSessions = @(Get-LiveSessions) return [ordered]@{ sampleId = [guid]::NewGuid().ToString() collectedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") cpuPercent = $cpuPercent memoryUsedPercent = $memoryUsedPercent uptimeSeconds = $uptimeSeconds disks = $disks network = $network errors = $errors sessionEvents = $sessionEvents liveSessions = $liveSessions agentVersion = $AgentVersion } } # Downloads the latest loop-only script (built from this same file's $agentScript # here-string - see scripts/build.mjs) and, only if it parses as valid PowerShell, # overwrites this script's own file on disk. Does NOT restart anything itself - the # caller is expected to exit non-zero afterward so the Scheduled Task's repeating # trigger (see Repair-ScheduledTaskTriggers below and Install-MonitorAgent.ps1) picks # the new file up. function Update-Agent { param([string]$PortalUrl, [string]$DestinationPath) try { $latest = (Invoke-WebRequest -Uri "$PortalUrl/downloads/MonitorAgent.ps1" -UseBasicParsing -TimeoutSec 30).Content # Invoke-WebRequest returns .Content as a raw byte[] instead of decoded text # when it can't determine the response is text from its Content-Type - decode # explicitly rather than relying on the portal always sending the right header. if ($latest -is [byte[]]) { $latest = [System.Text.Encoding]::UTF8.GetString($latest) } } catch { Write-Warning "Update download failed, will retry next heartbeat: $($_.Exception.Message)" return $false } if (-not $latest -or $latest.Trim().Length -eq 0) { Write-Warning "Update download was empty, will retry next heartbeat." return $false } $parseErrors = $null [System.Management.Automation.PSParser]::Tokenize($latest, [ref]$parseErrors) | Out-Null if ($parseErrors.Count -gt 0) { Write-Warning "Downloaded update did not parse as valid PowerShell, will retry next heartbeat." return $false } Set-Content -Path $DestinationPath -Value $latest -Encoding utf8 return $true } # Servers enrolled before this repeating-trigger fix only have an AtStartup trigger, so # an update-triggered exit would never come back on its own on those - repair the task's # triggers as part of applying any update, regardless of when the server was originally # enrolled. Idempotent: does nothing once the repeating trigger is already present. function Repair-ScheduledTaskTriggers { param([string]$TaskName) try { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop if ($task.Triggers | Where-Object { $_.Repetition -and $_.Repetition.Interval }) { return } Write-Host "Upgrading Scheduled Task '$TaskName' to a self-healing repeating trigger..." $startupTrigger = New-ScheduledTaskTrigger -AtStartup $repeatTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 3650) Set-ScheduledTask -TaskName $TaskName -Trigger @($startupTrigger, $repeatTrigger) | Out-Null } catch { Write-Warning "Could not repair Scheduled Task triggers: $($_.Exception.Message)" } } # Next wall-clock multiple of $reportSeconds (UTC epoch based, so :00/:15/:30/:45 for 900) # plus this agent's small fixed jitter - keeps the fleet's reports bunched together so the # database wakes once per interval, without every server hitting the same millisecond. $reportJitterSeconds = Get-Random -Minimum 0 -Maximum 30 function Get-NextReportTime { $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $next = ([Math]::Floor($epoch / $reportSeconds) + 1) * $reportSeconds + $reportJitterSeconds return [DateTimeOffset]::FromUnixTimeSeconds([int64]$next).LocalDateTime } Write-Host "TranTech Monitor Agent starting - sampling every $sampleSeconds second(s), reporting to $portalUrl every $reportSeconds second(s)." $buffer = New-Object System.Collections.Generic.List[object] $lastSampleTime = (Get-Date).AddSeconds(-$sampleSeconds) $nextSampleAt = Get-Date # Report straight away on start (fresh install, update or reboot) so the portal shows this # server as Reporting immediately, then settle onto the aligned schedule. $nextReportAt = Get-Date while ($true) { $reportDue = (Get-Date) -ge $nextReportAt # Always take a fresh sample right before reporting, so the live session list the # portal shows is as current as possible. if ((Get-Date) -ge $nextSampleAt -or $reportDue) { try { $buffer.Add((Get-HealthSample -Since $lastSampleTime)) $lastSampleTime = Get-Date while ($buffer.Count -gt $maxBufferedSamples) { $buffer.RemoveAt(0) } } catch { Write-Warning "Sample collection failed, will retry next interval: $($_.Exception.Message)" } $nextSampleAt = (Get-Date).AddSeconds($sampleSeconds) } if ($reportDue -and $buffer.Count -gt 0) { try { # .ToArray() assigned straight into the hashtable (no pipeline) stays a real array, # so even a single buffered sample serializes as a one-element JSON array. $body = @{ samples = $buffer.ToArray() } | ConvertTo-Json -Depth 8 -Compress $response = Invoke-RestMethod -Uri "$portalUrl/api/agent/report" -Method Post -ContentType 'application/json' ` -Headers @{ Authorization = "Bearer $credential" } -Body $body -TimeoutSec 60 $buffer.Clear() if ($response.sampleSeconds -ge 15 -and $response.sampleSeconds -le 900) { $sampleSeconds = [int]$response.sampleSeconds } if ($response.reportSeconds -ge 60 -and $response.reportSeconds -le 3600) { $reportSeconds = [int]$response.reportSeconds } if ($response.update) { Write-Host "Update available - downloading and applying..." if (Update-Agent -PortalUrl $portalUrl -DestinationPath $destScriptPath) { Repair-ScheduledTaskTriggers -TaskName $ScheduledTaskName Write-Host "Updated - exiting so the Scheduled Task's repeating trigger relaunches with the new version." # A distinct, non-zero-but-not-401 exit code, just for anyone reading task # history later - exit 0 specifically means "stop, don't come back" (used # above for a revoked credential). The Scheduled Task's repeating trigger # (see Install-MonitorAgent.ps1) relaunches within a minute regardless of # this exit code and picks up the file just written above. exit 75 } } } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 401) { Write-Warning "Agent credential rejected (revoked or business disabled) - stopping. Re-enrol with a new code to resume reporting." exit 0 } if ($status -eq 400 -or $status -eq 413) { # The portal rejected the batch's content itself - resending it unchanged would # fail forever and block every later sample, so drop it. Write-Warning "Portal rejected the buffered samples (HTTP $status) - discarding them." $buffer.Clear() } else { Write-Warning "Report failed, keeping $($buffer.Count) sample(s) to retry next interval: $($_.Exception.Message)" } } } if ($reportDue) { $nextReportAt = Get-NextReportTime } $wakeAt = if ($nextSampleAt -lt $nextReportAt) { $nextSampleAt } else { $nextReportAt } $sleepMs = [int][Math]::Ceiling(($wakeAt - (Get-Date)).TotalMilliseconds) if ($sleepMs -gt 0) { Start-Sleep -Milliseconds $sleepMs } } '@ $PortalUrl = $PortalUrl.TrimEnd('/') Write-Host "Registering '$Label' with $PortalUrl ..." try { $registerBody = @{ code = $EnrolmentCode; hostname = $Label } | ConvertTo-Json $registration = Invoke-RestMethod -Uri "$PortalUrl/api/agent/register" -Method Post -ContentType 'application/json' -Body $registerBody } catch { $detail = $_.ErrorDetails.Message Write-Error "Registration failed: $($_.Exception.Message)$(if ($detail) { " - $detail" }). The enrolment code may be expired, already used, or invalid - ask an admin to generate a new one." exit 1 } if (-not $InstallDir -or -not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } # Machine-scoped DPAPI: encrypted with a key tied to this computer (not this user), so the # SYSTEM account the Scheduled Task runs as can decrypt it, but the file is useless if copied # to another machine. Add-Type -AssemblyName System.Security $credentialBytes = [System.Text.Encoding]::UTF8.GetBytes($registration.credential) $protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect( $credentialBytes, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) $destScript = Join-Path $InstallDir "MonitorAgent.ps1" Set-Content -Path $destScript -Value $agentScript -Encoding utf8 # Keep a copy of this installer alongside the agent - Add/Remove Programs' Uninstall # button (registered below) needs a durable copy to call, since the originally # downloaded file may have been deleted or moved by the time someone uninstalls. $installerCopyPath = Join-Path $InstallDir "Install-MonitorAgent.ps1" Copy-Item -Path $PSCommandPath -Destination $installerCopyPath -Force $credentialPath = Join-Path $InstallDir "credential.dat" [System.IO.File]::WriteAllBytes($credentialPath, $protectedBytes) $configPath = Join-Path $InstallDir "config.json" @{ portalUrl = $PortalUrl serverId = $registration.serverId sampleSeconds = $registration.sampleSeconds reportSeconds = $registration.reportSeconds } | ConvertTo-Json | Set-Content -Path $configPath -Encoding utf8 # Restrict the install directory to Administrators and SYSTEM - it holds the (encrypted) # agent credential and should not be readable by ordinary users on the box. icacls $InstallDir /inheritance:r | Out-Null icacls $InstallDir /grant:r "SYSTEM:(OI)(CI)F" "BUILTIN\Administrators:(OI)(CI)F" | Out-Null $argumentList = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$destScript`" -InstallDir `"$InstallDir`"" $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $argumentList $startupTrigger = New-ScheduledTaskTrigger -AtStartup # Task Scheduler's "restart if the task fails" setting (RestartOnFailure) does NOT fire # just because the launched process exits non-zero - confirmed by testing it directly, # not assumed. What reliably relaunches the loop (whether it crashed or exited on # purpose to apply a self-update, see Update-Agent above) is a repeating trigger: # Task Scheduler re-checks every minute, forever, and -MultipleInstances IgnoreNew # means that check is a no-op while the loop is still alive and running normally. $repeatTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 3650) $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable ` -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $taskName -Confirm:$false } # Stop-ScheduledTask only reliably reaches a process Task Scheduler still tracks as # belonging to that task. Re-running this installer without it left a previous run's # MonitorAgent.ps1 process alive in memory - unregistering/re-registering the task # definition doesn't touch an already-running process - so it kept heartbeating with # its old credential indefinitely, alongside the new one, both reporting for the same # physical server. Belt-and-braces: find and stop any MonitorAgent.ps1 process by its # exact script path before installing fresh, so re-running this never results in two # agents reporting for the same server again. Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($destScript) -and $_.ProcessId -ne $PID } | ForEach-Object { Write-Host "Stopping existing agent process (PID $($_.ProcessId))..." Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Register-ScheduledTask -TaskName $taskName -Action $action -Trigger @($startupTrigger, $repeatTrigger) -Principal $principal -Settings $settings ` -Description "Reports server health to the TranTech monitoring portal every $([int]($registration.reportSeconds / 60)) minutes." | Out-Null Write-Host "Scheduled Task '$taskName' installed - starts at boot and restarts itself if it exits unexpectedly." $uninstallCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$installerCopyPath`" -Uninstall -InstallDir `"$InstallDir`"" New-Item -Path $uninstallKeyPath -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "DisplayName" -Value "TranTech Monitor Agent" -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "Publisher" -Value "TranTech Computers" -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "DisplayVersion" -Value $InstallerVersion -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "InstallLocation" -Value $InstallDir -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "UninstallString" -Value $uninstallCommand -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "QuietUninstallString" -Value $uninstallCommand -PropertyType String -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "NoModify" -Value 1 -PropertyType DWord -Force | Out-Null New-ItemProperty -Path $uninstallKeyPath -Name "NoRepair" -Value 1 -PropertyType DWord -Force | Out-Null Start-ScheduledTask -TaskName $taskName Write-Host "" Write-Host "Done. '$Label' is enrolled as server $($registration.serverId) and the agent is now running." Write-Host "It should appear as 'Reporting' in the portal within a minute (it then reports every $([int]($registration.reportSeconds / 60)) minutes)." Write-Host "" Write-Host "To remove: run '.\Install-MonitorAgent.ps1 -Uninstall', or use Add/Remove Programs" Write-Host "('TranTech Monitor Agent') from now on."