diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index bc38bba6..e3eaa19d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 = {}) { diff --git a/plugins/endpoints/DELIVERY-TESTING-notes.md b/plugins/endpoints/DELIVERY-TESTING-notes.md new file mode 100644 index 00000000..13a17060 --- /dev/null +++ b/plugins/endpoints/DELIVERY-TESTING-notes.md @@ -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//commands +{ "action": "run_script", "target": "remove-network-drives" } +{ "action": "update_app", "target": "" } +``` + +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//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/ (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//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. diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py index e4f289bc..2fc14121 100644 --- a/plugins/endpoints/api/__init__.py +++ b/plugins/endpoints/api/__init__.py @@ -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'] diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index b83cf4d8..a075831c 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -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 @@ -927,6 +930,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/); + 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:" - the app's own uploaded installer (installersha256) + - "version:" - a specific AppVersion's pinned package + - bare "" - 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:, version:, 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:" 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: 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('//commands', methods=['GET']) @require_permission('endpoints.view') def list_endpoint_commands(asset_id: int): @@ -985,6 +1141,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, @@ -1061,12 +1233,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. @@ -1115,3 +1308,106 @@ def report_command_result(command_id: int): db.session.commit() return success_response(command.to_dict(), message='Result recorded') + +@endpoints_bp.route('/apppackage/', 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/); + # 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/', methods=['GET']) +@endpoints_bp.route('/apppackage/app//', 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 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/', 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) + + diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py new file mode 100644 index 00000000..a585c315 --- /dev/null +++ b/plugins/endpoints/api/siteagent.py @@ -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://:' 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') diff --git a/plugins/endpoints/commands.py b/plugins/endpoints/commands.py index b846842e..fdf3f14c 100644 --- a/plugins/endpoints/commands.py +++ b/plugins/endpoints/commands.py @@ -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', } diff --git a/plugins/endpoints/frontend/routes.js b/plugins/endpoints/frontend/routes.js index 3e07f25d..bb9e2d28 100644 --- a/plugins/endpoints/frontend/routes.js +++ b/plugins/endpoints/frontend/routes.js @@ -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' } } ] diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index f2aadb1c..539a1e04 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -14,6 +14,16 @@ ================================================================= -->

Key management

+

+ STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED. +

+

+ Before you generate a key, open the ShopDB Secure Send + 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. +

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 @@

Save this key now

- It will not be shown again. Configure it as the agent - X-API-Key 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. +

+

+ Recommended: before you leave this page, open the + ShopDB Secure Send 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. +

+

+ Configure it as the agent X-API-Key header value.

{{ secret }}

+ +
+ +
+

{{ agentmessage }}

+

{{ agenterror }}

@@ -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 + } +} diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 79893f33..85508488 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -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: diff --git a/plugins/endpoints/tests/test_plugin.py b/plugins/endpoints/tests/test_plugin.py index 54373de8..5378a3fc 100644 --- a/plugins/endpoints/tests/test_plugin.py +++ b/plugins/endpoints/tests/test_plugin.py @@ -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 @@ -219,3 +246,4 @@ def test_fleet_list_route_resolves_without_trailing_slash(): assert endpoint.endswith('list_endpoints') endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET') assert endpoint_slash.endswith('list_endpoints') +