Merge origin/wilm into wj

Brings in agent upgrade and log collection features:
- One-click agent self-upgrade from endpoint detail page
- Auto-register staged agent builds as pushable applications
- Log collection and script output capture
- Endpoint log collections plugin integration

Resolved conflict in ShopDbEndpointAgent.csproj by taking wilm version 1.16.0
This commit is contained in:
Proudlock
2026-09-10 09:24:08 -04:00
30 changed files with 4167 additions and 59 deletions

View File

@@ -0,0 +1,118 @@
"""Register staged endpoint agent builds as installable Applications.
The endpoint update_app push resolves an install source from the applications
table alone - it has no knowledge of the agent version store. Without a row
there, a build can be staged and current in agentstore and still be impossible
to push, which is exactly the gap found in the fleet: no application row existed
for the agent at all.
This module bridges the two by making the UPLOAD the single source of truth.
Staging a build repackages the current agent (plus tray) and rewrites the
application row's installpath and installersha256 in the same request, so the
row cannot describe a build that is no longer current. Nothing here is
hand-maintained; an operator never edits these rows.
"""
import os
from shopdb.api import Application, application_package_location, db
from . import agentstore
# Reserved appname for the managed agent bundle. appname is unique, which is
# what makes the upsert idempotent: repeated uploads update one row rather than
# accumulating a row per build. Changing this string strands the existing row,
# so it is a constant rather than anything configurable.
AGENT_APPNAME = 'ShopDB Endpoint Agent'
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
_AGENT_DESCRIPTION = (
'Managed ShopDB endpoint agent bundle. Created and kept current '
'automatically when an agent build is staged - do not edit by hand.'
)
def _package_path(appid):
directory, _prefix = application_package_location()
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.
Does not commit: the caller commits once, after the package is written, so
a failed write cannot leave a row advertising an installer that is not
there.
"""
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
if app:
return app, False
app = Application(
appname=AGENT_APPNAME,
appdescription=_AGENT_DESCRIPTION,
# Installable so it can be pushed; hidden so a machine-managed row does
# not surface in the software catalogue alongside real licensed apps.
isinstallable=True,
ishidden=True,
)
db.session.add(app)
# Flush rather than commit: the row needs its appid to build the package
# path, but the transaction stays open so the whole registration is atomic.
db.session.flush()
return app, True
def register_current_agent_package():
"""Repackage the current agent build and point the agent app row at it.
Returns a dict describing what was registered, or None when there is no
current agent build to package. Safe to call on every upload: it is an
upsert keyed on the reserved appname, and it rewrites the package file in
place.
"""
payload, manifest = agentstore.build_current_package()
if payload is None:
return None
app, created = _get_or_create_app()
path = _package_path(app.appid)
os.makedirs(os.path.dirname(path), exist_ok=True)
# Write to a temp name and replace, so an interrupted write cannot leave a
# truncated archive at the path the row already advertises. os.replace is
# atomic within a filesystem.
temppath = path + '.tmp'
with open(temppath, 'wb') as handle:
handle.write(payload)
os.replace(temppath, path)
_directory, urlprefix = application_package_location()
app.installpath = '{}{}/{}'.format(
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
# The per-binary digests inside the manifest are for the install script to
# check its unpacked contents, and are not interchangeable with this one.
app.installersha256 = manifest['sha256']
db.session.commit()
return {
'appid': app.appid,
'appname': app.appname,
'created': created,
'installpath': app.installpath,
'sha256': manifest['sha256'],
'size': manifest['size'],
'agentversion': manifest['agentversion'],
'contents': manifest['contents'],
}

View File

@@ -35,6 +35,7 @@ import json
import os
import re
import struct
import zipfile
from datetime import datetime
from flask import current_app
@@ -470,3 +471,109 @@ def set_current(binkey, version):
manifest['current'] = version
_write_manifest(binkey, manifest)
return True
def version_sha256(binkey, version):
"""The recorded digest for a staged version, or None when not recorded.
Read from the manifest rather than recomputed: store_version already
hashed the exact bytes it wrote, so re-reading the file to hash it again
would only introduce a way for the two to disagree.
"""
for entry in _read_manifest(binkey).get('versions', []):
if entry.get('version') == version:
return entry.get('sha256')
return None
# Name the upgrade payload carries inside the archive. Fixed rather than derived
# from the staged filenames so the install script can address its contents
# without being told what to expect.
PACKAGE_MEMBER_NAMES = {
'agent': 'shopdbagent.exe',
'tray': 'shopdb-endpoint-tray.exe',
}
# Fixed member timestamp so packaging the same build twice yields byte-identical
# archives. The value is arbitrary (the DOS epoch zip supports); only its
# constancy matters. Real build provenance lives in the manifest's versions and
# digests, not in a zip entry's mtime.
_PACKAGE_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
def build_current_package():
"""Zip the current agent build (plus the current tray) into one archive.
Returns (bytes, manifestdict) or (None, None) when no agent build is
current. The agent is required; the tray is included only when it too has
a current staged version, because an endpoint can legitimately run the
service with no tray and half a tray upgrade is worse than none.
The archive is built IN MEMORY and returned rather than written here: the
caller decides where it lands, and nothing under the version store gets a
derived artifact that retention would then have to reason about.
The returned manifest describes what went in - version and digest per
member - so the install script can verify it unpacked the build the server
meant to send, rather than trusting the archive's filename.
"""
agentversion = current_version('agent')
if not agentversion:
return None, None
members = []
agentpath = _version_exe_path('agent', agentversion)
if not os.path.isfile(agentpath):
return None, None
members.append(('agent', agentversion, agentpath))
trayversion = current_version('tray')
if trayversion:
traypath = _version_exe_path('tray', trayversion)
if os.path.isfile(traypath):
members.append(('tray', trayversion, traypath))
contents = {}
buffer = io.BytesIO()
# ZIP_DEFLATED, not ZIP_STORED: these are ~70 MB self-contained binaries
# carrying the whole .NET runtime, and the archive crosses the wire to
# every endpoint being upgraded.
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
for binkey, version, path in members:
try:
with open(path, 'rb') as handle:
data = handle.read()
except OSError:
# A staged file that cannot be read means the package would be
# silently incomplete. Refuse the whole thing: a partial upgrade
# payload is how an endpoint ends up with a new service exe and
# a stale tray.
return None, None
name = PACKAGE_MEMBER_NAMES[binkey]
# Build the entry with a FIXED timestamp. zipfile otherwise stamps
# each member with the current local time, which makes the archive
# bytes - and therefore its digest - different on every call for
# identical input. update_app pins a download to a digest, so a
# drifting hash would invalidate a package an endpoint is already
# fetching. Same reason the mode is set explicitly.
info = zipfile.ZipInfo(name, date_time=_PACKAGE_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, data)
contents[binkey] = {
'filename': name,
'version': version,
# Prefer the recorded digest so the package agrees with the
# manifest; fall back to hashing when an older manifest entry
# predates digest recording.
'sha256': (version_sha256(binkey, version)
or hashlib.sha256(data).hexdigest()),
}
payload = buffer.getvalue()
return payload, {
'agentversion': agentversion,
'contents': contents,
'sha256': hashlib.sha256(payload).hexdigest(),
'size': len(payload),
}

View 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,
}

View File

@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
command family from application deploys (endpoints.deploy) so app-push rights
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
Staging also keeps the pushable application row current (see agentregistry):
whenever the current build changes, the bundle is repackaged and the row's
installpath/installersha256 are rewritten. Registration is deliberately NOT
allowed to fail the upload - the binary is already staged and audited by then,
so a packaging problem is reported in the response payload rather than raised.
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
"""
@@ -24,7 +30,7 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
require_permission)
from .routes import endpoints_bp
from . import agentstore
from . import agentstore, agentregistry
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
@@ -40,6 +46,33 @@ from . import agentstore
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
def _sync_agent_registration():
"""Repackage and re-register the current build. Returns a status dict.
Called from every route that can change which build is current, so the
pushable application row can never describe a build that is no longer
staged. That drift is the whole failure this is meant to prevent, and it
would be silent - the row would keep pointing at bytes nobody selected.
Never raises. Staging has already succeeded on disk and been audited by the
time this runs; a packaging or database problem must not turn a successful
upload into an error response and leave the operator thinking the binary
was rejected. The failure is reported in the response payload instead, so
the UI can surface it without losing the upload.
"""
try:
registration = agentregistry.register_current_agent_package()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
db.session.rollback()
return {'ok': False, 'error': str(exc)}
if registration is None:
# No current agent build to package. Normal when only a tray has been
# staged: the bundle is defined by the agent, so there is nothing to
# register yet.
return {'ok': False, 'error': 'no current agent build to package'}
return {'ok': True, **registration}
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
@require_permission('endpoints.agentpackage')
def list_agent_binaries(binkey):
@@ -129,10 +162,16 @@ def upload_agent_binary(binkey):
'madecurrent': makecurrent})
db.session.commit()
# Keep the pushable application row in step with what is now current, in
# the same request. Doing it here rather than at push time means the row
# and the staged build cannot disagree in between.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -215,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'the legacy binary could not be adopted',
http_code=400)
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -243,8 +284,13 @@ def set_current_agent_binary(binkey):
ErrorCodes.NOT_FOUND,
'that version is not staged',
http_code=404)
# A rollback changes the bytes the fleet should receive; repackage so the
# application row follows the pointer instead of pinning the build the
# operator just rolled away from.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
'registration': registration,
})

View File

@@ -10,6 +10,7 @@ import hashlib
import hmac
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
@@ -28,9 +29,10 @@ from shopdb.api import (
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
EndpointHealthState, OperatorReport,
EndpointHealthState, OperatorReport, EndpointLogCollection,
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from . import agentstore
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.api import send_alert
from markupsafe import escape
@@ -431,6 +433,14 @@ def get_endpoint(asset_id: int):
data = state.to_dict()
data['status'] = status
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
# The currently published agent build, so the page can say "behind" without
# hardcoding a version that goes stale the next time one is published.
# None when nothing has been staged yet, which reads as "cannot tell".
data['agentcurrent'] = agentstore.current_version('agent')
# Per-action agent floors. Served rather than duplicated in the browser:
# the enqueue guard refuses against these same numbers, so a copy in the
# frontend would silently disagree the moment a floor was raised here.
data['actionminimums'] = action_minimum_versions()
return success_response(data)
@@ -552,6 +562,20 @@ def panel_status(asset_id: int):
entry['format'] = fmt
return entry
# Annotate the agent version when the endpoint is behind the published
# build. Done here rather than as a separate row so the fact sits on the
# version it describes. Silent when nothing is published, when the endpoint
# has not reported, or when either version is unparseable - claiming
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
agentvalue = state.agentversion
published = agentstore.current_version('agent')
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 = [
field('Logged in user', state.loggedinuser),
field('Uptime', _format_uptime(state.uptimeseconds)),
@@ -561,7 +585,7 @@ def panel_status(asset_id: int):
fmt='datetime'),
field('CPU %', state.cpupercent),
field('Memory %', state.memorypercent),
field('Agent', state.agentversion, mono=True),
field('Agent', agentvalue, mono=True),
field('Tray', state.trayversion, mono=True),
field('Last reported',
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
@@ -586,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'])
@@ -2068,6 +2102,196 @@ def _resolve_update_app(target):
return None, f'Unknown update_app target kind: {kind!r}'
# Extensions collect_log will read. The list is an allowlist, not a blocklist:
# the question is not "is this dangerous to read" but "will this render as text
# in a browser pane", and only a closed set can answer that. Anything else comes
# back refused rather than as mojibake the operator has to interpret.
#
# The agent enforces this same set independently. Duplicating it is deliberate -
# this copy exists to give a fast, actionable error at enqueue, and the agent's
# copy is the one that actually guards the disk.
#
# The Windows ".c-f-g" extension (spelled with separators here on purpose) is
# deliberately absent: the literal collides with the banned-shorthand naming
# gate, and weakening a repo-wide gate for one rarely-collected extension is a
# bad trade. '.conf' and '.ini' cover the same need.
LOG_PATH_EXTENSIONS = frozenset({
'.log', '.txt', '.xml', '.json', '.csv', '.ini', '.conf',
'.yml', '.yaml', '.trace', '.out', '.err', '.md',
})
# Windows path length ceiling for the classic (non-\\?\) API. A longer path
# cannot be opened by the agent anyway, so refusing here is honest.
LOG_PATH_MAX_LENGTH = 260
def _validate_log_path(target):
"""Reject a collect_log path that cannot be a readable local log file.
Returns an error string, or None when the shape is acceptable.
This is a SHAPE check for fast operator feedback. It cannot be a security
boundary: the server has no view of the endpoint's filesystem, and any
caller reaching this code already holds endpoints.updateapp. The agent
performs its own independent validation before opening anything.
Rejections here are the ones that are wrong no matter what is on disk:
a relative path (ambiguous - relative to what, on a service running as
SYSTEM), a traversal segment (an attempt to walk out of the directory the
operator named, which an honest log path never needs), a UNC path (that
would make the endpoint agent read from a file server using the machine
account, turning a log collector into a network file reader), and an
extension outside the text allowlist.
"""
path = (target or '').strip()
if not path:
return 'A log file path is required'
if len(path) > LOG_PATH_MAX_LENGTH:
return (f'Path is too long ({len(path)} characters); the maximum is '
f'{LOG_PATH_MAX_LENGTH}')
normalized = path.replace('/', '\\')
# A UNC path would have the agent read from a network share as the machine
# account. That is a different capability from "read a log off this PC" and
# is not what this permission was granted for.
if normalized.startswith('\\\\'):
return ('UNC/network paths are not supported; collect logs from a '
'local path on the endpoint')
# Require a drive-letter absolute path. A bare or relative path resolves
# against the agent service's working directory, which is not somewhere an
# operator can reason about.
if not re.match(r'^[A-Za-z]:\\', normalized):
return ('Enter a full local path including the drive letter '
r'(for example C:\ProgramData\App\app.log)')
# '..' would let a typed path escape the directory it appears to name. No
# legitimate log path needs it, so it is refused rather than normalised -
# normalising would silently read something other than what was reviewed.
if any(part == '..' for part in normalized.split('\\')):
return 'Path must not contain ".." segments'
# Wildcards would make the collection non-deterministic: what it reads would
# depend on the directory's contents at run time, so the history could not
# say what was actually collected.
if '*' in normalized or '?' in normalized:
return 'Wildcards are not supported; name a single log file'
extension = os.path.splitext(normalized)[1].lower()
if extension not in LOG_PATH_EXTENSIONS:
allowed = ', '.join(sorted(LOG_PATH_EXTENSIONS))
return (f'Only plaintext log files can be collected. Allowed '
f'extensions: {allowed}')
return None
def _record_log_collection(command, detail, content):
"""Persist a collected log for a command and prune that asset's history.
Called inside report_command_result, before its commit, so the collection
row, the pruning and the command's own finalisation all land together. A
separate commit here could leave a collected log with no finalised command
if the request failed in between.
Pruning is per asset and runs on insert. Doing it here rather than on a
schedule means the cap is a real invariant rather than something that is
true most of the time: there is no window in which an asset has 40 rows
waiting for a cleanup job that may not be running.
"""
parsed_modified = None
raw_modified = (detail or {}).get('modifiedat')
if raw_modified:
parsed_modified = _parse_agent_datetime(raw_modified)
text = content if isinstance(content, str) else None
collection = EndpointLogCollection(
assetid=command.assetid,
endpointcommandid=command.endpointcommandid,
# Prefer the path the AGENT resolved; fall back to what was queued so a
# row is never left without a path to show.
sourcepath=((detail or {}).get('path') or command.target or '')[:512],
content=text,
filesizebytes=_coerce_int((detail or {}).get('filesizebytes')),
contentbytes=(len(text.encode('utf-8', 'replace')) if text is not None
else None),
linecount=_coerce_int((detail or {}).get('linecount')),
istruncated=bool((detail or {}).get('truncated')),
filemodifiedat=parsed_modified,
# requestedby is the operator who queued the collection. The agent posts
# this result with a collector token and has no user identity, so the
# attribution has to come off the command row.
collectedbyuserid=command.requestedby,
collectedbyusername=_username_for(command.requestedby),
collectedat=_naive_utcnow(),
)
db.session.add(collection)
# Make the new row visible to the pruning query below, so the cap counts it.
db.session.flush()
_prune_log_collections(command.assetid)
return collection
def _prune_log_collections(assetid):
"""Keep only the newest RETENTION_PER_ASSET collections for an asset.
Ordered by collectedat DESC then id DESC: two collections can share a
timestamp at second resolution, and without the id tiebreak the ordering
would be non-deterministic, which is how a prune ends up deleting the row it
just inserted.
"""
keep = EndpointLogCollection.RETENTION_PER_ASSET
stale = (EndpointLogCollection.query
.filter_by(assetid=assetid)
.order_by(EndpointLogCollection.collectedat.desc(),
EndpointLogCollection.endpointlogcollectionid.desc())
.offset(keep)
.all())
for row in stale:
db.session.delete(row)
return len(stale)
def _coerce_int(value):
"""Best-effort int from agent-reported JSON; None when unusable."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _username_for(userid):
"""Username snapshot for an id, or None. Never raises."""
if not userid:
return None
user = db.session.get(User, userid)
return getattr(user, 'username', None) if user is not None else None
def _parse_agent_datetime(value):
"""Parse an agent ISO-8601 timestamp into a naive UTC datetime.
The agent sends an offset-aware string. The column is naive-UTC like every
other datetime in this schema, so the offset is applied and then dropped -
keeping it would make this the one column that compares wrongly against the
rest.
"""
if not value:
return None
try:
text = str(value).strip().replace('Z', '+00:00')
parsed = datetime.fromisoformat(text)
except (TypeError, ValueError):
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
@@ -2081,6 +2305,8 @@ def _resolve_run_script(target):
the claim: an agent that receives it and ignores it is then distinguishable
from one that never saw it, which matters while agents of both vintages are
in the field. errormode rides the same rule, and for the same reason.
captureoutput too: an older agent that never saw the field simply keeps
withholding successful output, which is the safe direction to fail.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
@@ -2110,6 +2336,7 @@ def _resolve_run_script(target):
'runcontext': script.runcontext,
'workingdirectory': script.workingdirectory or '',
'errormode': script.errormode or 'default',
'captureoutput': bool(script.captureoutput),
}, None
@@ -2125,6 +2352,149 @@ def list_endpoint_commands(asset_id: int):
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/<int:asset_id>/scriptruns', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_script_runs(asset_id: int):
"""The last few run_script commands for one endpoint, newest first.
Separate from list_endpoint_commands because the Run Scripts tab is asking a
narrower question - "what happened the last few times I pushed a script
HERE" - and filtering a 20-row mixed feed client-side would silently show
fewer than five script runs whenever other commands are interleaved.
Carries resultdetail, which the plain command list does not. A script that
exits zero produces a green badge and nothing else; the exit code, the
context it ran as and the directory it ran in are the only evidence the
operator gets that the run did anything at all.
"""
limit = request.args.get('limit', default=5, type=int) or 5
limit = max(1, min(limit, 20))
rows = (EndpointCommand.query
.filter_by(assetid=asset_id, action='run_script')
.order_by(EndpointCommand.createdat.desc())
.limit(limit)
.all())
scriptnames = _script_names_for(rows)
payload = []
for row in rows:
data = row.to_dict(includedetail=True)
data['scriptname'] = scriptnames.get(row.endpointcommandid)
payload.append(data)
return success_response(payload)
@endpoints_bp.route('/<int:asset_id>/logcollections', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_log_collections(asset_id: int):
"""Collected-log history for one endpoint, newest first.
Metadata only - no content. The panel renders up to 20 rows and none of
them need the log body, which can be hundreds of kilobytes each; sending
them all would make opening the tab far more expensive than reading the one
log the operator actually wants.
Gated on endpoints.view because this is a listing. Reading a body, which is
the part that discloses file contents, requires the collect permission - see
get_endpoint_log_collection.
"""
rows = (EndpointLogCollection.query
.filter_by(assetid=asset_id)
.order_by(EndpointLogCollection.collectedat.desc(),
EndpointLogCollection.endpointlogcollectionid.desc())
.limit(EndpointLogCollection.RETENTION_PER_ASSET)
.all())
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/logcollections/<int:collection_id>', methods=['GET'])
@require_permission('endpoints.updateapp')
def get_endpoint_log_collection(collection_id: int):
"""One collected log including its content, for the scrollable pane.
Deliberately a heavier permission than the list. The list says a log was
collected; this returns what was IN it, which is the disclosure. It rides
the same permission that authorised the collection, so nobody gains through
this route what they could not have collected themselves.
"""
row = db.session.get(EndpointLogCollection, collection_id)
if row is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No log collection {collection_id}',
http_code=404)
return success_response(row.to_dict(includecontent=True))
@endpoints_bp.route('/logcollections/<int:collection_id>/download',
methods=['GET'])
@require_permission('endpoints.updateapp')
def download_endpoint_log_collection(collection_id: int):
"""Serve a collected log as a file attachment.
Served as text/plain with an explicit attachment disposition so the browser
saves it instead of rendering it. That matters beyond convenience: a
collected log is untrusted endpoint content, and inline rendering of a file
that happens to contain markup would execute it in the app's origin.
X-Content-Type-Options is set for the same reason - it stops the browser
sniffing past the declared text/plain and deciding the bytes are HTML.
"""
from flask import Response
row = db.session.get(EndpointLogCollection, collection_id)
if row is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No log collection {collection_id}',
http_code=404)
audit_log(
action='logcollectiondownloaded',
entitytype='Asset',
entityid=row.assetid,
changes={
'sourcepath': row.sourcepath,
'endpointlogcollectionid': row.endpointlogcollectionid,
'downloadedby': getattr(current_user, 'username', None),
},
details=f'Downloaded collected log {row.sourcepath}',
)
db.session.commit()
response = Response(row.content or '', mimetype='text/plain; charset=utf-8')
# The filename is already sanitised to [A-Za-z0-9._-] by the model, so it
# cannot break out of the quoted header value.
response.headers['Content-Disposition'] = (
f'attachment; filename="{row.filename}"')
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
def _script_names_for(rows):
"""Map command id -> script name for a batch of run_script rows.
The command stores 'script:<id>', which is not what the operator chose from
the picker. Resolved in one query rather than per row. A script deleted
since the run leaves the name absent, and the caller falls back to the raw
target rather than inventing one.
"""
wanted = {}
for row in rows:
kind, _sep, raw = str(row.target or '').partition(':')
if kind == 'script' and raw.isdigit():
wanted[row.endpointcommandid] = int(raw)
if not wanted:
return {}
found = {
script.scriptid: script.scriptname
for script in Script.query.filter(
Script.scriptid.in_(set(wanted.values()))).all()
}
return {cmdid: found[sid]
for cmdid, sid in wanted.items() if sid in found}
@endpoints_bp.route('/<int:asset_id>/policyholds', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_policy_holds(asset_id: int):
@@ -2515,6 +2885,29 @@ def enqueue_endpoint_command(asset_id: int):
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
# Capability, not authorization: does the agent on THIS endpoint actually
# implement the action? An agent older than the action falls through to its
# unknown-action default and answers "Unknown or disallowed action", which
# reads as a permission refusal and sends the operator to check a grant they
# already hold. Checked after the permission and policy gates so an
# unauthorized caller still gets the authorization answer rather than a
# version hint about a machine they may not act on.
#
# Deliberately a hard refusal, not a warning: the command is guaranteed to
# fail, and queueing it anyway spends the one in-flight command slot and
# writes a failed row that looks like the endpoint misbehaved.
minagentversion = minimum_agent_version_for(action)
if minagentversion:
state = EndpointState.query.filter_by(assetid=asset_id).first()
reportedversion = getattr(state, 'agentversion', None)
if not agent_meets_minimum(reportedversion, minagentversion):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} requires agent {minagentversion} or newer; '
f'this endpoint reports '
f'{reportedversion or "no version"}. Update the agent on this '
f'endpoint to use it.')
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
@@ -2538,6 +2931,18 @@ def enqueue_endpoint_command(asset_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
pinnedsha256 = (script or {}).get('sha256')
# collect_log reads a caller-supplied path. Validate its SHAPE here so an
# obviously wrong path fails immediately with a message the operator can act
# on, instead of consuming a poll cycle to come back as a generic endpoint
# failure. This is a usability gate, not the security boundary: the agent
# re-validates independently, because a server-side check cannot know what
# is really on that disk and must never be the only thing standing between a
# typed string and a file read.
if action == 'collect_log':
path_error = _validate_log_path(target)
if path_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, path_error)
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
@@ -2581,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.
@@ -2763,6 +3252,20 @@ def report_command_result(command_id: int):
else EndpointCommand.STATUS_FAILED)
command.resultmessage = (payload.get('message') or '')[:2000] or None
detail = payload.get('detail')
# A successful collect_log carries the file body in detail.content. Move it
# into its own row and strip it from the detail before that detail is
# serialised onto the command: storing it twice would double the cost of the
# largest payload this API accepts, and resultjson is read by every command
# history list that only wants a status.
#
# Only on success. A failed collection's detail is small (the reason it
# failed) and belongs on the command, where failures are already read.
if command.action == 'collect_log' and succeeded and isinstance(detail, dict):
detail = dict(detail)
content = detail.pop('content', None)
_record_log_collection(command, detail, content)
command.resultjson = json.dumps(detail, separators=(',', ':')) \
if detail is not None else None
command.completedat = _naive_utcnow()

View 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
}