diff --git a/plugins/endpoints/api/agentstore.py b/plugins/endpoints/api/agentstore.py index d656dfef..8f111fb8 100644 --- a/plugins/endpoints/api/agentstore.py +++ b/plugins/endpoints/api/agentstore.py @@ -33,6 +33,7 @@ import io import json import os import re +import struct from datetime import datetime from flask import current_app @@ -75,6 +76,155 @@ def looks_like_pe(data): return len(data) >= 2 and data[:2] == _PE_MAGIC +# RT_VERSION resource type id (the version resource Windows shows in file +# properties). We must read the version from THIS resource on the outer +# executable - not the first VS_FIXEDFILEINFO signature we can find in the bytes. +# A self-contained single-file .NET exe bundles dozens of framework assemblies, +# each carrying its own version block, so a naive byte scan returns a framework +# version (for example the .NET runtime's) instead of the app's. The only +# reliable value is the RT_VERSION resource reached by walking the PE resource +# directory, which belongs to the outer image alone. +_RT_VERSION = 16 + +# 0xFEEF04BD little-endian: the dwSignature that opens VS_FIXEDFILEINFO inside +# the RT_VERSION resource data. +_VS_FIXEDFILEINFO_SIGNATURE = b'\xbd\x04\xef\xfe' + + +def _rva_to_offset(data, rva, sections): + """Map a resource RVA to a file offset using the section table, or None.""" + for virtual_address, virtual_size, raw_offset, raw_size in sections: + if virtual_address <= rva < virtual_address + max(virtual_size, raw_size): + delta = rva - virtual_address + if delta < raw_size: + return raw_offset + delta + return None + + +def _first_resource_entry_offset(data, table_offset, resource_base, sections, + want_id): + """Walk one IMAGE_RESOURCE_DIRECTORY level and return a child offset. + + When want_id is not None, follow the entry whose id matches (used to select + RT_VERSION at the type level); otherwise follow the first entry (used to + descend name/language levels). Returns the absolute file offset of the child + directory or data entry, with the high bit stripped, or None. + """ + # IMAGE_RESOURCE_DIRECTORY: 12 fixed bytes, then NumberOfNamedEntries (2) + # and NumberOfIdEntries (2), then the entries. + named = struct.unpack_from(' pick RT_VERSION. Level 2: name/id -> first. + # Level 3: language -> first. Then a data entry pointing at the resource. + type_dir = _first_resource_entry_offset( + data, resource_base, resource_base, sections, _RT_VERSION) + if type_dir is None: + return None + name_dir = _first_resource_entry_offset( + data, type_dir, resource_base, sections, None) + if name_dir is None: + return None + lang_entry = _first_resource_entry_offset( + data, name_dir, resource_base, sections, None) + if lang_entry is None: + return None + + # lang_entry is an IMAGE_RESOURCE_DATA_ENTRY: RVA (4), size (4), ... + version_rva = struct.unpack_from('> 16) & 0xFFFF, + version_ms & 0xFFFF, + (version_ls >> 16) & 0xFFFF, + version_ls & 0xFFFF, + ) + if not any(parts): + return None + return '.'.join(str(part) for part in parts) + except (struct.error, TypeError, IndexError): + return None + + +def read_pe_file_version_from_path(path): + """Read a PE file's version from disk, or None on any failure.""" + try: + with open(path, 'rb') as handle: + return read_pe_file_version(handle.read()) + except OSError: + return None + + def _siteagent_dir(): return os.path.join(current_app.instance_path, 'siteagent') @@ -117,12 +267,32 @@ def _write_manifest(binkey, manifest): handle.write('\n') +def _version_sort_key(version): + """Parse a dotted-numeric version into a tuple of ints for ordering. + + Pads to four parts so 1.6 and 1.6.0.0 compare equal-ish and shorter + versions do not sort above longer ones with the same prefix. A part that + is not numeric (should not happen after validation) sorts as 0. + """ + parts = [] + for chunk in str(version).split('.'): + try: + parts.append(int(chunk)) + except ValueError: + parts.append(0) + while len(parts) < 4: + parts.append(0) + return tuple(parts[:4]) + + def list_versions(binkey): - """Staged versions for a binary, newest upload first. + """Staged versions for a binary, highest version number first. Each item is {version, uploadedat, size, iscurrent}. Reads the manifest as - the source of truth for order and currency; a version whose exe is missing - on disk is dropped so the caller never offers a broken push. + the source of truth for currency; a version whose exe is missing on disk is + dropped so the caller never offers a broken push. Ordered by version number + (descending) so a rollback dropdown reads newest-build-first regardless of + the order versions were uploaded. """ manifest = _read_manifest(binkey) current = manifest.get('current') @@ -137,6 +307,7 @@ def list_versions(binkey): 'size': entry.get('size'), 'iscurrent': version == current, }) + out.sort(key=lambda item: _version_sort_key(item['version']), reverse=True) return out @@ -164,6 +335,51 @@ def current_path(binkey): return None +def legacy_status(binkey): + """Describe an unmanaged legacy flat file, or None when there is none. + + Returned only when a flat file exists and no managed version is current, so + the UI can show its detected version and offer one-click adoption without + the operator re-uploading. {version} is the PE file version if readable, + else None. + """ + if current_version(binkey): + return None + legacy = _legacy_path(binkey) + if not os.path.isfile(legacy): + return None + try: + size = os.path.getsize(legacy) + except OSError: + size = None + return { + 'version': read_pe_file_version_from_path(legacy), + 'size': size, + } + + +def adopt_legacy(binkey, version): + """Copy the legacy flat file into the version store as a staged version. + + Reads the flat file, validates it as a PE, and stores it under the given + version (making it current). Returns the fresh manifest view, or None when + there is no legacy file or it is not a PE. Caller supplies the version - + typically the detected PE version, or an operator override when the binary + carries no version resource. + """ + legacy = _legacy_path(binkey) + if not os.path.isfile(legacy): + return None + try: + with open(legacy, 'rb') as handle: + data = handle.read() + except OSError: + return None + if not looks_like_pe(data): + return None + return store_version(binkey, version, data) + + def _prune(binkey, manifest): """Drop versions past the retention window, never removing the current one.""" current = manifest.get('current') @@ -197,14 +413,16 @@ def _remove_version_dir(binkey, version): pass -def store_version(binkey, version, data): - """Stage an uploaded binary as a new version and make it current. +def store_version(binkey, version, data, make_current=True): + """Stage an uploaded binary as a new version. Writes data to siteagent///, records it newest-first in - the manifest (de-duping a re-upload of the same version in place), marks it - current, and prunes older versions past RETAIN. Returns the fresh manifest - view {current, versions:[...]}. Caller must have validated binkey, version, - and that data is a PE binary. + the manifest (de-duping a re-upload of the same version in place), and prunes + older versions past RETAIN. When make_current is True the new version also + becomes the current staged build; pass False to stage a rollback/legacy build + that should appear in the version list without displacing the active current. + Returns the fresh manifest view {current, versions:[...]}. Caller must have + validated binkey, version, and that data is a PE binary. """ os.makedirs(os.path.join(_binary_dir(binkey), version), exist_ok=True) with open(_version_exe_path(binkey, version), 'wb') as handle: @@ -219,7 +437,8 @@ def store_version(binkey, version, data): 'size': len(data), }) manifest['versions'] = versions - manifest['current'] = version + if make_current or not manifest.get('current'): + manifest['current'] = version _prune(binkey, manifest) _write_manifest(binkey, manifest) return { diff --git a/plugins/endpoints/api/agentupload.py b/plugins/endpoints/api/agentupload.py index c180a0a1..3761ac05 100644 --- a/plugins/endpoints/api/agentupload.py +++ b/plugins/endpoints/api/agentupload.py @@ -51,15 +51,19 @@ def list_agent_binaries(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 and make it current. + """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. + 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, @@ -90,7 +94,94 @@ def upload_agent_binary(binkey): 'uploaded file is not a Windows executable', http_code=400) - result = agentstore.store_version(binkey, version, data) + result = agentstore.store_version(binkey, version, data, + make_current=makecurrent) + return success_response({ + 'binary': binkey, + 'current': result['current'], + 'versions': result['versions'], + }) + + +@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) return success_response({ 'binary': binkey, 'current': result['current'], diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index 216c1121..3604aa7b 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -462,6 +462,8 @@ def _siteagent_status_payload(): 'traycurrent': agentstore.current_version('tray'), 'agentversions': agentstore.list_versions('agent'), 'trayversions': agentstore.list_versions('tray'), + 'agentlegacy': agentstore.legacy_status('agent'), + 'traylegacy': agentstore.legacy_status('tray'), } diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 51d623d9..f213c234 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -160,7 +160,7 @@

Adoption tool

@@ -211,7 +211,7 @@

Build site agent

@@ -227,10 +227,9 @@

No agent binary is staged on this server. The package - will still build with config and installer, but you must drop - shopdbagent.exe beside them before deploying. Stage a - prebuilt agent under instance/siteagent/ to have it ship - in the download. + will still build with config and installer, but no agent will ship + inside it. Upload a current release in the binary section below to + have it included in every download.

@@ -317,43 +316,112 @@ class="binary-block" >

{{ binary.label }}

+

+ Upload {{ binary.name }} updated EXE. Uploading here will update the + binaries available for your "Build Site Agent" install package. +

Current staged version: - {{ binary.current || 'none (legacy or unstaged)' }} + {{ binary.current || 'none staged yet - upload a build below' }}

+ +
- + +
+ + + +

+ WARNING: confirm agent version before uploading from the EXE + properties and enter the version number. +

+
+ + +

{{ binary.message }}

{{ binary.error }}

+ +
+ + +
+
@@ -434,7 +502,7 @@ const copied = ref(false) const message = ref('') const error = ref('') -// Adoption tool (spike): build a one-shot, agentless enrollment download. +// Adoption tool: build a one-shot, agentless enrollment download. const adoptbaseurl = ref('') const adoptcollectorkey = ref('') const adoptbusy = ref(false) @@ -458,11 +526,15 @@ const agenterror = ref('') // version list, and busy/message state so the two rows act independently. The // 'key' matches the backend binkey (agent | tray). const binaries = ref([ - { key: 'agent', label: 'Endpoint agent (shopdbagent.exe)', - current: null, versions: [], version: '', file: null, + { key: 'agent', label: 'ShopDB Agent (shopdbagent.exe)', + name: 'ShopDB Agent', + current: null, versions: [], version: '', file: '', detecting: false, + legacyversion: '', legacyfile: '', busy: false, message: '', error: '', showprevious: false }, - { key: 'tray', label: 'Tray agent (shopdb-endpoint-tray.exe)', - current: null, versions: [], version: '', file: null, + { key: 'tray', label: 'Tray Agent (shopdb-endpoint-tray.exe)', + name: 'Tray Agent', + current: null, versions: [], version: '', file: '', detecting: false, + legacyversion: '', legacyfile: '', busy: false, message: '', error: '', showprevious: false }, ]) @@ -664,11 +736,39 @@ async function buildSiteAgent() { } } +async function detectVersion(binkey, file) { + const form = new FormData() + form.append('file', file) + const response = await api.post( + `/endpoints/siteagent/binaries/${binkey}/detect`, form) + return response.data.data?.version || '' +} + function onBinaryFileChange(binkey, event) { const binary = binaryFor(binkey) if (!binary) return const files = event.target.files - binary.file = files && files.length ? files[0] : null + binary.file = files && files.length ? files[0] : '' + binary.error = '' + if (!binary.file) return + binary.detecting = true + detectVersion(binkey, binary.file) + .then((version) => { + if (version) binary.version = version + }) + .catch((detectError) => { + console.error(detectError) + }) + .finally(() => { + binary.detecting = false + }) +} + +function onLegacyFileChange(binkey, event) { + const binary = binaryFor(binkey) + if (!binary) return + const files = event.target.files + binary.legacyfile = files && files.length ? files[0] : '' } async function uploadBinary(binkey) { @@ -686,7 +786,7 @@ async function uploadBinary(binkey) { const data = response.data.data || {} applyBinaryStatus(binkey, data.current, data.versions) binary.version = '' - binary.file = null + binary.file = '' binary.message = 'Uploaded and set as current.' } catch (uploadError) { binary.error = uploadError.response?.data?.data?.error?.message @@ -699,6 +799,35 @@ async function uploadBinary(binkey) { } } +async function uploadLegacy(binkey) { + const binary = binaryFor(binkey) + if (!binary || !binary.legacyfile || !binary.legacyversion) return + binary.busy = true + binary.message = '' + binary.error = '' + try { + const form = new FormData() + form.append('version', binary.legacyversion) + form.append('file', binary.legacyfile) + form.append('makecurrent', 'false') + const response = await api.post( + `/endpoints/siteagent/binaries/${binkey}`, form) + const data = response.data.data || {} + applyBinaryStatus(binkey, data.current, data.versions) + binary.legacyversion = '' + binary.legacyfile = '' + binary.message = 'Legacy build staged for rollback.' + } catch (uploadError) { + binary.error = uploadError.response?.data?.data?.error?.message + || (uploadError.response?.status === 403 + ? 'You do not have permission to upload agent binaries' + : 'Could not upload the legacy build') + console.error(uploadError) + } finally { + binary.busy = false + } +} + async function setCurrentBinary(binkey, version) { const binary = binaryFor(binkey) if (!binary) return @@ -775,6 +904,14 @@ function editConfiguration() { } .binary-upload .form-control[type="text"] { max-width: 12rem; } .previous-versions { margin-top: 0.5rem; } +.field-warning { + margin: 0.25rem 0 0.5rem; + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--color-warning, #b8860b); + background: rgba(184, 134, 11, 0.08); + font-size: 0.85rem; + font-weight: 600; +} .btn-link { background: none; border: none; diff --git a/tests/test_plugins/test_endpoints_agentstore.py b/tests/test_plugins/test_endpoints_agentstore.py new file mode 100644 index 00000000..f979f4ad --- /dev/null +++ b/tests/test_plugins/test_endpoints_agentstore.py @@ -0,0 +1,57 @@ +"""Unit tests for the versioned agent binary store helpers. + +Covers the pure, context-free helpers: version validation, PE magic and PE +file-version parsing. The staging/retention paths that touch the Flask instance +directory are exercised through the API tests; here we keep to the logic that +needs no app context. + +The PE version parser walks the resource directory to the outer image's +RT_VERSION resource (not the first VS_FIXEDFILEINFO signature in the bytes), +because a self-contained single-file .NET exe bundles many framework assemblies +that each carry their own version block. Building a full PE with a resource +directory inline is impractical here, so the parser's correctness is verified +against real agent binaries in the API/integration layer; these unit tests pin +the surrounding validators and the malformed-input contract (never raises, +returns None). +""" + +from plugins.endpoints.api import agentstore + + +def test_is_valid_version_accepts_dotted_numeric(): + assert agentstore.is_valid_version('1') + assert agentstore.is_valid_version('1.6.3') + assert agentstore.is_valid_version('1.6.3.0') + + +def test_is_valid_version_rejects_bad_input(): + assert not agentstore.is_valid_version('') + assert not agentstore.is_valid_version('v1.6') + assert not agentstore.is_valid_version('1.6.3.0.1') + assert not agentstore.is_valid_version('../etc') + assert not agentstore.is_valid_version('latest') + + +def test_is_valid_binary_key(): + assert agentstore.is_valid_binary_key('agent') + assert agentstore.is_valid_binary_key('tray') + assert not agentstore.is_valid_binary_key('other') + + +def test_looks_like_pe(): + assert agentstore.looks_like_pe(b'MZ\x90\x00') + assert not agentstore.looks_like_pe(b'PK\x03\x04') + assert not agentstore.looks_like_pe(b'') + + +def test_read_pe_file_version_none_on_non_pe(): + assert agentstore.read_pe_file_version(b'not a pe file') is None + + +def test_read_pe_file_version_none_on_truncated_pe(): + # MZ header but nothing else valid: must return None, never raise. + assert agentstore.read_pe_file_version(b'MZ' + b'\x00' * 200) is None + + +def test_read_pe_file_version_none_on_empty(): + assert agentstore.read_pe_file_version(b'') is None