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:
118
plugins/endpoints/api/agentregistry.py
Normal file
118
plugins/endpoints/api/agentregistry.py
Normal 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'],
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
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