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.
659 lines
28 KiB
PowerShell
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
|
|
}
|