Import the wilm agent store, WINC adoption work and batches CLI, with four fixes
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
472
plugins/endpoints/api/agentstore.py
Normal file
472
plugins/endpoints/api/agentstore.py
Normal 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
|
||||
231
plugins/endpoints/api/agentupload.py
Normal file
231
plugins/endpoints/api/agentupload.py
Normal 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),
|
||||
})
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user