Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows

This commit is contained in:
2026-09-02 16:15:53 -04:00
parent 2144305433
commit bb733f1332
84 changed files with 7107 additions and 186 deletions

View File

@@ -5,5 +5,7 @@ from .routes import endpoints_bp
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
from . import monitors # noqa: F401
__all__ = ['endpoints_bp']

View File

@@ -0,0 +1,235 @@
"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
A monitor definition is the reusable "what" an operator builds on the Settings
page: a named process or service with a bespoke display label and optional
launch/collector paths. Attaching a monitor to a PC type is a site-policy
concern (see plugins/computers policies); this surface owns the definitions
themselves plus the resolve-by-pctype read the agent uses at runtime.
Shipped rows (source='shipped') are product-owned: readable by everyone with
the view permission but not editable or deletable through this API.
The resolve-by-pctype read returns the monitor set a deployed agent should watch
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
active site policies attach to that type. Shipped monitors are hardcoded to
'Shopfloor PC' as a proof of concept until they are authored through the policy
route like any user-built monitor.
"""
from flask import request
from flask_jwt_extended import current_user
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
require_role, Setting,
)
from ..models import EndpointMonitor
from .routes import endpoints_bp
# Which PC type the shipped monitors attach to, as a SETTING rather than a
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
# type is called "Shop Floor Workstation" got no shipped monitors at all when
# this was hardcoded, and a plant that happens to use the name for something
# else got all of them. The default matches the seeded type so nothing changes
# for a site that uses it; a blank setting attaches the shipped bundle to
# nothing, which is the right neutral behaviour for a site that does not want
# it rather than a silent mismatch.
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
def shipped_pctype():
"""The PC type the shipped monitor bundle attaches to at this site."""
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
return (configured or '').strip()
_VALID_KINDS = ('process', 'service')
def _validate_monitor_payload(body, kind):
"""Return an error message for an invalid create/update body, or None."""
displayname = (body.get('displayname') or '').strip()
if not displayname:
return 'displayname is required'
if kind not in _VALID_KINDS:
return "kind must be 'process' or 'service'"
if kind == 'process' and not (body.get('processname') or '').strip():
return 'processname is required for a process monitor'
if kind == 'service' and not (body.get('servicename') or '').strip():
return 'servicename is required for a service monitor'
return None
@endpoints_bp.route('/monitors', methods=['GET'])
@require_permission('endpoints.view')
def list_monitors():
"""List monitor definitions (shipped and local)."""
monitors = EndpointMonitor.query.order_by(
EndpointMonitor.source.desc(),
EndpointMonitor.displayname.asc()).all()
return success_response([monitor.to_dict() for monitor in monitors])
@endpoints_bp.route('/monitors', methods=['POST'])
@require_role('admin')
def create_monitor():
"""Create a user-defined (local) monitor definition."""
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or '').strip().lower()
problem = _validate_monitor_payload(body, kind)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
monitor = EndpointMonitor(
kind=kind,
displayname=(body.get('displayname') or '').strip(),
processname=(body.get('processname') or '').strip() or None,
servicename=(body.get('servicename') or '').strip() or None,
launchpath=(body.get('launchpath') or '').strip() or None,
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
source='local',
isactive=bool(body.get('isactive', True)),
)
db.session.add(monitor)
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor created',
http_code=201)
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
@require_role('admin')
def update_monitor(monitor_id):
"""Update a local monitor definition. Shipped rows are read-only."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or monitor.kind or '').strip().lower()
problem = _validate_monitor_payload(
{
'displayname': body.get('displayname', monitor.displayname),
'processname': body.get('processname', monitor.processname),
'servicename': body.get('servicename', monitor.servicename),
},
kind,
)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
# one site's disk layout. It used to refuse every edit, which meant a plant
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
# the launch path nor switch the monitor off - and then got permanent false
# drift warnings for it. ADR-015 allows seed data precisely because a site
# can edit it afterwards.
#
# So: kind and displayname are the identity 0007's policy bundle matches on
# and stay fixed on a shipped row; the paths and the active flag, which are
# what actually differ between plants, are editable.
if monitor.source != 'local':
for field in ('kind', 'displayname'):
if field in body and (body.get(field) or '').strip() != (
getattr(monitor, field) or ''):
return error_response(
ErrorCodes.FORBIDDEN,
'A shipped monitor keeps its kind and display name; its '
'paths and active flag can be changed', http_code=403)
else:
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
if 'isactive' in body:
monitor.isactive = bool(body.get('isactive'))
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor updated')
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
@require_role('admin')
def delete_monitor(monitor_id):
"""Delete a local monitor definition. Shipped rows are protected."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
if monitor.source != 'local':
return error_response(ErrorCodes.FORBIDDEN,
'Shipped monitors cannot be deleted', http_code=403)
db.session.delete(monitor)
db.session.commit()
return success_response(None, message='Monitor deleted')
def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy.
Shipped monitors attach to the PC type named by the shipped-pctype setting;
policy-attached monitors come from the computers policy resolver by PC type.
Returns a list of EndpointMonitor rows (active only), de-duped by id.
"""
selected = {}
shipped_for = shipped_pctype()
if shipped_for and pctype == shipped_for:
for monitor in EndpointMonitor.query.filter_by(
source='shipped', isactive=True).all():
selected[monitor.endpointmonitorid] = monitor
computertypeid = _computertypeid_for(pctype)
if computertypeid is not None:
policy_ids = _policy_monitor_ids(computertypeid)
if policy_ids:
for monitor in EndpointMonitor.query.filter(
EndpointMonitor.endpointmonitorid.in_(policy_ids),
EndpointMonitor.isactive.is_(True)).all():
selected[monitor.endpointmonitorid] = monitor
return list(selected.values())
def _computertypeid_for(pctype):
"""Resolve a PC type name to its computertypeid, or None."""
try:
from plugins.computers.models import ComputerType
except Exception:
return None
row = ComputerType.query.filter_by(computertype=pctype).first()
return row.computertypeid if row else None
def _policy_monitor_ids(computertypeid):
"""Monitor ids attached to a PC type by effective policy, or empty set."""
try:
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
except Exception:
return set()
return monitor_ids_for_pctype(computertypeid)
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
@require_permission('endpoints.view')
def resolve_monitors():
"""Resolve the monitor set for a PC type (agent runtime fetch).
Query param pctype names the computer type. Returns the definitions the
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
active site policies attach to that type.
"""
pctype = (request.args.get('pctype') or '').strip()
if not pctype:
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
monitors = _resolve_pctype_monitors(pctype)
return success_response({
'pctype': pctype,
'monitors': [monitor.to_dict() for monitor in monitors],
})

View File

@@ -25,7 +25,10 @@ from shopdb.api import (
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
OperatorReport,
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
@@ -36,11 +39,12 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
# A watched service or process reports a STATE STRING, not a boolean, and the
# vocabulary varies by source. Both the services panel and the fleet health
# rollup have to agree on what counts as running, so the rule lives here once -
# reading it wrong makes every service look stopped.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
# What counts as RUNNING is defined once, by the reconciler, and imported here.
# The panel and the reconciler read the same agent snapshot, so a disagreement
# would paint a service green and flag it as drift on the same page. Importing
# api -> services is the direction the plugin layout already runs in; the
# reconciler's own import of this module is deferred, so there is no cycle.
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
def _service_running(entry):
@@ -87,7 +91,8 @@ def _format_uptime(seconds):
# Endpoint state
# =============================================================================
@endpoints_bp.route('', methods=['GET'])
@endpoints_bp.route('', methods=['GET'], strict_slashes=False)
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
@require_permission('endpoints.fleet')
def list_endpoints():
"""List reported endpoint states.
@@ -396,6 +401,24 @@ def list_endpoint_events(asset_id: int):
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
# =============================================================================
def _drift_for_state(state):
"""Parse the cached warn-only reconciler drift list off an EndpointState.
driftjson is written on every check-in by the endpoints ingest: the monitors
site policy expects running that the latest snapshot did not report running.
Null means never computed; an empty list means computed with no drift.
Tolerant of a malformed blob so a bad cache never breaks the panel.
"""
raw = getattr(state, 'driftjson', None) if state else None
if not raw:
return []
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
return parsed if isinstance(parsed, list) else []
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
@@ -425,6 +448,19 @@ def panel_status(asset_id: int):
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
]
fields = [f for f in fields if f['value'] not in (None, '')]
# Warn-only reconciler summary: monitors site policy expects to be running
# that the last check-in did not report as running. Surfaced as a status
# field so an operator sees the drift on the panel; nothing is auto-started.
drift = _drift_for_state(state)
if drift:
names = ', '.join(entry.get('displayname') or entry.get('name')
for entry in drift)
fields.append(field(
'Needs attention',
'{} monitor(s) expected running but not reported: {}'.format(
len(drift), names)))
return success_response({'fields': fields})
@@ -446,28 +482,100 @@ def panel_telemetry_meta(asset_id: int):
return success_response({'lastreportedat': stamp})
def _pctype_for_asset(asset_id):
"""Resolve an asset's PC type name, or None.
Endpoints stays optional-on-computers: a missing plugin, a non-computer
asset, or an unset type all resolve to None so the caller falls back to
reported-only behaviour.
"""
try:
from plugins.computers.models import Computer, ComputerType
except Exception:
return None
row = (db.session.query(ComputerType.computertype)
.join(Computer,
Computer.computertypeid == ComputerType.computertypeid)
.filter(Computer.assetid == asset_id)
.first())
return row[0] if row else None
def _resolved_monitors_for_asset(asset_id):
"""Monitor definitions attached to this asset's PC type by policy/shipped.
Reuses the endpoints monitor resolver (shipped defaults plus policy-attached
monitors). Returns a list of EndpointMonitor rows, or an empty list when the
PC type is unknown or nothing resolves.
"""
pctype = _pctype_for_asset(asset_id)
if not pctype:
return []
try:
from .monitors import _resolve_pctype_monitors
return _resolve_pctype_monitors(pctype) or []
except Exception:
return []
def _row_action_dict(app, kind, name, running):
"""Build the inline Start/Stop action map for a services/processes row."""
targets = row_actions_for(app, kind, name)
if not targets:
return None
return {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
@require_permission('endpoints.view')
def panel_services(asset_id: int):
"""List panel: monitored services/processes with a run-state badge per row.
Reshapes the latest servicesjson snapshot into rows the list renderer maps
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
an unknown state renders as an Unknown badge rather than failing.
to name + status badge, then MERGES the monitors that site policy (and the
shipped defaults) attach to this asset's PC type. A policy-attached monitor
the agent has not yet reported surfaces as an Unknown row with a Start
control, so an operator sees the intent immediately rather than waiting for
the next agent snapshot. Tolerant of a missing snapshot or plugin: an
unknown state renders as an Unknown badge rather than failing.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state or not state.servicesjson:
return success_response([])
maycommand = caller_holds('endpoints.command')
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
return success_response([])
services = []
if state and state.servicesjson:
try:
services = json.loads(state.servicesjson) or []
except (TypeError, ValueError):
services = []
rows = []
for entry in (services or []):
# Reported identities, keyed (kind, lowercased name), so a policy monitor
# the agent already reports is not duplicated as a synthetic row.
reported = set()
# Warn-only reconciler drift, keyed (kind, lowercased name), so a row that is
# expected running but not reported running can be flagged for the operator.
# kind may be None on an older snapshot, so match on name too.
drift_keys = set()
for entry in _drift_for_state(state):
name = (entry.get('name') or '').strip().lower()
if not name:
continue
drift_keys.add(((entry.get('kind') or '').lower() or None, name))
drift_keys.add((None, name))
def _is_drift(kind, name):
key = (name or '').strip().lower()
return (kind, key) in drift_keys or (None, key) in drift_keys
for entry in services:
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or 'Unknown'
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
@@ -476,9 +584,10 @@ def panel_services(asset_id: int):
else:
name, raw, app, kind = str(entry), '', None, None
running = _service_running(entry)
normkind = (kind or '').lower() or None
row = {
'name': name,
'kind': (kind or '').lower() or None,
'kind': normkind,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
# Amber for paused and the four Windows transitional states. A
# service stuck in Start Pending is failing to start on a retry
@@ -493,17 +602,42 @@ def panel_services(asset_id: int):
# emission never consulted the caller, so a read-only viewer was shown
# controls on a shop-floor PC that 403 on click - wrong signal, and it
# gets worse the moment guests can read this panel.
targets = row_actions_for(app, kind, name) if maycommand else None
if targets:
row['action'] = {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
action = _row_action_dict(app, kind, name, running) if maycommand else None
if action:
row['action'] = action
if not running and _is_drift(normkind, name):
row['drift'] = True
rows.append(row)
reported.add((normkind, name.strip().lower()))
# Merge policy/shipped monitors this PC type should watch. A monitor already
# reported by the agent stays as its live row; one not yet seen is appended
# as an Unknown row so the operator sees the intended coverage and can Start
# it. The monitor's displayname is the operator label (e.g. 'msedge.exe'
# -> 'Edge'); its process/service name is the wire target for actions.
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower() or None
osname = (monitor.processname if kind == 'process'
else monitor.servicename)
osname = (osname or '').strip()
if not osname:
continue
if (kind, osname.lower()) in reported:
continue
row = {
'name': monitor.displayname or osname,
'kind': kind,
'statelabel': 'Unknown',
'statecolor': 'gray',
}
action = _row_action_dict(None, kind, osname, False) if maycommand else None
if action:
row['action'] = action
if _is_drift(kind, osname):
row['drift'] = True
rows.append(row)
reported.add((kind, osname.lower()))
return success_response(rows)
@@ -1335,6 +1469,64 @@ def enqueue_endpoint_command(asset_id: int):
message='Command queued', http_code=201)
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
def resolved_monitors_for_agent():
"""Agent watch list: the monitors this endpoint should report on.
Collector-token auth (not JWT), same as the command channel. The agent
calls this each telemetry cycle with its hostname and receives the process
and service monitors that site policy (and the shipped defaults) attach to
this endpoint's PC type, so a policy-attached monitor is enumerated and
reported without editing appsettings.json on the box. The shape mirrors the
agent's MonitorConfig (processes[]/services[] of {name, app}) so the agent
can bind it directly: name is the wire identity (process image or service
key), app is the operator label. An unknown hostname or a PC type with no
monitors returns empty lists, which the agent merges as "watch nothing extra".
"""
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown hostname: {hostname!r}', http_code=404)
processes = []
services = []
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower()
if kind == 'process':
wirename = (monitor.processname or '').strip()
bucket = processes
elif kind == 'service':
wirename = (monitor.servicename or '').strip()
bucket = services
else:
continue
if not wirename:
continue
entry = {
'name': wirename,
'app': (monitor.displayname or wirename),
}
# Only process monitors carry a launch path, and only when set. The
# agent never trusts it blindly: it validates the path against an
# approved-root allowlist before it becomes launchable (fail closed).
if kind == 'process':
launchpath = (monitor.launchpath or '').strip()
if launchpath:
entry['launchpath'] = launchpath
bucket.append(entry)
return success_response({'processes': processes, 'services': services})
@endpoints_bp.route('/commands/next', methods=['GET'])
def claim_next_command():
"""Agent long-poll: claim the next pending command for a hostname.
@@ -1578,3 +1770,201 @@ def download_script_body(script_id: int):
return send_upload(script_dir, script.filename, as_attachment=True)
# Operator issue reports (tray screenshot + free-text notes)
# ---------------------------------------------------------------------------
# Retention groundwork: how many days an operator report (and its screenshot
# file) is kept before the purge sweep removes it. Read from the Setting store
# so Agent Configuration can expose a toggle later without a code change; the
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
# The multipart envelope carries the other form fields and the part boundaries
# too, so content_length is legitimately larger than the image. Allow for that
# rather than rejecting a screenshot that is just under the cap.
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
# PNG signature. The route stores and serves the file as image/png, so this is
# the one thing it accepts.
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
def _operator_report_dir():
return os.path.join(current_app.instance_path, 'operatorreports')
def _operator_report_retention_days():
"""Configured retention window in whole days (default 7, <=0 disables)."""
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
if raw is None:
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
try:
return int(raw)
except (TypeError, ValueError):
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
def _purge_expired_operator_reports():
"""Delete operator reports older than the retention window, files and all.
Best-effort and idempotent: called opportunistically on upload so the feed
stays bounded without a scheduler. A retention of <=0 disables the sweep.
"""
days = _operator_report_retention_days()
if days <= 0:
return
cutoff = _naive_utcnow() - timedelta(days=days)
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
if not stale:
return
report_dir = _operator_report_dir()
for report in stale:
if report.imagefilename:
path = os.path.join(report_dir, report.imagefilename)
try:
os.remove(path)
except OSError:
pass
db.session.delete(report)
db.session.commit()
@endpoints_bp.route('/operatorreports', methods=['POST'])
def upload_operator_report():
"""Agent uploads one operator issue report (collector-token auth).
The tray captures the operator's free-text note and an optional screenshot,
then hands them to the service, which posts here as multipart/form-data on
the same endpoints collector token used for telemetry. The reported hostname
resolves to an asset; an unresolved hostname is a validation error. The
screenshot bytes are written to the operator-reports uploads dir and the row
holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded.
"""
import uuid
error = _collector_auth_or_error()
if error is not None:
return error
# Refuse an oversized body BEFORE anything parses it. content_length is a
# header, so it is readable without touching the body; request.form is not
# - the first access to it makes werkzeug parse the whole multipart and
# spool the file part to a temp file. An earlier version of this check sat
# below that line and so ran after the thing it exists to prevent. The
# value is caller-supplied, which is why this is an early out and not the
# enforcement; the bounded read below is.
declared = request.content_length or 0
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
hostname = (request.form.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'could not resolve hostname to a computer: {!r}'.format(hostname))
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
details = (request.form.get('details') or '').strip() or None
imagefilename = None
imagemimetype = None
imagebytesize = None
upload = request.files.get('screenshot')
if upload is not None and upload.filename:
# Read one byte past the cap: enough to know it was exceeded, without
# taking the whole of a body that lied about its length.
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data and not data.startswith(_PNG_MAGIC):
# The stored file is served back as image/png. Storing whatever
# arrived under that name meant the extension and the bytes could
# disagree; the upload headers make the document case inert, but
# nothing is gained by keeping a file we already know is not what it
# claims.
return error_response(ErrorCodes.VALIDATION_ERROR,
'screenshot must be a PNG')
if data:
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
imagemimetype = 'image/png'
imagebytesize = len(data)
report_dir = _operator_report_dir()
os.makedirs(report_dir, exist_ok=True)
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
handle.write(data)
report = OperatorReport(
assetid=asset_id,
reason=reason,
details=details,
imagefilename=imagefilename,
imagemimetype=imagemimetype,
imagebytesize=imagebytesize,
reportedhostname=hostname,
)
db.session.add(report)
db.session.commit()
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
# must not fail the upload the operator already completed.
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
return success_response(report.to_dict(),
message='Operator report received', http_code=201)
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
@require_permission('endpoints.view')
def panel_operator_reports(asset_id: int):
"""Operator Reports panel: this PC's recent operator issue reports.
Newest first. Each row carries a hasimage flag so the viewer knows whether
to request the screenshot from the serve route below. Runs a retention
sweep opportunistically so a viewed PC does not accumulate stale rows.
"""
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
reports = (OperatorReport.query
.filter_by(assetid=asset_id)
.order_by(OperatorReport.createdat.desc())
.all())
return success_response({'reports': [r.to_dict() for r in reports]})
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
methods=['GET'])
@require_permission('endpoints.view')
def serve_operator_report_image(operatorreportid: int):
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
from shopdb.api import send_upload
report = db.session.get(OperatorReport, operatorreportid)
if report is None or not report.imagefilename:
return error_response(ErrorCodes.NOT_FOUND,
'No screenshot for this report', http_code=404)
return send_upload(_operator_report_dir(), report.imagefilename)

View File

@@ -41,6 +41,14 @@ from .routes import endpoints_bp
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
# The standalone uninstaller lives in the repo tree (not staged per-instance),
# so it ships in every bundle. Resolve it relative to this file:
# plugins/endpoints/api/siteagent.py -> repo root is three levels up.
UNINSTALLER_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))),
'scripts', 'uninstall-shopdbagent.ps1')
# 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
@@ -144,6 +152,12 @@ FILES
appsettings.json Non-secret runtime config (cadence, monitors,
launch paths). No credentials.
install.ps1 Provisions the credentials and installs the service.
uninstall-shopdbagent.ps1
Standalone uninstaller. Run elevated to reverse the
install: stops and deletes the service, kills agent
and tray processes, removes the tray autostart Run
key, the Start Menu shortcut, and the credential
hive, and deletes the install directory.
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
@@ -153,10 +167,16 @@ FILES
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.
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
SECURITY
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
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.
@@ -248,7 +268,7 @@ 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 ..."
Write-Host "[0/6] 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)" }}
@@ -264,7 +284,7 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
}}
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Write-Host "[1/6] 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
@@ -288,7 +308,7 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
}}
# 2. Copy agent files to the install directory.
Write-Host "[2/4] Copying agent files to $InstallDir ..."
Write-Host "[2/6] 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 {{ }}
@@ -306,17 +326,118 @@ if (-not $NoTray) {{
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {{
Copy-Item -Path $traySource -Destination $InstallDir -Force
$installedTray = Join-Path $InstallDir $TrayExeName
# Autostart the tray for every user at logon via the HKLM Run key (both
# 64-bit and 32-bit views, matching the credential hive). This is the
# reliable launch guarantee: a plain Start Menu shortcut does not
# autostart, and the SYSTEM service cannot cleanly spawn into the
# interactive session. The tray itself is single-instance, so a stray
# second launch is harmless.
Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"')
# Export the branded icon from the tray exe so the shortcut shows the
# ShopDB brand instead of the generic exe icon. The tray draws its icon
# at runtime (no embedded Win32 icon), so we ask it to write an .ico into
# the install dir and point the shortcut at it.
$iconPath = Join-Path $InstallDir "shopdb-agent.ico"
try {{ & $installedTray --export-icon $iconPath | Out-Null }}
catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }}
# Start Menu shortcut so operators can relaunch the tray on demand. This
# is discoverability only, not autostart. Written to the machine-wide
# (All Users) Programs folder.
try {{
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $installedTray
$shortcut.WorkingDirectory = $InstallDir
$shortcut.Description = "ShopDB Endpoint Agent tray"
if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }}
$shortcut.Save()
Write-Host " created Start Menu shortcut: $shortcutPath"
}}
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
# Launch the tray once now so it appears without waiting for the next
# logon. Single-instance guard makes a duplicate launch a no-op.
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
}}
}}
# 3. Install and start the service. The agent supports its own service install.
Write-Host "[3/4] Installing and starting the service ..."
Write-Host "[3/6] 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
# 4. Confirm the service (and tray, when installed) actually reached a running
# state before we destroy the installer. sc.exe start returns before the service
# is up, and a failed start would otherwise leave a half-installed endpoint with
# the secret-bearing install.ps1 already deleted. Poll with a short backoff and
# retry the start a few times; only proceed to cleanup once healthy.
Write-Host "[4/6] Verifying agent runtime ..."
$serviceOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
Start-Sleep -Seconds 3
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }}
Write-Host " service not running yet (attempt $attempt/5); retrying start ..."
try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }}
}}
if (-not $serviceOk) {{
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
}}
Write-Host " service is running." -ForegroundColor Green
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$trayOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
Start-Sleep -Seconds 2
}}
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
# ProgramData so the uninstaller and appsettings/cert are available later, then
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
# collector key in its default parameter, so it must not linger on the endpoint.
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{
if ([string]::IsNullOrWhiteSpace($name)) {{ continue }}
$src = Join-Path $sourceDir $name
if (Test-Path $src) {{
try {{ Copy-Item -Path $src -Destination $dataDir -Force }}
catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }}
}}
}}
$uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
$installSelf = Join-Path $sourceDir "install.ps1"
if (Test-Path $installSelf) {{
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
}}
Write-Host "[6/6] Done." -ForegroundColor Green
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."
}}
else {{
Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed."
}}
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
@@ -421,6 +542,9 @@ def package_siteagent():
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')