Make the server authoritative for what endpoints watch and launch
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / agent (push) Has been cancelled
CI / lean-build (push) Has been cancelled

Fleet health showed dncMain.exe red on endpoints that never ran eDNC. Three
layers each re-added the monitor, so fixing any one of them in isolation left
the others to put it back.

The installer baked a watch list into appsettings.json on every package, and
appsettings.json is never rewritten on the box, so an entry outlived whatever
policy justified it. The server auto-attached shipped monitors by PC type name.
The agent then UNIONED the server's resolved list with its local one, so an
entry could never be removed from the server side at all.

Now: the installer seeds nothing, auto-attach by PC type is gone, and
MonitorConfig.Merge replaces the local list with the server's. Null and empty
stay distinct - null means the fetch failed so keep what we have, empty means
watch nothing. Collapsing those blanks monitoring fleet-wide on one failed HTTP
call, or makes detaching the last monitor silently do nothing forever.

Ingest filters reported services against the resolved monitors as well. The
agent config sits on the endpoint, so it is not a trustworthy account of what
that endpoint should be reporting.

Launch paths move the same way, which exposed an older bug. MTConnect installs
to ProgramData and starts through run.bat, but LaunchPathPolicy approved only
the Program Files roots, so the launchpath on its shipped monitor row - seeded
in 0006, present from the start - was rejected every time as outside every
approved root. That was invisible because a local launchpaths entry wins in
ResolveLaunchPath and never reaches validation; remote start worked only
through the bypass. The same precedence meant editing an eDNC, UDC or MTConnect
path in the WebUI could never reach an existing endpoint.

So ProgramData is approved and the local launch map ships empty. ProgramData is
world-writable and the agent launches as SYSTEM, which is why it was excluded
originally; it is deliberately too broad for now and carries a REVISIT comment.
The remaining gates still hold - admin-configured monitor row, exists,
launchable extension, re-checked after symlink resolution. Narrowing it to
ProgramData\udc needs the fleet's non-Program-Files install locations
confirmed first.

Also seeds the Office ProPlus LTSC monitor templates, x64 and x86 as separate
rows since they differ only by path, and adds an agent test project. It links
the platform-pure sources rather than referencing the net8.0-windows assembly
so the semantics run on the Linux CI runners. Writing those tests caught a
merge bug where only the dedupe key was trimmed and the stored name was not.

Note for the wj merge: plugins/usb/frontend/views/usbSettings.spec.js fails on
this branch and is untouched here. Commit da91a29 added four kiosk settings the
save assertion does not expect. The spec looks like the stale side, but that is
the usb author's call - flagging rather than guessing.
This commit is contained in:
benjamin.baker
2026-09-20 15:04:29 -04:00
parent 790ed4ec66
commit 77eb594268
18 changed files with 1517 additions and 107 deletions

View File

@@ -28,22 +28,13 @@ 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.
# RETIRED. This setting named the PC type the shipped monitors auto-attached
# to, defaulting to 'Shopfloor PC'. That auto-attach path is gone - policy is
# now the only attachment route (see _resolve_pctype_monitors) - so nothing
# reads this. The key is kept named here, not deleted, so an existing row in a
# site's settings table is recognisable as dead rather than mysterious, and so
# the key is not reused for something else later.
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')
@@ -170,20 +161,25 @@ def delete_monitor(monitor_id):
def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy.
"""Monitor definitions a PC type should watch, from site policy alone.
Policy is the ONLY attachment route. Shipped monitors used to also attach
automatically to whichever PC type the shipped-pctype setting named, which
defaulted to 'Shopfloor PC' - so eDNC, UDC and MTConnect were expected
monitors on every endpoint of that type at every site, with no policy
anywhere and no way to see why. 0019 split the shipped bundle into
per-monitor policies and removed the shipped SITE policy, intending
"off by default"; this branch survived and kept them attached, which is
why dncMain.exe kept appearing in Fleet health for all endpoints.
Shipped monitors still exist and are still editable - a site composes them
through a Site Policy like anything else. What no longer happens is
attaching them because a PC type happens to carry a particular name.
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)

View File

@@ -201,21 +201,39 @@ _BASE_APPSETTINGS = {
'adoptonfirstrun': True,
'utilitiespath': '',
'logpath': '',
# Shipped EMPTY, deliberately. The server decides what an endpoint watches:
# the agent fetches its watch list from /api/endpoints/monitors/resolved
# every telemetry cycle and REPLACES this list with the answer, so anything
# seeded here is overwritten within one cadence anyway.
#
# It used to seed dncMain.exe, udc.exe, agent.exe and the MTConnect service,
# which is why every endpoint in the fleet reported eDNC regardless of what
# it actually ran. Back when the agent UNIONED the two lists, a baked-in
# entry could never be retracted from the server, so detaching a monitor in
# the WebUI silently did nothing on deployed endpoints and Fleet health
# surfaced dncMain everywhere. Seeding anything here re-creates that class of
# bug for as long as the affected packages stay in the field.
#
# To watch a process or service, attach a monitor to the PC type in the
# WebUI. That reaches every endpoint with no rebuild and no redeploy.
'monitors': {
'processes': [
{'name': 'dncMain.exe', 'app': 'eDNC'},
{'name': 'udc.exe', 'app': 'Universal Data Collector'},
{'name': 'agent.exe', 'app': 'MTConnect'},
],
'services': [
{'name': 'MTConnect UDC Agent', 'app': 'MTConnect'},
],
},
'launchpaths': {
'DncMain.exe': 'C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe',
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
'processes': [],
'services': [],
},
# Intentionally EMPTY, like monitors above, and for the same reason. Launch
# paths are server authoritative: they ride on the monitor rows the WebUI
# owns and reach the agent through /api/endpoints/monitors/resolved, so
# editing a path in the WebUI reaches every endpoint with no rebuild.
#
# A local entry here WINS over the server path AND bypasses the agent's
# LaunchPathPolicy validation (see CommandExecutor.ResolveLaunchPath), which
# is exactly how a stale pin outlives the configuration that justified it.
# eDNC, UDC and MTConnect all carry their launchpath on the shipped monitor
# rows instead.
#
# Seed an entry here only as a deliberate per-site override, accepting that
# the endpoint stops tracking WebUI changes for that image.
'launchpaths': {},
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so

View File

@@ -0,0 +1,162 @@
"""Seed the Office ProPlus LTSC monitor templates (x64 and x86).
Revision ID: endpoints0025officemonitors
Revises: endpoints0024bootstraptokens
Create Date: 2026-09-19
Adds shipped process-monitor definitions for the Office ProPlus LTSC suite at
both install roots:
C:\\Program Files\\Microsoft Office\\root\\Office16\\<app>.EXE (x64)
C:\\Program Files (x86)\\Microsoft Office\\root\\Office16\\<app>.EXE (x86)
Apps: WINWORD, EXCEL, POWERPNT, OUTLOOK, MSACCESS.
These are TEMPLATES, not an instruction to watch anything. Seeding a definition
only puts a row in the catalog; nothing is watched until a site policy attaches
it to a PC type. That separation is the whole point of the monitor rework - the
installer no longer bakes a watch list into appsettings.json, and the agent
replaces its list from the server each cycle - so adding rows here cannot
resurrect the "dncMain.exe on every endpoint" symptom.
x64 and x86 are SEPARATE rows rather than one row with two paths. The launchpath
column holds a single path, and the two roots are genuinely different installs;
a site pins whichever one it actually deploys. The kind/displayname unique
constraint is what forces the architecture into the display name, which is also
what an operator needs to see to pick the right one.
processname is the same image name for both (WINWORD.EXE and friends do not
differ by architecture), so these rows are distinguished by launchpath. A site
that runs both would attach only the row matching its install root.
Idempotent on (kind, displayname): a re-run inserts nothing. Unlike 0006 this
does NOT bail out when the table is non-empty - that guard would make this a
no-op on every existing site, which is precisely where these templates are
wanted. Guards on the table being present and no-ops if it is not.
Downgrade removes only the rows this added, and only when no policy action
references them, so a site that composed one into a policy does not lose it to a
rollback.
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# Alembic stores this in alembic_version.version_num, a VARCHAR(32).
revision = 'endpoints0025officemonitors'
down_revision = 'endpoints0024bootstraptokens'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
MONITORS = 'endpointmonitors'
_X64_ROOT = r'C:\Program Files\Microsoft Office\root\Office16'
_X86_ROOT = r'C:\Program Files (x86)\Microsoft Office\root\Office16'
# (label, image name). The image name is identical across architectures.
_OFFICE_APPS = [
('Word', 'WINWORD.EXE'),
('Excel', 'EXCEL.EXE'),
('PowerPoint', 'POWERPNT.EXE'),
('Outlook', 'OUTLOOK.EXE'),
('Access', 'MSACCESS.EXE'),
]
def _rows():
"""The shipped Office rows: every app at both install roots."""
for label, image in _OFFICE_APPS:
for architecture, root in (('x64', _X64_ROOT), ('x86', _X86_ROOT)):
yield {
'kind': 'process',
'displayname': 'Office {0} ({1})'.format(label, architecture),
'processname': image,
'servicename': None,
'launchpath': '{0}\\{1}'.format(root, image),
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
}
def _inspector():
from sqlalchemy import inspect
return inspect(op.get_bind())
def _has_table(name):
return _inspector().has_table(name)
def _existing(bind, kind, displayname):
return bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def upgrade():
if not _has_table(MONITORS):
return
bind = op.get_bind()
now = datetime.utcnow()
monitors = sa.table(
MONITORS,
sa.column('kind', sa.String),
sa.column('displayname', sa.String),
sa.column('processname', sa.String),
sa.column('servicename', sa.String),
sa.column('launchpath', sa.String),
sa.column('datacollectorpath', sa.String),
sa.column('logcollectorpath', sa.String),
sa.column('source', sa.String),
sa.column('isactive', sa.Boolean),
sa.column('createdat', sa.DateTime),
)
pending = []
for row in _rows():
if _existing(bind, row['kind'], row['displayname']) is not None:
continue
record = dict(row)
record['createdat'] = now
pending.append(record)
if pending:
op.bulk_insert(monitors, pending)
def downgrade():
if not _has_table(MONITORS):
return
bind = op.get_bind()
policies_present = _has_table('configurationpolicyactions')
for row in _rows():
monitorid = _existing(bind, row['kind'], row['displayname'])
if monitorid is None:
continue
# Leave behind anything a site has already composed into a policy:
# dropping it would silently break that policy's action.
if policies_present:
referenced = bind.execute(
sa.text('SELECT COUNT(*) FROM configurationpolicyactions '
'WHERE action = :action'),
{'action': 'monitor:{0}'.format(monitorid)}).scalar()
if referenced:
continue
bind.execute(
sa.text('DELETE FROM endpointmonitors '
'WHERE endpointmonitorid = :id'),
{'id': monitorid})

View File

@@ -1448,6 +1448,68 @@ class EndpointsPlugin(BasePlugin):
'(concurrent update?)'.format(stored, skipped, len(to_add)))
return stored
def _authoritative_services(self, assetid, reported):
"""Keep only reported monitors the SERVER expects this asset to watch.
The server is the authority on what is monitored. Without this, the
watch list is effectively whatever the endpoint says it is: the agent
merges its local appsettings.json into the server list, so anyone who
can edit that file on the box can add entries the server never
attached. Two attacks fall out of that, and the second is the one that
matters. Adding noise is merely annoying. Adding a NEVER-RUNNING entry
parks the asset at a permanent condition, and an operator who learns to
ignore a perpetually amber endpoint stops reading the signal that says
a real service died - the endpoint blinds the fleet view by talking,
not by going quiet.
Resolution is by wire name, case-insensitively, because that is the
identity the agent reports and the identity the resolver hands out.
An asset with no resolved monitors keeps nothing, which is the same
"no monitors, no conditions" contract _service_conditions already has.
Drops are logged rather than silent: an entry disappearing between the
endpoint and the page is exactly the kind of thing that is impossible
to diagnose from the UI alone.
"""
if not isinstance(reported, list):
return reported
from flask import current_app
from .api.routes import _resolved_monitors_for_asset, _service_name
try:
monitors = _resolved_monitors_for_asset(assetid)
except Exception:
# Never fail a check-in over the filter. A resolver error must not
# cost us the whole telemetry report, so fall back to storing what
# was reported and let the health layer apply its own rules.
current_app.logger.exception(
'[collector] asset_id=%s monitor_resolution_failed: '
'storing reported services unfiltered', assetid)
return reported
expected = set()
for monitor in monitors:
for wirename in (monitor.processname, monitor.servicename):
if wirename and wirename.strip():
expected.add(wirename.strip().lower())
kept, dropped = [], []
for entry in reported:
if not isinstance(entry, dict):
continue
name = (_service_name(entry) or '').strip().lower()
if name in expected:
kept.append(entry)
else:
dropped.append(name)
if dropped:
current_app.logger.info(
'[collector] asset_id=%s unmonitored_services_dropped: %s '
'(not attached by any site policy)', assetid, ', '.join(dropped))
return kept
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotently upsert one endpoint's state and append its events.
@@ -1526,7 +1588,8 @@ class EndpointsPlugin(BasePlugin):
if 'memorypercent' in payload:
state.memorypercent = payload.get('memorypercent')
if 'services' in payload:
state.servicesjson = _as_json(payload.get('services'))
state.servicesjson = _as_json(
self._authoritative_services(assetid, payload.get('services')))
if 'ports' in payload:
state.portsjson = _as_json(payload.get('ports'))
if 'interfaces' in payload: