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
|
||||
}
|
||||
@@ -110,6 +110,32 @@ COMMAND_ALLOWLIST = {
|
||||
'PCDetail.vue).',
|
||||
'targetkind': 'script',
|
||||
},
|
||||
'collect_log': {
|
||||
'label': 'Collect log file',
|
||||
'description': 'Read a plaintext log file from a known path on the '
|
||||
'endpoint and return its contents to ShopDB for '
|
||||
'viewing and download. Read-only: the agent opens the '
|
||||
'file for reading with sharing permitted (so a log a '
|
||||
'running application holds open can still be read), '
|
||||
'never writes, and never deletes. Only the TAIL of a '
|
||||
'file is returned, and only for plaintext extensions - '
|
||||
'a binary or oversized file is refused rather than '
|
||||
'mangled. Gated on endpoints.updateapp because the '
|
||||
'reason to collect a log is almost always to diagnose '
|
||||
'an application push, so it belongs with app lifecycle '
|
||||
'rights rather than as a separate grant. Note this '
|
||||
'still reads a caller-supplied path as SYSTEM: it is '
|
||||
'bounded to text files, but it is not a sandbox.',
|
||||
'targetkind': 'logpath',
|
||||
# Introduced in agent 1.16.0. An older agent has no case for this action
|
||||
# and falls through to its unknown-action default, which answers
|
||||
# "Unknown or disallowed action" - a message that reads as an
|
||||
# authorization refusal and sends the operator looking at permissions
|
||||
# they already hold. Declaring the floor here lets the enqueue route
|
||||
# refuse first, naming both versions, so the answer is the actionable
|
||||
# one: this endpoint needs a newer agent.
|
||||
'minagentversion': '1.16.0',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||
@@ -192,11 +218,18 @@ COMMAND_ALLOWLIST = {
|
||||
# update_app - runs a pinned installer
|
||||
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
||||
# user context
|
||||
#
|
||||
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
|
||||
# The reason to read a log is nearly always to find out why an app push behaved
|
||||
# the way it did, so the right to install an application and the right to read
|
||||
# that application's log are the same job. It is read-only and strictly weaker
|
||||
# than the installer push the same permission already allows.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
'scheduledtask_run': 'endpoints.corptools',
|
||||
'run_script': 'endpoints.runscript',
|
||||
'update_app': 'endpoints.updateapp',
|
||||
'collect_log': 'endpoints.updateapp',
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +238,70 @@ def elevated_permission_for(action):
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
def minimum_agent_version_for(action):
|
||||
"""The lowest agent version that implements an action, or None if any will do."""
|
||||
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
|
||||
|
||||
|
||||
def action_minimum_versions():
|
||||
"""Every action that has an agent version floor, as {action: version}.
|
||||
|
||||
Actions without a floor are omitted rather than mapped to None: a caller
|
||||
asking "is there a minimum for this?" gets a plain absent key, and the
|
||||
payload stays small as the allowlist grows.
|
||||
"""
|
||||
return {
|
||||
action: spec['minagentversion']
|
||||
for action, spec in COMMAND_ALLOWLIST.items()
|
||||
if spec.get('minagentversion')
|
||||
}
|
||||
|
||||
|
||||
def parse_agent_version(value):
|
||||
"""A reported agent version as a comparable tuple, or None if unusable.
|
||||
|
||||
Agents report four parts ('1.15.0.0') while the csproj and the binary store
|
||||
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
|
||||
the floor it satisfies. Both are normalised to a tuple and compared
|
||||
numerically, with missing trailing parts treated as zero, so '1.16.0' and
|
||||
'1.16.0.0' are the same version.
|
||||
|
||||
Anything non-numeric returns None rather than a guess. The fleet carries
|
||||
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
|
||||
placeholder and a real build - the caller decides what to do about an
|
||||
unknown version, which is a different question from being out of date.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
parts = []
|
||||
for chunk in str(value).strip().split('.'):
|
||||
if not chunk.isdigit():
|
||||
return None
|
||||
parts.append(int(chunk))
|
||||
if not parts:
|
||||
return None
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts[:4])
|
||||
|
||||
|
||||
def agent_meets_minimum(reported, minimum):
|
||||
"""Whether a reported agent version satisfies a minimum.
|
||||
|
||||
Returns False when either side is unparseable. That is the fail-closed
|
||||
direction: an endpoint whose version we cannot read is one we cannot claim
|
||||
supports the action, and letting it through only moves the failure to the
|
||||
agent where the error message is worse.
|
||||
"""
|
||||
if not minimum:
|
||||
return True
|
||||
reportedparts = parse_agent_version(reported)
|
||||
minimumparts = parse_agent_version(minimum)
|
||||
if reportedparts is None or minimumparts is None:
|
||||
return False
|
||||
return reportedparts >= minimumparts
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
@@ -339,6 +436,19 @@ POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
# policy is non-empty - a denial no operator can act on, because the fix
|
||||
# lives in a policy that was never about this action.
|
||||
'collect_now',
|
||||
# Read-only diagnostic retrieval. Policy describes how a machine should be
|
||||
# shaped - which monitors run, which service primitives are reachable - and
|
||||
# reading a log file shapes nothing. An existing policy was authored before
|
||||
# this action existed and so can never mention it, which under the
|
||||
# intersection means every endpoint carrying any policy at all refuses log
|
||||
# collection, including for an admin. That denial is also unactionable in
|
||||
# the way that matters: logs are what an operator reaches for when an
|
||||
# endpoint is already misbehaving, so gating them behind editing that same
|
||||
# endpoint's policy withholds the evidence exactly when it is needed.
|
||||
# Exemption from POLICY is not exemption from PERMISSION - collect_log
|
||||
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
|
||||
# the agent still refuses UNC, traversal, directories and non-text files.
|
||||
'collect_log',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for the staged agent rollout. Shown only to operators who
|
||||
hold the grant it will require, so it reads as "not built yet" to the
|
||||
people waiting on it rather than as a missing permission to everyone
|
||||
else. This page is due a refactor; the utility lands with that work. -->
|
||||
<div v-if="canagentupdate" class="rollout-note">
|
||||
Bulk Agent Update utility coming soon. Until then, agents update through
|
||||
the existing installer push, and an endpoint behind the published build is
|
||||
flagged on its own detail page.
|
||||
</div>
|
||||
|
||||
<!-- Actions live here rather than per row. A tenth column of buttons
|
||||
pushed itself off the edge of a nowrap table, and selecting one row
|
||||
costs a single click while giving one command path instead of two. -->
|
||||
@@ -194,6 +204,9 @@ const toast = useToast()
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
// Reserved for the staged rollout. Agent replacement is the control plane
|
||||
// modifying itself, so it stays separate from ordinary app deployment.
|
||||
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
|
||||
|
||||
const allselected = computed(() =>
|
||||
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
|
||||
@@ -515,6 +528,18 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
||||
|
||||
/* Deliberately quieter than .bulk-bar: this announces something that does not
|
||||
exist yet, so it must not compete with controls that actually do work. */
|
||||
.rollout-note {
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.select-cell { width: 1%; }
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
<span
|
||||
v-if="s.captureoutput"
|
||||
class="badge badge-secondary"
|
||||
title="A successful run returns its output to ShopDB"
|
||||
>
|
||||
Returns output
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
@@ -213,6 +220,27 @@
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.captureoutput" type="checkbox" />
|
||||
Return output to ShopDB on success
|
||||
</label>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
A failed run always sends its output back, so you can diagnose
|
||||
it from the Run Scripts history without going to the machine.
|
||||
A successful run does not, unless you tick this.
|
||||
</p>
|
||||
<p>
|
||||
Leave it off for scripts whose output could contain anything
|
||||
sensitive - account names, share paths, anything a Write-Host
|
||||
left behind. Turn it on for scripts where seeing the output is
|
||||
the only way to tell the run did real work. Either way the
|
||||
full output is written to the agent log on the endpoint.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
@@ -259,6 +287,7 @@ function emptyForm() {
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
captureoutput: false,
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
@@ -293,6 +322,7 @@ function openModal(script = null) {
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
captureoutput: !!script.captureoutput,
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
@@ -314,6 +344,7 @@ async function save() {
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
captureoutput: form.value.captureoutput,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointLock,
|
||||
EndpointLock, EndpointLogCollection,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
@@ -12,7 +12,7 @@ from .health import (
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointLock',
|
||||
'EndpointLock', 'EndpointLogCollection',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||
]
|
||||
|
||||
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
EndpointLogCollection
|
||||
A plaintext log file collected from an endpoint on operator
|
||||
demand. Kept out of EndpointCommand.resultjson because it is
|
||||
bulky, needs its own per-asset retention cap, and has to be
|
||||
addressable by id for the download route.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
@@ -275,8 +285,16 @@ class EndpointCommand(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, includedetail=False):
|
||||
"""Serialise a command for the operator views.
|
||||
|
||||
resultjson is opt-in. It is agent-reported structured detail, and for a
|
||||
script it carries the evidence that a green badge alone does not: the
|
||||
exit code, the context it ran as and the directory it really ran in. On
|
||||
failure the agent also puts the captured output in there, so it is not
|
||||
served to every list that only needs a status.
|
||||
"""
|
||||
data = {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
@@ -289,6 +307,24 @@ class EndpointCommand(db.Model):
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
if includedetail:
|
||||
data['resultdetail'] = self._resultdetail()
|
||||
return data
|
||||
|
||||
def _resultdetail(self):
|
||||
"""resultjson as a dict, or None.
|
||||
|
||||
Stored as text, so a malformed or truncated payload is possible. A
|
||||
detail panel is diagnostics: failing to parse it must not break the
|
||||
command list that surrounds it.
|
||||
"""
|
||||
if not self.resultjson:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(self.resultjson)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
@@ -531,3 +567,130 @@ class EndpointLock(db.Model):
|
||||
'releasedat': _utciso(self.releasedat),
|
||||
'releasedbyusername': self.releasedbyusername,
|
||||
}
|
||||
|
||||
|
||||
class EndpointLogCollection(db.Model):
|
||||
"""One plaintext log file collected from an endpoint by an operator.
|
||||
|
||||
Collected content lives here rather than in EndpointCommand.resultjson for
|
||||
three reasons. It is bulky and would be dragged along by every command
|
||||
history list that only wants a status; it needs its own retention rule
|
||||
(RETENTION_PER_ASSET), which a command row cannot express without deleting
|
||||
the audit record of the command itself; and the download route needs to
|
||||
serve bytes, which means it needs a row it can address by id rather than a
|
||||
JSON field it has to re-parse.
|
||||
|
||||
Retention is deliberately PER ASSET. A global cap would let one busy machine
|
||||
evict another machine's history, and the history is read per machine on the
|
||||
PC detail page, so a global cap would make the panel unreliable exactly
|
||||
where it is used. Pruning happens on insert in the result route.
|
||||
|
||||
The content stored here is a TAIL, not necessarily the whole file - see
|
||||
istruncated. A log that has run for months is not something to load into a
|
||||
browser pane, and the agent bounds what it sends.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlogcollections'
|
||||
|
||||
# How many collections to keep per asset. The operator asked for 20: enough
|
||||
# to compare a handful of runs against each other, few enough that stored
|
||||
# log text does not grow without bound across a fleet.
|
||||
RETENTION_PER_ASSET = 20
|
||||
|
||||
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The command that produced this, kept for audit: it ties the collected
|
||||
# bytes back to who queued the collection and when it ran. SET NULL rather
|
||||
# than CASCADE so pruning or expiring a command never silently destroys a
|
||||
# log an operator is still looking at.
|
||||
endpointcommandid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# The path as the AGENT resolved it, which is not always the path the
|
||||
# operator typed (environment variables get expanded endpoint-side). Storing
|
||||
# the resolved one is what makes the history honest about what was read.
|
||||
sourcepath = db.Column(db.String(512), nullable=False)
|
||||
|
||||
content = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Size of the file ON DISK, not the size of content. When the tail is
|
||||
# truncated these differ, and that difference is the whole point: it tells
|
||||
# the operator they are looking at the end of something larger.
|
||||
filesizebytes = db.Column(db.BigInteger, nullable=True)
|
||||
contentbytes = db.Column(db.Integer, nullable=True)
|
||||
linecount = db.Column(db.Integer, nullable=True)
|
||||
istruncated = db.Column(db.Boolean, nullable=False,
|
||||
default=False, server_default=sa.false())
|
||||
|
||||
# Last-write time reported by the endpoint. A log that has not changed since
|
||||
# the previous collection is the fastest way to see an app is not logging.
|
||||
filemodifiedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Username snapshot alongside the id: the id alone becomes unreadable if the
|
||||
# user is later removed, and this history is read long after collection.
|
||||
collectedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
collectedbyusername = db.Column(db.String(100), nullable=True)
|
||||
collectedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding the oldest rows to prune on insert.
|
||||
db.Index('idx_endpointlogcollections_assetcollected',
|
||||
'assetid', 'collectedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointLogCollection asset={self.assetid} "
|
||||
f"{self.sourcepath}>")
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""A download filename derived from the collected path."""
|
||||
raw = (self.sourcepath or '').replace('/', '\\')
|
||||
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
|
||||
# Keep it to characters that survive a Content-Disposition header and a
|
||||
# Windows save dialog without quoting games.
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
|
||||
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
|
||||
return f'{stamp}_{safe}'
|
||||
|
||||
def to_dict(self, includecontent=False):
|
||||
"""Serialise a collection.
|
||||
|
||||
Content is opt-in for the same reason resultjson is on EndpointCommand:
|
||||
the history list renders 20 rows and none of them need the log body,
|
||||
which may be hundreds of kilobytes each.
|
||||
"""
|
||||
data = {
|
||||
'endpointlogcollectionid': self.endpointlogcollectionid,
|
||||
'assetid': self.assetid,
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'sourcepath': self.sourcepath,
|
||||
'filesizebytes': self.filesizebytes,
|
||||
'contentbytes': self.contentbytes,
|
||||
'linecount': self.linecount,
|
||||
'istruncated': bool(self.istruncated),
|
||||
'filemodifiedat': _utciso(self.filemodifiedat),
|
||||
'collectedbyuserid': self.collectedbyuserid,
|
||||
'collectedbyusername': self.collectedbyusername,
|
||||
'collectedat': _utciso(self.collectedat),
|
||||
'filename': self.filename,
|
||||
}
|
||||
if includecontent:
|
||||
data['content'] = self.content or ''
|
||||
return data
|
||||
|
||||
@@ -206,7 +206,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
'already registered and allowlisted on the agent)', 'endpoints'),
|
||||
('endpoints.updateapp',
|
||||
'Push application installers to endpoints (runs a pinned '
|
||||
'installer as SYSTEM)', 'endpoints'),
|
||||
'installer as SYSTEM) and collect plaintext log files from '
|
||||
'endpoints by path (read-only, text files only)', 'endpoints'),
|
||||
# Blunt on purpose. This is not "system-level changes" - it is
|
||||
# arbitrary code execution as SYSTEM unless the script descriptor
|
||||
# pins a user context. Whoever grants it must see that here.
|
||||
@@ -503,7 +504,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
},
|
||||
'meta': [
|
||||
{'key': 'detail'},
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
# datetime, not date: 'date' renders via
|
||||
# toLocaleDateString and drops the clock time entirely,
|
||||
# which made every event on a given day look
|
||||
# simultaneous. Ordering a boot against the logon that
|
||||
# followed it is the whole point of this feed.
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'datetime'},
|
||||
],
|
||||
# Expands in place on click. The message body is several
|
||||
# hundred words of Windows boilerplate around the one or
|
||||
|
||||
Reference in New Issue
Block a user