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:
committed by
cproudlock
parent
7ac1772881
commit
4590156f30
@@ -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)
|
// Support Teams API (teams + nested contacts)
|
||||||
export const supportteamsApi = {
|
export const supportteamsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
|
|||||||
112
plugins/endpoints/DELIVERY-TESTING-notes.md
Normal file
112
plugins/endpoints/DELIVERY-TESTING-notes.md
Normal 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.
|
||||||
@@ -3,5 +3,7 @@
|
|||||||
from .routes import endpoints_bp
|
from .routes import endpoints_bp
|
||||||
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
|
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
|
||||||
from . import adoption # noqa: F401
|
from . import adoption # noqa: F401
|
||||||
|
# Attaches the site agent generator packaging routes to endpoints_bp.
|
||||||
|
from . import siteagent # noqa: F401
|
||||||
|
|
||||||
__all__ = ['endpoints_bp']
|
__all__ = ['endpoints_bp']
|
||||||
|
|||||||
@@ -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.
|
These routes are the human-facing read surface, gated by user JWT + RBAC.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ from shopdb.api import (
|
|||||||
require_role, caller_holds, mint_service_token, list_service_tokens,
|
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||||
revoke_service_tokens,
|
revoke_service_tokens,
|
||||||
AuditLog, collector_authorized, User,
|
AuditLog, collector_authorized, User,
|
||||||
|
Application, AppVersion, Script,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
|
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
|
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'])
|
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def list_endpoint_commands(asset_id: int):
|
def list_endpoint_commands(asset_id: int):
|
||||||
@@ -1050,6 +1206,22 @@ def enqueue_endpoint_command(asset_id: int):
|
|||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
f'Action {action!r} does not accept a target')
|
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()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
if state is None:
|
if state is None:
|
||||||
return error_response(ErrorCodes.NOT_FOUND,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
@@ -1126,12 +1298,33 @@ def claim_next_command():
|
|||||||
command.status = EndpointCommand.STATUS_RUNNING
|
command.status = EndpointCommand.STATUS_RUNNING
|
||||||
command.claimedat = _naive_utcnow()
|
command.claimedat = _naive_utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response({
|
claim = {
|
||||||
'endpointcommandid': command.endpointcommandid,
|
'endpointcommandid': command.endpointcommandid,
|
||||||
'action': command.action,
|
'action': command.action,
|
||||||
'target': command.target,
|
'target': command.target,
|
||||||
'targetkind': command.targetkind,
|
'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
|
# 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.
|
# 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()
|
db.session.commit()
|
||||||
return success_response(command.to_dict(), message='Result recorded')
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
439
plugins/endpoints/api/siteagent.py
Normal file
439
plugins/endpoints/api/siteagent.py
Normal 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')
|
||||||
@@ -77,6 +77,22 @@ COMMAND_ALLOWLIST = {
|
|||||||
'label': 'Collect now',
|
'label': 'Collect now',
|
||||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
'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.
|
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
||||||
ELEVATED_ACTION_PERMISSIONS = {
|
ELEVATED_ACTION_PERMISSIONS = {
|
||||||
'system_restart': 'endpoints.restart',
|
'system_restart': 'endpoints.restart',
|
||||||
|
'run_script': 'endpoints.deploy',
|
||||||
|
'update_app': 'endpoints.deploy',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export default [
|
|||||||
name: 'endpoints-agentkey-settings',
|
name: 'endpoints-agentkey-settings',
|
||||||
component: () => import('./views/AgentKeySettings.vue'),
|
component: () => import('./views/AgentKeySettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
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' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
================================================================= -->
|
================================================================= -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Key management</h3>
|
<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">
|
<p class="field-hint">
|
||||||
The endpoint agent reports with a single managed key scoped to endpoint
|
The endpoint agent reports with a single managed key scoped to endpoint
|
||||||
telemetry only. It is separate from every other collector, so rotating
|
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">
|
<div v-if="secret" class="card form-card secret-card">
|
||||||
<h4>Save this key now</h4>
|
<h4>Save this key now</h4>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
It will not be shown again. Configure it as the agent
|
Anyone with this key can report as an endpoint agent for this site.
|
||||||
<code>X-API-Key</code> header value.
|
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>
|
||||||
<p class="mono secret-value">{{ secret }}</p>
|
<p class="mono secret-value">{{ secret }}</p>
|
||||||
<button class="btn btn-secondary" @click="copySecret">
|
<button class="btn btn-secondary" @click="copySecret">
|
||||||
@@ -196,18 +217,92 @@
|
|||||||
<h3>Build site agent</h3>
|
<h3>Build site agent</h3>
|
||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<p class="field-hint">
|
<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
|
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>
|
||||||
|
<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">
|
<p class="field-hint">
|
||||||
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
|
For a reverse-proxy or internal-CA server this app cannot see, upload
|
||||||
the C# agent runtime is still under development. Generate a key above
|
the server's public certificate (or issuing CA) in PEM format. It is
|
||||||
and configure it manually as the agent <code>X-API-Key</code> for now.
|
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>
|
</p>
|
||||||
<button class="btn btn-secondary" type="button" disabled>
|
|
||||||
Build agent (coming soon)
|
<div class="button-row">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -255,6 +350,19 @@ const adoptbusy = ref(false)
|
|||||||
const adoptmessage = ref('')
|
const adoptmessage = ref('')
|
||||||
const adopterror = 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) {
|
function formatDate(value) {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
const parsed = new Date(value)
|
const parsed = new Date(value)
|
||||||
@@ -268,6 +376,7 @@ function applyStatus(data) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
adoptbaseurl.value = window.location.origin
|
adoptbaseurl.value = window.location.origin
|
||||||
|
agentbaseurl.value = window.location.origin
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/endpoints/agentkey')
|
const response = await api.get('/endpoints/agentkey')
|
||||||
applyStatus(response.data.data || {})
|
applyStatus(response.data.data || {})
|
||||||
@@ -279,6 +388,15 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
async function regenerate() {
|
||||||
@@ -299,6 +417,7 @@ async function regenerate() {
|
|||||||
secret.value = data.secret || ''
|
secret.value = data.secret || ''
|
||||||
if (data.secret) {
|
if (data.secret) {
|
||||||
adoptcollectorkey.value = data.secret
|
adoptcollectorkey.value = data.secret
|
||||||
|
agentcollectorkey.value = data.secret
|
||||||
}
|
}
|
||||||
message.value = 'Endpoint agent key generated.'
|
message.value = 'Endpoint agent key generated.'
|
||||||
} catch (regenError) {
|
} catch (regenError) {
|
||||||
@@ -366,6 +485,61 @@ async function downloadAdoptionTool() {
|
|||||||
adoptbusy.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -374,7 +548,27 @@ async function downloadAdoptionTool() {
|
|||||||
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
|
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
|
||||||
.form-card h4:first-child { margin-top: 0; }
|
.form-card h4:first-child { margin-top: 0; }
|
||||||
.button-row { display: flex; gap: 0.5rem; }
|
.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); }
|
.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); }
|
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
||||||
.settings-section { margin-bottom: 2rem; }
|
.settings-section { margin-bottom: 2rem; }
|
||||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||||
|
|||||||
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal file
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal 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>
|
||||||
@@ -147,6 +147,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
# remote reboot is a strictly heavier grant than a routine toggle.
|
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||||
('endpoints.restart',
|
('endpoints.restart',
|
||||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
'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
|
# Collector service scope for the endpoint agent. A managed token
|
||||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||||
@@ -226,6 +232,19 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'permission': 'endpoints.fleet',
|
'permission': 'endpoints.fleet',
|
||||||
'section': 'assets',
|
'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:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
|
|||||||
@@ -160,6 +160,33 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
|
|||||||
assert elevated_permission_for('collect_now') is None
|
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():
|
def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
||||||
"""The ingest allow-set must list every key the handler consumes today, so
|
"""The ingest allow-set must list every key the handler consumes today, so
|
||||||
those keys are never mislogged as 'unpersisted'. Anything the agent sends
|
those keys are never mislogged as 'unpersisted'. Anything the agent sends
|
||||||
|
|||||||
Reference in New Issue
Block a user