Merge origin/main (West Jeff) into wilm
Bring in the natural-key import fix, Metrics->Monitors rename, PC detail tabbing and panel groups, guest search grants, and the 2026-08-30 security fixes. Reconcile endpoint packaging/installer, migrations, contract version, and tests; keep the delivery uninstaller and 6-step installer alongside main's permission-based RBAC and pin-drift protection.
This commit is contained in:
@@ -23,7 +23,7 @@ import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import error_response, ErrorCodes, require_role
|
||||
from shopdb.api import error_response, ErrorCodes, require_permission
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
@@ -66,7 +66,7 @@ def _build_readme():
|
||||
|
||||
|
||||
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def package_adoption_tool():
|
||||
"""Bundle the adoption tool as a downloadable ZIP.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
|
||||
_CLAIM_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
def _pin_holds(command, descriptor):
|
||||
"""Does the descriptor still carry the digest this command was queued for?
|
||||
|
||||
True when nothing was pinned (actions that deliver no content), or when the
|
||||
descriptor is missing entirely - a vanished script or version already sends
|
||||
the command through with no descriptor so the agent fails closed, and that
|
||||
path is not a content swap.
|
||||
"""
|
||||
if not command.pinnedsha256:
|
||||
return True
|
||||
if descriptor is None:
|
||||
return True
|
||||
return hmac.compare_digest(
|
||||
str(descriptor.get('sha256') or ''), command.pinnedsha256)
|
||||
|
||||
|
||||
def _fail_on_pin_drift(command, action):
|
||||
"""Refuse a claim whose content changed after the operator approved it."""
|
||||
command.status = EndpointCommand.STATUS_FAILED
|
||||
command.resultmessage = (
|
||||
'Refused: the {} content changed after this command was queued'
|
||||
.format(action))
|
||||
command.completedat = _naive_utcnow()
|
||||
db.session.commit()
|
||||
current_app.logger.warning(
|
||||
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
|
||||
'longer matches the resolved descriptor',
|
||||
command.endpointcommandid, action, command.assetid,
|
||||
command.pinnedsha256)
|
||||
AuditLog.log('refused', 'EndpointCommand',
|
||||
entityid=command.endpointcommandid,
|
||||
entityname=action,
|
||||
details='content digest changed after enqueue')
|
||||
db.session.commit()
|
||||
return ('', 204)
|
||||
|
||||
|
||||
def _reap_stale_commands(asset_id):
|
||||
"""Expire in-flight commands an agent will never finish for this endpoint.
|
||||
|
||||
@@ -1097,14 +1135,39 @@ def _resolve_update_app(target):
|
||||
if application is None or not application.installpath:
|
||||
return None, ('Application for appversionid {} has no installer package'
|
||||
.format(ident))
|
||||
# PIN THE FILE THAT IS ACTUALLY SERVED.
|
||||
#
|
||||
# /api/endpoints/apppackage/<appversionid> resolves the version to its
|
||||
# APPLICATION and serves application.installpath - there is no
|
||||
# version-specific artifact behind that route. So version.sha256 pinned
|
||||
# a digest that need not describe the bytes delivered, and where it was
|
||||
# null the descriptor carried no digest at all and the agent had nothing
|
||||
# to verify against. Deriving it from the application, exactly as the
|
||||
# app branch does, makes the pin describe the delivery.
|
||||
sha256 = (application.installersha256
|
||||
or _backfill_app_installer_sha256(application))
|
||||
if not sha256:
|
||||
return None, ('Application for appversionid {} has an installer '
|
||||
'package with no recorded SHA-256, so the delivery '
|
||||
'cannot be verified'.format(ident))
|
||||
|
||||
# Carry the real filename so the agent's Path.GetExtension routes an MSI
|
||||
# through msiexec. Without it every MSI push failed, because this branch
|
||||
# ended the URL at the id and the extension was simply absent.
|
||||
filename = _hosted_package_filename(application)
|
||||
installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid)
|
||||
if filename:
|
||||
installpath = '/api/endpoints/apppackage/app/{}/{}'.format(
|
||||
application.appid, filename)
|
||||
|
||||
return {
|
||||
'source': 'version',
|
||||
'appversionid': version.appversionid,
|
||||
'appid': version.appid,
|
||||
'appname': application.appname,
|
||||
'version': version.version,
|
||||
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
|
||||
'sha256': version.sha256,
|
||||
'installpath': installpath,
|
||||
'sha256': sha256,
|
||||
'silentargs': version.silentargs,
|
||||
}, None
|
||||
|
||||
@@ -1209,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
# 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.
|
||||
# Digest of the content this push was APPROVED against. Kept on the row so
|
||||
# the claim path can refuse a descriptor that has changed since; see
|
||||
# EndpointCommand.pinnedsha256.
|
||||
pinnedsha256 = None
|
||||
|
||||
if action == 'update_app':
|
||||
_descriptor, resolve_error = _resolve_update_app(target)
|
||||
descriptor, resolve_error = _resolve_update_app(target)
|
||||
if resolve_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
|
||||
pinnedsha256 = (descriptor or {}).get('sha256')
|
||||
|
||||
# run_script pins a ShopDB-hosted script; verify it resolves to a real
|
||||
# uploaded body now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint later.
|
||||
if action == 'run_script':
|
||||
_script, script_error = _resolve_run_script(target)
|
||||
script, script_error = _resolve_run_script(target)
|
||||
if script_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||
pinnedsha256 = (script or {}).get('sha256')
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
@@ -1252,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
targetkind=targetkind if target else None,
|
||||
status=EndpointCommand.STATUS_PENDING,
|
||||
requestedby=getattr(current_user, 'userid', None),
|
||||
pinnedsha256=pinnedsha256,
|
||||
)
|
||||
db.session.add(command)
|
||||
|
||||
@@ -1312,6 +1383,8 @@ def claim_next_command():
|
||||
# fail closed rather than 500 the poll.
|
||||
if command.action == 'update_app':
|
||||
descriptor, _resolve_error = _resolve_update_app(command.target)
|
||||
if not _pin_holds(command, descriptor):
|
||||
return _fail_on_pin_drift(command, 'update_app')
|
||||
if descriptor is not None:
|
||||
claim['app'] = descriptor
|
||||
# run_script rides a resolved script descriptor: the agent gets a
|
||||
@@ -1322,6 +1395,8 @@ def claim_next_command():
|
||||
# agent fail closed rather than 500 the poll.
|
||||
if command.action == 'run_script':
|
||||
script_descriptor, _script_error = _resolve_run_script(command.target)
|
||||
if not _pin_holds(command, script_descriptor):
|
||||
return _fail_on_pin_drift(command, 'run_script')
|
||||
if script_descriptor is not None:
|
||||
claim['script'] = script_descriptor
|
||||
return success_response(claim)
|
||||
@@ -1351,6 +1426,33 @@ def report_command_result(command_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
# An agent may only finalize ITS OWN command. The collector key is shared
|
||||
# fleet-wide, so without this any agent could post a result for any command
|
||||
# id - marking an install succeeded on a machine that never ran it, which
|
||||
# writes a false compliance record. Binding to the claimed hostname is a
|
||||
# floor, not a fix: the credential still does not prove which endpoint is
|
||||
# calling, and only a per-endpoint credential closes that.
|
||||
# Optional because the shipped agent does not send it yet; enforced when it
|
||||
# does, so an updated fleet gets the check without a flag day.
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if hostname:
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if caller_asset_id != command.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
|
||||
# Only a claimed command can be finalized. Needs no agent change, and it
|
||||
# closes two things the hostname check cannot: reporting on a command that
|
||||
# was never claimed, and overwriting a terminal result - which would let a
|
||||
# recorded failure be replaced by a success.
|
||||
if command.status != EndpointCommand.STATUS_RUNNING:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Command {} is {}, not running'.format(
|
||||
command_id, command.status),
|
||||
http_code=409)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
succeeded = bool(payload.get('succeeded'))
|
||||
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
|
||||
|
||||
@@ -31,7 +31,8 @@ import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import success_response, error_response, ErrorCodes, require_role
|
||||
from shopdb.api import (success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
@@ -166,16 +167,16 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
||||
server after it was generated; regenerate the key in the UI to revoke it.
|
||||
@@ -456,14 +457,14 @@ def _siteagent_status_payload():
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent', methods=['GET'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def get_siteagent():
|
||||
"""Report whether the site agent build has a prebuilt binary staged."""
|
||||
return success_response(_siteagent_status_payload())
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/package', methods=['POST'])
|
||||
@require_role('admin')
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def package_siteagent():
|
||||
"""Bundle the site agent as a downloadable ZIP.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user