Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
905 lines
49 KiB
Markdown
905 lines
49 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 about every 60 s for the first 10 min, then every 5 min to 30 min, then every 15 min indefinitely, 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); a 429 or 5xx uses this backoff, not the pending schedule |
|
|
|
|
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 |
|
|
|
|
`ASSET_MISMATCH` also covers the case where the hostname the agent sends is on
|
|
NO computer record at all, which is a data problem on the server (an enrolled PC
|
|
whose computer record has no hostname) rather than a PC claiming to be another
|
|
one. The code and status are the same, so nothing changes for the agent, but the
|
|
message says which of the two it is and names the hostname. An admin fixes it by
|
|
setting the hostname on the enrolled computer; approving an enroll request does
|
|
that on its own (9.7).
|
|
|
|
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, "sethostname": false}`. The approval also makes sure the
|
|
computer CARRIES the request's hostname, because the credential is bound by
|
|
hostname afterwards (9.3): a computer with none is stamped with it
|
|
(`hostnameresult` `set`), a computer already carrying the same one is left
|
|
alone (`unchanged`), and a computer carrying a DIFFERENT hostname is refused
|
|
with **409** naming both plus `details.hostnameconflict`, until the same call
|
|
is repeated with `"sethostname": true` (`overwritten`). Both changes are
|
|
audited against `Computer`. Approving without this left a one-off PC 403ing
|
|
on every call after it enrolled.
|
|
- `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.
|
|
|
|
### 9.8 One-line bootstrap install
|
|
|
|
For a ONE-OFF PC. A managed bay keeps getting the agent from GE-Enforce; this
|
|
is the path for a machine nobody is going to author a manifest entry for.
|
|
|
|
An admin mints a short-lived token in Settings > Endpoints > Build site agent >
|
|
One-line install and hands a technician one command, which is run in an
|
|
ELEVATED PowerShell on a PC with no agent:
|
|
|
|
```
|
|
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
|
```
|
|
|
|
Download then run, never `irm ... | iex`: Microsoft Defender scores the
|
|
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
|
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
|
|
every bay runs Defender. The shape of the line is load-bearing too:
|
|
|
|
- a literal temp path and no `$` anywhere, because pasting into an
|
|
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
|
|
PARENT and hand the child a broken line - as written it means the same in
|
|
cmd.exe and in PowerShell;
|
|
- single quotes for every inner literal, so the outer `-c "..."` is never
|
|
closed early;
|
|
- `try`/`finally`, so the dropped file is removed even when the script exits
|
|
non-zero or the download itself fails. The wrapper's own exit code is the
|
|
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
|
|
|
|
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
|
|
would rather save the file and run it. Fetching it does not spend a use either
|
|
way.
|
|
|
|
The token is only an authorization to FETCH the installer. The package it
|
|
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
|
|
says, so the PC enrolls by the normal 9.2 path and waits in Pending until an
|
|
admin approves it.
|
|
|
|
Admin routes (admin role, audited):
|
|
|
|
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
|
|
(`hours` 1-168, `maxuses` 1-500). Returns
|
|
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
|
|
URL the command downloads, for the UI's Download .ps1 link.
|
|
The secret is in the response exactly once; only its sha256 and a 12-character
|
|
`tokenprefix` are stored.
|
|
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
|
|
`usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state`
|
|
of `active`, `expired`, `exhausted` or `revoked`. Never a secret.
|
|
- `POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke`
|
|
|
|
Token-authenticated routes. No login; the token is the whole authorization and
|
|
travels in the query string as `t`, because a PC with no agent has neither a
|
|
credential nor a user session to put in a header:
|
|
|
|
| Route | Spends a use | Returns |
|
|
|---|---|---|
|
|
| `GET /api/endpoints/siteagent/bootstrap.ps1?t=...` | no | `text/plain` PowerShell, generated per token |
|
|
| `GET /api/endpoints/siteagent/bootstrap/package?t=...` | yes | the keyless agent package ZIP |
|
|
|
|
Both answer **404** for a token that is unknown, malformed, expired, revoked or
|
|
exhausted, so the URL confirms nothing about which tokens exist. Both are
|
|
rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in
|
|
`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over
|
|
budget) and both write an audit row against `EndpointBootstrapToken` carrying
|
|
the caller's IP. Fetching the script deliberately does not spend a use: a
|
|
retried paste must not burn the download budget.
|
|
|
|
The generated script:
|
|
|
|
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
|
|
administrator;
|
|
- refuses when the agent service already exists (exit 2) and says a managed bay
|
|
should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set
|
|
to anything but `0` in the same session;
|
|
- downloads the package to a temp directory over TLS with normal chain
|
|
validation (nothing relaxes certificate checking), expands it, and verifies
|
|
`shopdbagent.exe` and `shopdb-endpoint-tray.exe` against the SHA-256 values
|
|
the server baked in at generation time. A mismatch, a missing binary, or a
|
|
binary the server never staged refuses the install (exit 3);
|
|
- runs the package's `install.ps1` with no key, then deletes the temp
|
|
directory;
|
|
- prints the installed version and service state, and exits 4 when the service
|
|
is not Running;
|
|
- prints "This PC is waiting for approval - an admin approves it in Settings >
|
|
Endpoints > Enrollment", then watches
|
|
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin` for up to two
|
|
minutes and prints "approved and enrolled" if the credential lands. This is a
|
|
LOCAL file watch, not a server poll: there is no public enrollment-status
|
|
route and adding one would hand an anonymous caller a way to probe the fleet.
|
|
|
|
Exit code 0 on install success even when the PC is still pending.
|
|
|
|
### 9.9 Fleet alert feed
|
|
|
|
`GET /api/endpoints/alerts?hostname=<host>` returns the alerts that should be
|
|
live on that PC right now (see the AUTHORITATIVE rule above). One object per
|
|
alert:
|
|
|
|
| Key | Type | Meaning |
|
|
|---|---|---|
|
|
| `notificationid` | int | the notification, and the id posted back to `/alerts/<id>/acknowledge` |
|
|
| `type` | string | notification type name |
|
|
| `typecolor` | string | CSS colour the tray paints the toast with |
|
|
| `title` | string | first line of the message, max 100 chars |
|
|
| `message` | string | full message |
|
|
| `timeout` | int or null | seconds the toast stays on screen |
|
|
|
|
Response keys match the column names, per the naming convention. Two extra keys
|
|
ride along for now:
|
|
|
|
| Legacy key | Same value as | Remove when |
|
|
|---|---|---|
|
|
| `notificationId` | `notificationid` | no tray older than 1.7.0 is left in the fleet |
|
|
| `typeColor` | `typecolor` | no tray older than 1.7.0 is left in the fleet |
|
|
|
|
A tray at 1.6.x fetches this feed itself and deserializes case-sensitively, so
|
|
serving only the lowercase keys makes every alert vanish on those machines with
|
|
no error raised anywhere. Both spellings therefore go down with identical
|
|
values until the fleet is upgraded, and the camelCase pair is deleted in the
|
|
same release that drops support for trays below 1.7.0. Any NEW field on this
|
|
object is lowercase only and never gets a camelCase twin.
|