Files
shopdb-flask/plugins/endpoints/AGENT-CONTRACT.md
cproudlock 1164cfb287 Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and
  progress, each PC's state with the reasons it is blocked, "Upgrade
  selected" and "Upgrade next 25", and live wave progress. Credentials tab:
  enrolled PCs with revoke, pending requests with approve and reject, and the
  shared key and auto-enroll switches. Turning the shared key off asks for
  confirmation and shows how many PCs are not enrolled.
- The server certificate page shows the SPKI pin to put in the GE-Enforce
  pin entry.
- The agent upgrade script and the asset and printer reporters send the PC's
  credential when it has one (over https, only to the issuing server), fall
  back to the shared key, check server pins, and log refusals clearly.
- New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1.
- AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers
  phases, gates, rollback, pin and wave entries, and every place the shared
  key lives and how to clear it at cutover.
- Removed the 09-14 certificate design notes and dated session reports, and
  two VM notes that carried a live collector key and advised plain http.
- Corrected the GE-Enforce collector note, which claimed a plain registry
  token is protected by ACL.
2026-09-15 16:07:06 -04:00

37 KiB

Endpoint agent contract (C# build reference)

Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the future C# agent targets, derived from EndpointsPlugin.get_collector_schema() and the generic collector dispatcher in shopdb/core/api/collector.py. When the schema changes, update this file in the same change.

1. Transport

  • Method/URL: POST {baseurl}/api/collector/endpoints
  • Auth header: X-API-Key: <collector key> (Bearer token also accepted). The key is a UI-managed, endpoints-only service token minted from Settings -> Endpoint agent key (section 7). A bootstrap .env collector key still works until the first UI key is generated, then it is switched off (section 7). No querystring fallback. Agents 1.20 and later enroll and then send X-Endpoint-Credential instead of the shared key (section 9).
  • Body: application/json, a single flat object (NOT an array). One POST per report per endpoint.
  • Identity field: hostname (required, case-insensitive).

2. Request payload

Only hostname is required. Every other field is optional; the server upserts only the keys that are present, so a lightweight report can omit inventory arrays. adopt is first-contact only.

{
  "hostname": "SHOP-PC-014",     // required, resolved to computers.hostname
  "adopt": true,                 // first run ONLY; delegates PC creation to computers plugin
  "loggedinuser": "j.smith",
  "serialnumber": "5CD1234ABC",  // adoption subset (forwarded to computers)
  "pctype": "CNC Controller",    // adoption subset
  "machinenumber": "12345",      // adoption subset; "" or "9999" == none
  "agentversion": "1.0.0",
  "trayversion": "1.0.0",            // file version of the interactive tray exe, omitted if no tray
  "lastboottime": "2025-01-14T06:12:00Z",   // ISO-8601 UTC
  "uptimeseconds": 86400,
  "idleseconds": 120,
  "cpupercent": 12.5,
  "memorypercent": 47.0,
  "services": [ /* opaque array, stored as JSON */ ],
  "ports": [ /* opaque array */ ],
  "interfaces": [
    {
      "alias": "Intel(R) Ethernet Connection",  // adapter description
      "ipv4": "10.20.30.40",                     // "" if none
      "mac": "AA:BB:CC:DD:EE:FF",
      "gateway": "10.20.30.1",                   // "" for the static machine-IPS NIC
      "subnet": "255.255.255.0",
      "dhcp": false
    }
  ],
  "scheduledtasks": [ /* opaque array */ ],
  "disks": [
	{
	  "drive": "C:",
	  "label": "OS",
	  "filesystem": "NTFS",
	  "sizebytes": 511000000000,
	  "freebytes": 128000000000,
	  "usedbytes": 383000000000,
	  "freepercent": 25.0            // precomputed; a threshold is a direct compare
	}
  ],
  "bios": { /* opaque object: vendor, version, release date */ },
  "memory": { /* opaque object: totalbytes, freebytes */ },
  "processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
  "osinfo": { /* opaque object: build, version, install date */ },
  "domain": "shopfloor.example.com",  // domain or workgroup membership
  "pendingreboot": false,             // omit when unknown; absent != false
  "rebootreasons": [],                // [] = checked, nothing pending; see notes
  "bootinfo": {
    "hiberbootenabled": false,        // fast startup CONFIGURED
    "lastboottype": "Cold"            // what the last boot DID: Cold|Fast startup|Resume|Unknown
  },
  "defender": {                       // persisted, deliberately not surfaced
    "serviceenabled": true,
    "realtimeenabled": true,
    "signatureagedays": 0,            // real 0 = updated today; null = did not report
    "antispywareagedays": 0,
    "quickscanagedays": 0
  },
  "diskhealth": [                     // per PHYSICAL device; see notes
    {
      "deviceid": "0",
      "model": "Samsung SSD 990 PRO 1TB",
      "mediatype": "SSD",
      "bustype": "NVMe",
      "healthstatus": "Healthy",
      "predictfailure": false,        // precomputed; null = drive did not answer
      "source": "physicaldisk"        // or "diskdrive"
    }
  ],
  "usercontext": {                    // omitted entirely when nobody is signed in
	"netuse": [
	  {
		"localname": "Z:",
		"remotename": "\\\\fileserver\\programs",
		"status": "OK",
		"username": "SHOPFLOOR\\operator",
		"persistent": true
	  }
	],
	"userlisteners": [
	  { "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
	],
	"idleseconds": 45,
	"username": "operator",
	"elevated": false,             // token elevation of the collecting process;
								   // a spool stamped true is REJECTED (see 5.3)
	"collectedat": "2025-01-14T06:12:00Z"
  },
  "events": [
	{
	  "eventid": 6008,                        // required per item; non-catalog IDs are dropped, not errored
	  "eventrecordid": 184412,                // Windows record id; dedupe key, see notes
	  "eventchannel": "System",
	  "eventsource": "EventLog",
	  "eventtime": "2025-01-14T06:11:58Z",
	  "eventmessage": "The previous system shutdown was unexpected.",
	  "fields": { "any": "extra" }            // opaque object, stored as JSON
	}
  ]
}

Notes:

  • adopt=true is sent ONLY by the installer's first run. Steady-state reports omit it (or send false). After adoption, an unknown hostname is a hard validation error again (DESIGN-DECISIONS-agent.md 1a).
  • The adoption subset (serialnumber, pctype, machinenumber, plus loggedinuser) is forwarded to the computers collector so the newly created PC is not a bare shell. Send them on first contact.
  • services/ports/interfaces/scheduledtasks are stored verbatim as JSON; the server does not validate their inner shape yet. Keep them stable so the UI can render them later. interfaces reports EVERY hardware NIC (not just the primary) so the gatewayless static NIC that links a PC to its machine's IPS is preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is {alias, ipv4, mac, gateway, subnet, dhcp}.
  • machinenumber is discovered on-box by the agent from the eDNC registry (HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo) or the UDC udc_settings_*.json (GeneralSettings.MachineNumber), in that order (DESIGN-DECISIONS-agent.md 9.4/9.5). "" or "9999" means none.
  • eventrecordid is the Windows event record id: monotonic and unique within a channel on a machine. The agent uses it as a per-channel harvest watermark and the server enforces a unique index on (assetid, eventchannel, eventrecordid), so one real event is stored exactly once. It is nullable: an older agent omits it, and MySQL permits repeated NULLs in a unique index, so those reports keep the previous append behavior rather than failing ingest. Send it whenever the source record exposes one.
  • disks/bios/memory/processor/osinfo/usercontext are stored verbatim as JSON snapshots on the endpoint state, newest-wins. domain and pendingreboot are scalar columns because the fleet view filters and alerts on them.
  • OMITTING a key is not the same as sending an empty value. Every one of these fields is applied only when the key is PRESENT, so a report that omits it preserves the last-known value. Send null/[] only when you mean "this is genuinely empty now"; omit when you simply did not collect it this cycle.
  • pendingreboot follows that rule strictly: omit it when the check failed. A literal false asserts the endpoint is confirmed NOT holding a reboot.
  • rebootreasons is NOT redundant with pendingreboot. That key is a single bool OR-ed from three registry signals and cannot say which fired, and the difference is operational: pendingfilerename means files are staged for replacement on next boot, so the box is mid-servicing, while a lone componentbasedservicing or windowsupdate key can sit set for weeks on a machine nobody has rebooted. Same boolean, very different urgency. An EMPTY array means checked and nothing pending; OMIT the key when the check failed.
  • bootinfo carries two distinct facts that must not be collapsed: hiberbootenabled is whether fast startup is CONFIGURED (registry, always readable) and lastboottype is what the last boot ACTUALLY did (Kernel-Boot event 27). The registry cannot tell you what the last boot did, and the event may have aged out of the log, so neither substitutes for the other. This exists because uptime LIES under fast startup: a box reports days of uptime after what the operator experienced as a shutdown.
  • defender is stored but deliberately NOT surfaced. Defender produces known false positives in this environment, so wiring it to a card now would train operators to ignore that card. Signature/scan ages are in DAYS; a real 0 means updated today, so send null when Defender did not report rather than defaulting to zero.
  • interfaces entries gained linkspeedbps and fullduplex, paired on by MAC from MSFT_NetAdapter. Both are null when link state was unreadable or the adapter is disconnected - an unknown duplex must not read as half-duplex.
  • diskhealth is per PHYSICAL device and is NOT the same list as disks, which is per volume. One physical drive can back several volumes or none the operator sees, so the two do not share a key and are not merged.
  • diskhealth carries BOTH agent sources and the server does not reconcile them. source: "physicaldisk" is MSFT_PhysicalDisk, the only source that reports NVMe at all - the classic ATA SMART class (MSStorageDriver_FailurePredictStatus) returns zero instances on an NVMe machine, so an agent collecting only that would report an empty health list on modern hardware and look like "no problems". source: "diskdrive" is Win32_DiskDrive.Status, covering ATA/SATA and hosts where the Storage namespace is unavailable. When both answer for one drive and disagree, both rows are shown; that disagreement is the signal.
  • predictfailure is precomputed by the agent so an alert is a boolean test rather than a string match against a Windows enum the server would have to keep in sync. null means the drive did not answer: unknown, NOT healthy. healthstatus keeps the human-readable source value alongside it.
  • usercontext is gathered by a probe run inside the signed-in operator's session by the ShopDB user-context probe scheduled task (and, opportunistically, by the tray), because mapped drives, user-owned listeners and idle time do not exist in the agent's own LocalSystem session 0. It is omitted when no one is signed in, which is a normal unattended state, not a fault. The security-context split that makes this work is normative; see section 5.
  • ports carries LISTENING sockets only. The full connection table is available on demand via the network_connections command instead, because established connections churn constantly and would change every report for no diagnostic gain.

3. Response

Success (HTTP 200), ADR-006 envelope:

{
  "status": "ok",
  "action": "created",           // "created" first time, "updated" thereafter
  "assetid": 4821,
  "identityvalue": "SHOP-PC-014",
  "warnings": [
	"adopted new computer for hostname: 'SHOP-PC-014'"   // present only on adoption
  ]
}

Validation failure (HTTP 400) - e.g. unknown hostname with adopt absent:

{ "status": "error", "error": { "code": "VALIDATION_ERROR",
  "message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } }

Agent handling: treat non-200 as retryable except a VALIDATION_ERROR that names an unresolved hostname on a NON-first-contact report - that means the PC was removed server-side; surface it, do not spin.

4. C# DTOs

Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming rule; use attributes so C# properties can stay PascalCase without a camelCase serializer changing the wire keys.

using System.Text.Json.Serialization;

public sealed class EndpointReport
{
	[JsonPropertyName("hostname")]      public string Hostname { get; set; } = "";
	[JsonPropertyName("adopt")]         public bool? Adopt { get; set; }
	[JsonPropertyName("loggedinuser")]  public string? LoggedInUser { get; set; }
	[JsonPropertyName("serialnumber")]  public string? SerialNumber { get; set; }
	[JsonPropertyName("pctype")]        public string? PcType { get; set; }
	[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
	[JsonPropertyName("agentversion")]  public string? AgentVersion { get; set; }
	[JsonPropertyName("trayversion")]   public string? TrayVersion { get; set; }
	[JsonPropertyName("lastboottime")]  public DateTimeOffset? LastBootTime { get; set; }
	[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
	[JsonPropertyName("idleseconds")]   public long? IdleSeconds { get; set; }
	[JsonPropertyName("cpupercent")]    public double? CpuPercent { get; set; }
	[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
	[JsonPropertyName("services")]      public object[]? Services { get; set; }
	[JsonPropertyName("ports")]         public object[]? Ports { get; set; }
	[JsonPropertyName("interfaces")]    public object[]? Interfaces { get; set; }
	[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
	[JsonPropertyName("disks")]         public object[]? Disks { get; set; }
	[JsonPropertyName("bios")]          public object? Bios { get; set; }
	[JsonPropertyName("memory")]        public object? Memory { get; set; }
	[JsonPropertyName("processor")]     public object? Processor { get; set; }
	[JsonPropertyName("osinfo")]        public object? OsInfo { get; set; }
	[JsonPropertyName("domain")]        public string? Domain { get; set; }
	[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
	[JsonPropertyName("usercontext")]   public object? UserContext { get; set; }
	[JsonPropertyName("applications")]  public object[]? Applications { get; set; }
	[JsonPropertyName("events")]        public EndpointEventRecord[]? Events { get; set; }
}

public sealed class EndpointEventRecord
{
	[JsonPropertyName("eventid")]       public int EventId { get; set; }
	[JsonPropertyName("eventrecordid")]  public long? EventRecordId { get; set; }
	[JsonPropertyName("eventchannel")]  public string? EventChannel { get; set; }
	[JsonPropertyName("eventsource")]  public string? EventSource { get; set; }
	[JsonPropertyName("eventtime")]    public DateTimeOffset? EventTime { get; set; }
	[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
	[JsonPropertyName("fields")]       public Dictionary<string, object>? Fields { get; set; }
}

public sealed class CollectorResponse
{
	[JsonPropertyName("status")]        public string Status { get; set; } = "";
	[JsonPropertyName("action")]        public string? Action { get; set; }
	[JsonPropertyName("assetid")]       public int? AssetId { get; set; }
	[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
	[JsonPropertyName("warnings")]      public string[]? Warnings { get; set; }
}

Serializer settings: DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.

5. Security contexts, spool handoff and log destinations (NORMATIVE)

Three processes run as three different principals. Confusing them is what makes usercontext silently empty, so the boundaries are fixed by contract.

Process Runs as May write to
Service (shopdbagent.exe) LocalSystem, session 0 %ProgramData%\ShopDB\EndpointAgent\logs\<date>-agent.log
Tray + probe (shopdb-endpoint-tray.exe, shopdbagent.exe --collect-usercontext) ShopFloor operator, interactive session, UNELEVATED (task principal S-1-5-32-545 at RunLevel Limited) C:\Users\ShopFloor\ShopDB\ and C:\Users\ShopFloor\ShopDB\Logs\<date>-trayagent.log
Installer (install.ps1, generated by _install_script() in plugins/endpoints/api/siteagent.py) Elevated admin, one-shot %ProgramData%\ShopDB\Logs\<datetime>-shopdbinstall.log and the append-only ledger %ProgramData%\ShopDB\Logs\shopdbagent-installs.log

Rules:

  1. The operator cannot write to C:\Program Files or to the SYSTEM-owned %ProgramData%\ShopDB\EndpointAgent\logs. Anything running in the user's session MUST log under C:\Users\ShopFloor\ShopDB\Logs. The file log sink degrades to a silent no-op when it cannot create its directory, so violating this does not raise an error - it discards the log line and the failure becomes invisible.
  2. User context is collected in the operator's own logon session, because mapped drives, user-owned listeners and idle time are per-session and do not exist in session 0. The service MUST NOT try to descend into the session to collect them. The authoritative collector is the ShopDB user-context probe scheduled task, registered by the installer against the BUILTIN\Users group (S-1-5-32-545) at RunLevel Limited, triggered at logon and every 30 minutes. The tray also probes on its own cadence and writes the same spool; the two are belt-and-braces and the last writer wins.
  3. The probe MUST NOT run elevated, and a spool stamped elevated is REJECTED. Drive letters are a property of the logon token, not the machine: an elevated process gets a different token to the operator's interactive one and enumerates a different, usually empty, drive table. This was measured on a live endpoint, where the same two shares reported Unavailable from an elevated shell and OK from the operator's, in both Win32_NetworkConnection and Get-SmbMapping. --collect-usercontext therefore refuses to run when elevated (exit code 2) rather than producing a confident wrong answer, and the installer MUST NOT start the tray or probe as a child process - it is elevated, and explorer.exe de-elevates without changing ACCOUNT, so an install run by an admin produced a probe running as that admin. It MUST instead reach the operator's session through a scheduled task principal. RunLevel Limited on the task is load-bearing for the same reason; a task promoted to Highest reproduces the bug while still looking registered.
  4. The probe WRITES the spool; the service only READS it. The spool PERSISTS and is overwritten by each probe run. The service MUST NOT delete it: the collector's last-known-good cache is in-memory only, so consuming the file meant a service restart blanked mapped drives until the next probe cycle. Staleness is bounded by the in-file collectedat stamp (max age 90 minutes), not by deletion, so a probe that has stopped running still stops re-reporting stale drives - it just does so on a timer instead of on a read.
  5. The spool location is fixed at C:\Users\ShopFloor\ShopDB\usercontext.json, and writer and reader MUST derive it from the same helper. Every shop-floor endpoint uses the same local ShopFloor account. Resolving the path through WTSQueryUserToken or through %USERPROFILE% is forbidden: the former is privileged and endpoint protection blocks it, the latter is wrong under a brokered or re-parented tray launch, and in both cases the writer and reader disagree about the location with no error on either side.
  6. The probe MUST verify the spool exists after writing it. A zero exit code from powershell.exe proves the interpreter started, not that a file was produced; success is reported only when the file is present on disk.
  7. A spool not stamped ShopFloor is REJECTED. collecting username and the service discards any spool whose stamp is missing or different. Drive mappings from the wrong session are worse than no drive mappings, so this fails loudly rather than degrading quietly.
  8. Losing user context is never fatal. If the ShopFloor profile is absent or unwritable, usercontext is omitted and telemetry, commands, inventory and the command channel continue unaffected.
  9. --collect-usercontext MUST NOT touch privileged resources. It runs as the operator and only writes into that operator's own profile, so it is handled before the service's bootstrap logger, fault monitor and config load. It MUST NOT open the SYSTEM-owned log directory, register an EventLog source, or read CollectorKey from HKLM - those are precisely the calls an EPM broker intercepts, and the registry read would put the collector secret in a process that has no need of it. It therefore also works on a host whose CollectorKey has not been provisioned yet.
  10. The installer provisions C:\Users\ShopFloor\ShopDB\ and its Logs subdirectory with ShopFloor: Modify before the probe first runs.
  11. Every install appends one line to the install ledger, including reinstalls and repeat installs of the same version, recording timestamp, host, installing user, service version, tray version, install directory and the user-context task state (ok:<principal>/<runlevel>, UNVERIFIED:..., FAILED:... or skipped). The task is the single point of failure for session-scoped collection, so its registration outcome MUST be recorded where it survives the install transcript.
  12. Task registration MUST be verified by reading the task back, not inferred from Register-ScheduledTask returning without error. Policy or a management agent can rewrite or drop a task after registration, and a task that exists but runs with the wrong principal or run level looks healthy in the scheduler UI while producing wrong data.
  13. All log filenames use <stamp>-<component>.log so service, tray and installer logs sort chronologically and collect with one date-leading glob.

The three log destinations are deliberately separate: an install is one-shot and elevated, the service log is a rolling runtime log, and the tray log is the only one written from the operator's session. Merging them makes an install indistinguishable from runtime behaviour when reading a failure back.

6. "Build site agent" settings flow (build cadence)

Goal: an operator in ShopDB Settings produces an instance-locked .exe that knows its server URL and its collector key, needing no manual config on the client.

  1. Settings -> "Build site agent": operator picks/creates a collector-scoped managed token (the same token accepted by X-API-Key on /api/collector/endpoints). This binds the build to one ShopDB instance.
  2. Server stamps a small config (base URL + token + reporting cadence) into the agent build artifact. Instance-locked = the token only works against that server, and the server can revoke it to kill a leaked build.
  3. First run on a client:
    • Collect the ShopDB PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in user, serial number, pc type, machine number, boot time, plus the curated event tail.
    • POST once with adopt=true. The endpoints plugin delegates PC creation to the computers collector, then writes the first EndpointState in the same commit. The endpoint appears in the Fleet UI immediately - no second call, no separate enrollment endpoint.
  4. Subsequent runs POST on the configured cadence with adopt omitted; strict hostname resolution applies from then on.

Open item (from DESIGN-DECISIONS-agent.md 1a): whether adopt stays a per-report flag or is promoted to a site setting (strict vs self-adopting fleet). Default proposed: agent sends adopt=true on first run only.

7. UI-managed agent key (kill switch)

The endpoint agent authenticates with a single UI-managed key scoped to ONLY collector.ingest.endpoints, so rotating or revoking it stops all endpoint-agent traffic without affecting the computers or geenforce collectors.

  • Settings -> Endpoint agent key (admin only) generates the key. The full secret is shown ONCE at generation and stored only as a hash afterwards; configure it as the agent X-API-Key.
  • Generating a key is a hard cutover: any previous endpoint key is revoked in the same request, and the bootstrap .env fallback for endpoints is switched off (plugin.endpoints.envkeyfallback -> false), so the managed key becomes the sole credential.
  • Regenerate to rotate (old key dies immediately). Revoke to stop all endpoint-agent reporting until a new key is generated - the kill switch for a leaked agent.
  • Backend: GET/POST /api/endpoints/agentkey, POST /api/endpoints/agentkey/regenerate, DELETE /api/endpoints/agentkey. These use the mint_service_token/list_service_tokens/revoke_service_tokens contract helpers (shopdb.api, contract 0.22.0).

The bootstrap .env key (COLLECTOR_API_KEY[_ENDPOINTS]) remains only as a first-run path before a UI key exists; it cannot be the runtime kill switch because container env is injected at boot and cannot be rotated live.

8. Command channel (WebUI -> agent)

The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary command string). Delivery is agent-initiated long-poll, so shop-floor PCs open no inbound port: the agent makes an outbound request the server holds open until a command is queued, runs it locally, and posts the result back. All three routes reuse the same endpoints collector key (section 7) via X-API-Key, so the command channel shares the kill switch and adds no second secret.

Allowlisted actions (server source of truth: plugins/endpoints/commands.py):

  • mtconnect_start - run the MTConnect UDC agent start script (C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat).
  • mtconnect_stop - stop the running MTConnect UDC agent process.
  • mtconnect_status - report whether the MTConnect UDC agent is running.
  • network_connections - return the full TCP connection table with owning process ids and names. Read-only diagnostic. The result is capped at 500 connections with a truncated flag in detail, because an oversized payload rejected by the server returns nothing at all and a partial answer is more useful than a failed one. Standard reports carry listening ports only.
  • run_script - run a reviewed PowerShell script from the script library. The body is NOT carried on the command channel; the claim carries a descriptor and the agent fetches the bytes separately (section 7.4).

7.1 Claim the next command (long-poll)

GET {baseurl}/api/endpoints/commands/next?hostname=<hostname>

  • Auth: X-API-Key (endpoints collector key).
  • The server holds the request open up to ~25s. If a command is queued it is atomically claimed (marked running) and returned; only one poll ever claims a given command.
  • Command queued (HTTP 200):
{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } }
  • Nothing queued within the hold window: HTTP 204 (no body). Re-poll.

7.2 Report a command result

POST {baseurl}/api/endpoints/commands/<endpointcommandid>/result

  • Auth: X-API-Key (endpoints collector key).
  • Body:
{
  "succeeded": true,               // required; false marks the command failed
  "message": "MTConnect started",  // short summary, optional (<= 2000 chars)
  "mtconnectrunning": true,        // optional; observed run state after the action
  "detail": { "any": "extra" }     // optional structured detail, stored as JSON
}
  • When mtconnectrunning is present the server caches it on the endpoint's EndpointState (mtconnectrunning + mtconnectcheckedat) so the WebUI toggle shows a real state between polls. Every mtconnect_* action SHOULD probe and report the resulting run state so the toggle never lies.

7.3 Enqueue (WebUI side, for reference)

POST /api/endpoints/<assetid>/commands with { "action": "mtconnect_start" }, gated by user JWT + endpoints.command. Only one command may be in flight per endpoint (a second enqueue returns HTTP 409 until the first finalizes).

7.4 run_script claim descriptor

A run_script claim carries a script object alongside the usual endpointcommandid and action:

{
  "scriptid": 12,
  "scriptname": "sfld_autologon",
  "installpath": "/api/endpoints/scriptbody/12",  // fetch with X-API-Key
  "sha256": "3f9a...",                            // 64 hex chars, lowercase
  "runcontext": "system",                         // "system" | "user"
  "workingdirectory": "C:\\Tools",                // "" = agent staging folder
  "errormode": "default"                          // "default" | "strict"
}

The agent MUST download installpath, verify the SHA-256 of the received bytes against sha256, and refuse to run on mismatch. The body never travels on the command channel so a queued command cannot be used to smuggle code past review.

workingdirectory is the folder the script runs from. Empty means the agent's staging directory, which is correct for a self-contained script and wrong for one invoking a separately-installed tool by relative path. A pinned folder that does not exist MUST fail the command rather than falling back, so the script never runs somewhere unintended.

errormode selects how the run is judged:

  • default - PowerShell's normal behaviour. Most errors are non-terminating: the script writes the error, continues, and still exits zero.
  • strict - the agent prepends $ErrorActionPreference = 'Stop' so the first error terminates the run and the exit code reflects reality.

default is the default precisely BECAUSE it is permissive. Every script authored before this field existed relies on that behaviour, and some rely on it deliberately - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item. Promoting strict globally would make those bodies abort partway and leave endpoints half-configured, so it is opt-in per script.

Two consequences the agent MUST honour:

  • The strict prologue is applied AFTER digest verification, so the hash still validates the bytes the server actually delivered.
  • Because the prologue adds a line, an error reported on line N of a strict run refers to line N-1 of the stored body. Surfaces showing the body should say so.

An agent that does not recognise an errormode value MUST treat it as default. The server always sends the field explicitly, even at the default, so an agent that received it and ignored it stays distinguishable from one that never saw it.

Script output

Script stdout/stderr is ALWAYS written to the local agent log on the endpoint. It is returned to the server in detail.output only when the command FAILED. Routine output stays on the machine that produced it: shipping every successful run back would put arbitrary script output, including whatever an author printed while debugging, into the server's command history for no diagnostic gain. Output is truncated to 8000 characters and flagged when truncated, because an oversized payload rejected by the server returns nothing at all.

Timed-out runs do not carry output: the script is still running and still holds the capture file open, so anything read at that moment would be misleading.

9. Per-PC credentials

Each endpoint authenticates with its OWN secret, readable only by SYSTEM on that PC. The server derives the calling asset from the secret. The shared fleet key (section 7) keeps working for PCs that have not enrolled until an admin switches it off, and is refused for any PC that has enrolled. The rollout is in docs/PER-PC-CREDENTIALS-ROLLOUT.md.

9.1 Headers

State Header Value
Not enrolled X-API-Key the shared endpoints key, as today
Enrolled X-Endpoint-Credential sdbe_ + 43 chars base64url (32 random bytes)

Never both on one request. The server stores only the SHA-256 of the full credential plus its first 12 characters (credentialprefix) for display.

9.2 Enroll

POST {baseurl}/api/endpoints/enroll, authenticated with X-API-Key while plugin.endpoints.sharedkeyallowed is true. With the shared key switched off the route takes no auth, but then only returns a credential for a request an admin has approved.

{
  "hostname": "SHOP-PC-014",       // 1-255
  "serialnumber": "5CD1234ABC",    // 1-128, Win32_BIOS.SerialNumber
  "deviceid": null,                // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
  "enrollnonce": "<43 chars>",     // 32-128 [A-Za-z0-9_-], generated once, persisted
  "agentversion": "1.20.0"
}
Status Body Agent action
201 {"status":"enrolled","credential":"sdbe_...","assetid":123} store, switch headers, no restart
202 {"status":"pending","requestid":45,"reason":"serial mismatch"} retry every 15 min with jitter
403 ENROLL_NONCE_MISMATCH error envelope an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve
other error envelope back off 1, 5, 15 min (cap)

Auto-issue happens only when plugin.endpoints.autoenroll is true, the shared key authenticated the call, the hostname matches exactly one computer, the serial on record matches (trimmed, case-insensitive) and the asset has no active credential. Otherwise the request is pending with one of the reasons unknown hostname, duplicate hostname, no serial on record, serial mismatch, already enrolled, awaiting approval, nonce changed. The credential is in the 201 body exactly once.

9.3 Error codes

All use the standard error envelope (data.error.code).

HTTP Code Meaning Agent action
401 CREDENTIAL_INVALID unknown or revoked credential delete credential.bin, fall back to the shared key, wake enrollment
401 SHARED_KEY_REFUSED shared key sent, but this PC is enrolled or the shared key is switched off wake enrollment; use the credential
403 ASSET_MISMATCH the credential's asset differs from the hostname/assetid named in the request log; do not retry the same request

Routes that authenticate this way: POST /api/endpoints/collect (same handler as /api/collector/endpoints), GET /api/endpoints/monitors/resolved, GET /api/endpoints/commands/next, POST /api/endpoints/commands/<id>/result, GET /api/endpoints/apppackage/..., GET /api/endpoints/scriptbody/<id>, POST /api/endpoints/operatorreports, GET /api/endpoints/alerts, POST /api/endpoints/alerts/<id>/acknowledge. With a credential, a missing hostname is allowed and the asset comes from the credential; an acknowledge with assetid 0 or absent means the caller. The core collector (/api/collector/computers, /api/collector/printers) accepts the same header for the SYSTEM reporter scripts.

9.4 Storage on the endpoint

  • %ProgramData%\ShopDB\EndpointAgent\credential\ is ACLed SYSTEM and Administrators only.
  • credential.bin = DPAPI LocalMachine of UTF-8 JSON {"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}. SYSTEM scripts read it with [System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine') and must not send the credential to a URL outside baseurl.
  • enroll.json = {"enrollnonce":"...","createdat":"..."}, plain, same folder.
  • The shared key in the registry and appsettings.json is never deleted by the agent; clearing it is a cutover step in the runbook.

9.5 Server pins

  • HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ, both registry views) merged with serverpins (string array) in appsettings.json. Legacy servercertthumbprint is still honoured.
  • A pin is the lowercase hex SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. The Server Certificate settings page shows it (spkisha256 on GET /api/endpoints/settings/server-cert); client/Get-ShopDBServerPin.ps1 computes it from a URL or a file; client/Set-ShopDBServerPins.ps1 writes it.
  • Any pin configured: accept the handshake if and only if the leaf key hash is in the set (chain and name errors ignored). No pin: require a clean Windows validation.

9.6 Tray pipe

The tray holds no secret and talks to the SYSTEM agent over \\.\pipe\ShopDB.EndpointAgent.v1 (SYSTEM and Administrators full control, INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request then response, then close; request max 64 KB, read timeout 10 s.

Request Response
{"op":"ping"} {"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123}
{"op":"sendstatus"} {"ok":true} or {"ok":false,"error":"..."}
{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"} {"ok":true} or {"ok":false,"error":"..."}; the path must be inside the caller's own profile and not a reparse point
{"op":"alerts"} {"ok":true,"alerts":[ ...same objects as GET /alerts... ]}
{"op":"ackalert","notificationid":42} {"ok":true} or {"ok":false,"error":"..."}

The caller is identified from the pipe (impersonation user name and SID), never from the request body.

9.7 Admin API (WebUI side, for reference)

Read needs endpoints.fleet; writes need the admin role and are audited.

  • GET /api/endpoints/enrollment (page, perpage <= 100, state, search)
  • POST /api/endpoints/enrollment/requests/<id>/approve {"assetid": n}
  • POST /api/endpoints/enrollment/requests/<id>/reject
  • POST /api/endpoints/enrollment/assets/<assetid>/revoke (un-enroll)
  • PUT /api/endpoints/enrollment/settings {"sharedkeyallowed": bool, "autoenroll": bool}
  • GET /api/endpoints/enrollment/migration (page, perpage <= 100, state, search)
  • POST /api/endpoints/enrollment/migration/wave {"assetids": [...]} or {"next": n} (n <= 100)