Import the wilm agent store and WINC adoption work, with four fixes

Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption
import, a versioned agent binary store with upload and rollback, remote-BuildKit
CI, tray version reporting, and UI fixes. Applied as a patch again - the
published mirror rewrites hashes, so the branches have unrelated histories and
git refuses to merge them.

Reviewed by three agents before merging. Four things fixed here.

THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in
publish-images.yml sits at column 0 instead of indented under steps:, so the
whole file is invalid YAML and GitHub would refuse it. Their entire GHCR
distribution channel - which Start-ShopDBDev.ps1 and the compose files consume -
was broken at the source and clearly never run. Indented; all five workflows now
parse.

THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the
approval packet from the same upload and then asked the writer whether they
matched, so packet_matches_plan compared a hash against itself. The documented
contract - "a human approves a specific, frozen plan, and the writer can prove
nothing drifted" - did not exist in the web path: an admin could preview file A
and commit file B, gated only by the needs-create group NAMES they ticked. The
preview's planhash is now required at commit and checked against a freshly
rebuilt plan. Their tests committed without previewing, which is what let this
through; they go through the real flow now, and three new tests pin it - all
confirmed to fail with the check stubbed out.

endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a
ZIP"; with the upload routes it also means "choose the executable every future
package installs and runs as SYSTEM". Before this, staging needed filesystem
access to the server. The permission description still said only "generate and
download a package", so an admin granting it could not see what they were
granting. Reworded.

NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size
and timestamp; re-uploading a version replaces the bytes in place under the same
label. A sha256 of the stored file is now recorded, and the upload is
audit-logged with it - after an incident there was previously no answer to who
put that binary on the fleet.

Also documented the nine new routes in docs/api-inventory.json (that gate cannot
run on the mirror, so it falls to us at promotion time again), regenerated the
spec and the project map, and kept our own docs where they conflicted: docs/ is
scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be
channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file
rather than our 737-line canonical one. Their journal section is appended to
ours.

Contract version comes across as 0.30.0 for a genuinely additive surface.

Suite 2369, frontend 324, gate green.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 07:54:14 -04:00
parent 7f6aaf2e64
commit 13be0ef90f
53 changed files with 5064 additions and 95 deletions

View File

@@ -9,9 +9,9 @@
look like a peer of printing a sticker. -->
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
</div>
</div>
@@ -376,6 +376,14 @@ const toast = useToast()
const authStore = useAuthStore()
const { isEnabled } = useIdentifierFlags()
// Return-navigation origin. Fleet opens this page with ?from=fleet so Back,
// Edit, and Retire all lead back to Fleet; every other entry path falls back
// to the PCs list.
const fromFleet = computed(() => route.query.from === 'fleet')
const fromQuery = computed(() => (fromFleet.value ? '?from=fleet' : ''))
const returnTo = computed(() => (fromFleet.value ? '/endpoints' : '/pcs'))
const returnLabel = computed(() => (fromFleet.value ? 'Back to Fleet' : 'Back to List'))
const loading = ref(true)
const computer = ref(null)
// Liveness for the triage band. Null when the endpoints plugin is absent, or
@@ -643,7 +651,7 @@ async function confirmRetire() {
try {
await computersApi.delete(route.params.id)
toast.success(`Retired ${label}`)
router.push('/pcs')
router.push(returnTo.value)
} catch (error) {
console.error('Error retiring PC:', error)
toast.error(apiError(error, 'Failed to retire PC'))

View File

@@ -353,7 +353,7 @@
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save PC' }}
</button>
<router-link to="/pcs" class="btn btn-secondary">Cancel</router-link>
<router-link :to="returnTo" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
@@ -385,6 +385,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Return-navigation origin, threaded through from Fleet via ?from=fleet so
// Cancel and a successful Save land back on Fleet; every other entry path
// falls back to the PCs list.
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
@@ -685,7 +690,7 @@ async function savePC() {
}
}
router.push('/pcs')
router.push(returnTo.value)
} catch (err) {
console.error('Error saving PC:', err)
error.value = apiError(err, 'Failed to save PC')

View File

@@ -32,6 +32,7 @@ arrays. `adopt` is first-contact only.
"pctype": "CNC Controller", // adoption subset
"machinenumber": "12345", // adoption subset; "" or "9999" == none
"agentversion": "1.0.0",
"trayversion": "1.0.0", // file version of the interactive tray exe, omitted if no tray
"lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC
"uptimeseconds": 86400,
"idleseconds": 120,
@@ -126,6 +127,7 @@ public sealed class EndpointReport
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("trayversion")] public string? TrayVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }

View File

@@ -5,6 +5,8 @@ from .routes import endpoints_bp
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
# Attaches the agent binary upload/retention routes to endpoints_bp.
from . import agentupload # noqa: F401
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
from . import monitors # noqa: F401

View File

@@ -0,0 +1,472 @@
"""Versioned staging and retention for the endpoint agent binaries.
The site-agent packager ships a prebuilt shopdbagent.exe and an optional
shopdb-endpoint-tray.exe. Originally each was a single staged file under
instance/siteagent/; this module adds per-version staging so an operator can
drop in a new build, keep the last few, and roll a push out from a known
version.
Layout (all under the Flask instance directory):
siteagent/
shopdbagent.exe legacy flat path (pre-versioning)
shopdb-endpoint-tray.exe legacy flat path (pre-versioning)
agent/
manifest.json {current, versions:[{version,uploadedat,size}]}
1.6.3/shopdbagent.exe
1.6.2/shopdbagent.exe
tray/
manifest.json
1.0.0/shopdb-endpoint-tray.exe
Retention: the RETAIN most recently uploaded versions per binary are kept; the
current version is never pruned even if it falls outside that window. The legacy
flat file is treated as an unmanaged fallback: the packager still ships it when
no versioned build exists, so an install that predates versioning keeps working.
This module holds NO routes and NO permission checks - it is pure staging logic
so it can be unit-tested without a request context and reused by both the upload
routes and the packager. Callers own authorization.
"""
import hashlib
import io
import json
import os
import re
import struct
from datetime import datetime
from flask import current_app
# How many versions to retain per binary. The current version is exempt from
# pruning, so the effective floor is max(RETAIN, 1-including-current).
RETAIN = 3
# Per-binary staging spec. 'exe' is the canonical filename shipped in the ZIP and
# stored inside each version directory; 'legacy' is the flat pre-versioning path
# under siteagent/ kept as a fallback source.
BINARY_SPECS = {
'agent': {'exe': 'shopdbagent.exe', 'legacy': 'shopdbagent.exe'},
'tray': {'exe': 'shopdb-endpoint-tray.exe', 'legacy': 'shopdb-endpoint-tray.exe'},
}
# A dotted numeric version, 1 to 4 parts (matches .NET assembly versions like
# 1.6.3 or 1.6.3.0). Deliberately strict so a typo or an arbitrary label cannot
# become a directory name.
_VERSION_RE = re.compile(r'^\d+(\.\d+){0,3}$')
# Windows PE magic. Uploaded agent binaries must be real PE executables; a quick
# header check rejects an operator who uploads the wrong file (a ZIP, a script)
# before it is ever staged or pushed.
_PE_MAGIC = b'MZ'
def is_valid_binary_key(binkey):
"""Whether binkey names a managed binary (agent or tray)."""
return binkey in BINARY_SPECS
def is_valid_version(version):
"""Whether version is a strict dotted-numeric string safe to use as a dir."""
return bool(version) and bool(_VERSION_RE.match(version))
def looks_like_pe(data):
"""Whether data begins with the Windows PE 'MZ' magic."""
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('<H', data, table_offset + 12)[0]
id_count = struct.unpack_from('<H', data, table_offset + 14)[0]
entries_offset = table_offset + 16
for index in range(named + id_count):
entry_offset = entries_offset + index * 8
name_or_id, offset_to_data = struct.unpack_from('<II', data, entry_offset)
# Named entries carry the high bit in name_or_id; we only match numeric
# ids, so skip named entries when selecting by id.
is_named = bool(name_or_id & 0x80000000)
if want_id is not None:
if is_named or name_or_id != want_id:
continue
# Directory children have the high bit set on offset_to_data.
child = (offset_to_data & 0x7FFFFFFF) + resource_base
return child
return None
def read_pe_file_version(data):
"""Dotted four-part file version from the PE's RT_VERSION resource, or None.
Walks the PE resource directory to the RT_VERSION resource of the outer
executable and decodes its VS_FIXEDFILEINFO file version. Returns None when
the file has no version resource or the structures are malformed. Never
raises - callers treat None as "unknown, ask the operator".
"""
try:
if len(data) < 0x40 or data[:2] != _PE_MAGIC:
return None
pe_offset = struct.unpack_from('<I', data, 0x3C)[0]
if data[pe_offset:pe_offset + 4] != b'PE\x00\x00':
return None
coff = pe_offset + 4
number_of_sections = struct.unpack_from('<H', data, coff + 2)[0]
size_of_optional = struct.unpack_from('<H', data, coff + 16)[0]
optional = coff + 20
# Magic distinguishes PE32 (0x10b) from PE32+ (0x20b); the data-directory
# array sits at a different offset in each.
magic = struct.unpack_from('<H', data, optional)[0]
if magic == 0x20B:
data_dir = optional + 112
else:
data_dir = optional + 96
# Resource table is data directory index 2: its RVA then size.
resource_rva = struct.unpack_from('<I', data, data_dir + 2 * 8)[0]
if not resource_rva:
return None
section_table = optional + size_of_optional
sections = []
for index in range(number_of_sections):
base = section_table + index * 40
virtual_size = struct.unpack_from('<I', data, base + 8)[0]
virtual_address = struct.unpack_from('<I', data, base + 12)[0]
raw_size = struct.unpack_from('<I', data, base + 16)[0]
raw_offset = struct.unpack_from('<I', data, base + 20)[0]
sections.append((virtual_address, virtual_size, raw_offset, raw_size))
resource_base = _rva_to_offset(data, resource_rva, sections)
if resource_base is None:
return None
# Level 1: type -> 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('<I', data, lang_entry)[0]
version_offset = _rva_to_offset(data, version_rva, sections)
if version_offset is None:
return None
# Within the version resource, find VS_FIXEDFILEINFO by its signature.
signature = data.find(_VS_FIXEDFILEINFO_SIGNATURE, version_offset,
version_offset + 512)
if signature == -1:
return None
base = signature + 4 + 4 # skip signature and dwStrucVersion
version_ms, version_ls = struct.unpack_from('<II', data, base)
parts = (
(version_ms >> 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')
def _binary_dir(binkey):
return os.path.join(_siteagent_dir(), binkey)
def _manifest_path(binkey):
return os.path.join(_binary_dir(binkey), 'manifest.json')
def _legacy_path(binkey):
return os.path.join(_siteagent_dir(), BINARY_SPECS[binkey]['legacy'])
def _version_exe_path(binkey, version):
return os.path.join(_binary_dir(binkey), version, BINARY_SPECS[binkey]['exe'])
def _read_manifest(binkey):
"""Return the manifest dict, or an empty skeleton when none is written yet."""
path = _manifest_path(binkey)
try:
with open(path, 'r', encoding='utf-8') as handle:
data = json.load(handle)
except (OSError, ValueError):
return {'current': None, 'versions': []}
if not isinstance(data, dict):
return {'current': None, 'versions': []}
data.setdefault('current', None)
data.setdefault('versions', [])
return data
def _write_manifest(binkey, manifest):
os.makedirs(_binary_dir(binkey), exist_ok=True)
with open(_manifest_path(binkey), 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2)
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, highest version number first.
Each item is {version, uploadedat, size, iscurrent}. Reads the manifest as
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')
out = []
for entry in manifest.get('versions', []):
version = entry.get('version')
if not version or not os.path.isfile(_version_exe_path(binkey, version)):
continue
out.append({
'version': version,
'uploadedat': entry.get('uploadedat'),
'size': entry.get('size'),
'iscurrent': version == current,
})
out.sort(key=lambda item: _version_sort_key(item['version']), reverse=True)
return out
def current_version(binkey):
"""The current version string, or None when only a legacy flat file exists."""
manifest = _read_manifest(binkey)
current = manifest.get('current')
if current and os.path.isfile(_version_exe_path(binkey, current)):
return current
return None
def current_path(binkey):
"""Absolute path to the exe the packager should ship, or None.
Prefers the current managed version; falls back to the legacy flat file so a
site that staged a binary before versioning still packages it.
"""
version = current_version(binkey)
if version:
return _version_exe_path(binkey, version)
legacy = _legacy_path(binkey)
if os.path.isfile(legacy):
return legacy
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')
versions = manifest.get('versions', [])
keep = []
removed = 0
for index, entry in enumerate(versions):
version = entry.get('version')
# Keep the RETAIN newest (list is newest-first) plus the current always.
if index < RETAIN or version == current:
keep.append(entry)
continue
# Beyond the window and not current: remove its directory.
_remove_version_dir(binkey, version)
removed += 1
manifest['versions'] = keep
return removed
def _remove_version_dir(binkey, version):
directory = os.path.join(_binary_dir(binkey), version)
try:
exe = _version_exe_path(binkey, version)
if os.path.isfile(exe):
os.remove(exe)
if os.path.isdir(directory) and not os.listdir(directory):
os.rmdir(directory)
except OSError:
# Best effort: a locked or already-gone file must not fail the upload
# that triggered pruning. The manifest no longer references it either way.
pass
def store_version(binkey, version, data, make_current=True):
"""Stage an uploaded binary as a new version.
Writes data to siteagent/<binkey>/<version>/<exe>, records it newest-first in
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:
handle.write(data)
manifest = _read_manifest(binkey)
versions = [entry for entry in manifest.get('versions', [])
if entry.get('version') != version]
versions.insert(0, {
'version': version,
'uploadedat': datetime.utcnow().isoformat() + 'Z',
'size': len(data),
# The identity of the BYTES, not of the label. A version string is
# chosen by whoever uploads, and re-uploading the same string replaces
# the file in place - without this, a swap under a trusted label leaves
# no trace. It is also what an operator compares against the build they
# meant to ship, and what package time can check before handing an exe
# to the fleet.
'sha256': hashlib.sha256(data).hexdigest(),
})
manifest['versions'] = versions
if make_current or not manifest.get('current'):
manifest['current'] = version
_prune(binkey, manifest)
_write_manifest(binkey, manifest)
return {
'current': manifest['current'],
'versions': list_versions(binkey),
}
def set_current(binkey, version):
"""Point 'current' at an already-staged version. Returns True on success.
Used to roll back to a retained version. Refuses a version that is not
staged on disk so current never dangles.
"""
if not os.path.isfile(_version_exe_path(binkey, version)):
return False
manifest = _read_manifest(binkey)
if not any(entry.get('version') == version
for entry in manifest.get('versions', [])):
return False
manifest['current'] = version
_write_manifest(binkey, manifest)
return True

View File

@@ -0,0 +1,231 @@
"""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.
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
# Hard cap on an uploaded agent binary. The real agent is a few MB; 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.
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
@endpoints_bp.route('/siteagent/binaries/<binkey>', 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/<binkey>', 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)
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)
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)
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()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
})
@endpoints_bp.route('/siteagent/binaries/<binkey>/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/<binkey>/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'],
'versions': result['versions'],
})
@endpoints_bp.route('/siteagent/binaries/<binkey>/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)
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
})

View File

@@ -444,6 +444,7 @@ def panel_status(asset_id: int):
field('CPU %', state.cpupercent),
field('Memory %', state.memorypercent),
field('Agent', state.agentversion, mono=True),
field('Tray', state.trayversion, mono=True),
field('Last reported',
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
]

View File

@@ -35,6 +35,7 @@ from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
from . import agentstore
# Prebuilt, staged agent binaries. Built out of band and dropped under
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
@@ -446,13 +447,23 @@ else {{
def _siteagent_status_payload():
"""Whether a prebuilt agent binary is staged for packaging."""
agent_staged = os.path.isfile(_staged_path(STAGED_AGENT_RELPATH))
tray_staged = os.path.isfile(_staged_path(STAGED_TRAY_RELPATH))
"""Whether a prebuilt agent binary is staged for packaging.
Reports both the legacy flat-file staging and the versioned store so the UI
can show current versions and an expandable retained-version history.
"""
agent_staged = agentstore.current_path('agent') is not None
tray_staged = agentstore.current_path('tray') is not None
return {
'agentstaged': agent_staged,
'traystaged': tray_staged,
'certavailable': _server_cert_path() is not None,
'agentcurrent': agentstore.current_version('agent'),
'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'),
}
@@ -545,11 +556,11 @@ def package_siteagent():
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
agent = agentstore.current_path('agent')
if agent and os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = _staged_path(STAGED_TRAY_RELPATH)
if os.path.isfile(tray):
tray = agentstore.current_path('tray')
if tray and os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
@@ -557,8 +568,16 @@ def package_siteagent():
archive.write(certpath, 'server.crt')
buffer.seek(0)
# Stamp the staged agent version into the download filename so a DT operator
# can see which agent build a package carries at a glance. This is cosmetic:
# install.ps1 finds the exe by its fixed name inside the ZIP, so the download
# name never affects the install. Falls back to the unversioned name when no
# managed version is current (only a legacy flat file is staged).
agentversion = agentstore.current_version('agent')
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
return send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name='shopdb-endpoint-agent.zip')
download_name=download_name)

View File

@@ -106,8 +106,9 @@
</div>
<div class="card form-card">
<h4>How it works</h4>
<ul class="field-hint info-list">
<details class="collapsible">
<summary><h4>How it works</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
@@ -127,9 +128,11 @@
the kill switch.
</li>
</ul>
</details>
<h4>When to rotate</h4>
<ul class="field-hint info-list">
<details class="collapsible">
<summary><h4>When to rotate</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
@@ -156,11 +159,12 @@
regenerate and redeploy to known-good endpoints.
</li>
</ul>
</details>
</div>
</section>
<!-- ====================================================================
Adoption tool (spike)
Adoption tool
================================================================= -->
<section class="settings-section">
<h3>Adoption tool</h3>
@@ -211,7 +215,7 @@
</section>
<!-- ====================================================================
Build site agent (placeholder)
Build site agent
================================================================= -->
<section class="settings-section">
<h3>Build site agent</h3>
@@ -227,10 +231,9 @@
</p>
<p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop
<code>shopdbagent.exe</code> beside them before deploying. Stage a
prebuilt agent under <code>instance/siteagent/</code> 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.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
@@ -303,6 +306,162 @@
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================
Agent binary versions: upload a new build, keep the last few,
and roll the current pointer back to a retained version. Staging
only changes what a FUTURE download contains; it does not touch
any running endpoint. Pushing to live endpoints is a separate,
heavier grant handled elsewhere. Collapsed by default - binary
management is infrequent next to building a package. -->
<details class="collapsible binary-manager">
<summary><h4>Upload New/Legacy Agents</h4></summary>
<div
v-for="binary in binaries"
:key="binary.key"
class="binary-block"
>
<h4>{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
</p>
<p class="field-hint">
Current staged version:
<strong>{{ binary.current || 'none staged yet - upload a build below' }}</strong>
</p>
<!-- Current release upload. The version is auto-detected from the
EXE's PE version resource on select; the operator can override.
This upload becomes the current staged build. -->
<label class="field-label">
Upload {{ binary.name }} binary current release
</label>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onBinaryFileChange(binary.key, $event)"
/>
<input
v-model="binary.version"
class="form-control"
type="text"
:placeholder="binary.detecting
? 'Reading version...'
: 'Version, auto-detected from EXE'"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
</button>
</div>
<!-- Rollback / legacy path. Stages a build into the version list
without displacing the current release so it is available to
roll back to. WARNING copy per intent: the version must be
confirmed from the EXE properties before uploading. -->
<label class="field-label">
Upload legacy build for rollback
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
</p>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onLegacyFileChange(binary.key, $event)"
/>
<input
v-model="binary.legacyversion"
class="form-control"
type="text"
placeholder="Version, e.g. 1.5.0"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
</button>
</div>
<p v-if="binary.message" class="field-hint">{{ binary.message }}</p>
<p v-if="binary.error" class="field-error">{{ binary.error }}</p>
<!-- Version selector: pick the staged build shipped by the next
download. Ordered by version number (highest first). -->
<div v-if="binary.versions.length" class="binary-upload">
<label class="field-label" :for="`select-${binary.key}`">
Active version
</label>
<select
:id="`select-${binary.key}`"
class="form-control"
:value="binary.current || ''"
:disabled="binary.busy"
@change="setCurrentBinary(binary.key, $event.target.value)"
>
<option
v-for="entry in binary.versions"
:key="entry.version"
:value="entry.version"
>
{{ entry.version }}{{ entry.iscurrent ? ' (current)' : '' }}
</option>
</select>
</div>
<div v-if="binary.versions.length" class="previous-versions">
<button
class="btn-link"
type="button"
@click="binary.showprevious = !binary.showprevious"
>
{{ binary.showprevious ? 'Hide' : 'Show' }} staged versions
({{ binary.versions.length }})
</button>
<table v-if="binary.showprevious" class="versions-table">
<thead>
<tr>
<th>Version</th>
<th>Uploaded</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="entry in binary.versions" :key="entry.version">
<td class="mono">{{ entry.version }}</td>
<td>{{ formatDate(entry.uploadedat) }}</td>
<td>{{ formatBytes(entry.size) }}</td>
<td>
<span v-if="entry.iscurrent" class="current-tag">current</span>
<button
v-else
class="btn-link"
type="button"
:disabled="binary.busy"
@click="setCurrentBinary(binary.key, entry.version)"
>
Make current
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
</section>
@@ -350,7 +509,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)
@@ -370,12 +529,45 @@ const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
// Versioned agent/tray binaries. Each block owns its own upload form, retained
// 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: '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)',
name: 'Tray Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false },
])
function binaryFor(binkey) {
return binaries.value.find((entry) => entry.key === binkey)
}
function formatBytes(value) {
if (!value && value !== 0) return ''
if (value < 1024) return value + ' B'
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB'
return (value / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
}
function filenameFromResponse(response, fallback) {
const disposition = response.headers?.['content-disposition'] || ''
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
return match ? decodeURIComponent(match[1]) : fallback
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
@@ -401,11 +593,20 @@ onMounted(async () => {
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
} catch (statusError) {
console.error(statusError)
}
})
function applyBinaryStatus(binkey, current, versions) {
const binary = binaryFor(binkey)
if (!binary) return
binary.current = current || null
binary.versions = Array.isArray(versions) ? versions : []
}
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
@@ -528,7 +729,10 @@ async function buildSiteAgent() {
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = 'shopdb-endpoint-agent.zip'
// Honor the server's versioned download name (shopdb-endpoint-agent-<ver>.zip)
// from Content-Disposition so operators see the agent version in the file
// name; fall back to the fixed name when the header is absent.
link.download = filenameFromResponse(response, 'shopdb-endpoint-agent.zip')
document.body.appendChild(link)
link.click()
link.remove()
@@ -548,6 +752,123 @@ 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] : ''
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) {
const binary = binaryFor(binkey)
if (!binary || !binary.file || !binary.version) return
binary.busy = true
binary.message = ''
binary.error = ''
try {
const form = new FormData()
form.append('version', binary.version)
form.append('file', binary.file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
binary.file = ''
binary.message = 'Uploaded and set as current.'
} 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 binary')
console.error(uploadError)
} finally {
binary.busy = false
}
}
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
if (!window.confirm(`Make version ${version} the current staged build? `
+ 'New downloads will ship this version.')) {
return
}
binary.busy = true
binary.message = ''
binary.error = ''
try {
const response = await api.put(
`/endpoints/siteagent/binaries/${binkey}/current`, { version })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.message = `Version ${version} is now current.`
} catch (setError) {
binary.error = setError.response?.data?.data?.error?.message
|| 'Could not change the current version'
console.error(setError)
} finally {
binary.busy = false
}
}
function editConfiguration() {
router.push({ name: 'endpoints-monitors-settings' })
}
@@ -585,4 +906,72 @@ function editConfiguration() {
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
.binary-block {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
}
.binary-upload {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.binary-upload .form-control[type="text"] { max-width: 12rem; }
.previous-versions { margin-top: 0.5rem; }
.collapsible { margin: 0.5rem 0; }
.collapsible > summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 0.4rem;
user-select: none;
}
.collapsible > summary::-webkit-details-marker { display: none; }
.collapsible > summary::before {
content: "\25be";
display: inline-block;
transform: rotate(-90deg);
transition: transform 0.15s ease;
color: var(--color-text-muted, #888);
}
.collapsible[open] > summary::before { transform: rotate(0deg); }
.collapsible > summary h4 { margin: 0; }
.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;
color: var(--color-link, #4ea1ff);
cursor: pointer;
padding: 0;
font-size: 0.9rem;
text-decoration: underline;
}
.btn-link:disabled { color: var(--color-text-muted, #888); cursor: default; }
.versions-table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
font-size: 0.85rem;
}
.versions-table th,
.versions-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.current-tag {
font-size: 0.8rem;
color: var(--color-success, #4caf50);
font-weight: 600;
}
</style>

View File

@@ -342,7 +342,9 @@ async function loadEndpoints() {
// navigates when it resolved to a computerid.
function openPc(item) {
if (item.computerid) {
router.push(`/pcs/${item.computerid}`)
// Tag the origin so PC Details and the edit form return here (Fleet)
// instead of the PCs list. Absent for the normal PCs-list entry path.
router.push(`/pcs/${item.computerid}?from=fleet`)
}
}

View File

@@ -0,0 +1,49 @@
"""Cache the interactive tray app version on the endpoint state.
Adds endpointstates.trayversion: the file version of the ShopDB endpoint tray
app, reported by the agent alongside its own agentversion. The tray and the
agent service ship and update independently (they are separate exes and can be
pushed apart), so the tray version is stored as its own field rather than
assumed equal to the agent (AGENT-CONTRACT.md 2).
Nullable so an endpoint with no tray installed, or an older agent that predates
the field, reads as "unknown" rather than an empty string.
Idempotent: the column-add checks the live schema first, so a db.create_all()
site (which built the column from the model already) and a re-run both stay
clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0011trayversion'
down_revision = 'endpoints0010renamepolicy'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if _has_column('endpointstates', 'trayversion'):
return
op.add_column(
'endpointstates',
sa.Column('trayversion', sa.String(length=40), nullable=True))
def downgrade():
if not _has_column('endpointstates', 'trayversion'):
return
op.drop_column('endpointstates', 'trayversion')

View File

@@ -59,6 +59,11 @@ class EndpointState(db.Model):
hostname = db.Column(db.String(100), nullable=True)
loggedinuser = db.Column(db.String(100), nullable=True)
agentversion = db.Column(db.String(40), nullable=True)
# The interactive tray app versions independently of the agent service, so
# it is reported and cached as its own field (AGENT-CONTRACT.md 2). Null
# when the endpoint has no tray installed or an older agent that predates
# the field.
trayversion = db.Column(db.String(40), nullable=True)
# Fast-moving metrics
lastboottime = db.Column(db.DateTime, nullable=True)
@@ -105,6 +110,7 @@ class EndpointState(db.Model):
'hostname': self.hostname,
'loggedinuser': self.loggedinuser,
'agentversion': self.agentversion,
'trayversion': self.trayversion,
'lastboottime': _utciso(self.lastboottime),
'uptimeseconds': self.uptimeseconds,
'idleseconds': self.idleseconds,

View File

@@ -49,7 +49,7 @@ CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
KNOWN_PAYLOAD_KEYS = frozenset({
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'applications', 'events',
})
@@ -167,19 +167,40 @@ class EndpointsPlugin(BasePlugin):
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
# It now also governs SUPPLYING the binary, not just wrapping it.
# Before the agent store, the exe could only be staged by someone
# with filesystem access to instance/siteagent/; the upload routes
# put that behind this same grant, so a holder chooses the file
# every future package installs and runs as SYSTEM. Whoever grants
# this has to be able to see that from the description.
('endpoints.agentpackage',
'Upload the agent binary the fleet installs, and generate or '
'download an agent or adoption-tool package (supplies an '
'executable that runs as SYSTEM; the package contains a live '
'collector key)', 'endpoints'),
# Pushing an agent REPLACEMENT to running endpoints is a distinct,
# heavier job than either packaging (endpoints.agentpackage) or
# pushing an ordinary application installer (endpoints.deploy).
# Kept separate on purpose: the agent runs as SYSTEM and is the
# command channel itself, so replacing it is self-modifying the
# control plane. Folding it into endpoints.deploy would let anyone
# who can push an app silently swap the agent; folding it into
# endpoints.agentpackage would let a packager who only mints ZIPs
# also force a live fleet-wide swap. A future Operator role can
# hold upload without this, or this without upload.
('endpoints.agentupdate',
'Push agent/tray binary updates to running endpoints '
'(replaces the SYSTEM agent)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
# touching computers/geenforce collectors. Minted from the settings
@@ -575,6 +596,7 @@ class EndpointsPlugin(BasePlugin):
},
'loggedinuser': {'type': 'string'},
'agentversion': {'type': 'string'},
'trayversion': {'type': 'string'},
'lastboottime': {'type': 'string', 'format': 'date-time'},
'uptimeseconds': {'type': 'integer'},
'idleseconds': {'type': 'integer'},
@@ -786,6 +808,8 @@ class EndpointsPlugin(BasePlugin):
state.loggedinuser = payload.get('loggedinuser')
if 'agentversion' in payload:
state.agentversion = payload.get('agentversion')
if 'trayversion' in payload:
state.trayversion = payload.get('trayversion')
if 'lastboottime' in payload:
state.lastboottime = _parse_dt(payload.get('lastboottime'))
if 'uptimeseconds' in payload: