Add endpoint software delivery: run_script and update_app push

Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built.
This commit is contained in:
benjamin.baker
2026-08-30 14:40:51 -04:00
committed by cproudlock
parent 7ac1772881
commit 4590156f30
11 changed files with 1493 additions and 12 deletions

View File

@@ -687,6 +687,43 @@ export const applicationsApi = {
}
}
// ShopDB-hosted script library. Backs run_script delivery: the agent fetches a
// script body by digest with its collector credential and runs it in the
// script's configured run context (system or user).
export const scriptsApi = {
list(params = {}) {
return api.get('/scripts', { params })
},
listAll(params = {}) {
return fetchAllPages('/scripts', params)
},
get(id) {
return api.get(`/scripts/${id}`)
},
create(data) {
return api.post('/scripts', data)
},
update(id, data) {
return api.put(`/scripts/${id}`, data)
},
delete(id) {
return api.delete(`/scripts/${id}`)
},
// script-body upload; backend sets filename/sha256 to the stored payload.
uploadBody(id, file, onProgress) {
const form = new FormData()
form.append('file', file)
return api.post(`/scripts/${id}/body`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: event => {
if (onProgress && event.total) {
onProgress(Math.round((event.loaded * 100) / event.total))
}
}
})
}
}
// Support Teams API (teams + nested contacts)
export const supportteamsApi = {
list(params = {}) {

View File

@@ -0,0 +1,112 @@
# Delivery testing notes (update_app + run_script)
Temporary field notes for exercising the endpoint delivery path end to end and
for handing the UI work to Cameron. ASCII only, no em-dashes, no emojis, per
repo style. Remove the dev test bench (view, route, nav entry) once the
production delivery UI lands.
## What exists today (the contract Cameron builds against)
Server allowlist (`plugins/endpoints/commands.py`):
- `run_script` - targetkind `script`. `target` is a script NAME the agent
already holds in its local `scriptpaths` allowlist. No body or path crosses
the wire.
- `update_app` - targetkind `app`. `target` is an `appversionid` that resolves
to a ShopDB-hosted installer package. The server pins the package by SHA-256
and passes this version's `silentargs`.
Both actions require `endpoints.deploy` ON TOP OF `endpoints.command`. Queueing
one is the actual authority (audited with the requesting user). The agent claims
it with its collector credential.
Enqueue call (same route as every other endpoint command):
```
POST /api/endpoints/<assetid>/commands
{ "action": "run_script", "target": "remove-network-drives" }
{ "action": "update_app", "target": "<appversionid>" }
```
Rules the UI must respect:
- At most one pending/running command per endpoint. A second enqueue returns
409 (treat as "already in flight", not a failure).
- `update_app` is validated at enqueue: an appversionid with no installer
package or unknown id is rejected 400 before it is queued.
- A version with no `sha256` has no uploaded package yet. Do not offer it for
`update_app`; the agent's integrity check will fail closed.
Read the result back:
```
GET /api/endpoints/<assetid>/commands
```
Each row carries `status` (pending/running/succeeded/failed), `resultmessage`
(short summary) and `resultjson` (structured detail: for run_script the exit
code and script path; for update_app the installer path, exit code and digest
check).
Agent-facing package download (collector-authenticated, not user JWT):
```
GET /api/endpoints/apppackage/<appversionid> (X-API-Key collector token)
```
## The dev test bench
Route: `/endpoints/delivery-test` (sidebar "Delivery test bench", gated on
`endpoints.deploy`). It is deliberately minimal: pick an endpoint, push a script
by name or push an app version, then watch the command log pane. There is no
script-library component yet, so `run_script` takes a free-text name; the agent
runs it only if that name is in its `scriptpaths`.
## Preparing the test endpoint
1. Copy the dev build to the test box:
- `agent\dev-bin\agent\shopdbagent.exe`
- `agent\dev-bin\agent\appsettings.json`
Stage them the same way the site installer does (service exe + appsettings).
2. Provision the collector credential and base URL. Per repo policy these are
NOT in appsettings.json; set the registry values under
`HKLM\SOFTWARE\GE\ShopDB` (`CollectorKey`, `BaseUrl`). The shipped
appsettings has `baseurl` empty and `apikey` = REPLACE_WITH_COLLECTOR_KEY so
the agent fails closed until provisioned.
3. Stage the sample script where appsettings points:
`C:\ProgramData\ShopDB\EndpointAgent\scripts\remove-network-drives.ps1`
(source: `agent\dev-bin\scripts\remove-network-drives.ps1`). The
`scriptpaths` entry in appsettings.json maps the name `remove-network-drives`
to that path.
4. For an `update_app` test, create the application in the Applications module,
upload its installer package (this stamps the version's `sha256`), and note
the version's `appversionid`.
## Collecting logs
Endpoint side (the agent):
- Rolling diagnostics log: `%ProgramData%\ShopDB\EndpointAgent\logs`
(override with `logpath` in appsettings). This is where run_script and
update_app record what they resolved, the digest check, and the installer or
script exit code.
- Windows Event Log source `ShopDB Endpoint Agent` for service faults.
Web UI / ShopDB side:
- The command log pane on the bench (or `GET /api/endpoints/<assetid>/commands`)
shows `status`, `resultmessage` and `resultjson` reported by the agent.
- Ingest observability: unknown/unpersisted telemetry keys are logged by the
endpoints plugin on the API container, so incoming agent data is visible in
the app logs even before UI persistence catches up.
- Audit log records each enqueue (`created EndpointCommand`, action + target +
asset) with the requesting user.
## Notes to capture during testing (for Cameron)
- Round-trip latency from enqueue to a terminal status, at the agent cadence.
- Whether `resultmessage` alone is enough for the operator, or the UI needs to
surface `resultjson` (exit codes, digest mismatches).
- How a 409 (already in flight) should read in the real UI.
- What a failed digest check or non-zero installer exit looks like end to end,
so the production UI can present it clearly.

View File

@@ -3,5 +3,7 @@
from .routes import endpoints_bp
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
__all__ = ['endpoints_bp']

View File

@@ -6,7 +6,9 @@ service token, and are handled by EndpointsPlugin.apply_collector_payload.
These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import hashlib
import json
import os
import time
from datetime import datetime, timedelta, timezone
@@ -19,6 +21,7 @@ from shopdb.api import (
require_role, caller_holds, mint_service_token, list_service_tokens,
revoke_service_tokens,
AuditLog, collector_authorized, User,
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
@@ -992,6 +995,159 @@ def _resolve_asset_for_hostname(hostname):
return computer.assetid if computer is not None else None
_HOSTED_PACKAGE_PREFIX = '/api/applications/package/'
def _hosted_package_filename(application):
"""Return the stored filename for a ShopDB-hosted installer, or None.
installpath is the core package URL (/api/applications/package/<filename>);
a site-typed share path (not our upload) has no servable file here.
"""
installpath = application.installpath or ''
if not installpath.startswith(_HOSTED_PACKAGE_PREFIX):
return None
return installpath[len(_HOSTED_PACKAGE_PREFIX):]
def _backfill_app_installer_sha256(application):
"""Compute and persist installersha256 for a pre-migration package.
Packages uploaded before the 7d36 migration have a null installersha256,
which used to force a re-upload (Bug B). When the hosted package file is
present on disk, hash it once, stamp the app, and commit so future resolves
hit the stored value. Returns the digest, or None when no servable file.
"""
filename = _hosted_package_filename(application)
if not filename:
return None
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
package_path = os.path.join(package_dir, filename)
if not os.path.isfile(package_path):
return None
digest = hashlib.sha256()
with open(package_path, 'rb') as package_file:
for chunk in iter(lambda: package_file.read(65536), b''):
digest.update(chunk)
application.installersha256 = digest.hexdigest()
db.session.commit()
return application.installersha256
def _resolve_update_app(target):
"""Resolve an update_app target to a delivery descriptor.
Accepts either an app-level or a version-level source, so a push works
whether or not the site tracks AppVersion rows (the applications UI stores a
single app-level installer; versioning is optional):
- "app:<appid>" - the app's own uploaded installer (installersha256)
- "version:<id>" - a specific AppVersion's pinned package
- bare "<int>" - legacy: treated as an appversionid
Returns (descriptor, error_message). The descriptor carries what the agent
needs to fetch and run the pinned installer: a collector-authenticated
download URL, its SHA-256, and the silent-install switches. The agent
fetches the bytes with its existing collector credential; no bytes here.
"""
kind, _sep, raw = str(target or '').partition(':')
if not raw:
# No prefix: legacy bare appversionid.
kind, raw = 'version', kind
try:
ident = int(raw)
except (TypeError, ValueError):
return None, 'update_app target must be app:<id>, version:<id>, or an appversionid'
if kind == 'app':
application = db.session.get(Application, ident)
if application is None:
return None, f'Unknown appid: {ident}'
if not application.installpath:
return None, (f'Application {ident} has no uploaded installer package')
# Backfill installersha256 for packages uploaded before the 7d36 migration
# so app-level delivery resolves without a re-upload (Bug B).
sha256 = application.installersha256 or _backfill_app_installer_sha256(application)
if not sha256:
return None, (f'Application {ident} has no uploaded installer package')
# Carry the real installer filename (with extension) on the delivery URL so
# the agent's Path.GetExtension routes MSI through msiexec (Bug A rc2). The
# trailing filename is cosmetic for the agent; the app_id drives the lookup.
filename = _hosted_package_filename(application)
installpath = '/api/endpoints/apppackage/app/{}'.format(application.appid)
if filename:
installpath = '{}/{}'.format(installpath, filename)
return {
'source': 'app',
'appid': application.appid,
'appversionid': None,
'appname': application.appname,
'version': None,
'installpath': installpath,
'sha256': sha256,
'silentargs': None,
}, None
if kind == 'version':
version = db.session.get(AppVersion, ident)
if version is None:
return None, f'Unknown appversionid: {ident}'
application = db.session.get(Application, version.appid)
if application is None or not application.installpath:
return None, ('Application for appversionid {} has no installer package'
.format(ident))
return {
'source': 'version',
'appversionid': version.appversionid,
'appid': version.appid,
'appname': application.appname,
'version': version.version,
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
'sha256': version.sha256,
'silentargs': version.silentargs,
}, None
return None, f'Unknown update_app target kind: {kind!r}'
def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
Accepts either "script:<scriptid>" or a bare script name. The descriptor
carries a collector-authenticated download URL, the body's SHA-256, and the
per-script run context (system or user). The agent fetches the body with
its collector credential and runs it; the body never crosses the wire.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
if kind == 'script' and raw:
try:
script = db.session.get(Script, int(raw))
except (TypeError, ValueError):
return None, 'run_script target script:<id> must be numeric'
else:
# Bare name (the whole target, prefix included when there was no colon).
name = (target or '').strip()
if name:
script = Script.query.filter_by(scriptname=name).first()
if script is None:
return None, f'Unknown script: {target!r}'
if not script.filename or not script.sha256:
return None, f'Script {script.scriptname!r} has no uploaded body'
if not script.isactive:
return None, f'Script {script.scriptname!r} is inactive'
return {
'scriptid': script.scriptid,
'scriptname': script.scriptname,
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
'sha256': script.sha256,
'runcontext': script.runcontext,
}, None
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_commands(asset_id: int):
@@ -1050,6 +1206,22 @@ def enqueue_endpoint_command(asset_id: int):
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
if action == 'update_app':
_descriptor, resolve_error = _resolve_update_app(target)
if resolve_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
# run_script pins a ShopDB-hosted script; verify it resolves to a real
# uploaded body now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint later.
if action == 'run_script':
_script, script_error = _resolve_run_script(target)
if script_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
@@ -1126,12 +1298,33 @@ def claim_next_command():
command.status = EndpointCommand.STATUS_RUNNING
command.claimedat = _naive_utcnow()
db.session.commit()
return success_response({
claim = {
'endpointcommandid': command.endpointcommandid,
'action': command.action,
'target': command.target,
'targetkind': command.targetkind,
})
}
# update_app rides a resolved delivery descriptor: the agent gets
# the package URL, its pinned SHA-256, and the version's silent-
# install switches, and fetches the bytes with its collector
# credential. If the version was deleted between enqueue and claim,
# send the command through with no descriptor and let the agent
# fail closed rather than 500 the poll.
if command.action == 'update_app':
descriptor, _resolve_error = _resolve_update_app(command.target)
if descriptor is not None:
claim['app'] = descriptor
# run_script rides a resolved script descriptor: the agent gets a
# collector-authenticated body URL, the pinned SHA-256, and the
# per-script run context. Fetch happens agent-side with the
# collector credential. If the script vanished between enqueue and
# claim, send the command through with no descriptor and let the
# agent fail closed rather than 500 the poll.
if command.action == 'run_script':
script_descriptor, _script_error = _resolve_run_script(command.target)
if script_descriptor is not None:
claim['script'] = script_descriptor
return success_response(claim)
# Nothing queued: release the session so a concurrent enqueue is visible
# on the next poll, wait a beat, then re-check until the hold expires.
@@ -1180,3 +1373,106 @@ def report_command_result(command_id: int):
db.session.commit()
return success_response(command.to_dict(), message='Result recorded')
@endpoints_bp.route('/apppackage/<int:appversionid>', methods=['GET'])
def download_app_package(appversionid: int):
"""Agent-facing installer download (collector-token auth).
The core /api/applications/package route is gated on applications.view, a
permission the endpoint agent's collector token does not hold. This route
authorizes on the same endpoints collector token the agent already uses for
telemetry and command polling, so the whole app-delivery path rides one key.
Serves the exact bytes the update_app descriptor pinned by SHA-256.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
version = db.session.get(AppVersion, appversionid)
if version is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown appversionid: {appversionid}', http_code=404)
application = db.session.get(Application, version.appid)
if application is None or not application.installpath:
return error_response(ErrorCodes.NOT_FOUND,
'No installer package for this application',
http_code=404)
# installpath is the core package URL (/api/applications/package/<filename>);
# take the stored filename and serve it from the core package directory. A
# site-typed share path (not our upload) has no servable file here.
prefix = '/api/applications/package/'
if not application.installpath.startswith(prefix):
return error_response(ErrorCodes.NOT_FOUND,
'Application installer is not a ShopDB-hosted package',
http_code=404)
filename = application.installpath[len(prefix):]
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
return send_upload(package_dir, filename, as_attachment=True)
@endpoints_bp.route('/apppackage/app/<int:app_id>', methods=['GET'])
@endpoints_bp.route('/apppackage/app/<int:app_id>/<path:filename>', methods=['GET'])
def download_app_package_by_app(app_id: int, filename: str = None):
"""Agent-facing installer download by APP (collector-token auth).
The app-level counterpart to download_app_package: serves the application's
own uploaded installer (installpath + installersha256) so update_app can
resolve an install source by app alone, with no AppVersion row. Same
collector-token auth and same package directory as the per-version route.
The optional trailing <filename> segment lets the delivery URL carry the
real installer extension (so the agent routes MSI through msiexec); it is
validated against the stored filename and does not drive the lookup.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
application = db.session.get(Application, app_id)
if application is None or not application.installpath:
return error_response(ErrorCodes.NOT_FOUND,
'No installer package for this application',
http_code=404)
prefix = '/api/applications/package/'
if not application.installpath.startswith(prefix):
return error_response(ErrorCodes.NOT_FOUND,
'Application installer is not a ShopDB-hosted package',
http_code=404)
stored_filename = application.installpath[len(prefix):]
if filename is not None and filename != stored_filename:
return error_response(ErrorCodes.NOT_FOUND,
'Requested installer filename does not match this application',
http_code=404)
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
return send_upload(package_dir, stored_filename, as_attachment=True)
@endpoints_bp.route('/scriptbody/<int:script_id>', methods=['GET'])
def download_script_body(script_id: int):
"""Agent-facing script body download (collector-token auth).
The core /api/scripts/body route is gated on applications.view, which the
endpoint agent's collector token does not hold. This route authorizes on
the same endpoints collector token the agent already uses, keeping the
whole script-delivery path on one key. Serves the exact bytes the
run_script descriptor pinned by SHA-256.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
script = db.session.get(Script, script_id)
if script is None or not script.filename:
return error_response(ErrorCodes.NOT_FOUND,
'No body for this script', http_code=404)
script_dir = os.path.join(current_app.instance_path, 'scripts')
return send_upload(script_dir, script.filename, as_attachment=True)

View File

@@ -0,0 +1,439 @@
"""Site agent generator packaging.
Builds a deployable, instance-locked endpoint-agent package for this site. An
admin supplies the server base URL, the managed endpoint agent key (minted in
the key generator above), and the reporting cadence; this route returns a ZIP
carrying the prebuilt shopdbagent.exe, a non-secret appsettings.json, an
install.ps1 that provisions the credentials, and a README.
This module is deliberately self-contained so it can be maintained alongside
the endpoint agent without editing routes.py. It attaches two admin-gated
routes to the existing endpoints blueprint.
The container has no .NET SDK, so - exactly like the adoption tool - the agent
exe is built out of band and staged under instance/siteagent/. When a prebuilt
binary is present it ships in the ZIP; otherwise the ZIP still carries the
config and installer so an operator can drop the exe in beside them.
Per repo policy the live CollectorKey and BaseUrl are provisioned into the
Windows registry (HKLM\\SOFTWARE\\GE\\ShopDB), never baked into appsettings.json.
The generated install.ps1 writes those two registry values from the operator's
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
the agent fails closed until provisioned. The collector key is embedded only in
the install script's default parameter and is never stored or logged here.
"""
import io
import json
import os
import ssl
import zipfile
from flask import request, current_app, send_file
from shopdb.api import success_response, error_response, ErrorCodes, require_role
from .routes import endpoints_bp
# Prebuilt, staged agent binaries. Built out of band and dropped under
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
# Public TLS certificate the agent must trust when this deployment terminates
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
# terminates TLS with certs/server.crt). When a site fronts the app with its own
# reverse proxy the app cannot see the proxy's cert, so bundling is opt-in and
# only offered when a readable cert file is actually found here. Resolution order:
# the SHOPDB_TLS_CERT env var, then the mounted container path, then the repo
# path. Only a PEM public certificate is ever read - never a private key.
_CERT_CANDIDATES = (
os.environ.get('SHOPDB_TLS_CERT') or '',
'/app/certs/server.crt',
os.path.join(os.getcwd(), 'certs', 'server.crt'),
)
def _server_cert_path():
"""Path to a readable public TLS cert to bundle, or None if none exists."""
for candidate in _CERT_CANDIDATES:
if candidate and os.path.isfile(candidate):
return candidate
return None
def _validate_pem_cert(raw):
"""Return normalized PEM text for a valid X.509 certificate, else None.
Accepts operator-uploaded .crt/.pem bytes. Parses every PEM certificate
block through the stdlib ssl module (which round-trips it to DER, so junk
or a non-certificate PEM such as a private key is rejected). The returned
text is the concatenation of the re-encoded blocks, so a leaf-plus-chain
upload is preserved while any trailing garbage is dropped.
"""
try:
text = raw.decode('ascii')
except (UnicodeDecodeError, AttributeError):
return None
marker = '-----BEGIN CERTIFICATE-----'
if marker not in text:
return None
blocks = []
start = text.find(marker)
while start != -1:
end = text.find('-----END CERTIFICATE-----', start)
if end == -1:
return None
end += len('-----END CERTIFICATE-----')
block = text[start:end] + '\n'
try:
ssl.PEM_cert_to_DER_cert(block)
except (ssl.SSLError, ValueError):
return None
blocks.append(block)
start = text.find(marker, end)
if not blocks:
return None
return ''.join(blocks)
# The non-secret runtime config shipped in the package. baseurl and apikey stay
# empty/placeholder so the agent fails closed until the registry is provisioned
# by install.ps1. Cadence is stamped from the operator's input at build time.
_BASE_APPSETTINGS = {
'baseurl': '',
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
'cadenceseconds': 900,
'jitterseconds': 60,
'adoptonfirstrun': True,
'utilitiespath': '',
'logpath': '',
'monitors': {
'processes': [
{'name': 'dncMain.exe', 'app': 'eDNC'},
{'name': 'udc.exe', 'app': 'Universal Data Collector'},
{'name': 'agent.exe', 'app': 'MTConnect'},
],
'services': [
{'name': 'MTConnect UDC Agent', 'app': 'MTConnect'},
],
},
'launchpaths': {
'DncMain.exe': 'C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe',
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
}
_README = """ShopDB endpoint agent (site build)
WHAT THIS IS
An instance-locked endpoint agent for this site. It installs as a Windows
service, reports identity, hardware, and installed-software inventory on a
fixed cadence, and executes delivery commands (scripts, app updates) issued
from this ShopDB server. Unlike the adoption tool it stays resident.
FILES
shopdbagent.exe The agent service host (present only if the server
had a prebuilt copy staged).
shopdb-endpoint-tray.exe Optional interactive tray app (present when staged).
appsettings.json Non-secret runtime config (cadence, monitors,
launch paths). No credentials.
install.ps1 Provisions the credentials and installs the service.
server.crt Server TLS certificate (present only for self-signed
or internal-CA deployments, either auto-detected or
uploaded by the operator). install.ps1 imports it
into the machine Trusted Root store so the agent can
validate its outbound TLS connection to the server.
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service.
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
"""
def _staged_path(relpath):
"""Absolute path to a staged binary under the instance directory."""
return os.path.join(current_app.instance_path, relpath)
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
"""The install.ps1 that provisions credentials and installs the service.
The operator's server URL and collector key are stamped as the default
parameter values so a double-click install needs no arguments. The key is
written only to the registry (HKLM\\SOFTWARE\\GE\\ShopDB), never into
appsettings.json, honoring the registry-provisioning contract. When a server
certificate is bundled, certfilename names it and the script imports it into
the machine Trusted Root store so the agent's outbound TLS to a self-signed
or internal-CA server validates.
"""
baseurl_literal = json.dumps(baseurl)
collectorkey_literal = json.dumps(collectorkey)
certfile_literal = json.dumps(certfilename or '')
return """# install.ps1
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Provisions the registry credentials, copies the agent to
# Program Files, and installs and starts the Windows service. The server URL and
# collector key are stamped as defaults by the site agent generator.
[CmdletBinding()]
param(
[string] $BaseUrl = {baseurl},
[string] $CollectorKey = {collectorkey},
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[string] $CertFileName = {certfile},
[switch] $NoTray
)
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}}
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}}
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
}}
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
# The agent reads Registry64 first then Registry32 and uses the first view that
# yields a value, so a stale WOW6432Node copy from a prior install (for example
# an old http:// BaseUrl) would silently win if we only wrote the 64-bit view.
# Writing both keeps the two views in lockstep so re-provisioning cannot be
# shadowed by a leftover value.
function Set-HklmValueBothViews {{
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {{
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {{
$key = $base.CreateSubKey($SubKey)
try {{ $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) }}
finally {{ $key.Dispose() }}
}}
finally {{ $base.Dispose() }}
}}
}}
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {{
Write-Host "[0/4] Removing existing service $ServiceName ..."
if ($existing.Status -ne "Stopped") {{
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
}}
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}}
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @("shopdbagent", $trayBase)) {{
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {{
try {{ Stop-Process -Id $p.Id -Force -ErrorAction Stop }} catch {{ }}
}}
}}
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
# TLS to a self-signed or internal-CA server fails closed until the machine
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
# certificate, in which case no cert is bundled.
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
$certPath = Join-Path $sourceDir $CertFileName
if (Test-Path $certPath) {{
Write-Host " trusting bundled server certificate $CertFileName ..."
try {{
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
}}
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
}}
else {{
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
}}
}}
# 2. Copy agent files to the install directory.
Write-Host "[2/4] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
}}
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {{
throw "shopdbagent.exe not found in $sourceDir. This package did not ship a prebuilt agent; stage the exe beside install.ps1 and rerun."
}}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
if (Test-Path $appsettings) {{
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}}
if (-not $NoTray) {{
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {{
Copy-Item -Path $traySource -Destination $InstallDir -Force
}}
}}
# 3. Install and start the service. The agent supports its own service install.
Write-Host "[3/4] Installing and starting the service ..."
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
$binPath = '"' + $installedAgent + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe start $ServiceName | Out-Null
Write-Host "[4/4] Done." -ForegroundColor Green
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
certfile=certfile_literal,
)
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))
return {
'agentstaged': agent_staged,
'traystaged': tray_staged,
'certavailable': _server_cert_path() is not None,
}
@endpoints_bp.route('/siteagent', methods=['GET'])
@require_role('admin')
def get_siteagent():
"""Report whether the site agent build has a prebuilt binary staged."""
return success_response(_siteagent_status_payload())
@endpoints_bp.route('/siteagent/package', methods=['POST'])
@require_role('admin')
def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.
Accepts either JSON or multipart/form-data with fields baseurl,
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
X.509 and bundled so the installer trusts it as a root on the endpoint. This
is the reverse-proxy path where the app cannot see its own TLS cert. When no
cert is uploaded but bundlecert is set and this app terminates TLS with a
readable certificate, that auto-detected cert ships instead.
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
present), a non-secret appsettings.json, install.ps1, and README.txt. The
collector key is embedded only in install.ps1 as its default parameter and
is never stored or logged.
"""
if request.form or request.files:
source = request.form
certupload = request.files.get('certfile')
else:
source = request.get_json(silent=True) or {}
certupload = None
baseurl = (source.get('baseurl') or '').strip()
collectorkey = (source.get('collectorkey') or '').strip()
cadenceseconds = source.get('cadenceseconds', 900)
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
if not baseurl or not collectorkey:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'baseurl and collectorkey are required',
http_code=400)
try:
cadenceseconds = int(cadenceseconds)
except (TypeError, ValueError):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cadenceseconds must be an integer',
http_code=400)
if cadenceseconds < 60:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cadenceseconds must be at least 60',
http_code=400)
appsettings = dict(_BASE_APPSETTINGS)
appsettings['cadenceseconds'] = cadenceseconds
# An operator-uploaded cert wins over the auto-detected server cert: it is
# the explicit reverse-proxy answer where the app cannot see its own TLS
# cert. Validate it as X.509 and bundle the normalized PEM. Fall back to the
# app-owned cert only when bundlecert is set and no upload was provided.
cert_pem = None
certpath = None
if certupload is not None and certupload.filename:
cert_pem = _validate_pem_cert(certupload.read())
if cert_pem is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'certfile is not a valid PEM X.509 certificate',
http_code=400)
elif bundlecert:
certpath = _server_cert_path()
certfilename = 'server.crt' if (cert_pem or certpath) else None
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = _staged_path(STAGED_TRAY_RELPATH)
if os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
buffer.seek(0)
return send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name='shopdb-endpoint-agent.zip')

View File

@@ -77,6 +77,22 @@ COMMAND_ALLOWLIST = {
'label': 'Collect now',
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
},
'run_script': {
'label': 'Run script',
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
'fetches the script body by digest using its collector '
'credential and runs it in the script\'s configured '
'context (system or the logged-on user).',
'targetkind': 'script',
},
'update_app': {
'label': 'Install/update application',
'description': 'Push a ShopDB-hosted installer for an application (by app '
'or by a specific version). The agent fetches the pinned '
'package by digest using its collector credential and runs '
'it silently.',
'targetkind': 'app',
},
}
@@ -85,6 +101,8 @@ COMMAND_ALLOWLIST = {
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'run_script': 'endpoints.deploy',
'update_app': 'endpoints.deploy',
}

View File

@@ -17,6 +17,16 @@ export default [
name: 'endpoints-agentkey-settings',
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production
// delivery UI lands.
{
path: 'endpoints/delivery-test',
name: 'endpoints-delivery-test',
component: () => import('./views/DeliveryTestBench.vue'),
meta: { requiresAuth: true, plugin: 'endpoints' }
}
]

View File

@@ -14,6 +14,16 @@
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<p class="key-warning">
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
</p>
<p class="field-hint">
Before you generate a key, open the <strong>ShopDB Secure Send</strong>
tool in a second window. As soon as the key appears, paste it into
Secure Send, generate a secure link, and send that link to yourself in
Teams so the site leader has a one-time, self-destructing backup. Never
paste the raw key into a chat message or a plain file.
</p>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
@@ -75,8 +85,19 @@
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
It will not be shown again. Configure it as the agent
<code>X-API-Key</code> header value.
Anyone with this key can report as an endpoint agent for this site.
Treat it like a password. If it is lost you must regenerate, which
revokes the old key and forces every agent to be reconfigured.
</p>
<p class="field-hint">
<strong>Recommended:</strong> before you leave this page, open the
<strong>ShopDB Secure Send</strong> tool in a second window, paste
this key, generate a secure link, and send that link to yourself in
Teams. That gives the site leader a one-time, self-destructing copy to
fall back on instead of pasting the raw key into a chat or a file.
</p>
<p class="field-hint">
Configure it as the agent <code>X-API-Key</code> header value.
</p>
<p class="mono secret-value">{{ secret }}</p>
<button class="btn btn-secondary" @click="copySecret">
@@ -196,18 +217,92 @@
<h3>Build site agent</h3>
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent executable stamped with this server's
Produce an instance-locked agent package stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client.
with no manual configuration on the client. The download includes the
agent, a non-secret <code>appsettings.json</code>, and an
<code>install.ps1</code> that writes the credentials to the registry
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
appsettings.
</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.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
<input
id="agent-baseurl"
v-model="agentbaseurl"
class="form-control"
type="text"
placeholder="https://shopdb.example"
/>
<label class="field-label" for="agent-key">Endpoint agent key</label>
<input
id="agent-key"
v-model="agentcollectorkey"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
/>
<label class="field-label" for="agent-cadence">
Reporting cadence (seconds)
</label>
<input
id="agent-cadence"
v-model.number="agentcadenceseconds"
class="form-control"
type="number"
min="60"
/>
<label v-if="certavailable" class="checkbox-row">
<input v-model="agentbundlecert" type="checkbox" />
<span>
Bundle this server's TLS certificate and trust it on the endpoint.
Enable only for self-signed or internal-CA servers; leave off when
the server uses a publicly trusted certificate.
</span>
</label>
<label class="field-label" for="agent-cert">
Server TLS certificate (optional)
</label>
<input
id="agent-cert"
ref="agentcertinput"
class="form-control"
type="file"
accept=".crt,.pem,.cer"
@change="onAgentCertChange"
/>
<p class="field-hint">
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
the C# agent runtime is still under development. Generate a key above
and configure it manually as the agent <code>X-API-Key</code> for now.
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate (or issuing CA) in PEM format. It is
validated, bundled with the package, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code>. Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
<button class="btn btn-secondary" type="button" disabled>
Build agent (coming soon)
</button>
<div class="button-row">
<button
class="btn btn-primary"
type="button"
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
@click="buildSiteAgent"
>
{{ agentbusy ? 'Building...' : 'Build site agent' }}
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
</div>
</section>
@@ -255,6 +350,19 @@ const adoptbusy = ref(false)
const adoptmessage = ref('')
const adopterror = ref('')
// Site agent generator: build an instance-locked, resident endpoint agent.
const agentbaseurl = ref('')
const agentcollectorkey = ref('')
const agentcadenceseconds = ref(900)
const agentstaged = ref(false)
const certavailable = ref(false)
const agentbundlecert = ref(false)
const agentcertinput = ref(null)
const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
@@ -268,6 +376,7 @@ function applyStatus(data) {
onMounted(async () => {
adoptbaseurl.value = window.location.origin
agentbaseurl.value = window.location.origin
try {
const response = await api.get('/endpoints/agentkey')
applyStatus(response.data.data || {})
@@ -279,6 +388,15 @@ onMounted(async () => {
} finally {
loading.value = false
}
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
} catch (statusError) {
console.error(statusError)
}
})
async function regenerate() {
@@ -299,6 +417,7 @@ async function regenerate() {
secret.value = data.secret || ''
if (data.secret) {
adoptcollectorkey.value = data.secret
agentcollectorkey.value = data.secret
}
message.value = 'Endpoint agent key generated.'
} catch (regenError) {
@@ -366,6 +485,61 @@ async function downloadAdoptionTool() {
adoptbusy.value = false
}
}
function onAgentCertChange(event) {
const files = event.target.files
agentcertfile.value = files && files.length ? files[0] : null
}
async function readBlobError(blob) {
try {
const parsed = JSON.parse(await blob.text())
return parsed.data?.error?.message || parsed.message
|| 'Could not build the site agent'
} catch (parseError) {
return 'Could not build the site agent'
}
}
async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
form.append('collectorkey', agentcollectorkey.value)
form.append('cadenceseconds', agentcadenceseconds.value)
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
if (agentcertfile.value) {
form.append('certfile', agentcertfile.value)
}
const response = await api.post(
'/endpoints/siteagent/package',
form,
{ responseType: 'blob' })
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = 'shopdb-endpoint-agent.zip'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)
} else {
agenterror.value = buildError.response?.status === 403
? 'Only an administrator can build the site agent'
: 'Could not build the site agent'
}
console.error(buildError)
} finally {
agentbusy.value = false
}
}
</script>
<style scoped>
@@ -374,7 +548,27 @@ async function downloadAdoptionTool() {
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
.form-card h4:first-child { margin-top: 0; }
.button-row { display: flex; gap: 0.5rem; }
.checkbox-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin: 0.75rem 0;
font-size: 0.9rem;
}
.checkbox-row input { margin-top: 0.2rem; }
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
.key-warning {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border: 2px solid #ff0000;
border-radius: 4px;
background: rgba(255, 0, 0, 0.12);
color: #ff2d2d;
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 0.02em;
text-align: center;
}
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.settings-section > h3 { margin-bottom: 0.25rem; }

View File

@@ -0,0 +1,327 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>

View File

@@ -147,6 +147,12 @@ class EndpointsPlugin(BasePlugin):
# remote reboot is a strictly heavier grant than a routine toggle.
('endpoints.restart',
'Restart endpoint computers (destructive)', 'endpoints'),
# Elevated grant for software delivery (run_script, update_app).
# Required IN ADDITION to endpoints.command by the enqueue route, so
# 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'),
# 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
@@ -226,6 +232,19 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.fleet',
'section': 'assets',
},
# TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
# run_script delivery path while Cameron builds the production UI.
# Gated on endpoints.deploy so only delivery operators see it. Remove
# this entry (and the DeliveryTestBench view/route) once the real
# delivery UI ships.
{
'name': 'Delivery test bench',
'icon': 'send',
'route': '/endpoints/delivery-test',
'position': 17,
'permission': 'endpoints.deploy',
'section': 'assets',
},
]
def init_app(self, app: Flask, db_instance) -> None:

View File

@@ -160,6 +160,33 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
assert elevated_permission_for('collect_now') is None
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:
run_script carries a script name, update_app carries an appversionid."""
for action in ('run_script', 'update_app'):
assert is_allowed_action(action)
assert action in COMMAND_ALLOWLIST
assert requires_target(action)
assert COMMAND_ALLOWLIST['run_script']['targetkind'] == 'script'
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
def test_delivery_actions_require_the_elevated_deploy_permission():
"""run_script and update_app both demand endpoints.deploy ON TOP OF
endpoints.command, so pushing software is a strictly heavier grant than a
routine service/process toggle."""
assert elevated_permission_for('run_script') == 'endpoints.deploy'
assert elevated_permission_for('update_app') == 'endpoints.deploy'
def test_endpoints_declares_the_deploy_permission():
"""The dedicated software-delivery permission is declared for role seeding."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.deploy' in permissions
def test_known_payload_keys_cover_persisted_and_relayed_fields():
"""The ingest allow-set must list every key the handler consumes today, so
those keys are never mislogged as 'unpersisted'. Anything the agent sends