Files
shopdb-flask/plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
cproudlock 0a49714d05
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Keep every server pin in the upgrade script, and stop promising enrollment to an enrolled PC
Found in the Windows VM check.

- The agent upgrade script wrapped Get-ShopDbServerPins in @(), which turned
  several registry pins into one joined string. During a certificate rotation
  the upgrade download would have refused the rotated server. It also merged
  appsettings pins with the registry; it now follows the agent's rule: when the
  ServerPins value exists, even empty, the registry owns the list. The shared
  pin block in the upgrade script and both reporters gains
  Test-ShopDbServerPinsValue.
- A key-less package installed over an enrolled PC printed both "existing
  enrolled credential found; kept as is" and "This PC will request
  enrollment". The enrollment notices now print only when no credential exists.
2026-09-15 15:52:54 -04:00

659 lines
28 KiB
PowerShell

# upgrade-shopdbagent.ps1
# ShopDB Endpoint Agent SELF-UPGRADE template. Delivered to an endpoint by the
# run_script command and executed by the agent as SYSTEM.
#
# WHY THIS TAKES NO PARAMETERS
# run_script executes "powershell.exe -File <script>" with no argument list, so
# there is no channel to pass BaseUrl/CollectorKey in. That is not a problem
# here: an upgrade by definition targets an endpoint that is ALREADY installed
# and therefore already holds its own config. This script READS that config and
# never rewrites it - the values stay exactly as provisioned, so an upgrade can
# not silently repoint an endpoint at a different server or key. A fresh
# install is still install-shopdbagent.ps1's job.
#
# WHY IT RE-LAUNCHES ITSELF DETACHED
# The agent process is what runs this script, and the upgrade must stop that
# same agent to release the lock on shopdbagent.exe (single-file self-contained
# exes are held open while running). If the script stayed a child of the agent,
# stopping the service would kill the script mid-copy and leave the install
# directory half-written. The first pass therefore copies itself to a temp
# location, relaunches detached, and exits immediately so the agent sees a
# clean, fast result. All real work happens in the detached second pass.
#
# REPORTING
# Success is NOT reported through the command result - by the time the upgrade
# succeeds, the agent that would have reported it has been restarted. The new
# version is confirmed on the next check-in. This script's own transcript is
# the local record; see $LogFile below.
#
# AUTH AND PINS
# The download authenticates the way the agent does: the per-PC credential from
# the agent's SYSTEM-only credential.bin when this PC has enrolled, otherwise
# the shared CollectorKey. When ServerPins is set (registry, or serverpins in
# appsettings.json) the server's TLS key must match a pin or nothing is
# downloaded. A 1.19 agent upgrading to 1.20 has no credential yet and uses the
# shared key; the new agent enrolls itself after it starts.
[CmdletBinding()]
param(
# Set only when the script relaunches itself. Operators never pass this.
[switch] $Detached
)
$ErrorActionPreference = 'Stop'
$ServiceName = 'ShopDbEndpointAgent'
$InstallDir = 'C:\Program Files\ShopDB\EndpointAgent'
$TrayExeName = 'shopdb-endpoint-tray.exe'
$AgentExeName = 'shopdbagent.exe'
$StateDir = Join-Path $env:ProgramData 'ShopDB\EndpointAgent'
$WorkDir = Join-Path $StateDir 'upgrade'
$LogDir = Join-Path $StateDir 'logs'
$LogFile = Join-Path $LogDir ('upgrade-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
# Server-relative path of the managed agent bundle. This reuses the EXISTING
# agent-facing app-package route rather than adding a parallel download: it
# already authenticates with the collector key and already resolves an
# application's installpath, which is precisely what upload registration
# stamped for the agent bundle. The appid is substituted by the server when
# this template is rendered, because it is assigned when the row is created.
$PackagePath = '/api/endpoints/apppackage/app/{{AGENT_APPID}}'
function Write-Log {
param([string] $Message, [string] $Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
}
# The shared credential block below logs through Log, as the reporters do.
function Log([string] $msg) { Write-Log $msg }
# ---------------------------------------------------------------------------
# Per-PC credential and server pin. Kept in step with the same block in
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
#
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
# Administrators. When present it is sent as X-Endpoint-Credential and the
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
#
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
# is set the TLS handshake is accepted only if the leaf key matches one.
# ---------------------------------------------------------------------------
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
$ShopDbPinSource = @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
public static class ShopDbServerPinValidator
{
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public static string LastPresented;
public static void Install(string[] pins)
{
Pins.Clear();
foreach (string pin in pins)
{
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
}
ServicePointManager.ServerCertificateValidationCallback = Validate;
}
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
if (certificate == null) { return false; }
try
{
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
}
catch (Exception)
{
LastPresented = null;
return false;
}
return Pins.Contains(LastPresented);
}
public static bool IsPinned(string pin)
{
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
{
int length;
int position = EnterSequence(der, 0, out length);
position = EnterSequence(der, position, out length);
if (der[position] == 0xA0) { position = Skip(der, position); }
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
int end = Skip(der, position);
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(der, position, end - position);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private static int ReadLength(byte[] der, int position, out int length)
{
int first = der[position];
if (first < 0x80) { length = first; return position + 1; }
int count = first & 0x7F;
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
length = 0;
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
return position + 1 + count;
}
private static int EnterSequence(byte[] der, int position, out int length)
{
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
return ReadLength(der, position + 1, out length);
}
private static int Skip(byte[] der, int position)
{
int length;
int content = ReadLength(der, position + 1, out length);
return content + length;
}
}
'@
function Get-ShopDbServerPins {
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
$pins = @()
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
foreach ($value in @($key.GetValue('ServerPins'))) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
}
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return ,$pins
}
function Test-ShopDbServerPinsValue {
# True when ServerPins exists in either registry view, even empty. The
# registry then owns the pin list and appsettings pins are ignored, the
# same rule the agent follows.
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return $false
}
function Enable-ShopDbServerPins([string[]]$Pins) {
# True when pinning is active. False means pins are set but could not be
# enforced; the caller must not send anything then.
if ($PSVersionTable.PSEdition -eq 'Core') {
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
# would silently not happen.
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
return $false
}
try {
if (-not ('ShopDbServerPinValidator' -as [type])) {
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
}
[ShopDbServerPinValidator]::Install($Pins)
return $true
} catch {
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
return $false
}
}
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
# Empty when there is none or it cannot be used for this URL.
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
try {
Add-Type -AssemblyName System.Security -ErrorAction Stop
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
} catch {
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
return ''
}
$credential = [string]$record.credential
if (-not $credential.StartsWith('sdbe_')) {
Log 'WARN endpoint credential file has no usable credential; ignoring it'
return ''
}
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
return ''
}
# Never hand a credential to a server other than the one that issued it.
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
return ''
}
return $credential
}
function Get-ShopDbHttpFailure($ErrorRecord) {
# Status and error envelope code (data.error.code) from a failed request.
$status = 0
$code = ''
$text = ''
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
$text = $ErrorRecord.ErrorDetails.Message
} else {
try {
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
$text = $reader.ReadToEnd()
$reader.Dispose()
} catch {}
}
if ($text) {
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
}
$presented = ''
$matched = $false
if ('ShopDbServerPinValidator' -as [type]) {
$presented = [string][ShopDbServerPinValidator]::LastPresented
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
}
# Innermost exception text: the outer WebException hides the real cause
# (unexpected EOF, connection reset, trust failure).
$message = ''
$exception = $ErrorRecord.Exception
while ($exception) {
if ($exception.Message) { $message = $exception.Message }
$exception = $exception.InnerException
}
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
}
function Write-ShopDbAuthFailure($Failure) {
switch ($Failure.Code) {
'CREDENTIAL_INVALID' {
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
}
'SHARED_KEY_REFUSED' {
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
}
'ASSET_MISMATCH' {
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
}
default {
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
# A pin was seen, but only an absent pin is a pin failure. A
# matched pin means the handshake passed and something later
# failed (server dropped mid-transfer, timeout).
if ($Failure.PinMatched) {
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
} else {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
}
}
}
}
}
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
# 64-bit agent's config is not, and the upgrade would look unconfigured.
function Get-Hklm64Value {
param([string] $SubKey, [string] $Name)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.OpenSubKey($SubKey)
if (-not $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Config resolution mirrors the agent's own order of preference, so the upgrade
# reaches the same server the agent is actually talking to. Registry first;
# appsettings.json is the fallback for locked-down hosts (CyberArk/EPM) that
# gate the HKLM hive against reads.
function Get-AgentConfig {
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
$pins = @()
# Read even when the registry is complete: serverpins lives only here.
$settings = Join-Path $InstallDir 'appsettings.json'
if (Test-Path $settings) {
try {
$json = Get-Content $settings -Raw | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
}
foreach ($value in @($json.serverpins)) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$') { $pins += $pin }
}
}
catch {
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
}
}
if ([string]::IsNullOrWhiteSpace($baseUrl)) {
throw 'No BaseUrl found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
}
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = [string]$apiKey; ServerPins = $pins }
}
# ---------------------------------------------------------------------------
# First pass: relaunch detached and get out of the way. See header.
# ---------------------------------------------------------------------------
if (-not $Detached) {
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
$selfCopy = Join-Path $WorkDir 'upgrade-shopdbagent.ps1'
Copy-Item -Path $PSCommandPath -Destination $selfCopy -Force
# Copied out of the agent's own temp dir on purpose: the delivered script
# may be cleaned up by the agent once the command returns, which would
# delete this file out from under the detached run.
Start-Process -FilePath 'powershell.exe' `
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', `
'-File', "`"$selfCopy`"", '-Detached' `
-WindowStyle Hidden | Out-Null
Write-Host 'Upgrade handed off to a detached process; this agent will restart shortly.'
exit 0
}
# ---------------------------------------------------------------------------
# Second pass (detached): the actual upgrade.
# ---------------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
Write-Log '== ShopDB Endpoint Agent self-upgrade =='
try {
$config = Get-AgentConfig
Write-Log "server: $($config.BaseUrl)"
# 1. Download BEFORE stopping anything. A failed download must leave the
# running agent untouched rather than stranding the endpoint with a
# stopped service and no replacement binaries.
$zipPath = Join-Path $WorkDir 'agent-package.zip'
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
$url = $config.BaseUrl + $PackagePath
Write-Log "downloading package from $url ..."
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
# protocols the server will refuse.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Get-ShopDbServerPins already returns an array; wrapping it in @() made
# several pins one joined string. Registry wins when the value exists,
# even empty; appsettings pins apply only when it is absent.
$serverPins = Get-ShopDbServerPins
if (-not (Test-ShopDbServerPinsValue)) {
foreach ($pin in $config.ServerPins) {
if ($serverPins -notcontains $pin) { $serverPins += $pin }
}
}
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
throw 'ServerPins is set but the pin check could not be enabled; not downloading.'
}
Write-Log "server pin check on ($($serverPins.Count) pin(s))"
}
$credential = Get-ShopDbEndpointCredential $config.BaseUrl
$attempts = @()
if ($credential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $credential }) }
if ($config.ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $config.ApiKey }) }
if ($attempts.Count -eq 0) {
throw 'No endpoint credential and no CollectorKey on this endpoint; cannot authenticate the download.'
}
# Credential first. A refused credential (revoked) falls back to the shared
# key once, which the server admits again for an un-enrolled PC.
$downloaded = $false
foreach ($attempt in $attempts) {
try {
Invoke-WebRequest -Uri $url -Headers $attempt[1] `
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
Write-Log "authenticated with $($attempt[0])"
$downloaded = $true
break
}
catch {
$failure = Get-ShopDbHttpFailure $_
Write-Log "download with $($attempt[0]) failed: $($_.Exception.Message)" 'WARN'
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { throw }
}
}
if (-not $downloaded) { throw 'Package download was refused.' }
$size = (Get-Item $zipPath).Length
Write-Log "downloaded $size bytes"
if ($size -lt 1MB) {
throw "Package is only $size bytes; refusing to install a payload this small."
}
# 2. Unpack to a staging dir and validate the payload BEFORE touching the
# install. Expanding straight over Program Files would leave a partially
# replaced install if the archive turned out to be short a binary.
$stage = Join-Path $WorkDir 'stage'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $stage -Force
$newAgent = Join-Path $stage $AgentExeName
if (-not (Test-Path $newAgent)) {
throw "$AgentExeName not found in the downloaded package; aborting before any change."
}
$newTray = Join-Path $stage $TrayExeName
$haveTray = Test-Path $newTray
Write-Log ("payload validated (agent{0})" -f $(if ($haveTray) { ' + tray' } else { ', no tray' }))
# 3. Keep the current binaries so a failed swap can be undone. The config
# files are deliberately NOT part of this - they are never modified.
$backup = Join-Path $WorkDir 'backup'
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
New-Item -ItemType Directory -Force -Path $backup | Out-Null
foreach ($name in @($AgentExeName, $TrayExeName)) {
$existing = Join-Path $InstallDir $name
if (Test-Path $existing) { Copy-Item $existing -Destination $backup -Force }
}
Write-Log 'backed up current binaries'
# 4. Stop the service and kill lingering processes. Until this completes
# the exes are locked and the copy would fail with a file-in-use error.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Log "stopping service $ServiceName ..."
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
catch { Write-Log "could not stop service: $($_.Exception.Message)" 'WARN' }
}
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @('shopdbagent', $trayBase)) {
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
Write-Log "killing $($p.ProcessName) (pid $($p.Id))"
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { }
}
}
Start-Sleep -Milliseconds 750
# 5. Swap the binaries. On failure, restore the backup and restart, so a
# broken upgrade leaves a RUNNING old agent rather than a dead endpoint
# that can no longer be reached to fix remotely.
try {
Copy-Item -Path $newAgent -Destination $InstallDir -Force
if ($haveTray) { Copy-Item -Path $newTray -Destination $InstallDir -Force }
Write-Log 'binaries replaced'
}
catch {
Write-Log "copy failed: $($_.Exception.Message); rolling back" 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw
}
# 6. Clear Mark-of-the-Web. Files extracted from a downloaded archive carry
# a Zone.Identifier stream; SmartScreen blocks the unsigned exe on first
# run, which would leave the service unable to start.
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch { }
}
# 7. Restart and verify it stays up. A service that starts and immediately
# faults is a failed upgrade, so confirm rather than assume.
Write-Log "starting service $ServiceName ..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 5
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc -or $svc.Status -ne 'Running') {
Write-Log 'service did not stay running; rolling back' 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw 'Upgraded service failed to start; rolled back to the previous build.'
}
$installed = (Get-Item (Join-Path $InstallDir $AgentExeName)).VersionInfo.FileVersion
Write-Log "upgrade complete; agent version now $installed"
# 8. Relaunch the tray for the interactive user.
#
# This CANNOT use the installer's `explorer.exe <path>` trick. That works in
# install-shopdbagent.ps1 only because the installer runs AS THE USER - note
# its own guard, `if ($interactiveUser -ne "SYSTEM")`. This script always
# runs as SYSTEM in session 0, where `explorer.exe <path>` hands the request
# to a shell that is not reachable from here and then exits 0. Start-Process
# would report success and we would log a relaunch that never happened,
# which is exactly the bug this replaces.
#
# It also must NOT descend into the session with a token-duplicating launch.
# UserContextSpool documents that WTSQueryUserToken is a privileged operation
# that endpoint protection blocks on these hardened hosts - the service tried
# that direction before and it produced nothing on a live endpoint.
#
# So the tray is started the same way its own data path is pinned: against
# the known ShopFloor account. Task Scheduler performs the session/token
# transition, and an Interactive principal with no RunLevel starts the tray
# at the user's normal integrity - preserving the de-elevation the installer
# wanted, because an elevated unsigned tray trips the CyberArk/EPM broker.
#
# Best-effort by design: the agent is already upgraded and running, and the
# HKLM Run key the installer wrote still starts the tray at next logon. A
# late tray is not worth failing - or rolling back - a good agent upgrade.
if ($haveTray) {
$trayPath = Join-Path $InstallDir $TrayExeName
# Same pinned account the spool uses (FileLoggerProvider.ShopFloorUserName):
# every shop-floor endpoint signs in as this local account, and the tray
# must run as it or the mapped-drive probe reports the wrong account's
# (empty) drive table. Only relaunch if that profile is actually present.
$trayUser = 'ShopFloor'
$trayUserProfile = Join-Path $env:SystemDrive (Join-Path 'Users' $trayUser)
if (Test-Path $trayUserProfile) {
$taskName = 'ShopDB tray upgrade relaunch'
try {
# Clear a leftover from an interrupted earlier upgrade so we do
# not stack duplicates.
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
$action = New-ScheduledTaskAction -Execute $trayPath
$principal = New-ScheduledTaskPrincipal -UserId $trayUser -LogonType Interactive
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
# Verify rather than assume. The old code logged success off a
# Start-Process that could not fail, so a missing tray looked
# like a clean upgrade. Poll for the real process instead.
$trayBaseName = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
$running = $null
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Milliseconds 500
$running = Get-Process -Name $trayBaseName -ErrorAction SilentlyContinue
if ($running) { break }
}
if ($running) { Write-Log "tray relaunched as $trayUser" }
else {
Write-Log ('tray did not appear after relaunch; it will start at next ' +
'logon via the HKLM Run key') 'WARN'
}
# One-shot hand-off, not a persistent artifact: leaving it would
# surface in the uninstaller's leftover sweep and in task audits.
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
catch {
Write-Log ("could not relaunch tray: $($_.Exception.Message); " +
'it will start at next logon') 'WARN'
}
}
else {
Write-Log ("no $trayUser profile at $trayUserProfile; " +
'leaving the tray to start at next logon') 'WARN'
}
}
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
exit 0
}
catch {
Write-Log "UPGRADE FAILED: $($_.Exception.Message)" 'ERROR'
exit 1
}