Two changes to the PC page, both about what a responder meets first. LIVE STATE FIRST. The contributed panels moved to the head of the card flow. The grid is a two-column FLOW (column-count, with display:contents on the column wrappers), so a card cannot be pinned to a side and only document order decides where it lands - putting the panels first fills the left column with endpoint status, services, processes, interfaces and activity, and leaves identity, hardware, network and location to the right. Panels still self-order among themselves by declared position and still self-hide when no plugin answers for the asset type. ONE ACTIVITY FEED replaces the Recent events card. Events and commands answer the same question - what happened to this box - and reading them as two lists meant interleaving by eye. GET /api/endpoints/asset/<id>/activity merges them newest-first, capped at 15. Both are keyed by assetid, so it is two indexed reads and a merge sort rather than a join, and it renders through the existing ADR-010 list renderer with no bespoke frontend work. A failed command is marked critical, because that is the row somebody needs to notice. Not collapsed by default, unlike the card it replaces. The core AuditLog is deliberately NOT a third source: its rows carry the action as the entity name and the asset only inside a details string, so including it would mean parsing prose to find out which machine a row belongs to. Requester names are withheld from callers who cannot themselves issue commands, via caller_holds. A read-only viewer - a guest, on a site that grants this panel - gets the history without a roster of who restarted what, and the lookup is skipped entirely rather than fetched and discarded. requestedby holds a user ID, so names resolve through one batched query, not one per row. Verified on dev telemetry: a queued command shows as 'Collect now / Pending by admin' above the Windows events, ordered by time. Full suite 2124 passed, vitest 260 passed, naming/contract/parity gates green, spec regenerated at 332 paths.
621 lines
27 KiB
Python
621 lines
27 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,
|
|
},
|
|
{
|
|
# One feed instead of two cards. Events and commands answer the
|
|
# same question - what happened to this box - and reading them
|
|
# separately meant interleaving two lists by eye. Sits high on
|
|
# the page because it is the first thing looked at when
|
|
# something has gone wrong, and NOT collapsed by default for
|
|
# the same reason.
|
|
'id': 'endpoints-activity',
|
|
'title': 'Activity',
|
|
'assettypes': ['computer'],
|
|
'endpoint': '/api/endpoints/asset/{assetid}/activity',
|
|
'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': 'detail'},
|
|
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
|
],
|
|
},
|
|
'empty': 'Nothing recorded for this endpoint yet.',
|
|
'position': 20,
|
|
},
|
|
{
|
|
'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,
|
|
} |