Merge the wilm delivery work into main
Ben Baker's installed-software collection, software delivery (run_script and
update_app), agentless adoption tool and command-queue hardening, with his
authorship preserved.
Reviewed before promotion. Fixed: the endpoints plugin tests were never collected
by pytest, the SBOM snapshot table's key would exhaust on MySQL, ingest did not
truncate two columns strict MySQL would reject, and update_app's version branch
pinned a digest for a file that route never serves. Agent packaging moved off
require_role('admin') onto a new endpoints.agentpackage permission.
Contract 0.27.0. Full suite 2231, frontend 314.
This commit is contained in:
@@ -26,7 +26,9 @@ from shopdb.api import db
|
||||
|
||||
from .api import endpoints_bp
|
||||
from .eventcatalog import EVENT_CATALOG
|
||||
from .models import EndpointState, EndpointEvent, EndpointCommand
|
||||
from .models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +39,21 @@ logger = logging.getLogger(__name__)
|
||||
# of a raw event firehose.
|
||||
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
|
||||
|
||||
# Top-level payload keys the ingest path knows about today - either persisted on
|
||||
# EndpointState, appended as events, or relayed to the computers collector.
|
||||
# Anything the agent sends outside this set is not stored yet; it is logged (see
|
||||
# apply_collector_payload) so new agent telemetry - disks, bios, memory, etc. -
|
||||
# is observable in the logs before the server grows columns for it. Keep this in
|
||||
# lockstep with the keys consumed in apply_collector_payload and the computers
|
||||
# handoff subset.
|
||||
KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
|
||||
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
|
||||
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||
'scheduledtasks', 'applications', 'events',
|
||||
})
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
|
||||
@@ -61,6 +78,20 @@ def _as_json(value):
|
||||
return json.dumps(value, separators=(',', ':'), sort_keys=True)
|
||||
|
||||
|
||||
def _clamp(value, length):
|
||||
"""A string bounded to its column width, or None.
|
||||
|
||||
Ingest writes whatever an endpoint reports. A registry value longer than its
|
||||
column is not a data-quality nuisance on MySQL: in strict mode it raises
|
||||
Data too long and fails the whole check-in transaction, so one machine with
|
||||
a malformed Uninstall entry stops reporting telemetry and events entirely.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:length] or None
|
||||
|
||||
|
||||
class EndpointsPlugin(BasePlugin):
|
||||
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
|
||||
|
||||
@@ -91,7 +122,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
return endpoints_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [EndpointState, EndpointEvent, EndpointCommand]
|
||||
return [EndpointState, EndpointEvent, EndpointCommand,
|
||||
EndpointApplication]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC for the human-facing read routes.
|
||||
@@ -129,6 +161,23 @@ class EndpointsPlugin(BasePlugin):
|
||||
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||
('endpoints.restart',
|
||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
||||
# Elevated grant for software delivery (run_script, update_app).
|
||||
# Required IN ADDITION to endpoints.command by the enqueue route, so
|
||||
# running a staged script or pushing an installer is a strictly
|
||||
# heavier grant than a routine service/process toggle.
|
||||
('endpoints.deploy',
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
# Packaging the agent is a heavier grant than deploying to one, and
|
||||
# it is not the same job either: the generated ZIP carries a live
|
||||
# collector key stamped into its install script, so whoever holds
|
||||
# this can mint a distributable artefact containing the fleet-wide
|
||||
# command-channel credential. It was require_role('admin'), which
|
||||
# this codebase otherwise reserves for the few genuinely
|
||||
# role-shaped checks - and a role cannot be granted to one person
|
||||
# for one job the way a permission can.
|
||||
('endpoints.agentpackage',
|
||||
'Generate and download an agent or adoption-tool package '
|
||||
'(contains a live collector key)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||
@@ -208,6 +257,19 @@ class EndpointsPlugin(BasePlugin):
|
||||
'permission': 'endpoints.fleet',
|
||||
'section': 'assets',
|
||||
},
|
||||
# TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
|
||||
# run_script delivery path while Cameron builds the production UI.
|
||||
# Gated on endpoints.deploy so only delivery operators see it. Remove
|
||||
# this entry (and the DeliveryTestBench view/route) once the real
|
||||
# delivery UI ships.
|
||||
{
|
||||
'name': 'Delivery test bench',
|
||||
'icon': 'send',
|
||||
'route': '/endpoints/delivery-test',
|
||||
'position': 17,
|
||||
'permission': 'endpoints.deploy',
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
@@ -374,6 +436,26 @@ class EndpointsPlugin(BasePlugin):
|
||||
'empty': 'No network interfaces reported.',
|
||||
'position': 16,
|
||||
},
|
||||
{
|
||||
# Installed-software (SBOM) inventory. Low-priority reference
|
||||
# data, not something looked at on every visit, so it starts
|
||||
# collapsed and sits near the bottom. Raw discovered list for
|
||||
# now: no managed-application linkage is surfaced yet.
|
||||
'id': 'endpoints-applications',
|
||||
'title': 'Installed software',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/applications',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'collapsedDefault': True,
|
||||
'columns': [
|
||||
{'key': 'displayname', 'label': 'Name'},
|
||||
{'key': 'displayversion', 'label': 'Version'},
|
||||
{'key': 'publisher', 'label': 'Publisher'},
|
||||
],
|
||||
'empty': 'No installed software reported.',
|
||||
'position': 30,
|
||||
},
|
||||
]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
@@ -468,6 +550,24 @@ class EndpointsPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'description': 'Scheduled task inventory + last run.',
|
||||
},
|
||||
'applications': {
|
||||
'type': 'array',
|
||||
'description': 'Installed-software SBOM from both '
|
||||
'Windows Uninstall registry hives. Each '
|
||||
'item is {displayname, displayversion, '
|
||||
'publisher}, de-duped on name+version. '
|
||||
'Stored as a per-asset snapshot, '
|
||||
'replaced on every report.',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'required': ['displayname'],
|
||||
'properties': {
|
||||
'displayname': {'type': 'string'},
|
||||
'displayversion': {'type': 'string'},
|
||||
'publisher': {'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'events': {
|
||||
'type': 'array',
|
||||
'description': 'Curated Windows Event Log records. Only '
|
||||
@@ -540,6 +640,48 @@ class EndpointsPlugin(BasePlugin):
|
||||
db.session.flush()
|
||||
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
|
||||
|
||||
def _replace_applications(self, assetid: int, applications,
|
||||
warnings: List[str]) -> int:
|
||||
"""Replace an asset's SBOM snapshot with the reported set.
|
||||
|
||||
The agent reports the full installed-software list each cycle, so the
|
||||
current inventory is the newest report, not an accumulation. Delete the
|
||||
asset's existing rows and insert the reported ones in the same
|
||||
transaction the caller flushes. appid is left null: no
|
||||
discovery-to-catalog ("Managed Applications") matcher is wired yet.
|
||||
Malformed items (no displayname) are skipped, not fatal.
|
||||
"""
|
||||
EndpointApplication.query.filter_by(assetid=assetid).delete(
|
||||
synchronize_session=False)
|
||||
|
||||
if not applications:
|
||||
return 0
|
||||
|
||||
stored = 0
|
||||
for item in applications:
|
||||
if not isinstance(item, dict):
|
||||
warnings.append('skipped non-object application entry')
|
||||
continue
|
||||
displayname = (item.get('displayname') or '').strip()
|
||||
if not displayname:
|
||||
warnings.append('skipped application with no displayname')
|
||||
continue
|
||||
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
|
||||
# mode an over-long value raises Data too long and takes down the
|
||||
# WHOLE check-in transaction, so one machine with a malformed
|
||||
# Uninstall registry entry stops reporting entirely - telemetry,
|
||||
# events and all - until somebody uninstalls the offender. SQLite
|
||||
# does not enforce declared lengths, so no test here would ever see
|
||||
# it. displayname was already clamped; these two were not.
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid,
|
||||
displayname=displayname[:255],
|
||||
displayversion=_clamp(item.get('displayversion'), 128),
|
||||
publisher=_clamp(item.get('publisher'), 255),
|
||||
))
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotently upsert one endpoint's state and append its events.
|
||||
|
||||
@@ -616,8 +758,36 @@ class EndpointsPlugin(BasePlugin):
|
||||
if 'scheduledtasks' in payload:
|
||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
||||
|
||||
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
|
||||
# table (endpointapplications), not a JSON blob on the state, so it can
|
||||
# later join the applications catalog ("Managed Applications"). Only
|
||||
# replace when the key is present: a report that omits applications must
|
||||
# not wipe the last-known inventory. The delete + re-insert happens in
|
||||
# this same transaction (the dispatcher owns the commit), so a reader
|
||||
# never sees a half-written list. displayname is required; malformed
|
||||
# rows are skipped rather than failing the whole report.
|
||||
if 'applications' in payload:
|
||||
self._replace_applications(assetid, payload.get('applications'),
|
||||
warnings)
|
||||
|
||||
state.lastreportedat = datetime.utcnow()
|
||||
|
||||
# Log any keys the agent sent that the ingest path does not consume yet.
|
||||
# New agent telemetry (disks, bios, memory, ...) lands here first: it is
|
||||
# not persisted until the server grows columns for it, but logging the
|
||||
# keys and a compact preview makes incoming telemetry visible in the
|
||||
# logs so we can confirm the agent is sending it before wiring the UI.
|
||||
unknown_keys = [key for key in payload if key not in KNOWN_PAYLOAD_KEYS]
|
||||
if unknown_keys:
|
||||
preview = {key: payload.get(key) for key in sorted(unknown_keys)}
|
||||
try:
|
||||
preview_text = json.dumps(preview, default=str)[:2000]
|
||||
except (TypeError, ValueError):
|
||||
preview_text = repr(preview)[:2000]
|
||||
logger.info(
|
||||
'endpoint %s sent %d unpersisted telemetry key(s): %s | preview=%s',
|
||||
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
|
||||
|
||||
stored_events = 0
|
||||
for record in (payload.get('events') or []):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user