Twelve endpoints routes shared one permission, which made a reasonable policy impossible to express. A site that wants signed-out visitors to see live status on a PC page had to hand them the entire fleet - including every logged-in username on the floor - because the per-asset panels and the fleet-wide list were gated on the same string. endpoints.fleet now gates the list and the three dashboard routes. endpoints.view keeps the per-asset reads: status, services, processes, interfaces, events, command history. The name is load-bearing. guest_grantable_permissions() opens any '.view' or '.read' permission to the guest role, so calling this one 'endpoints.fleet' rather than 'endpoints.fleetview' is what keeps the roster off the public surface while per-asset telemetry stays grantable. Verified both ways. Also: the services panel emitted Start/Stop row actions to every reader, because row_actions_for() never consulted the caller. Clicking was always refused server-side, so nothing was exploitable, but showing a read-only viewer controls over a shop-floor PC is the wrong signal - and it stops being cosmetic the moment a site grants that panel to guests. That needed a contract addition rather than a local check: caller_holds() on shopdb.api answers whether THIS caller holds a permission without refusing the request, which is the complement to require_permission for a surface that serves everyone but should only offer what the caller can act on. Guests are included, since a signed-out visitor holds whatever the guest role was granted. Reaching into shopdb.utils.authz instead would repeat the ADR-002 violation the contract gate caught earlier in this branch. Contract 0.24.0 -> 0.25.0. DEPLOYMENT: endpoints.fleet is a new permission. Run 'flask seed permissions' and grant it to the roles that should keep seeing the Fleet page. Until then that page is admin-only, since admins bypass every check - a safe direction, but not an automatic one. Full suite 2124 passed, naming and contract gates green, map and spec regenerated.
615 lines
26 KiB
Python
615 lines
26 KiB
Python
"""Endpoints plugin main class.
|
|
|
|
Endpoint RMM telemetry reported by the ShopDB agent. Accepts collector input
|
|
over HTTPS per ADR-006: the agent posts a per-host payload to
|
|
POST /api/collector/endpoints, the plugin resolves the reported hostname to an
|
|
existing computer asset, overwrites that asset's cached EndpointState, and
|
|
appends any curated Windows events the report carried - all in one commit, so
|
|
the cached snapshot and the events it summarizes never disagree.
|
|
|
|
Identity is the hostname, resolved against the computers plugin. An unresolved
|
|
hostname is reported as a ValueError (surfaced to the caller as a validation
|
|
error), never silently dropped: an unknown host is a real gap between the fleet
|
|
and ShopDB, the same discipline the backups collector uses for machine numbers.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
from shopdb.api import db
|
|
|
|
from .api import endpoints_bp
|
|
from .eventcatalog import EVENT_CATALOG
|
|
from .models import EndpointState, EndpointEvent, EndpointCommand
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The fixed catalog of Windows Event IDs the agent forwards. Derived from
|
|
# EVENT_CATALOG (eventcatalog.py) so the ingest filter and the display titles
|
|
# can never drift. IDs outside this set are ignored on ingest rather than
|
|
# stored, keeping endpointevents a readable security/stability history instead
|
|
# of a raw event firehose.
|
|
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
|
|
|
|
|
|
def _parse_dt(value):
|
|
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
|
|
if not value:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.replace(tzinfo=None)
|
|
text = str(value).strip().replace('Z', '+00:00')
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is not None:
|
|
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
|
|
return parsed
|
|
|
|
|
|
def _as_json(value):
|
|
"""Serialize a list/dict to JSON text, or None when nothing was sent."""
|
|
if value is None:
|
|
return None
|
|
return json.dumps(value, separators=(',', ':'), sort_keys=True)
|
|
|
|
|
|
class EndpointsPlugin(BasePlugin):
|
|
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
|
|
|
|
def __init__(self):
|
|
self._manifest = self._load_manifest()
|
|
|
|
def _load_manifest(self) -> Dict:
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
if manifest_path.exists():
|
|
with open(manifest_path, 'r') as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'endpoints'),
|
|
version=self._manifest.get('version', '0.1.0'),
|
|
description=self._manifest.get(
|
|
'description', 'Endpoint RMM telemetry'),
|
|
author=self._manifest.get('author', 'ShopDB Team'),
|
|
dependencies=self._manifest.get('dependencies', ['computers']),
|
|
core_version=self._manifest.get('core_version', '>=0.20.0,<1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/endpoints'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
return endpoints_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [EndpointState, EndpointEvent, EndpointCommand]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""RBAC for the human-facing read routes.
|
|
|
|
View only. Ingest is not covered here: the collector path authorizes on
|
|
a collector.ingest-scoped service token, not a user JWT. There is no
|
|
write/delete permission because operators do not edit telemetry - the
|
|
agent is the sole author, and history is pruned by policy, not by hand.
|
|
"""
|
|
return [
|
|
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
|
|
# The FLEET-WIDE list and dashboards, separate from endpoints.view,
|
|
# which covers reading ONE endpoint that the caller already reached
|
|
# through its PC page.
|
|
#
|
|
# They were one permission, and that made a reasonable policy
|
|
# impossible to express: a site that wants signed-out visitors to
|
|
# see live status on a PC page had to hand them the whole fleet,
|
|
# including every logged-in username on the floor, because the
|
|
# panels and the list were gated on the same string.
|
|
#
|
|
# It is deliberately NOT named '.view'. guest_grantable_permissions
|
|
# opens any '.view' or '.read' permission to the guest role, so this
|
|
# name is what keeps the fleet roster off the public surface while
|
|
# per-asset telemetry can still be granted.
|
|
('endpoints.fleet',
|
|
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
|
|
# Issue an allowlisted command to an endpoint agent (for example the
|
|
# MTConnect toggle). Separate from endpoints.view because acting on a
|
|
# shop-floor PC is a heavier grant than reading its telemetry.
|
|
('endpoints.command',
|
|
'Send allowlisted commands to endpoint agents', 'endpoints'),
|
|
# Elevated grant for destructive endpoint actions (system_restart).
|
|
# Required IN ADDITION to endpoints.command by the enqueue route, so a
|
|
# remote reboot is a strictly heavier grant than a routine toggle.
|
|
('endpoints.restart',
|
|
'Restart endpoint computers (destructive)', '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
|
|
# touching computers/geenforce collectors. Minted from the settings
|
|
# UI (see get_settings_cards). Admins hold it via the catalog.
|
|
('collector.ingest.endpoints',
|
|
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
|
|
]
|
|
|
|
def get_settings_defaults(self) -> List[Dict]:
|
|
"""Settings owned by the endpoints agent-key kill switch.
|
|
|
|
`envkeyfallback` gates the bootstrap .env collector key: while true the
|
|
old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the
|
|
only path before a UI key exists). The regenerate flow flips it false so
|
|
the .env key stops working and the managed token is the sole credential,
|
|
making revocation an instant kill switch. `agentkeyprovisioned` records
|
|
that a UI-managed key has been minted so the settings page can render
|
|
the rotate/revoke state instead of the first-run generate state.
|
|
"""
|
|
return [
|
|
{
|
|
'key': 'plugin.endpoints.envkeyfallback',
|
|
'value': 'true',
|
|
'valuetype': 'boolean',
|
|
'category': 'plugin.endpoints',
|
|
'description': 'Accept the bootstrap .env collector key for '
|
|
'endpoints. Turned off once a UI-managed agent '
|
|
'key is provisioned so revocation is a real kill '
|
|
'switch.',
|
|
},
|
|
{
|
|
'key': 'plugin.endpoints.agentkeyprovisioned',
|
|
'value': 'false',
|
|
'valuetype': 'boolean',
|
|
'category': 'plugin.endpoints',
|
|
'description': 'True once an operator has minted a UI-managed '
|
|
'endpoint agent key from the settings page.',
|
|
},
|
|
]
|
|
|
|
def get_settings_cards(self) -> List[Dict]:
|
|
"""The endpoints management card on the global settings page.
|
|
|
|
A settings card rather than a docs page because it is the operator's
|
|
control surface: manage the agent key (the kill switch), build the site
|
|
agent, and configure what it reports. Admin only - key management mints
|
|
a collector service credential.
|
|
"""
|
|
return [
|
|
{
|
|
'group': 'Endpoints',
|
|
'to': '/settings/endpoints/agentkey',
|
|
'icon': 'key',
|
|
'title': 'Endpoints',
|
|
'description': 'Manage the endpoint agent key, build the site '
|
|
'agent, and configure what it reports.',
|
|
'position': 27,
|
|
},
|
|
]
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
"""Sidebar entry for the fleet list view.
|
|
|
|
Filed under Assets, immediately after PCs (position 15), because the
|
|
fleet view is a telemetry lens over the same computers rather than a
|
|
different kind of thing. It shipped declaring a section of its own,
|
|
which rendered a one-item 'Fleet' group at the bottom of the sidebar,
|
|
far from the PCs it describes.
|
|
"""
|
|
return [
|
|
{
|
|
'name': 'Fleet',
|
|
'icon': 'activity',
|
|
'route': '/endpoints',
|
|
'position': 16,
|
|
'permission': 'endpoints.fleet',
|
|
'section': 'assets',
|
|
},
|
|
]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
logger.info('Endpoints plugin initialized (v%s)', self.meta.version)
|
|
|
|
# ---- ADR-010 UI hooks -----------------------------------------------
|
|
|
|
def get_asset_panels(self) -> List[Dict]:
|
|
"""Per-PC detail panels, computer assets only.
|
|
|
|
Panels feeding the generic renderer: the current status snapshot
|
|
(keyvalue), monitored services and processes split into two list cards
|
|
that share one services snapshot (filtered by kind), the most recent
|
|
curated events (list), and the network interfaces (table). Each is a
|
|
single-asset indexed read, so the cost is the same profile as the
|
|
warranty/backups panels. Panels hide themselves when their endpoint
|
|
returns nothing.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'endpoints-status',
|
|
'title': 'Endpoint status',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/status',
|
|
'render': 'keyvalue',
|
|
'empty': 'No agent report yet.',
|
|
'position': 10,
|
|
},
|
|
{
|
|
'id': 'endpoints-tracked-services',
|
|
'title': 'Tracked Services',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
|
'render': 'list',
|
|
'collapsible': True,
|
|
'filter': {'key': 'kind', 'equals': 'service'},
|
|
'status': {
|
|
'key': 'statecolor',
|
|
'levels': {
|
|
'green': 'ok',
|
|
'yellow': 'warning',
|
|
'red': 'critical',
|
|
},
|
|
},
|
|
'map': {
|
|
'title': 'name',
|
|
'badge': {'label': 'statelabel', 'color': 'statecolor'},
|
|
'action': 'action',
|
|
},
|
|
'commandendpoint': '/api/endpoints/{assetid}/commands',
|
|
'permission': 'endpoints.command',
|
|
'empty': 'No monitored services reported.',
|
|
'position': 12,
|
|
},
|
|
{
|
|
'id': 'endpoints-tracked-processes',
|
|
'title': 'Tracked Processes',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
|
'render': 'list',
|
|
'collapsible': True,
|
|
'filter': {'key': 'kind', 'equals': 'process'},
|
|
'status': {
|
|
'key': 'statecolor',
|
|
'levels': {
|
|
'green': 'ok',
|
|
'yellow': 'warning',
|
|
'red': 'critical',
|
|
},
|
|
},
|
|
'map': {
|
|
'title': 'name',
|
|
'badge': {'label': 'statelabel', 'color': 'statecolor'},
|
|
'action': 'action',
|
|
},
|
|
'commandendpoint': '/api/endpoints/{assetid}/commands',
|
|
'permission': 'endpoints.command',
|
|
'empty': 'No monitored processes reported.',
|
|
'position': 13,
|
|
},
|
|
{
|
|
'id': 'endpoints-events',
|
|
'title': 'Recent events',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
|
|
'render': 'list',
|
|
'collapsible': True,
|
|
'status': {
|
|
'key': 'severity',
|
|
'levels': {
|
|
'info': 'ok',
|
|
'warning': 'warning',
|
|
'critical': 'critical',
|
|
},
|
|
},
|
|
'map': {
|
|
'title': 'title',
|
|
'badge': {
|
|
'label': 'severity',
|
|
'labelmap': {
|
|
'critical': 'Critical',
|
|
'warning': 'Warning',
|
|
'info': 'Info',
|
|
},
|
|
},
|
|
'meta': [
|
|
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
|
],
|
|
},
|
|
'empty': 'No recent events.',
|
|
'collapsedDefault': True,
|
|
'position': 90,
|
|
},
|
|
{
|
|
'id': 'endpoints-interfaces',
|
|
'title': 'Network interfaces',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/interfaces',
|
|
'render': 'table',
|
|
'collapsible': True,
|
|
'columns': [
|
|
{'key': 'alias', 'label': 'Adapter'},
|
|
{'key': 'ipv4', 'label': 'IPv4'},
|
|
{'key': 'mac', 'label': 'MAC'},
|
|
{'key': 'gateway', 'label': 'Gateway'},
|
|
{'key': 'subnet', 'label': 'Subnet'},
|
|
{'key': 'dhcp', 'label': 'Assignment'},
|
|
{'key': 'role', 'label': 'Role'},
|
|
],
|
|
'empty': 'No network interfaces reported.',
|
|
'position': 16,
|
|
},
|
|
]
|
|
|
|
def get_dashboard_widgets(self) -> List[Dict]:
|
|
"""Fleet-wide exception cards. Run once on dashboard load, not per PC.
|
|
|
|
Both cards hide when empty and cap their rows, so a healthy fleet adds
|
|
nothing to the dashboard and an unhealthy one adds a bounded list.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'endpoints-quiet',
|
|
'viewall': '/pcs',
|
|
'title': 'Endpoints gone quiet',
|
|
'endpoint': '/api/endpoints/dashboard/quiet',
|
|
'render': 'exceptions',
|
|
'severity': 'warning',
|
|
'permission': 'endpoints.fleet',
|
|
'empty': 'hide',
|
|
'position': 22,
|
|
'map': {
|
|
'title': 'hostname',
|
|
'detail': '',
|
|
'meta': [{'key': 'quietdays', 'label': 'silent',
|
|
'suffix': ' days'}],
|
|
'link': '/pcs/{computerid}',
|
|
},
|
|
},
|
|
{
|
|
'id': 'endpoints-stability',
|
|
'viewall': '/pcs',
|
|
'title': 'Endpoints with crashes or shutdowns',
|
|
'endpoint': '/api/endpoints/dashboard/stability',
|
|
'render': 'exceptions',
|
|
'severity': 'critical',
|
|
'permission': 'endpoints.fleet',
|
|
'empty': 'hide',
|
|
'position': 16,
|
|
'map': {
|
|
'title': 'hostname',
|
|
'detail': '',
|
|
'meta': [{'key': 'eventcount', 'suffix': ' events (48h)'}],
|
|
'link': '/pcs/{computerid}',
|
|
},
|
|
},
|
|
]
|
|
|
|
# ---- ADR-006 collector contract -------------------------------------
|
|
|
|
def get_collector_schema(self) -> Optional[Dict]:
|
|
return {
|
|
'identityfield': 'hostname',
|
|
'fields': {
|
|
'type': 'object',
|
|
'required': ['hostname'],
|
|
'properties': {
|
|
'hostname': {
|
|
'type': 'string',
|
|
'description': 'Endpoint hostname. Resolved against '
|
|
'the computers plugin (computers.hostname).',
|
|
},
|
|
'adopt': {
|
|
'type': 'boolean',
|
|
'description': 'First-contact only. When true and the '
|
|
'hostname does not resolve, adoption is '
|
|
'delegated to the computers collector '
|
|
'(see DESIGN-DECISIONS-agent.md 1a). This '
|
|
'plugin never creates assets itself.',
|
|
},
|
|
'loggedinuser': {'type': 'string'},
|
|
'agentversion': {'type': 'string'},
|
|
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
|
'uptimeseconds': {'type': 'integer'},
|
|
'idleseconds': {'type': 'integer'},
|
|
'cpupercent': {'type': 'number'},
|
|
'memorypercent': {'type': 'number'},
|
|
'services': {
|
|
'type': 'array',
|
|
'description': 'Monitored services/processes and their '
|
|
'run state (UDC, eDnc, Intune, ...).',
|
|
},
|
|
'ports': {
|
|
'type': 'array',
|
|
'description': 'Open listening ports.',
|
|
},
|
|
'interfaces': {
|
|
'type': 'array',
|
|
'description': 'Network interface information.',
|
|
},
|
|
'scheduledtasks': {
|
|
'type': 'array',
|
|
'description': 'Scheduled task inventory + last run.',
|
|
},
|
|
'events': {
|
|
'type': 'array',
|
|
'description': 'Curated Windows Event Log records. Only '
|
|
'IDs in the server catalog are stored.',
|
|
'items': {
|
|
'type': 'object',
|
|
'required': ['eventid'],
|
|
'properties': {
|
|
'eventid': {'type': 'integer'},
|
|
'eventchannel': {'type': 'string'},
|
|
'eventsource': {'type': 'string'},
|
|
'eventtime': {'type': 'string',
|
|
'format': 'date-time'},
|
|
'eventmessage': {'type': 'string'},
|
|
'fields': {'type': 'object'},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
# PC-identifying and hardware fields forwarded to the computers collector,
|
|
# which owns the Asset/Computer row and renders the Hardware and Network
|
|
# cards. The endpoints plugin only caches telemetry, so these must be
|
|
# relayed or vendor/model/OS/IP never land on the PC (see
|
|
# DESIGN-DECISIONS-agent.md 1a).
|
|
_COMPUTER_HANDOFF_FIELDS = (
|
|
'loggedinuser', 'serialnumber', 'pctype', 'machinenumber',
|
|
'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime',
|
|
)
|
|
|
|
def _forward_to_computers(self, hostname: str, payload: Dict):
|
|
"""Return the computers plugin and the PC subset to hand off, or None.
|
|
|
|
Both adoption and steady-state reports relay the same hardware subset,
|
|
so the Hardware and Network cards fill in on every report, not just at
|
|
first contact.
|
|
"""
|
|
from flask import current_app
|
|
|
|
manager = current_app.extensions.get('plugin_manager')
|
|
computers = manager.get_plugin('computers') if manager else None
|
|
if computers is None:
|
|
return None, None
|
|
|
|
subset = {'hostname': hostname}
|
|
for field in self._COMPUTER_HANDOFF_FIELDS:
|
|
if payload.get(field):
|
|
subset[field] = payload.get(field)
|
|
return computers, subset
|
|
|
|
def _adopt_computer(self, hostname: str, payload: Dict,
|
|
warnings: List[str]) -> None:
|
|
"""Delegate first-contact adoption to the computers collector.
|
|
|
|
This plugin never creates Asset or Computer rows itself (see
|
|
DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole
|
|
authority for that, so on an unknown hostname we forward the
|
|
PC-identifying subset of the payload to its collector. It runs inside
|
|
the same transaction, so a later failure rolls the adoption back.
|
|
"""
|
|
computers, adoptpayload = self._forward_to_computers(hostname, payload)
|
|
if computers is None:
|
|
raise ValueError(
|
|
'cannot adopt {!r}: computers plugin is not available'
|
|
.format(hostname))
|
|
|
|
computers.apply_collector_payload(adoptpayload)
|
|
db.session.flush()
|
|
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
|
|
|
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
|
"""Idempotently upsert one endpoint's state and append its events.
|
|
|
|
The cached EndpointState and any EndpointEvent rows are written in the
|
|
same transaction (the dispatcher owns the commit), so a reader never
|
|
sees a snapshot without the events that produced it.
|
|
"""
|
|
from plugins.computers.models import Computer
|
|
|
|
warnings: List[str] = []
|
|
|
|
hostname = (payload.get('hostname') or '').strip()
|
|
if not hostname:
|
|
raise ValueError('hostname is required')
|
|
|
|
computer = (Computer.query
|
|
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
|
.first())
|
|
if computer is None:
|
|
# First-contact adoption is delegated to the computers collector,
|
|
# which is the only authority allowed to create Asset + Computer
|
|
# rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents
|
|
# assets itself. Adoption runs in the dispatcher's transaction, so a
|
|
# later ValueError rolls the whole report back.
|
|
if payload.get('adopt'):
|
|
self._adopt_computer(hostname, payload, warnings)
|
|
computer = (Computer.query
|
|
.filter(db.func.lower(Computer.hostname)
|
|
== hostname.lower())
|
|
.first())
|
|
if computer is None:
|
|
raise ValueError(
|
|
'could not resolve hostname to a computer: {!r}'.format(hostname))
|
|
else:
|
|
# Already adopted: relay the hardware/identity subset on every
|
|
# report so the computers plugin keeps vendor/model/OS/IP current.
|
|
# Without this, Hardware and Network stay blank after adoption
|
|
# because this plugin only caches telemetry.
|
|
computers, subset = self._forward_to_computers(hostname, payload)
|
|
if computers is not None and len(subset) > 1:
|
|
computers.apply_collector_payload(subset)
|
|
db.session.flush()
|
|
|
|
assetid = computer.assetid
|
|
|
|
state = EndpointState.query.filter_by(assetid=assetid).first()
|
|
action = 'updated'
|
|
if state is None:
|
|
state = EndpointState(assetid=assetid)
|
|
db.session.add(state)
|
|
action = 'created'
|
|
|
|
state.hostname = hostname
|
|
if 'loggedinuser' in payload:
|
|
state.loggedinuser = payload.get('loggedinuser')
|
|
if 'agentversion' in payload:
|
|
state.agentversion = payload.get('agentversion')
|
|
if 'lastboottime' in payload:
|
|
state.lastboottime = _parse_dt(payload.get('lastboottime'))
|
|
if 'uptimeseconds' in payload:
|
|
state.uptimeseconds = payload.get('uptimeseconds')
|
|
if 'idleseconds' in payload:
|
|
state.idleseconds = payload.get('idleseconds')
|
|
if 'cpupercent' in payload:
|
|
state.cpupercent = payload.get('cpupercent')
|
|
if 'memorypercent' in payload:
|
|
state.memorypercent = payload.get('memorypercent')
|
|
if 'services' in payload:
|
|
state.servicesjson = _as_json(payload.get('services'))
|
|
if 'ports' in payload:
|
|
state.portsjson = _as_json(payload.get('ports'))
|
|
if 'interfaces' in payload:
|
|
state.interfacesjson = _as_json(payload.get('interfaces'))
|
|
if 'scheduledtasks' in payload:
|
|
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
|
|
|
state.lastreportedat = datetime.utcnow()
|
|
|
|
stored_events = 0
|
|
for record in (payload.get('events') or []):
|
|
try:
|
|
eventid = int(record.get('eventid'))
|
|
except (TypeError, ValueError):
|
|
warnings.append('skipped event with non-integer eventid')
|
|
continue
|
|
if eventid not in CURATED_EVENT_IDS:
|
|
# Outside the catalog: ignored on purpose, not an error.
|
|
continue
|
|
db.session.add(EndpointEvent(
|
|
assetid=assetid,
|
|
eventid=eventid,
|
|
eventchannel=record.get('eventchannel'),
|
|
eventsource=record.get('eventsource'),
|
|
eventtime=_parse_dt(record.get('eventtime')),
|
|
eventmessage=record.get('eventmessage'),
|
|
payloadjson=_as_json(record.get('fields')),
|
|
))
|
|
stored_events += 1
|
|
|
|
# flush, not commit: the collector dispatcher owns the transaction.
|
|
db.session.flush()
|
|
|
|
return {
|
|
'action': action,
|
|
'assetid': assetid,
|
|
'identityvalue': hostname,
|
|
'storedevents': stored_events,
|
|
'warnings': warnings,
|
|
} |