Files
shopdb-flask/plugins/endpoints/AGENT-CONTRACT.md
cproudlock f4a03387b2
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 / lean-build (push) Has been cancelled
Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
Found in the Windows VM re-test.

- A CA-flagged server certificate (common with a plain openssl self-signed
  cert) produced an installer package with no pin at all, so agent 1.20
  refused the server. The package now always carries the SPKI pin of the
  certificate PCs receive; the CA flag only skips the trusted-root import.
  When an uploaded CA-flagged certificate differs from the server's own, both
  pins are included. The build response carries X-ShopDB-Build-Warning and the
  installer logs what older agents need.
- Pins written into appsettings.json by the installer were merged with the
  registry, so removing a pin from HKLM never took effect. When the ServerPins
  value exists in the registry it is now authoritative, even when empty;
  appsettings pins apply only when it is absent. Set-ShopDBServerPins.ps1
  -Clear deletes the value; new -None writes an empty value (no pins).
- A script or package download sent with a credential revoked moments earlier
  got 401 CREDENTIAL_INVALID and failed the command. The agent now retries that
  download once with the shared key and records the fallback in the result.
2026-09-15 15:22:25 -04:00

766 lines
41 KiB
Markdown

# 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.
```jsonc
{
"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:
```jsonc
{
"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:
```jsonc
{ "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.
```csharp
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):
```jsonc
{ "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:
```jsonc
{
"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`:
```jsonc
{
"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`. The route never refuses a call for its
authentication: a PC installed without the shared key still records a pending
request an admin can approve. `X-API-Key` only decides auto-issue: a valid
shared key while `plugin.endpoints.sharedkeyallowed` is true. No key, an invalid
key (logged by the server, never a 401 here) or the shared key switched off all
mean the call is unauthenticated, and it then only returns a credential for a
request an admin has approved for this hostname, serial and nonce. There is one
request row per hostname and serial (case-insensitive); repeated calls update it.
```jsonc
{
"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`, `reenroll blocked`.
`reenroll blocked` means an admin revoked the PC with `blockreenroll` (9.7); the
agent keeps retrying on its normal schedule and enrolls once an admin calls
`allow-reenroll`. 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; a `scriptbody` / `apppackage` download retries once (shared key, or a credential enrolled meanwhile) and the command result carries `detail.authfallback`; with no shared key the command fails saying so |
| 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. Acknowledging an alert that does not
target the caller returns 404 `NOT_FOUND`, as for an unknown id.
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)
is authoritative whenever the value EXISTS in either view, even empty: its
pins (possibly none) are the whole set and `serverpins` / legacy
`servercertthumbprint` in appsettings.json are ignored. Only when the value
is absent in both views do the appsettings.json pins apply. The agent logs
which source is in effect (`registry` or `appsettings.json`) at startup and
on every change.
- `Set-ShopDBServerPins.ps1 -Pins <pins>` writes the set; `-None` writes an
empty value (registry authoritative, no pins, normal chain validation);
`-Clear` deletes the value (appsettings.json pins apply again). To retire an
old pin after a rotation, write only the new pin.
- 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.
- The agent re-reads the pin set within about 60 seconds of a change; no
service restart is needed.
- The same `GET /api/endpoints/settings/server-cert` carries `sha1thumbprint`
(hex SHA-1 of the certificate), and
`GET /api/endpoints/settings/server-cert/download` returns the public
certificate as a `.cer`. `client/Install-ShopDBServerCertificate.ps1` checks
the file against that thumbprint and adds it to `LocalMachine\Root` on PCs
whose pre-1.20 agent validates the chain when it downloads the upgrade.
- 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`).
`counts.enrolled` and `counts.notenrolled` count computers by `state`
(`notenrolled` includes `revoked`); `counts.pendingcomputers` counts computers
in state `pending`; `counts.pending` is the number of pending enroll requests,
including requests that match no computer (unknown hostname) and requests from
PCs blocked from re-enrolling, so it equals the pending entries in `requests`.
- `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). Optional
body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars,
used only with `blockreenroll`) also keeps the PC from enrolling again: its
enroll requests stay pending with `reenroll blocked`, and the Migration tab
shows it blocked with the same blocker. Works on a PC whose credential is
already revoked. Without the block a 1.20 agent re-enrolls within about a
second of a plain revoke.
- `POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` lifts the
block; the PC enrolls on its next attempt. 404 when the PC has no credential
record; a PC that was not blocked returns success unchanged.
- `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).
Returns `{"waveid", "queued": [...], "skipped": [{"assetid", "reason"}], "retried": [...]}`.
`retried` lists queued PCs whose only blocker was
`server certificate not trusted by this PC` (see below).
- `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
`reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
`credentialstate` filter. `GET /api/endpoints/<assetid>` carries a
`credential` object in the same vocabulary; below `endpoints.fleet` only its
state is filled in.
- `GET /api/endpoints/settings/server-cert/live` (`endpoints.fleet`) reads the
certificate presented at `site_base_url` and returns `presentedspkisha256`,
`presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`.
- Migration blockers include `server certificate not trusted by this PC`: the
PC's last upgrade download failed on the certificate chain (the server only
knows from the failure the PC reported). It stays shown until a later upgrade
succeeds, so it is retryable: trust the certificate
(`Install-ShopDBServerCertificate.ps1`) and queue the PC again by its id in
`{"assetids": [...]}`. An explicit id is queued when this is the PC's only
blocker and is reported in `retried`; `{"next": n}` never picks such a PC, and
a PC with any other blocker as well is still skipped.