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