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