<# .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, [switch]$EnableDeleteAuditing, [string[]]$AuditFolders, [int]$SecurityLogMB = 200 ) # 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.5.2" $ErrorActionPreference = 'Stop' # Older Windows Server builds default PowerShell to TLS 1.0, which the portal refuses. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $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 ($EnableDeleteAuditing) { # Opt-in, per server and per folder: makes Windows record who deletes files so the agent # can show it in the portal's timeline. Needs no enrolment code and doesn't touch the # agent - run it on any server (before or after installing), naming the folders to watch. if (-not $AuditFolders -or $AuditFolders.Count -eq 0) { Write-Error "-AuditFolders is required, e.g. -AuditFolders 'D:\Shares\Data','D:\Shares\Accounts'." exit 1 } foreach ($folder in $AuditFolders) { if (-not (Test-Path -LiteralPath $folder -PathType Container)) { Write-Error "Folder not found: $folder - nothing has been changed." exit 1 } } # Audit policy "File System" (success only) - by GUID so it works on any Windows language. # Deliberately not "Detailed File Share": that logs every SMB file access and bloats the log. $fileSystemGuid = '{0CCE921D-69AE-11D9-BED3-505054503030}' & auditpol.exe /set /subcategory:$fileSystemGuid /success:enable | Out-Null if ($LASTEXITCODE -ne 0) { Write-Error "auditpol could not enable File System auditing."; exit 1 } Write-Host "Audit policy 'File System': success auditing enabled." # Security log: a fixed-size file that overwrites its oldest events when full, so this # only ever sets the ceiling. Raise it from the 20 MB default, never lower it. $logInfo = Get-WinEvent -ListLog Security $wantBytes = [int64]$SecurityLogMB * 1MB if ($logInfo.MaximumSizeInBytes -lt $wantBytes) { & wevtutil.exe sl Security /ms:$wantBytes Write-Host "Security log size limit raised to $SecurityLogMB MB (it overwrites the oldest events when full)." } else { Write-Host "Security log limit is already $([int]($logInfo.MaximumSizeInBytes / 1MB)) MB - left as is." } foreach ($folder in $AuditFolders) { $acl = Get-Acl -LiteralPath $folder -Audit $already = $acl.Audit | Where-Object { $_.IdentityReference.Value -eq 'Everyone' -and $_.AuditFlags -band [System.Security.AccessControl.AuditFlags]::Success -and ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::Delete) -eq [System.Security.AccessControl.FileSystemRights]::Delete -and $_.InheritanceFlags -eq ([System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit') } if ($already) { Write-Host "Delete auditing already set on $folder."; continue } $rule = New-Object System.Security.AccessControl.FileSystemAuditRule( 'Everyone', 'Delete,DeleteSubdirectoriesAndFiles', 'ContainerInherit,ObjectInherit', 'None', 'Success') $acl.AddAuditRule($rule) Set-Acl -LiteralPath $folder -AclObject $acl Write-Host "Delete auditing set on $folder (inherited by everything inside it)." } Write-Host "" Write-Host "Done. Verify with: auditpol /get /subcategory:$fileSystemGuid" Write-Host "If the server is on a domain, a Group Policy audit setting can override this - check it if auditing switches itself off." 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. 1.4.0 hardening: - Session-event backfill: the time up to which session events have been successfully reported is saved to state.json, and on startup (reboot, crash, update) events are read back from that point (at most 3 days) instead of from "now" - Logoffs written at shutdown used to be lost, leaving sessions looking open for days in the portal. Events queue locally and go out at most 50 per sample (the portal's per-sample cap). - Diagnostic log: agent.log in the install directory, one line per sample/report/error, rotated to agent.log.1 at 1 MB - never more than ~2 MB on disk in total. - Watchdog: a background thread kills this process if the main loop hasn't completed an iteration in 20 minutes (a hung CIM/event-log/quser call has no timeout of its own); the Scheduled Task's repeating trigger relaunches it within a minute. On startup, any other MonitorAgent.ps1 process (an orphan from an earlier update/restart) is stopped. 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.5.2' $ScheduledTaskName = 'TranTech Monitor Agent' $ErrorActionPreference = 'Stop' # Older Windows Server builds default PowerShell to TLS 1.0, which the portal refuses. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $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) # --- Diagnostic log: agent.log + one rotated agent.log.1, each capped at 1 MB --- $logPath = Join-Path $InstallDir "agent.log" $maxLogBytes = 1MB function Write-Log { param([string]$Message, [string]$Level = 'INFO') if ($Message.Length -gt 500) { $Message = $Message.Substring(0, 500) } $line = "{0} {1} {2}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, ($Message -replace '\s+', ' ') try { $file = New-Object System.IO.FileInfo($logPath) if ($file.Exists -and $file.Length -ge $maxLogBytes) { Move-Item -Path $logPath -Destination "$logPath.1" -Force } Add-Content -Path $logPath -Value $line -Encoding utf8 } catch {} if ($Level -eq 'INFO') { Write-Host $Message } else { Write-Warning $Message } } # --- Watchdog: a separate runspace (thread) in this same process. The main loop stamps # LastLoop every iteration and Stage before each call that can block; if LastLoop goes # 20 minutes stale the watchdog logs where it was stuck and kills the whole process, and # the Scheduled Task's repeating trigger relaunches the agent within a minute. --- $watchdogState = [hashtable]::Synchronized(@{ LastLoop = [DateTime]::UtcNow; Stage = 'startup' }) $watchdogMinutes = 20 $watchdog = [PowerShell]::Create() [void]$watchdog.AddScript({ param($State, $Minutes, $LogPath) while ($true) { Start-Sleep -Seconds 30 $age = ([DateTime]::UtcNow - $State.LastLoop).TotalMinutes if ($age -gt $Minutes) { $line = "{0} ERROR Watchdog: main loop stuck in '{1}' for {2:N0} minutes - killing the agent so the Scheduled Task relaunches it." -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $State.Stage, $age try { Add-Content -Path $LogPath -Value $line -Encoding utf8 } catch {} [System.Diagnostics.Process]::GetCurrentProcess().Kill() } } }).AddArgument($watchdogState).AddArgument($watchdogMinutes).AddArgument($logPath) [void]$watchdog.BeginInvoke() # Any other process running this same script is an orphan: the Scheduled Task uses # -MultipleInstances IgnoreNew, so it only starts us when its own previous instance is gone # (Torque ended up with two after the 1.3.0 update, both reporting). Newest wins. try { Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and $_.CommandLine.IndexOf($destScriptPath, [StringComparison]::OrdinalIgnoreCase) -ge 0 } | ForEach-Object { Write-Log "Stopping orphaned agent process PID $($_.ProcessId) (started $($_.CreationDate))." 'WARN' Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } catch { Write-Log "Orphan check failed: $($_.Exception.Message)" 'WARN' } # --- Session-event backfill state: how far session events have been successfully # reported, so a restart resumes from there instead of silently skipping the gap. --- $statePath = Join-Path $InstallDir "state.json" $maxBackfillDays = 3 $eventsReadThrough = (Get-Date).AddSeconds(-$sampleSeconds) try { if (Test-Path $statePath) { $saved = (Get-Content -Path $statePath -Raw | ConvertFrom-Json).eventsReportedThrough if ($saved) { $savedTime = if ($saved -is [datetime]) { $saved.ToLocalTime() } else { [DateTime]::Parse([string]$saved).ToLocalTime() } $floor = (Get-Date).AddDays(-$maxBackfillDays) $eventsReadThrough = if ($savedTime -lt $floor) { $floor } else { $savedTime } } } } catch { Write-Log "Could not read $statePath, starting session events from now: $($_.Exception.Message)" 'WARN' } function Save-EventState { param([string]$ReportedThroughUtc) try { @{ eventsReportedThrough = $ReportedThroughUtc } | ConvertTo-Json | Set-Content -Path $statePath -Encoding utf8 } catch { Write-Log "Could not write ${statePath}: $($_.Exception.Message)" 'WARN' } } # The portal names its canonical URL in every report response. If it differs from the one # in config.json (e.g. installs from before the move off monitoring.transoft.com.au), switch # to it - but only after confirming this server can actually reach it (a client firewall or # web filter may not allow the new domain yet); otherwise stay put and retry next report. function Move-PortalUrl { param([string]$NewUrl) $NewUrl = $NewUrl.TrimEnd('/') if ($NewUrl -notmatch '^https://[A-Za-z0-9.-]+$' -or $NewUrl -eq $script:portalUrl) { return } try { $status = Invoke-RestMethod -Uri "$NewUrl/api/status" -TimeoutSec 15 if (-not $status.configured) { throw "portal at $NewUrl reports not configured" } } catch { Write-Log "Portal asked to move to $NewUrl but it isn't reachable from here yet, staying on $($script:portalUrl): $($_.Exception.Message)" 'WARN' return } try { $cfg = Get-Content -Path $configPath -Raw | ConvertFrom-Json $cfg.portalUrl = $NewUrl $cfg | ConvertTo-Json | Set-Content -Path $configPath -Encoding utf8 Write-Log "Portal URL changed from $($script:portalUrl) to $NewUrl." $script:portalUrl = $NewUrl } catch { Write-Log "Could not save new portal URL to ${configPath}: $($_.Exception.Message)" 'WARN' } } 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") }) } # 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. Queried separately with a cap, so a brute-force flood across a # multi-day backfill window can't bury (or balloon memory over) real session events. $secEvents = New-Object System.Collections.Generic.List[object] try { foreach ($e in @(Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4778, 4779; StartTime = $Since } -ErrorAction Stop)) { $secEvents.Add($e) } } catch {} try { foreach ($e in @(Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4625; StartTime = $Since } -MaxEvents 200 -ErrorAction Stop)) { $secEvents.Add($e) } } catch {} 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") }) } try { foreach ($d in @(Get-FileDeleteEvents -Since $Since)) { $rows.Add($d) } } catch { Write-Log "File-delete event read failed: $($_.Exception.Message)" 'WARN' } # .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() } # --- File deletions (Security 4663), only present where delete auditing has been enabled --- # Nothing is written to the Security log until the installer's -EnableDeleteAuditing has set # the audit policy and a Delete audit rule on the chosen folders, so on other servers this # simply finds nothing. 4663 says who (account + logon ID) and what (path); the source IP # isn't in it, so it is looked up from the 4624 logon with the same logon ID. $deleteIgnoreName = '^(~\$.*|.*\.tmp|thumbs\.db|desktop\.ini|\.ds_store)$' $logonClientCache = @{} function Get-LogonClient { param([string]$LogonId) if (-not $LogonId -or $LogonId -notmatch '^0x[0-9a-fA-F]+$') { return $null } $key = $LogonId.ToLowerInvariant() if ($logonClientCache.ContainsKey($key)) { return $logonClientCache[$key] } if ($logonClientCache.Count -gt 200) { $logonClientCache.Clear() } $client = $null try { $upper = '0x' + $key.Substring(2).ToUpperInvariant() $xpath = "*[System[(EventID=4624)]] and *[EventData[Data[@Name='TargetLogonId']='$key' or Data[@Name='TargetLogonId']='$upper']]" $logon = Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 1 -ErrorAction Stop $xml = [xml]$logon.ToXml() $client = Get-EventDataByName -XmlEvent $xml -Name 'IpAddress' if (-not $client -or $client -in '-', '::1', '127.0.0.1') { $client = Get-EventDataByName -XmlEvent $xml -Name 'WorkstationName' } if ($client -eq '-') { $client = $null } } catch {} $logonClientCache[$key] = $client return $client } function Get-FileDeleteEvents { param([datetime]$Since) try { # Windows filters out the 0x80 (ReadAttributes) events before we see them - Defender # scans can log tens of thousands of those an hour, which would otherwise be parsed # one by one here and could crowd real deletions out of the MaxEvents window. $ms = [int64][Math]::Max(1, [Math]::Ceiling(((Get-Date) - $Since).TotalMilliseconds)) $xpath = "*[System[(EventID=4663) and TimeCreated[timediff(@SystemTime) <= $ms]]] and *[EventData[Data[@Name='AccessMask']!='0x80']]" $events = @(Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 2000 -ErrorAction Stop) } catch { return @() } $items = New-Object System.Collections.Generic.List[object] $seen = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($evt in $events) { $xml = [xml]$evt.ToXml() if ((Get-EventDataByName -XmlEvent $xml -Name 'ObjectType') -ne 'File') { continue } $mask = Get-EventDataByName -XmlEvent $xml -Name 'AccessMask' if (-not $mask -or $mask -notmatch '^0x[0-9a-fA-F]+$') { continue } # 0x10000 = DELETE. Auditing may also log reads of attributes etc., which aren't deletions. if (([Convert]::ToInt64($mask.Substring(2), 16) -band 0x10000) -eq 0) { continue } $path = Get-EventDataByName -XmlEvent $xml -Name 'ObjectName' $name = Get-EventDataByName -XmlEvent $xml -Name 'SubjectUserName' if (-not $path -or -not $name -or $name.EndsWith('$')) { continue } if (($path -replace '^.*\\', '') -match $deleteIgnoreName) { continue } $domain = Get-EventDataByName -XmlEvent $xml -Name 'SubjectDomainName' $user = if ($domain) { "$domain\$name" } else { $name } # One delete can raise several 4663s (separate handles) - count each path once. if (-not $seen.Add("$user|$path")) { continue } $items.Add([pscustomobject]@{ RecordId = $evt.RecordId; Time = $evt.TimeCreated; Path = $path; User = $user Dir = ($path -replace '\\[^\\]*$', ''); LogonId = Get-EventDataByName -XmlEvent $xml -Name 'SubjectLogonId' }) } # One row per user and folder, so deleting a folder of 5,000 files is one timeline entry # (with a count) rather than 5,000. $rows = New-Object System.Collections.Generic.List[object] foreach ($group in @($items | Group-Object { "$($_.User)|$($_.Dir)" })) { $members = @($group.Group | Sort-Object Time) $first = $members[0] $text = if ($members.Count -eq 1) { "Deleted $($first.Path)" } else { "Deleted $($members.Count) items in $($first.Dir) (first: $($first.Path -replace '^.*\\', ''))" } if ($text.Length -gt 200) { $text = $text.Substring(0, 199) + [char]0x2026 } $rows.Add([ordered]@{ source = 'Security'; recordId = "d$($first.RecordId)"; windowsEventId = 4663; type = 'FileDeleted' user = $first.User; sessionId = $null; client = (Get-LogonClient -LogonId $first.LogonId) reasonCode = $null; reasonText = $text occurredAt = $first.Time.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }) } 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, [object[]]$SessionEvents = @()) $watchdogState.Stage = 'CIM (OS/CPU/disk/network)' $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-Log "CPU counter unavailable: $($_.Exception.Message)" 'WARN' } $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-Log "Network counters unavailable: $($_.Exception.Message)" 'WARN' } $errors = @() $watchdogState.Stage = 'System/Application error log' 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-Log "Error-log read failed: $($_.Exception.Message)" 'WARN' } } # @() 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. (Session events are read # by the main loop into its pending queue and handed in already batched.) $sessionEvents = @($SessionEvents) $watchdogState.Stage = 'quser' $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-Log "Update download failed, will retry next heartbeat: $($_.Exception.Message)" 'WARN' return $false } if (-not $latest -or $latest.Trim().Length -eq 0) { Write-Log "Update download was empty, will retry next heartbeat." 'WARN' return $false } $parseErrors = $null [System.Management.Automation.PSParser]::Tokenize($latest, [ref]$parseErrors) | Out-Null if ($parseErrors.Count -gt 0) { Write-Log "Downloaded update did not parse as valid PowerShell, will retry next heartbeat." 'WARN' 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-Log "Could not repair Scheduled Task triggers: $($_.Exception.Message)" 'WARN' } } # 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-Log "TranTech Monitor Agent $AgentVersion starting (PID $PID) - sampling every $sampleSeconds second(s), reporting to $portalUrl every $reportSeconds second(s); session events from $($eventsReadThrough.ToString('yyyy-MM-dd HH:mm:ss'))." $buffer = New-Object System.Collections.Generic.List[object] # Session events read from the event logs but not yet put into a sample, oldest first. At # most $maxEventsPerSample ride in each sample, so a startup backfill (or a burst) drains # over the following minutes instead of producing a sample the portal would reject. $pendingEvents = New-Object System.Collections.Generic.List[object] $maxEventsPerSample = 25 $maxPendingEvents = 3000 # Samples per POST - keeps each request well under the portal's 2 MB body limit even when # a long outage has filled the buffer with event-heavy samples. $maxSamplesPerPost = 100 $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) { $watchdogState.LastLoop = [DateTime]::UtcNow $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) { $sampleStarted = Get-Date try { # Read from just before the query starts - an event written while the query runs # is picked up again next time rather than missed (the portal de-duplicates). $watchdogState.Stage = 'session event logs' $readAt = Get-Date $newEvents = @(Get-SessionEvents -Since $eventsReadThrough | Sort-Object { $_.occurredAt }) foreach ($e in $newEvents) { $pendingEvents.Add($e) } $eventsReadThrough = $readAt if ($pendingEvents.Count -gt $maxPendingEvents) { # Shed failed-logon noise first, then the oldest of whatever is left. $excess = $pendingEvents.Count - $maxPendingEvents for ($i = 0; $i -lt $pendingEvents.Count -and $excess -gt 0; ) { if ($pendingEvents[$i].type -eq 'FailedLogon') { $pendingEvents.RemoveAt($i); $excess-- } else { $i++ } } if ($excess -gt 0) { $pendingEvents.RemoveRange(0, $excess) } Write-Log "Session event backlog over $maxPendingEvents - dropped the excess (failed logons first)." 'WARN' } $take = [Math]::Min($maxEventsPerSample, $pendingEvents.Count) $batch = $pendingEvents.GetRange(0, $take).ToArray() $sample = Get-HealthSample -Since $lastSampleTime -SessionEvents $batch $pendingEvents.RemoveRange(0, $take) $buffer.Add($sample) $lastSampleTime = $sampleStarted while ($buffer.Count -gt $maxBufferedSamples) { $buffer.RemoveAt(0) } Write-Log ("Sample: {0} live session(s), {1} event(s) ({2} new, {3} still queued), took {4:N1}s, {5} buffered." -f ` $sample.liveSessions.Count, $take, $newEvents.Count, $pendingEvents.Count, ((Get-Date) - $sampleStarted).TotalSeconds, $buffer.Count) } catch { Write-Log "Sample collection failed, will retry next interval: $($_.Exception.Message)" 'WARN' } $nextSampleAt = (Get-Date).AddSeconds($sampleSeconds) } if ($reportDue -and $buffer.Count -gt 0) { $watchdogState.Stage = 'report POST' $updateRequested = $false $sent = 0 try { while ($buffer.Count -gt 0) { $n = [Math]::Min($maxSamplesPerPost, $buffer.Count) # .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.GetRange(0, $n).ToArray() } | ConvertTo-Json -Depth 8 -Compress try { $response = Invoke-RestMethod -Uri "$portalUrl/api/agent/report" -Method Post -ContentType 'application/json' ` -Headers @{ Authorization = "Bearer $credential" } -Body $body -TimeoutSec 60 } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 401) { Write-Log "Agent credential rejected (revoked or business disabled) - stopping. Re-enrol with a new code to resume reporting." 'ERROR' exit 0 } if ($status -eq 400 -or $status -eq 413) { # The portal rejected this chunk's content itself - resending it unchanged # would fail forever and block every later sample, so drop it. Write-Log "Portal rejected $n buffered sample(s) (HTTP $status) - discarding them." 'WARN' $buffer.RemoveRange(0, $n) continue } throw } $buffer.RemoveRange(0, $n) $sent += $n if ($response.update) { $updateRequested = $true } 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 } } # Everything read so far is now either reported or still queued locally - persist # how far that is, so a restart re-reads (and the portal de-duplicates) from there. $through = if ($pendingEvents.Count -gt 0) { $pendingEvents[0].occurredAt } else { $eventsReadThrough.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") } Save-EventState -ReportedThroughUtc $through Write-Log "Report sent: $sent sample(s)." if ($response.portalUrl) { Move-PortalUrl -NewUrl ([string]$response.portalUrl) } if ($updateRequested) { Write-Log "Update available - downloading and applying..." $watchdogState.Stage = 'update download' if (Update-Agent -PortalUrl $portalUrl -DestinationPath $destScriptPath) { Repair-ScheduledTaskTriggers -TaskName $ScheduledTaskName Write-Log "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 { Write-Log "Report failed, keeping $($buffer.Count) sample(s) to retry next interval: $($_.Exception.Message)" 'WARN' } } if ($reportDue) { $nextReportAt = Get-NextReportTime } $watchdogState.Stage = 'sleeping' $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."