"""Agent binary upload and retention routes. Lets an operator drop in a new shopdbagent.exe or shopdb-endpoint-tray.exe from the Endpoints page, keep the last few versions, and roll the current pointer back to a retained build. The actual staging/retention logic lives in agentstore.py; this module is only the HTTP surface and authorization. Authorization boundary (deliberate): - Uploading, listing, and selecting the current staged version is endpoints.agentpackage - the same grant that already lets someone build and download the site-agent ZIP. Staging a binary only changes what a FUTURE download contains; it does not touch any running endpoint. - Pushing that binary to live endpoints (agent replacement) is a separate, heavier grant, endpoints.agentupdate, and is NOT wired here. It is a distinct 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. """ from flask import request from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog, db, require_permission) from .routes import endpoints_bp 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 # fully into memory-staged storage. # # 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB # (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap # rejected outright. The upload failed on SIZE, but the operator saw only a # version-detection failure, because the size check returns before the PE is # ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the # whole multipart envelope, so a cap equal to it would reject a legal file on # the boundary bytes alone. _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/', methods=['GET']) @require_permission('endpoints.agentpackage') def list_agent_binaries(binkey): """List staged versions for a binary (agent or tray), newest first.""" if not agentstore.is_valid_binary_key(binkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'binkey must be one of: agent, tray', http_code=400) return success_response({ 'binary': binkey, 'current': agentstore.current_version(binkey), 'versions': agentstore.list_versions(binkey), }) @endpoints_bp.route('/siteagent/binaries/', methods=['POST']) @require_permission('endpoints.agentpackage') def upload_agent_binary(binkey): """Stage an uploaded binary as a new version. Expects multipart/form-data with a 'file' upload and a 'version' field. The version must be a strict dotted-numeric string (for example 1.6.3) so it is safe as a directory name and orderable. The upload must be a Windows PE executable. Older versions past the retention window are pruned, but never the current one. By default the upload becomes the current staged build; send makecurrent=false to stage a rollback/legacy build that joins the version list without displacing the active current. """ version = (request.form.get('version') or '').strip() makecurrent = (request.form.get('makecurrent') or 'true').strip().lower() makecurrent = makecurrent not in ('false', '0', 'no') if not agentstore.is_valid_binary_key(binkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'binkey must be one of: agent, tray', http_code=400) upload = request.files.get('file') if upload is None or not upload.filename: return error_response( ErrorCodes.VALIDATION_ERROR, 'a file upload is required', http_code=400) data = upload.read(_MAX_UPLOAD_BYTES + 1) if len(data) > _MAX_UPLOAD_BYTES: return error_response( ErrorCodes.VALIDATION_ERROR, 'uploaded binary exceeds the size limit', http_code=400) if not agentstore.looks_like_pe(data): return error_response( ErrorCodes.VALIDATION_ERROR, 'uploaded file is not a Windows executable', http_code=400) # Gate the version at the PE, not at the raw input. Product versions carry # semver build metadata (1.5.0+abcdef...) that is not a legal directory # name, so the operator's typed string is only a hint. The dotted-numeric # FILE version stamped in the binary's PE resource is the authority; fall # back to it whenever the typed value is missing or not dotted-numeric, and # only reject when neither yields a usable version. if not agentstore.is_valid_version(version): detected = agentstore.read_pe_file_version(data) if agentstore.is_valid_version(detected): version = detected else: return error_response( ErrorCodes.VALIDATION_ERROR, 'version must be a dotted-numeric string such as 1.6.3', http_code=400) result = agentstore.store_version(binkey, version, data, make_current=makecurrent) # This is the binary every future package installs and runs as SYSTEM on # the fleet. Without a record, an incident has no answer to "who staged # this, and when" - the manifest holds the file, not the person. The digest # identifies the BYTES, because a version string is chosen by whoever # uploads and re-uploading one replaces the file in place. entry = next((v for v in result['versions'] if v.get('version') == version), {}) AuditLog.log( 'uploaded', 'AgentBinary', entityname='%s %s' % (binkey, version), changes={'sha256': entry.get('sha256'), 'size': entry.get('size'), '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, }) @endpoints_bp.route('/siteagent/binaries//detect', methods=['POST']) @require_permission('endpoints.agentpackage') def detect_agent_binary_version(binkey): """Read the PE file version of an uploaded EXE without staging it. The Build Site Agent page calls this on file-select so the version field is prefilled from the binary's own version resource - the operator does not have to read the EXE properties by hand for a current-release upload. Returns {version} which is null when the file carries no readable version. """ if not agentstore.is_valid_binary_key(binkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'binkey must be one of: agent, tray', http_code=400) upload = request.files.get('file') if upload is None or not upload.filename: return error_response( ErrorCodes.VALIDATION_ERROR, 'a file upload is required', http_code=400) data = upload.read(_MAX_UPLOAD_BYTES + 1) if len(data) > _MAX_UPLOAD_BYTES: return error_response( ErrorCodes.VALIDATION_ERROR, 'uploaded binary exceeds the size limit', http_code=400) if not agentstore.looks_like_pe(data): return error_response( ErrorCodes.VALIDATION_ERROR, 'uploaded file is not a Windows executable', http_code=400) return success_response({ 'binary': binkey, 'version': agentstore.read_pe_file_version(data), }) @endpoints_bp.route('/siteagent/binaries//adopt', methods=['POST']) @require_permission('endpoints.agentpackage') def adopt_legacy_agent_binary(binkey): """Adopt the unmanaged legacy flat file into the version store. The pre-versioning install dropped a single shopdbagent.exe (or tray exe) straight under siteagent/. This copies that file in as a real staged version so it shows a version and joins retention, without the operator having to re-upload it. The version comes from the request when the binary carries no readable PE version resource; otherwise the detected version is used. """ if not agentstore.is_valid_binary_key(binkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'binkey must be one of: agent, tray', http_code=400) status = agentstore.legacy_status(binkey) if status is None: return error_response( ErrorCodes.NOT_FOUND, 'there is no unmanaged legacy binary to adopt', http_code=404) source = request.get_json(silent=True) or {} version = (source.get('version') or status.get('version') or '').strip() if not agentstore.is_valid_version(version): return error_response( ErrorCodes.VALIDATION_ERROR, 'version must be a dotted-numeric string such as 1.6.3', http_code=400) result = agentstore.adopt_legacy(binkey, version) if result is None: return error_response( 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, }) @endpoints_bp.route('/siteagent/binaries//current', methods=['PUT']) @require_permission('endpoints.agentpackage') def set_current_agent_binary(binkey): """Point 'current' at an already-staged version (roll back or forward).""" if not agentstore.is_valid_binary_key(binkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'binkey must be one of: agent, tray', http_code=400) source = request.get_json(silent=True) or {} version = (source.get('version') or '').strip() if not agentstore.is_valid_version(version): return error_response( ErrorCodes.VALIDATION_ERROR, 'version must be a dotted-numeric string such as 1.6.3', http_code=400) if not agentstore.set_current(binkey, version): return error_response( 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, })