Add one-click agent self-upgrade from the endpoint detail page
An operator could see that an endpoint was behind the published build but had no way to act on it. update_app resolves against Application rows, and the agent lives in the version store, so there was nothing to push. Bridge the two: the staged build is packaged and registered as a reserved Application row, and a managed PowerShell script is seeded to install it. The endpoint downloads that package over the existing collector-authenticated app-package route, so no parallel delivery path is introduced. The new route prepares the delivery and then DELEGATES to enqueue_endpoint_command as a digest-pinned run_script. It deliberately does not implement its own enqueue: a second path would duplicate, and eventually drift from, the endpoint lock, the Configuration Policy gate, stale-command reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the last action that should skip a quarantine check. Tests pin that delegation rather than only the happy path. Because it lands as run_script, the caller needs endpoints.runscript on top of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM PowerShell by the same mechanism as any other script push. The audit event is written only on an actual 201, so a refused push does not record an upgrade that never happened. Replacing the agent is self-modifying the control plane - the process receiving the command is the one being swapped. The script therefore re-launches itself detached and exits immediately, because the agent runs run_script as a direct child and holds its stdout/stderr pipes while waiting on it. Expect the command to report queued rather than success: the process that would report completion is stopped mid-run. Judge the result by the version the endpoint reports afterward. Tray relaunch does not 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 `-ne "SYSTEM"` guard. This script always runs as SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so Start-Process reported success for a relaunch that never happened. It also cannot descend into the session with a token-duplicating launch: UserContextSpool documents that WTSQueryUserToken is blocked by endpoint protection on these hosts. Instead a one-shot scheduled task targets the pinned ShopFloor account, matching how the user-context spool already resolves that user, and the tray must run as that account or the mapped-drive probe reports the wrong (empty) drive table. The relaunch is verified by polling for the process rather than assumed, and stays best-effort: the Run key still starts the tray at next logon, and a late tray is not worth rolling back a good agent upgrade. panel_status now carries agentbehind/agentversion/agentcurrent so the UI gates the action on a flag rather than parsing "(behind X)" out of a label. The button is confirmed by typing UPDATE, naming both versions and the service interruption, matching how run_script and system_restart are gated. Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to 1.16.0.0. run_script delivery, digest fetch and system context all predate 1.13.0.0, so no minimum agent version is declared for this path.
This commit is contained in:
@@ -38,6 +38,15 @@ def _package_path(appid):
|
||||
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
|
||||
|
||||
|
||||
def get_agent_application():
|
||||
"""The reserved agent Application row, or None when nothing is registered.
|
||||
|
||||
Read-only lookup for callers that need the appid to build a delivery URL
|
||||
without causing a row to spring into existence as a side effect.
|
||||
"""
|
||||
return Application.query.filter_by(appname=AGENT_APPNAME).first()
|
||||
|
||||
|
||||
def _get_or_create_app():
|
||||
"""Return the reserved agent Application row, creating it when absent.
|
||||
|
||||
|
||||
136
plugins/endpoints/api/agentupgradescript.py
Normal file
136
plugins/endpoints/api/agentupgradescript.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Seed the agent self-upgrade script into the ShopDB script library.
|
||||
|
||||
run_script only delivers bodies that live in the script library, pinned by
|
||||
SHA-256. The upgrade template therefore has to exist as a real Script row with
|
||||
a stored body before any upgrade can be pushed.
|
||||
|
||||
The template is source-controlled at
|
||||
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 and seeded from there
|
||||
rather than typed into the library by hand, so the reviewed script and the one
|
||||
endpoints actually run are the same artifact. Editing the row's body in the UI
|
||||
is pointless - the next seed overwrites it.
|
||||
|
||||
It lives under the plugin, not under agent/, because the Docker image copies
|
||||
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
|
||||
from agent/ resolves fine on a developer checkout and then fails in the
|
||||
deployed container, which is the worst place to discover it.
|
||||
|
||||
One placeholder is rendered at seed time: the agent application's appid, which
|
||||
only exists once agentregistry has created the row. Rendering happens BEFORE
|
||||
the digest is computed, so the stored body and its sha256 always agree; a
|
||||
substitution done after hashing would fail the agent's verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from shopdb.api import Script, db, script_body_location
|
||||
|
||||
from . import agentregistry
|
||||
|
||||
# Reserved script name. Unique in the library, which makes seeding idempotent
|
||||
# and keeps a hand-made script from colliding with the managed one.
|
||||
UPGRADE_SCRIPT_NAME = 'ShopDB Endpoint Agent Self-Upgrade'
|
||||
|
||||
_UPGRADE_DESCRIPTION = (
|
||||
'Managed: downloads the current published agent bundle and replaces the '
|
||||
'installed agent in place, then restarts the service. Seeded from source '
|
||||
'control - edits here are overwritten.'
|
||||
)
|
||||
|
||||
# Substituted with the agent application's appid so the script can fetch its
|
||||
# payload from the existing collector-authenticated app-package route.
|
||||
_APPID_TOKEN = '{{AGENT_APPID}}'
|
||||
|
||||
_TEMPLATE_FILENAME = 'upgrade-shopdbagent.ps1'
|
||||
|
||||
|
||||
def template_path():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'templates', _TEMPLATE_FILENAME)
|
||||
|
||||
|
||||
def _script_dir():
|
||||
"""Where script bodies live. Resolved through the contract surface helper
|
||||
so this cannot drift from the directory the download route serves from."""
|
||||
directory, _prefix = script_body_location()
|
||||
return directory
|
||||
|
||||
|
||||
def render_template(appid):
|
||||
"""Return the upgrade script body with the appid substituted.
|
||||
|
||||
Raises if the placeholder is missing: that means the template was edited
|
||||
in a way that breaks the download, and seeding a script that would fetch
|
||||
from a literal '{{AGENT_APPID}}' path is worse than refusing.
|
||||
"""
|
||||
with open(template_path(), 'r', encoding='utf-8') as handle:
|
||||
body = handle.read()
|
||||
if _APPID_TOKEN not in body:
|
||||
raise ValueError(
|
||||
'upgrade template is missing the {} placeholder; refusing to seed '
|
||||
'a script that cannot resolve its package'.format(_APPID_TOKEN))
|
||||
return body.replace(_APPID_TOKEN, str(appid))
|
||||
|
||||
|
||||
def seed_upgrade_script():
|
||||
"""Create or refresh the managed upgrade script row and its body.
|
||||
|
||||
Returns a dict describing the seeded script, or None when the agent
|
||||
application row does not exist yet (nothing to point the download at).
|
||||
Idempotent: unchanged input rewrites nothing and leaves the digest alone.
|
||||
"""
|
||||
app = agentregistry.get_agent_application()
|
||||
if app is None:
|
||||
return None
|
||||
|
||||
body = render_template(app.appid).encode('utf-8')
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
|
||||
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
|
||||
created = script is None
|
||||
if created:
|
||||
script = Script(
|
||||
scriptname=UPGRADE_SCRIPT_NAME,
|
||||
scriptdescription=_UPGRADE_DESCRIPTION,
|
||||
# SYSTEM: the script stops a service, writes into Program Files,
|
||||
# and edits HKLM. None of that is available in a user context.
|
||||
runcontext='system',
|
||||
# Failures always report; success is opt-in. Capture it here
|
||||
# because an upgrade that "succeeded" but rolled back internally
|
||||
# is only diagnosable from the transcript.
|
||||
captureoutput=True,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.flush()
|
||||
|
||||
# Body filename follows the library convention (script-<id>.ps1) so the
|
||||
# collector download route serves it like any other script.
|
||||
filename = 'script-{}.ps1'.format(script.scriptid)
|
||||
path = os.path.join(_script_dir(), filename)
|
||||
|
||||
unchanged = (script.filename == filename and script.sha256 == digest
|
||||
and os.path.isfile(path))
|
||||
if not unchanged:
|
||||
os.makedirs(_script_dir(), exist_ok=True)
|
||||
# Temp-then-replace so an interrupted write cannot leave a truncated
|
||||
# body at the path the row's digest already claims to describe.
|
||||
temppath = path + '.tmp'
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(body)
|
||||
os.replace(temppath, path)
|
||||
script.filename = filename
|
||||
script.sha256 = digest
|
||||
|
||||
script.scriptdescription = _UPGRADE_DESCRIPTION
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scriptid': script.scriptid,
|
||||
'scriptname': script.scriptname,
|
||||
'created': created,
|
||||
'updated': not unchanged,
|
||||
'sha256': digest,
|
||||
'bytes': len(body),
|
||||
'appid': app.appid,
|
||||
}
|
||||
@@ -569,10 +569,11 @@ def panel_status(asset_id: int):
|
||||
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
|
||||
agentvalue = state.agentversion
|
||||
published = agentstore.current_version('agent')
|
||||
if (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published)):
|
||||
agentbehind = (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published))
|
||||
if agentbehind:
|
||||
agentvalue = '{} (behind {})'.format(agentvalue, published)
|
||||
|
||||
fields = [
|
||||
@@ -609,7 +610,17 @@ def panel_status(asset_id: int):
|
||||
'{} monitor(s) expected running but not reported: {}'.format(
|
||||
len(drift), names)))
|
||||
|
||||
return success_response({'fields': fields})
|
||||
# Machine-readable companion to the "(behind X)" text above. The UI needs to
|
||||
# decide whether to offer the upgrade action, and parsing that decision back
|
||||
# out of a display string would tie the button to the wording of a label.
|
||||
# Emitted as a sibling of fields so nothing about the existing rendering
|
||||
# changes; an older UI simply ignores it.
|
||||
return success_response({
|
||||
'fields': fields,
|
||||
'agentbehind': bool(agentbehind),
|
||||
'agentversion': state.agentversion,
|
||||
'agentcurrent': published,
|
||||
})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@@ -2975,6 +2986,90 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/agentupgrade', methods=['POST'])
|
||||
@require_permission('endpoints.agentupdate')
|
||||
def enqueue_agent_upgrade(asset_id: int):
|
||||
"""Push the published agent build to one endpoint.
|
||||
|
||||
Replacing the agent is self-modifying the control plane: the process that
|
||||
receives this command is the one being swapped. It therefore sits behind
|
||||
endpoints.agentupdate rather than endpoints.command or endpoints.deploy -
|
||||
see the permission's rationale in plugin.py.
|
||||
|
||||
This does NOT enqueue directly. It prepares the delivery (packages the
|
||||
current build, seeds/refreshes the upgrade script) and then delegates to
|
||||
enqueue_endpoint_command as a run_script push. Writing a second enqueue
|
||||
path here would duplicate - and eventually drift from - the endpoint lock
|
||||
check, the Configuration Policy gate, stale-command reaping and the
|
||||
at-most-one-in-flight rule. Those are exactly the protections a
|
||||
fleet-wide agent swap must not be the one action to skip.
|
||||
|
||||
Because it lands as run_script, the caller needs endpoints.runscript too
|
||||
(enforced by the delegate as the elevated permission for that action).
|
||||
That is deliberate: this delivers arbitrary SYSTEM PowerShell by the same
|
||||
mechanism as any other script push.
|
||||
"""
|
||||
from . import agentregistry, agentupgradescript
|
||||
|
||||
# Refresh the package and the script body from the CURRENT staged build
|
||||
# before queueing, so an upgrade cannot ship a stale payload if the row was
|
||||
# written before the latest upload. Both operations are idempotent.
|
||||
try:
|
||||
registration = agentregistry.register_current_agent_package()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not package the agent build: {exc}')
|
||||
if registration is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No agent build is published. Upload an agent binary before '
|
||||
'pushing an upgrade.')
|
||||
|
||||
try:
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not prepare the upgrade script: {exc}')
|
||||
if seeded is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'The agent upgrade script could not be prepared.')
|
||||
|
||||
# Rewrite the request body to the equivalent run_script push and hand it to
|
||||
# the real enqueue route. The policyoverride/overridereason fields are
|
||||
# forwarded untouched so break-glass keeps behaving exactly as it does for
|
||||
# a normal script push, including its admin-role and reason requirements.
|
||||
payload = request.get_json(silent=True) or {}
|
||||
forwarded = {
|
||||
'action': 'run_script',
|
||||
'target': 'script:{}'.format(seeded['scriptid']),
|
||||
}
|
||||
if payload.get('policyoverride'):
|
||||
forwarded['policyoverride'] = True
|
||||
forwarded['overridereason'] = payload.get('overridereason') or ''
|
||||
|
||||
# Swap the parsed JSON for the delegate. Flask caches the parsed body on
|
||||
# the request, so setting the cache is what makes get_json() return this.
|
||||
request._cached_json = (forwarded, forwarded)
|
||||
|
||||
response = enqueue_endpoint_command(asset_id)
|
||||
|
||||
# Audit only a push that actually queued. The delegate returns a non-201
|
||||
# for a lock, a policy denial or an in-flight command, and recording those
|
||||
# as upgrades would put events in the log for pushes that never happened.
|
||||
status = getattr(response, 'status_code', None)
|
||||
if status == 201:
|
||||
AuditLog.log(
|
||||
'created', 'AgentUpgrade', entityid=asset_id,
|
||||
entityname='agent {}'.format(registration['agentversion']),
|
||||
details='agent upgrade queued for asset {} (version {}, script {})'
|
||||
.format(asset_id, registration['agentversion'],
|
||||
seeded['scriptid']))
|
||||
db.session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
|
||||
def resolved_monitors_for_agent():
|
||||
"""Agent watch list: the monitors this endpoint should report on.
|
||||
|
||||
332
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
332
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
@@ -0,0 +1,332 @@
|
||||
# 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.
|
||||
|
||||
[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 { }
|
||||
}
|
||||
|
||||
# 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'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
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)'
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
throw 'No BaseUrl/CollectorKey found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
}
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = $apiKey }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
Invoke-WebRequest -Uri $url -Headers @{ 'X-API-Key' = $config.ApiKey } `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
|
||||
$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
|
||||
}
|
||||
Reference in New Issue
Block a user