- Settings > Endpoints > Enrollment. Migration tab: readiness counts and progress, each PC's state with the reasons it is blocked, "Upgrade selected" and "Upgrade next 25", and live wave progress. Credentials tab: enrolled PCs with revoke, pending requests with approve and reject, and the shared key and auto-enroll switches. Turning the shared key off asks for confirmation and shows how many PCs are not enrolled. - The server certificate page shows the SPKI pin to put in the GE-Enforce pin entry. - The agent upgrade script and the asset and printer reporters send the PC's credential when it has one (over https, only to the issuing server), fall back to the shared key, check server pins, and log refusals clearly. - New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1. - AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers phases, gates, rollback, pin and wave entries, and every place the shared key lives and how to clear it at cutover. - Removed the 09-14 certificate design notes and dated session reports, and two VM notes that carried a live collector key and advised plain http. - Corrected the GE-Enforce collector note, which claimed a plain registry token is protected by ACL.
610 lines
26 KiB
PowerShell
610 lines
26 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);
|
|
}
|
|
|
|
// 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 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 = ''
|
|
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
|
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
|
}
|
|
|
|
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) {
|
|
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
|
|
|
|
$serverPins = @(Get-ShopDbServerPins)
|
|
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
|
|
}
|