Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
@@ -1,77 +0,0 @@
|
||||
# Adoption tool spike
|
||||
|
||||
Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as
|
||||
a single unit once endpoint agent development is complete.
|
||||
|
||||
## What it is
|
||||
|
||||
A one-shot, agentless enrollment tool. An admin generates a small download from
|
||||
the Endpoints key settings page; running the tool once on a PC creates a ShopDB
|
||||
PC (Computer) and Endpoint entry using only the endpoints collector key. It is
|
||||
NOT the endpoint agent: it installs nothing, runs no service, and sends no
|
||||
ongoing telemetry. It targets sites that want an inventory entry without running
|
||||
the full agent.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow).
|
||||
2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled
|
||||
from the browser origin) and pastes the collector key (prefilled when a key
|
||||
was just generated, since the secret is only shown once).
|
||||
3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with
|
||||
`adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt
|
||||
binary is staged on the server.
|
||||
4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args),
|
||||
then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`.
|
||||
The existing `_adopt_computer` path creates the Computer + EndpointState.
|
||||
|
||||
## Collection
|
||||
|
||||
The tool gathers the same identity, hardware, and SBOM subset the endpoint
|
||||
agent collects, using identical WMI queries and wire keys so the server stores
|
||||
it without translation:
|
||||
|
||||
- Identity: `hostname`, `loggedinuser`, `ipaddress`.
|
||||
- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber`
|
||||
(Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem).
|
||||
- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node),
|
||||
de-duped `{displayname, displayversion, publisher}`. The server logs this as
|
||||
an unpersisted key today (same as the agent) until the SBOM store lands.
|
||||
|
||||
The tool requires administrator rights (enforced by its app manifest
|
||||
`requireAdministrator`, with a runtime elevation guard) because the machine
|
||||
Uninstall hives and full WMI need elevation, or collection would be silently
|
||||
partial.
|
||||
|
||||
## Cherry-pick boundary
|
||||
|
||||
The spike is deliberately isolated. Cherry-pick exactly:
|
||||
|
||||
- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`)
|
||||
- `agent/ShopDbEndpointAgent.slnx` (one line adding the project)
|
||||
- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`)
|
||||
- `plugins/endpoints/api/__init__.py` (one-line import to register the route)
|
||||
- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only)
|
||||
- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file)
|
||||
|
||||
`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery
|
||||
spike changes there do not tangle with this feature.
|
||||
|
||||
## Prebuilt exe distribution
|
||||
|
||||
The api container has no .NET SDK, so the package embeds the exe only when a
|
||||
prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise
|
||||
the ZIP ships the config plus README (build the exe out of band, same model as
|
||||
the endpoint agent installer bundle). Follow-up: a CI job that publishes
|
||||
`shopdb-adopt.exe` and stages it into the image or instance path.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- Agentless health signal: an adopted PC never reports again, so
|
||||
`EndpointState.lastreportedat` reflects only the adoption moment. The fleet
|
||||
health/dashboard logic should distinguish "agentless, adopted" from
|
||||
"agent expected but silent" so these PCs do not read as permanently failed.
|
||||
Left out of this spike to keep the server create-path unchanged for a clean
|
||||
cherry-pick; the tool does not send an `agentless` marker yet because the
|
||||
server does not persist one (`KNOWN_PAYLOAD_KEYS`).
|
||||
- CI packaging of the prebuilt exe (see above).
|
||||
@@ -10,9 +10,10 @@ schema changes, update this file in the same change.
|
||||
- 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 6). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 6). No
|
||||
querystring fallback.
|
||||
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).
|
||||
@@ -51,9 +52,69 @@ arrays. `adopt` is first-contact only.
|
||||
}
|
||||
],
|
||||
"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",
|
||||
@@ -81,6 +142,71 @@ Notes:
|
||||
(`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
|
||||
|
||||
@@ -137,13 +263,23 @@ public sealed class EndpointReport
|
||||
[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("eventchannel")] public string? EventChannel { get; set; }
|
||||
[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; }
|
||||
@@ -163,7 +299,102 @@ public sealed class CollectorResponse
|
||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
||||
|
||||
## 5. "Build site agent" settings flow (build cadence)
|
||||
## 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
|
||||
@@ -190,7 +421,7 @@ 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.
|
||||
|
||||
## 6. UI-managed agent key (kill switch)
|
||||
## 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
|
||||
@@ -215,13 +446,13 @@ 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.
|
||||
|
||||
## 7. Command channel (WebUI -> agent)
|
||||
## 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 6) via `X-API-Key`, so
|
||||
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`):
|
||||
@@ -230,6 +461,14 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
(`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)
|
||||
|
||||
@@ -274,3 +513,444 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
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 { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\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 rest of the shape is load-bearing too:
|
||||
|
||||
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
|
||||
lands verbatim in the IIS and waitress access logs, and a live install token
|
||||
has no business sitting in a log file;
|
||||
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
|
||||
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
|
||||
OWNER full control of what they create, so any standard user on the bay can
|
||||
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
|
||||
paste the command, which then runs the planted content elevated;
|
||||
- the line re-asserts an admin-only DACL on the directory it creates
|
||||
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
|
||||
`S-1-5-18`, never group names, which a rename or a localized install would
|
||||
break), and CHECKS that icacls succeeded: it is a native command, so it
|
||||
reports failure only in an exit code and `$ErrorActionPreference` never
|
||||
applies to it. Exit 6 with a red message, rather than downloading into a
|
||||
directory that still inherits whatever the parent grants;
|
||||
- it refuses (exit 5) to run a staged file owned by anyone but an
|
||||
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
|
||||
file looks like;
|
||||
- a literal staging path and no `$` anywhere - including the exit-code read,
|
||||
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
|
||||
already-running PowerShell would otherwise expand every `$` 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 script is offered in the UI as a **Download .ps1** link, for a tech
|
||||
who would rather save the file and run it. That link is the one caller that
|
||||
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
|
||||
does not spend a use either way.
|
||||
|
||||
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
|
||||
before this change - one still running a command pasted when the query form was
|
||||
the published shape - keeps working. It is deprecated: every such fetch logs a
|
||||
warning naming the caller's IP, and that warning is how the rollout finds bays
|
||||
whose command needs re-issuing. Do not publish it, do not paste it into a new
|
||||
bay, and do not build it by hand. A site that has re-issued all of its live
|
||||
commands turns the query form off with
|
||||
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
|
||||
Download .ps1 link.
|
||||
|
||||
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
|
||||
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
|
||||
site agent > One-line install ("Accept the token in the URL"). It is read
|
||||
through the core settings API, not a route of this plugin's own.
|
||||
|
||||
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"}`. `command` carries the token in
|
||||
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
|
||||
the `?t=<secret>&via=download` form a browser link can actually use. The
|
||||
`via=download` marker is what keeps an admin's own click out of the
|
||||
un-migrated-bay warning count - it is a hint, not authorization, and nothing
|
||||
is granted by it; what matters is that a command generated before this change
|
||||
cannot carry it. 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,
|
||||
because a PC with no agent has neither a credential nor a user session. It
|
||||
travels in the `X-Endpoint-Bootstrap-Token` header:
|
||||
|
||||
| Route | Spends a use | Returns |
|
||||
|---|---|---|
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
|
||||
|
||||
Both also accept `?t=<secret>` while
|
||||
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
|
||||
provisioned before the header form and for the UI Download .ps1 link. A query
|
||||
token without `via=download` logs a deprecation warning against the caller's
|
||||
IP; the Download link's own fetches log at info instead and are audited as
|
||||
`(query-string token, admin download link)`, so the warning count stays a list
|
||||
of bays to re-issue. With the setting false, a query token answers **400**
|
||||
naming the header.
|
||||
|
||||
Both answer **404** for a token that is unknown, malformed, expired, revoked or
|
||||
exhausted, and for a request that presents no token at all - so the URL
|
||||
confirms nothing about which tokens exist, and a bare probe cannot learn
|
||||
whether this site still accepts the query form. 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 (the wrapper line has already refused with exit 6 if the
|
||||
staging directory could not be locked down, or exit 5 if the staged file was
|
||||
owned by someone else);
|
||||
- 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.
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Delivery testing notes (update_app + run_script)
|
||||
|
||||
Temporary field notes for exercising the endpoint delivery path end to end and
|
||||
for handing the UI work to Cameron. ASCII only, no em-dashes, no emojis, per
|
||||
repo style. Remove the dev test bench (view, route, nav entry) once the
|
||||
production delivery UI lands.
|
||||
|
||||
## What exists today (the contract Cameron builds against)
|
||||
|
||||
Server allowlist (`plugins/endpoints/commands.py`):
|
||||
|
||||
- `run_script` - targetkind `script`. `target` is a script NAME the agent
|
||||
already holds in its local `scriptpaths` allowlist. No body or path crosses
|
||||
the wire.
|
||||
- `update_app` - targetkind `app`. `target` is an `appversionid` that resolves
|
||||
to a ShopDB-hosted installer package. The server pins the package by SHA-256
|
||||
and passes this version's `silentargs`.
|
||||
|
||||
Both actions require `endpoints.deploy` ON TOP OF `endpoints.command`. Queueing
|
||||
one is the actual authority (audited with the requesting user). The agent claims
|
||||
it with its collector credential.
|
||||
|
||||
Enqueue call (same route as every other endpoint command):
|
||||
|
||||
```
|
||||
POST /api/endpoints/<assetid>/commands
|
||||
{ "action": "run_script", "target": "remove-network-drives" }
|
||||
{ "action": "update_app", "target": "<appversionid>" }
|
||||
```
|
||||
|
||||
Rules the UI must respect:
|
||||
|
||||
- At most one pending/running command per endpoint. A second enqueue returns
|
||||
409 (treat as "already in flight", not a failure).
|
||||
- `update_app` is validated at enqueue: an appversionid with no installer
|
||||
package or unknown id is rejected 400 before it is queued.
|
||||
- A version with no `sha256` has no uploaded package yet. Do not offer it for
|
||||
`update_app`; the agent's integrity check will fail closed.
|
||||
|
||||
Read the result back:
|
||||
|
||||
```
|
||||
GET /api/endpoints/<assetid>/commands
|
||||
```
|
||||
|
||||
Each row carries `status` (pending/running/succeeded/failed), `resultmessage`
|
||||
(short summary) and `resultjson` (structured detail: for run_script the exit
|
||||
code and script path; for update_app the installer path, exit code and digest
|
||||
check).
|
||||
|
||||
Agent-facing package download (collector-authenticated, not user JWT):
|
||||
|
||||
```
|
||||
GET /api/endpoints/apppackage/<appversionid> (X-API-Key collector token)
|
||||
```
|
||||
|
||||
## The dev test bench
|
||||
|
||||
Route: `/endpoints/delivery-test` (sidebar "Delivery test bench", gated on
|
||||
`endpoints.deploy`). It is deliberately minimal: pick an endpoint, push a script
|
||||
by name or push an app version, then watch the command log pane. There is no
|
||||
script-library component yet, so `run_script` takes a free-text name; the agent
|
||||
runs it only if that name is in its `scriptpaths`.
|
||||
|
||||
## Preparing the test endpoint
|
||||
|
||||
1. Copy the dev build to the test box:
|
||||
- `agent\dev-bin\agent\shopdbagent.exe`
|
||||
- `agent\dev-bin\agent\appsettings.json`
|
||||
Stage them the same way the site installer does (service exe + appsettings).
|
||||
2. Provision the collector credential and base URL. Per repo policy these are
|
||||
NOT in appsettings.json; set the registry values under
|
||||
`HKLM\SOFTWARE\GE\ShopDB` (`CollectorKey`, `BaseUrl`). The shipped
|
||||
appsettings has `baseurl` empty and `apikey` = REPLACE_WITH_COLLECTOR_KEY so
|
||||
the agent fails closed until provisioned.
|
||||
3. Stage the sample script where appsettings points:
|
||||
`C:\ProgramData\ShopDB\EndpointAgent\scripts\remove-network-drives.ps1`
|
||||
(source: `agent\dev-bin\scripts\remove-network-drives.ps1`). The
|
||||
`scriptpaths` entry in appsettings.json maps the name `remove-network-drives`
|
||||
to that path.
|
||||
4. For an `update_app` test, create the application in the Applications module,
|
||||
upload its installer package (this stamps the version's `sha256`), and note
|
||||
the version's `appversionid`.
|
||||
|
||||
## Collecting logs
|
||||
|
||||
Endpoint side (the agent):
|
||||
|
||||
- Rolling diagnostics log: `%ProgramData%\ShopDB\EndpointAgent\logs`
|
||||
(override with `logpath` in appsettings). This is where run_script and
|
||||
update_app record what they resolved, the digest check, and the installer or
|
||||
script exit code.
|
||||
- Windows Event Log source `ShopDB Endpoint Agent` for service faults.
|
||||
|
||||
Web UI / ShopDB side:
|
||||
|
||||
- The command log pane on the bench (or `GET /api/endpoints/<assetid>/commands`)
|
||||
shows `status`, `resultmessage` and `resultjson` reported by the agent.
|
||||
- Ingest observability: unknown/unpersisted telemetry keys are logged by the
|
||||
endpoints plugin on the API container, so incoming agent data is visible in
|
||||
the app logs even before UI persistence catches up.
|
||||
- Audit log records each enqueue (`created EndpointCommand`, action + target +
|
||||
asset) with the requesting user.
|
||||
|
||||
## Notes to capture during testing (for Cameron)
|
||||
|
||||
- Round-trip latency from enqueue to a terminal status, at the agent cadence.
|
||||
- Whether `resultmessage` alone is enough for the operator, or the UI needs to
|
||||
surface `resultjson` (exit codes, digest mismatches).
|
||||
- How a 409 (already in flight) should read in the real UI.
|
||||
- What a failed digest check or non-zero installer exit looks like end to end,
|
||||
so the production UI can present it clearly.
|
||||
@@ -9,5 +9,10 @@ from . import siteagent # noqa: F401
|
||||
from . import agentupload # noqa: F401
|
||||
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
|
||||
from . import monitors # noqa: F401
|
||||
# Attaches per-PC credential enrollment, admin and migration routes.
|
||||
from . import enrollment # noqa: F401
|
||||
# Attaches the one-line bootstrap installer: token admin plus the two
|
||||
# token-authenticated download routes.
|
||||
from . import bootstrap # noqa: F401
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
|
||||
293
plugins/endpoints/api/agentauth.py
Normal file
293
plugins/endpoints/api/agentauth.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Agent authentication: per-PC credentials, with the shared key as fallback.
|
||||
|
||||
Every agent-facing endpoints route authenticates through authenticate_agent()
|
||||
and, when the route names a PC, binds the caller through bind_hostname().
|
||||
|
||||
X-Endpoint-Credential The PC's own secret. The server derives the asset from
|
||||
it; a hostname or assetid in the request that names a
|
||||
different asset is refused with ASSET_MISMATCH.
|
||||
X-API-Key The shared fleet key (or a collector-scoped managed
|
||||
token). Accepted only while
|
||||
plugin.endpoints.sharedkeyallowed is on, and never for a
|
||||
PC that holds an active credential. A PC whose
|
||||
credential was REVOKED falls back to the shared key,
|
||||
which is how a rollback to an older agent works and how
|
||||
the un-migrated fleet keeps reporting; a site that has
|
||||
finished migrating turns
|
||||
plugin.endpoints.revokeddeniessharedkey on and a revoked
|
||||
PC is then refused until it enrolls again. A PC that
|
||||
never enrolled has no credential row at all and is
|
||||
never touched by either rule.
|
||||
|
||||
Only the sha256 of a credential is stored. The full secret exists once, in the
|
||||
enroll response.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import g, request
|
||||
|
||||
from shopdb.api import (
|
||||
db, Setting, error_response, ErrorCodes, collector_authorized,
|
||||
)
|
||||
|
||||
CREDENTIAL_HEADER = 'X-Endpoint-Credential'
|
||||
CREDENTIAL_PREFIX = 'sdbe_'
|
||||
# sdbe_ + 43 chars base64url (32 random bytes, unpadded).
|
||||
_CREDENTIAL_PATTERN = re.compile(r'^sdbe_[A-Za-z0-9_-]{43}$')
|
||||
DISPLAY_PREFIX_LENGTH = 12
|
||||
|
||||
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
||||
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
|
||||
# Off by default, which is what this site does today: revoking a credential
|
||||
# drops the PC back to the shared key. Turning it on makes a revoke a real
|
||||
# eviction - the PC reports nothing until it enrolls again - so it is only safe
|
||||
# once the fleet is off the shared key, and it also closes the rollback path
|
||||
# that revoke + blockreenroll exists for.
|
||||
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
|
||||
|
||||
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
|
||||
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
|
||||
CODE_ASSET_MISMATCH = 'ASSET_MISMATCH'
|
||||
|
||||
AUTH_CREDENTIAL = 'credential'
|
||||
AUTH_SHARED_KEY = 'sharedkey'
|
||||
|
||||
# lastusedat is a display value, not an audit trail: one write per 5 minutes
|
||||
# per PC keeps the long-poll from turning every claim into an UPDATE.
|
||||
_TOUCH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def generate_credential():
|
||||
return CREDENTIAL_PREFIX + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_credential(secret):
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def shared_key_allowed():
|
||||
return Setting.get(SHARED_KEY_SETTING, default=True) is not False
|
||||
|
||||
|
||||
def auto_enroll_enabled():
|
||||
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
|
||||
|
||||
|
||||
def revoked_denies_shared_key():
|
||||
"""True when a revoked credential also bars the PC from the shared key."""
|
||||
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
|
||||
default=False) is True
|
||||
|
||||
|
||||
def active_credential_for(assetid):
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if assetid is None:
|
||||
return None
|
||||
return (EndpointCredential.query
|
||||
.filter(EndpointCredential.assetid == assetid,
|
||||
EndpointCredential.revokedat.is_(None))
|
||||
.first())
|
||||
|
||||
|
||||
def shared_key_denied_for(assetid):
|
||||
"""True when this asset may not report on the shared key.
|
||||
|
||||
An active credential always denies it: the PC has its own secret and a
|
||||
shared-key call for it is either an older agent or somebody else.
|
||||
|
||||
A revoked credential denies it only while revokeddeniessharedkey is on.
|
||||
Today it does not, on purpose: revoke is also how an admin rotates a
|
||||
credential or rolls a PC back to an agent that only knows the shared key
|
||||
(revoke + blockreenroll), and denying by default would silence that PC
|
||||
instead. A PC that never enrolled has no credential row and is never denied,
|
||||
which is what keeps the un-migrated fleet reporting.
|
||||
"""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if assetid is None:
|
||||
return False
|
||||
row = (EndpointCredential.query
|
||||
.filter(EndpointCredential.assetid == assetid)
|
||||
.first())
|
||||
if row is None:
|
||||
return False
|
||||
if row.revokedat is None:
|
||||
return True
|
||||
return revoked_denies_shared_key()
|
||||
|
||||
|
||||
def reenroll_blocked(assetid):
|
||||
"""True when an admin blocked this asset from enrolling again."""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if assetid is None:
|
||||
return False
|
||||
return (EndpointCredential.query
|
||||
.filter(EndpointCredential.assetid == assetid,
|
||||
EndpointCredential.reenrollblocked.is_(True))
|
||||
.first()) is not None
|
||||
|
||||
|
||||
def resolve_credential(secret, touch=True):
|
||||
"""Asset id for an active credential, else None."""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if not secret or not _CREDENTIAL_PATTERN.match(secret):
|
||||
return None
|
||||
digest = hash_credential(secret)
|
||||
row = EndpointCredential.query.filter_by(credentialhash=digest).first()
|
||||
if row is None or row.revokedat is not None:
|
||||
return None
|
||||
if not hmac.compare_digest(row.credentialhash, digest):
|
||||
return None
|
||||
if touch:
|
||||
now = _naive_utcnow()
|
||||
if row.lastusedat is None or now - row.lastusedat >= _TOUCH_INTERVAL:
|
||||
row.lastusedat = now
|
||||
db.session.commit()
|
||||
return row.assetid
|
||||
|
||||
|
||||
def resolve_hostname(hostname):
|
||||
"""Asset id of the one computer with this hostname (case-insensitive)."""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
hostname = (hostname or '').strip()
|
||||
if not hostname:
|
||||
return None
|
||||
computer = (Computer.query
|
||||
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
||||
.first())
|
||||
return computer.assetid if computer is not None else None
|
||||
|
||||
|
||||
def shared_key_refused_for(hostname):
|
||||
"""True when the shared key may not report for this hostname."""
|
||||
if not shared_key_allowed():
|
||||
return True
|
||||
return shared_key_denied_for(resolve_hostname(hostname))
|
||||
|
||||
|
||||
def credential_invalid_response():
|
||||
return error_response(
|
||||
CODE_CREDENTIAL_INVALID,
|
||||
'Endpoint credential is unknown or revoked; enroll again',
|
||||
http_code=401)
|
||||
|
||||
|
||||
def shared_key_refused_response():
|
||||
return error_response(
|
||||
CODE_SHARED_KEY_REFUSED,
|
||||
'The shared key is not accepted for this endpoint; use its credential',
|
||||
http_code=401)
|
||||
|
||||
|
||||
def shared_key_revoked_response():
|
||||
"""Same code and status; the operator needs to know which refusal it is.
|
||||
|
||||
The PC holds no credential and cannot use the shared key either, so an
|
||||
admin has to let it enroll again. Nothing changes for the agent, which
|
||||
keeps retrying either way.
|
||||
"""
|
||||
return error_response(
|
||||
CODE_SHARED_KEY_REFUSED,
|
||||
'This endpoint had its credential revoked, so the shared key is not '
|
||||
'accepted for it; let it enroll again',
|
||||
http_code=401)
|
||||
|
||||
|
||||
def asset_mismatch_response():
|
||||
return error_response(
|
||||
CODE_ASSET_MISMATCH,
|
||||
'The request names a different endpoint than the credential',
|
||||
http_code=403)
|
||||
|
||||
|
||||
def hostname_unrecorded_response(hostname):
|
||||
"""Refused, but say which of the two failures this actually is.
|
||||
|
||||
The credential is good and the hostname is simply on no computer record, so
|
||||
there is nothing to compare it with. That is a data problem on this server -
|
||||
an enrolled PC whose computer record carries no hostname - not a PC claiming
|
||||
to be another one. Same code and status, because nothing changes for the
|
||||
agent: it keeps retrying, and an admin has to set the hostname.
|
||||
"""
|
||||
return error_response(
|
||||
CODE_ASSET_MISMATCH,
|
||||
'No computer record carries the hostname {0}, so this credential '
|
||||
'cannot be matched to it. Set the hostname on the enrolled '
|
||||
'computer.'.format(hostname),
|
||||
http_code=403)
|
||||
|
||||
|
||||
def authenticate_agent():
|
||||
"""Authorize an agent request. Returns None when authorized, else a response.
|
||||
|
||||
Sets g.agentauth ('credential' or 'sharedkey') and g.agentassetid (the
|
||||
credential's asset, or None until bind_hostname resolves one).
|
||||
"""
|
||||
g.agentauth = None
|
||||
g.agentassetid = None
|
||||
|
||||
secret = request.headers.get(CREDENTIAL_HEADER)
|
||||
if secret:
|
||||
assetid = resolve_credential(secret.strip())
|
||||
if assetid is None:
|
||||
return credential_invalid_response()
|
||||
g.agentauth = AUTH_CREDENTIAL
|
||||
g.agentassetid = assetid
|
||||
return None
|
||||
|
||||
error = collector_authorized('endpoints')
|
||||
if error is not None:
|
||||
return error
|
||||
if not shared_key_allowed():
|
||||
return shared_key_refused_response()
|
||||
g.agentauth = AUTH_SHARED_KEY
|
||||
return None
|
||||
|
||||
|
||||
def is_credential_caller():
|
||||
return getattr(g, 'agentauth', None) == AUTH_CREDENTIAL
|
||||
|
||||
|
||||
def bind_hostname(hostname):
|
||||
"""The asset this request acts for. Returns (assetid, error_response).
|
||||
|
||||
Credential: the credential's asset; a hostname naming another asset is an
|
||||
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
|
||||
same code but its own message, and a missing hostname is fine.
|
||||
Shared key: the hostname's asset (None when absent or unknown, for the
|
||||
route to answer); an enrolled PC is refused, and so is one whose credential
|
||||
was revoked while revokeddeniessharedkey is on.
|
||||
"""
|
||||
hostname = (hostname or '').strip()
|
||||
if is_credential_caller():
|
||||
if hostname:
|
||||
named = resolve_hostname(hostname)
|
||||
if named is None:
|
||||
return None, hostname_unrecorded_response(hostname)
|
||||
if named != g.agentassetid:
|
||||
return None, asset_mismatch_response()
|
||||
return g.agentassetid, None
|
||||
|
||||
if not hostname:
|
||||
return None, None
|
||||
assetid = resolve_hostname(hostname)
|
||||
if shared_key_denied_for(assetid):
|
||||
if active_credential_for(assetid) is None:
|
||||
return None, shared_key_revoked_response()
|
||||
return None, shared_key_refused_response()
|
||||
g.agentassetid = assetid
|
||||
return assetid, None
|
||||
|
||||
127
plugins/endpoints/api/agentregistry.py
Normal file
127
plugins/endpoints/api/agentregistry.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Register staged endpoint agent builds as installable Applications.
|
||||
|
||||
The endpoint update_app push resolves an install source from the applications
|
||||
table alone - it has no knowledge of the agent version store. Without a row
|
||||
there, a build can be staged and current in agentstore and still be impossible
|
||||
to push, which is exactly the gap found in the fleet: no application row existed
|
||||
for the agent at all.
|
||||
|
||||
This module bridges the two by making the UPLOAD the single source of truth.
|
||||
Staging a build repackages the current agent (plus tray) and rewrites the
|
||||
application row's installpath and installersha256 in the same request, so the
|
||||
row cannot describe a build that is no longer current. Nothing here is
|
||||
hand-maintained; an operator never edits these rows.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from shopdb.api import Application, application_package_location, db
|
||||
|
||||
from . import agentstore
|
||||
|
||||
# Reserved appname for the managed agent bundle. appname is unique, which is
|
||||
# what makes the upsert idempotent: repeated uploads update one row rather than
|
||||
# accumulating a row per build. Changing this string strands the existing row,
|
||||
# so it is a constant rather than anything configurable.
|
||||
AGENT_APPNAME = 'ShopDB Endpoint Agent'
|
||||
|
||||
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
|
||||
|
||||
_AGENT_DESCRIPTION = (
|
||||
'Managed ShopDB endpoint agent bundle. Created and kept current '
|
||||
'automatically when an agent build is staged - do not edit by hand.'
|
||||
)
|
||||
|
||||
|
||||
def _package_path(appid):
|
||||
directory, _prefix = application_package_location()
|
||||
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
|
||||
|
||||
|
||||
def get_agent_application():
|
||||
"""The reserved agent Application row, or None when nothing is registered.
|
||||
|
||||
Read-only lookup for callers that need the appid to build a delivery URL
|
||||
without causing a row to spring into existence as a side effect.
|
||||
"""
|
||||
return Application.query.filter_by(appname=AGENT_APPNAME).first()
|
||||
|
||||
|
||||
def _get_or_create_app():
|
||||
"""Return the reserved agent Application row, creating it when absent.
|
||||
|
||||
Does not commit: the caller commits once, after the package is written, so
|
||||
a failed write cannot leave a row advertising an installer that is not
|
||||
there.
|
||||
"""
|
||||
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
|
||||
if app:
|
||||
return app, False
|
||||
|
||||
app = Application(
|
||||
appname=AGENT_APPNAME,
|
||||
appdescription=_AGENT_DESCRIPTION,
|
||||
# Installable so it can be pushed; hidden so a machine-managed row does
|
||||
# not surface in the software catalogue alongside real licensed apps.
|
||||
isinstallable=True,
|
||||
ishidden=True,
|
||||
)
|
||||
db.session.add(app)
|
||||
# Flush rather than commit: the row needs its appid to build the package
|
||||
# path, but the transaction stays open so the whole registration is atomic.
|
||||
db.session.flush()
|
||||
return app, True
|
||||
|
||||
|
||||
def register_current_agent_package():
|
||||
"""Repackage the current agent build and point the agent app row at it.
|
||||
|
||||
Returns a dict describing what was registered, or None when there is no
|
||||
current agent build to package. Safe to call on every upload: it is an
|
||||
upsert keyed on the reserved appname, and it rewrites the package file in
|
||||
place.
|
||||
"""
|
||||
payload, manifest = agentstore.build_current_package()
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
app, created = _get_or_create_app()
|
||||
|
||||
path = _package_path(app.appid)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Write to a temp name and replace, so an interrupted write cannot leave a
|
||||
# truncated archive at the path the row already advertises. os.replace is
|
||||
# atomic within a filesystem. The temp name is unique per call so two
|
||||
# concurrent pushes (a batch wave) cannot interleave writes into one file.
|
||||
temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex)
|
||||
try:
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(payload)
|
||||
os.replace(temppath, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(temppath)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
_directory, urlprefix = application_package_location()
|
||||
app.installpath = '{}{}/{}'.format(
|
||||
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
|
||||
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
|
||||
# The per-binary digests inside the manifest are for the install script to
|
||||
# check its unpacked contents, and are not interchangeable with this one.
|
||||
app.installersha256 = manifest['sha256']
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'appid': app.appid,
|
||||
'appname': app.appname,
|
||||
'created': created,
|
||||
'installpath': app.installpath,
|
||||
'sha256': manifest['sha256'],
|
||||
'size': manifest['size'],
|
||||
'agentversion': manifest['agentversion'],
|
||||
'contents': manifest['contents'],
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
@@ -470,3 +471,109 @@ def set_current(binkey, version):
|
||||
manifest['current'] = version
|
||||
_write_manifest(binkey, manifest)
|
||||
return True
|
||||
|
||||
|
||||
def version_sha256(binkey, version):
|
||||
"""The recorded digest for a staged version, or None when not recorded.
|
||||
|
||||
Read from the manifest rather than recomputed: store_version already
|
||||
hashed the exact bytes it wrote, so re-reading the file to hash it again
|
||||
would only introduce a way for the two to disagree.
|
||||
"""
|
||||
for entry in _read_manifest(binkey).get('versions', []):
|
||||
if entry.get('version') == version:
|
||||
return entry.get('sha256')
|
||||
return None
|
||||
|
||||
|
||||
# Name the upgrade payload carries inside the archive. Fixed rather than derived
|
||||
# from the staged filenames so the install script can address its contents
|
||||
# without being told what to expect.
|
||||
PACKAGE_MEMBER_NAMES = {
|
||||
'agent': 'shopdbagent.exe',
|
||||
'tray': 'shopdb-endpoint-tray.exe',
|
||||
}
|
||||
|
||||
# Fixed member timestamp so packaging the same build twice yields byte-identical
|
||||
# archives. The value is arbitrary (the DOS epoch zip supports); only its
|
||||
# constancy matters. Real build provenance lives in the manifest's versions and
|
||||
# digests, not in a zip entry's mtime.
|
||||
_PACKAGE_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
def build_current_package():
|
||||
"""Zip the current agent build (plus the current tray) into one archive.
|
||||
|
||||
Returns (bytes, manifestdict) or (None, None) when no agent build is
|
||||
current. The agent is required; the tray is included only when it too has
|
||||
a current staged version, because an endpoint can legitimately run the
|
||||
service with no tray and half a tray upgrade is worse than none.
|
||||
|
||||
The archive is built IN MEMORY and returned rather than written here: the
|
||||
caller decides where it lands, and nothing under the version store gets a
|
||||
derived artifact that retention would then have to reason about.
|
||||
|
||||
The returned manifest describes what went in - version and digest per
|
||||
member - so the install script can verify it unpacked the build the server
|
||||
meant to send, rather than trusting the archive's filename.
|
||||
"""
|
||||
agentversion = current_version('agent')
|
||||
if not agentversion:
|
||||
return None, None
|
||||
|
||||
members = []
|
||||
agentpath = _version_exe_path('agent', agentversion)
|
||||
if not os.path.isfile(agentpath):
|
||||
return None, None
|
||||
members.append(('agent', agentversion, agentpath))
|
||||
|
||||
trayversion = current_version('tray')
|
||||
if trayversion:
|
||||
traypath = _version_exe_path('tray', trayversion)
|
||||
if os.path.isfile(traypath):
|
||||
members.append(('tray', trayversion, traypath))
|
||||
|
||||
contents = {}
|
||||
buffer = io.BytesIO()
|
||||
# ZIP_DEFLATED, not ZIP_STORED: these are ~70 MB self-contained binaries
|
||||
# carrying the whole .NET runtime, and the archive crosses the wire to
|
||||
# every endpoint being upgraded.
|
||||
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for binkey, version, path in members:
|
||||
try:
|
||||
with open(path, 'rb') as handle:
|
||||
data = handle.read()
|
||||
except OSError:
|
||||
# A staged file that cannot be read means the package would be
|
||||
# silently incomplete. Refuse the whole thing: a partial upgrade
|
||||
# payload is how an endpoint ends up with a new service exe and
|
||||
# a stale tray.
|
||||
return None, None
|
||||
name = PACKAGE_MEMBER_NAMES[binkey]
|
||||
# Build the entry with a FIXED timestamp. zipfile otherwise stamps
|
||||
# each member with the current local time, which makes the archive
|
||||
# bytes - and therefore its digest - different on every call for
|
||||
# identical input. update_app pins a download to a digest, so a
|
||||
# drifting hash would invalidate a package an endpoint is already
|
||||
# fetching. Same reason the mode is set explicitly.
|
||||
info = zipfile.ZipInfo(name, date_time=_PACKAGE_TIMESTAMP)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o644 << 16
|
||||
archive.writestr(info, data)
|
||||
contents[binkey] = {
|
||||
'filename': name,
|
||||
'version': version,
|
||||
# Prefer the recorded digest so the package agrees with the
|
||||
# manifest; fall back to hashing when an older manifest entry
|
||||
# predates digest recording.
|
||||
'sha256': (version_sha256(binkey, version)
|
||||
or hashlib.sha256(data).hexdigest()),
|
||||
}
|
||||
|
||||
payload = buffer.getvalue()
|
||||
return payload, {
|
||||
'agentversion': agentversion,
|
||||
'contents': contents,
|
||||
'sha256': hashlib.sha256(payload).hexdigest(),
|
||||
'size': len(payload),
|
||||
}
|
||||
|
||||
144
plugins/endpoints/api/agentupgradescript.py
Normal file
144
plugins/endpoints/api/agentupgradescript.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Seed the agent self-upgrade script into the ShopDB script library.
|
||||
|
||||
run_script only delivers bodies that live in the script library, pinned by
|
||||
SHA-256. The upgrade template therefore has to exist as a real Script row with
|
||||
a stored body before any upgrade can be pushed.
|
||||
|
||||
The template is source-controlled at
|
||||
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 and seeded from there
|
||||
rather than typed into the library by hand, so the reviewed script and the one
|
||||
endpoints actually run are the same artifact. Editing the row's body in the UI
|
||||
is pointless - the next seed overwrites it.
|
||||
|
||||
It lives under the plugin, not under agent/, because the Docker image copies
|
||||
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
|
||||
from agent/ resolves fine on a developer checkout and then fails in the
|
||||
deployed container, which is the worst place to discover it.
|
||||
|
||||
One placeholder is rendered at seed time: the agent application's appid, which
|
||||
only exists once agentregistry has created the row. Rendering happens BEFORE
|
||||
the digest is computed, so the stored body and its sha256 always agree; a
|
||||
substitution done after hashing would fail the agent's verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from shopdb.api import Script, db, script_body_location
|
||||
|
||||
from . import agentregistry
|
||||
|
||||
# Reserved script name. Unique in the library, which makes seeding idempotent
|
||||
# and keeps a hand-made script from colliding with the managed one.
|
||||
UPGRADE_SCRIPT_NAME = 'ShopDB Endpoint Agent Self-Upgrade'
|
||||
|
||||
_UPGRADE_DESCRIPTION = (
|
||||
'Managed: downloads the current published agent bundle and replaces the '
|
||||
'installed agent in place, then restarts the service. Seeded from source '
|
||||
'control - edits here are overwritten.'
|
||||
)
|
||||
|
||||
# Substituted with the agent application's appid so the script can fetch its
|
||||
# payload from the existing collector-authenticated app-package route.
|
||||
_APPID_TOKEN = '{{AGENT_APPID}}'
|
||||
|
||||
_TEMPLATE_FILENAME = 'upgrade-shopdbagent.ps1'
|
||||
|
||||
|
||||
def template_path():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'templates', _TEMPLATE_FILENAME)
|
||||
|
||||
|
||||
def _script_dir():
|
||||
"""Where script bodies live. Resolved through the contract surface helper
|
||||
so this cannot drift from the directory the download route serves from."""
|
||||
directory, _prefix = script_body_location()
|
||||
return directory
|
||||
|
||||
|
||||
def render_template(appid):
|
||||
"""Return the upgrade script body with the appid substituted.
|
||||
|
||||
Raises if the placeholder is missing: that means the template was edited
|
||||
in a way that breaks the download, and seeding a script that would fetch
|
||||
from a literal '{{AGENT_APPID}}' path is worse than refusing.
|
||||
"""
|
||||
with open(template_path(), 'r', encoding='utf-8') as handle:
|
||||
body = handle.read()
|
||||
if _APPID_TOKEN not in body:
|
||||
raise ValueError(
|
||||
'upgrade template is missing the {} placeholder; refusing to seed '
|
||||
'a script that cannot resolve its package'.format(_APPID_TOKEN))
|
||||
return body.replace(_APPID_TOKEN, str(appid))
|
||||
|
||||
|
||||
def seed_upgrade_script():
|
||||
"""Create or refresh the managed upgrade script row and its body.
|
||||
|
||||
Returns a dict describing the seeded script, or None when the agent
|
||||
application row does not exist yet (nothing to point the download at).
|
||||
Idempotent: unchanged input rewrites nothing and leaves the digest alone.
|
||||
"""
|
||||
app = agentregistry.get_agent_application()
|
||||
if app is None:
|
||||
return None
|
||||
|
||||
body = render_template(app.appid).encode('utf-8')
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
|
||||
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
|
||||
created = script is None
|
||||
if created:
|
||||
script = Script(
|
||||
scriptname=UPGRADE_SCRIPT_NAME,
|
||||
scriptdescription=_UPGRADE_DESCRIPTION,
|
||||
# SYSTEM: the script stops a service, writes into Program Files,
|
||||
# and edits HKLM. None of that is available in a user context.
|
||||
runcontext='system',
|
||||
# Failures always report; success is opt-in. Capture it here
|
||||
# because an upgrade that "succeeded" but rolled back internally
|
||||
# is only diagnosable from the transcript.
|
||||
captureoutput=True,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.flush()
|
||||
|
||||
# Body filename follows the library convention (script-<id>.ps1) so the
|
||||
# collector download route serves it like any other script.
|
||||
filename = 'script-{}.ps1'.format(script.scriptid)
|
||||
path = os.path.join(_script_dir(), filename)
|
||||
|
||||
unchanged = (script.filename == filename and script.sha256 == digest
|
||||
and os.path.isfile(path))
|
||||
if not unchanged:
|
||||
os.makedirs(_script_dir(), exist_ok=True)
|
||||
# Temp-then-replace so an interrupted write cannot leave a truncated
|
||||
# body at the path the row's digest already claims to describe.
|
||||
temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex)
|
||||
try:
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(body)
|
||||
os.replace(temppath, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(temppath)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
script.filename = filename
|
||||
script.sha256 = digest
|
||||
|
||||
script.scriptdescription = _UPGRADE_DESCRIPTION
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scriptid': script.scriptid,
|
||||
'scriptname': script.scriptname,
|
||||
'created': created,
|
||||
'updated': not unchanged,
|
||||
'sha256': digest,
|
||||
'bytes': len(body),
|
||||
'appid': app.appid,
|
||||
}
|
||||
@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
|
||||
command family from application deploys (endpoints.deploy) so app-push rights
|
||||
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
|
||||
|
||||
Staging also keeps the pushable application row current (see agentregistry):
|
||||
whenever the current build changes, the bundle is repackaged and the row's
|
||||
installpath/installersha256 are rewritten. Registration is deliberately NOT
|
||||
allowed to fail the upload - the binary is already staged and audited by then,
|
||||
so a packaging problem is reported in the response payload rather than raised.
|
||||
|
||||
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
|
||||
"""
|
||||
|
||||
@@ -24,12 +30,47 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
from . import agentstore
|
||||
from . import agentstore, agentregistry
|
||||
|
||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
||||
# file before it is buffered fully into memory-staged storage.
|
||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||
# refuses an operator who points at a giant wrong file before it is buffered
|
||||
# fully into memory-staged storage.
|
||||
#
|
||||
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
||||
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
||||
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
||||
# version-detection failure, because the size check returns before the PE is
|
||||
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
||||
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
||||
# the boundary bytes alone.
|
||||
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def _sync_agent_registration():
|
||||
"""Repackage and re-register the current build. Returns a status dict.
|
||||
|
||||
Called from every route that can change which build is current, so the
|
||||
pushable application row can never describe a build that is no longer
|
||||
staged. That drift is the whole failure this is meant to prevent, and it
|
||||
would be silent - the row would keep pointing at bytes nobody selected.
|
||||
|
||||
Never raises. Staging has already succeeded on disk and been audited by the
|
||||
time this runs; a packaging or database problem must not turn a successful
|
||||
upload into an error response and leave the operator thinking the binary
|
||||
was rejected. The failure is reported in the response payload instead, so
|
||||
the UI can surface it without losing the upload.
|
||||
"""
|
||||
try:
|
||||
registration = agentregistry.register_current_agent_package()
|
||||
except Exception as exc: # noqa: BLE001 - reported, not swallowed
|
||||
db.session.rollback()
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
if registration is None:
|
||||
# No current agent build to package. Normal when only a tray has been
|
||||
# staged: the bundle is defined by the agent, so there is nothing to
|
||||
# register yet.
|
||||
return {'ok': False, 'error': 'no current agent build to package'}
|
||||
return {'ok': True, **registration}
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||
@@ -69,11 +110,6 @@ def upload_agent_binary(binkey):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'binkey must be one of: agent, tray',
|
||||
http_code=400)
|
||||
if not agentstore.is_valid_version(version):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if upload is None or not upload.filename:
|
||||
@@ -94,6 +130,22 @@ def upload_agent_binary(binkey):
|
||||
'uploaded file is not a Windows executable',
|
||||
http_code=400)
|
||||
|
||||
# Gate the version at the PE, not at the raw input. Product versions carry
|
||||
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
|
||||
# name, so the operator's typed string is only a hint. The dotted-numeric
|
||||
# FILE version stamped in the binary's PE resource is the authority; fall
|
||||
# back to it whenever the typed value is missing or not dotted-numeric, and
|
||||
# only reject when neither yields a usable version.
|
||||
if not agentstore.is_valid_version(version):
|
||||
detected = agentstore.read_pe_file_version(data)
|
||||
if agentstore.is_valid_version(detected):
|
||||
version = detected
|
||||
else:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
result = agentstore.store_version(binkey, version, data,
|
||||
make_current=makecurrent)
|
||||
|
||||
@@ -110,10 +162,16 @@ def upload_agent_binary(binkey):
|
||||
'madecurrent': makecurrent})
|
||||
db.session.commit()
|
||||
|
||||
# Keep the pushable application row in step with what is now current, in
|
||||
# the same request. Doing it here rather than at push time means the row
|
||||
# and the staged build cannot disagree in between.
|
||||
registration = _sync_agent_registration()
|
||||
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': result['current'],
|
||||
'versions': result['versions'],
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
|
||||
@@ -196,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'the legacy binary could not be adopted',
|
||||
http_code=400)
|
||||
registration = _sync_agent_registration()
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': result['current'],
|
||||
'versions': result['versions'],
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
|
||||
@@ -224,8 +284,13 @@ def set_current_agent_binary(binkey):
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'that version is not staged',
|
||||
http_code=404)
|
||||
# A rollback changes the bytes the fleet should receive; repackage so the
|
||||
# application row follows the pointer instead of pinning the build the
|
||||
# operator just rolled away from.
|
||||
registration = _sync_agent_registration()
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': agentstore.current_version(binkey),
|
||||
'versions': agentstore.list_versions(binkey),
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
786
plugins/endpoints/api/bootstrap.py
Normal file
786
plugins/endpoints/api/bootstrap.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""One-line bootstrap installer for the endpoint agent.
|
||||
|
||||
An admin mints a short-lived token in Settings > Endpoints and hands a tech one
|
||||
command. On a PC with no agent, in an ELEVATED PowerShell: the command
|
||||
downloads bootstrap.ps1 to a file, runs it, and deletes it again.
|
||||
|
||||
That installs the agent and tray with NO shared key, so the PC asks to enroll
|
||||
and waits for an admin to approve it in Settings > Endpoints > Enrollment. This
|
||||
is the path for a ONE-OFF PC; a managed bay keeps getting the agent from
|
||||
GE-Enforce, and the generated script says so when it finds an agent already
|
||||
installed.
|
||||
|
||||
Routes
|
||||
POST /siteagent/bootstrap/tokens admin, audited; secret once
|
||||
GET /siteagent/bootstrap/tokens admin; never a secret
|
||||
POST /siteagent/bootstrap/tokens/<id>/revoke admin, audited
|
||||
GET /siteagent/bootstrap.ps1 token only; not a use
|
||||
GET /siteagent/bootstrap/package token only; one use
|
||||
|
||||
The two token-authenticated routes take the token in the
|
||||
X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so
|
||||
a command already pasted on a bay keeps working. They answer 404 for anything
|
||||
not currently usable - unknown, malformed, expired, revoked, exhausted, or no
|
||||
token presented at all - so the URL confirms nothing about which tokens exist,
|
||||
nor about whether the query form is still accepted here. The one 400 is a real
|
||||
query token offered at a site that has turned the query form off.
|
||||
|
||||
The admin UI's Download .ps1 link has to use the query form (a browser link
|
||||
cannot send a header), so its URL carries a marker that tells it apart from a
|
||||
bay still running a command pasted before this change. Only the bay logs the
|
||||
deprecation warning, which is what the rollout counts un-migrated bays with.
|
||||
|
||||
Both routes are per-IP rate limited with the same fixed window shape the login
|
||||
and payload-download limiters use, and both write an audit row carrying the
|
||||
caller's IP.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Response, current_app, request, send_file
|
||||
|
||||
from shopdb.api import (
|
||||
db, AuditLog, Setting, client_ip, error_response, success_response,
|
||||
ErrorCodes, require_role,
|
||||
)
|
||||
|
||||
from ..models import EndpointBootstrapToken
|
||||
from . import siteagent
|
||||
from .ratelimit import ratelimited, rate_limit_response
|
||||
from .routes import endpoints_bp
|
||||
|
||||
# sdbb_ + 43 chars base64url (32 random bytes, unpadded). Same shape as the
|
||||
# per-PC credential, different prefix so a leaked value is identifiable.
|
||||
TOKEN_PREFIX = 'sdbb_'
|
||||
_TOKEN_PATTERN = re.compile(r'^sdbb_[A-Za-z0-9_-]{43}$')
|
||||
DISPLAY_PREFIX_LENGTH = 12
|
||||
|
||||
DEFAULT_HOURS = 24
|
||||
DEFAULT_MAX_USES = 25
|
||||
MINIMUM_HOURS = 1
|
||||
MAXIMUM_HOURS = 168
|
||||
MINIMUM_USES = 1
|
||||
MAXIMUM_USES = 500
|
||||
|
||||
LIST_LIMIT = 100
|
||||
|
||||
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package'
|
||||
|
||||
# Where the one-line command stages the script before running it. A literal,
|
||||
# not $env:TEMP: the line has to survive being pasted into an already-running
|
||||
# PowerShell, which expands every $ inside the outer double quotes before the
|
||||
# child process sees them.
|
||||
#
|
||||
# NOT C:\Windows\Temp. That directory grants Users create-file and gives
|
||||
# CREATOR OWNER full control of what they create, so any standard user on the
|
||||
# bay can pre-plant this exact path and wait for a tech to paste the command,
|
||||
# which then runs the planted content elevated. Program Files is admin-only on
|
||||
# a stock install, the command re-asserts an admin-only DACL on the directory
|
||||
# it creates, and it refuses to run a staged file owned by anyone else.
|
||||
COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
|
||||
COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1'
|
||||
|
||||
# Well-known SIDs, not names: the bays are domain-joined and a renamed or
|
||||
# localized Administrators group would quietly break both the grant and the
|
||||
# ownership test.
|
||||
ADMINISTRATORS_SID = 'S-1-5-32-544'
|
||||
SYSTEM_SID = 'S-1-5-18'
|
||||
|
||||
# The token the two public routes accept. A header, not ?t=: a query string
|
||||
# lands verbatim in the reverse proxy and gunicorn access logs, and a live
|
||||
# install token has no business sitting in a log file.
|
||||
BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
|
||||
QUERY_TOKEN_ARG = 't'
|
||||
|
||||
# Where the token on this request came from. Kept apart because the rollout
|
||||
# counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an
|
||||
# admin clicking Download .ps1 in the browser must not land in that count, and
|
||||
# a request carrying no token at all must not answer differently from one
|
||||
# carrying a token nobody issued.
|
||||
SOURCE_HEADER = 'header'
|
||||
SOURCE_QUERY = 'query'
|
||||
SOURCE_UI_LINK = 'uilink'
|
||||
SOURCE_NONE = 'none'
|
||||
|
||||
# The marker on the admin Download .ps1 link. A hint, NOT authentication: a
|
||||
# caller can add it by hand, and nothing is granted by it. What matters is the
|
||||
# other direction - a one-line command generated before this change cannot
|
||||
# carry it, so a request without it really is a bay to go and re-issue.
|
||||
UI_LINK_ARG = 'via'
|
||||
UI_LINK_VALUE = 'download'
|
||||
|
||||
# DEPRECATED, kept on purpose: every command already pasted on a bay, and the
|
||||
# UI's "Download .ps1" link (a browser link cannot send a header), carry the
|
||||
# token in the query string. Accepting it stays the default so a mid-rollout
|
||||
# bay keeps working; a site that has re-issued its live commands turns it off.
|
||||
# Turning it off also disables the download link.
|
||||
#
|
||||
# Declared in the endpoints plugin's get_settings_defaults() (so the row seeds
|
||||
# boolean, not string) and flipped from Settings > Endpoints > Build site agent
|
||||
# > One-line install, "Accept the token in the URL". A switch with no seed and
|
||||
# no control would be inert on an air-gapped box.
|
||||
QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
|
||||
# Fixed-window per-IP limiter, same shape and cache extension as the login
|
||||
# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech
|
||||
# fetching a script and a package is two hits; a scanner walking token guesses
|
||||
# is thousands.
|
||||
BOOTSTRAP_RATELIMIT_MAX = 30
|
||||
BOOTSTRAP_RATELIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
# The endpoint-side force switch. Set it in the same command to reinstall over
|
||||
# an agent that is already there.
|
||||
FORCE_VARIABLE = 'SHOPDB_BOOTSTRAP_FORCE'
|
||||
|
||||
_HOST_PATTERN = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _caller_ip():
|
||||
"""The caller address for the audit rows and the deprecation warning.
|
||||
|
||||
Read the value before trusting it to identify a bay. client_ip() returns
|
||||
remote_addr and nothing else, so it is only as good as what the layer in
|
||||
front put there.
|
||||
|
||||
Under the bundled Docker stack that layer is nginx, which sets
|
||||
X-Forwarded-For, and TRUSTED_PROXY_COUNT=1 on the api service makes ProxyFix
|
||||
take the hop nginx appended - so this reads the real caller. Keep that count
|
||||
equal to the number of proxies actually in front: add one without bumping it
|
||||
and this reads the inner proxy instead.
|
||||
|
||||
Behind a reverse proxy that does NOT forward, or one the app was not told to
|
||||
trust, every row and every warning reads the proxy's own address and the
|
||||
un-migrated-bay list identifies no bay. That is a deployment mistake this
|
||||
function cannot detect, which is why the token prefix below is carried as
|
||||
well.
|
||||
|
||||
It is a real address only where the site enabled that rewrite rule (the
|
||||
installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a
|
||||
front proxy that sets the header itself.
|
||||
"""
|
||||
return client_ip(request) or 'unknown'
|
||||
|
||||
|
||||
def _ratelimited():
|
||||
"""True when this caller is over budget for the current window.
|
||||
|
||||
The implementation moved to .ratelimit so the enrollment route shares it
|
||||
rather than carrying a second copy. Budget name, config keys and defaults
|
||||
are unchanged, so the behaviour here is identical.
|
||||
"""
|
||||
return ratelimited('endpointbootstrapratelimit',
|
||||
'ENDPOINTS_BOOTSTRAP_RATELIMIT',
|
||||
BOOTSTRAP_RATELIMIT_MAX,
|
||||
BOOTSTRAP_RATELIMIT_WINDOW_SECONDS)
|
||||
|
||||
|
||||
def _rate_limit_response():
|
||||
return rate_limit_response('Too many bootstrap requests. Try again later.')
|
||||
|
||||
|
||||
def _not_found():
|
||||
"""The single answer for every unusable token, so nothing is confirmed."""
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404)
|
||||
|
||||
|
||||
def _safe_host(value):
|
||||
host = (value or '').strip()
|
||||
return host if host and _HOST_PATTERN.match(host) else None
|
||||
|
||||
|
||||
def bootstrap_base_url():
|
||||
"""Base URL baked into the generated command and script.
|
||||
|
||||
Prefers the configured site_base_url, which already carries the scheme and
|
||||
the mount path. Falls back to the forwarded host (only when a proxy is
|
||||
actually declared) or the request Host plus the script root, forced to
|
||||
https - the same resolution the printer installer batch generator uses,
|
||||
and for the same reason: behind a reverse proxy the app sees http on a
|
||||
loopback port and its url_root drops the mount.
|
||||
"""
|
||||
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
|
||||
if base:
|
||||
return base
|
||||
|
||||
host = None
|
||||
if current_app.config.get('TRUSTED_PROXY_COUNT'):
|
||||
host = _safe_host(request.headers.get('X-Forwarded-Host'))
|
||||
if not host:
|
||||
host = _safe_host(request.host)
|
||||
|
||||
root = (request.script_root or '').rstrip('/')
|
||||
if not host:
|
||||
return root
|
||||
return 'https://{0}{1}'.format(host, root)
|
||||
|
||||
|
||||
def _base(baseurl=None):
|
||||
resolved = baseurl if baseurl is not None else bootstrap_base_url()
|
||||
return resolved.rstrip('/')
|
||||
|
||||
|
||||
def bootstrap_script_url(secret, baseurl=None):
|
||||
"""The UI's "Download .ps1" link: a plain file a tech can save and run.
|
||||
|
||||
Still the query-string form, because an <a download> cannot set a header.
|
||||
The one-line command no longer uses this shape - see bootstrap_command.
|
||||
|
||||
It carries UI_LINK_ARG so the server can tell this click apart from a bay
|
||||
running a pre-change command. Without that the admin's own Download link
|
||||
fires the deprecation warning the rollout uses to FIND un-migrated bays,
|
||||
and the count becomes noise the moment an admin mints a token.
|
||||
"""
|
||||
return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH,
|
||||
QUERY_TOKEN_ARG, secret,
|
||||
UI_LINK_ARG, UI_LINK_VALUE)
|
||||
|
||||
|
||||
def bootstrap_fetch_url(path, baseurl=None):
|
||||
"""A token-free URL for the machine-to-machine fetches. The token rides in
|
||||
BOOTSTRAP_TOKEN_HEADER so it never reaches an access log."""
|
||||
return '{0}{1}'.format(_base(baseurl), path)
|
||||
|
||||
|
||||
def bootstrap_command(secret, baseurl=None):
|
||||
"""The one line a tech pastes into an elevated PowerShell.
|
||||
|
||||
Download to a file and run it, 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. Every bay runs Defender.
|
||||
|
||||
Single quotes inside and no $ anywhere, so the one line means the same
|
||||
whether it is pasted into cmd.exe or into a PowerShell prompt. The download
|
||||
and the run are wrapped in try/finally so the staged file goes away even
|
||||
when the script exits non-zero or the download itself fails.
|
||||
|
||||
Staging is the part worth being careful about, so the line creates
|
||||
COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and
|
||||
refuses (exit 5) to run a staged script that some other principal owns -
|
||||
which is what a pre-planted file looks like. Well-known SIDs throughout,
|
||||
and the owner may also be the elevated caller, since a file created by an
|
||||
administrator is owned by that account rather than by the group.
|
||||
|
||||
The hardening is also CHECKED (exit 6). icacls is a native command, so it
|
||||
reports failure only in an exit code: no exception is raised, and
|
||||
$ErrorActionPreference never applies to it. Unchecked, an icacls that
|
||||
cannot write the DACL leaves the directory inheriting whatever the parent
|
||||
grants and the line downloads into it anyway - the silent version of the
|
||||
hole this staging move was made to close. The same check also covers the
|
||||
New-Item before it, because icacls on a directory that was never created
|
||||
exits non-zero too.
|
||||
|
||||
Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the
|
||||
whole line must stay free of `$`, or a paste into an already-running
|
||||
PowerShell expands it in the PARENT and the child is handed `-ne 0` with
|
||||
the parent's last exit code baked in.
|
||||
"""
|
||||
return (
|
||||
'powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' "
|
||||
'| Out-Null; '
|
||||
"icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
|
||||
"'*{system}:(OI)(CI)F' | Out-Null; "
|
||||
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
|
||||
"{{ Write-Host 'Refusing to run: could not restrict "
|
||||
"{stagedir} to administrators (icacls failed). Fix the directory "
|
||||
"or delete it, then run the command again.' -ForegroundColor Red; "
|
||||
'exit 6 }}; '
|
||||
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
|
||||
"Invoke-WebRequest -UseBasicParsing '{url}' "
|
||||
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
|
||||
"if ((Get-Acl '{path}').GetOwner("
|
||||
'[Security.Principal.SecurityIdentifier]).Value -notin '
|
||||
"@('{admin}','{system}',"
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
|
||||
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
|
||||
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
|
||||
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(stagedir=COMMAND_STAGE_DIR,
|
||||
path=COMMAND_SCRIPT_PATH,
|
||||
url=bootstrap_fetch_url(SCRIPT_PATH, baseurl),
|
||||
header=BOOTSTRAP_TOKEN_HEADER,
|
||||
secret=secret,
|
||||
admin=ADMINISTRATORS_SID,
|
||||
system=SYSTEM_SID)
|
||||
|
||||
|
||||
def _hash_token(secret):
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _presented_token():
|
||||
"""(secret, source) for the token this request carries.
|
||||
|
||||
Header first, query string second. The query form is deprecated but still
|
||||
the only one a browser download link can use.
|
||||
|
||||
SOURCE_NONE when nothing was presented, which is NOT the same as an empty
|
||||
query token: reporting a bare GET as 'query' made it answer 400 naming the
|
||||
header, telling an anonymous caller whether this site had turned the query
|
||||
form off, and contradicting the 404-for-everything-unusable rule at the top
|
||||
of this module.
|
||||
"""
|
||||
header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip()
|
||||
if header:
|
||||
return header, SOURCE_HEADER
|
||||
query = (request.args.get(QUERY_TOKEN_ARG) or '').strip()
|
||||
if not query:
|
||||
return '', SOURCE_NONE
|
||||
if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE:
|
||||
return query, SOURCE_UI_LINK
|
||||
return query, SOURCE_QUERY
|
||||
|
||||
|
||||
def _is_query_source(source):
|
||||
"""Both query-string forms, the bay's and the admin download link's. The
|
||||
setting is about a token in an access log, and the admin link puts one
|
||||
there just as surely as a pasted command does."""
|
||||
return source in (SOURCE_QUERY, SOURCE_UI_LINK)
|
||||
|
||||
|
||||
def _query_token_allowed():
|
||||
"""Defaults to True: today's pasted commands and the UI download link both
|
||||
use the query form, and turning that off mid-rollout strands a bay."""
|
||||
return bool(Setting.get(QUERY_TOKEN_SETTING, True))
|
||||
|
||||
|
||||
def _query_token_refused():
|
||||
"""Answered before any lookup, so it confirms nothing about any token."""
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Send the bootstrap token in the {0} header.'.format(
|
||||
BOOTSTRAP_TOKEN_HEADER),
|
||||
http_code=400)
|
||||
|
||||
|
||||
def _note_token_source(source):
|
||||
"""Audit suffix for a token that really resolved, and, for a query-string
|
||||
token, one log line naming the caller and WHICH query path it was.
|
||||
|
||||
Only the bay warns. That warning is the rollout's un-migrated-bay counter
|
||||
(see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link
|
||||
logs at info instead - otherwise every mint-and-test by an admin adds a
|
||||
bay that does not exist.
|
||||
|
||||
Never the token itself, in either line: keeping it out of the logs is the
|
||||
whole point of the header form.
|
||||
"""
|
||||
if source == SOURCE_UI_LINK:
|
||||
current_app.logger.info(
|
||||
'bootstrap script fetched by %s from the admin Download .ps1 '
|
||||
'link; query-string token, but the admin browser path, NOT an '
|
||||
'un-migrated bay', _caller_ip())
|
||||
return ' (query-string token, admin download link)'
|
||||
if source == SOURCE_QUERY:
|
||||
current_app.logger.warning(
|
||||
'bootstrap token presented in the query string by %s with no '
|
||||
'admin download marker, so this is an un-migrated bay running a '
|
||||
'command pasted before the header form; that form is deprecated '
|
||||
'and slated for removal, re-issue the one-line install command',
|
||||
_caller_ip())
|
||||
return ' (query-string token, deprecated)'
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_token(secret):
|
||||
"""The usable token with this secret, else None.
|
||||
|
||||
Unknown, malformed, expired, revoked and exhausted all resolve to None so
|
||||
the caller can answer 404 for every one of them.
|
||||
"""
|
||||
if not _TOKEN_PATTERN.match(secret):
|
||||
return None
|
||||
row = EndpointBootstrapToken.query.filter_by(
|
||||
tokenhash=_hash_token(secret)).first()
|
||||
if row is None or not row.isusable(_naive_utcnow()):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _audit(action, row, details):
|
||||
AuditLog.log(action, 'EndpointBootstrapToken', entityid=row.tokenid,
|
||||
entityname=row.tokenprefix,
|
||||
details='{0} from {1}'.format(details, _caller_ip()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The generated script
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Why the wait loop watches a LOCAL file rather than a server route: there is no
|
||||
# public "am I approved yet" endpoint, and adding one would hand an anonymous
|
||||
# caller a way to probe enrollment state. The agent writes credential.bin the
|
||||
# moment it collects an approved credential, so the file appearing IS the
|
||||
# enrollment, observed without asking the server anything.
|
||||
_SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap
|
||||
#
|
||||
# Generated by the ShopDB server for one short-lived install token. Run from an
|
||||
# ELEVATED Windows PowerShell. It downloads this server's agent package, checks
|
||||
# the binaries against the hashes stamped in below, installs the agent and tray
|
||||
# with NO shared key, and leaves the PC waiting for an admin to approve its
|
||||
# enrollment in Settings > Endpoints > Enrollment.
|
||||
#
|
||||
# A managed bay should get the agent from GE-Enforce instead. This is the
|
||||
# one-off path.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BootstrapPackageUrl = {packageurl}
|
||||
$BootstrapPackageHeaders = @{{ {tokenheader} = {token} }}
|
||||
$BootstrapServiceName = "ShopDbEndpointAgent"
|
||||
$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent"
|
||||
$BootstrapAgentSha256 = {agentsha}
|
||||
$BootstrapTraySha256 = {traysha}
|
||||
$BootstrapForceVariable = "{forcevariable}"
|
||||
$BootstrapWaitSeconds = {waitseconds}
|
||||
$BootstrapCredentialPath = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"
|
||||
|
||||
Write-Host "== ShopDB endpoint agent bootstrap ==" -ForegroundColor Cyan
|
||||
|
||||
# 1. Elevation. Every later step writes HKLM and Program Files.
|
||||
$bootstrapIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$bootstrapPrincipal = New-Object Security.Principal.WindowsPrincipal($bootstrapIdentity)
|
||||
if (-not $bootstrapPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
|
||||
Write-Host "This installer must run elevated." -ForegroundColor Red
|
||||
Write-Host "Open PowerShell as administrator (right-click Windows PowerShell, Run as administrator) and paste the command again."
|
||||
exit 1
|
||||
}}
|
||||
|
||||
# 2. An agent already here means this PC is probably managed.
|
||||
$bootstrapForce = $false
|
||||
$bootstrapForceValue = [Environment]::GetEnvironmentVariable($BootstrapForceVariable)
|
||||
if ($bootstrapForceValue -and $bootstrapForceValue -ne "0") {{ $bootstrapForce = $true }}
|
||||
|
||||
$bootstrapExisting = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue
|
||||
if ($bootstrapExisting -and -not $bootstrapForce) {{
|
||||
Write-Host ("The ShopDB endpoint agent is already installed here (service " + $BootstrapServiceName + ", " + $bootstrapExisting.Status + ").") -ForegroundColor Yellow
|
||||
Write-Host "A managed bay should get the agent from GE-Enforce, not from this one-line installer."
|
||||
Write-Host ("To reinstall anyway, run this first in the same window, then paste the command again:")
|
||||
Write-Host (" " + '$env:' + $BootstrapForceVariable + " = '1'")
|
||||
exit 2
|
||||
}}
|
||||
|
||||
$bootstrapAlreadyEnrolled = Test-Path $BootstrapCredentialPath
|
||||
|
||||
# 3. Download the package over TLS with normal chain validation. Nothing here
|
||||
# relaxes certificate checking: a server this PC does not trust must be fixed,
|
||||
# not bypassed.
|
||||
try {{
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}}
|
||||
catch {{ }}
|
||||
|
||||
$bootstrapStamp = [DateTime]::UtcNow.ToString("yyyyMMddHHmmssfff")
|
||||
$bootstrapTempDir = Join-Path $env:TEMP ("shopdb-bootstrap-" + $bootstrapStamp)
|
||||
$bootstrapZipPath = Join-Path $bootstrapTempDir "package.zip"
|
||||
$bootstrapExtractDir = Join-Path $bootstrapTempDir "package"
|
||||
|
||||
function Remove-BootstrapTemp {{
|
||||
if (Test-Path $bootstrapTempDir) {{
|
||||
try {{ Remove-Item -Path $bootstrapTempDir -Recurse -Force -ErrorAction Stop }}
|
||||
catch {{ Write-Warning ("could not remove " + $bootstrapTempDir + "; delete it manually.") }}
|
||||
}}
|
||||
}}
|
||||
|
||||
function Test-BootstrapHash {{
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Path,
|
||||
[AllowEmptyString()] [string] $Expected,
|
||||
[Parameter(Mandatory)] [string] $Label
|
||||
)
|
||||
$present = Test-Path $Path
|
||||
if ([string]::IsNullOrWhiteSpace($Expected)) {{
|
||||
# The server staged no such binary, so the package must not carry one.
|
||||
if ($present) {{
|
||||
Write-Host ($Label + " is in the package but this server staged none.") -ForegroundColor Red
|
||||
return $false
|
||||
}}
|
||||
Write-Host (" " + $Label + ": not shipped by this server")
|
||||
return $true
|
||||
}}
|
||||
if (-not $present) {{
|
||||
Write-Host ($Label + " is missing from the downloaded package.") -ForegroundColor Red
|
||||
return $false
|
||||
}}
|
||||
$actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $Expected.ToLowerInvariant()) {{
|
||||
Write-Host ($Label + " does not match the expected SHA256.") -ForegroundColor Red
|
||||
Write-Host (" expected " + $Expected)
|
||||
Write-Host (" actual " + $actual)
|
||||
return $false
|
||||
}}
|
||||
Write-Host (" " + $Label + ": sha256 verified")
|
||||
return $true
|
||||
}}
|
||||
|
||||
try {{
|
||||
New-Item -ItemType Directory -Force -Path $bootstrapTempDir | Out-Null
|
||||
|
||||
Write-Host "[1/4] Downloading the agent package ..."
|
||||
Invoke-WebRequest -Uri $BootstrapPackageUrl -Headers $BootstrapPackageHeaders -OutFile $bootstrapZipPath -UseBasicParsing
|
||||
|
||||
Write-Host "[2/4] Expanding and verifying ..."
|
||||
Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force
|
||||
|
||||
$bootstrapOk = $true
|
||||
if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdbagent.exe") -Expected $BootstrapAgentSha256 -Label "shopdbagent.exe")) {{ $bootstrapOk = $false }}
|
||||
if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdb-endpoint-tray.exe") -Expected $BootstrapTraySha256 -Label "shopdb-endpoint-tray.exe")) {{ $bootstrapOk = $false }}
|
||||
if (-not $bootstrapOk) {{
|
||||
Remove-BootstrapTemp
|
||||
Write-Host "Refusing to install an unverified package." -ForegroundColor Red
|
||||
exit 3
|
||||
}}
|
||||
|
||||
$bootstrapInstaller = Join-Path $bootstrapExtractDir "install.ps1"
|
||||
if (-not (Test-Path $bootstrapInstaller)) {{
|
||||
Remove-BootstrapTemp
|
||||
Write-Host "The downloaded package carries no install.ps1." -ForegroundColor Red
|
||||
exit 3
|
||||
}}
|
||||
|
||||
# No key argument: this package has no -CollectorKey parameter at all.
|
||||
Write-Host "[3/4] Installing ..."
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootstrapInstaller
|
||||
$bootstrapInstallExit = $LASTEXITCODE
|
||||
}}
|
||||
catch {{
|
||||
Remove-BootstrapTemp
|
||||
Write-Host ("Bootstrap failed: " + $_.Exception.Message) -ForegroundColor Red
|
||||
exit 3
|
||||
}}
|
||||
|
||||
Remove-BootstrapTemp
|
||||
|
||||
# 4. Report what actually landed. The installer's own exit code is not enough:
|
||||
# a PowerShell -File run can return 0 after a terminating error, so the service
|
||||
# is read back directly.
|
||||
Write-Host "[4/4] Result"
|
||||
$bootstrapAgentPath = Join-Path $BootstrapInstallDir "shopdbagent.exe"
|
||||
$bootstrapVersion = "not installed"
|
||||
if (Test-Path $bootstrapAgentPath) {{
|
||||
$bootstrapVersion = (Get-Item $bootstrapAgentPath).VersionInfo.FileVersion
|
||||
}}
|
||||
$bootstrapService = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue
|
||||
$bootstrapState = "not installed"
|
||||
if ($bootstrapService) {{ $bootstrapState = [string] $bootstrapService.Status }}
|
||||
|
||||
Write-Host (" installed version : " + $bootstrapVersion)
|
||||
Write-Host (" service state : " + $bootstrapState)
|
||||
|
||||
if (-not $bootstrapService -or $bootstrapState -ne "Running") {{
|
||||
Write-Host "The agent service is not running. The install did not complete." -ForegroundColor Red
|
||||
if ($bootstrapInstallExit) {{ Write-Host (" installer exit code: " + $bootstrapInstallExit) }}
|
||||
exit 4
|
||||
}}
|
||||
|
||||
if ($bootstrapAlreadyEnrolled) {{
|
||||
Write-Host "This PC already holds an endpoint credential; no approval is needed." -ForegroundColor Green
|
||||
exit 0
|
||||
}}
|
||||
|
||||
Write-Host "This PC is waiting for approval - an admin approves it in Settings > Endpoints > Enrollment." -ForegroundColor Yellow
|
||||
|
||||
# Watch for the credential the agent writes once an admin approves it. Purely
|
||||
# local: nothing is asked of the server, and a PC still pending after the wait
|
||||
# is a normal outcome, not a failure.
|
||||
$bootstrapDeadline = (Get-Date).AddSeconds($BootstrapWaitSeconds)
|
||||
$bootstrapEnrolled = $false
|
||||
while ((Get-Date) -lt $bootstrapDeadline) {{
|
||||
if (Test-Path $BootstrapCredentialPath) {{ $bootstrapEnrolled = $true; break }}
|
||||
Start-Sleep -Seconds 5
|
||||
}}
|
||||
|
||||
if ($bootstrapEnrolled) {{
|
||||
Write-Host "approved and enrolled" -ForegroundColor Green
|
||||
}}
|
||||
else {{
|
||||
Write-Host "Still pending. The agent keeps asking on its own; nothing else is needed on this PC."
|
||||
}}
|
||||
|
||||
exit 0
|
||||
'''
|
||||
|
||||
|
||||
def build_bootstrap_script(secret, baseurl=None):
|
||||
"""The PowerShell text served for one token, with the URL and hashes baked in."""
|
||||
base = _base(baseurl)
|
||||
hashes = siteagent.staged_binary_hashes()
|
||||
return _SCRIPT_TEMPLATE.format(
|
||||
packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)),
|
||||
tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER),
|
||||
token=json.dumps(secret),
|
||||
agentsha=json.dumps(hashes.get('agent', '')),
|
||||
traysha=json.dumps(hashes.get('tray', '')),
|
||||
forcevariable=FORCE_VARIABLE,
|
||||
waitseconds=120,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _username():
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
return getattr(current_user, 'username', None)
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def create_bootstrap_token():
|
||||
"""Mint one short-lived bootstrap token. The secret is returned ONCE."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
hours = int(payload.get('hours', DEFAULT_HOURS))
|
||||
maxuses = int(payload.get('maxuses', DEFAULT_MAX_USES))
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hours and maxuses must be whole numbers',
|
||||
http_code=400)
|
||||
|
||||
if not MINIMUM_HOURS <= hours <= MAXIMUM_HOURS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'hours must be between {0} and {1}'.format(MINIMUM_HOURS,
|
||||
MAXIMUM_HOURS),
|
||||
http_code=400)
|
||||
if not MINIMUM_USES <= maxuses <= MAXIMUM_USES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'maxuses must be between {0} and {1}'.format(MINIMUM_USES,
|
||||
MAXIMUM_USES),
|
||||
http_code=400)
|
||||
|
||||
secret = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
now = _naive_utcnow()
|
||||
row = EndpointBootstrapToken(
|
||||
tokenhash=_hash_token(secret),
|
||||
tokenprefix=secret[:DISPLAY_PREFIX_LENGTH],
|
||||
createdat=now,
|
||||
createdby=_username(),
|
||||
expiresat=now + timedelta(hours=hours),
|
||||
maxuses=maxuses,
|
||||
usecount=0,
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.flush()
|
||||
_audit('created', row,
|
||||
'bootstrap token created, {0}h, {1} use(s)'.format(hours, maxuses))
|
||||
db.session.commit()
|
||||
|
||||
return success_response(
|
||||
{
|
||||
'token': row.to_dict(now),
|
||||
'secret': secret,
|
||||
'command': bootstrap_command(secret),
|
||||
'scripturl': bootstrap_script_url(secret),
|
||||
},
|
||||
message='Bootstrap token created. The command is shown once.',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def list_bootstrap_tokens():
|
||||
"""Every bootstrap token, newest first. Never a secret."""
|
||||
now = _naive_utcnow()
|
||||
rows = (EndpointBootstrapToken.query
|
||||
.order_by(EndpointBootstrapToken.tokenid.desc())
|
||||
.limit(LIST_LIMIT).all())
|
||||
return success_response({'tokens': [row.to_dict(now) for row in rows]})
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap/tokens/<int:tokenid>/revoke',
|
||||
methods=['POST'])
|
||||
@require_role('admin')
|
||||
def revoke_bootstrap_token(tokenid):
|
||||
"""Kill a bootstrap token now. Already-revoked is a no-op, not an error."""
|
||||
row = db.session.get(EndpointBootstrapToken, tokenid)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404)
|
||||
now = _naive_utcnow()
|
||||
if row.revokedat is None:
|
||||
row.revokedat = now
|
||||
_audit('updated', row, 'bootstrap token revoked')
|
||||
db.session.commit()
|
||||
return success_response({'token': row.to_dict(now)},
|
||||
message='Bootstrap token revoked')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-authenticated: no login, the token is the whole authorization. It
|
||||
# rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
|
||||
def serve_bootstrap_script():
|
||||
"""The PowerShell the one-line command downloads, runs and deletes.
|
||||
|
||||
The same URL is the UI's "Download .ps1" link, for a tech who would rather
|
||||
save the file and run it than paste a command.
|
||||
|
||||
Fetching the script does NOT spend a use: a tech who pastes the command
|
||||
twice, or a shell that retries, must not burn the budget the package
|
||||
download is metered by.
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
_audit('requested', row,
|
||||
'bootstrap script served' + _note_token_source(source))
|
||||
db.session.commit()
|
||||
|
||||
return Response(build_bootstrap_script(secret), mimetype='text/plain')
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap/package', methods=['GET'])
|
||||
def download_bootstrap_package():
|
||||
"""The keyless agent package the bootstrap script installs.
|
||||
|
||||
Never carries a collector key, whatever the shared-key setting says: a
|
||||
bootstrap PC enrolls and waits for approval. Counts as one use.
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
now = _naive_utcnow()
|
||||
row.usecount = (row.usecount or 0) + 1
|
||||
row.lastusedat = now
|
||||
row.lastusedip = _caller_ip()[:45]
|
||||
_audit('downloaded', row, 'bootstrap package, use {0} of {1}{2}'.format(
|
||||
row.usecount, row.maxuses, _note_token_source(source)))
|
||||
db.session.commit()
|
||||
|
||||
buffer, warning = siteagent.build_keyless_package(bootstrap_base_url())
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=siteagent.package_download_name())
|
||||
if warning:
|
||||
response.headers[siteagent.CA_WARNING_HEADER] = warning
|
||||
return response
|
||||
1369
plugins/endpoints/api/enrollment.py
Normal file
1369
plugins/endpoints/api/enrollment.py
Normal file
File diff suppressed because it is too large
Load Diff
52
plugins/endpoints/api/ratelimit.py
Normal file
52
plugins/endpoints/api/ratelimit.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Fixed-window per-IP rate limiting for the agent-facing routes.
|
||||
|
||||
One implementation, several budgets. The bootstrap installer routes and the
|
||||
enrollment route are both unauthenticated by design - a PC with no agent has
|
||||
neither a credential nor a user session - so the only thing standing between
|
||||
them and a loop is a limiter. Each caller gets its own budget name so the two
|
||||
surfaces cannot spend each other's allowance.
|
||||
|
||||
Same shape and same cache extension as the login limiter in
|
||||
shopdb.core.api.auth. Under the default SimpleCache the counter is per-process,
|
||||
so with N gunicorn workers the effective budget is N times the configured
|
||||
maximum; that is defence in depth rather than a hard quota, and a shared cache
|
||||
backend (Redis/memcached) tightens it to a true global budget.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from flask import current_app, request
|
||||
|
||||
from shopdb.api import cache, client_ip, error_response
|
||||
|
||||
|
||||
def caller_ip():
|
||||
return client_ip(request) or 'unknown'
|
||||
|
||||
|
||||
def ratelimited(budget, configprefix, defaultmax, defaultwindow):
|
||||
"""True when this caller is over budget for the current window.
|
||||
|
||||
budget cache key namespace, e.g. 'endpointenrollratelimit'
|
||||
configprefix config key stem, e.g. 'ENDPOINTS_ENROLL_RATELIMIT'; reads
|
||||
<prefix>_ENABLED, <prefix>_MAX and <prefix>_WINDOW_SECONDS
|
||||
"""
|
||||
if not current_app.config.get(configprefix + '_ENABLED', True):
|
||||
return False
|
||||
window = current_app.config.get(configprefix + '_WINDOW_SECONDS',
|
||||
defaultwindow)
|
||||
maxhits = current_app.config.get(configprefix + '_MAX', defaultmax)
|
||||
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
key = '{0}:{1}:{2}'.format(budget, caller_ip(), bucket)
|
||||
count = cache.get(key) or 0
|
||||
if count >= maxhits:
|
||||
return True
|
||||
cache.set(key, count + 1, timeout=window)
|
||||
return False
|
||||
|
||||
|
||||
def rate_limit_response(message):
|
||||
"""The standard 429 shape every limiter in the product answers with."""
|
||||
return error_response('RATE_LIMITED', message, http_code=429)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,11 +21,19 @@ The generated install.ps1 writes those two registry values from the operator's
|
||||
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
|
||||
the agent fails closed until provisioned. The collector key is embedded only in
|
||||
the install script's default parameter and is never stored or logged here.
|
||||
|
||||
Per-PC credentials: the shared key is optional per build. A package built
|
||||
without it carries no key at all; the agent then asks to enroll and an admin
|
||||
approves it. Every package pins the server's public key (ServerPins, the
|
||||
lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a
|
||||
second "next" pin for a planned certificate rotation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import zipfile
|
||||
|
||||
@@ -34,9 +42,12 @@ from flask import request, current_app, send_file
|
||||
from shopdb.api import (success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
from .routes import endpoints_bp, _spki_sha256
|
||||
from .agentauth import shared_key_allowed
|
||||
from . import agentstore
|
||||
|
||||
_PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$')
|
||||
|
||||
# Prebuilt, staged agent binaries. Built out of band and dropped under
|
||||
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
|
||||
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
||||
@@ -110,6 +121,74 @@ def _validate_pem_cert(raw):
|
||||
return None
|
||||
return ''.join(blocks)
|
||||
|
||||
|
||||
def _leaf_certificate_facts(pembytes):
|
||||
"""(spkisha256, isca) for the FIRST certificate in a PEM, else (None, None).
|
||||
|
||||
The first block is the leaf in a leaf-plus-chain file, which is the cert
|
||||
the agent pins and the one Import-Certificate brings into the store.
|
||||
"""
|
||||
from cryptography import x509
|
||||
|
||||
try:
|
||||
text = pembytes.decode('ascii')
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
return None, None
|
||||
start = text.find('-----BEGIN CERTIFICATE-----')
|
||||
end = text.find('-----END CERTIFICATE-----', start)
|
||||
if start == -1 or end == -1:
|
||||
return None, None
|
||||
block = (text[start:end + len('-----END CERTIFICATE-----')] + '\n').encode('ascii')
|
||||
try:
|
||||
certificate = x509.load_pem_x509_certificate(block)
|
||||
except ValueError:
|
||||
return None, None
|
||||
try:
|
||||
isca = bool(certificate.extensions.get_extension_for_class(
|
||||
x509.BasicConstraints).value.ca)
|
||||
except x509.ExtensionNotFound:
|
||||
isca = False
|
||||
return _spki_sha256(block), isca
|
||||
|
||||
|
||||
def _current_server_facts():
|
||||
"""(spkisha256, isca) of this server's own leaf certificate, else (None, None)."""
|
||||
path = _server_cert_path()
|
||||
if not path:
|
||||
return None, None
|
||||
try:
|
||||
with open(path, 'rb') as handle:
|
||||
return _leaf_certificate_facts(handle.read())
|
||||
except OSError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _current_server_pin():
|
||||
"""Pin of this server's own leaf certificate, or None when not found.
|
||||
|
||||
The SPKI pin does not depend on basicConstraints, so a CA-flagged
|
||||
self-signed certificate is pinned like any other leaf.
|
||||
"""
|
||||
return _current_server_facts()[0]
|
||||
|
||||
|
||||
# Shown to the admin at build time (response header and UI) when the pinned
|
||||
# certificate carries basicConstraints CA:TRUE.
|
||||
CA_CERT_BUILD_WARNING = (
|
||||
'The server certificate has basicConstraints CA:TRUE, so install.ps1 will '
|
||||
'not import it into LocalMachine\\Root. The package still pins it: agents '
|
||||
'1.20 and newer trust the server by pin alone. Agents older than 1.20 and '
|
||||
'PowerShell 5.1 downloads without pin support will not trust this server. '
|
||||
'Regenerate the server certificate in Settings > Endpoints > Server '
|
||||
'certificate (the generator does not set CA), or trust a non-CA '
|
||||
'certificate on those PCs with Install-ShopDBServerCertificate.ps1.')
|
||||
CA_CERT_BOTH_PINS_WARNING = (
|
||||
'The uploaded CA-flagged certificate does not match this server\'s own '
|
||||
'certificate, so both pins were included in ServerPins: the uploaded '
|
||||
'certificate and the server\'s own certificate.')
|
||||
CA_WARNING_HEADER = 'X-ShopDB-Build-Warning'
|
||||
|
||||
|
||||
# The non-secret runtime config shipped in the package. baseurl and apikey stay
|
||||
# empty/placeholder so the agent fails closed until the registry is provisioned
|
||||
# by install.ps1. Cadence is stamped from the operator's input at build time.
|
||||
@@ -118,6 +197,7 @@ _BASE_APPSETTINGS = {
|
||||
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
|
||||
'cadenceseconds': 900,
|
||||
'jitterseconds': 60,
|
||||
'startupjitterseconds': 30,
|
||||
'adoptonfirstrun': True,
|
||||
'utilitiespath': '',
|
||||
'logpath': '',
|
||||
@@ -136,6 +216,12 @@ _BASE_APPSETTINGS = {
|
||||
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
||||
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
|
||||
},
|
||||
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
|
||||
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
|
||||
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
|
||||
# a site can narrow or widen the set without an agent rebuild. Anything not
|
||||
# matched fails closed.
|
||||
'approvedtaskpaths': [],
|
||||
}
|
||||
|
||||
_README = """ShopDB endpoint agent (site build)
|
||||
@@ -168,20 +254,42 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
{provision} to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
{enrollment}
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
||||
server after it was generated; regenerate the key in the UI to revoke it.
|
||||
"""
|
||||
{security}"""
|
||||
|
||||
_README_SHAREDKEY = {
|
||||
True: {
|
||||
'provision': 'writes the server URL, server pins, and collector key',
|
||||
'enrollment': '',
|
||||
'security': (
|
||||
' install.ps1 carries a live collector key in its default -CollectorKey value.\n'
|
||||
' Treat this package as a secret. The key is not recoverable from the ShopDB\n'
|
||||
' server after it was generated; regenerate the key in the UI to revoke it.\n'),
|
||||
},
|
||||
False: {
|
||||
'provision': 'writes the server URL and server pins',
|
||||
'enrollment': (
|
||||
' This package carries NO shared key. After install the PC requests\n'
|
||||
' enrollment; approve it in Settings > Endpoints > Enrollment.\n'),
|
||||
'security': (
|
||||
' This package holds no secret. Each PC receives its own credential when\n'
|
||||
' an admin approves its enrollment request; an existing enrolled\n'
|
||||
' credential on the PC is never touched by install.ps1.\n'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _readme(includesharedkey):
|
||||
return _README.format(**_README_SHAREDKEY[bool(includesharedkey)])
|
||||
|
||||
|
||||
def _staged_path(relpath):
|
||||
@@ -189,7 +297,9 @@ def _staged_path(relpath):
|
||||
return os.path.join(current_app.instance_path, relpath)
|
||||
|
||||
|
||||
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
||||
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None,
|
||||
serverpins=None, includesharedkey=True,
|
||||
trustcertinroot=True):
|
||||
"""The install.ps1 that provisions credentials and installs the service.
|
||||
|
||||
The operator's server URL and collector key are stamped as the default
|
||||
@@ -199,10 +309,38 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
||||
certificate is bundled, certfilename names it and the script imports it into
|
||||
the machine Trusted Root store so the agent's outbound TLS to a self-signed
|
||||
or internal-CA server validates.
|
||||
|
||||
serverpins are written as REG_MULTI_SZ ServerPins. With includesharedkey
|
||||
False the collector key is left out of the script entirely (no parameter,
|
||||
no registry write) and the installer tells the operator to approve the
|
||||
enrollment request. trustcertinroot False (a CA certificate) skips the Root
|
||||
import; the script also re-checks basicConstraints on the endpoint.
|
||||
"""
|
||||
baseurl_literal = json.dumps(baseurl)
|
||||
collectorkey_literal = json.dumps(collectorkey)
|
||||
certfile_literal = json.dumps(certfilename or '')
|
||||
pins_literal = '@(' + ', '.join(json.dumps(pin) for pin in (serverpins or [])) + ')'
|
||||
if includesharedkey:
|
||||
keyparam = ' [string] $CollectorKey = {0},\n'.format(json.dumps(collectorkey))
|
||||
keycheck = (
|
||||
'if ([string]::IsNullOrWhiteSpace($CollectorKey)) {\n'
|
||||
' throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."\n'
|
||||
'}\n')
|
||||
keywrite = 'Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey\n'
|
||||
sharedkeymode = 'included'
|
||||
enrollnotice = ''
|
||||
else:
|
||||
keyparam = ''
|
||||
keycheck = ''
|
||||
keywrite = (
|
||||
'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n'
|
||||
' Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n'
|
||||
'}\n')
|
||||
sharedkeymode = 'omitted'
|
||||
enrollnotice = (
|
||||
'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n'
|
||||
' Write-Host " This PC will request enrollment. Approve it in '
|
||||
'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\n'
|
||||
'}\n')
|
||||
return """# install.ps1
|
||||
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
|
||||
# the endpoint. Provisions the registry credentials, copies the agent to
|
||||
@@ -212,12 +350,24 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BaseUrl = {baseurl},
|
||||
[string] $CollectorKey = {collectorkey},
|
||||
{keyparam} [string[]] $ServerPins = {serverpins},
|
||||
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
[string] $CertFileName = {certfile},
|
||||
[switch] $NoTray
|
||||
[switch] $NoTray,
|
||||
|
||||
# Scheduled task that runs the user-context probe INSIDE the operator's own
|
||||
# logon session. Mapped drives are per logon token, so this is the only
|
||||
# reliable way to collect them; see the registration block below.
|
||||
[string] $UserContextTaskName = "ShopDB user-context probe",
|
||||
[int] $UserContextTaskMinutes = 30,
|
||||
|
||||
# The account the probe task runs as. Must match the account whose profile
|
||||
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
|
||||
# the probe writes session data the service never looks at.
|
||||
[string] $UserContextTaskUser = "ShopFloor",
|
||||
[switch] $NoUserContextTask
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -232,9 +382,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
|
||||
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
||||
}}
|
||||
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
|
||||
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
|
||||
}}
|
||||
{keycheck}$sharedKeyMode = "{sharedkeymode}"
|
||||
$trustCertInRoot = {trustcert}
|
||||
|
||||
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
|
||||
# The agent reads Registry64 first then Registry32 and uses the first view that
|
||||
@@ -261,10 +410,106 @@ function Set-HklmValueBothViews {{
|
||||
}}
|
||||
}}
|
||||
|
||||
# Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views.
|
||||
#
|
||||
# WHY. The shared fleet key is written here and stays until every consumer is
|
||||
# on a per-PC credential, and the DACL this key inherits by default grants
|
||||
# BUILTIN\\Users read - so any logged-on user on any bay could read the fleet
|
||||
# key, which is the exact exposure the per-PC credential migration exists to
|
||||
# close. Everything that legitimately reads this key runs as SYSTEM (the agent
|
||||
# service, the GE-Enforce reporters) or as an admin (the adoption tool,
|
||||
# Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the
|
||||
# service over the named pipe - so nothing loses access here.
|
||||
#
|
||||
# It also protects ServerPins from a standard user, which is what makes the
|
||||
# registry safe to treat as authoritative for pinning.
|
||||
function Protect-HklmShopdbKey {{
|
||||
param([Parameter(Mandatory)] [string] $SubKey)
|
||||
$rights = [System.Security.AccessControl.RegistryRights]
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {{
|
||||
$key = $base.OpenSubKey(
|
||||
$SubKey,
|
||||
[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
|
||||
$rights::ChangePermissions -bor $rights::ReadPermissions)
|
||||
if (-not $key) {{ continue }}
|
||||
try {{
|
||||
$acl = $key.GetAccessControl(
|
||||
[System.Security.AccessControl.AccessControlSections]::Access)
|
||||
# Break inheritance and DROP the inherited entries rather than
|
||||
# copying them in: copying is what silently preserves the
|
||||
# BUILTIN\\Users read this function exists to remove.
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }}
|
||||
foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{
|
||||
$who = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
||||
$acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule(
|
||||
$who,
|
||||
$rights::FullControl,
|
||||
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit,
|
||||
[System.Security.AccessControl.PropagationFlags]::None,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow)))
|
||||
}}
|
||||
$key.SetAccessControl($acl)
|
||||
}}
|
||||
finally {{ $key.Dispose() }}
|
||||
}}
|
||||
finally {{ $base.Dispose() }}
|
||||
}}
|
||||
}}
|
||||
|
||||
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
|
||||
# pins leaves the value (and its last-write time) alone.
|
||||
function Set-HklmMultiStringBothViews {{
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $SubKey,
|
||||
[Parameter(Mandatory)] [string] $Name,
|
||||
[Parameter(Mandatory)] [string[]] $Value
|
||||
)
|
||||
$wanted = ($Value -join "`n")
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {{
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {{
|
||||
$same = $false
|
||||
$existingValue = $key.GetValue($Name, $null)
|
||||
if ($null -ne $existingValue -and
|
||||
$key.GetValueKind($Name) -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {{
|
||||
$same = ((@($existingValue) -join "`n") -ceq $wanted)
|
||||
}}
|
||||
if ($same) {{
|
||||
Write-Host " $Name unchanged ($view)"
|
||||
}}
|
||||
else {{
|
||||
$key.SetValue($Name, [string[]] $Value, [Microsoft.Win32.RegistryValueKind]::MultiString)
|
||||
Write-Host " $Name written ($view)"
|
||||
}}
|
||||
}}
|
||||
finally {{ $key.Dispose() }}
|
||||
}}
|
||||
finally {{ $base.Dispose() }}
|
||||
}}
|
||||
}}
|
||||
|
||||
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
|
||||
Write-Host "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
Write-Host "ServiceName : $ServiceName"
|
||||
Write-Host "Shared key : $sharedKeyMode"
|
||||
|
||||
# Per-PC credential lives under ProgramData\\ShopDB\\EndpointAgent\\credential and
|
||||
# belongs to the agent. This installer never writes or deletes it, so a reinstall
|
||||
# or upgrade keeps an enrolled PC enrolled.
|
||||
$credentialDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential"
|
||||
if (Test-Path (Join-Path $credentialDir "credential.bin")) {{
|
||||
Write-Host " existing enrolled credential found; kept as is."
|
||||
}}
|
||||
|
||||
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
@@ -287,21 +532,65 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
|
||||
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
||||
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
{keywrite}
|
||||
# Server public-key pins (current, plus next during a planned rotation). An
|
||||
# empty list leaves any ServerPins already on the PC untouched.
|
||||
$pinList = @($ServerPins | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }})
|
||||
if ($pinList.Count -gt 0) {{
|
||||
Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins" -Value $pinList
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "no server pin in this package; ServerPins left as is."
|
||||
}}
|
||||
|
||||
# Harden the key AFTER the writes: the DACL is replaced, not merged, so writing
|
||||
# first and locking second keeps this correct whether or not the key pre-existed.
|
||||
try {{
|
||||
Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"
|
||||
Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators."
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)"
|
||||
Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed."
|
||||
}}
|
||||
|
||||
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
||||
# TLS to a self-signed or internal-CA server fails closed until the machine
|
||||
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
||||
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
|
||||
# certificate, in which case no cert is bundled.
|
||||
# Kept for agents older than 1.20 and PowerShell download paths that do not pin.
|
||||
# Only a leaf is trusted: a CA in Root would trust everything it signs.
|
||||
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
||||
$certPath = Join-Path $sourceDir $CertFileName
|
||||
if (Test-Path $certPath) {{
|
||||
Write-Host " trusting bundled server certificate $CertFileName ..."
|
||||
try {{
|
||||
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
||||
$certIsCa = -not $trustCertInRoot
|
||||
if (-not $certIsCa) {{
|
||||
try {{
|
||||
$probe = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath)
|
||||
foreach ($ext in $probe.Extensions) {{
|
||||
if ($ext -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] -and
|
||||
$ext.CertificateAuthority) {{ $certIsCa = $true }}
|
||||
}}
|
||||
}}
|
||||
catch {{ }}
|
||||
}}
|
||||
if ($certIsCa) {{
|
||||
Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root."
|
||||
if ($pinList.Count -gt 0) {{
|
||||
Write-Warning "ServerPins were written: agents 1.20 and newer trust the server by pin alone."
|
||||
}}
|
||||
Write-Warning "Agents older than 1.20, and PowerShell 5.1 downloads without pin support, will NOT trust this server."
|
||||
Write-Warning "Fix: regenerate the server certificate with the ShopDB generator (Settings > Endpoints > Server certificate; it does not set CA),"
|
||||
Write-Warning "or have an admin trust a non-CA server certificate on this PC with Install-ShopDBServerCertificate.ps1."
|
||||
}}
|
||||
else {{
|
||||
Write-Host " trusting bundled server certificate $CertFileName ..."
|
||||
try {{
|
||||
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
||||
}}
|
||||
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
|
||||
}}
|
||||
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
|
||||
@@ -362,10 +651,175 @@ if (-not $NoTray) {{
|
||||
}}
|
||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||
|
||||
# Launch the tray once now so it appears without waiting for the next
|
||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
||||
# Deliberately NOT launched here. This installer runs elevated, so
|
||||
# Start-Process would hand the tray an ADMIN token, and drive letters
|
||||
# are per logon token: the probe would then enumerate the installing
|
||||
# admin's drive table, not the operator's. Measured on a live endpoint,
|
||||
# the same two shares reported Unavailable from the elevated shell and
|
||||
# OK from the operator's own session, in both Win32_NetworkConnection
|
||||
# and Get-SmbMapping. Launching via explorer.exe does not fix this
|
||||
# either - it de-elevates but does NOT change account.
|
||||
#
|
||||
# The Run key above is the correct autostart: it fires at a genuine
|
||||
# logon, in the operator's own session. Collection no longer depends on
|
||||
# the tray at all - the scheduled task below owns it.
|
||||
Write-Host " tray will start at next logon (not launched from this elevated install)."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
|
||||
# USER. This is what makes mapped drives, user listeners and idle time
|
||||
# collectable: it is the only mechanism that starts a process in the operator's
|
||||
# own session with the operator's own token.
|
||||
#
|
||||
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
|
||||
# token is precisely what sees the operator's mapped drives.
|
||||
#
|
||||
# UserId, NOT GroupId. A group-principal task is registered against the group
|
||||
# rather than against any one account, and a member of that group cannot invoke
|
||||
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
|
||||
# denied, so the task only ever ran on its own trigger. That is exactly the bug
|
||||
# where "Refresh Status" appeared to do nothing. Registering to the specific
|
||||
# account makes the task startable by that account, which is what both the tray
|
||||
# and any manual retry depend on. The group form is kept only as a fallback for
|
||||
# a host where the account cannot be resolved - it still collects on a timer,
|
||||
# it just cannot be kicked on demand.
|
||||
$taskState = "skipped"
|
||||
if (-not $NoUserContextTask) {{
|
||||
Write-Host " registering user-context probe task ..."
|
||||
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
|
||||
$taskRegistered = $false
|
||||
$taskDetail = "no principal accepted"
|
||||
try {{
|
||||
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
|
||||
# AtLogOn covers the normal case. The repeating trigger matters just as
|
||||
# much: AtLogOn does NOT fire for a session that is already signed in,
|
||||
# which is every install onto a machine someone is currently using.
|
||||
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
|
||||
if ($UserContextTaskMinutes -gt 0) {{
|
||||
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
|
||||
}}
|
||||
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
|
||||
|
||||
# Try the machine-qualified account first, then the bare name. Interactive
|
||||
# LogonType so the task joins the existing interactive session instead of
|
||||
# creating a separate one with its own (empty) drive table.
|
||||
$taskCandidates = @(
|
||||
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
|
||||
@{{ Kind = "user"; Id = $UserContextTaskUser }},
|
||||
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
|
||||
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
|
||||
)
|
||||
foreach ($candidate in $taskCandidates) {{
|
||||
try {{
|
||||
if ($candidate.Kind -eq "user") {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
else {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
|
||||
$taskRegistered = $true
|
||||
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
|
||||
break
|
||||
}}
|
||||
catch {{ $taskDetail = $_.Exception.Message }}
|
||||
}}
|
||||
}}
|
||||
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
|
||||
|
||||
if ($taskRegistered) {{
|
||||
# Read the task back rather than trusting Register-ScheduledTask to have
|
||||
# returned cleanly: policy or a management agent can rewrite or drop a
|
||||
# task after registration, and a task that exists but runs elevated
|
||||
# looks healthy in the scheduler UI while producing wrong data.
|
||||
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
|
||||
if (-not $verified) {{
|
||||
$taskState = "UNVERIFIED:task not found after registration"
|
||||
Write-Warning " user-context task registered but cannot be read back."
|
||||
}}
|
||||
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
|
||||
$taskState = "UNVERIFIED:elevated"
|
||||
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
|
||||
}}
|
||||
elseif ($verified.Principal.GroupId) {{
|
||||
# Registered, will collect on its triggers, but cannot be started on
|
||||
# demand by the operator - so "Refresh Status" will fall back to the
|
||||
# timer. Surfaced rather than reported as ok, because the symptom
|
||||
# (stale session data that never responds to a refresh) is otherwise
|
||||
# indistinguishable from a broken probe.
|
||||
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
|
||||
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
|
||||
}}
|
||||
else {{
|
||||
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
|
||||
Write-Host " user-context task registered and verified: $taskState"
|
||||
}}
|
||||
|
||||
# AtLogOn will not fire for an already-signed-in session, so kick it once
|
||||
# rather than leaving a fresh install blind until the repeat interval.
|
||||
try {{
|
||||
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
|
||||
Write-Host " user-context task started once for the current session."
|
||||
}}
|
||||
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
|
||||
}}
|
||||
else {{
|
||||
# Not fatal: the service still checks in, only the session-scoped fields
|
||||
# are missing, and the ledger records why.
|
||||
$taskState = "FAILED:$taskDetail"
|
||||
Write-Warning " could not register the user-context task: $taskDetail"
|
||||
Write-Warning " mapped drives and idle time will not be collected."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2c. Start the tray in the operator's session.
|
||||
#
|
||||
# The Run key registered above only fires at LOGON, so installing onto a machine
|
||||
# someone is already signed in to leaves no tray until they sign out and back in.
|
||||
# The installer cannot start it directly - it is elevated, and the tray must run
|
||||
# unelevated in the user's own session - so we borrow the same mechanism that
|
||||
# solves this for the probe: a transient scheduled task with a Limited group
|
||||
# principal, run once and then deleted.
|
||||
#
|
||||
# Registered without a trigger; it exists only to be invoked immediately. The
|
||||
# tray is single-instance, so if one is somehow already running this is a no-op.
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$launchTaskName = "ShopDB tray first launch"
|
||||
try {{
|
||||
$launchExe = Join-Path $InstallDir $TrayExeName
|
||||
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
|
||||
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$launched = $false
|
||||
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
|
||||
try {{
|
||||
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
|
||||
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
|
||||
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
|
||||
$launched = $true
|
||||
break
|
||||
}}
|
||||
catch {{ }}
|
||||
}}
|
||||
|
||||
# Give the task a moment to spawn the tray before removing it: deleting a
|
||||
# task that is still starting can kill the process it just launched.
|
||||
if ($launched) {{
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " tray started in the operator's session."
|
||||
}}
|
||||
else {{
|
||||
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
|
||||
}}
|
||||
|
||||
# Transient by design: this is a one-shot launcher, not an autostart.
|
||||
# The Run key owns autostart, and leaving this behind would mean two
|
||||
# things racing to start the same single-instance tray at every logon.
|
||||
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
|
||||
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -394,24 +848,31 @@ if (-not $serviceOk) {{
|
||||
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
|
||||
}}
|
||||
Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
{enrollnotice}
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$trayOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
||||
# Report only; never relaunch from here. This block used to Start-Process the
|
||||
# tray when it was not found running, which would hand it this installer's
|
||||
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
|
||||
# above is the only place the tray is started during install, and it starts
|
||||
# it correctly - unelevated, in the operator's session.
|
||||
$trayFound = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
||||
if ($trayFound) {{
|
||||
Write-Host " tray is running." -ForegroundColor Green
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
||||
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
|
||||
# collector key in its default parameter, so it must not linger on the endpoint.
|
||||
# scrub install.ps1 from wherever it was run. A shared-key build carries the live
|
||||
# key in its default parameter, so it must not linger on the endpoint.
|
||||
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
||||
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
||||
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
||||
@@ -427,11 +888,34 @@ $uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
|
||||
|
||||
$installSelf = Join-Path $sourceDir "install.ps1"
|
||||
if (Test-Path $installSelf) {{
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }}
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
|
||||
# Append one line per install, including reinstalls and repeat installs of the
|
||||
# same version. The user-context task state is recorded because it is the single
|
||||
# point of failure for session-scoped collection: if mapped drives go missing
|
||||
# later, this answers "was the task ever registered on this host, and as what"
|
||||
# without needing the endpoint back. The ledger is a record, not a gate - a
|
||||
# failure to append must never fail an otherwise-good install.
|
||||
try {{
|
||||
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
|
||||
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
|
||||
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
|
||||
$ledgerAgentVersion = "unknown"
|
||||
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
|
||||
$ledgerTrayVersion = "not-installed"
|
||||
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
||||
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
||||
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}} sharedkey={{7}} serverpins={{8}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState, $sharedKeyMode, $pinList.Count
|
||||
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
|
||||
Write-Host "Install ledger: $ledgerPath"
|
||||
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
||||
}}
|
||||
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
|
||||
|
||||
if (Test-Path $uninstaller) {{
|
||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||
@@ -441,11 +925,120 @@ else {{
|
||||
}}
|
||||
""".format(
|
||||
baseurl=baseurl_literal,
|
||||
collectorkey=collectorkey_literal,
|
||||
keyparam=keyparam,
|
||||
keycheck=keycheck,
|
||||
keywrite=keywrite,
|
||||
sharedkeymode=sharedkeymode,
|
||||
enrollnotice=enrollnotice,
|
||||
serverpins=pins_literal,
|
||||
trustcert='$true' if trustcertinroot else '$false',
|
||||
certfile=certfile_literal,
|
||||
)
|
||||
|
||||
|
||||
def build_package_zip(baseurl, collectorkey, cadenceseconds, appsettings,
|
||||
certfilename=None, cert_pem=None, certpath=None,
|
||||
serverpins=None, includesharedkey=True,
|
||||
trustcertinroot=True):
|
||||
"""Assemble the agent package ZIP in memory.
|
||||
|
||||
The single place the archive is laid out, so the admin download route and
|
||||
the token-authenticated bootstrap download cannot drift apart in what they
|
||||
ship. Callers own validation, pin resolution and the appsettings they pass.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('appsettings.json',
|
||||
json.dumps(appsettings, indent=2) + '\n')
|
||||
archive.writestr('install.ps1',
|
||||
_install_script(baseurl.rstrip('/'), collectorkey,
|
||||
cadenceseconds, certfilename,
|
||||
serverpins=serverpins,
|
||||
includesharedkey=includesharedkey,
|
||||
trustcertinroot=trustcertinroot))
|
||||
archive.writestr('README.txt', _readme(includesharedkey))
|
||||
|
||||
if os.path.isfile(UNINSTALLER_PATH):
|
||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||
|
||||
agent = agentstore.current_path('agent')
|
||||
if agent and os.path.isfile(agent):
|
||||
archive.write(agent, 'shopdbagent.exe')
|
||||
tray = agentstore.current_path('tray')
|
||||
if tray and os.path.isfile(tray):
|
||||
archive.write(tray, 'shopdb-endpoint-tray.exe')
|
||||
if cert_pem:
|
||||
archive.writestr('server.crt', cert_pem)
|
||||
elif certpath:
|
||||
archive.write(certpath, 'server.crt')
|
||||
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
|
||||
def package_download_name():
|
||||
"""Versioned download filename for the package, or the plain one."""
|
||||
agentversion = agentstore.current_version('agent')
|
||||
return ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
|
||||
if agentversion else 'shopdb-endpoint-agent.zip')
|
||||
|
||||
|
||||
# Filenames the package ships the staged binaries under, keyed by store binkey.
|
||||
STAGED_EXE_NAMES = {
|
||||
'agent': 'shopdbagent.exe',
|
||||
'tray': 'shopdb-endpoint-tray.exe',
|
||||
}
|
||||
|
||||
|
||||
def staged_binary_hashes():
|
||||
"""{binkey: lowercase sha256} for every staged binary, '' when none.
|
||||
|
||||
The bootstrap script bakes these in and re-checks the downloaded ZIP
|
||||
against them on the endpoint, so a package swapped in transit is refused
|
||||
before anything is executed. Hashed from the file rather than read out of
|
||||
the store manifest so a legacy flat-file staging is covered too.
|
||||
"""
|
||||
hashes = {}
|
||||
for binkey in STAGED_EXE_NAMES:
|
||||
path = agentstore.current_path(binkey)
|
||||
digest = ''
|
||||
if path and os.path.isfile(path):
|
||||
hasher = hashlib.sha256()
|
||||
with open(path, 'rb') as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b''):
|
||||
hasher.update(chunk)
|
||||
digest = hasher.hexdigest()
|
||||
hashes[binkey] = digest
|
||||
return hashes
|
||||
|
||||
|
||||
def build_keyless_package(baseurl, cadenceseconds=900):
|
||||
"""The package the bootstrap installer downloads: never a collector key.
|
||||
|
||||
Pins this server exactly as the admin builder does with no upload - the
|
||||
server's own leaf certificate - and bundles that certificate when the app
|
||||
can read it, which is the self-signed topology where the endpoint would
|
||||
otherwise fail its own TLS validation.
|
||||
"""
|
||||
appsettings = dict(_BASE_APPSETTINGS)
|
||||
appsettings['cadenceseconds'] = cadenceseconds
|
||||
appsettings['apikey'] = ''
|
||||
|
||||
certpath = _server_cert_path()
|
||||
certfilename = 'server.crt' if certpath else None
|
||||
currentpin, pinisca = _current_server_facts()
|
||||
serverpins = [pin for pin in (currentpin,) if pin]
|
||||
appsettings['serverpins'] = serverpins
|
||||
|
||||
buffer = build_package_zip(
|
||||
baseurl, '', cadenceseconds, appsettings,
|
||||
certfilename=certfilename, certpath=certpath,
|
||||
serverpins=serverpins, includesharedkey=False,
|
||||
trustcertinroot=not pinisca)
|
||||
warning = CA_CERT_BUILD_WARNING if (currentpin and pinisca) else None
|
||||
return buffer, warning
|
||||
|
||||
|
||||
def _siteagent_status_payload():
|
||||
"""Whether a prebuilt agent binary is staged for packaging.
|
||||
|
||||
@@ -458,6 +1051,8 @@ def _siteagent_status_payload():
|
||||
'agentstaged': agent_staged,
|
||||
'traystaged': tray_staged,
|
||||
'certavailable': _server_cert_path() is not None,
|
||||
'serverpin': _current_server_pin(),
|
||||
'sharedkeyallowed': shared_key_allowed(),
|
||||
'agentcurrent': agentstore.current_version('agent'),
|
||||
'traycurrent': agentstore.current_version('tray'),
|
||||
'agentversions': agentstore.list_versions('agent'),
|
||||
@@ -480,7 +1075,12 @@ def package_siteagent():
|
||||
"""Bundle the site agent as a downloadable ZIP.
|
||||
|
||||
Accepts either JSON or multipart/form-data with fields baseurl,
|
||||
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
|
||||
collectorkey, cadenceseconds, bundlecert, includesharedkey, and
|
||||
nextserverpin. includesharedkey defaults to the site setting
|
||||
plugin.endpoints.sharedkeyallowed; when off, collectorkey is ignored and
|
||||
nothing in the package carries it. nextserverpin (64 lowercase hex) is
|
||||
shipped beside the current server pin for a planned certificate rotation.
|
||||
A multipart request may also
|
||||
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
|
||||
X.509 and bundled so the installer trusts it as a root on the endpoint. This
|
||||
is the reverse-proxy path where the app cannot see its own TLS cert. When no
|
||||
@@ -490,7 +1090,9 @@ def package_siteagent():
|
||||
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
|
||||
present), a non-secret appsettings.json, install.ps1, and README.txt. The
|
||||
collector key is embedded only in install.ps1 as its default parameter and
|
||||
is never stored or logged.
|
||||
is never stored or logged. When the pinned certificate is CA-flagged the
|
||||
response carries an X-ShopDB-Build-Warning header explaining that only
|
||||
pinning (agents 1.20 and newer) will trust the server.
|
||||
"""
|
||||
if request.form or request.files:
|
||||
source = request.form
|
||||
@@ -503,11 +1105,25 @@ def package_siteagent():
|
||||
collectorkey = (source.get('collectorkey') or '').strip()
|
||||
cadenceseconds = source.get('cadenceseconds', 900)
|
||||
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
|
||||
rawinclude = source.get('includesharedkey')
|
||||
if rawinclude is None or str(rawinclude).strip() == '':
|
||||
includesharedkey = shared_key_allowed()
|
||||
else:
|
||||
includesharedkey = str(rawinclude).lower() in ('1', 'true', 'yes', 'on')
|
||||
nextserverpin = (source.get('nextserverpin') or '').strip()
|
||||
|
||||
if not baseurl or not collectorkey:
|
||||
if not includesharedkey:
|
||||
collectorkey = ''
|
||||
if not baseurl or (includesharedkey and not collectorkey):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'baseurl and collectorkey are required',
|
||||
'baseurl and collectorkey are required' if includesharedkey
|
||||
else 'baseurl is required',
|
||||
http_code=400)
|
||||
if nextserverpin and not _PIN_PATTERN.match(nextserverpin):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'nextserverpin must be 64 lowercase hex characters',
|
||||
http_code=400)
|
||||
|
||||
try:
|
||||
@@ -525,6 +1141,8 @@ def package_siteagent():
|
||||
|
||||
appsettings = dict(_BASE_APPSETTINGS)
|
||||
appsettings['cadenceseconds'] = cadenceseconds
|
||||
if not includesharedkey:
|
||||
appsettings['apikey'] = ''
|
||||
|
||||
# An operator-uploaded cert wins over the auto-detected server cert: it is
|
||||
# the explicit reverse-proxy answer where the app cannot see its own TLS
|
||||
@@ -544,40 +1162,62 @@ def package_siteagent():
|
||||
|
||||
certfilename = 'server.crt' if (cert_pem or certpath) else None
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('appsettings.json',
|
||||
json.dumps(appsettings, indent=2) + '\n')
|
||||
archive.writestr('install.ps1',
|
||||
_install_script(baseurl.rstrip('/'), collectorkey,
|
||||
cadenceseconds, certfilename))
|
||||
archive.writestr('README.txt', _README)
|
||||
|
||||
if os.path.isfile(UNINSTALLER_PATH):
|
||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||
|
||||
agent = agentstore.current_path('agent')
|
||||
if agent and os.path.isfile(agent):
|
||||
archive.write(agent, 'shopdbagent.exe')
|
||||
tray = agentstore.current_path('tray')
|
||||
if tray and os.path.isfile(tray):
|
||||
archive.write(tray, 'shopdb-endpoint-tray.exe')
|
||||
# Pin the leaf the server presents: the bundled/uploaded leaf, else this
|
||||
# server's own certificate. The SPKI pin ignores basicConstraints, so a
|
||||
# CA-flagged self-signed cert is still pinned; the CA flag only gates the
|
||||
# Root import.
|
||||
trustcertinroot = True
|
||||
currentpin = None
|
||||
pinisca = False
|
||||
if certfilename:
|
||||
if cert_pem:
|
||||
archive.writestr('server.crt', cert_pem)
|
||||
elif certpath:
|
||||
archive.write(certpath, 'server.crt')
|
||||
bundledbytes = cert_pem.encode('ascii')
|
||||
else:
|
||||
with open(certpath, 'rb') as handle:
|
||||
bundledbytes = handle.read()
|
||||
currentpin, bundledisca = _leaf_certificate_facts(bundledbytes)
|
||||
trustcertinroot = not bundledisca
|
||||
pinisca = bool(bundledisca)
|
||||
# An uploaded CA-flagged cert may be the server's own self-signed cert or an
|
||||
# internal CA root sent by mistake. Ship the server's own pin beside it so
|
||||
# the agent (which accepts any listed pin) works either way.
|
||||
serverownpin = None
|
||||
if cert_pem and pinisca:
|
||||
serverownpin = _current_server_pin()
|
||||
if serverownpin == currentpin:
|
||||
serverownpin = None
|
||||
if currentpin is None:
|
||||
currentpin, pinisca = _current_server_facts()
|
||||
warnings = [CA_CERT_BUILD_WARNING] if (currentpin and pinisca) else []
|
||||
if serverownpin:
|
||||
warnings.append(CA_CERT_BOTH_PINS_WARNING)
|
||||
if nextserverpin and currentpin is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'nextserverpin needs a current server pin; bundle or upload the '
|
||||
'server leaf certificate',
|
||||
http_code=400)
|
||||
serverpins = [pin for pin in (currentpin, serverownpin, nextserverpin) if pin]
|
||||
serverpins = list(dict.fromkeys(serverpins))
|
||||
appsettings['serverpins'] = serverpins
|
||||
|
||||
buffer = build_package_zip(
|
||||
baseurl, collectorkey, cadenceseconds, appsettings,
|
||||
certfilename=certfilename, cert_pem=cert_pem, certpath=certpath,
|
||||
serverpins=serverpins, includesharedkey=includesharedkey,
|
||||
trustcertinroot=trustcertinroot)
|
||||
|
||||
buffer.seek(0)
|
||||
# Stamp the staged agent version into the download filename so a DT operator
|
||||
# can see which agent build a package carries at a glance. This is cosmetic:
|
||||
# install.ps1 finds the exe by its fixed name inside the ZIP, so the download
|
||||
# name never affects the install. Falls back to the unversioned name when no
|
||||
# managed version is current (only a legacy flat file is staged).
|
||||
agentversion = agentstore.current_version('agent')
|
||||
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
|
||||
if agentversion else 'shopdb-endpoint-agent.zip')
|
||||
return send_file(
|
||||
download_name = package_download_name()
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=download_name)
|
||||
if warnings:
|
||||
response.headers[CA_WARNING_HEADER] = ' '.join(warnings)
|
||||
return response
|
||||
|
||||
658
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
658
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
@@ -0,0 +1,658 @@
|
||||
# upgrade-shopdbagent.ps1
|
||||
# ShopDB Endpoint Agent SELF-UPGRADE template. Delivered to an endpoint by the
|
||||
# run_script command and executed by the agent as SYSTEM.
|
||||
#
|
||||
# WHY THIS TAKES NO PARAMETERS
|
||||
# run_script executes "powershell.exe -File <script>" with no argument list, so
|
||||
# there is no channel to pass BaseUrl/CollectorKey in. That is not a problem
|
||||
# here: an upgrade by definition targets an endpoint that is ALREADY installed
|
||||
# and therefore already holds its own config. This script READS that config and
|
||||
# never rewrites it - the values stay exactly as provisioned, so an upgrade can
|
||||
# not silently repoint an endpoint at a different server or key. A fresh
|
||||
# install is still install-shopdbagent.ps1's job.
|
||||
#
|
||||
# WHY IT RE-LAUNCHES ITSELF DETACHED
|
||||
# The agent process is what runs this script, and the upgrade must stop that
|
||||
# same agent to release the lock on shopdbagent.exe (single-file self-contained
|
||||
# exes are held open while running). If the script stayed a child of the agent,
|
||||
# stopping the service would kill the script mid-copy and leave the install
|
||||
# directory half-written. The first pass therefore copies itself to a temp
|
||||
# location, relaunches detached, and exits immediately so the agent sees a
|
||||
# clean, fast result. All real work happens in the detached second pass.
|
||||
#
|
||||
# REPORTING
|
||||
# Success is NOT reported through the command result - by the time the upgrade
|
||||
# succeeds, the agent that would have reported it has been restarted. The new
|
||||
# version is confirmed on the next check-in. This script's own transcript is
|
||||
# the local record; see $LogFile below.
|
||||
#
|
||||
# AUTH AND PINS
|
||||
# The download authenticates the way the agent does: the per-PC credential from
|
||||
# the agent's SYSTEM-only credential.bin when this PC has enrolled, otherwise
|
||||
# the shared CollectorKey. When ServerPins is set (registry, or serverpins in
|
||||
# appsettings.json) the server's TLS key must match a pin or nothing is
|
||||
# downloaded. A 1.19 agent upgrading to 1.20 has no credential yet and uses the
|
||||
# shared key; the new agent enrolls itself after it starts.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Set only when the script relaunches itself. Operators never pass this.
|
||||
[switch] $Detached
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ServiceName = 'ShopDbEndpointAgent'
|
||||
$InstallDir = 'C:\Program Files\ShopDB\EndpointAgent'
|
||||
$TrayExeName = 'shopdb-endpoint-tray.exe'
|
||||
$AgentExeName = 'shopdbagent.exe'
|
||||
|
||||
$StateDir = Join-Path $env:ProgramData 'ShopDB\EndpointAgent'
|
||||
$WorkDir = Join-Path $StateDir 'upgrade'
|
||||
$LogDir = Join-Path $StateDir 'logs'
|
||||
$LogFile = Join-Path $LogDir ('upgrade-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
|
||||
# Server-relative path of the managed agent bundle. This reuses the EXISTING
|
||||
# agent-facing app-package route rather than adding a parallel download: it
|
||||
# already authenticates with the collector key and already resolves an
|
||||
# application's installpath, which is precisely what upload registration
|
||||
# stamped for the agent bundle. The appid is substituted by the server when
|
||||
# this template is rendered, because it is assigned when the row is created.
|
||||
$PackagePath = '/api/endpoints/apppackage/app/{{AGENT_APPID}}'
|
||||
|
||||
function Write-Log {
|
||||
param([string] $Message, [string] $Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# The shared credential block below logs through Log, as the reporters do.
|
||||
function Log([string] $msg) { Write-Log $msg }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-PC credential and server pin. Kept in step with the same block in
|
||||
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
||||
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
||||
#
|
||||
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
||||
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
||||
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
||||
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
||||
#
|
||||
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
||||
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
||||
# is set the TLS handshake is accepted only if the leaf key matches one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
||||
|
||||
$ShopDbPinSource = @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinValidator
|
||||
{
|
||||
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public static string LastPresented;
|
||||
|
||||
public static void Install(string[] pins)
|
||||
{
|
||||
Pins.Clear();
|
||||
foreach (string pin in pins)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
||||
}
|
||||
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
||||
}
|
||||
|
||||
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
||||
if (certificate == null) { return false; }
|
||||
try
|
||||
{
|
||||
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LastPresented = null;
|
||||
return false;
|
||||
}
|
||||
return Pins.Contains(LastPresented);
|
||||
}
|
||||
|
||||
public static bool IsPinned(string pin)
|
||||
{
|
||||
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-ShopDbServerPins {
|
||||
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
||||
$pins = @()
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
foreach ($value in @($key.GetValue('ServerPins'))) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return ,$pins
|
||||
}
|
||||
|
||||
function Test-ShopDbServerPinsValue {
|
||||
# True when ServerPins exists in either registry view, even empty. The
|
||||
# registry then owns the pin list and appsettings pins are ignored, the
|
||||
# same rule the agent follows.
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Enable-ShopDbServerPins([string[]]$Pins) {
|
||||
# True when pinning is active. False means pins are set but could not be
|
||||
# enforced; the caller must not send anything then.
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
||||
# would silently not happen.
|
||||
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
||||
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
||||
}
|
||||
[ShopDbServerPinValidator]::Install($Pins)
|
||||
return $true
|
||||
} catch {
|
||||
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
||||
# Empty when there is none or it cannot be used for this URL.
|
||||
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||||
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
$credential = [string]$record.credential
|
||||
if (-not $credential.StartsWith('sdbe_')) {
|
||||
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
||||
return ''
|
||||
}
|
||||
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
||||
return ''
|
||||
}
|
||||
# Never hand a credential to a server other than the one that issued it.
|
||||
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
||||
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
||||
return ''
|
||||
}
|
||||
return $credential
|
||||
}
|
||||
|
||||
function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
# Status and error envelope code (data.error.code) from a failed request.
|
||||
$status = 0
|
||||
$code = ''
|
||||
$text = ''
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$text = $ErrorRecord.ErrorDetails.Message
|
||||
} else {
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
||||
$text = $reader.ReadToEnd()
|
||||
$reader.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
if ($text) {
|
||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||
}
|
||||
$presented = ''
|
||||
$matched = $false
|
||||
if ('ShopDbServerPinValidator' -as [type]) {
|
||||
$presented = [string][ShopDbServerPinValidator]::LastPresented
|
||||
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
|
||||
}
|
||||
# Innermost exception text: the outer WebException hides the real cause
|
||||
# (unexpected EOF, connection reset, trust failure).
|
||||
$message = ''
|
||||
$exception = $ErrorRecord.Exception
|
||||
while ($exception) {
|
||||
if ($exception.Message) { $message = $exception.Message }
|
||||
$exception = $exception.InnerException
|
||||
}
|
||||
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
|
||||
}
|
||||
|
||||
function Write-ShopDbAuthFailure($Failure) {
|
||||
switch ($Failure.Code) {
|
||||
'CREDENTIAL_INVALID' {
|
||||
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
||||
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
||||
}
|
||||
'SHARED_KEY_REFUSED' {
|
||||
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
||||
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
||||
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
||||
}
|
||||
'ASSET_MISMATCH' {
|
||||
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
||||
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
# A pin was seen, but only an absent pin is a pin failure. A
|
||||
# matched pin means the handshake passed and something later
|
||||
# failed (server dropped mid-transfer, timeout).
|
||||
if ($Failure.PinMatched) {
|
||||
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
|
||||
} else {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
||||
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
|
||||
# 64-bit agent's config is not, and the upgrade would look unconfigured.
|
||||
function Get-Hklm64Value {
|
||||
param([string] $SubKey, [string] $Name)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Dispose() }
|
||||
}
|
||||
finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
# Config resolution mirrors the agent's own order of preference, so the upgrade
|
||||
# reaches the same server the agent is actually talking to. Registry first;
|
||||
# appsettings.json is the fallback for locked-down hosts (CyberArk/EPM) that
|
||||
# gate the HKLM hive against reads.
|
||||
function Get-AgentConfig {
|
||||
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
|
||||
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
|
||||
|
||||
$pins = @()
|
||||
|
||||
# Read even when the registry is complete: serverpins lives only here.
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
|
||||
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
|
||||
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
|
||||
}
|
||||
foreach ($value in @($json.serverpins)) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$') { $pins += $pin }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) {
|
||||
throw 'No BaseUrl found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
}
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = [string]$apiKey; ServerPins = $pins }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First pass: relaunch detached and get out of the way. See header.
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not $Detached) {
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
|
||||
$selfCopy = Join-Path $WorkDir 'upgrade-shopdbagent.ps1'
|
||||
Copy-Item -Path $PSCommandPath -Destination $selfCopy -Force
|
||||
|
||||
# Copied out of the agent's own temp dir on purpose: the delivered script
|
||||
# may be cleaned up by the agent once the command returns, which would
|
||||
# delete this file out from under the detached run.
|
||||
Start-Process -FilePath 'powershell.exe' `
|
||||
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', `
|
||||
'-File', "`"$selfCopy`"", '-Detached' `
|
||||
-WindowStyle Hidden | Out-Null
|
||||
|
||||
Write-Host 'Upgrade handed off to a detached process; this agent will restart shortly.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Second pass (detached): the actual upgrade.
|
||||
# ---------------------------------------------------------------------------
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
|
||||
Write-Log '== ShopDB Endpoint Agent self-upgrade =='
|
||||
|
||||
try {
|
||||
$config = Get-AgentConfig
|
||||
Write-Log "server: $($config.BaseUrl)"
|
||||
|
||||
# 1. Download BEFORE stopping anything. A failed download must leave the
|
||||
# running agent untouched rather than stranding the endpoint with a
|
||||
# stopped service and no replacement binaries.
|
||||
$zipPath = Join-Path $WorkDir 'agent-package.zip'
|
||||
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
|
||||
$url = $config.BaseUrl + $PackagePath
|
||||
Write-Log "downloading package from $url ..."
|
||||
|
||||
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
|
||||
# protocols the server will refuse.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# Get-ShopDbServerPins already returns an array; wrapping it in @() made
|
||||
# several pins one joined string. Registry wins when the value exists,
|
||||
# even empty; appsettings pins apply only when it is absent.
|
||||
$serverPins = Get-ShopDbServerPins
|
||||
if (-not (Test-ShopDbServerPinsValue)) {
|
||||
foreach ($pin in $config.ServerPins) {
|
||||
if ($serverPins -notcontains $pin) { $serverPins += $pin }
|
||||
}
|
||||
}
|
||||
if ($serverPins.Count -gt 0) {
|
||||
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
||||
throw 'ServerPins is set but the pin check could not be enabled; not downloading.'
|
||||
}
|
||||
Write-Log "server pin check on ($($serverPins.Count) pin(s))"
|
||||
}
|
||||
|
||||
$credential = Get-ShopDbEndpointCredential $config.BaseUrl
|
||||
$attempts = @()
|
||||
if ($credential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $credential }) }
|
||||
if ($config.ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $config.ApiKey }) }
|
||||
if ($attempts.Count -eq 0) {
|
||||
throw 'No endpoint credential and no CollectorKey on this endpoint; cannot authenticate the download.'
|
||||
}
|
||||
|
||||
# Credential first. A refused credential (revoked) falls back to the shared
|
||||
# key once, which the server admits again for an un-enrolled PC.
|
||||
$downloaded = $false
|
||||
foreach ($attempt in $attempts) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $url -Headers $attempt[1] `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
Write-Log "authenticated with $($attempt[0])"
|
||||
$downloaded = $true
|
||||
break
|
||||
}
|
||||
catch {
|
||||
$failure = Get-ShopDbHttpFailure $_
|
||||
Write-Log "download with $($attempt[0]) failed: $($_.Exception.Message)" 'WARN'
|
||||
Write-ShopDbAuthFailure $failure
|
||||
if ($failure.Code -ne 'CREDENTIAL_INVALID') { throw }
|
||||
}
|
||||
}
|
||||
if (-not $downloaded) { throw 'Package download was refused.' }
|
||||
|
||||
$size = (Get-Item $zipPath).Length
|
||||
Write-Log "downloaded $size bytes"
|
||||
if ($size -lt 1MB) {
|
||||
throw "Package is only $size bytes; refusing to install a payload this small."
|
||||
}
|
||||
|
||||
# 2. Unpack to a staging dir and validate the payload BEFORE touching the
|
||||
# install. Expanding straight over Program Files would leave a partially
|
||||
# replaced install if the archive turned out to be short a binary.
|
||||
$stage = Join-Path $WorkDir 'stage'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
Expand-Archive -Path $zipPath -DestinationPath $stage -Force
|
||||
|
||||
$newAgent = Join-Path $stage $AgentExeName
|
||||
if (-not (Test-Path $newAgent)) {
|
||||
throw "$AgentExeName not found in the downloaded package; aborting before any change."
|
||||
}
|
||||
$newTray = Join-Path $stage $TrayExeName
|
||||
$haveTray = Test-Path $newTray
|
||||
Write-Log ("payload validated (agent{0})" -f $(if ($haveTray) { ' + tray' } else { ', no tray' }))
|
||||
|
||||
# 3. Keep the current binaries so a failed swap can be undone. The config
|
||||
# files are deliberately NOT part of this - they are never modified.
|
||||
$backup = Join-Path $WorkDir 'backup'
|
||||
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $backup | Out-Null
|
||||
foreach ($name in @($AgentExeName, $TrayExeName)) {
|
||||
$existing = Join-Path $InstallDir $name
|
||||
if (Test-Path $existing) { Copy-Item $existing -Destination $backup -Force }
|
||||
}
|
||||
Write-Log 'backed up current binaries'
|
||||
|
||||
# 4. Stop the service and kill lingering processes. Until this completes
|
||||
# the exes are locked and the copy would fail with a file-in-use error.
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -ne 'Stopped') {
|
||||
Write-Log "stopping service $ServiceName ..."
|
||||
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
|
||||
catch { Write-Log "could not stop service: $($_.Exception.Message)" 'WARN' }
|
||||
}
|
||||
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
foreach ($name in @('shopdbagent', $trayBase)) {
|
||||
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
|
||||
Write-Log "killing $($p.ProcessName) (pid $($p.Id))"
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 750
|
||||
|
||||
# 5. Swap the binaries. On failure, restore the backup and restart, so a
|
||||
# broken upgrade leaves a RUNNING old agent rather than a dead endpoint
|
||||
# that can no longer be reached to fix remotely.
|
||||
try {
|
||||
Copy-Item -Path $newAgent -Destination $InstallDir -Force
|
||||
if ($haveTray) { Copy-Item -Path $newTray -Destination $InstallDir -Force }
|
||||
Write-Log 'binaries replaced'
|
||||
}
|
||||
catch {
|
||||
Write-Log "copy failed: $($_.Exception.Message); rolling back" 'ERROR'
|
||||
Get-ChildItem $backup -File | ForEach-Object {
|
||||
Copy-Item $_.FullName -Destination $InstallDir -Force
|
||||
}
|
||||
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
# 6. Clear Mark-of-the-Web. Files extracted from a downloaded archive carry
|
||||
# a Zone.Identifier stream; SmartScreen blocks the unsigned exe on first
|
||||
# run, which would leave the service unable to start.
|
||||
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# 7. Restart and verify it stays up. A service that starts and immediately
|
||||
# faults is a failed upgrade, so confirm rather than assume.
|
||||
Write-Log "starting service $ServiceName ..."
|
||||
Start-Service -Name $ServiceName
|
||||
Start-Sleep -Seconds 5
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if (-not $svc -or $svc.Status -ne 'Running') {
|
||||
Write-Log 'service did not stay running; rolling back' 'ERROR'
|
||||
Get-ChildItem $backup -File | ForEach-Object {
|
||||
Copy-Item $_.FullName -Destination $InstallDir -Force
|
||||
}
|
||||
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
throw 'Upgraded service failed to start; rolled back to the previous build.'
|
||||
}
|
||||
|
||||
$installed = (Get-Item (Join-Path $InstallDir $AgentExeName)).VersionInfo.FileVersion
|
||||
Write-Log "upgrade complete; agent version now $installed"
|
||||
|
||||
# 8. Relaunch the tray for the interactive user.
|
||||
#
|
||||
# This CANNOT use the installer's `explorer.exe <path>` trick. That works in
|
||||
# install-shopdbagent.ps1 only because the installer runs AS THE USER - note
|
||||
# its own guard, `if ($interactiveUser -ne "SYSTEM")`. This script always
|
||||
# runs as SYSTEM in session 0, where `explorer.exe <path>` hands the request
|
||||
# to a shell that is not reachable from here and then exits 0. Start-Process
|
||||
# would report success and we would log a relaunch that never happened,
|
||||
# which is exactly the bug this replaces.
|
||||
#
|
||||
# It also must NOT descend into the session with a token-duplicating launch.
|
||||
# UserContextSpool documents that WTSQueryUserToken is a privileged operation
|
||||
# that endpoint protection blocks on these hardened hosts - the service tried
|
||||
# that direction before and it produced nothing on a live endpoint.
|
||||
#
|
||||
# So the tray is started the same way its own data path is pinned: against
|
||||
# the known ShopFloor account. Task Scheduler performs the session/token
|
||||
# transition, and an Interactive principal with no RunLevel starts the tray
|
||||
# at the user's normal integrity - preserving the de-elevation the installer
|
||||
# wanted, because an elevated unsigned tray trips the CyberArk/EPM broker.
|
||||
#
|
||||
# Best-effort by design: the agent is already upgraded and running, and the
|
||||
# HKLM Run key the installer wrote still starts the tray at next logon. A
|
||||
# late tray is not worth failing - or rolling back - a good agent upgrade.
|
||||
if ($haveTray) {
|
||||
$trayPath = Join-Path $InstallDir $TrayExeName
|
||||
# Same pinned account the spool uses (FileLoggerProvider.ShopFloorUserName):
|
||||
# every shop-floor endpoint signs in as this local account, and the tray
|
||||
# must run as it or the mapped-drive probe reports the wrong account's
|
||||
# (empty) drive table. Only relaunch if that profile is actually present.
|
||||
$trayUser = 'ShopFloor'
|
||||
$trayUserProfile = Join-Path $env:SystemDrive (Join-Path 'Users' $trayUser)
|
||||
if (Test-Path $trayUserProfile) {
|
||||
$taskName = 'ShopDB tray upgrade relaunch'
|
||||
try {
|
||||
# Clear a leftover from an interrupted earlier upgrade so we do
|
||||
# not stack duplicates.
|
||||
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $trayPath
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $trayUser -LogonType Interactive
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
|
||||
# Verify rather than assume. The old code logged success off a
|
||||
# Start-Process that could not fail, so a missing tray looked
|
||||
# like a clean upgrade. Poll for the real process instead.
|
||||
$trayBaseName = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
$running = $null
|
||||
for ($i = 0; $i -lt 10; $i++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$running = Get-Process -Name $trayBaseName -ErrorAction SilentlyContinue
|
||||
if ($running) { break }
|
||||
}
|
||||
if ($running) { Write-Log "tray relaunched as $trayUser" }
|
||||
else {
|
||||
Write-Log ('tray did not appear after relaunch; it will start at next ' +
|
||||
'logon via the HKLM Run key') 'WARN'
|
||||
}
|
||||
|
||||
# One-shot hand-off, not a persistent artifact: leaving it would
|
||||
# surface in the uninstaller's leftover sweep and in task audits.
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
Write-Log ("could not relaunch tray: $($_.Exception.Message); " +
|
||||
'it will start at next logon') 'WARN'
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log ("no $trayUser profile at $trayUserProfile; " +
|
||||
'leaving the tray to start at next logon') 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "UPGRADE FAILED: $($_.Exception.Message)" 'ERROR'
|
||||
exit 1
|
||||
}
|
||||
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
@@ -0,0 +1,157 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Print the SPKI SHA-256 pin of a ShopDB server certificate, for building the
|
||||
Set-ShopDBServerPins.ps1 entry.
|
||||
|
||||
.DESCRIPTION
|
||||
The pin is the lowercase hex SHA-256 of the leaf certificate's
|
||||
SubjectPublicKeyInfo: the same value the Server Certificate settings page
|
||||
shows and the endpoint agent compares. It depends only on the key pair, so
|
||||
a certificate renewed with the same key keeps its pin.
|
||||
|
||||
Give it either a URL (the certificate the server actually presents is read
|
||||
from the TLS handshake; it is NOT trusted or validated, only hashed) or a
|
||||
certificate file (.cer, .crt, .pem, DER or PEM). Use the file form to pin a
|
||||
new certificate BEFORE it goes live on the server.
|
||||
|
||||
Reading from a URL trusts the network path at that moment. Compare the
|
||||
printed pin with the one on the Server Certificate settings page before
|
||||
rolling it out.
|
||||
|
||||
.PARAMETER Url
|
||||
https://<server>[:port]. Only the host and port are used.
|
||||
|
||||
.PARAMETER Path
|
||||
A certificate file.
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Url https://<server>
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Path .\server.crt
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Url')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Url', Position = 0)]
|
||||
[string]$Url,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
|
||||
[string]$Path,
|
||||
|
||||
[int]$TimeoutSec = 15
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$source = @'
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinReader
|
||||
{
|
||||
public static byte[] FetchLeafCertificate(string host, int port, int timeoutMilliseconds)
|
||||
{
|
||||
using (TcpClient client = new TcpClient())
|
||||
{
|
||||
IAsyncResult pending = client.BeginConnect(host, port, null, null);
|
||||
if (!pending.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
|
||||
{
|
||||
throw new TimeoutException("Timed out connecting to " + host + ":" + port);
|
||||
}
|
||||
client.EndConnect(pending);
|
||||
client.ReceiveTimeout = timeoutMilliseconds;
|
||||
client.SendTimeout = timeoutMilliseconds;
|
||||
|
||||
byte[] captured = null;
|
||||
RemoteCertificateValidationCallback capture = delegate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (certificate != null) { captured = certificate.GetRawCertData(); }
|
||||
return true;
|
||||
};
|
||||
using (SslStream ssl = new SslStream(client.GetStream(), false, capture))
|
||||
{
|
||||
ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false);
|
||||
}
|
||||
if (captured == null) { throw new InvalidOperationException("The server presented no certificate"); }
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
if (-not ('ShopDbServerPinReader' -as [type])) {
|
||||
Add-Type -TypeDefinition $source
|
||||
}
|
||||
|
||||
try {
|
||||
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
||||
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $resolved
|
||||
$der = $certificate.RawData
|
||||
$label = "$resolved (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
} else {
|
||||
$uri = [Uri]$Url
|
||||
if ($uri.Scheme -ne 'https') { throw "The URL must be https ($Url)" }
|
||||
$der = [ShopDbServerPinReader]::FetchLeafCertificate($uri.Host, $uri.Port, $TimeoutSec * 1000)
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (,$der)
|
||||
$label = "$($uri.Host):$($uri.Port) (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
}
|
||||
$pin = [ShopDbServerPinReader]::SpkiSha256Hex($der)
|
||||
} catch {
|
||||
Write-Error "Could not read a certificate: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Certificate: $label"
|
||||
Write-Host "Pin (SPKI SHA-256):"
|
||||
Write-Output $pin
|
||||
exit 0
|
||||
194
plugins/endpoints/client/Install-ShopDBServerCertificate.ps1
Normal file
194
plugins/endpoints/client/Install-ShopDBServerCertificate.ps1
Normal file
@@ -0,0 +1,194 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Trust the ShopDB server's own certificate on this PC by adding it to
|
||||
LocalMachine\Root, after checking it is the expected certificate and not a
|
||||
certificate authority.
|
||||
|
||||
.DESCRIPTION
|
||||
WHY: endpoint agents older than 1.20 check the normal Windows certificate
|
||||
chain when they download the upgrade package. They know nothing about
|
||||
ServerPins. A server with a self-signed certificate is therefore refused
|
||||
by those agents, and the upgrade to 1.20 never lands. The certificate has
|
||||
to be trusted on the PC BEFORE a store wave queues the upgrade.
|
||||
|
||||
PCs installed by the server's generated installer already trust it. PCs
|
||||
installed from the share do not, and need this entry.
|
||||
|
||||
Adding a non-CA leaf certificate to Root trusts exactly that one server.
|
||||
It cannot be used to vouch for any other site, so the blast radius is the
|
||||
server itself. A CA certificate in Root would trust everything that CA
|
||||
signs, which is why a CA is refused unless -AllowCa is passed.
|
||||
|
||||
Get the .cer from Settings > Endpoints > Server certificate (download), or
|
||||
GET /api/endpoints/settings/server-cert/download. The expected thumbprint
|
||||
is the sha1thumbprint field of GET /api/endpoints/settings/server-cert.
|
||||
|
||||
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, in the same
|
||||
targeting as the Set-ShopDBServerPins.ps1 entry. Idempotent: the Root store
|
||||
is checked first and nothing is written when the certificate is already
|
||||
there, so it is safe every cycle. Nothing else is removed or changed.
|
||||
|
||||
.PARAMETER CertPath
|
||||
Path to the server certificate file, DER or PEM (.cer / .crt / .pem).
|
||||
|
||||
.PARAMETER Thumbprint
|
||||
Expected SHA-1 thumbprint of the certificate. Spaces, colons and case are
|
||||
ignored. The file is refused when it does not match.
|
||||
|
||||
.PARAMETER AllowCa
|
||||
Import even when the certificate is a CA (basicConstraints CA=true). Only
|
||||
for a site that deliberately trusts its own private CA.
|
||||
|
||||
.PARAMETER LogPath
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-ShopDBServerCertificate.ps1 -CertPath <share>\shopdb-server.cer -Thumbprint <sha1thumbprint>
|
||||
Trust the server certificate on this PC.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint <sha1thumbprint> -WhatIf
|
||||
Show what would be imported without touching the store.
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 imported, or already trusted
|
||||
1 bad input, mismatch, refused CA, or an error stopped the import
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CertPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Thumbprint,
|
||||
[switch]$AllowCa,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = "{0} [Install-ShopDBServerCertificate] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Output $line
|
||||
if ($LogPath) {
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -WhatIf:$false -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Read-Certificate {
|
||||
param([string]$Path)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
|
||||
$match = [regex]::Match($text, '-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----',
|
||||
[System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||
if ($match.Success) {
|
||||
$bytes = [Convert]::FromBase64String(($match.Groups[1].Value -replace '\s', ''))
|
||||
}
|
||||
return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,[byte[]]$bytes)
|
||||
}
|
||||
|
||||
# --- 1. validate the request ------------------------------------------------
|
||||
|
||||
$expected = ($Thumbprint -replace '[\s:]', '').ToUpperInvariant()
|
||||
if ($expected -notmatch '^[0-9A-F]{40}$') {
|
||||
Log "ERROR '$Thumbprint' is not a SHA-1 thumbprint (40 hex characters); nothing imported"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CertPath -PathType Leaf)) {
|
||||
Log "ERROR certificate file not found: $CertPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$cert = Read-Certificate -Path $CertPath
|
||||
} catch {
|
||||
Log "ERROR could not read a certificate from $CertPath : $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$actual = $cert.Thumbprint.ToUpperInvariant()
|
||||
Log "file: $CertPath"
|
||||
Log "subject: $($cert.Subject); issuer: $($cert.Issuer); expires: $($cert.NotAfter.ToString('yyyy-MM-dd'))"
|
||||
if ($actual -ne $expected) {
|
||||
Log "ERROR thumbprint of the file is $actual, expected $expected; refusing to import"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($cert.HasPrivateKey) {
|
||||
Log 'ERROR the file carries a private key; export the public certificate only'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 2. refuse a CA ---------------------------------------------------------
|
||||
|
||||
$isCa = $false
|
||||
foreach ($extension in $cert.Extensions) {
|
||||
if ($extension.Oid.Value -eq '2.5.29.19') {
|
||||
$constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$extension
|
||||
$isCa = $constraints.CertificateAuthority
|
||||
}
|
||||
}
|
||||
if ($isCa) {
|
||||
if (-not $AllowCa) {
|
||||
Log ('ERROR the certificate is a CA (basicConstraints CA=true). In Root it would trust every ' +
|
||||
'certificate it signs, not just the ShopDB server. Refusing; pass -AllowCa only if that is intended.')
|
||||
exit 1
|
||||
}
|
||||
Log 'WARN the certificate is a CA and -AllowCa was passed; everything it signs will be trusted'
|
||||
} else {
|
||||
Log 'not a CA (basicConstraints CA absent or false); trusts only this server'
|
||||
}
|
||||
|
||||
if ($cert.Subject -ne $cert.Issuer) {
|
||||
Log 'WARN the certificate is not self-signed; Windows may still need its issuer to build the chain'
|
||||
}
|
||||
if ($cert.NotAfter -lt (Get-Date)) {
|
||||
Log 'WARN the certificate has expired; the server needs a new one, importing anyway'
|
||||
}
|
||||
|
||||
# --- 3. import into LocalMachine\Root ---------------------------------------
|
||||
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
|
||||
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
|
||||
try {
|
||||
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
|
||||
try {
|
||||
$present = $store.Certificates.Find(
|
||||
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
|
||||
$alreadyTrusted = $present.Count -gt 0
|
||||
} finally { $store.Close() }
|
||||
|
||||
if ($alreadyTrusted) {
|
||||
Log "LocalMachine\Root already holds $actual; nothing to do"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess("Cert:\LocalMachine\Root", "Import $actual ($($cert.Subject))")) {
|
||||
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
||||
try { $store.Add($cert) } finally { $store.Close() }
|
||||
|
||||
# Read back rather than trust Add.
|
||||
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
|
||||
try {
|
||||
$after = $store.Certificates.Find(
|
||||
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
|
||||
} finally { $store.Close() }
|
||||
if ($after.Count -eq 0) {
|
||||
Log "ERROR $actual did not read back from LocalMachine\Root after import"
|
||||
exit 1
|
||||
}
|
||||
Log "imported $actual into LocalMachine\Root"
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR $($_.Exception.Message)"
|
||||
exit 1
|
||||
} finally {
|
||||
$store.Close()
|
||||
}
|
||||
|
||||
exit 0
|
||||
212
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
212
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
@@ -0,0 +1,212 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write the ShopDB server public key pins to HKLM:\SOFTWARE\GE\ShopDB
|
||||
ServerPins, so the endpoint agent and the SYSTEM reporters trust only this
|
||||
site's server key.
|
||||
|
||||
.DESCRIPTION
|
||||
Each pin is the lowercase hex SHA-256 of the server leaf certificate's
|
||||
SubjectPublicKeyInfo. The ShopDB Server Certificate settings page shows the
|
||||
current one, and Get-ShopDBServerPin.ps1 computes one from a URL or a
|
||||
certificate file.
|
||||
|
||||
Written as REG_MULTI_SZ in BOTH registry views (64-bit and WOW6432Node),
|
||||
the same way CollectorKey is, so a 32-bit reader sees the same set.
|
||||
|
||||
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, before agent
|
||||
1.20 reaches the PC. Idempotent: the set on the PC is compared with the
|
||||
requested set first, and nothing is written when they already match, so it
|
||||
is safe every cycle.
|
||||
|
||||
THE REGISTRY WINS. When ServerPins exists in either view, even empty, the
|
||||
agent uses it alone and ignores serverpins / servercertthumbprint in
|
||||
appsettings.json (the installer and store upgrade write those). Only when
|
||||
the value is absent in both views do the appsettings pins apply.
|
||||
|
||||
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has
|
||||
both, switch the server certificate, then write ONLY the new pin to retire
|
||||
the old one. A PC holding only the old pin refuses the new certificate.
|
||||
|
||||
.PARAMETER Pins
|
||||
One or more pins separated by commas, semicolons or spaces. Colons and case
|
||||
are ignored. Every value must be 64 hex characters or nothing is written.
|
||||
|
||||
.PARAMETER Clear
|
||||
Delete ServerPins from both views. The agent then falls back to the pins
|
||||
in appsettings.json; with none there, normal Windows certificate checks.
|
||||
|
||||
.PARAMETER None
|
||||
Write an EMPTY ServerPins in both views. The registry stays authoritative
|
||||
with no pins, so appsettings.json pins are ignored and the normal Windows
|
||||
certificate checks apply.
|
||||
|
||||
.PARAMETER LogPath
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins <pin>
|
||||
Pin one server key.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
|
||||
Show what a key rotation would write without touching the registry.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins <newpin>
|
||||
After the server switched certificates: retire the old pin.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -None
|
||||
Turn pinning off on a PC whose appsettings.json still carries pins.
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 written, or already correct
|
||||
1 bad input or an error stopped the write
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$Pins,
|
||||
[switch]$Clear,
|
||||
[switch]$None,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$SubKey = 'SOFTWARE\GE\ShopDB'
|
||||
$ValueName = 'ServerPins'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = "{0} [Set-ShopDBServerPins] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Output $line
|
||||
if ($LogPath) {
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Returns @{ Present; Pins }. Present = the value exists, even empty.
|
||||
function Get-CurrentPins {
|
||||
param([Microsoft.Win32.RegistryView]$View)
|
||||
$result = @{ Present = $false; Pins = @() }
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return $result }
|
||||
try {
|
||||
$value = $key.GetValue($ValueName)
|
||||
if ($null -eq $value) { return $result }
|
||||
$result.Present = $true
|
||||
$result.Pins = @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
return $result
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
function Test-SamePins {
|
||||
param([string[]]$Left, [string[]]$Right)
|
||||
$a = @($Left | Sort-Object -Unique)
|
||||
$b = @($Right | Sort-Object -Unique)
|
||||
if ($a.Count -ne $b.Count) { return $false }
|
||||
for ($i = 0; $i -lt $a.Count; $i++) {
|
||||
if ($a[$i] -ne $b[$i]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# --- 1. validate the request ------------------------------------------------
|
||||
|
||||
$requested = @()
|
||||
if (@(@($Clear.IsPresent, $None.IsPresent, [bool]$Pins) | Where-Object { $_ }).Count -gt 1) {
|
||||
Log 'ERROR use only one of -Pins, -Clear, -None'
|
||||
exit 1
|
||||
}
|
||||
if ($None) {
|
||||
Log 'requested an empty ServerPins (registry authoritative, no pins)'
|
||||
} elseif (-not $Clear) {
|
||||
if (-not $Pins -or -not $Pins.Trim()) {
|
||||
Log 'ERROR no -Pins given (-Clear deletes the value, -None writes it empty)'
|
||||
exit 1
|
||||
}
|
||||
foreach ($raw in ($Pins -split '[,;\s]+')) {
|
||||
if (-not $raw) { continue }
|
||||
$pin = ($raw -replace ':', '').ToLowerInvariant()
|
||||
if ($pin -notmatch '^[0-9a-f]{64}$') {
|
||||
Log "ERROR '$raw' is not a SHA-256 pin (64 hex characters); nothing written"
|
||||
exit 1
|
||||
}
|
||||
if ($requested -notcontains $pin) { $requested += $pin }
|
||||
}
|
||||
if ($requested.Count -eq 0) {
|
||||
Log 'ERROR -Pins held no values; nothing written'
|
||||
exit 1
|
||||
}
|
||||
Log "requested $($requested.Count) pin(s): $($requested -join ', ')"
|
||||
}
|
||||
|
||||
# --- 2. write each view -----------------------------------------------------
|
||||
|
||||
$views = @(
|
||||
@{ Name = '64-bit'; View = [Microsoft.Win32.RegistryView]::Registry64 },
|
||||
@{ Name = '32-bit'; View = [Microsoft.Win32.RegistryView]::Registry32 }
|
||||
)
|
||||
|
||||
$failed = $false
|
||||
foreach ($entry in $views) {
|
||||
try {
|
||||
$state = Get-CurrentPins -View $entry.View
|
||||
$current = @($state.Pins)
|
||||
|
||||
if ($Clear) {
|
||||
if (-not $state.Present) {
|
||||
Log "$($entry.Name): no ServerPins; nothing to do"
|
||||
continue
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", "Remove $ValueName")) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey, $true)
|
||||
if ($key) {
|
||||
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ($state.Present -and (Test-SamePins -Left $current -Right $requested)) {
|
||||
Log "$($entry.Name): ServerPins already correct; nothing to do"
|
||||
continue
|
||||
}
|
||||
|
||||
$action = "Set $ValueName to $($requested.Count) pin(s) (was $($current.Count))"
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", $action)) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {
|
||||
$key.SetValue($ValueName, [string[]]$requested, [Microsoft.Win32.RegistryValueKind]::MultiString)
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
|
||||
# Read back rather than trust SetValue.
|
||||
$after = Get-CurrentPins -View $entry.View
|
||||
if (-not $after.Present -or -not (Test-SamePins -Left @($after.Pins) -Right $requested)) {
|
||||
Log "ERROR $($entry.Name): ServerPins did not read back as written"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Log "$($entry.Name): wrote ServerPins ($($requested.Count) pin(s), was $($current.Count))"
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR $($entry.Name): $($_.Exception.Message)"
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed) { exit 1 }
|
||||
exit 0
|
||||
@@ -77,14 +77,65 @@ COMMAND_ALLOWLIST = {
|
||||
'label': 'Collect now',
|
||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
||||
},
|
||||
'network_connections': {
|
||||
'label': 'Show network connections',
|
||||
'description': 'Return the full TCP connection table with owning processes. '
|
||||
'Read-only diagnostic; the standard report carries only '
|
||||
'listening ports because established connections churn.',
|
||||
},
|
||||
'scheduledtask_run': {
|
||||
'label': 'Run scheduled task',
|
||||
'description': 'Start an already-registered Windows scheduled task on '
|
||||
'the endpoint by its task path (for example '
|
||||
'\\ReportIP). The agent does not create, modify, or '
|
||||
'define tasks - it only starts one that already exists, '
|
||||
'and only when the path clears the agent-side approved '
|
||||
'task allowlist. The task runs in whatever context its '
|
||||
'own registered definition specifies, not the agent\'s.',
|
||||
'targetkind': 'scheduledtask',
|
||||
},
|
||||
'run_script': {
|
||||
'label': 'Run script',
|
||||
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
|
||||
'fetches the script body by digest using its collector '
|
||||
'credential and runs it in the script\'s configured '
|
||||
'context (system or the logged-on user).',
|
||||
'context (system or the logged-on user). CAUTION: a '
|
||||
'script sent this way runs as SYSTEM unless its '
|
||||
'descriptor pins the user context, so this is arbitrary '
|
||||
'code execution on the endpoint by design. Grant '
|
||||
'endpoints.runscript only to the most trusted '
|
||||
'operators, '
|
||||
'and gate the UI behind an explicit confirm the way '
|
||||
'system_restart is gated (see confirmRestart in '
|
||||
'PCDetail.vue).',
|
||||
'targetkind': 'script',
|
||||
},
|
||||
'collect_log': {
|
||||
'label': 'Collect log file',
|
||||
'description': 'Read a plaintext log file from a known path on the '
|
||||
'endpoint and return its contents to ShopDB for '
|
||||
'viewing and download. Read-only: the agent opens the '
|
||||
'file for reading with sharing permitted (so a log a '
|
||||
'running application holds open can still be read), '
|
||||
'never writes, and never deletes. Only the TAIL of a '
|
||||
'file is returned, and only for plaintext extensions - '
|
||||
'a binary or oversized file is refused rather than '
|
||||
'mangled. Gated on endpoints.updateapp because the '
|
||||
'reason to collect a log is almost always to diagnose '
|
||||
'an application push, so it belongs with app lifecycle '
|
||||
'rights rather than as a separate grant. Note this '
|
||||
'still reads a caller-supplied path as SYSTEM: it is '
|
||||
'bounded to text files, but it is not a sandbox.',
|
||||
'targetkind': 'logpath',
|
||||
# Introduced in agent 1.16.0. An older agent has no case for this action
|
||||
# and falls through to its unknown-action default, which answers
|
||||
# "Unknown or disallowed action" - a message that reads as an
|
||||
# authorization refusal and sends the operator looking at permissions
|
||||
# they already hold. Declaring the floor here lets the enqueue route
|
||||
# refuse first, naming both versions, so the answer is the actionable
|
||||
# one: this endpoint needs a newer agent.
|
||||
'minagentversion': '1.16.0',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||
@@ -96,13 +147,89 @@ COMMAND_ALLOWLIST = {
|
||||
}
|
||||
|
||||
|
||||
# Dell Command | Update: TWO utilities, deliberately two separate actions.
|
||||
# A scan reports what is available and changes nothing; an update installs and
|
||||
# can reboot. Collapsing them into one parameterized action would make the
|
||||
# destructive one reachable by editing a field, so they stay distinct and the
|
||||
# UI can gate them differently.
|
||||
#
|
||||
# Both are driven through scheduledtask_run against ShopDB-owned task
|
||||
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
|
||||
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
|
||||
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
|
||||
# output path, which is why no new agent code is needed to run them.
|
||||
#
|
||||
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
|
||||
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
|
||||
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
|
||||
# the default; keeping ShopDB's output separate is the only thing that makes
|
||||
# "did ShopDB ask for this scan, or did corporate" answerable when the results
|
||||
# are collected. The path lives in the task definition and is generated
|
||||
# server-side, so it can change without touching the agent.
|
||||
#
|
||||
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
|
||||
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
|
||||
# means "scan started", never "scan finished" - the command result cannot carry
|
||||
# findings. Reporting available updates back into ShopDB needs a collector that
|
||||
# reads the report from the path above and folds it into the telemetry payload.
|
||||
# That is a separate change from this one; do not infer from a green command
|
||||
# result that the data path exists.
|
||||
|
||||
|
||||
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
|
||||
# placement note so the next person adds it here rather than inventing a new
|
||||
# transport. When it lands it will re-establish a Disconnected mapping surfaced
|
||||
# by the user-context card:
|
||||
#
|
||||
# 'remap_drive': {
|
||||
# 'label': 'Reconnect mapped drive',
|
||||
# 'description': 'Re-establish a disconnected network drive mapping.',
|
||||
# 'targetkind': 'drive',
|
||||
# },
|
||||
#
|
||||
# It MUST execute in the interactive USER session. A SYSTEM-context remap creates
|
||||
# a mapping in SYSTEM's own drive table that the operator cannot see, so the card
|
||||
# would keep reporting the drive as broken while the "fix" silently succeeded
|
||||
# somewhere nobody is looking. An ELEVATED remap fails the same way, because UAC
|
||||
# token separation gives the elevated context a different drive table - the same
|
||||
# reason the probe itself runs unelevated from the tray.
|
||||
#
|
||||
# CommandExecutor.RunScriptInUserSession is the existing path for this.
|
||||
|
||||
|
||||
# Actions that demand a permission stricter than the base endpoints.command.
|
||||
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
|
||||
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
||||
#
|
||||
# These four are gated by SCOPE, never by Configuration Policy. Policy shapes
|
||||
# what a PC TYPE exposes; it is not the authority on whether a given person may
|
||||
# reboot a machine or run arbitrary code. The check runs in the enqueue route
|
||||
# BEFORE the policy gate, so it is also not break-glass-able: an admin may
|
||||
# override what policy says about a machine, never what permissions say about
|
||||
# the caller.
|
||||
#
|
||||
# Previously scheduledtask_run, run_script and update_app all mapped to a single
|
||||
# endpoints.deploy grant. That bundle made app updates impossible to grant
|
||||
# without also granting arbitrary SYSTEM code execution via run_script, so they
|
||||
# are now four independent permissions. They are NOT equal risk, and the
|
||||
# descriptions in plugin.py get_permissions() say so plainly:
|
||||
# scheduledtask_run - starts something already registered and allowlisted
|
||||
# agent-side
|
||||
# update_app - runs a pinned installer
|
||||
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
||||
# user context
|
||||
#
|
||||
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
|
||||
# The reason to read a log is nearly always to find out why an app push behaved
|
||||
# the way it did, so the right to install an application and the right to read
|
||||
# that application's log are the same job. It is read-only and strictly weaker
|
||||
# than the installer push the same permission already allows.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
'run_script': 'endpoints.deploy',
|
||||
'update_app': 'endpoints.deploy',
|
||||
'scheduledtask_run': 'endpoints.corptools',
|
||||
'run_script': 'endpoints.runscript',
|
||||
'update_app': 'endpoints.updateapp',
|
||||
'collect_log': 'endpoints.updateapp',
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +238,70 @@ def elevated_permission_for(action):
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
def minimum_agent_version_for(action):
|
||||
"""The lowest agent version that implements an action, or None if any will do."""
|
||||
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
|
||||
|
||||
|
||||
def action_minimum_versions():
|
||||
"""Every action that has an agent version floor, as {action: version}.
|
||||
|
||||
Actions without a floor are omitted rather than mapped to None: a caller
|
||||
asking "is there a minimum for this?" gets a plain absent key, and the
|
||||
payload stays small as the allowlist grows.
|
||||
"""
|
||||
return {
|
||||
action: spec['minagentversion']
|
||||
for action, spec in COMMAND_ALLOWLIST.items()
|
||||
if spec.get('minagentversion')
|
||||
}
|
||||
|
||||
|
||||
def parse_agent_version(value):
|
||||
"""A reported agent version as a comparable tuple, or None if unusable.
|
||||
|
||||
Agents report four parts ('1.15.0.0') while the csproj and the binary store
|
||||
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
|
||||
the floor it satisfies. Both are normalised to a tuple and compared
|
||||
numerically, with missing trailing parts treated as zero, so '1.16.0' and
|
||||
'1.16.0.0' are the same version.
|
||||
|
||||
Anything non-numeric returns None rather than a guess. The fleet carries
|
||||
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
|
||||
placeholder and a real build - the caller decides what to do about an
|
||||
unknown version, which is a different question from being out of date.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
parts = []
|
||||
for chunk in str(value).strip().split('.'):
|
||||
if not chunk.isdigit():
|
||||
return None
|
||||
parts.append(int(chunk))
|
||||
if not parts:
|
||||
return None
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts[:4])
|
||||
|
||||
|
||||
def agent_meets_minimum(reported, minimum):
|
||||
"""Whether a reported agent version satisfies a minimum.
|
||||
|
||||
Returns False when either side is unparseable. That is the fail-closed
|
||||
direction: an endpoint whose version we cannot read is one we cannot claim
|
||||
supports the action, and letting it through only moves the failure to the
|
||||
agent where the error message is worse.
|
||||
"""
|
||||
if not minimum:
|
||||
return True
|
||||
reportedparts = parse_agent_version(reported)
|
||||
minimumparts = parse_agent_version(minimum)
|
||||
if reportedparts is None or minimumparts is None:
|
||||
return False
|
||||
return reportedparts >= minimumparts
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
@@ -215,6 +406,52 @@ def monitor_id_from_action(action):
|
||||
return None
|
||||
|
||||
|
||||
# Actions that are NOT a per-endpoint configuration concern.
|
||||
#
|
||||
# Membership here is a statement about the action's nature, not its risk. An
|
||||
# action belongs in this set when Configuration Policy has nothing meaningful to
|
||||
# say about it: it invokes an already-registered, agent-allowlisted task rather
|
||||
# than describing how a machine should be shaped. Policy exists to shape what a
|
||||
# given PC TYPE exposes - which monitors run, which service primitives are
|
||||
# reachable - so a policy that never mentions such an action must not be read as
|
||||
# removing it.
|
||||
#
|
||||
# This is deliberately narrow. Do not add an action here merely because a denial
|
||||
# is inconvenient; the correct remedy for an over-narrow policy is to fix the
|
||||
# policy, or to use the audited admin break-glass override at the enqueue route.
|
||||
#
|
||||
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
|
||||
# silently drops these actions on any endpoint whose effective policy is
|
||||
# non-empty, producing "Action not permitted by policy for this endpoint" even
|
||||
# for a caller who holds the matching grant. Exemption from POLICY is not
|
||||
# exemption from PERMISSION: each of these actions still carries its own entry
|
||||
# in ELEVATED_ACTION_PERMISSIONS, enforced per-action in the enqueue route
|
||||
# before policy is ever consulted.
|
||||
POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
'scheduledtask_run',
|
||||
# Read-only telemetry refresh. Policy shapes WHAT a PC type exposes; asking
|
||||
# an agent to report its existing state off-cadence changes nothing about
|
||||
# the machine, so a policy that never mentions it must not be read as
|
||||
# removing it. Without this, "Refresh Status" dies on every endpoint whose
|
||||
# policy is non-empty - a denial no operator can act on, because the fix
|
||||
# lives in a policy that was never about this action.
|
||||
'collect_now',
|
||||
# Read-only diagnostic retrieval. Policy describes how a machine should be
|
||||
# shaped - which monitors run, which service primitives are reachable - and
|
||||
# reading a log file shapes nothing. An existing policy was authored before
|
||||
# this action existed and so can never mention it, which under the
|
||||
# intersection means every endpoint carrying any policy at all refuses log
|
||||
# collection, including for an admin. That denial is also unactionable in
|
||||
# the way that matters: logs are what an operator reaches for when an
|
||||
# endpoint is already misbehaving, so gating them behind editing that same
|
||||
# endpoint's policy withholds the evidence exactly when it is needed.
|
||||
# Exemption from POLICY is not exemption from PERMISSION - collect_log
|
||||
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
|
||||
# the agent still refuses UNC, traversal, directories and non-text files.
|
||||
'collect_log',
|
||||
})
|
||||
|
||||
|
||||
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
"""The action names an endpoint may run.
|
||||
|
||||
@@ -223,8 +460,15 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
result is narrowed to the actions the endpoint's effective policy permits -
|
||||
this is the allowlist-gating half of the policy design. With the feature off
|
||||
(or no assetid), it returns the full base allowlist, so nothing changes until
|
||||
a site opts in. Policy resolution is imported lazily to keep this module free
|
||||
of a hard dependency on the computers plugin.
|
||||
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away,
|
||||
because policy is not the authority on them; they remain gated by their own
|
||||
entry in ELEVATED_ACTION_PERMISSIONS at the enqueue route. Policy resolution
|
||||
is imported lazily to keep this module free of a hard dependency on the
|
||||
computers plugin.
|
||||
|
||||
This answers allowlist questions only. It does not consult desired-state
|
||||
holds, and it is not a permission check - callers must still enforce
|
||||
ELEVATED_ACTION_PERMISSIONS themselves.
|
||||
"""
|
||||
base = set(COMMAND_ALLOWLIST.keys())
|
||||
if assetid is None:
|
||||
@@ -247,6 +491,10 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
# "not permitted by policy". Expand each monitor:<id> to its kind's control
|
||||
# actions before intersecting.
|
||||
allowed = allowed | _monitor_control_actions(allowed)
|
||||
# Exempt actions are added back unconditionally: policy narrows the
|
||||
# per-endpoint surface, but it is not the authority on these actions. Their
|
||||
# own elevated permission still gates them in the enqueue route.
|
||||
allowed = allowed | POLICY_EXEMPT_ACTIONS
|
||||
# Never widen past the code-level allowlist: policy can only gate within it.
|
||||
return base & allowed
|
||||
|
||||
@@ -282,3 +530,256 @@ def _monitor_control_actions(action_names):
|
||||
granted |= {'service_start', 'service_stop', 'service_status'}
|
||||
return granted
|
||||
|
||||
|
||||
def PLACE_POLICY_HOLD(assetid, action, reason, userid=None, username=None,
|
||||
minutes=None):
|
||||
"""Suspend desired-state reconciliation of one action on one endpoint.
|
||||
|
||||
Called after an admin break-glasses past a policy denial, or directly from
|
||||
the admin hold dialog. Without this, an override on a desired-mode action is
|
||||
undone by the next reconcile pass: the admin stops the service, the resolver
|
||||
sees drift, and the command comes straight back. Gate-mode overrides need no
|
||||
hold - nothing reverses them.
|
||||
|
||||
minutes=None takes DEFAULT_HOLD_MINUTES. minutes=0 places an INDEFINITE hold
|
||||
(expiresat null) that never clears itself and stands until released by hand.
|
||||
Nothing surfaces a stale indefinite hold, so it suppresses reconciliation of
|
||||
that action silently and forever - deliberate, but the sharpest edge here.
|
||||
|
||||
Idempotent on an action already actively held: re-holding returns True
|
||||
without stacking a second row. The multi-select picker makes "select all"
|
||||
natural, and duplicate rows would mean release had to be pressed once per
|
||||
hidden duplicate to actually resume policy.
|
||||
|
||||
Returns True when a hold is in place for the action afterwards. Resolved
|
||||
lazily against the computers plugin, matching ALLOWED_ACTIONS_FOR, so this
|
||||
module keeps no hard dependency on it: with the computers plugin absent
|
||||
there is no reconciliation to suppress, and False is the honest answer.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import (PolicyActionHold,
|
||||
DEFAULT_HOLD_MINUTES)
|
||||
from shopdb.api import db
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import or_
|
||||
|
||||
existing = (PolicyActionHold.query
|
||||
.filter(PolicyActionHold.assetid == assetid,
|
||||
PolicyActionHold.action == action,
|
||||
PolicyActionHold.isactive.is_(True),
|
||||
or_(PolicyActionHold.expiresat.is_(None),
|
||||
PolicyActionHold.expiresat > datetime.utcnow()))
|
||||
.first())
|
||||
if existing is not None:
|
||||
return True
|
||||
|
||||
if minutes is None:
|
||||
expiresat = datetime.utcnow() + timedelta(minutes=DEFAULT_HOLD_MINUTES)
|
||||
elif int(minutes) == 0:
|
||||
expiresat = None
|
||||
else:
|
||||
expiresat = datetime.utcnow() + timedelta(minutes=int(minutes))
|
||||
|
||||
hold = PolicyActionHold(
|
||||
assetid=assetid,
|
||||
action=action,
|
||||
expiresat=expiresat,
|
||||
createdbyuserid=userid,
|
||||
createdbyusername=username,
|
||||
reason=reason,
|
||||
)
|
||||
db.session.add(hold)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def ACTIVE_POLICY_HOLDS(assetid):
|
||||
"""The unexpired break-glass holds on one endpoint, newest first.
|
||||
|
||||
The read counterpart to PLACE_POLICY_HOLD, resolved lazily against the
|
||||
computers plugin for the same reason: with that plugin absent there is no
|
||||
reconciliation to suppress, so there are no holds and an empty list is the
|
||||
honest answer.
|
||||
|
||||
Expiry is filtered here rather than by a sweeper, matching the resolver, so
|
||||
a stalled job can never silently extend a hold. Expired rows stay in the
|
||||
table as the audit record; they are simply not reported as active.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Null expiresat is an indefinite hold and is always active, so it is
|
||||
# matched explicitly - a bare comparison would report it as gone while the
|
||||
# resolver still honours it, which is the worst kind of disagreement.
|
||||
rows = (PolicyActionHold.query
|
||||
.filter(PolicyActionHold.assetid == assetid,
|
||||
PolicyActionHold.isactive.is_(True),
|
||||
or_(PolicyActionHold.expiresat.is_(None),
|
||||
PolicyActionHold.expiresat > datetime.utcnow()))
|
||||
.all())
|
||||
# Sorted here rather than in SQL: NULLS FIRST is not portable across the
|
||||
# dialects this runs on. Indefinite holds sort ahead of every dated one,
|
||||
# then latest expiry first. Keyed on datetime.max rather than a numeric
|
||||
# fallback: datetime.min.timestamp() raises on Windows, and the placeholder
|
||||
# is never actually compared against a real date anyway - the leading flag
|
||||
# already separates the two groups.
|
||||
rows.sort(key=lambda row: (row.expiresat is None,
|
||||
row.expiresat or datetime.max),
|
||||
reverse=True)
|
||||
return [{
|
||||
'action': row.action,
|
||||
'expiresat': row.expiresat.isoformat() if row.expiresat else None,
|
||||
'reason': row.reason,
|
||||
'createdbyusername': row.createdbyusername,
|
||||
} for row in rows]
|
||||
|
||||
|
||||
def HOLDABLE_ACTIONS_FOR(assetid):
|
||||
"""Desired-mode actions on this endpoint, with their current hold state.
|
||||
|
||||
Only desired-mode actions are offered: a hold suspends desired-state
|
||||
reconciliation, so holding a gate-mode action would suspend nothing and the
|
||||
control would silently do nothing.
|
||||
|
||||
Returns [] when the computers plugin is absent or policies are off - with no
|
||||
reconciliation running there is nothing to hold, and an empty picker is the
|
||||
honest answer rather than a list of actions the hold would not affect.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
effective = effective_policy_for(assetid)
|
||||
held = effective.heldactions
|
||||
return [{
|
||||
'action': name,
|
||||
'held': name in held,
|
||||
} for name in sorted(effective.holdable_actions())]
|
||||
|
||||
|
||||
def RELEASE_POLICY_HOLD(assetid, action):
|
||||
"""End an active hold early, resuming reconciliation of one action.
|
||||
|
||||
Soft-deletes via isactive rather than dropping the row: the hold IS the
|
||||
audit record of what was suppressed and by whom, and deleting it on release
|
||||
would erase exactly the history worth reviewing. Both the resolver and
|
||||
ACTIVE_POLICY_HOLDS already filter on isactive, so clearing it is enough to
|
||||
resume reconciliation on the next resolve.
|
||||
|
||||
Returns the number of holds released, so a caller can tell "released" from
|
||||
"there was nothing to release".
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from shopdb.api import db
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Indefinite holds must be releasable too. Without the null branch the one
|
||||
# kind of hold that never clears on its own would also be the one kind the
|
||||
# release button could not clear.
|
||||
rows = (PolicyActionHold.query
|
||||
.filter(PolicyActionHold.assetid == assetid,
|
||||
PolicyActionHold.action == action,
|
||||
PolicyActionHold.isactive.is_(True),
|
||||
or_(PolicyActionHold.expiresat.is_(None),
|
||||
PolicyActionHold.expiresat > datetime.utcnow()))
|
||||
.all())
|
||||
for row in rows:
|
||||
row.isactive = False
|
||||
if rows:
|
||||
db.session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def ACTIVE_LOCK_FOR(assetid):
|
||||
"""The endpoint lock standing on this asset, or None.
|
||||
|
||||
Unlike the hold helpers above there is no defensive cross-plugin import:
|
||||
endpointlocks belongs to THIS plugin, so if the import could fail the
|
||||
enqueue route calling it would not be running either.
|
||||
|
||||
No expiry filter, deliberately. A lock has no TTL - it is cleared by an
|
||||
admin or it stands. Anything that looks like an expiry check here would be
|
||||
a bug.
|
||||
"""
|
||||
from .models import EndpointLock
|
||||
|
||||
return (EndpointLock.query
|
||||
.filter(EndpointLock.assetid == assetid,
|
||||
EndpointLock.releasedat.is_(None))
|
||||
.order_by(EndpointLock.lockedat.desc())
|
||||
.first())
|
||||
|
||||
|
||||
def PLACE_ENDPOINT_LOCK(assetid, reason, userid=None, username=None):
|
||||
"""Quarantine one endpoint against non-admin commands.
|
||||
|
||||
Idempotent on the active lock: if one already stands this returns it
|
||||
unchanged rather than stacking a second row. Two active locks on one
|
||||
endpoint is not a state this table should represent, and the caller who
|
||||
"locked" a locked machine got the outcome they asked for either way.
|
||||
|
||||
The invariant is enforced here rather than by a unique constraint because
|
||||
released rows accumulate per asset as history, so uniqueness on assetid
|
||||
would reject the second lock in an endpoint's life.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import EndpointLock
|
||||
|
||||
existing = ACTIVE_LOCK_FOR(assetid)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
lock = EndpointLock(
|
||||
assetid=assetid,
|
||||
reason=reason,
|
||||
lockedbyuserid=userid,
|
||||
lockedbyusername=username,
|
||||
lockedat=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(lock)
|
||||
db.session.commit()
|
||||
return lock
|
||||
|
||||
|
||||
def RELEASE_ENDPOINT_LOCK(assetid, userid=None, username=None):
|
||||
"""Lift the quarantine, recording who lifted it.
|
||||
|
||||
Stamps releasedat rather than deleting the row: the lock IS the record that
|
||||
this machine was quarantined and why, and that record is most wanted after
|
||||
the lock is gone and no longer able to explain itself. Every read filters on
|
||||
releasedat, so stamping it is enough to restore normal command flow.
|
||||
|
||||
Returns the released lock, or None when there was nothing to release, so a
|
||||
caller can tell those two cases apart.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
lock = ACTIVE_LOCK_FOR(assetid)
|
||||
if lock is None:
|
||||
return None
|
||||
|
||||
lock.releasedat = datetime.utcnow()
|
||||
lock.releasedbyuserid = userid
|
||||
lock.releasedbyusername = username
|
||||
db.session.commit()
|
||||
return lock
|
||||
|
||||
|
||||
477
plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
Normal file
477
plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Per-PC credentials rollout
|
||||
|
||||
Runbook for moving a site's endpoint fleet from the shared collector key to
|
||||
per-PC credentials. The wire contract is `AGENT-CONTRACT.md` section 9.
|
||||
|
||||
Placeholders: `<server>` is the ShopDB base URL, `<share>` the GE-Enforce share
|
||||
root, `<pin>` a server key pin, `<pilotbay>` / `<pilothost>` the pilot PC.
|
||||
|
||||
## What changes
|
||||
|
||||
- Every endpoint gets its own secret (`sdbe_...`), stored in
|
||||
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
|
||||
LocalMachine, readable by SYSTEM and Administrators only.
|
||||
- The server knows which asset is calling from the secret. A PC can no longer
|
||||
report as another PC.
|
||||
- The tray holds no secret. It talks to the SYSTEM agent over a local pipe.
|
||||
- The agent, the upgrade script and the SYSTEM reporters
|
||||
(`Report-AssetToShopDB.ps1`, `Report-PrintersToShopDB.ps1`) pin the server's
|
||||
public key when `ServerPins` is set.
|
||||
- The shared key keeps working for PCs that have NOT enrolled, until an admin
|
||||
turns it off. An enrolled PC is always refused the shared key
|
||||
(`401 SHARED_KEY_REFUSED`).
|
||||
|
||||
## Where to look in the app
|
||||
|
||||
- **Settings -> Endpoints -> Enrollment**: Migration tab (readiness, waves) and
|
||||
Credentials tab (enrolled PCs, pending requests, un-enroll, allow
|
||||
re-enrollment, the shared key and auto-enroll switches).
|
||||
- **Settings -> Endpoints** (the agent key page): the shared key PCs use until
|
||||
they enroll, with an enrolled-of-total summary and the pending request count.
|
||||
Regenerate and revoke there say how many PCs are not enrolled and would be
|
||||
affected.
|
||||
- **Settings -> Endpoints -> Server Certificate**: the certificate file's pin
|
||||
and thumbprint, and **What PCs receive**, a live check of the certificate the
|
||||
site's base URL (`site_base_url`) actually presents. When the two differ, pin
|
||||
the presented one.
|
||||
- **Fleet** (sidebar, under Assets): the **Credential** column (Enrolled,
|
||||
Pending, Re-enroll blocked, Shared key (not enrolled), Not reporting) and a
|
||||
filter on it.
|
||||
- **PC detail**: the Endpoint status card shows the PC's credential state (with
|
||||
prefix, issued, last used, enrolled via and block reason for
|
||||
`endpoints.fleet` holders). Admins get **Revoke credential** (with **Keep this
|
||||
PC from enrolling again**) and **Allow re-enrollment** beside the status chip.
|
||||
- **Dashboard**: "N PCs waiting for enrollment approval" (warning, links to
|
||||
Enrollment) and "PCs blocked from re-enrolling over 7 days" (info, counted
|
||||
from when the credential was revoked).
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Server deployed with the endpoints migration `endpoints0023credentials`
|
||||
applied (`flask plugin upgrade-all`).
|
||||
2. Settings -> Endpoints -> Server Certificate shows a **Public key pin (SPKI
|
||||
SHA-256)**. Cross-check it from a PC:
|
||||
`Get-ShopDBServerPin.ps1 -Url https://<server>`. The two must match.
|
||||
3. Agent 1.20 (and tray 1.7) uploaded as the current release under Settings ->
|
||||
Endpoints -> Upload New/Legacy Agents. Keep the previous release staged as a
|
||||
legacy build: it is the rollback.
|
||||
4. Settings -> Endpoints -> Enrollment -> Credentials: **Shared key allowed**
|
||||
on, **Auto-enroll** on (both are the defaults).
|
||||
5. The GE-Enforce manifest scope that will carry the entries below is at
|
||||
`Version` 2.2 or later, and the fleet's GE-Enforce client library
|
||||
understands `TargetHostnames` / `TargetMachineNumbers`. An older manifest or
|
||||
library ignores those lists and applies the entry to EVERY PC in scope. The
|
||||
same happens with an empty list: never publish a targeted entry with a blank
|
||||
target.
|
||||
|
||||
## Phase 1: pins on the PCs
|
||||
|
||||
Pins must be on a PC BEFORE agent 1.20 arrives there. A 1.20 agent with no pin
|
||||
falls back to plain Windows validation, which a self-signed or internal-CA
|
||||
server may fail; a pin set after the fact is not a rollout order you want to
|
||||
debug per bay.
|
||||
|
||||
Stage `plugins/endpoints/client/Set-ShopDBServerPins.ps1` on the share and add
|
||||
a PS1 entry. Pilot first:
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "Server key pin for per-PC credentials. Pilot only.",
|
||||
"Name": "ShopDB server pins",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Set-ShopDBServerPins.ps1",
|
||||
"Args": "-Pins <pin>",
|
||||
"DetectionMethod": "Always",
|
||||
"TargetMachineNumbers": ["<pilotbay>"]
|
||||
}
|
||||
```
|
||||
|
||||
Then widen by hostname list (or machine number list) in batches:
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "ShopDB server pins",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Set-ShopDBServerPins.ps1",
|
||||
"Args": "-Pins <pin>",
|
||||
"DetectionMethod": "Always",
|
||||
"TargetHostnames": ["<host1>", "<host2>", "<host3>"]
|
||||
}
|
||||
```
|
||||
|
||||
The script compares before it writes, so `Always` costs nothing per cycle.
|
||||
A running agent picks up a changed `ServerPins` within about 60 seconds; no
|
||||
service restart is needed.
|
||||
|
||||
**The registry wins.** The installer and the store upgrade also write
|
||||
`serverpins` into appsettings.json. When `ServerPins` exists in the registry
|
||||
(either view), even empty, the agent uses only the registry and ignores the
|
||||
appsettings.json pins; only a PC with no `ServerPins` value falls back to them.
|
||||
The agent log says which source is in effect (`Server certificate trust from
|
||||
registry` / `from appsettings.json`, and `Server pins changed, source ...`).
|
||||
|
||||
- `-Pins <pin>[,<pin>]`: write exactly this set.
|
||||
- `-None`: write an EMPTY value. No pins, normal Windows certificate checks,
|
||||
appsettings.json pins ignored.
|
||||
- `-Clear`: DELETE the value. The appsettings.json pins apply again (if any).
|
||||
|
||||
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
|
||||
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
|
||||
equal to the pin on the settings page, and the next `report-asset-*.log` under
|
||||
`C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a
|
||||
RESPONSE line.
|
||||
|
||||
**Rotating the server key later:** any server-side change that gives the
|
||||
certificate a new key (a regenerated or re-issued certificate) changes the pin.
|
||||
Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins`
|
||||
(`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the
|
||||
certificate on the server, then retire the old pin by writing ONLY the new pin
|
||||
to the registry (`-Pins <newpin>`). Do not use `-Clear` for this: deleting the
|
||||
value brings back whatever pins appsettings.json carries, which may be the old
|
||||
one. Switching the server first
|
||||
cuts off every pinned PC, including its ability to receive the fixed pin list
|
||||
through anything that talks to ShopDB. A renewal that keeps the same key keeps
|
||||
the same pin. PCs trusted through the Root store (below) also need the new
|
||||
certificate imported before the switch.
|
||||
|
||||
### Trust the server certificate on PCs with agents older than 1.20
|
||||
|
||||
Required before ANY store wave. Agents older than 1.20 know nothing about
|
||||
`ServerPins`: when they download the upgrade package they check the normal
|
||||
Windows certificate chain. With a self-signed server certificate that check
|
||||
fails and the upgrade never lands.
|
||||
|
||||
- PCs installed by the server's generated installer (Build site agent) already
|
||||
trust the certificate.
|
||||
- PCs installed from the share do NOT, and need this entry.
|
||||
|
||||
Get the certificate and its thumbprint from the server:
|
||||
|
||||
- Settings -> Endpoints -> Server Certificate -> download, or
|
||||
`GET /api/endpoints/settings/server-cert/download` (a `.cer`).
|
||||
- The thumbprint is `sha1thumbprint` on `GET /api/endpoints/settings/server-cert`.
|
||||
|
||||
Stage the `.cer` and `plugins/endpoints/client/Install-ShopDBServerCertificate.ps1`
|
||||
on the share and add a PS1 entry with the SAME targeting as the pin entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "Trust the ShopDB server certificate for pre-1.20 agent upgrades.",
|
||||
"Name": "ShopDB server certificate",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Install-ShopDBServerCertificate.ps1",
|
||||
"Args": "-CertPath <share>\\scripts\\shopdb-server.cer -Thumbprint <sha1thumbprint>",
|
||||
"DetectionMethod": "Always",
|
||||
"TargetMachineNumbers": ["<pilotbay>"]
|
||||
}
|
||||
```
|
||||
|
||||
The script refuses a file whose thumbprint differs, refuses a CA certificate
|
||||
(one in Root would trust everything it signs), imports into
|
||||
`LocalMachine\Root` only when the certificate is not already there, and removes
|
||||
nothing. A non-CA leaf in Root trusts only this one server.
|
||||
|
||||
If a wave reaches a PC that does not trust the certificate, the download fails
|
||||
and the Migration tab marks the PC **Blocked** with `server certificate not
|
||||
trusted by this PC`. The server only knows about trust from that failure, so
|
||||
the blocker stays until the PC is queued again. Fix trust with the entry above,
|
||||
then in the Migration tab tick the PC (a PC whose only blocker is this one keeps
|
||||
a checkbox and shows "Retry after fixing trust") and click **Upgrade selected**.
|
||||
**Upgrade next 25** never picks it, and a PC with any other blocker as well
|
||||
stays unselectable until that blocker is fixed.
|
||||
|
||||
## Phase 2: re-stamp the share's agent entries
|
||||
|
||||
PCs that got the agent from the share (collections manifest entries with a
|
||||
`Hash` detection on `shopdbagent.exe`) are held to that hash by GE-Enforce. If
|
||||
a store upgrade wave puts 1.20 on such a PC while the entry still carries the
|
||||
1.19 hash, GE-Enforce reinstalls 1.19 on its next cycle and the PC flips back.
|
||||
|
||||
Before any wave reaches a share-installed PC, either:
|
||||
|
||||
- re-stamp those entries to the 1.20 payload and its `DetectionValue` SHA-256
|
||||
(and keep the `ProductCode`/version guards the entries already have), or
|
||||
- remove the entries for the PCs the wave will cover.
|
||||
|
||||
Agent entries stay targeted. Illustrative shape for the pilot (keep your
|
||||
existing entry's type, installer and guards; what matters is the hash and a
|
||||
non-empty target):
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "ShopDB endpoint agent 1.20",
|
||||
"Type": "PS1",
|
||||
"Script": "apps/shopdbagent/1.20.0/install-shopdbagent.ps1",
|
||||
"DetectionMethod": "Hash",
|
||||
"DetectionPath": "C:\\Program Files\\ShopDB\\EndpointAgent\\shopdbagent.exe",
|
||||
"DetectionValue": "<sha256 of the 1.20 shopdbagent.exe>",
|
||||
"TargetHostnames": ["<pilothost>"]
|
||||
}
|
||||
```
|
||||
|
||||
**Gate:** the manifest preview for the pilot shows the 1.20 hash, and no entry
|
||||
for the same file with a blank target list exists in any scope that reaches it.
|
||||
|
||||
## Phase 3: pilot one PC
|
||||
|
||||
1. Enrollment -> Migration: find `<pilothost>`. It should be **Ready**. If it is
|
||||
**Blocked**, the Blockers column lists every reason; fix those first.
|
||||
2. Tick it, **Upgrade selected**.
|
||||
3. Watch the row: **Upgrading**, then **Enrolled** within a few minutes of the
|
||||
new agent starting. The agent enrolls on its own with the shared key it
|
||||
already holds.
|
||||
|
||||
**Gate before any wave:**
|
||||
|
||||
- Credentials tab shows the PC enrolled, `enrolledvia` = `auto`, and a
|
||||
`lastusedat` that keeps moving.
|
||||
- The PC's Fleet row keeps reporting; commands (Collect now) still work.
|
||||
- The tray shows alerts and Send Status works (it now goes through the pipe).
|
||||
- `report-asset-*.log` and `report-printers-*.log` show `RESPONSE (credential)`.
|
||||
- Nothing reinstalled the old agent after two GE-Enforce cycles.
|
||||
|
||||
## Phase 4: waves
|
||||
|
||||
Migration tab -> **Upgrade next 25**. The confirm dialog states how many PCs get
|
||||
agent 1.20. The server re-checks readiness per PC and packages the agent once
|
||||
per wave; PCs that stopped being ready are returned as skipped.
|
||||
|
||||
Between waves, in the Waves list:
|
||||
|
||||
- wait until the wave shows **Done** (every PC enrolled or failed),
|
||||
- investigate every **failed** PC before the next wave,
|
||||
- check nothing moved from Enrolled back to Upgrading or Ready (a share entry
|
||||
reinstalling the old agent, see phase 2).
|
||||
|
||||
Repeat until **Ready** is 0.
|
||||
|
||||
## Phase 5: stragglers and new PCs
|
||||
|
||||
- **Blocked:** each blocker names its fix. `offline`: bring the PC back or wait.
|
||||
`no serial on record` / `duplicate hostname`: fix the computer record.
|
||||
`commands locked`: release the endpoint lock or policy hold. `no agent package
|
||||
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
|
||||
share (targeted agent entry as in phase 2). `server certificate not trusted
|
||||
by this PC`: add the PC to the certificate entry (phase 1), wait a cycle,
|
||||
then tick it and **Upgrade selected** (phase 1 explains the retry). `reenroll blocked`: the PC was rolled back on purpose; clear
|
||||
it with allow-reenroll (see Rollback) only when moving it forward again.
|
||||
- **Not reporting:** the PC has no working agent. It can only move via the
|
||||
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
|
||||
Pending or Enrolled once the agent runs.
|
||||
- **Pending:** the agent asked but auto-enroll could not decide (serial
|
||||
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
|
||||
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
|
||||
matched computer, search to choose another. The PC collects its credential on
|
||||
its next retry - about a minute for a PC that started asking recently,
|
||||
slowing to 15 minutes once it has been pending a while. **Reject** anything
|
||||
you do not recognise. Approving also records the hostname the PC reports on
|
||||
the computer you picked when that record has none; if the record carries a
|
||||
DIFFERENT hostname the approval stops and asks, because the PC cannot
|
||||
authenticate against a computer recorded under another name.
|
||||
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
|
||||
auto-enrolls on first start. A new PC can also be installed WITHOUT the
|
||||
shared key at any time, before or after cutover (the generated installer's
|
||||
"Include shared key" option off): the enroll call needs no key, so its request
|
||||
lands in Pending and it waits there until an admin approves it in the UI. An
|
||||
invalid key behaves the same (the server logs it). After the shared key is
|
||||
turned off (phase 6) that is the only path, and the steady-state path for new
|
||||
PCs.
|
||||
|
||||
## Installing a one-off PC
|
||||
|
||||
A PC nobody is going to author a GE-Enforce manifest entry for - a loaner, a
|
||||
one-off office machine, a bay being rebuilt out of band - can be installed with
|
||||
a single command instead of a downloaded package. **Managed bays keep using
|
||||
GE-Enforce**; this does not replace the share channel, and the script itself
|
||||
says so when it finds an agent already installed.
|
||||
|
||||
1. Settings -> Endpoints -> Build site agent -> **One-line install**. Set how
|
||||
long the token lives (hours, 1 to 168) and how many installs it may serve
|
||||
(1 to 500), then **Generate install command**.
|
||||
2. Copy the command. The token is shown ONCE and cannot be recovered; generate
|
||||
a new one if it is lost.
|
||||
3. On the target PC, open PowerShell **as administrator** and paste it:
|
||||
|
||||
**Copy the command the UI gives you.** Do not retype it from here and do not
|
||||
reuse one you saved from an older round - see the warning under this block. It
|
||||
looks like this:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
It creates an admin-only staging directory, downloads the script into it, runs
|
||||
it and deletes it again. Do not shorten it back to `irm ... | iex`: Defender
|
||||
scores that shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
anything runs, on a default install with no ASR rules. Paste it as one line
|
||||
into an elevated **cmd** or **PowerShell** window; it means the same in both.
|
||||
**Download .ps1** in the same block saves the script instead, for a PC where
|
||||
pasting a long line is awkward - it carries the same token, so treat the file
|
||||
the way you treat the command.
|
||||
|
||||
> **Do not use the old `?t=<token>` one-liner that staged into
|
||||
> `C:\Windows\Temp`.** Any standard user on the bay can create that exact path
|
||||
> first and wait for a tech to paste the command, which then runs the planted
|
||||
> file elevated; the token also lands in the web server's access log. The
|
||||
> command above stages under `C:\Program Files`, locks the directory to
|
||||
> Administrators and SYSTEM and stops if that fails, refuses to run a staged
|
||||
> file owned by anyone else, and sends the token in a header.
|
||||
>
|
||||
> The `?t=` form is still ACCEPTED, but only so a bay provisioned before this
|
||||
> change keeps working until its command is re-issued. Every such fetch logs a
|
||||
> server warning and writes an audit row against the token:
|
||||
>
|
||||
> ```
|
||||
> bootstrap token presented in the query string by <caller> with no admin
|
||||
> download marker, so this is an un-migrated bay running a command pasted
|
||||
> before the header form; ... re-issue the one-line install command
|
||||
> ```
|
||||
>
|
||||
> **`<caller>` is 127.0.0.1 on a stock Windows install.** IIS runs the app
|
||||
> through HttpPlatformHandler over loopback and sets no `X-Forwarded-For` of
|
||||
> its own; `deploy/windows/web.config` ships the rewrite rule that would add
|
||||
> one commented out, so waitress has nothing to rewrite `REMOTE_ADDR` from.
|
||||
> Check before you rely on the address: if these lines all say 127.0.0.1, the
|
||||
> warning still tells you un-migrated bays EXIST and how many fetches they
|
||||
> made, but not which bays. Get real addresses by enabling that rule (the
|
||||
> Windows installer's `-ClientIpSource direct`, which installs URL Rewrite and
|
||||
> allows `HTTP_X_FORWARDED_FOR` at server level); behind a further reverse
|
||||
> proxy leave it off and set `TRUSTED_PROXY_COUNT` instead. Without that, the
|
||||
> usable identifier is the token: each warning has a matching audit row on
|
||||
> `EndpointBootstrapToken` carrying the 12-character `tokenprefix`, so you can
|
||||
> at least tell which install round the stale command came from.
|
||||
>
|
||||
> Work that list down: mint a new token and hand out the new command. Clicking
|
||||
> **Download .ps1** yourself does NOT add to it - that link is marked
|
||||
> `&via=download` and logs at info, precisely so the warnings stay a list of
|
||||
> bays. Once the list is empty, clear **Accept the token in the URL** in
|
||||
> Settings -> Endpoints -> Build site agent -> One-line install
|
||||
> (`plugin.endpoints.bootstrapquerytoken`) to refuse the query form outright.
|
||||
> That also disables the Download .ps1 link and kills every command still in a
|
||||
> technician's hands, so do it deliberately and re-issue first.
|
||||
|
||||
The token is short-lived and only fetches the installer. It carries no shared
|
||||
key and provisions nothing by itself: the package it downloads is built with
|
||||
"Include shared key" off, whatever the shared-key setting is, so the PC asks to
|
||||
enroll and **waits for approval**. Approve it in Enrollment -> Credentials ->
|
||||
Requests, the same as any other pending PC (phase 5).
|
||||
|
||||
A one-off PC is pending for `unknown hostname` by definition, so pick (or
|
||||
create) the computer record it belongs to. The approval records the hostname
|
||||
the PC reports on that record, which is what the PC authenticates against
|
||||
afterwards; picking a record already named something else stops the approval
|
||||
and asks before renaming it. Skipping that step is what used to leave a
|
||||
bootstrapped PC enrolled and then refused (403) on every call.
|
||||
|
||||
The script checks the downloaded binaries against the SHA-256 values the server
|
||||
stamped into it, refuses to run unelevated, and refuses to run on a PC that
|
||||
already has the agent service (set `SHOPDB_BOOTSTRAP_FORCE=1` in the same
|
||||
window to override). After a successful install it waits up to two minutes for
|
||||
the credential to land and prints `approved and enrolled` if an admin approves
|
||||
it in that window; still pending is a normal outcome, not a failure.
|
||||
|
||||
If it stops before any of that with a red **Refusing to run**, nothing was
|
||||
installed and nothing was downloaded. Two cases, both meaning look at the bay
|
||||
rather than retry:
|
||||
|
||||
| Exit | Message | What to do |
|
||||
|---|---|---|
|
||||
| 6 | could not restrict `C:\Program Files\ShopDB\bootstrap` to administrators | the ACL could not be set - usually the directory already exists with an owner or DACL you did not put there. Inspect it (`icacls`), delete it, run the command again. |
|
||||
| 5 | the staged bootstrap script is not owned by an administrator | something else wrote the staged file. Treat the bay as suspect, do not force past it. |
|
||||
|
||||
Revoke a token from the same block when the install round is done. Tokens list
|
||||
with their prefix, expiry and uses; they are also spent automatically
|
||||
(`usecount` reaching `maxuses`) and expire on their own.
|
||||
|
||||
## Phase 6: cutover
|
||||
|
||||
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
|
||||
you have decided may stop reporting. Remember that turning the shared key off
|
||||
also refuses it on `/api/collector/computers` and `/api/collector/printers`, so
|
||||
a PC with reporter scripts but no enrolled agent goes silent.
|
||||
|
||||
1. Turn **Shared key allowed** off. The confirm shows how many PCs are not
|
||||
enrolled; type `OFF`.
|
||||
2. Watch Fleet for an hour. A PC that went quiet is either unenrolled (turn the
|
||||
shared key back on, fix it, try again) or failed pinning.
|
||||
3. Remove the shared key from every place it lives (below).
|
||||
4. Rotate the old key so any copy you missed is dead: Settings -> Endpoints ->
|
||||
**Regenerate key** (and rotate the collector token the reporters used, if it
|
||||
was a separate token). With the shared key off nothing on the floor needs
|
||||
the new key: approved enroll requests are collected without it. Store it
|
||||
the way the key page describes and keep it out of every file below. A
|
||||
fleet rollback after this point (see Rollback) needs the new key pushed to
|
||||
the PCs again.
|
||||
|
||||
### Every place the shared key lives
|
||||
|
||||
| Where | Put there by | How to clear at cutover |
|
||||
|---|---|---|
|
||||
| GE-Enforce manifest entry `Args` (`-ApiKey ...`) | whoever authored the entry | Remove the argument from the entry and publish. Search every scope for the key prefix. |
|
||||
| `HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, both registry views | agent `install.ps1`; `Set-ShopdbCollectorKey.ps1` from `<share>\common\configs\shopdb-collector.txt` | First retire the `Set-ShopdbCollectorKey.ps1` entry (or it rewrites the value every cycle), then a targeted PS1 entry that deletes `CollectorKey` in both views. |
|
||||
| `<share>\common\configs\shopdb-collector.txt` | share staging | Delete the file after the entry above is retired. |
|
||||
| `appsettings.json` `apikey` in the agent install folder | agent package / hand edits | Targeted PS1 entry that sets `apikey` to an empty string. The agent never needs it once enrolled. |
|
||||
| Generated `install.ps1` default `-CollectorKey` (Build site agent downloads) | Settings -> Endpoints -> Build site agent | Delete every kept copy (share, USB, work folders). Rebuild after rotation only if you still need the package. |
|
||||
| Display kiosk `shopdb-key.txt` | display imaging | Confirm what the kiosk uses it for before clearing; a kiosk has no endpoint agent, so anything it reports with the shared key stops at cutover. Delete the file once that is settled. |
|
||||
|
||||
The agent never deletes the shared key itself: other SYSTEM scripts may still
|
||||
read it until this table is done.
|
||||
|
||||
## Rollback
|
||||
|
||||
The order matters. A 1.20 agent that is plainly un-enrolled enrolls again
|
||||
within about a second, before any downgrade can land, and the old agent it is
|
||||
then replaced with only knows the shared key, which the server refuses for an
|
||||
enrolled PC. Block re-enrollment FIRST.
|
||||
|
||||
**One PC:**
|
||||
|
||||
1. Make sure **Shared key allowed** is on.
|
||||
2. Credentials tab -> **Un-enroll** the PC with **Keep this PC from enrolling
|
||||
again** ticked. API equivalent:
|
||||
`POST /api/endpoints/enrollment/assets/<assetid>/revoke`
|
||||
`{"blockreenroll": true, "reason": "rollback"}`.
|
||||
Its credential stops working immediately, the server admits the shared key
|
||||
for it again, and its enroll attempts stay pending with `reenroll blocked`.
|
||||
3. Make the previous agent current in the store (Upload New/Legacy Agents) and
|
||||
queue the upgrade for that PC, or re-stamp its share entry to the old hash.
|
||||
4. Verify: the Fleet row reports the old agent version, and the reporter logs
|
||||
show `RESPONSE (shared key)`.
|
||||
5. Remove the PC from the pin entry's targets only if pinning itself is the
|
||||
problem. `Set-ShopDBServerPins.ps1 -None` turns pinning off outright;
|
||||
`-Clear` deletes the value, which falls back to any appsettings.json pins.
|
||||
6. Later, when moving the PC forward again, lift the block:
|
||||
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
|
||||
same action in the UI). The PC enrolls on its next attempt once it runs
|
||||
1.20. Until then the Migration tab shows it blocked with `reenroll blocked`.
|
||||
|
||||
**Whole fleet:** turn **Shared key allowed** back on first. Every PC that still
|
||||
holds the shared key reports again immediately. Then revoke with the block and
|
||||
downgrade PCs as above, in waves.
|
||||
|
||||
## Verified on Windows
|
||||
|
||||
A full pass on a Windows test PC against a test server proved:
|
||||
|
||||
- the agent enrolls on its own;
|
||||
- the shared key is refused for the enrolled PC (`SHARED_KEY_REFUSED`);
|
||||
- a request naming another asset is refused (`ASSET_MISMATCH`);
|
||||
- server pins are enforced;
|
||||
- the tray pipe works for a standard (non-admin) user;
|
||||
- alert acknowledge works;
|
||||
- both SYSTEM reporters send with the per-PC credential;
|
||||
- revoke is followed by re-enrollment;
|
||||
- rollback to the previous agent works;
|
||||
- with the shared key switched off, enrolled PCs keep working.
|
||||
|
||||
## Quick reference
|
||||
|
||||
- Pin of the live server: Server Certificate settings page, or
|
||||
`Get-ShopDBServerPin.ps1 -Url https://<server>`.
|
||||
- Pin of a certificate file: `Get-ShopDBServerPin.ps1 -Path <file>`.
|
||||
- Agent log of enrollment: `%ProgramData%\ShopDB\EndpointAgent\logs`.
|
||||
- Reporter logs: `C:\Logs\Shopfloor\report-asset-*.log`,
|
||||
`report-printers-*.log`. `SHARED_KEY_REFUSED` there means the PC is enrolled
|
||||
or the shared key is off and the reporter could not read the credential;
|
||||
`CREDENTIAL_INVALID` means the PC was un-enrolled and the agent is
|
||||
re-enrolling.
|
||||
@@ -24,7 +24,21 @@ EVENT_CATALOG = {
|
||||
55: ('File system corruption', 'critical', 'System'),
|
||||
7011: ('Service timeout', 'critical', 'System'),
|
||||
10016: ('DCOM permission error', 'warning', 'System'),
|
||||
1001: ('Application crash', 'critical', 'Application'),
|
||||
# Windows Error Reporting trio on the Application channel. 1000 is the
|
||||
# faulting-application record (app name, version, faulting module, exception
|
||||
# code) and is the one that says what actually broke; 1001 is the WER bucket
|
||||
# record Windows writes alongside it; 1002 is a hang rather than a crash, so
|
||||
# it is a warning - the app is unresponsive, not gone.
|
||||
#
|
||||
# 1001 is deliberately NOT critical. Windows writes it ALONGSIDE 1000 for the
|
||||
# same crash, so counting both double-counted every fault: a box with 2200
|
||||
# crashes reported "Application crash x4400". It also won the dominant-event
|
||||
# tie often enough to displace 1000, which is the record that actually names
|
||||
# the faulting module. Kept curated so the history panel still shows it, but
|
||||
# it no longer drives the fleet health verdict - 1000 speaks for the crash.
|
||||
1000: ('Application error', 'critical', 'Application'),
|
||||
1001: ('Application crash', 'info', 'Application'),
|
||||
1002: ('Application hang', 'warning', 'Application'),
|
||||
}
|
||||
|
||||
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
|
||||
|
||||
@@ -24,15 +24,37 @@ export default [
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
|
||||
// run_script delivery path end to end while Cameron builds the real UI.
|
||||
// Gated on endpoints.deploy inside the view; remove once the production
|
||||
// delivery UI lands.
|
||||
{
|
||||
path: 'endpoints/delivery-test',
|
||||
name: 'endpoints-delivery-test',
|
||||
component: () => import('./views/DeliveryTestBench.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'endpoints' }
|
||||
path: 'settings/endpoints/server-cert',
|
||||
name: 'endpoints-server-cert-settings',
|
||||
component: () => import('./views/ServerCertSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
|
||||
// non-admin operators, and the sibling pages' admin gate would lock the
|
||||
// holders of the grant out of the library they are trusted to manage. The
|
||||
// API enforces the same permission, so this only keeps the menu honest.
|
||||
{
|
||||
path: 'settings/endpoints/scripts',
|
||||
name: 'endpoints-scripts-settings',
|
||||
component: () => import('./views/SystemScriptsSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.runscript',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
},
|
||||
// Readable by endpoints.fleet holders so they can watch a migration; every
|
||||
// write on the page is admin-only and re-checked server-side.
|
||||
{
|
||||
path: 'settings/endpoints/enrollment',
|
||||
name: 'endpoints-enrollment-settings',
|
||||
component: () => import('./views/EnrollmentSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.fleet',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Manage how shop-floor endpoint agents authenticate to this server, build
|
||||
the agent for this site, and set what it reports and how often.
|
||||
Manage the shared key PCs use until they enroll, build the agent for this
|
||||
site, and set what it reports and how often. Each enrolled PC
|
||||
authenticates with its own credential instead; see Settings > Endpoints
|
||||
> Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- ====================================================================
|
||||
Key management
|
||||
Shared key
|
||||
================================================================= -->
|
||||
<section class="settings-section">
|
||||
<h3>Key management</h3>
|
||||
<h3>Shared key (used until PCs enroll)</h3>
|
||||
<p class="key-warning">
|
||||
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
|
||||
</p>
|
||||
@@ -25,12 +27,40 @@
|
||||
paste the raw key into a chat message or a plain file.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
The endpoint agent reports with a single managed key scoped to endpoint
|
||||
telemetry only. It is separate from every other collector, so rotating
|
||||
or revoking it stops all endpoint-agent traffic immediately without
|
||||
touching the PC or GE-Enforce collectors.
|
||||
Every PC installed with the shared key holds this same key. It lets a
|
||||
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
|
||||
uses its credential instead, so rotating or revoking the key only
|
||||
affects PCs that have not enrolled. It is scoped to endpoint telemetry
|
||||
and separate from every other collector.
|
||||
</p>
|
||||
|
||||
<div v-if="enrollment" class="card form-card enrollment-summary">
|
||||
<div class="enrollment-counts">
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.enrolled }}</strong> of {{ enrollment.total }} PCs enrolled
|
||||
</span>
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.notenrolled }}</strong> still on the shared key
|
||||
</span>
|
||||
<span class="enrollment-count">
|
||||
<strong>{{ enrollment.requests }}</strong>
|
||||
pending enrollment {{ enrollment.requests === 1 ? 'request' : 'requests' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
<template v-if="!enrollment.sharedkeyallowed">
|
||||
<strong>The shared key is switched off</strong> in Enrollment, so PCs
|
||||
that have not enrolled already cannot report with it.
|
||||
</template>
|
||||
Approve requests, move PCs over and retire the shared key in
|
||||
<router-link :to="{ name: 'endpoints-enrollment-settings' }">
|
||||
Settings > Endpoints > Enrollment</router-link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -49,8 +79,8 @@
|
||||
<template v-else>Not used yet.</template>
|
||||
</p>
|
||||
<p v-else class="field-hint">
|
||||
No endpoint agent key is active. Generate one to let agents report
|
||||
with a managed credential.
|
||||
No shared key is active. PCs that have not enrolled cannot report
|
||||
or auto-enroll until one is generated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -85,9 +115,10 @@
|
||||
<div v-if="secret" class="card form-card secret-card">
|
||||
<h4>Save this key now</h4>
|
||||
<p class="field-hint">
|
||||
Anyone with this key can report as an endpoint agent for this site.
|
||||
Treat it like a password. If it is lost you must regenerate, which
|
||||
revokes the old key and forces every agent to be reconfigured.
|
||||
Anyone with this key can report as a not-yet-enrolled endpoint agent
|
||||
for this site and ask to enroll. Treat it like a password. If it is
|
||||
lost you must regenerate, which revokes the old key; PCs that have not
|
||||
enrolled then need the new one.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
<strong>Recommended:</strong> before you leave this page, open the
|
||||
@@ -110,53 +141,57 @@
|
||||
<summary><h4>How it works</h4></summary>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>Generate</strong> mints a fresh key and, on the first
|
||||
generation, turns off the bootstrap <code>.env</code> fallback so
|
||||
the managed key becomes the only accepted credential. The full
|
||||
secret is shown once - store it in the agent config as the
|
||||
<code>X-API-Key</code> header.
|
||||
<strong>Until a PC enrolls</strong> it reports with this shared key.
|
||||
Agents from 1.20 use it once more to ask for their own credential,
|
||||
and from then on send that credential instead.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Regenerate</strong> is a hard cutover: the previous key is
|
||||
revoked in the same moment the new one is minted. Every deployed
|
||||
agent must be reconfigured with the new key before it can report
|
||||
again.
|
||||
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
|
||||
key for any PC that holds a credential, so regenerating or revoking
|
||||
it does not touch them.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Revoke</strong> deactivates the key and leaves it off. All
|
||||
endpoint-agent reporting stops until you generate a new key. This is
|
||||
the kill switch.
|
||||
<strong>Generate</strong> mints a key and, the first time, turns off
|
||||
the bootstrap <code>.env</code> fallback. The full secret is shown
|
||||
once; it goes into the agent config as the <code>X-API-Key</code>
|
||||
header.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Regenerate</strong> revokes the previous key in the same
|
||||
moment. PCs that have not enrolled must be given the new key before
|
||||
they can report or enroll again.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Revoke</strong> leaves no shared key. PCs that have not
|
||||
enrolled stop reporting and can no longer auto-enroll; enrolled PCs
|
||||
carry on.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="collapsible">
|
||||
<summary><h4>When to rotate</h4></summary>
|
||||
<summary><h4>When to rotate or retire</h4></summary>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>An installer or agent left the secure environment.</strong>
|
||||
If a build carrying the key is copied off the shop floor - onto an
|
||||
engineer's laptop, a USB stick, or a machine that leaves the site -
|
||||
treat the key as compromised and regenerate immediately.
|
||||
<strong>At cutover.</strong> Once every PC has enrolled, switch the
|
||||
shared key off in Enrollment, clear it from every place it lives,
|
||||
then regenerate so any copy you missed is dead. The steps are in the
|
||||
per-PC credentials rollout runbook.
|
||||
</li>
|
||||
<li>
|
||||
<strong>A device is lost, stolen, or decommissioned</strong> while
|
||||
still holding the key, and you cannot confirm the key was wiped.
|
||||
<strong>An installer or key left the secure environment.</strong> A
|
||||
build or file carrying the key copied onto a laptop, a USB stick or a
|
||||
machine that leaves the site. Regenerate; only PCs that have not
|
||||
enrolled need the new key.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Staff offboarding.</strong> Someone who could read the key
|
||||
from a config or build pipeline is leaving; rotate as part of the
|
||||
offboarding checklist.
|
||||
from a config or build pipeline is leaving.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
|
||||
(for example quarterly) so a silently leaked key has a bounded
|
||||
lifetime.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Suspicious traffic.</strong> The Fleet view shows reports
|
||||
from a host you do not recognize - revoke now, investigate, then
|
||||
regenerate and redeploy to known-good endpoints.
|
||||
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
|
||||
a host you do not recognize. Revoke now, investigate, then regenerate
|
||||
for the PCs that still need it.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -222,10 +257,11 @@
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Produce an instance-locked agent package stamped with this server's
|
||||
URL, the endpoint agent key, and the reporting cadence, so it deploys
|
||||
with no manual configuration on the client. The download includes the
|
||||
agent, a non-secret <code>appsettings.json</code>, and an
|
||||
<code>install.ps1</code> that writes the credentials to the registry
|
||||
URL, server pin, optionally the endpoint agent key, and the reporting
|
||||
cadence, so it deploys with no manual configuration on the client. The
|
||||
download includes the agent, a non-secret
|
||||
<code>appsettings.json</code>, and an <code>install.ps1</code> that
|
||||
writes the settings to the registry
|
||||
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
|
||||
appsettings.
|
||||
</p>
|
||||
@@ -245,14 +281,46 @@
|
||||
placeholder="https://shopdb.example"
|
||||
/>
|
||||
|
||||
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="agentincludesharedkey" type="checkbox" />
|
||||
<span>
|
||||
Include shared key. When off, the package holds no key and each PC
|
||||
requests enrollment for an admin to approve in Settings >
|
||||
Endpoints > Enrollment.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<template v-if="agentincludesharedkey">
|
||||
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
||||
<input
|
||||
id="agent-key"
|
||||
v-model="agentcollectorkey"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
placeholder="Paste the endpoint agent key generated above"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<label class="field-label" for="agent-nextpin">
|
||||
Next server pin (optional)
|
||||
</label>
|
||||
<input
|
||||
id="agent-key"
|
||||
v-model="agentcollectorkey"
|
||||
id="agent-nextpin"
|
||||
v-model.trim="agentnextserverpin"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
placeholder="Paste the endpoint agent key generated above"
|
||||
maxlength="64"
|
||||
placeholder="64 lowercase hex characters"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Current server pin:
|
||||
<code>{{ agentserverpin || 'not available (no server certificate found)' }}</code>.
|
||||
Add the next certificate's pin only ahead of a planned certificate
|
||||
rotation.
|
||||
</p>
|
||||
<p v-if="agentnextpininvalid()" class="field-error">
|
||||
The next server pin must be exactly 64 lowercase hex characters.
|
||||
</p>
|
||||
|
||||
<label class="field-label" for="agent-cadence">
|
||||
Reporting cadence (seconds)
|
||||
@@ -287,9 +355,11 @@
|
||||
/>
|
||||
<p class="field-hint">
|
||||
For a reverse-proxy or internal-CA server this app cannot see, upload
|
||||
the server's public certificate (or issuing CA) in PEM format. It is
|
||||
validated, bundled with the package, and imported into the endpoint's
|
||||
Trusted Root store by <code>install.ps1</code>. Leave empty when the
|
||||
the server's public certificate in PEM format. It is validated,
|
||||
bundled with the package, pinned, and imported into the endpoint's
|
||||
Trusted Root store by <code>install.ps1</code> (a CA-flagged
|
||||
certificate is bundled and pinned but never imported, so only agents
|
||||
1.20 and newer will trust the server). Leave empty when the
|
||||
server uses a publicly trusted certificate. An upload takes precedence
|
||||
over the bundled server certificate above.
|
||||
</p>
|
||||
@@ -298,15 +368,183 @@
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
|
||||
:disabled="agentbusy || !agentbaseurl
|
||||
|| (agentincludesharedkey && !agentcollectorkey)
|
||||
|| agentnextpininvalid()"
|
||||
@click="buildSiteAgent"
|
||||
>
|
||||
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
|
||||
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
|
||||
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
||||
|
||||
<!-- ================================================================
|
||||
One-line install. An admin mints a short-lived token and hands a
|
||||
tech one command; the token only fetches the installer. The
|
||||
package it serves never carries the shared key, so the PC waits
|
||||
in Pending until an admin approves its enrollment. -->
|
||||
<div class="card bootstrap-block">
|
||||
<h4>One-line install</h4>
|
||||
<p class="field-hint">
|
||||
Generate a short-lived token and give a technician the command.
|
||||
On a PC with no agent, run it from an <strong>elevated</strong>
|
||||
PowerShell. The token only fetches the installer: it carries no
|
||||
shared key and provisions nothing on its own. A managed bay should
|
||||
keep getting the agent from GE-Enforce instead.
|
||||
</p>
|
||||
|
||||
<div class="bootstrap-inputs">
|
||||
<div>
|
||||
<label class="field-label" for="bootstrap-hours">
|
||||
Valid for (hours)
|
||||
</label>
|
||||
<input
|
||||
id="bootstrap-hours"
|
||||
v-model.number="bootstraphours"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="bootstrap-maxuses">
|
||||
Maximum uses
|
||||
</label>
|
||||
<input
|
||||
id="bootstrap-maxuses"
|
||||
v-model.number="bootstrapmaxuses"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="bootstrapbusy"
|
||||
@click="generateBootstrapToken"
|
||||
>
|
||||
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="bootstrapcommand">
|
||||
<p class="field-warning">
|
||||
Copy this now. The token is shown once and cannot be recovered.
|
||||
</p>
|
||||
<label class="field-label" for="bootstrap-command">
|
||||
Install command
|
||||
</label>
|
||||
<div class="bootstrap-command-row">
|
||||
<input
|
||||
id="bootstrap-command"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
readonly
|
||||
:value="bootstrapcommand"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
@click="copyBootstrapCommand"
|
||||
>
|
||||
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="bootstrapscripturl" class="field-hint">
|
||||
Or save the script and run it on the PC instead:
|
||||
<a
|
||||
class="bootstrap-download"
|
||||
:href="bootstrapscripturl"
|
||||
download="shopdb-bootstrap.ps1"
|
||||
>
|
||||
Download .ps1
|
||||
</a>
|
||||
(the same token; it carries the secret, so treat the file the
|
||||
way you treat the command).
|
||||
</p>
|
||||
<p v-if="bootstraptoken" class="field-hint">
|
||||
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
||||
{{ bootstraptoken.remaining }} of
|
||||
{{ bootstraptoken.maxuses }} uses remaining.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p class="field-hint">
|
||||
The PC installs with no shared key and then waits for approval in
|
||||
Settings > Endpoints > Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- The deprecated query form of the bootstrap token. Rendered here
|
||||
because this is the page that mints the command the setting
|
||||
governs: turning it off invalidates every command already handed
|
||||
out that carries ?t=. -->
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-querytoken"
|
||||
:checked="querytokenallowed"
|
||||
:disabled="querytokenbusy"
|
||||
@change="onQueryTokenToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Accept the token in the URL.</strong> On by default:
|
||||
the pasted one-line command and the Download .ps1 link above both
|
||||
send the token as <code>?t=</code>, which is the only form a
|
||||
browser download link can use. The token lands in the IIS and
|
||||
proxy logs that way, so the header form
|
||||
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
|
||||
and every install command still in a technician's hands stops
|
||||
working immediately - re-issue them first.
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
|
||||
|
||||
<table v-if="bootstraptokens.length" class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>State</th>
|
||||
<th>Expires</th>
|
||||
<th>Uses</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
|
||||
<td class="mono">{{ entry.tokenprefix }}</td>
|
||||
<td>{{ entry.state }}</td>
|
||||
<td>{{ formatDate(entry.expiresat) }}</td>
|
||||
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="entry.state === 'active'"
|
||||
class="btn-link"
|
||||
type="button"
|
||||
:disabled="bootstrapbusy"
|
||||
@click="revokeBootstrapToken(entry.tokenid)"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="field-hint">No install tokens yet.</p>
|
||||
|
||||
<p v-if="bootstrapmessage" class="field-hint">
|
||||
{{ bootstrapmessage }}
|
||||
</p>
|
||||
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Agent binary versions: upload a new build, keep the last few,
|
||||
and roll the current pointer back to a retained version. Staging
|
||||
@@ -319,9 +557,9 @@
|
||||
<div
|
||||
v-for="binary in binaries"
|
||||
:key="binary.key"
|
||||
class="binary-block"
|
||||
class="card binary-block"
|
||||
>
|
||||
<h4>{{ binary.label }}</h4>
|
||||
<h4 class="binary-title">{{ binary.label }}</h4>
|
||||
<p class="field-hint">
|
||||
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
||||
binaries available for your "Build Site Agent" install package.
|
||||
@@ -355,7 +593,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="binary.busy || !binary.version || !binary.file"
|
||||
:disabled="binary.busy || binary.detecting || !binary.file"
|
||||
@click="uploadBinary(binary.key)"
|
||||
>
|
||||
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
|
||||
@@ -370,8 +608,9 @@
|
||||
Upload legacy build for rollback
|
||||
</label>
|
||||
<p class="field-warning">
|
||||
WARNING: confirm agent version before uploading from the EXE
|
||||
properties and enter the version number.
|
||||
The version is read from the EXE itself. Leave this blank to use the
|
||||
binary's own file version, or enter a dotted-numeric override only
|
||||
when the build carries no readable version.
|
||||
</p>
|
||||
<div class="binary-upload">
|
||||
<input
|
||||
@@ -389,7 +628,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
|
||||
:disabled="binary.busy || !binary.legacyfile"
|
||||
@click="uploadLegacy(binary.key)"
|
||||
>
|
||||
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
|
||||
@@ -495,7 +734,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -508,6 +747,9 @@ const secret = ref('')
|
||||
const copied = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
// Enrollment progress, from the same counts the Enrollment page shows. Null
|
||||
// when the read fails; the key controls work without it.
|
||||
const enrollment = ref(null)
|
||||
|
||||
// Adoption tool: build a one-shot, agentless enrollment download.
|
||||
const adoptbaseurl = ref('')
|
||||
@@ -528,6 +770,38 @@ const agentcertfile = ref(null)
|
||||
const agentbusy = ref(false)
|
||||
const agentmessage = ref('')
|
||||
const agenterror = ref('')
|
||||
const agentwarning = ref('')
|
||||
const agentincludesharedkey = ref(true)
|
||||
const agentnextserverpin = ref('')
|
||||
const agentserverpin = ref('')
|
||||
|
||||
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
|
||||
// response that minted it: the list read never carries one, so a reload leaves
|
||||
// the field empty rather than re-showing a token nobody can recover.
|
||||
const bootstraphours = ref(24)
|
||||
const bootstrapmaxuses = ref(25)
|
||||
const bootstrapbusy = ref(false)
|
||||
const bootstrapcommand = ref('')
|
||||
const bootstrapscripturl = ref('')
|
||||
const bootstraptoken = ref(null)
|
||||
const bootstrapcopied = ref(false)
|
||||
const bootstraptokens = ref([])
|
||||
const bootstrapmessage = ref('')
|
||||
const bootstraperror = ref('')
|
||||
|
||||
// plugin.endpoints.bootstrapquerytoken, read and written through the core
|
||||
// settings API: the endpoints plugin has no settings route of its own for it,
|
||||
// and the key is declared in get_settings_defaults so the upsert on PUT gets
|
||||
// the boolean valuetype and the description from that declaration.
|
||||
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
const querytokenallowed = ref(true)
|
||||
const querytokenbusy = ref(false)
|
||||
const querytokenerror = ref('')
|
||||
|
||||
function agentnextpininvalid() {
|
||||
const pin = agentnextserverpin.value
|
||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||
}
|
||||
|
||||
// Versioned agent/tray binaries. Each block owns its own upload form, retained
|
||||
// version list, and busy/message state so the two rows act independently. The
|
||||
@@ -568,6 +842,40 @@ function filenameFromResponse(response, fallback) {
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
const enrolledPercent = computed(() => {
|
||||
const total = enrollment.value?.total || 0
|
||||
return total ? Math.round((enrollment.value.enrolled / total) * 100) : 0
|
||||
})
|
||||
|
||||
async function loadEnrollment() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/enrollment', { params: { perpage: 1 } })
|
||||
const data = response.data.data || {}
|
||||
const counts = data.counts || {}
|
||||
const enrolled = counts.enrolled || 0
|
||||
// pending PCs asked but hold no credential yet: still on the shared key.
|
||||
const notenrolled = (counts.notenrolled || 0) + (counts.pendingcomputers || 0)
|
||||
enrollment.value = {
|
||||
enrolled,
|
||||
notenrolled,
|
||||
total: enrolled + notenrolled,
|
||||
requests: (data.requests || []).filter(
|
||||
(entry) => entry.status === 'pending' && entry.reason !== 'reenroll blocked').length,
|
||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false
|
||||
}
|
||||
} catch (loadError) {
|
||||
enrollment.value = null
|
||||
console.error(loadError)
|
||||
}
|
||||
}
|
||||
|
||||
// The number of PCs a change to the shared key reaches, for the confirms.
|
||||
function notEnrolledPhrase() {
|
||||
if (!enrollment.value) return 'Every PC that has not enrolled'
|
||||
const count = enrollment.value.notenrolled
|
||||
return `${count} PC${count === 1 ? '' : 's'} not enrolled`
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
key.value = data.key || null
|
||||
envkeyfallback.value = !!data.envkeyfallback
|
||||
@@ -587,12 +895,17 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadEnrollment()
|
||||
loadBootstrapTokens()
|
||||
loadQueryTokenSetting()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
agentstaged.value = !!status.agentstaged
|
||||
certavailable.value = !!status.certavailable
|
||||
agentbundlecert.value = !!status.certavailable
|
||||
agentincludesharedkey.value = status.sharedkeyallowed !== false
|
||||
agentserverpin.value = status.serverpin || ''
|
||||
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
|
||||
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
|
||||
} catch (statusError) {
|
||||
@@ -600,6 +913,117 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQueryTokenSetting() {
|
||||
try {
|
||||
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
|
||||
// The backend reads it as bool(Setting.get(key, True)), so only an
|
||||
// explicit false is off. A row seeded as boolean comes back as a real
|
||||
// false; an older row that was created as a string comes back as 'false'.
|
||||
const value = response.data.data?.value
|
||||
querytokenallowed.value = !(value === false || value === 'false')
|
||||
} catch (loadError) {
|
||||
// 404 = the row was never seeded (plugin installed before the key was
|
||||
// declared). The backend default is on, so show on.
|
||||
if (loadError.response?.status !== 404) console.error(loadError)
|
||||
querytokenallowed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueryTokenToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (!turningOn) {
|
||||
const answer = prompt(
|
||||
'Stop accepting the token in the URL?\n\n'
|
||||
+ 'Every install command already handed out uses ?t= and stops '
|
||||
+ 'working at once, including the Download .ps1 links. Re-issue them '
|
||||
+ 'before turning this off.\n\n'
|
||||
+ 'Type OFF to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
||||
event.target.checked = true
|
||||
return
|
||||
}
|
||||
}
|
||||
querytokenbusy.value = true
|
||||
querytokenerror.value = ''
|
||||
try {
|
||||
// PUT upserts, so this works on a site whose row was never seeded.
|
||||
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
|
||||
querytokenallowed.value = turningOn
|
||||
} catch (saveError) {
|
||||
querytokenerror.value = saveError.response?.status === 403
|
||||
? 'Only an administrator can change this setting'
|
||||
: 'Could not save the setting'
|
||||
event.target.checked = querytokenallowed.value
|
||||
console.error(saveError)
|
||||
} finally {
|
||||
querytokenbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBootstrapTokens() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||
bootstraptokens.value = response.data.data?.tokens || []
|
||||
} catch (loadError) {
|
||||
bootstraptokens.value = []
|
||||
console.error(loadError)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBootstrapToken() {
|
||||
bootstrapbusy.value = true
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
bootstrapcommand.value = ''
|
||||
bootstrapscripturl.value = ''
|
||||
bootstraptoken.value = null
|
||||
bootstrapcopied.value = false
|
||||
try {
|
||||
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
|
||||
hours: bootstraphours.value,
|
||||
maxuses: bootstrapmaxuses.value
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
bootstrapcommand.value = data.command || ''
|
||||
bootstrapscripturl.value = data.scripturl || ''
|
||||
bootstraptoken.value = data.token || null
|
||||
bootstrapmessage.value = 'Install command generated.'
|
||||
await loadBootstrapTokens()
|
||||
} catch (generateError) {
|
||||
bootstraperror.value = generateError.response?.data?.data?.error?.message
|
||||
|| 'Could not generate an install token'
|
||||
console.error(generateError)
|
||||
} finally {
|
||||
bootstrapbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBootstrapCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bootstrapcommand.value)
|
||||
bootstrapcopied.value = true
|
||||
} catch (copyError) {
|
||||
console.error(copyError)
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeBootstrapToken(tokenid) {
|
||||
bootstrapbusy.value = true
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
try {
|
||||
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
|
||||
bootstrapmessage.value = 'Install token revoked.'
|
||||
await loadBootstrapTokens()
|
||||
} catch (revokeError) {
|
||||
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|
||||
|| 'Could not revoke the install token'
|
||||
console.error(revokeError)
|
||||
} finally {
|
||||
bootstrapbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyBinaryStatus(binkey, current, versions) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary) return
|
||||
@@ -609,8 +1033,9 @@ function applyBinaryStatus(binkey, current, versions) {
|
||||
|
||||
async function regenerate() {
|
||||
if (key.value &&
|
||||
!window.confirm('Regenerate the key? The current key stops working '
|
||||
+ 'immediately and every agent must be reconfigured.')) {
|
||||
!window.confirm('Regenerate the shared key? The current key stops working '
|
||||
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
|
||||
+ 'can report or enroll. Enrolled PCs are not affected.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
@@ -637,8 +1062,9 @@ async function regenerate() {
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
|
||||
+ 'until a new key is generated.')) {
|
||||
if (!window.confirm('Revoke the shared key? '
|
||||
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
|
||||
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
@@ -713,10 +1139,17 @@ async function buildSiteAgent() {
|
||||
agentbusy.value = true
|
||||
agentmessage.value = ''
|
||||
agenterror.value = ''
|
||||
agentwarning.value = ''
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('baseurl', agentbaseurl.value)
|
||||
form.append('collectorkey', agentcollectorkey.value)
|
||||
if (agentincludesharedkey.value) {
|
||||
form.append('collectorkey', agentcollectorkey.value)
|
||||
}
|
||||
form.append('includesharedkey', agentincludesharedkey.value ? 'true' : 'false')
|
||||
if (agentnextserverpin.value) {
|
||||
form.append('nextserverpin', agentnextserverpin.value)
|
||||
}
|
||||
form.append('cadenceseconds', agentcadenceseconds.value)
|
||||
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
|
||||
if (agentcertfile.value) {
|
||||
@@ -738,6 +1171,7 @@ async function buildSiteAgent() {
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
agentmessage.value = 'Site agent built.'
|
||||
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
|
||||
} catch (buildError) {
|
||||
if (buildError.response?.status === 400 && buildError.response?.data) {
|
||||
agenterror.value = await readBlobError(buildError.response.data)
|
||||
@@ -756,7 +1190,8 @@ async function detectVersion(binkey, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
return response.data.data?.version || ''
|
||||
}
|
||||
|
||||
@@ -789,7 +1224,7 @@ function onLegacyFileChange(binkey, event) {
|
||||
|
||||
async function uploadBinary(binkey) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary || !binary.file || !binary.version) return
|
||||
if (!binary || !binary.file) return
|
||||
binary.busy = true
|
||||
binary.message = ''
|
||||
binary.error = ''
|
||||
@@ -798,7 +1233,8 @@ async function uploadBinary(binkey) {
|
||||
form.append('version', binary.version)
|
||||
form.append('file', binary.file)
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const data = response.data.data || {}
|
||||
applyBinaryStatus(binkey, data.current, data.versions)
|
||||
binary.version = ''
|
||||
@@ -817,7 +1253,7 @@ async function uploadBinary(binkey) {
|
||||
|
||||
async function uploadLegacy(binkey) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary || !binary.legacyfile || !binary.legacyversion) return
|
||||
if (!binary || !binary.legacyfile) return
|
||||
binary.busy = true
|
||||
binary.message = ''
|
||||
binary.error = ''
|
||||
@@ -827,7 +1263,8 @@ async function uploadLegacy(binkey) {
|
||||
form.append('file', binary.legacyfile)
|
||||
form.append('makecurrent', 'false')
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const data = response.data.data || {}
|
||||
applyBinaryStatus(binkey, data.current, data.versions)
|
||||
binary.legacyversion = ''
|
||||
@@ -903,13 +1340,39 @@ function editConfiguration() {
|
||||
}
|
||||
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
||||
.settings-section { margin-bottom: 2rem; }
|
||||
.enrollment-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.enrollment-count { font-size: 0.9rem; }
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border, #333);
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-success, #4caf50);
|
||||
}
|
||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||
.info-list li { margin-bottom: 0.5rem; }
|
||||
/* Each agent is its own nested card inside the collapsible, so the two
|
||||
binaries read as distinct units instead of one continuous wall of
|
||||
controls separated by a hairline rule. */
|
||||
.binary-block {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.binary-block:first-of-type { margin-top: 0.75rem; }
|
||||
.binary-title {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
.binary-upload {
|
||||
display: flex;
|
||||
@@ -969,6 +1432,23 @@ function editConfiguration() {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
.bootstrap-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.bootstrap-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bootstrap-inputs .form-control { max-width: 9rem; }
|
||||
.bootstrap-command-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.bootstrap-command-row .form-control { flex: 1; }
|
||||
.current-tag {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-success, #4caf50);
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Delivery Test Bench</h2>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
|
||||
run_script delivery path end to end and to take notes for the real UI
|
||||
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
|
||||
production delivery UI lands. Nothing here is a supported operator surface.
|
||||
-->
|
||||
<div class="card dev-note">
|
||||
<strong>Development test vehicle.</strong>
|
||||
This bench pushes an allowlisted delivery command to one endpoint and reads
|
||||
back the result. The agent only runs a script name it already holds in its
|
||||
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
|
||||
Both actions require the <code>endpoints.deploy</code> permission.
|
||||
</div>
|
||||
|
||||
<div v-if="!candeploy" class="card load-error">
|
||||
Your account lacks the <code>endpoints.deploy</code> permission, so no
|
||||
delivery command can be queued. Ask an administrator to grant it.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 1. Pick the target endpoint -->
|
||||
<div class="card bench-section">
|
||||
<h3>1. Target endpoint</h3>
|
||||
<div class="bench-row">
|
||||
<input
|
||||
v-model="endpointSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search hostname or asset..."
|
||||
@keyup.enter="loadEndpoints"
|
||||
/>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
|
||||
</div>
|
||||
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
|
||||
<option value="">Select an endpoint...</option>
|
||||
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
|
||||
{{ row.hostname }} (asset {{ row.assetid }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 2. Run a ShopDB-hosted script from the library -->
|
||||
<div class="card bench-section">
|
||||
<h3>2. Run script</h3>
|
||||
<p class="bench-hint">
|
||||
Pick a script from the ShopDB script library. The agent fetches the
|
||||
script body by digest with its collector credential and runs it in the
|
||||
script's configured run context (system or the interactive user).
|
||||
</p>
|
||||
<div class="bench-row">
|
||||
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
|
||||
<option value="">Select script...</option>
|
||||
<option v-for="script in scripts" :key="script.id" :value="script.id">
|
||||
{{ script.scriptname }} ({{ script.runcontext }})
|
||||
<template v-if="!script.sha256"> (no body - upload first)</template>
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
:disabled="busy || !selectedAssetId || !selectedScriptId"
|
||||
@click="pushScript"
|
||||
>
|
||||
Push run_script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Install/update an application -->
|
||||
<div class="card bench-section">
|
||||
<h3>3. Install/update application</h3>
|
||||
<p class="bench-hint">
|
||||
Pick an application, then the app-level install source (the installer
|
||||
uploaded on the application itself). The agent fetches the pinned
|
||||
package by digest with its collector credential and runs it silently.
|
||||
</p>
|
||||
<div class="bench-row">
|
||||
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
|
||||
<option value="">Select application...</option>
|
||||
<option v-for="app in applications" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
|
||||
<option value="">Select install source...</option>
|
||||
<option v-for="source in installSources" :key="source.value" :value="source.value">
|
||||
{{ source.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
:disabled="busy || !selectedAssetId || !selectedSource"
|
||||
@click="pushUpdateApp"
|
||||
>
|
||||
Push update_app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Command results (the Web UI log pane) -->
|
||||
<div class="card bench-section">
|
||||
<div class="bench-row bench-row-between">
|
||||
<h3>4. Command log (Web UI side)</h3>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
|
||||
<table v-else-if="commands.length" class="table bench-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="command in commands" :key="command.commandid">
|
||||
<td>{{ formatWhen(command.createdat) }}</td>
|
||||
<td>{{ command.action }}</td>
|
||||
<td>{{ command.target || '-' }}</td>
|
||||
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
|
||||
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
|
||||
|
||||
const busy = ref(false)
|
||||
|
||||
const endpointSearch = ref('')
|
||||
const endpoints = ref([])
|
||||
const selectedAssetId = ref('')
|
||||
|
||||
const scripts = ref([])
|
||||
const selectedScriptId = ref('')
|
||||
|
||||
const applications = ref([])
|
||||
const selectedAppId = ref('')
|
||||
const installSources = ref([])
|
||||
const selectedSource = ref('')
|
||||
|
||||
const commands = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (!candeploy.value) return
|
||||
loadEndpoints()
|
||||
loadApplications()
|
||||
loadScripts()
|
||||
})
|
||||
|
||||
async function loadEndpoints() {
|
||||
busy.value = true
|
||||
try {
|
||||
const params = { perpage: 100 }
|
||||
if (endpointSearch.value) params.search = endpointSearch.value
|
||||
const response = await endpointsApi.list(params)
|
||||
endpoints.value = response.data.data || []
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not load endpoints'))
|
||||
endpoints.value = []
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
try {
|
||||
applications.value = await applicationsApi.listAll()
|
||||
} catch (error) {
|
||||
applications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
try {
|
||||
scripts.value = await scriptsApi.listAll()
|
||||
} catch (error) {
|
||||
scripts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Build the install-source options for the chosen application: the app-level
|
||||
// source (the installer uploaded on the application itself). The wire target is
|
||||
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
|
||||
async function loadSources() {
|
||||
selectedSource.value = ''
|
||||
installSources.value = []
|
||||
if (!selectedAppId.value) return
|
||||
|
||||
const sources = []
|
||||
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
|
||||
if (app && app.installpath) {
|
||||
sources.push({
|
||||
value: `app:${app.appid}`,
|
||||
label: app.installersha256
|
||||
? 'App-level installer'
|
||||
: 'App-level installer (no digest - upload package first)',
|
||||
})
|
||||
}
|
||||
|
||||
installSources.value = sources
|
||||
}
|
||||
|
||||
async function pushScript() {
|
||||
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
|
||||
}
|
||||
|
||||
async function pushUpdateApp() {
|
||||
await enqueue('update_app', selectedSource.value, 'update_app')
|
||||
}
|
||||
|
||||
async function enqueue(action, target, label) {
|
||||
if (!selectedAssetId.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
|
||||
toast.success(`${label} queued`)
|
||||
await loadCommands()
|
||||
} catch (error) {
|
||||
// 409 = a command is already in flight for this endpoint (at-most-one queue).
|
||||
const status = error.response?.status
|
||||
if (status === 409) {
|
||||
toast.info('A command is already in flight for this endpoint')
|
||||
} else {
|
||||
toast.error(apiError(error, `${label} failed`))
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
if (!selectedAssetId.value) return
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(selectedAssetId.value)
|
||||
commands.value = response.data.data || []
|
||||
} catch (error) {
|
||||
commands.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso) {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'succeeded') return 'ok'
|
||||
if (status === 'failed') return 'bad'
|
||||
if (status === 'expired') return 'bad'
|
||||
if (status === 'running') return 'warn'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-note {
|
||||
border-left: 3px solid var(--warning, #d6a012);
|
||||
}
|
||||
.bench-section h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bench-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.bench-row-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bench-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.bench-table {
|
||||
width: 100%;
|
||||
}
|
||||
.result-cell {
|
||||
max-width: 32rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.status-badge.ok {
|
||||
color: var(--success, #2e7d32);
|
||||
}
|
||||
.status-badge.bad {
|
||||
color: var(--danger, #c62828);
|
||||
}
|
||||
.status-badge.warn {
|
||||
color: var(--warning, #d6a012);
|
||||
}
|
||||
.load-error {
|
||||
color: var(--danger, #c62828);
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,12 @@
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="never">Never reported</option>
|
||||
</select>
|
||||
<select v-model="credentialstate" class="form-control fleet-filter" @change="applyFilters">
|
||||
<option value="">All credentials</option>
|
||||
<option v-for="option in credentialOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
|
||||
<option value="">All PC types</option>
|
||||
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
|
||||
@@ -61,6 +67,16 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for the staged agent rollout. Shown only to operators who
|
||||
hold the grant it will require, so it reads as "not built yet" to the
|
||||
people waiting on it rather than as a missing permission to everyone
|
||||
else. This page is due a refactor; the utility lands with that work. -->
|
||||
<div v-if="canagentupdate" class="rollout-note">
|
||||
Bulk Agent Update utility coming soon. Until then, agents update through
|
||||
the existing installer push, and an endpoint behind the published build is
|
||||
flagged on its own detail page.
|
||||
</div>
|
||||
|
||||
<!-- Actions live here rather than per row. A tenth column of buttons
|
||||
pushed itself off the edge of a nowrap table, and selecting one row
|
||||
costs a single click while giving one command path instead of two. -->
|
||||
@@ -87,10 +103,13 @@
|
||||
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
|
||||
</th>
|
||||
<th>Status</th>
|
||||
<th>Alias</th>
|
||||
<th>Hostname</th>
|
||||
<th>Machine</th>
|
||||
<th>PC Type</th>
|
||||
<th>Health</th>
|
||||
<th>Agent Version</th>
|
||||
<th>Credential</th>
|
||||
<th>Logged-in User</th>
|
||||
<th>Uptime</th>
|
||||
<th>Last Reported</th>
|
||||
@@ -110,6 +129,7 @@
|
||||
<td>
|
||||
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
|
||||
</td>
|
||||
<td>{{ item.alias || '-' }}</td>
|
||||
<td>{{ item.hostname || '-' }}</td>
|
||||
<td>
|
||||
<!-- What stops if this PC is dead. On a shop floor that is
|
||||
@@ -123,12 +143,18 @@
|
||||
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.agentversion || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="item.credentialstate" class="credential-chip"
|
||||
:class="item.credentialstate">{{ credentialLabel(item.credentialstate) }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.loggedinuser || '-' }}</td>
|
||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
|
||||
<td :colspan="cancommand ? 12 : 11" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -178,6 +204,17 @@ const sort = ref('status')
|
||||
const summary = ref(null)
|
||||
const criticalcount = ref(0)
|
||||
const computertypeid = ref('')
|
||||
// Which credential the PC reports with, computed server-side so the filter
|
||||
// holds across pages. Read-only here: revoke and re-enrollment live on the PC
|
||||
// detail page and the Enrollment settings page.
|
||||
const credentialstate = ref('')
|
||||
const credentialOptions = [
|
||||
{ value: 'enrolled', label: 'Enrolled' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'reenrollblocked', label: 'Re-enroll blocked' },
|
||||
{ value: 'sharedkey', label: 'Shared key (not enrolled)' },
|
||||
{ value: 'notreporting', label: 'Not reporting' }
|
||||
]
|
||||
const locationid = ref('')
|
||||
const problemsonly = ref(false)
|
||||
const computertypes = ref([])
|
||||
@@ -190,6 +227,9 @@ const toast = useToast()
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
// Reserved for the staged rollout. Agent replacement is the control plane
|
||||
// modifying itself, so it stays separate from ordinary app deployment.
|
||||
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
|
||||
|
||||
const allselected = computed(() =>
|
||||
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
|
||||
@@ -274,23 +314,26 @@ onMounted(() => {
|
||||
async function loadFilterOptions() {
|
||||
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
|
||||
// location list would silently lose its tail in a dropdown.
|
||||
try {
|
||||
computertypes.value = await computersApi.types.listAll()
|
||||
} catch (error) {
|
||||
computertypes.value = []
|
||||
}
|
||||
try {
|
||||
locations.value = await locationsApi.listAll()
|
||||
} catch (error) {
|
||||
locations.value = []
|
||||
}
|
||||
// Issued together: neither list feeds the other, so awaiting them in sequence
|
||||
// only added a round trip to first paint. Each keeps its own fallback so one
|
||||
// failing dropdown does not empty the other.
|
||||
const [types, places] = await Promise.all([
|
||||
computersApi.types.listAll().catch(() => []),
|
||||
locationsApi.listAll().catch(() => [])
|
||||
])
|
||||
computertypes.value = types
|
||||
locations.value = places
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const response = await endpointsApi.dashboardSummary()
|
||||
// Issued together: the stability call does not depend on the summary, so
|
||||
// sequencing them just stacked the slower request behind the faster one.
|
||||
const [response, stability] = await Promise.all([
|
||||
endpointsApi.dashboardSummary(),
|
||||
endpointsApi.dashboardStability()
|
||||
])
|
||||
summary.value = response.data.data || null
|
||||
const stability = await endpointsApi.dashboardStability()
|
||||
criticalcount.value = (stability.data.data || []).length
|
||||
} catch (error) {
|
||||
// The strip is a convenience, not the page. If it fails the table still
|
||||
@@ -309,6 +352,7 @@ async function loadEndpoints() {
|
||||
if (search.value) params.search = search.value
|
||||
if (status.value) params.status = status.value
|
||||
if (sort.value) params.sort = sort.value
|
||||
if (credentialstate.value) params.credentialstate = credentialstate.value
|
||||
if (computertypeid.value) params.computertypeid = computertypeid.value
|
||||
if (locationid.value) params.locationid = locationid.value
|
||||
|
||||
@@ -348,6 +392,11 @@ function openPc(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function credentialLabel(value) {
|
||||
const option = credentialOptions.find(entry => entry.value === value)
|
||||
return option ? option.label : value
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
||||
}
|
||||
@@ -374,6 +423,7 @@ function setStatus(value) {
|
||||
|
||||
function clearFilters() {
|
||||
status.value = ''
|
||||
credentialstate.value = ''
|
||||
problemsonly.value = false
|
||||
applyFilters()
|
||||
}
|
||||
@@ -495,6 +545,22 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.fleet-filter { max-width: 200px; }
|
||||
|
||||
.credential-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.credential-chip.enrolled { color: var(--success-dark); }
|
||||
.credential-chip.pending { color: var(--warning-dark); }
|
||||
.credential-chip.reenrollblocked { color: var(--danger); }
|
||||
[data-theme="dark"] .credential-chip.enrolled { color: var(--success); }
|
||||
[data-theme="dark"] .credential-chip.pending { color: var(--warning); }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -508,6 +574,18 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
||||
|
||||
/* Deliberately quieter than .bulk-bar: this announces something that does not
|
||||
exist yet, so it must not compete with controls that actually do work. */
|
||||
.rollout-note {
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.select-cell { width: 1%; }
|
||||
|
||||
|
||||
|
||||
1254
plugins/endpoints/frontend/views/EnrollmentSettings.vue
Normal file
1254
plugins/endpoints/frontend/views/EnrollmentSettings.vue
Normal file
File diff suppressed because it is too large
Load Diff
640
plugins/endpoints/frontend/views/ServerCertSettings.vue
Normal file
640
plugins/endpoints/frontend/views/ServerCertSettings.vue
Normal file
@@ -0,0 +1,640 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Server Certificate</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
The TLS certificate PCs see when they connect to this site, whether it is
|
||||
served by IIS or by the Docker stack. Its public key pin goes into the
|
||||
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
|
||||
trusted root entry for agents older than 1.20.
|
||||
</p>
|
||||
|
||||
<!-- Certificate status -->
|
||||
<section class="settings-section">
|
||||
<h3>Current certificate</h3>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="field-hint">Loading certificate details...</div>
|
||||
|
||||
<template v-else-if="cert">
|
||||
<div v-if="cert.expires_in_days < 30" class="warning-banner">
|
||||
<strong>Warning:</strong> Certificate expires in {{ cert.expires_in_days }} days
|
||||
({{ formatDate(cert.not_after) }}). Renew soon to avoid service disruption.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Common Name (CN)</label>
|
||||
<p class="field-hint mono">{{ cert.subject_cn }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subject Alternative Names</label>
|
||||
<p class="field-hint mono">{{ cert.san_list.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Valid From</label>
|
||||
<p class="field-hint">{{ formatDate(cert.not_before) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Valid Until</label>
|
||||
<p class="field-hint" :class="{ 'expiry-warning': cert.expires_in_days < 30 }">
|
||||
{{ formatDate(cert.not_after) }}
|
||||
<span v-if="cert.expires_in_days >= 0">
|
||||
({{ cert.expires_in_days }} days remaining)
|
||||
</span>
|
||||
<span v-else class="expired-text">
|
||||
(EXPIRED {{ Math.abs(cert.expires_in_days) }} days ago)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Thumbprint (SHA256)</label>
|
||||
<p class="field-hint mono">{{ cert.thumbprint }}</p>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyThumbprint">
|
||||
{{ thumbprintCopied ? 'Copied' : 'Copy thumbprint' }}
|
||||
</button>
|
||||
<p class="field-hint">
|
||||
Use this value for ServerCertThumbprint in agent appsettings.json
|
||||
to enable certificate pinning (prevents MITM attacks).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group spki-pin">
|
||||
<label>Public key pin (SPKI SHA-256)</label>
|
||||
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
|
||||
<p v-else class="field-hint">Not reported by this server.</p>
|
||||
<button
|
||||
v-if="cert.spkisha256"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="copyPin"
|
||||
>
|
||||
{{ pinCopied ? 'Copied' : 'Copy pin' }}
|
||||
</button>
|
||||
<p class="field-hint">
|
||||
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
|
||||
(-Pins) so agents 1.20 and later trust only this server's key.
|
||||
It survives a renewal that keeps the same private key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Issuer</label>
|
||||
<p class="field-hint mono">{{ cert.issuer }}</p>
|
||||
<span v-if="cert.is_self_signed" class="badge">Self-signed</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="field-hint">No certificate file found at certs/server.crt</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live check: what the public address really presents -->
|
||||
<section class="settings-section">
|
||||
<h3>What PCs receive</h3>
|
||||
<p class="field-hint">
|
||||
Connects to this site's base URL and reads the certificate it presents,
|
||||
the way a PC does. An IIS binding or a proxy in front of the app can
|
||||
present a different certificate than the file above.
|
||||
</p>
|
||||
<div class="card form-card">
|
||||
<div class="form-group live-check">
|
||||
<label>Presented public key pin (SPKI SHA-256)</label>
|
||||
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
|
||||
<template v-else-if="live">
|
||||
<p v-if="!live.configured" class="field-hint live-unconfigured">
|
||||
No site base URL is set (site_base_url, under Settings, Email /
|
||||
SMTP, or in the setup wizard), so there is no address to check.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
|
||||
<p v-if="live.error" class="live-error">{{ live.error }}</p>
|
||||
<template v-else>
|
||||
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
|
||||
{{ live.presentedspkisha256 }}
|
||||
</p>
|
||||
<p v-if="live.matchesfile" class="live-match live-status">
|
||||
Matches the pin of the certificate file above.
|
||||
</p>
|
||||
<div v-else class="live-warning">
|
||||
<strong>The site presents a different certificate than the file above.</strong>
|
||||
PCs will be pinned to the certificate they actually receive; use the
|
||||
presented pin.
|
||||
<div class="live-pins">
|
||||
<span>File pin:</span>
|
||||
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
|
||||
<span>Presented pin:</span>
|
||||
<span class="mono">{{ live.presentedspkisha256 }}</span>
|
||||
<span>Presented thumbprint:</span>
|
||||
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
|
||||
{{ liveLoading ? 'Checking...' : 'Recheck' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Generate self-signed certificate -->
|
||||
<section class="settings-section">
|
||||
<h3>Generate self-signed certificate</h3>
|
||||
<p class="field-hint">
|
||||
Create a new self-signed certificate for development or air-gapped sites.
|
||||
The certificate will be valid for 1 year (365 days).
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div class="form-group">
|
||||
<label for="cn">Common Name (CN)</label>
|
||||
<input
|
||||
id="cn"
|
||||
v-model="generateForm.cn"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="hostname or IP"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Hostname or IP that agents use to connect, exactly as it appears in
|
||||
the agent's base URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="san">Subject Alternative Names (SANs)</label>
|
||||
<input
|
||||
id="san"
|
||||
v-model="generateForm.san"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="DNS:hostname,IP:address"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Comma-separated list of DNS:hostname and IP:address entries. Include
|
||||
every name and address a bay may use to reach this server; a name
|
||||
that is missing here fails certificate validation on the endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="days">Validity (days)</label>
|
||||
<input
|
||||
id="days"
|
||||
v-model.number="generateForm.days"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
max="3650"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Certificate lifetime (recommended: 365 for self-signed)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="warning-banner" v-if="cert">
|
||||
<strong>Warning:</strong> Generating a new certificate will replace the current one.
|
||||
Agents using ServerCertThumbprint pinning will reject connections until you update
|
||||
their appsettings.json with the new thumbprint. A new certificate also has a new
|
||||
key, so agents with ServerPins set will reject it until the pin entry carries the
|
||||
new pin as well. Generating here gives no window to do that; to rotate without an
|
||||
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
|
||||
it to the pin entry alongside the old one, and upload that certificate below.
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="generateCertificate"
|
||||
:disabled="busy || !generateForm.cn"
|
||||
>
|
||||
{{ busy ? 'Generating...' : 'Generate self-signed certificate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Upload certificate -->
|
||||
<section class="settings-section">
|
||||
<h3>Upload certificate</h3>
|
||||
<p class="field-hint">
|
||||
Upload a certificate from a trusted CA or internal PKI. You must provide
|
||||
both the certificate (.crt or .pem) and private key (.key).
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div class="form-group">
|
||||
<label for="cert-file">Certificate file</label>
|
||||
<input
|
||||
id="cert-file"
|
||||
type="file"
|
||||
accept=".crt,.pem"
|
||||
@change="onCertFileChange"
|
||||
class="form-control"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
PEM-encoded certificate (.crt or .pem)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="key-file">Private key file</label>
|
||||
<input
|
||||
id="key-file"
|
||||
type="file"
|
||||
accept=".key,.pem"
|
||||
@change="onKeyFileChange"
|
||||
class="form-control"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
PEM-encoded private key (.key or .pem, unencrypted)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="warning-banner" v-if="cert">
|
||||
<strong>Warning:</strong> Uploading a new certificate will replace the current one.
|
||||
Nginx will be restarted automatically.
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="uploadCertificate"
|
||||
:disabled="busy || !uploadForm.certFile || !uploadForm.keyFile"
|
||||
>
|
||||
{{ busy ? 'Uploading...' : 'Upload certificate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Documentation -->
|
||||
<section class="settings-section">
|
||||
<div class="card form-card">
|
||||
<details class="collapsible">
|
||||
<summary><h4>Certificate requirements</h4></summary>
|
||||
<ul class="field-hint info-list">
|
||||
<li>
|
||||
<strong>Format:</strong> PEM-encoded X.509 certificate and RSA/ECDSA private key
|
||||
</li>
|
||||
<li>
|
||||
<strong>Private key:</strong> Must be unencrypted (no passphrase)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Common Name:</strong> Must match the hostname agents use to connect
|
||||
</li>
|
||||
<li>
|
||||
<strong>Subject Alternative Names:</strong> Include all hostnames and IPs agents might use
|
||||
</li>
|
||||
<li>
|
||||
<strong>Trust:</strong> Self-signed certs require ServerCertThumbprint pinning on agents,
|
||||
or agents must disable validation (development only)
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="collapsible">
|
||||
<summary><h4>Agent certificate pinning</h4></summary>
|
||||
<p class="field-hint">
|
||||
To prevent MITM attacks with self-signed certificates, configure agents to validate
|
||||
the server certificate thumbprint:
|
||||
</p>
|
||||
<ol class="field-hint info-list">
|
||||
<li>Copy the SHA256 thumbprint from the "Current certificate" section above</li>
|
||||
<li>Update each agent's appsettings.json:
|
||||
<pre class="code-block">{
|
||||
"ServerCertThumbprint": "AA:BB:CC:..."
|
||||
}</pre>
|
||||
</li>
|
||||
<li>Restart the ShopDbEndpointAgent service on each agent</li>
|
||||
</ol>
|
||||
<p class="field-hint">
|
||||
<strong>Important:</strong> When you renew the server certificate, you must update
|
||||
ServerCertThumbprint on all agents before the old certificate expires, or agents
|
||||
will reject connections.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="collapsible">
|
||||
<summary><h4>Air-gapped deployment</h4></summary>
|
||||
<p class="field-hint">
|
||||
For air-gapped sites, generate the certificate on the work PC and include it in
|
||||
the deployment bundle:
|
||||
</p>
|
||||
<ol class="field-hint info-list">
|
||||
<li>Generate certificate using this page (use site-specific CN/SANs)</li>
|
||||
<li>Certificate files are saved to certs/ directory (already in git)</li>
|
||||
<li>Commit and bundle the code (certificate included automatically)</li>
|
||||
<li>Deploy bundle to site - nginx picks up certificate on startup</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
const cert = ref(null)
|
||||
const live = ref(null)
|
||||
const liveLoading = ref(false)
|
||||
const thumbprintCopied = ref(false)
|
||||
const pinCopied = ref(false)
|
||||
|
||||
// No baked host or address (ADR-015: no site literals in product code). One
|
||||
// site's LAN address was shipped here as the default SAN, which every other
|
||||
// site would have generated a certificate for. The operator types what this
|
||||
// server is actually reached by; the form refuses to submit empty.
|
||||
const generateForm = ref({
|
||||
cn: '',
|
||||
san: '',
|
||||
days: 365
|
||||
})
|
||||
|
||||
const uploadForm = ref({
|
||||
certFile: null,
|
||||
keyFile: null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadCertificate()
|
||||
loadLive()
|
||||
})
|
||||
|
||||
// What the site's public address actually presents. A proxy or IIS binding
|
||||
// can serve a different certificate than the file this page manages, and a PC
|
||||
// pins what it receives.
|
||||
async function loadLive() {
|
||||
liveLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/endpoints/settings/server-cert/live')
|
||||
live.value = response.data.data || null
|
||||
} catch (err) {
|
||||
live.value = {
|
||||
configured: true,
|
||||
url: live.value?.url || '',
|
||||
error: `Could not run the check: ${err.message}`
|
||||
}
|
||||
} finally {
|
||||
liveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCertificate() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await api.get('/endpoints/settings/server-cert')
|
||||
if (response.data.status === 'success') {
|
||||
cert.value = response.data.data
|
||||
} else {
|
||||
error.value = response.data.data?.error?.message || 'Failed to load certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to load certificate: ${err.message}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCertificate() {
|
||||
if (!generateForm.value.cn) {
|
||||
error.value = 'Common Name is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(
|
||||
'Generate a new self-signed certificate? This will replace the current certificate. ' +
|
||||
'Agents using thumbprint pinning will need to be reconfigured.'
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await api.post('/endpoints/settings/server-cert/generate', generateForm.value)
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate generated successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
} else {
|
||||
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to generate certificate: ${err.message}`
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCertFileChange(event) {
|
||||
uploadForm.value.certFile = event.target.files[0]
|
||||
}
|
||||
|
||||
function onKeyFileChange(event) {
|
||||
uploadForm.value.keyFile = event.target.files[0]
|
||||
}
|
||||
|
||||
async function uploadCertificate() {
|
||||
if (!uploadForm.value.certFile || !uploadForm.value.keyFile) {
|
||||
error.value = 'Both certificate and private key files are required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(
|
||||
'Upload new certificate? This will replace the current certificate and restart nginx.'
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('cert', uploadForm.value.certFile)
|
||||
formData.append('key', uploadForm.value.keyFile)
|
||||
|
||||
const response = await api.post('/endpoints/settings/server-cert/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
// Clear file inputs
|
||||
document.getElementById('cert-file').value = ''
|
||||
document.getElementById('key-file').value = ''
|
||||
uploadForm.value.certFile = null
|
||||
uploadForm.value.keyFile = null
|
||||
} else {
|
||||
error.value = response.data.data?.error?.message || 'Failed to upload certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to upload certificate: ${err.message}`
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyThumbprint() {
|
||||
navigator.clipboard.writeText(cert.value.thumbprint)
|
||||
thumbprintCopied.value = true
|
||||
setTimeout(() => {
|
||||
thumbprintCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function copyPin() {
|
||||
navigator.clipboard.writeText(cert.value.spkisha256)
|
||||
pinCopied.value = true
|
||||
setTimeout(() => {
|
||||
pinCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.warning-banner {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.warning-banner {
|
||||
background: #664d03;
|
||||
border-color: #ffca2c;
|
||||
color: #ffecb5;
|
||||
}
|
||||
}
|
||||
|
||||
.expiry-warning {
|
||||
color: #d63384;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expired-text {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.code-block,
|
||||
pre.code-block {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
color: #212529;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Dark mode support for code blocks */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.code-block {
|
||||
background: #2b2b2b !important;
|
||||
border-color: #444 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Ensure pre tags inside lists also get dark mode */
|
||||
.info-list .code-block,
|
||||
.field-hint .code-block,
|
||||
pre.code-block {
|
||||
background: #2b2b2b !important;
|
||||
border-color: #444 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.live-match { color: var(--success-dark); }
|
||||
.live-mismatch { color: var(--danger); }
|
||||
.live-status { font-size: 13px; margin: 4px 0; }
|
||||
[data-theme="dark"] .live-match { color: var(--success); }
|
||||
|
||||
.live-error,
|
||||
.live-warning {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--danger);
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-pins {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 12px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-list li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
443
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
443
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>System Scripts</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
PowerShell the site agent can be told to run on an endpoint. A script is
|
||||
stored here once, pinned by digest, and fetched by the agent at push time
|
||||
so the bytes that run are the bytes that were reviewed.
|
||||
</p>
|
||||
|
||||
<div class="warning-banner">
|
||||
<strong>Every script here runs as SYSTEM.</strong>
|
||||
There is no user context and no prompt: the agent is a service, so a
|
||||
script cannot see a signed-in person's desktop, mapped drives, or
|
||||
credentials. Scripts are intended to TRIGGER an already registered
|
||||
scheduled task on the endpoint, which is what does the real work. Anything
|
||||
written to act "as the logged-on user" will not behave the way it reads.
|
||||
</div>
|
||||
|
||||
<details class="explainer">
|
||||
<summary>How does this work?</summary>
|
||||
<div class="explainer-body">
|
||||
<h4>Delivery</h4>
|
||||
<p>
|
||||
The body is stored here and hashed. At push time the agent downloads it
|
||||
with its own credential and checks the digest before running anything,
|
||||
so the bytes that run are the bytes that were reviewed. The body never
|
||||
travels on the command channel and is deleted from the endpoint after
|
||||
the run.
|
||||
</p>
|
||||
|
||||
<h4>Runs in</h4>
|
||||
<p>
|
||||
<strong>System</strong> is the agent's own service account: no desktop,
|
||||
no mapped drives, no signed-in person's credentials.
|
||||
<strong>User</strong> runs in the interactive session instead, and only
|
||||
works when somebody is actually signed in.
|
||||
</p>
|
||||
|
||||
<h4>Run in folder</h4>
|
||||
<p>
|
||||
The working directory for the run. Blank means the agent's staging
|
||||
folder, which is right for a self-contained script. Set it when the
|
||||
script calls a tool installed separately on the endpoint by relative
|
||||
path. If the folder is missing the run fails outright rather than
|
||||
running somewhere unintended.
|
||||
</p>
|
||||
|
||||
<h4>When a line fails</h4>
|
||||
<p>
|
||||
This is the one worth understanding. PowerShell treats most errors as
|
||||
<em>non-terminating</em>: it writes the error, carries on to the next
|
||||
line, and still exits zero. Because the command's verdict comes from
|
||||
that exit code, <strong>Continue</strong> means a script can report
|
||||
success having accomplished nothing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stop</strong> makes the first error end the run, so the verdict
|
||||
means what you would assume it means. It is not the default because it
|
||||
changes how a script behaves: a body that tolerates a failing cleanup,
|
||||
probes with a cmdlet that errors when something is absent, or loops
|
||||
past bad items will start aborting partway through, leaving the
|
||||
endpoint half-configured. Read the script first, then switch it on.
|
||||
</p>
|
||||
|
||||
<h4>Where the output goes</h4>
|
||||
<p>
|
||||
Script output is always written to the agent log on the endpoint. It is
|
||||
sent back to ShopDB only when the run fails, so routine output stays on
|
||||
the machine that produced it. Long output is truncated and marked as
|
||||
such.
|
||||
</p>
|
||||
<p class="hint">
|
||||
In Stop mode the agent adds one line to the top of the script, so an
|
||||
error reported on line N refers to line N-1 of the body shown here.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Runs in</th>
|
||||
<th>On error</th>
|
||||
<th>Body</th>
|
||||
<th>Digest</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in scripts" :key="s.scriptid">
|
||||
<td>{{ s.scriptname }}</td>
|
||||
<td class="cell-truncate" :title="s.scriptdescription">
|
||||
{{ s.scriptdescription || '-' }}
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.workingdirectory">
|
||||
{{ s.workingdirectory || 'Agent staging folder' }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
||||
Stop on first error
|
||||
</span>
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
<span
|
||||
v-if="s.captureoutput"
|
||||
class="badge badge-secondary"
|
||||
title="A successful run returns its output to ShopDB"
|
||||
>
|
||||
Returns output
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
<span v-else class="badge badge-warning">No body - cannot run</span>
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
|
||||
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="scripts.length === 0">
|
||||
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
||||
No scripts
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="warning-banner">
|
||||
<strong>This script will run as SYSTEM on every endpoint it is
|
||||
pushed to.</strong>
|
||||
Write it to trigger a scheduled task that already exists on the
|
||||
endpoint. It has no user session, so anything depending on a
|
||||
person being signed in will not work.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.scriptname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description <span class="hint">(what it triggers, and why)</span></label>
|
||||
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Script file <span class="hint">(.ps1, replaces the stored body)</span>
|
||||
</label>
|
||||
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
|
||||
<p v-if="editing && editing.hasbody" class="hint">
|
||||
Current: {{ editing.filename }}. Uploading replaces it and
|
||||
restamps the digest.
|
||||
</p>
|
||||
<p v-else-if="editing" class="hint">
|
||||
No body uploaded yet. A script without a body cannot be pushed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Run in folder
|
||||
<span class="hint">(optional, endpoint-local absolute path)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.workingdirectory"
|
||||
type="text"
|
||||
class="form-control mono"
|
||||
placeholder="C:\Program Files\CorporateTools"
|
||||
/>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
Leave blank to run from the agent's staging folder. Set this only
|
||||
when the script calls a tool already installed on the endpoint by
|
||||
relative path. The folder must exist on the endpoint or the push
|
||||
fails rather than running somewhere else.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
When a line fails
|
||||
<span class="hint">(how the run is judged)</span>
|
||||
</label>
|
||||
<select v-model="form.errormode" class="form-control">
|
||||
<option value="default">Continue - report success if the script reaches the end</option>
|
||||
<option value="strict">Stop on the first error and fail the command</option>
|
||||
</select>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
PowerShell normally writes an error and carries on, and still
|
||||
exits zero, so a script can report success having done none of
|
||||
its work. Stop makes the first failure end the run and fail the
|
||||
command. Leave this on Continue unless you have read the script
|
||||
and know it does not rely on a step being allowed to fail.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.captureoutput" type="checkbox" />
|
||||
Return output to ShopDB on success
|
||||
</label>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
A failed run always sends its output back, so you can diagnose
|
||||
it from the Run Scripts history without going to the machine.
|
||||
A successful run does not, unless you tick this.
|
||||
</p>
|
||||
<p>
|
||||
Leave it off for scripts whose output could contain anything
|
||||
sensitive - account names, share paths, anything a Write-Host
|
||||
left behind. Turn it on for scripts where seeing the output is
|
||||
the only way to tell the run did real work. Either way the
|
||||
full output is written to the agent log on the endpoint.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { scriptsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const scripts = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const pendingfile = ref(null)
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
scriptname: '',
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
captureoutput: false,
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
|
||||
function shortdigest(s) {
|
||||
return s.sha256 ? s.sha256.slice(0, 12) : '-'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'false' })
|
||||
scripts.value = response.data.data || []
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to load scripts'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickfile(event) {
|
||||
pendingfile.value = event.target.files[0] || null
|
||||
}
|
||||
|
||||
function openModal(script = null) {
|
||||
error.value = ''
|
||||
pendingfile.value = null
|
||||
editing.value = script
|
||||
form.value = script
|
||||
? {
|
||||
scriptname: script.scriptname,
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
captureoutput: !!script.captureoutput,
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
pendingfile.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
scriptname: form.value.scriptname,
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
captureoutput: form.value.captureoutput,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
if (editing.value) {
|
||||
await scriptsApi.update(editing.value.scriptid, payload)
|
||||
scriptid = editing.value.scriptid
|
||||
} else {
|
||||
const created = await scriptsApi.create(payload)
|
||||
scriptid = created.data.data.scriptid
|
||||
}
|
||||
// Second call on purpose: the body is multipart and the catalog row is
|
||||
// JSON. A failure here leaves a bodyless row, which the list marks as
|
||||
// unrunnable rather than pretending it is ready to push.
|
||||
if (pendingfile.value) {
|
||||
await scriptsApi.uploadBody(scriptid, pendingfile.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save script')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s) {
|
||||
if (!confirm(`Delete script "${s.scriptname}"?`)) return
|
||||
try {
|
||||
await scriptsApi.delete(s.scriptid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cell-truncate {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono { font-family: monospace; }
|
||||
.hint { color: var(--text-light); font-weight: normal; }
|
||||
.warning-banner {
|
||||
border: 1px solid var(--warning);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
}
|
||||
.explainer {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body {
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
.explainer-body h4 {
|
||||
margin: 0.9rem 0 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-help {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.field-help summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field-help p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
210
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
210
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* One-line install block on the endpoints agent key page.
|
||||
*
|
||||
* The token is shown exactly once, so the two things worth pinning are that the
|
||||
* command rendered is the one the server returned, and that reloading the page
|
||||
* does NOT put a token back on screen - the list read carries no secret, and a
|
||||
* field that repopulated from it would be lying about what is recoverable.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
import api from '@/api'
|
||||
import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
|
||||
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
|
||||
const SCRIPT_URL =
|
||||
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
|
||||
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
|
||||
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
|
||||
const COMMAND =
|
||||
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
|
||||
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
|
||||
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
|
||||
|
||||
const MINTED_TOKEN = {
|
||||
tokenid: 7,
|
||||
tokenprefix: 'sdbb_012345',
|
||||
createdat: '2026-09-15T10:00:00Z',
|
||||
expiresat: '2026-09-16T10:00:00Z',
|
||||
maxuses: 25,
|
||||
usecount: 0,
|
||||
remaining: 25,
|
||||
revokedat: null,
|
||||
state: 'active'
|
||||
}
|
||||
|
||||
function listResponse(tokens) {
|
||||
return { data: { data: { tokens } } }
|
||||
}
|
||||
|
||||
async function render(tokens = []) {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
|
||||
}
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
const wrapper = mount(AgentKeySettings, {
|
||||
global: { stubs: { RouterLink: RouterLinkStub } }
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function generateButton(wrapper) {
|
||||
return wrapper.findAll('button')
|
||||
.find((button) => button.text() === 'Generate install command')
|
||||
}
|
||||
|
||||
describe('AgentKeySettings one-line install', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.delete.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
|
||||
const wrapper = await render()
|
||||
const block = wrapper.find('.bootstrap-block').text()
|
||||
expect(block).toContain('One-line install')
|
||||
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
|
||||
expect(block).toContain('GE-Enforce')
|
||||
expect(block).toContain('elevated')
|
||||
})
|
||||
|
||||
it('generate posts the hours and maximum uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
await wrapper.find('#bootstrap-hours').setValue(48)
|
||||
await wrapper.find('#bootstrap-maxuses').setValue(5)
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
|
||||
})
|
||||
|
||||
it('defaults to 24 hours and 25 uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
|
||||
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
|
||||
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
|
||||
})
|
||||
|
||||
it('renders the returned command, the once-only warning and the expiry', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const field = wrapper.find('#bootstrap-command')
|
||||
expect(field.exists()).toBe(true)
|
||||
expect(field.element.value).toBe(COMMAND)
|
||||
expect(field.element.value).toContain(SECRET)
|
||||
expect(field.attributes('readonly')).toBeDefined()
|
||||
const block = wrapper.find('.bootstrap-block').text()
|
||||
expect(block).toContain('The token is shown once and cannot be recovered')
|
||||
expect(block).toContain('25 of')
|
||||
expect(block).toContain('uses remaining')
|
||||
})
|
||||
|
||||
it('offers the same token as a Download .ps1 link', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
|
||||
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const link = wrapper.find('.bootstrap-download')
|
||||
expect(link.attributes('href')).toBe(SCRIPT_URL)
|
||||
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
|
||||
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
|
||||
})
|
||||
|
||||
it('copies the command to the clipboard', async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
|
||||
await copy.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(COMMAND)
|
||||
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
|
||||
})
|
||||
|
||||
it('lists live tokens and revoke posts to that token', async () => {
|
||||
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
|
||||
const wrapper = await render([MINTED_TOKEN])
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
|
||||
const rows = wrapper.findAll('.bootstrap-block tbody tr')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].text()).toContain('sdbb_012345')
|
||||
expect(rows[0].text()).toContain('0 / 25')
|
||||
|
||||
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
|
||||
await revoke.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
|
||||
})
|
||||
|
||||
it('offers no Revoke on a token that is already spent', async () => {
|
||||
const wrapper = await render([
|
||||
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
|
||||
])
|
||||
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not show the secret again after a reload', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const first = await render()
|
||||
await generateButton(first).trigger('click')
|
||||
await flushPromises()
|
||||
expect(first.find('#bootstrap-command').exists()).toBe(true)
|
||||
|
||||
// The reload sees the token in the list read, which carries no secret.
|
||||
const reloaded = await render([MINTED_TOKEN])
|
||||
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
|
||||
expect(reloaded.text()).not.toContain(SECRET)
|
||||
expect(reloaded.text()).not.toContain('The token is shown once')
|
||||
// The row is still there, by prefix only.
|
||||
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
|
||||
})
|
||||
})
|
||||
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal file
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Endpoints (shared key) settings page.
|
||||
*
|
||||
* The shared key only matters to PCs that have not enrolled, so the page must
|
||||
* say how far enrollment has got, and the regenerate and revoke confirms must
|
||||
* name how many PCs the change actually reaches.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
import api from '@/api'
|
||||
import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const KEY = { tokenprefix: 'abcd', createddate: null, lastusedat: null }
|
||||
|
||||
function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true }) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
settings,
|
||||
counts: { enrolled: 30, notenrolled: 8, pendingcomputers: 2, pending: 3 },
|
||||
computers: [],
|
||||
requests: [
|
||||
{ requestid: 1, status: 'pending', reason: 'serial mismatch' },
|
||||
{ requestid: 2, status: 'pending', reason: 'awaiting approval' },
|
||||
{ requestid: 3, status: 'pending', reason: 'reenroll blocked' },
|
||||
{ requestid: 4, status: 'issued', reason: null }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render(payload = enrollmentPayload()) {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
|
||||
}
|
||||
if (path === '/endpoints/enrollment') return Promise.resolve(payload)
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
api.post.mockResolvedValue({ data: { data: { key: KEY } } })
|
||||
api.delete.mockResolvedValue({ data: { data: { key: null } } })
|
||||
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('AgentKeySettings shared key section', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.delete.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('titles the section for the per-PC model', async () => {
|
||||
const wrapper = await render()
|
||||
expect(wrapper.text()).toContain('Shared key (used until PCs enroll)')
|
||||
expect(wrapper.text()).not.toContain('Key management')
|
||||
expect(wrapper.text()).toMatch(/Enrolled PCs ignore it/)
|
||||
expect(wrapper.text()).toMatch(/At cutover/)
|
||||
})
|
||||
|
||||
it('shows enrollment progress from one small enrollment read', async () => {
|
||||
const wrapper = await render()
|
||||
expect(api.get).toHaveBeenCalledWith('/endpoints/enrollment', { params: { perpage: 1 } })
|
||||
const summary = wrapper.find('.enrollment-summary').text()
|
||||
expect(summary).toContain('30 of 40 PCs enrolled')
|
||||
expect(summary).toContain('10 still on the shared key')
|
||||
// A blocked PC asking again is not a request anyone has to decide.
|
||||
expect(summary).toContain('2 pending enrollment requests')
|
||||
const link = wrapper.findComponent(RouterLinkStub)
|
||||
expect(link.props('to')).toEqual({ name: 'endpoints-enrollment-settings' })
|
||||
})
|
||||
|
||||
it('regenerate confirm names the PCs that need the new key', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Regenerate key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will need the new key/)
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/Enrolled PCs are not affected/)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/agentkey/regenerate')
|
||||
})
|
||||
|
||||
it('revoke confirm names the PCs that lose access', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will lose access/)
|
||||
expect(api.delete).toHaveBeenCalledWith('/endpoints/agentkey')
|
||||
})
|
||||
|
||||
it('sends nothing when revoke is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => false))
|
||||
const wrapper = await render()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still manages the key when the enrollment read fails', async () => {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === '/endpoints/enrollment') return Promise.reject(new Error('403'))
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
|
||||
}
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.enrollment-summary').exists()).toBe(false)
|
||||
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal file
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Fleet list Credential column.
|
||||
*
|
||||
* The state is computed by the list route so that filtering by it survives
|
||||
* pagination; the page must send the filter to the server rather than narrowing
|
||||
* the rows it already has, and must render the server's value, not derive one.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
endpointsApi: {
|
||||
list: vi.fn(),
|
||||
dashboardSummary: vi.fn(),
|
||||
dashboardStability: vi.fn(),
|
||||
enqueueCommand: vi.fn()
|
||||
},
|
||||
computersApi: { types: { listAll: vi.fn(() => Promise.resolve([])) } },
|
||||
locationsApi: { listAll: vi.fn(() => Promise.resolve([])) }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
|
||||
}))
|
||||
vi.mock('@/composables/toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/composables/listQuery', () => ({
|
||||
useListQuery: () => ({
|
||||
page: ref(1), search: ref(''), setPage: vi.fn(), setSearch: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/PaginationBar.vue', () => ({
|
||||
default: { name: 'PaginationBar', template: '<div />' }
|
||||
}))
|
||||
|
||||
import { endpointsApi } from '@/api'
|
||||
import EndpointsList from './EndpointsList.vue'
|
||||
|
||||
function row(assetid, hostname, credentialstate) {
|
||||
return {
|
||||
assetid, hostname, computerid: assetid, status: 'reporting', machines: [],
|
||||
health: null, credentialstate
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
endpointsApi.dashboardSummary.mockResolvedValue({ data: { data: null } })
|
||||
endpointsApi.dashboardStability.mockResolvedValue({ data: { data: [] } })
|
||||
endpointsApi.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
row(1, 'PC-ENROLLED', 'enrolled'),
|
||||
row(2, 'PC-PENDING', 'pending'),
|
||||
row(3, 'PC-BLOCKED', 'reenrollblocked'),
|
||||
row(4, 'PC-SHARED', 'sharedkey'),
|
||||
row(5, 'PC-SILENT', 'notreporting')
|
||||
],
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('EndpointsList credential column', () => {
|
||||
it('renders a chip per server state', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
|
||||
const chips = wrapper.findAll('.credential-chip').map(chip => chip.text())
|
||||
expect(chips).toEqual([
|
||||
'Enrolled', 'Pending', 'Re-enroll blocked', 'Shared key (not enrolled)',
|
||||
'Not reporting'
|
||||
])
|
||||
expect(wrapper.find('.credential-chip.reenrollblocked').exists()).toBe(true)
|
||||
expect(wrapper.findAll('th').map(th => th.text())).toContain('Credential')
|
||||
})
|
||||
|
||||
it('sends the credential filter to the server', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.findAll('select.fleet-filter')
|
||||
.find(element => element.text().includes('All credentials'))
|
||||
await select.setValue('sharedkey')
|
||||
await flushPromises()
|
||||
|
||||
const params = endpointsApi.list.mock.calls.at(-1)[0]
|
||||
expect(params.credentialstate).toBe('sharedkey')
|
||||
expect(params.perpage).toBe(20)
|
||||
})
|
||||
|
||||
it('offers no credential actions on the list', async () => {
|
||||
const wrapper = mount(EndpointsList)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toMatch(/Revoke|Allow re-enrollment/)
|
||||
})
|
||||
})
|
||||
345
plugins/endpoints/frontend/views/enrollmentSettings.spec.js
Normal file
345
plugins/endpoints/frontend/views/enrollmentSettings.spec.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Enrollment settings page (per-PC credentials).
|
||||
*
|
||||
* Pins the wire shapes the page sends, because the server contract is exact:
|
||||
* a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen
|
||||
* assetid, and turning the shared key off must tell the admin how many PCs are
|
||||
* not enrolled yet before anything is sent.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
computersApi: { list: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
|
||||
}))
|
||||
vi.mock('@/composables/toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/components/PaginationBar.vue', () => ({
|
||||
default: { name: 'PaginationBar', template: '<div />' }
|
||||
}))
|
||||
|
||||
import api, { computersApi } from '@/api'
|
||||
import EnrollmentSettings from './EnrollmentSettings.vue'
|
||||
|
||||
function migrationPayload() {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
target: '1.20.0',
|
||||
counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 },
|
||||
computers: [
|
||||
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
|
||||
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
|
||||
blockers: ['offline', 'no serial on record'] },
|
||||
{ assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
|
||||
blockers: ['server certificate not trusted by this PC'] },
|
||||
{ assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
|
||||
blockers: ['offline', 'server certificate not trusted by this PC'] }
|
||||
],
|
||||
waves: [
|
||||
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
|
||||
]
|
||||
},
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enrollmentPayload() {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
settings: { sharedkeyallowed: true, autoenroll: true,
|
||||
maxpendingenrollments: 500 },
|
||||
counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
|
||||
computers: [
|
||||
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
|
||||
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto',
|
||||
reenrollblocked: false, reenrollblockedreason: null },
|
||||
{ assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked',
|
||||
credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto',
|
||||
reenrollblocked: true, reenrollblockedreason: 'rollback' }
|
||||
],
|
||||
requests: [
|
||||
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
|
||||
status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null }
|
||||
]
|
||||
},
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
api.get.mockImplementation(path => Promise.resolve(
|
||||
path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload()))
|
||||
api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } })
|
||||
api.put.mockResolvedValue({ data: { data: {} } })
|
||||
const wrapper = mount(EnrollmentSettings)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openCredentials(wrapper) {
|
||||
const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials')
|
||||
await tab.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('EnrollmentSettings', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.put.mockReset()
|
||||
computersApi.list.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the migration counts and enrolled progress', async () => {
|
||||
const wrapper = await render()
|
||||
expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40')
|
||||
expect(wrapper.find('[data-count="ready"]').text()).toBe('30')
|
||||
expect(wrapper.find('[data-count="blocked"]').text()).toBe('5')
|
||||
expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20')
|
||||
expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled')
|
||||
})
|
||||
|
||||
it('shows every blocker for a blocked PC', async () => {
|
||||
const wrapper = await render()
|
||||
const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED'))
|
||||
expect(row.find('.blockers').text()).toBe('offline, no serial on record')
|
||||
})
|
||||
|
||||
it('only offers a checkbox on ready rows', async () => {
|
||||
const wrapper = await render()
|
||||
const rows = wrapper.findAll('.migration-row')
|
||||
expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true)
|
||||
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => {
|
||||
const wrapper = await render()
|
||||
const rows = wrapper.findAll('.migration-row')
|
||||
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
|
||||
const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE'))
|
||||
expect(trust.find('input[type="checkbox"]').exists()).toBe(true)
|
||||
expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust')
|
||||
expect(mixed.find('input[type="checkbox"]').exists()).toBe(false)
|
||||
expect(mixed.find('.retry-hint').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('includes a trust-blocked PC in Upgrade selected', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
const rows = wrapper.findAll('.migration-row')
|
||||
await rows[0].find('input[type="checkbox"]').setValue(true)
|
||||
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
|
||||
await trust.find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.find('.upgrade-selected').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] })
|
||||
})
|
||||
|
||||
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
await wrapper.find('.upgrade-next').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
|
||||
expect(confirmSpy.mock.calls[0][0]).toMatch(
|
||||
/older than 1\.20 must trust the server certificate before they can download the upgrade/)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
|
||||
})
|
||||
|
||||
it('sends nothing when the wave confirm is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => false))
|
||||
const wrapper = await render()
|
||||
await wrapper.find('.upgrade-next').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts the selected assetids for Upgrade selected', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.find('.upgrade-selected').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] })
|
||||
})
|
||||
|
||||
it('approves a request with the matched asset by default', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
expect(wrapper.find('.asset-picker').element.value).toBe('77')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
|
||||
})
|
||||
|
||||
// Approving a one-off PC onto a computer recorded under another hostname is
|
||||
// refused by the server, because the PC authenticates by hostname afterwards.
|
||||
// The page has to show that message and let the admin answer it, not swallow
|
||||
// it as "could not approve".
|
||||
function hostnameConflict() {
|
||||
return {
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
data: {
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.',
|
||||
details: {
|
||||
hostnameconflict: true,
|
||||
computerhostname: 'PC-OLD',
|
||||
requesthostname: 'PC-NEW'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('offers to set the hostname when approve comes back 409, then re-posts', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
api.post.mockReset()
|
||||
api.post.mockRejectedValueOnce(hostnameConflict())
|
||||
api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } })
|
||||
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post.mock.calls).toEqual([
|
||||
['/endpoints/enrollment/requests/45/approve', { assetid: 77 }],
|
||||
['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }]
|
||||
])
|
||||
const asked = confirmSpy.mock.calls.at(-1)[0]
|
||||
expect(asked).toContain('recorded as PC-OLD')
|
||||
expect(asked).toContain('Set the hostname on this PC?')
|
||||
})
|
||||
|
||||
it('sends nothing more when the hostname question is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
api.post.mockReset()
|
||||
api.post.mockRejectedValue(hostnameConflict())
|
||||
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledTimes(1)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve',
|
||||
{ assetid: 77 })
|
||||
})
|
||||
|
||||
it('rejects a request after confirm', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.reject').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject')
|
||||
})
|
||||
|
||||
it('un-enrolls through the revoke route', async () => {
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.revoke').trigger('click')
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again')
|
||||
expect(wrapper.find('.revoke-block').element.checked).toBe(false)
|
||||
await wrapper.find('.revoke-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
|
||||
})
|
||||
|
||||
it('sends blockreenroll with the reason when the block box is ticked', async () => {
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.revoke').trigger('click')
|
||||
await wrapper.find('.revoke-block').setValue(true)
|
||||
await wrapper.find('.revoke-reason').setValue(' rollback ')
|
||||
await wrapper.find('.revoke-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke',
|
||||
{ blockreenroll: true, reason: 'rollback' })
|
||||
})
|
||||
|
||||
it('shows the block chip and allows re-enrollment after confirm', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
const rows = wrapper.findAll('.credential-row')
|
||||
const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK'))
|
||||
const enrolled = rows.find(r => r.text().includes('PC-ENROLLED'))
|
||||
expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked')
|
||||
expect(enrolled.find('.block-chip').exists()).toBe(false)
|
||||
expect(enrolled.find('.allow-reenroll').exists()).toBe(false)
|
||||
await blocked.find('.allow-reenroll').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll')
|
||||
})
|
||||
|
||||
it('shows the not-enrolled count before turning the shared key off', async () => {
|
||||
const promptSpy = vi.fn(() => 'OFF')
|
||||
vi.stubGlobal('prompt', promptSpy)
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.toggle-sharedkey').setValue(false)
|
||||
await flushPromises()
|
||||
expect(promptSpy).toHaveBeenCalledTimes(1)
|
||||
expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/)
|
||||
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
|
||||
{ sharedkeyallowed: false, autoenroll: true })
|
||||
})
|
||||
|
||||
it('leaves the shared key on when the confirmation is not typed', async () => {
|
||||
vi.stubGlobal('prompt', vi.fn(() => 'yes'))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
const toggle = wrapper.find('.toggle-sharedkey')
|
||||
await toggle.setValue(false)
|
||||
await flushPromises()
|
||||
expect(api.put).not.toHaveBeenCalled()
|
||||
expect(toggle.element.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('saves a new pending request limit', async () => {
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
const field = wrapper.find('.input-maxpending')
|
||||
expect(field.element.value).toBe('500')
|
||||
await field.setValue(25)
|
||||
await field.trigger('change')
|
||||
await flushPromises()
|
||||
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
|
||||
{ maxpendingenrollments: 25 })
|
||||
})
|
||||
|
||||
// The negative case is proved server side (tests/test_plugins/
|
||||
// test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here
|
||||
// would only be asserting what vue-test-utils does to a number input.
|
||||
})
|
||||
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal file
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Server Certificate live check.
|
||||
*
|
||||
* A PC pins the certificate the site presents, which a proxy or IIS binding can
|
||||
* make different from the file this page manages. The page must show the
|
||||
* presented pin, warn plainly when it differs, and let the admin recheck.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn() }
|
||||
}))
|
||||
|
||||
import api from '@/api'
|
||||
import ServerCertSettings from './ServerCertSettings.vue'
|
||||
|
||||
const FILEPIN = 'a'.repeat(64)
|
||||
const OTHERPIN = 'b'.repeat(64)
|
||||
|
||||
function certificate() {
|
||||
return {
|
||||
data: {
|
||||
status: 'success',
|
||||
data: {
|
||||
subject_cn: 'shopdb.example.test', issuer: 'shopdb.example.test',
|
||||
not_before: null, not_after: null, thumbprint: 'AA:BB',
|
||||
spkisha256: FILEPIN, sha1thumbprint: 'CC', san_list: [],
|
||||
expires_in_days: 300, is_self_signed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render(live) {
|
||||
api.get.mockImplementation((path) => Promise.resolve(
|
||||
path === '/endpoints/settings/server-cert/live'
|
||||
? { data: { status: 'success', data: live } }
|
||||
: certificate()))
|
||||
const wrapper = mount(ServerCertSettings)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ServerCertSettings live check', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
})
|
||||
|
||||
it('shows the presented pin in green when it matches the file', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: FILEPIN,
|
||||
presentedsha1thumbprint: 'CC', matchesfile: true, error: null
|
||||
})
|
||||
const check = wrapper.find('.live-check')
|
||||
expect(check.find('.live-match').text()).toContain(FILEPIN)
|
||||
expect(check.text()).toContain('Matches the pin of the certificate file above')
|
||||
expect(check.find('.live-warning').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('warns in red with both pins when the site presents another certificate', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: OTHERPIN,
|
||||
presentedsha1thumbprint: 'DD', matchesfile: false, error: null
|
||||
})
|
||||
const warning = wrapper.find('.live-warning')
|
||||
expect(warning.exists()).toBe(true)
|
||||
expect(warning.text()).toContain(
|
||||
'PCs will be pinned to the certificate they actually receive; use the presented pin')
|
||||
expect(warning.text()).toContain(FILEPIN)
|
||||
expect(warning.text()).toContain(OTHERPIN)
|
||||
expect(wrapper.find('.live-mismatch').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('says when no site address is configured', async () => {
|
||||
const wrapper = await render({ configured: false })
|
||||
expect(wrapper.find('.live-unconfigured').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the check error', async () => {
|
||||
const wrapper = await render({
|
||||
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: null,
|
||||
presentedsha1thumbprint: null, matchesfile: false, error: 'Could not read the certificate'
|
||||
})
|
||||
expect(wrapper.find('.live-error').text()).toContain('Could not read the certificate')
|
||||
})
|
||||
|
||||
it('rechecks on demand', async () => {
|
||||
const wrapper = await render({ configured: false })
|
||||
const before = api.get.mock.calls.filter(
|
||||
([path]) => path === '/endpoints/settings/server-cert/live').length
|
||||
await wrapper.find('.live-recheck').trigger('click')
|
||||
await flushPromises()
|
||||
const after = api.get.mock.calls.filter(
|
||||
([path]) => path === '/endpoints/settings/server-cert/live').length
|
||||
expect(after).toBe(before + 1)
|
||||
})
|
||||
})
|
||||
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* System Scripts settings page.
|
||||
*
|
||||
* Two things are worth pinning here and nothing else is:
|
||||
*
|
||||
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
|
||||
* agent is a service with no user session; exposing a picker would let
|
||||
* someone author a script that reads as "run as the logged-on user" and
|
||||
* silently does not.
|
||||
* 2. The SYSTEM warning must be present on both the list and the add/edit
|
||||
* form. Someone pasting a script into the modal may never have read the
|
||||
* page copy above it.
|
||||
*
|
||||
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
|
||||
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
|
||||
|
||||
describe('SystemScriptsSettings', () => {
|
||||
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
|
||||
const warnings = source.match(/runs? as SYSTEM/g) || []
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not offer a run context picker', () => {
|
||||
expect(source).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('does not send a runcontext in its save payload', () => {
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('flags a script with no body as unrunnable', () => {
|
||||
expect(source).toMatch(/hasbody/)
|
||||
expect(source).toMatch(/cannot run/i)
|
||||
})
|
||||
|
||||
it('is gated on the standalone script permission, not admin', () => {
|
||||
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
|
||||
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
|
||||
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
|
||||
})
|
||||
|
||||
it('offers an optional working directory and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.workingdirectory/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
|
||||
})
|
||||
|
||||
it('shows the staging default rather than an empty cell', () => {
|
||||
// A blank cell reads as "unknown". The folder is the difference between a
|
||||
// corporate tool being found and the script failing further in, so the
|
||||
// list has to state the default out loud.
|
||||
expect(source).toMatch(/Agent staging folder/)
|
||||
})
|
||||
|
||||
it('offers an error mode and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.errormode/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/errormode/)
|
||||
})
|
||||
|
||||
it('defaults a new script to the permissive mode', () => {
|
||||
// Strict changes how a body behaves partway through. Defaulting to it
|
||||
// would silently alter every script authored from here on, so a new
|
||||
// script has to start where the old ones already are.
|
||||
const empty = source.slice(source.indexOf('function emptyForm'))
|
||||
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
|
||||
})
|
||||
|
||||
it('names both error modes by their consequence, not by their flag', () => {
|
||||
// "default" and "strict" mean nothing to the person choosing. What they
|
||||
// need to know is which one can report success having done nothing.
|
||||
expect(source).toMatch(/Continue - report success if the script reaches the end/)
|
||||
expect(source).toMatch(/Stop on the first error and fail the command/)
|
||||
})
|
||||
|
||||
it('explains the non-terminating error trap that justifies the choice', () => {
|
||||
// The whole feature exists because PowerShell exits zero after an error.
|
||||
// If the page never says so, the dropdown looks like pedantry and stays
|
||||
// on the setting that produces false green.
|
||||
expect(source).toMatch(/non-terminating/i)
|
||||
expect(source).toMatch(/exits? zero/i)
|
||||
})
|
||||
|
||||
it('offers the explainer collapsed so it does not bury the page', () => {
|
||||
expect(source).toMatch(/<details class="explainer">/)
|
||||
expect(source).toMatch(/How does this work\?/)
|
||||
expect(source).not.toMatch(/<details class="explainer" open>/)
|
||||
})
|
||||
|
||||
it('collapses the long per-field prose in the form', () => {
|
||||
// The modal is already tall enough to scroll. Three paragraphs of standing
|
||||
// explanation between the inputs pushes Save off screen and gets skimmed
|
||||
// past, which is the opposite of what the copy is for.
|
||||
const helps = source.match(/<details class="field-help">/g) || []
|
||||
expect(helps.length).toBeGreaterThanOrEqual(2)
|
||||
expect(source).toMatch(/<summary>How this works<\/summary>/)
|
||||
})
|
||||
|
||||
it('styles itself from defined theme tokens only', () => {
|
||||
// --surface and --text-primary are not defined by this app's theme, so a
|
||||
// var() fallback silently rendered a white panel on the dark background.
|
||||
// A fallback colour is the bug: it looks correct in light mode and only
|
||||
// shows up as a bright block for dark-mode users.
|
||||
const styles = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
|
||||
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
|
||||
it('states that output is kept locally and only returned on failure', () => {
|
||||
// Operators otherwise assume either that nothing was captured or that
|
||||
// every run is shipped back to the server.
|
||||
const explainer = source.slice(source.indexOf('Where the output goes'))
|
||||
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Endpoint RMM telemetry: metrics, services, inventory, and curated Windows event records reported by the ShopDB agent",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": ["computers"],
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"core_version": ">=0.34.0,<1.0.0",
|
||||
"api_prefix": "/api/endpoints",
|
||||
"provides": {
|
||||
"features": [
|
||||
|
||||
@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
|
||||
process agent.exe -> MTConnect
|
||||
service MTConnect UDC Agent -> MTConnect
|
||||
|
||||
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
|
||||
or delete them. Launch paths mirror the agent's launchpaths block; collector and
|
||||
Shipped rows (source='shipped') keep their kind and displayname - that pair is
|
||||
the identity the monitor policies match on - but a site may edit their paths and
|
||||
active flag. Launch paths mirror the agent's launchpaths block; collector and
|
||||
log paths are left null until site paths are supplied.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Ship the shopfloor app monitors as a read-only Site Policy.
|
||||
"""Ship the shopfloor app monitors as a Site Policy bundle.
|
||||
|
||||
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
|
||||
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
|
||||
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
|
||||
sites compose their own. Read this for history; do not extend it.
|
||||
|
||||
Two claims below were true when written and are now false, corrected here rather
|
||||
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
|
||||
at the policy CRUD guards), and no seeder reconciles them - the idempotency
|
||||
check at the top of upgrade() returns early if the policy exists, which is what
|
||||
keeps a site's edits safe across upgrades.
|
||||
|
||||
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
|
||||
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
|
||||
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
|
||||
its watch-list from the server instead.
|
||||
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
|
||||
stops carrying them as hardcoded appsettings.json defaults and reads its
|
||||
watch-list from the server instead.
|
||||
|
||||
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
|
||||
CRUD API refuses to edit or delete them):
|
||||
Seeds two computers-plugin rows, both source='shipped':
|
||||
|
||||
ConfigurationPolicy "Shopfloor App Monitors"
|
||||
scope {"type":"true"} (every endpoint), one action per shipped monitor,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Dedup curated Windows events on the Windows event record id.
|
||||
|
||||
Adds endpointevents.eventrecordid and a unique index over
|
||||
(assetid, eventchannel, eventrecordid).
|
||||
|
||||
Why: the agent used to re-read the newest window of every curated channel on
|
||||
each cadence tick, and this ingest appended those rows without a dedup check, so
|
||||
one real event was stored once per report for as long as it stayed in that
|
||||
window - up to 96 identical rows a day. The agent now carries a per-channel
|
||||
watermark keyed on the Windows record id, which is monotonic and unique within a
|
||||
channel on a machine; this index is the server-side guarantee that survives an
|
||||
agent reinstall, a restored image, or two overlapping reports, none of which
|
||||
preserve the agent's watermark.
|
||||
|
||||
Nullable on purpose: an agent older than this field reports no record id, and
|
||||
MySQL permits repeated NULLs inside a unique index, so those rows keep the
|
||||
previous append-only behavior instead of failing ingest.
|
||||
|
||||
Existing rows are left alone. They all predate the column and so carry NULL,
|
||||
which cannot collide with the new index. Purging the historical duplicates is a
|
||||
separate, deliberate data cleanup - not something a schema migration should do
|
||||
irreversibly.
|
||||
|
||||
Idempotent: both the column-add and the index-create check the live schema
|
||||
first, so a db.create_all() site and a re-run both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0012eventrecordid'
|
||||
down_revision = 'endpoints0011trayversion'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = 'idx_endpointevents_assetrecord'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_index(table_name, index_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(index['name'] == index_name
|
||||
for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column('endpointevents', 'eventrecordid'):
|
||||
op.add_column(
|
||||
'endpointevents',
|
||||
sa.Column('eventrecordid', sa.BigInteger(), nullable=True))
|
||||
|
||||
if not _has_index('endpointevents', INDEX_NAME):
|
||||
op.create_index(
|
||||
INDEX_NAME,
|
||||
'endpointevents',
|
||||
['assetid', 'eventchannel', 'eventrecordid'],
|
||||
unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_index('endpointevents', INDEX_NAME):
|
||||
op.drop_index(INDEX_NAME, table_name='endpointevents')
|
||||
|
||||
if _has_column('endpointevents', 'eventrecordid'):
|
||||
op.drop_column('endpointevents', 'eventrecordid')
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Persist the hardware, OS, and session inventory the agent already reports.
|
||||
|
||||
Adds to endpointstates: disksjson, biosjson, memoryjson, processorjson,
|
||||
osinfojson, usercontextjson, domain, and pendingreboot.
|
||||
|
||||
Why now: the agent has been sending disks, bios, memory, processor, osinfo and
|
||||
domain on every report for several versions. The ingest logged them as unknown
|
||||
payload keys and threw them away, which was the intended staging step - the
|
||||
shapes are now proven against real endpoint traffic, so they get columns.
|
||||
usercontext and pendingreboot are new in this same change.
|
||||
|
||||
Shape follows the existing servicesjson/portsjson convention: newest snapshot
|
||||
per endpoint as MEDIUMTEXT, not one row per sample. The agent resends the full
|
||||
structure every cycle and operators ask "what is in this PC now", not "what was
|
||||
in it in March". History would be pure write amplification on a fleet-sized
|
||||
table.
|
||||
|
||||
domain and pendingreboot are real columns rather than JSON because they are
|
||||
single values the fleet view filters, groups, and alerts on; burying a scalar in
|
||||
a blob makes it unqueryable.
|
||||
|
||||
All columns are nullable. Every existing row predates them, and there is no
|
||||
correct backfill value - a machine whose reboot state has never been reported is
|
||||
NOT the same as one confirmed to have no reboot pending, and inventing False
|
||||
would silently create a fleet of "healthy" endpoints.
|
||||
|
||||
Idempotent: each column-add checks the live schema first, so a db.create_all()
|
||||
site and a re-run both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0013inventorycolumns'
|
||||
down_revision = 'endpoints0012eventrecordid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
# MEDIUMTEXT. An installed-application or disk list on a busy engineering
|
||||
# workstation comfortably exceeds the 64KB TEXT ceiling, and a silent truncation
|
||||
# there would corrupt the JSON rather than merely shorten it.
|
||||
_SNAPSHOT_COLUMNS = (
|
||||
'disksjson',
|
||||
'biosjson',
|
||||
'memoryjson',
|
||||
'processorjson',
|
||||
'osinfojson',
|
||||
'usercontextjson',
|
||||
)
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
for column_name in _SNAPSHOT_COLUMNS:
|
||||
if not _has_column(TABLE_NAME, column_name):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
if not _has_column(TABLE_NAME, 'domain'):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column('domain', sa.String(length=255), nullable=True))
|
||||
|
||||
if not _has_column(TABLE_NAME, 'pendingreboot'):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column('pendingreboot', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, 'pendingreboot'):
|
||||
op.drop_column(TABLE_NAME, 'pendingreboot')
|
||||
|
||||
if _has_column(TABLE_NAME, 'domain'):
|
||||
op.drop_column(TABLE_NAME, 'domain')
|
||||
|
||||
for column_name in reversed(_SNAPSHOT_COLUMNS):
|
||||
if _has_column(TABLE_NAME, column_name):
|
||||
op.drop_column(TABLE_NAME, column_name)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Add diskhealthjson to endpointstates.
|
||||
|
||||
Per-drive health, kept separate from disksjson. That column answers "how full
|
||||
is this drive"; this one answers "is this drive dying". Merging them would mean
|
||||
a capacity collection failure and a health collection failure could not be told
|
||||
apart, and the two come from different WMI sources with different failure modes.
|
||||
|
||||
JSON rather than scalar columns because a machine has a variable number of
|
||||
drives and each carries its own verdict, and because the agent reports the same
|
||||
drive from two sources - MSFT_PhysicalDisk for NVMe and Win32_DiskDrive for
|
||||
ATA/SATA - which the server keeps side by side rather than collapsing.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. A drive whose health has never been reported is not
|
||||
a healthy drive, and defaulting to an empty list would read as "no disks have
|
||||
problems" on every endpoint that predates this column.
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0014diskhealth'
|
||||
down_revision = 'endpoints0013inventorycolumns'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
COLUMN_NAME = 'diskhealthjson'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.drop_column(TABLE_NAME, COLUMN_NAME)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
|
||||
|
||||
Phase 2 system-side telemetry. All three are collect-and-persist; none is
|
||||
surfaced in the UI by this change.
|
||||
|
||||
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
|
||||
column is a single boolean OR-ed from three registry signals, so it cannot say
|
||||
which one fired, and the difference is operationally significant: a
|
||||
PendingFileRenameOperations entry means files are staged for replacement on the
|
||||
next boot, so the machine is mid-servicing, whereas a lone Component Based
|
||||
Servicing or Windows Update key can sit set for weeks on a box nobody has
|
||||
rebooted. Same boolean, very different urgency.
|
||||
|
||||
bootinfojson exists because uptime lies under fast startup - a machine reports
|
||||
days of uptime after what the operator experienced as a shutdown, and a "reboot"
|
||||
that was really a hybrid resume does not clear the driver or kernel state a
|
||||
support call is usually trying to clear.
|
||||
|
||||
defenderjson is stored deliberately unsurfaced. Defender is known to produce
|
||||
false positives in this environment, so wiring it to a card now would train
|
||||
operators to ignore that card; persisting it starts the history accumulating for
|
||||
whenever someone is ready to interpret it.
|
||||
|
||||
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
|
||||
and Defender payloads are small related groups that are read together and would
|
||||
otherwise be six sparse columns.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. Null means never reported, which is deliberately
|
||||
distinct from an empty list (checked, nothing pending) - defaulting the reboot
|
||||
reasons to [] would assert every pre-existing endpoint was confirmed clean.
|
||||
|
||||
Idempotent: each column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0015systemtelemetry'
|
||||
down_revision = 'endpoints0014diskhealth'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
_COLUMNS = (
|
||||
'rebootreasonsjson',
|
||||
'bootinfojson',
|
||||
'defenderjson',
|
||||
)
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
for column_name in _COLUMNS:
|
||||
if not _has_column(TABLE_NAME, column_name):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for column_name in reversed(_COLUMNS):
|
||||
if _has_column(TABLE_NAME, column_name):
|
||||
op.drop_column(TABLE_NAME, column_name)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add dellupdatesjson to endpointstates.
|
||||
|
||||
Stores the pending Dell Command | Update summary reported by the
|
||||
shopdb-dellupdates agent utility. MEDIUMTEXT for consistency with the other
|
||||
snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. Null means the endpoint has never reported a Dell
|
||||
scan result, which is deliberately distinct from a stored payload whose status
|
||||
is 'ok' with an empty list (scanned, nothing pending). The status card renders
|
||||
those two states differently, so defaulting this to an empty summary would
|
||||
assert every pre-existing endpoint had been scanned and found clean.
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0016dellupdates'
|
||||
down_revision = 'endpoints0015systemtelemetry'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
COLUMN_NAME = 'dellupdatesjson'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.drop_column(TABLE_NAME, COLUMN_NAME)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Add endpoint health rollup tables.
|
||||
|
||||
Three tables behind the Fleet Health column:
|
||||
|
||||
endpointhealthstates one cached verdict per endpoint, read directly by
|
||||
the Fleet list instead of recomputing a 48h
|
||||
fleet-wide event aggregate on every page load
|
||||
endpointhealthtransitions capped history, written on CHANGE not on sample
|
||||
endpointmonitorimpacts per-PC-type operational impact for a monitor
|
||||
|
||||
No backfill. An endpoint with no row has simply not checked in since this
|
||||
shipped, and the first check-in writes one. Backfilling would have to invent a
|
||||
firstseenat for every existing condition, and a fabricated "broken since" is
|
||||
worse than an absent one - an operator reading it has no way to tell it apart
|
||||
from an observed timestamp.
|
||||
|
||||
endpointmonitorimpacts ships EMPTY on purpose. Absence of a row means the
|
||||
built-in default impact applies, so an endpoint with no policies behaves
|
||||
exactly as it did before this migration. Seeding defaults here would freeze
|
||||
today's guesses into rows nobody could distinguish from deliberate choices.
|
||||
|
||||
Idempotent: every create checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0017healthrollup'
|
||||
down_revision = 'endpoints0016dellupdates'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
HEALTHSTATES = 'endpointhealthstates'
|
||||
TRANSITIONS = 'endpointhealthtransitions'
|
||||
IMPACTS = 'endpointmonitorimpacts'
|
||||
|
||||
|
||||
def _has_table(table_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_table(HEALTHSTATES):
|
||||
op.create_table(
|
||||
HEALTHSTATES,
|
||||
sa.Column('endpointhealthstateid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('level', sa.String(length=16), nullable=False,
|
||||
server_default='unknown'),
|
||||
sa.Column('label', sa.String(length=255), nullable=True),
|
||||
sa.Column('impactclass', sa.String(length=16), nullable=True),
|
||||
sa.Column('sourcekind', sa.String(length=16), nullable=False,
|
||||
server_default='none'),
|
||||
sa.Column('sourceref', sa.String(length=64), nullable=True),
|
||||
sa.Column('firstseenat', sa.DateTime(), nullable=True),
|
||||
sa.Column('lastconfirmedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('lastevaluatedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('flapcount', sa.Integer(), nullable=False,
|
||||
server_default='0'),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointhealthstateid'),
|
||||
# One live verdict per endpoint. Unique rather than indexed: two
|
||||
# rows for one asset is not a state this table should represent.
|
||||
sa.UniqueConstraint('assetid',
|
||||
name='uq_endpointhealthstates_assetid'),
|
||||
)
|
||||
op.create_index('idx_endpointhealthstates_asset', HEALTHSTATES,
|
||||
['assetid'])
|
||||
op.create_index('idx_endpointhealthstates_impactclass', HEALTHSTATES,
|
||||
['impactclass'])
|
||||
|
||||
if not _has_table(TRANSITIONS):
|
||||
op.create_table(
|
||||
TRANSITIONS,
|
||||
sa.Column('endpointhealthtransitionid', sa.Integer(),
|
||||
nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('level', sa.String(length=16), nullable=False),
|
||||
sa.Column('label', sa.String(length=255), nullable=True),
|
||||
sa.Column('impactclass', sa.String(length=16), nullable=True),
|
||||
sa.Column('sourcekind', sa.String(length=16), nullable=False,
|
||||
server_default='none'),
|
||||
sa.Column('sourceref', sa.String(length=64), nullable=True),
|
||||
sa.Column('openedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('resolvedat', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointhealthtransitionid'),
|
||||
)
|
||||
# Retention reads newest-first per asset; the resolved index serves the
|
||||
# "never evict an unresolved row" filter without scanning history.
|
||||
op.create_index('idx_endpointhealthtransitions_assetopened',
|
||||
TRANSITIONS, ['assetid', 'openedat'])
|
||||
op.create_index('idx_endpointhealthtransitions_assetresolved',
|
||||
TRANSITIONS, ['assetid', 'resolvedat'])
|
||||
|
||||
if not _has_table(IMPACTS):
|
||||
op.create_table(
|
||||
IMPACTS,
|
||||
sa.Column('endpointmonitorimpactid', sa.Integer(), nullable=False),
|
||||
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
|
||||
# Plain integer, not a ForeignKey: computertypes belongs to the
|
||||
# computers plugin and owns its own migration chain. A cross-plugin
|
||||
# FK would couple the two chains' ordering for no benefit the
|
||||
# unique constraint does not already provide.
|
||||
sa.Column('computertypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('impactclass', sa.String(length=16), nullable=False,
|
||||
server_default='degraded'),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['endpointmonitorid'],
|
||||
['endpointmonitors.endpointmonitorid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('endpointmonitorimpactid'),
|
||||
sa.UniqueConstraint('endpointmonitorid', 'computertypeid',
|
||||
name='uq_endpointmonitorimpacts_monitortype'),
|
||||
)
|
||||
op.create_index('idx_endpointmonitorimpacts_computertypeid', IMPACTS,
|
||||
['computertypeid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Dropped newest-first so the FK to endpointmonitors goes before anything
|
||||
# that might reference it. All three are caches or mappings - nothing here
|
||||
# is a system of record, so a downgrade loses no telemetry.
|
||||
for table_name in (IMPACTS, TRANSITIONS, HEALTHSTATES):
|
||||
if _has_table(table_name):
|
||||
op.drop_table(table_name)
|
||||
87
plugins/endpoints/migrations/versions/0018_endpoint_locks.py
Normal file
87
plugins/endpoints/migrations/versions/0018_endpoint_locks.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Add endpointlocks (admin-only endpoint quarantine).
|
||||
|
||||
One table behind the endpoint lock: an admin-set refusal of operator commands
|
||||
on a single endpoint, independent of policy and permission.
|
||||
|
||||
Deliberately separate from the computers plugin's policyactionholds. That table
|
||||
suppresses ONE desired-state action on the policy-resolution axis and expires on
|
||||
a timer; this one refuses NON-ADMIN CALLERS on the whole endpoint and does not
|
||||
expire at all. Two concepts, two tables, two enforcement points - collapsing
|
||||
them would recreate the axis-mixing the surrounding refactor exists to undo.
|
||||
|
||||
No expiresat column, and that is the design rather than an omission. A lock
|
||||
stands for a physical-world condition (machine mid-repair, validated part
|
||||
running, box that must not reboot during an audit) and those do not end on a
|
||||
schedule. A TTL would silently unlock a machine someone still has their hands
|
||||
inside.
|
||||
|
||||
Ships EMPTY. No endpoint has ever been locked before this migration, so there is
|
||||
nothing to backfill and every endpoint keeps behaving exactly as it did.
|
||||
|
||||
Idempotent: the create checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0018endpointlocks'
|
||||
down_revision = 'endpoints0017healthrollup'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
LOCKS = 'endpointlocks'
|
||||
|
||||
|
||||
def _has_table(table_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_table(LOCKS):
|
||||
op.create_table(
|
||||
LOCKS,
|
||||
sa.Column('endpointlockid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=False),
|
||||
sa.Column('lockedbyuserid', sa.Integer(), nullable=True),
|
||||
sa.Column('lockedbyusername', sa.String(length=100), nullable=True),
|
||||
sa.Column('lockedat', sa.DateTime(), nullable=False),
|
||||
# Null while the lock stands. Setting it is what releases the lock,
|
||||
# so there is no separate active flag able to drift out of step.
|
||||
sa.Column('releasedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('releasedbyuserid', sa.Integer(), nullable=True),
|
||||
sa.Column('releasedbyusername', sa.String(length=100),
|
||||
nullable=True),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
# SET NULL, not CASCADE: deleting the admin who locked a machine
|
||||
# must not delete the record that it was locked. The username
|
||||
# snapshot columns survive to say who it was.
|
||||
sa.ForeignKeyConstraint(['lockedbyuserid'], ['users.userid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['releasedbyuserid'], ['users.userid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('endpointlockid'),
|
||||
)
|
||||
# Serves the hot path: "is there an active lock on this asset", asked on
|
||||
# every enqueue and every endpoint detail load. NOT unique on assetid -
|
||||
# released rows accumulate per endpoint as history, and a unique
|
||||
# constraint would make the second lock in a machine's life fail. The
|
||||
# one-active-lock invariant is enforced on write instead.
|
||||
op.create_index('idx_endpointlocks_assetactive', LOCKS,
|
||||
['assetid', 'releasedat'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Safe to drop: the table is a system of record for quarantine state, but a
|
||||
# downgrade past this revision removes the enforcement that reads it, so
|
||||
# leaving orphaned rows behind would be worse - they would silently reapply
|
||||
# if the revision were ever re-applied.
|
||||
if _has_table(LOCKS):
|
||||
op.drop_index('idx_endpointlocks_assetactive', table_name=LOCKS)
|
||||
op.drop_table(LOCKS)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Split the shipped monitor bundle into one policy per monitor.
|
||||
|
||||
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
|
||||
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
|
||||
same name. That bundle was all-or-nothing: a site that runs eDNC but no
|
||||
MTConnect could not detach the half it does not have, because the whole row was
|
||||
product-owned and read-only.
|
||||
|
||||
This migration replaces it with one Configuration Policy per shipped monitor:
|
||||
|
||||
Monitor: eDNC (process)
|
||||
Monitor: Universal Data Collector (process)
|
||||
Monitor: MTConnect (process)
|
||||
Monitor: MTConnect (service)
|
||||
|
||||
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
|
||||
scope {"type":"true"} - no PC type attribution. The name carries the kind
|
||||
because displayname alone is not unique: MTConnect ships as both a process and
|
||||
a service, and configurationpolicies.name is UNIQUE.
|
||||
|
||||
source stays 'shipped'. Provenance is still true - the product did ship these -
|
||||
and the UI keeps showing where they came from. What changes is that the CRUD
|
||||
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
|
||||
without losing the record of its origin.
|
||||
|
||||
The shipped SITE policy is REMOVED and not replaced. Composition is a site
|
||||
decision: which monitors apply to which endpoints, and at what priority, is
|
||||
exactly the judgement a plant makes about its own floor. Shipping one meant
|
||||
every install inherited "apply all four to every endpoint" as a starting point
|
||||
that could not be edited. Sites now build their own from the per-monitor pieces.
|
||||
|
||||
Removing it is the behaviour change to be aware of on upgrade: monitors that
|
||||
were attached through that bundle stop being attached until a site policy
|
||||
composes them. It is deliberate and it is the point of the split - see the
|
||||
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
|
||||
the per-monitor policies are listed and ready to compose.
|
||||
|
||||
Idempotent on the policy names, so a re-run neither duplicates the split rows
|
||||
nor resurrects the bundle. Guards on the policy tables being present and no-ops
|
||||
if they are not, matching 0007.
|
||||
|
||||
Revision ID: endpoints0019splitpolicies
|
||||
Revises: endpoints0018endpointlocks
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
|
||||
# id passes on SQLite and fails on strict MySQL with "Data too long".
|
||||
revision = 'endpoints0019splitpolicies'
|
||||
down_revision = 'endpoints0018endpointlocks'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
|
||||
# so this survives id drift across installs exactly as 0007 does.
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
|
||||
'configurationpolicyactions', 'businesspolicies',
|
||||
'businesspolicyitems'}
|
||||
|
||||
|
||||
def _tables(bind):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return set(inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _policy_description(kind, displayname):
|
||||
noun = 'process' if kind == 'process' else 'service'
|
||||
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
|
||||
f'Attach it to a Site Policy to apply it to endpoints.')
|
||||
|
||||
|
||||
def _config_policy_id(bind, name):
|
||||
return bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': name}).scalar()
|
||||
|
||||
|
||||
def _drop_config_policy(bind, name):
|
||||
"""Delete a configuration policy, its actions, and any items citing it."""
|
||||
policyid = _config_policy_id(bind, name)
|
||||
if policyid is None:
|
||||
return
|
||||
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
|
||||
# sqlite does not enforce by default.
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicies '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
|
||||
|
||||
def _drop_business_policy(bind, name):
|
||||
policyid = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name AND source = :source'),
|
||||
{'name': name, 'source': 'shipped'}).scalar()
|
||||
if policyid is None:
|
||||
return
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
|
||||
|
||||
def _monitor_id(bind, kind, displayname):
|
||||
return bind.execute(
|
||||
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
if _config_policy_id(bind, name) is not None:
|
||||
continue
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is None:
|
||||
# A site that never had this monitor gets no policy for it.
|
||||
continue
|
||||
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': name,
|
||||
'description': _policy_description(kind, displayname),
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': _config_policy_id(bind, name),
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
# Retire the monolith. The business policy goes first so its item rows are
|
||||
# gone before the configuration policy it cited is removed.
|
||||
_drop_business_policy(bind, BUNDLE_NAME)
|
||||
_drop_config_policy(bind, BUNDLE_NAME)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Reinstate the single shipped bundle and drop the per-monitor policies.
|
||||
|
||||
Rebuilt from the same monitor identities rather than from anything the
|
||||
upgrade stashed, so it lands in the state 0007 produces regardless of what
|
||||
a site did to the split rows in between.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
monitor_ids = []
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is not None:
|
||||
monitor_ids.append(monitorid)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
_drop_config_policy(bind, _policy_name(kind, displayname))
|
||||
|
||||
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
|
||||
return
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped watch-list for the shopfloor apps '
|
||||
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
|
||||
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': index,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
} for index, monitorid in enumerate(monitor_ids)])
|
||||
|
||||
business_policies = sa.table(
|
||||
'businesspolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('priority', sa.Integer),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(business_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped Site Policy that applies the shopfloor '
|
||||
'app monitors to every endpoint. Read-only.',
|
||||
'priority': 100,
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
|
||||
items = sa.table(
|
||||
'businesspolicyitems',
|
||||
sa.column('businesspolicyid', sa.Integer),
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(items, [{
|
||||
'businesspolicyid': business_policy_id,
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add fingerprint column for delta-based application updates
|
||||
|
||||
Revision ID: endpoints0020appfingerprints
|
||||
Revises: endpoints0019splitpolicies
|
||||
Create Date: 2026-09-14
|
||||
|
||||
Performance optimization: Add appfingerprint column to endpointapplications
|
||||
table to enable delta-based updates instead of delete-all-insert-all pattern.
|
||||
|
||||
Impact:
|
||||
- Before: 200 endpoints x 50 apps x 4 reports/hour = 40,000 DELETEs + 40,000 INSERTs/hour
|
||||
- After: Only changed apps are touched (~2% per day) = 40 DELETEs + 40 INSERTs/hour
|
||||
- Reduction: 99.9% fewer writes (1.92M writes/day -> 1,920 writes/day)
|
||||
|
||||
The fingerprint is SHA256(displayname|displayversion|publisher) computed by
|
||||
the agent and sent in the collector payload. Nullable during rollout - legacy
|
||||
agents without fingerprint support fall back to delete-all-insert-all behavior.
|
||||
|
||||
DEPLOYMENT SEQUENCE (CRITICAL):
|
||||
1. Deploy this migration: flask db upgrade
|
||||
2. Run backfill script: python scripts/backfill_app_fingerprints.py
|
||||
3. Deploy updated plugin.py (delta logic code)
|
||||
4. Deploy updated C# agent (fingerprint computation)
|
||||
|
||||
ROLLBACK PROCEDURE:
|
||||
1. Deploy old plugin.py FIRST (remove delta logic)
|
||||
2. THEN run: flask db downgrade -1
|
||||
3. Verify: SELECT COUNT(*) FROM endpointapplications (should be unchanged)
|
||||
|
||||
WARNING: Do NOT downgrade migration before reverting code - will cause AttributeError!
|
||||
|
||||
SAFETY FEATURES:
|
||||
- Row-level locking prevents concurrent update races
|
||||
- Fingerprint format validation (64 hex chars)
|
||||
- Delete count verification detects silent failures
|
||||
- Memory limits prevent pathological cases (>5000 apps)
|
||||
- Metrics logged for monitoring
|
||||
|
||||
Idempotent: a database that already has the column (create_all built it from
|
||||
the model, or an earlier build of this revision added it as app_fingerprint)
|
||||
is left alone. 0023 renames the old spelling.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'endpoints0020appfingerprints'
|
||||
down_revision = 'endpoints0019splitpolicies'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
TABLE = 'endpointapplications'
|
||||
INDEX = 'idx_endpointapplications_fingerprint'
|
||||
|
||||
|
||||
def _columns():
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return {c['name'] for c in inspect(op.get_bind()).get_columns(TABLE)}
|
||||
|
||||
|
||||
def _indexes():
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return {ix['name'] for ix in inspect(op.get_bind()).get_indexes(TABLE)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
columns = _columns()
|
||||
# Old spelling present: leave it for 0023 to rename.
|
||||
if 'appfingerprint' in columns or 'app_fingerprint' in columns:
|
||||
return
|
||||
op.add_column(TABLE, sa.Column('appfingerprint', sa.String(64),
|
||||
nullable=True))
|
||||
if INDEX not in _indexes():
|
||||
# Unique per asset: the delta logic keys rows on it.
|
||||
op.create_index(INDEX, TABLE, ['assetid', 'appfingerprint'],
|
||||
unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if INDEX in _indexes():
|
||||
op.drop_index(INDEX, table_name=TABLE)
|
||||
columns = _columns()
|
||||
for name in ('appfingerprint', 'app_fingerprint'):
|
||||
if name in columns:
|
||||
with op.batch_alter_table(TABLE) as batch:
|
||||
batch.drop_column(name)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Retired: client certificate columns on assets.
|
||||
|
||||
Revision ID: endpoints0021clientcerts
|
||||
Revises: endpoints0020appfingerprints
|
||||
Create Date: 2026-09-14
|
||||
|
||||
This revision once added client certificate columns to the core assets table.
|
||||
Client certificate authentication was replaced by per-PC endpoint credentials
|
||||
(0023), so the revision is kept only so databases already stamped with it
|
||||
still resolve. It does nothing; 0023 drops the columns wherever an earlier
|
||||
build of this revision created them.
|
||||
"""
|
||||
|
||||
revision = 'endpoints0021clientcerts'
|
||||
down_revision = 'endpoints0020appfingerprints'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Retired: client certificate revocation columns on assets.
|
||||
|
||||
Revision ID: endpoints0022certrevocation
|
||||
Revises: endpoints0021clientcerts
|
||||
Create Date: 2026-09-14
|
||||
|
||||
Kept only so databases already stamped with this revision still resolve. It
|
||||
does nothing; 0023 drops the columns wherever an earlier build of this
|
||||
revision created them.
|
||||
"""
|
||||
|
||||
revision = 'endpoints0022certrevocation'
|
||||
down_revision = 'endpoints0021clientcerts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Per-PC endpoint credentials; remove the client certificate leftovers.
|
||||
|
||||
Revision ID: endpoints0023credentials
|
||||
Revises: endpoints0022certrevocation
|
||||
Create Date: 2026-09-15
|
||||
|
||||
Three things, each guarded against the live schema so the revision is safe on
|
||||
a database built by create_all, on one that ran the earlier client certificate
|
||||
builds of 0021/0022, and on a re-run:
|
||||
|
||||
1. Drop the client certificate columns (and their index) from the core assets
|
||||
table where an earlier build of 0021/0022 created them.
|
||||
2. Rename endpointapplications.app_fingerprint to appfingerprint where an
|
||||
earlier build of 0020 used the underscore spelling.
|
||||
3. Create endpointcredentials (with the reenrollblocked flag and its reason),
|
||||
endpointenrollrequests and endpointmigrationwaves.
|
||||
|
||||
MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses
|
||||
prefix lengths so the key stays under 767 bytes on utf8mb4.
|
||||
|
||||
Downgrade drops the three tables. The certificate columns are not restored:
|
||||
the code that read them is gone.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'endpoints0023credentials'
|
||||
down_revision = 'endpoints0022certrevocation'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
CREDENTIALS = 'endpointcredentials'
|
||||
REQUESTS = 'endpointenrollrequests'
|
||||
WAVES = 'endpointmigrationwaves'
|
||||
|
||||
_CERT_COLUMNS = ('client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at',
|
||||
'cert_revoked_at', 'cert_revocation_reason')
|
||||
_APPS = 'endpointapplications'
|
||||
_FINGERPRINT_INDEX = 'idx_endpointapplications_fingerprint'
|
||||
|
||||
|
||||
def _inspector():
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(name):
|
||||
return _inspector().has_table(name)
|
||||
|
||||
|
||||
def _columns(table):
|
||||
return {c['name'] for c in _inspector().get_columns(table)}
|
||||
|
||||
|
||||
def _indexes(table):
|
||||
return _inspector().get_indexes(table)
|
||||
|
||||
|
||||
def _drop_certificate_columns():
|
||||
if not _has_table('assets'):
|
||||
return
|
||||
present = _columns('assets') & set(_CERT_COLUMNS)
|
||||
if not present:
|
||||
return
|
||||
# Any index over a certificate column goes first: 0021 named its own,
|
||||
# create_all names by convention, and SQLite refuses to drop an indexed column.
|
||||
for index in _indexes('assets'):
|
||||
if set(index.get('column_names') or ()) & present:
|
||||
op.drop_index(index['name'], table_name='assets')
|
||||
for column in _CERT_COLUMNS:
|
||||
if column in present:
|
||||
op.drop_column('assets', column)
|
||||
|
||||
|
||||
def _rename_fingerprint():
|
||||
if not _has_table(_APPS):
|
||||
return
|
||||
columns = _columns(_APPS)
|
||||
if 'app_fingerprint' not in columns:
|
||||
return
|
||||
if 'appfingerprint' in columns:
|
||||
return
|
||||
for index in _indexes(_APPS):
|
||||
if 'app_fingerprint' in (index.get('column_names') or ()):
|
||||
op.drop_index(index['name'], table_name=_APPS)
|
||||
op.alter_column(_APPS, 'app_fingerprint', new_column_name='appfingerprint',
|
||||
existing_type=sa.String(64), existing_nullable=True)
|
||||
op.create_index(_FINGERPRINT_INDEX, _APPS, ['assetid', 'appfingerprint'],
|
||||
unique=True)
|
||||
|
||||
|
||||
def upgrade():
|
||||
_drop_certificate_columns()
|
||||
_rename_fingerprint()
|
||||
|
||||
if not _has_table(CREDENTIALS):
|
||||
op.create_table(
|
||||
CREDENTIALS,
|
||||
sa.Column('credentialid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('credentialhash', sa.CHAR(length=64), nullable=False),
|
||||
sa.Column('credentialprefix', sa.String(length=12), nullable=False),
|
||||
sa.Column('enrolledvia', sa.String(length=16), nullable=False),
|
||||
sa.Column('issuedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('revokedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('revokedby', sa.String(length=64), nullable=True),
|
||||
sa.Column('reenrollblocked', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column('reenrollblockedreason', sa.String(length=64),
|
||||
nullable=True),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('credentialid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
sa.UniqueConstraint('credentialhash'),
|
||||
)
|
||||
else:
|
||||
# A test database that ran an earlier build of this revision.
|
||||
columns = _columns(CREDENTIALS)
|
||||
if 'reenrollblocked' not in columns:
|
||||
op.add_column(CREDENTIALS, sa.Column(
|
||||
'reenrollblocked', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()))
|
||||
if 'reenrollblockedreason' not in columns:
|
||||
op.add_column(CREDENTIALS, sa.Column(
|
||||
'reenrollblockedreason', sa.String(length=64), nullable=True))
|
||||
|
||||
if not _has_table(REQUESTS):
|
||||
op.create_table(
|
||||
REQUESTS,
|
||||
sa.Column('requestid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=255), nullable=False),
|
||||
sa.Column('serialnumber', sa.String(length=128), nullable=False),
|
||||
sa.Column('deviceid', sa.String(length=64), nullable=True),
|
||||
sa.Column('enrollnonce', sa.String(length=128), nullable=False),
|
||||
sa.Column('agentversion', sa.String(length=32), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('reason', sa.String(length=64), nullable=True),
|
||||
sa.Column('matchedassetid', sa.Integer(), nullable=True),
|
||||
sa.Column('sourceip', sa.String(length=45), nullable=True),
|
||||
sa.Column('requestedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('lastseenat', sa.DateTime(), nullable=False),
|
||||
sa.Column('decidedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('decidedby', sa.String(length=64), nullable=True),
|
||||
sa.ForeignKeyConstraint(['matchedassetid'], ['assets.assetid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('requestid'),
|
||||
)
|
||||
existing = {tuple(ix.get('column_names') or ()) for ix in _indexes(REQUESTS)}
|
||||
if ('hostname', 'serialnumber') not in existing:
|
||||
op.create_index('idx_endpointenrollrequests_hostserial', REQUESTS,
|
||||
['hostname', 'serialnumber'],
|
||||
mysql_length={'hostname': 128, 'serialnumber': 63})
|
||||
|
||||
if not _has_table(WAVES):
|
||||
op.create_table(
|
||||
WAVES,
|
||||
sa.Column('waveid', sa.Integer(), nullable=False),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.Column('createdby', sa.String(length=64), nullable=True),
|
||||
sa.Column('assetids', sa.Text(), nullable=False),
|
||||
sa.Column('notes', sa.String(length=255), nullable=True),
|
||||
sa.PrimaryKeyConstraint('waveid'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_table(WAVES):
|
||||
op.drop_table(WAVES)
|
||||
if _has_table(REQUESTS):
|
||||
for index in _indexes(REQUESTS):
|
||||
if index['name'] == 'idx_endpointenrollrequests_hostserial':
|
||||
op.drop_index(index['name'], table_name=REQUESTS)
|
||||
op.drop_table(REQUESTS)
|
||||
if _has_table(CREDENTIALS):
|
||||
op.drop_table(CREDENTIALS)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""One-line bootstrap install tokens.
|
||||
|
||||
Revision ID: endpoints0024bootstraptokens
|
||||
Revises: endpoints0023credentials
|
||||
Create Date: 2026-09-15
|
||||
|
||||
Creates endpointbootstraptokens: the short-lived, admin-minted authorizations
|
||||
that let a tech fetch the bootstrap script and the keyless agent package with a
|
||||
single command. Only the sha256 of the secret is stored.
|
||||
|
||||
Guarded so the revision is safe on a database built by create_all and on a
|
||||
re-run. MySQL 5.6 safe: no JSON columns, and the only index is the unique key
|
||||
on a fixed-length CHAR(64), well under the 767-byte limit on utf8mb4.
|
||||
|
||||
Downgrade drops the table.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'endpoints0024bootstraptokens'
|
||||
down_revision = 'endpoints0023credentials'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
plugin_name = 'endpoints'
|
||||
|
||||
TOKENS = 'endpointbootstraptokens'
|
||||
|
||||
|
||||
def _inspector():
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(name):
|
||||
return _inspector().has_table(name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if _has_table(TOKENS):
|
||||
return
|
||||
op.create_table(
|
||||
TOKENS,
|
||||
sa.Column('tokenid', sa.Integer(), nullable=False),
|
||||
sa.Column('tokenhash', sa.CHAR(length=64), nullable=False),
|
||||
sa.Column('tokenprefix', sa.String(length=12), nullable=False),
|
||||
sa.Column('createdat', sa.DateTime(), nullable=False),
|
||||
sa.Column('createdby', sa.String(length=64), nullable=True),
|
||||
sa.Column('expiresat', sa.DateTime(), nullable=False),
|
||||
sa.Column('maxuses', sa.Integer(), nullable=False),
|
||||
sa.Column('usecount', sa.Integer(), nullable=False,
|
||||
server_default='0'),
|
||||
sa.Column('revokedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('lastusedip', sa.String(length=45), nullable=True),
|
||||
sa.PrimaryKeyConstraint('tokenid'),
|
||||
sa.UniqueConstraint('tokenhash'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_table(TOKENS):
|
||||
op.drop_table(TOKENS)
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointLock, EndpointLogCollection,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
from .credential import (
|
||||
EndpointCredential, EndpointEnrollRequest, EndpointMigrationWave,
|
||||
)
|
||||
from .bootstrap import EndpointBootstrapToken
|
||||
from .health import (
|
||||
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointLock', 'EndpointLogCollection',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
'EndpointCredential', 'EndpointEnrollRequest', 'EndpointMigrationWave',
|
||||
'EndpointBootstrapToken',
|
||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||
]
|
||||
|
||||
78
plugins/endpoints/models/bootstrap.py
Normal file
78
plugins/endpoints/models/bootstrap.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""One-line bootstrap install tokens.
|
||||
|
||||
An admin mints a short-lived token in the UI and hands a tech one command. The
|
||||
token is only ever an authorization to FETCH the bootstrap script and the
|
||||
keyless agent package: it carries no fleet key, provisions no credential, and
|
||||
the PC it installs still has to be approved in Settings > Endpoints >
|
||||
Enrollment before it can report anything.
|
||||
|
||||
Only the sha256 of the secret is stored, plus a short display prefix, the same
|
||||
shape as EndpointCredential. A token is spent per package download (usecount
|
||||
against maxuses) and dies at expiresat or when an admin revokes it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class EndpointBootstrapToken(db.Model):
|
||||
"""A short-lived authorization to fetch the bootstrap installer."""
|
||||
|
||||
__tablename__ = 'endpointbootstraptokens'
|
||||
|
||||
tokenid = db.Column(db.Integer, primary_key=True)
|
||||
tokenhash = db.Column(db.CHAR(64), nullable=False, unique=True)
|
||||
tokenprefix = db.Column(db.String(12), nullable=False)
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
createdby = db.Column(db.String(64), nullable=True)
|
||||
expiresat = db.Column(db.DateTime, nullable=False)
|
||||
maxuses = db.Column(db.Integer, nullable=False, default=1)
|
||||
usecount = db.Column(db.Integer, nullable=False, default=0,
|
||||
server_default='0')
|
||||
revokedat = db.Column(db.DateTime, nullable=True)
|
||||
lastusedat = db.Column(db.DateTime, nullable=True)
|
||||
lastusedip = db.Column(db.String(45), nullable=True)
|
||||
|
||||
def isusable(self, now):
|
||||
"""Whether this token may still serve a download at `now`."""
|
||||
if self.revokedat is not None:
|
||||
return False
|
||||
if self.expiresat is None or self.expiresat <= now:
|
||||
return False
|
||||
return (self.usecount or 0) < (self.maxuses or 0)
|
||||
|
||||
def state(self, now):
|
||||
if self.revokedat is not None:
|
||||
return 'revoked'
|
||||
if self.expiresat is None or self.expiresat <= now:
|
||||
return 'expired'
|
||||
if (self.usecount or 0) >= (self.maxuses or 0):
|
||||
return 'exhausted'
|
||||
return 'active'
|
||||
|
||||
def to_dict(self, now=None):
|
||||
now = now or datetime.utcnow()
|
||||
used = self.usecount or 0
|
||||
maximum = self.maxuses or 0
|
||||
return {
|
||||
'tokenid': self.tokenid,
|
||||
'tokenprefix': self.tokenprefix,
|
||||
'createdat': _utciso(self.createdat),
|
||||
'createdby': self.createdby,
|
||||
'expiresat': _utciso(self.expiresat),
|
||||
'maxuses': maximum,
|
||||
'usecount': used,
|
||||
'remaining': max(maximum - used, 0),
|
||||
'revokedat': _utciso(self.revokedat),
|
||||
'lastusedat': _utciso(self.lastusedat),
|
||||
'lastusedip': self.lastusedip,
|
||||
'state': self.state(now),
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<EndpointBootstrapToken {self.tokenprefix}>'
|
||||
165
plugins/endpoints/models/credential.py
Normal file
165
plugins/endpoints/models/credential.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Per-PC endpoint credentials, enrollment requests and migration waves.
|
||||
|
||||
EndpointCredential
|
||||
One row per asset. Holds only the sha256 of the secret the
|
||||
agent was issued, plus a short display prefix. Revoking sets
|
||||
revokedat; re-issuing after a revoke UPDATES the same row, so
|
||||
an asset never carries two credentials. The row therefore also
|
||||
says that this PC has enrolled at least once, which is how
|
||||
agentauth tells a revoked PC from one that never enrolled:
|
||||
plugin.endpoints.revokeddeniessharedkey refuses the shared key
|
||||
to the first and never to the second. reenrollblocked keeps
|
||||
a revoked PC from enrolling again (rolling it back to an agent
|
||||
that only knows the shared key, or a compromised PC) until an
|
||||
admin allows it.
|
||||
|
||||
EndpointEnrollRequest
|
||||
One row per (hostname, serial number) that asked to enroll -
|
||||
the endpoint's identity, and the reason every contact from one
|
||||
PC lands on one row. The agent's enrollment nonce is recorded
|
||||
on first sight and is what an admin approval is bound to, so a
|
||||
second machine claiming the same hostname and serial cannot
|
||||
collect the approved credential. A REJECTED row binds to the
|
||||
identity instead: any nonce from that hostname and serial stays
|
||||
rejected, so a reinstall cannot argue its way back into the
|
||||
queue. Only an admin approving the request clears it.
|
||||
|
||||
EndpointMigrationWave
|
||||
An admin action that pushed the agent upgrade to a set of PCs.
|
||||
Progress is computed live from credentials and commands; only
|
||||
the membership is stored.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
class EndpointCredential(db.Model):
|
||||
"""The credential issued to one endpoint (hash only)."""
|
||||
|
||||
__tablename__ = 'endpointcredentials'
|
||||
|
||||
ENROLLED_AUTO = 'auto'
|
||||
ENROLLED_APPROVED = 'approved'
|
||||
|
||||
credentialid = db.Column(db.Integer, primary_key=True)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
credentialhash = db.Column(db.CHAR(64), nullable=False, unique=True)
|
||||
credentialprefix = db.Column(db.String(12), nullable=False)
|
||||
enrolledvia = db.Column(db.String(16), nullable=False)
|
||||
issuedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
lastusedat = db.Column(db.DateTime, nullable=True)
|
||||
revokedat = db.Column(db.DateTime, nullable=True)
|
||||
revokedby = db.Column(db.String(64), nullable=True)
|
||||
reenrollblocked = db.Column(db.Boolean, nullable=False, default=False,
|
||||
server_default=db.false())
|
||||
reenrollblockedreason = db.Column(db.String(64), nullable=True)
|
||||
|
||||
@property
|
||||
def isactive(self):
|
||||
return self.revokedat is None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'credentialid': self.credentialid,
|
||||
'assetid': self.assetid,
|
||||
'credentialprefix': self.credentialprefix,
|
||||
'enrolledvia': self.enrolledvia,
|
||||
'issuedat': _utciso(self.issuedat),
|
||||
'lastusedat': _utciso(self.lastusedat),
|
||||
'revokedat': _utciso(self.revokedat),
|
||||
'revokedby': self.revokedby,
|
||||
'reenrollblocked': bool(self.reenrollblocked),
|
||||
'reenrollblockedreason': self.reenrollblockedreason,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<EndpointCredential asset={self.assetid} {self.credentialprefix}>'
|
||||
|
||||
|
||||
class EndpointEnrollRequest(db.Model):
|
||||
"""A PC asking for a credential."""
|
||||
|
||||
__tablename__ = 'endpointenrollrequests'
|
||||
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_APPROVED = 'approved'
|
||||
STATUS_REJECTED = 'rejected'
|
||||
STATUS_ISSUED = 'issued'
|
||||
|
||||
requestid = db.Column(db.Integer, primary_key=True)
|
||||
hostname = db.Column(db.String(255), nullable=False)
|
||||
serialnumber = db.Column(db.String(128), nullable=False)
|
||||
deviceid = db.Column(db.String(64), nullable=True)
|
||||
enrollnonce = db.Column(db.String(128), nullable=False)
|
||||
agentversion = db.Column(db.String(32), nullable=True)
|
||||
status = db.Column(db.String(16), nullable=False, default=STATUS_PENDING)
|
||||
reason = db.Column(db.String(64), nullable=True)
|
||||
matchedassetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
sourceip = db.Column(db.String(45), nullable=True)
|
||||
requestedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
lastseenat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
decidedat = db.Column(db.DateTime, nullable=True)
|
||||
decidedby = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# Prefix lengths keep the key under 767 bytes on utf8mb4 (4 bytes a char).
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointenrollrequests_hostserial', 'hostname',
|
||||
'serialnumber',
|
||||
mysql_length={'hostname': 128, 'serialnumber': 63}),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'requestid': self.requestid,
|
||||
'hostname': self.hostname,
|
||||
'serialnumber': self.serialnumber,
|
||||
'deviceid': self.deviceid,
|
||||
'agentversion': self.agentversion,
|
||||
'reason': self.reason,
|
||||
'status': self.status,
|
||||
'matchedassetid': self.matchedassetid,
|
||||
'requestedat': _utciso(self.requestedat),
|
||||
'lastseenat': _utciso(self.lastseenat),
|
||||
'decidedat': _utciso(self.decidedat),
|
||||
'decidedby': self.decidedby,
|
||||
'sourceip': self.sourceip,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<EndpointEnrollRequest {self.hostname!r} {self.status}>'
|
||||
|
||||
|
||||
class EndpointMigrationWave(db.Model):
|
||||
"""One batch of agent upgrades pushed to move PCs onto credentials."""
|
||||
|
||||
__tablename__ = 'endpointmigrationwaves'
|
||||
|
||||
waveid = db.Column(db.Integer, primary_key=True)
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
createdby = db.Column(db.String(64), nullable=True)
|
||||
# Comma list of asset ids. TEXT, not JSON: MySQL 5.6.
|
||||
assetids = db.Column(db.Text, nullable=False, default='')
|
||||
notes = db.Column(db.String(255), nullable=True)
|
||||
|
||||
@property
|
||||
def assetidlist(self):
|
||||
return [int(part) for part in (self.assetids or '').split(',')
|
||||
if part.strip().isdigit()]
|
||||
|
||||
def __repr__(self):
|
||||
return f'<EndpointMigrationWave {self.waveid}>'
|
||||
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
EndpointLogCollection
|
||||
A plaintext log file collected from an endpoint on operator
|
||||
demand. Kept out of EndpointCommand.resultjson because it is
|
||||
bulky, needs its own per-asset retention cap, and has to be
|
||||
addressable by id for the download route.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
@@ -80,6 +90,65 @@ class EndpointState(db.Model):
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Hardware and OS inventory, same newest-snapshot rule as above. These
|
||||
# arrived on the wire well before this and were logged as unknown keys, so
|
||||
# the shapes are already proven against real endpoint traffic.
|
||||
disksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
biosjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
memoryjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
processorjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
osinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Session-scoped data collected by a probe running as the signed-in user
|
||||
# (mapped drives, user-owned listeners). Null when nobody is signed in,
|
||||
# which is a normal unattended state rather than a fault.
|
||||
usercontextjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Windows domain or workgroup membership. A scalar, not JSON: it is a single
|
||||
# value the fleet view filters and groups on.
|
||||
domain = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# True when the endpoint is holding a reboot. Null = never reported, which
|
||||
# is deliberately distinct from a confident False.
|
||||
pendingreboot = db.Column(db.Boolean, nullable=True)
|
||||
|
||||
# Per-drive health from the agent's two disk sources. JSON because a machine
|
||||
# has a variable number of drives and each carries its own verdict. Kept
|
||||
# separate from disksjson: that is capacity, this is whether the hardware is
|
||||
# dying. Null = never reported.
|
||||
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
|
||||
# from three sources and so cannot say WHY, and the difference matters: a
|
||||
# pendingfilerename means files are staged for replacement on next boot, so
|
||||
# the box is mid-servicing, while a lone servicing key can sit set for weeks
|
||||
# on a machine nobody rebooted. Empty list = checked, nothing pending.
|
||||
# Null = never reported or the check failed.
|
||||
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Fast startup configuration and what the last boot actually did. Kept
|
||||
# because uptime is not trustworthy under fast startup: a box can report
|
||||
# days of uptime after what the operator experienced as a shutdown, and a
|
||||
# "reboot" that was a hybrid resume does not clear the state a support call
|
||||
# is usually trying to clear.
|
||||
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
|
||||
# this environment produces known false positives, and wiring it to a card
|
||||
# now would train operators to ignore that card. Stored so the history is
|
||||
# already accumulating when someone is ready to interpret it.
|
||||
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
|
||||
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
|
||||
# the count drives the status card; the short list is stored so the panel
|
||||
# can show what is actually pending without a second round trip.
|
||||
#
|
||||
# A null column and a stored payload with status 'unknown' both mean "never
|
||||
# scanned", which stays distinct from a scan that found nothing pending. The
|
||||
# status card relies on that distinction - see _dell_updates_field.
|
||||
dellupdatesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||
# the endpoint is expected to be running but the latest snapshot does not
|
||||
@@ -116,6 +185,8 @@ class EndpointState(db.Model):
|
||||
'idleseconds': self.idleseconds,
|
||||
'cpupercent': self.cpupercent,
|
||||
'memorypercent': self.memorypercent,
|
||||
'domain': self.domain,
|
||||
'pendingreboot': self.pendingreboot,
|
||||
'mtconnectrunning': self.mtconnectrunning,
|
||||
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
||||
'lastreportedat': _utciso(self.lastreportedat),
|
||||
@@ -214,8 +285,16 @@ class EndpointCommand(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, includedetail=False):
|
||||
"""Serialise a command for the operator views.
|
||||
|
||||
resultjson is opt-in. It is agent-reported structured detail, and for a
|
||||
script it carries the evidence that a green badge alone does not: the
|
||||
exit code, the context it ran as and the directory it really ran in. On
|
||||
failure the agent also puts the captured output in there, so it is not
|
||||
served to every list that only needs a status.
|
||||
"""
|
||||
data = {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
@@ -228,6 +307,24 @@ class EndpointCommand(db.Model):
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
if includedetail:
|
||||
data['resultdetail'] = self._resultdetail()
|
||||
return data
|
||||
|
||||
def _resultdetail(self):
|
||||
"""resultjson as a dict, or None.
|
||||
|
||||
Stored as text, so a malformed or truncated payload is possible. A
|
||||
detail panel is diagnostics: failing to parse it must not break the
|
||||
command list that surrounds it.
|
||||
"""
|
||||
if not self.resultjson:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(self.resultjson)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
@@ -255,6 +352,16 @@ class EndpointEvent(db.Model):
|
||||
eventchannel = db.Column(db.String(50), nullable=True)
|
||||
eventsource = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# The Windows event record id: monotonic and unique within its channel on a
|
||||
# given machine. This is the dedup key. The agent also uses it as its
|
||||
# harvest watermark, so in normal operation a record is sent once; the
|
||||
# unique index below is what guarantees it even when an agent is reinstalled
|
||||
# (losing its watermark) or two reports overlap. Nullable because an agent
|
||||
# older than this field still reports, and MySQL permits repeated NULLs in a
|
||||
# unique index, so those degrade to the previous append-only behavior rather
|
||||
# than failing.
|
||||
eventrecordid = db.Column(db.BigInteger, nullable=True)
|
||||
|
||||
# When the event happened on the endpoint (from the record), not when it
|
||||
# reached ShopDB.
|
||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||
@@ -269,6 +376,8 @@ class EndpointEvent(db.Model):
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||
db.Index('idx_endpointevents_assetrecord',
|
||||
'assetid', 'eventchannel', 'eventrecordid', unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -279,6 +388,7 @@ class EndpointEvent(db.Model):
|
||||
'endpointeventid': self.endpointeventid,
|
||||
'assetid': self.assetid,
|
||||
'eventid': self.eventid,
|
||||
'eventrecordid': self.eventrecordid,
|
||||
'eventchannel': self.eventchannel,
|
||||
'eventsource': self.eventsource,
|
||||
'eventtime': _utciso(self.eventtime),
|
||||
@@ -354,8 +464,15 @@ class EndpointApplication(db.Model):
|
||||
# When this snapshot row was written (the report that produced it).
|
||||
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# SHA256 fingerprint for delta-based updates: hash of displayname, displayversion,
|
||||
# publisher. Allows efficient comparison to detect changes without comparing all
|
||||
# columns. Computed by agent and sent in payload. Nullable for backward compatibility
|
||||
# during migration - backfill script will populate existing rows.
|
||||
appfingerprint = db.Column(db.String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'),
|
||||
db.Index('idx_endpointapplications_fingerprint', 'assetid', 'appfingerprint', unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -371,3 +488,216 @@ class EndpointApplication(db.Model):
|
||||
'appid': self.appid,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointLock(db.Model):
|
||||
"""An admin-only quarantine refusing operator commands on one endpoint.
|
||||
|
||||
Deliberately NOT the same thing as a PolicyActionHold, despite the similar
|
||||
name. The two sit on different axes and collapsing them would recreate the
|
||||
axis-mixing this refactor exists to undo:
|
||||
|
||||
PolicyActionHold policy-resolution axis. Suppresses ONE desired-state
|
||||
action so reconciliation stops fighting an admin.
|
||||
Per-action, several may stand at once, and it EXPIRES.
|
||||
EndpointLock caller-authority axis. Refuses commands from non-admins
|
||||
on this endpoint regardless of policy or permission.
|
||||
Per-endpoint, one at a time, and INDEFINITE.
|
||||
|
||||
Indefinite is the point. A lock represents a physical-world condition - a
|
||||
machine mid-repair, a validated part running, a box that must not reboot
|
||||
during a customer audit - and those do not end on a timer. A TTL here would
|
||||
quietly unlock a machine someone is still working inside, which is the exact
|
||||
accident the lock exists to prevent. Stale locks are surfaced in the UI
|
||||
instead of being auto-cleared.
|
||||
|
||||
Released rows are kept and marked rather than deleted: the record of who
|
||||
quarantined a machine, why, and who lifted it is the audit trail, and it is
|
||||
most wanted precisely when the lock is no longer there to explain itself.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlocks'
|
||||
|
||||
endpointlockid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Not unique at the schema level: released rows accumulate per asset and a
|
||||
# unique constraint would make the second lock in an endpoint's life fail.
|
||||
# The one-active-lock invariant is enforced on write in PLACE_ENDPOINT_LOCK.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
reason = db.Column(db.Text, nullable=False)
|
||||
|
||||
# Who set it. The id goes null if the account is deleted, but the username
|
||||
# snapshot survives: a quarantine that forgets who imposed it is unusable at
|
||||
# review time, which is the only time anyone reads it.
|
||||
lockedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
lockedbyusername = db.Column(db.String(100), nullable=True)
|
||||
lockedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Null while the lock stands. Set on release, which is what makes this row
|
||||
# inactive - there is no isactive flag to drift out of step with them.
|
||||
releasedat = db.Column(db.DateTime, nullable=True)
|
||||
releasedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
releasedbyusername = db.Column(db.String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves the hot path: "is there an active lock on this asset", asked on
|
||||
# every enqueue and every detail page load.
|
||||
db.Index('idx_endpointlocks_assetactive', 'assetid', 'releasedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
state = 'released' if self.releasedat else 'active'
|
||||
return f"<EndpointLock asset={self.assetid} {state}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointlockid': self.endpointlockid,
|
||||
'assetid': self.assetid,
|
||||
'reason': self.reason,
|
||||
'lockedbyuserid': self.lockedbyuserid,
|
||||
'lockedbyusername': self.lockedbyusername,
|
||||
'lockedat': _utciso(self.lockedat),
|
||||
'releasedat': _utciso(self.releasedat),
|
||||
'releasedbyusername': self.releasedbyusername,
|
||||
}
|
||||
|
||||
|
||||
class EndpointLogCollection(db.Model):
|
||||
"""One plaintext log file collected from an endpoint by an operator.
|
||||
|
||||
Collected content lives here rather than in EndpointCommand.resultjson for
|
||||
three reasons. It is bulky and would be dragged along by every command
|
||||
history list that only wants a status; it needs its own retention rule
|
||||
(RETENTION_PER_ASSET), which a command row cannot express without deleting
|
||||
the audit record of the command itself; and the download route needs to
|
||||
serve bytes, which means it needs a row it can address by id rather than a
|
||||
JSON field it has to re-parse.
|
||||
|
||||
Retention is deliberately PER ASSET. A global cap would let one busy machine
|
||||
evict another machine's history, and the history is read per machine on the
|
||||
PC detail page, so a global cap would make the panel unreliable exactly
|
||||
where it is used. Pruning happens on insert in the result route.
|
||||
|
||||
The content stored here is a TAIL, not necessarily the whole file - see
|
||||
istruncated. A log that has run for months is not something to load into a
|
||||
browser pane, and the agent bounds what it sends.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlogcollections'
|
||||
|
||||
# How many collections to keep per asset. The operator asked for 20: enough
|
||||
# to compare a handful of runs against each other, few enough that stored
|
||||
# log text does not grow without bound across a fleet.
|
||||
RETENTION_PER_ASSET = 20
|
||||
|
||||
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The command that produced this, kept for audit: it ties the collected
|
||||
# bytes back to who queued the collection and when it ran. SET NULL rather
|
||||
# than CASCADE so pruning or expiring a command never silently destroys a
|
||||
# log an operator is still looking at.
|
||||
endpointcommandid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# The path as the AGENT resolved it, which is not always the path the
|
||||
# operator typed (environment variables get expanded endpoint-side). Storing
|
||||
# the resolved one is what makes the history honest about what was read.
|
||||
sourcepath = db.Column(db.String(512), nullable=False)
|
||||
|
||||
content = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Size of the file ON DISK, not the size of content. When the tail is
|
||||
# truncated these differ, and that difference is the whole point: it tells
|
||||
# the operator they are looking at the end of something larger.
|
||||
filesizebytes = db.Column(db.BigInteger, nullable=True)
|
||||
contentbytes = db.Column(db.Integer, nullable=True)
|
||||
linecount = db.Column(db.Integer, nullable=True)
|
||||
istruncated = db.Column(db.Boolean, nullable=False,
|
||||
default=False, server_default=sa.false())
|
||||
|
||||
# Last-write time reported by the endpoint. A log that has not changed since
|
||||
# the previous collection is the fastest way to see an app is not logging.
|
||||
filemodifiedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Username snapshot alongside the id: the id alone becomes unreadable if the
|
||||
# user is later removed, and this history is read long after collection.
|
||||
collectedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
collectedbyusername = db.Column(db.String(100), nullable=True)
|
||||
collectedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding the oldest rows to prune on insert.
|
||||
db.Index('idx_endpointlogcollections_assetcollected',
|
||||
'assetid', 'collectedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointLogCollection asset={self.assetid} "
|
||||
f"{self.sourcepath}>")
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""A download filename derived from the collected path."""
|
||||
raw = (self.sourcepath or '').replace('/', '\\')
|
||||
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
|
||||
# Keep it to characters that survive a Content-Disposition header and a
|
||||
# Windows save dialog without quoting games.
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
|
||||
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
|
||||
return f'{stamp}_{safe}'
|
||||
|
||||
def to_dict(self, includecontent=False):
|
||||
"""Serialise a collection.
|
||||
|
||||
Content is opt-in for the same reason resultjson is on EndpointCommand:
|
||||
the history list renders 20 rows and none of them need the log body,
|
||||
which may be hundreds of kilobytes each.
|
||||
"""
|
||||
data = {
|
||||
'endpointlogcollectionid': self.endpointlogcollectionid,
|
||||
'assetid': self.assetid,
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'sourcepath': self.sourcepath,
|
||||
'filesizebytes': self.filesizebytes,
|
||||
'contentbytes': self.contentbytes,
|
||||
'linecount': self.linecount,
|
||||
'istruncated': bool(self.istruncated),
|
||||
'filemodifiedat': _utciso(self.filemodifiedat),
|
||||
'collectedbyuserid': self.collectedbyuserid,
|
||||
'collectedbyusername': self.collectedbyusername,
|
||||
'collectedat': _utciso(self.collectedat),
|
||||
'filename': self.filename,
|
||||
}
|
||||
if includecontent:
|
||||
data['content'] = self.content or ''
|
||||
return data
|
||||
|
||||
267
plugins/endpoints/models/health.py
Normal file
267
plugins/endpoints/models/health.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Endpoint health rollup: cached verdict, transition history, impact mapping.
|
||||
|
||||
Three tables, one job: decide what the Fleet Health column says about an
|
||||
endpoint, decide it ONCE when the data changes, and keep enough history to
|
||||
answer "when did this break".
|
||||
|
||||
Why this exists at all. The verdict used to be computed on every Fleet page
|
||||
load by aggregating 48h of endpointevents fleet-wide, which cost more the
|
||||
longer the fleet ran, and it ranked a raw Windows event count above every
|
||||
other signal. A box with a crash loop reported "Application crash x5861" while
|
||||
its dead eDNC service - the thing that actually stops work on the floor - was
|
||||
structurally unreachable, because the event branch returned first.
|
||||
|
||||
The model here separates two things the old rollup conflated:
|
||||
|
||||
events Point-in-time facts. They happened, they are counted, they age
|
||||
out of the window on their own.
|
||||
conditions A stopped service or a disconnected drive is not an event, it is
|
||||
a STATE with a duration. It opens, it persists, and it is only
|
||||
over when something observes it to be over.
|
||||
|
||||
Ranking a condition against an event count is comparing incompatible things.
|
||||
Conditions are pinned until explicitly resolved; events are ranked beneath
|
||||
them. That is the whole reason the Health column can be trusted now.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
def _utciso(value):
|
||||
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
# Operational impact, in the site's terms rather than the OS's. Windows calls a
|
||||
# crash severe; the floor calls a disconnected drive a stoppage. These disagree,
|
||||
# and the floor wins - which is why this vocabulary is not the event catalog's
|
||||
# 'critical'/'warning'/'info'.
|
||||
IMPACT_BLOCKER = 'blocker'
|
||||
IMPACT_DEGRADED = 'degraded'
|
||||
IMPACT_INFORMATIONAL = 'informational'
|
||||
|
||||
IMPACT_CLASSES = (IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL)
|
||||
|
||||
# Lower sorts worse, matching _DRIVE_STATUS_RANK's existing convention so the
|
||||
# two ladders in this plugin do not read in opposite directions.
|
||||
IMPACT_RANK = {
|
||||
IMPACT_BLOCKER: 0,
|
||||
IMPACT_DEGRADED: 1,
|
||||
IMPACT_INFORMATIONAL: 2,
|
||||
}
|
||||
|
||||
# Where a verdict came from. Kept alongside sourceref so the UI can deep-link
|
||||
# back to the monitor or the event that produced the label.
|
||||
SOURCE_DRIVE = 'drive'
|
||||
SOURCE_SERVICE = 'service'
|
||||
SOURCE_PROCESS = 'process'
|
||||
SOURCE_EVENT = 'event'
|
||||
SOURCE_NONE = 'none'
|
||||
|
||||
|
||||
class EndpointHealthState(db.Model):
|
||||
"""The current health verdict for one endpoint - the Fleet page's only read.
|
||||
|
||||
Cached deliberately. The Fleet list joins this and reads it directly; it
|
||||
does not recompute anything per row. Written in the SAME commit as the
|
||||
check-in that produced it, so the cached verdict and the telemetry it
|
||||
reflects can never disagree (the ledger pattern this codebase already uses
|
||||
for quantityonhand).
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointhealthstates'
|
||||
|
||||
endpointhealthstateid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# One row per asset. Unique rather than a plain index: two live verdicts
|
||||
# for one endpoint is not a state this table should be able to represent.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# Display vocabulary, matching what the Fleet renderer already understands:
|
||||
# 'ok', 'warning', 'critical', 'unknown'.
|
||||
level = db.Column(db.String(16), nullable=False, default='unknown')
|
||||
|
||||
# The phrase an operator reads ("eDNC stopped", "S: disconnected"). A
|
||||
# PHRASE, not a score - it has to say what to do.
|
||||
label = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Null for an ok/unknown verdict: nothing is wrong, so nothing has impact.
|
||||
impactclass = db.Column(db.String(16), nullable=True)
|
||||
|
||||
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
|
||||
|
||||
# Monitor id, drive letter or Windows event id, depending on sourcekind.
|
||||
# Free text because those identifier spaces have nothing in common.
|
||||
sourceref = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# When the CURRENT condition opened. Survives re-evaluation: a service that
|
||||
# has been down for three days keeps its original firstseenat, so the UI can
|
||||
# say how long rather than only that it is down now.
|
||||
firstseenat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Last time an agent explicitly observed this endpoint. Drives staleness:
|
||||
# a pinned blocker on an endpoint that stopped reporting is neither
|
||||
# resolvable nor trustworthy, and must not render as present tense.
|
||||
lastconfirmedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
lastevaluatedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow)
|
||||
|
||||
# Transitions inside the trailing window. A service flapping ten times an
|
||||
# hour is a different fault from one cleanly down, and dispatching someone
|
||||
# to a service that has already restarted twice wastes a trip.
|
||||
flapcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointhealthstates_asset', 'assetid'),
|
||||
db.Index('idx_endpointhealthstates_impactclass', 'impactclass'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointHealthState asset={self.assetid} {self.level}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'assetid': self.assetid,
|
||||
'level': self.level,
|
||||
'label': self.label,
|
||||
'impactclass': self.impactclass,
|
||||
'sourcekind': self.sourcekind,
|
||||
'sourceref': self.sourceref,
|
||||
'firstseenat': _utciso(self.firstseenat),
|
||||
'lastconfirmedat': _utciso(self.lastconfirmedat),
|
||||
'lastevaluatedat': _utciso(self.lastevaluatedat),
|
||||
'flapcount': self.flapcount,
|
||||
}
|
||||
|
||||
|
||||
class EndpointHealthTransition(db.Model):
|
||||
"""One verdict CHANGE for an endpoint. Written on change, never on sample.
|
||||
|
||||
Storing a row per check-in would make the last 50 rows cover a few minutes
|
||||
and say nothing. Storing a row per change makes 50 rows cover months on a
|
||||
stable endpoint, compresses "nothing happened" to zero cost, and answers
|
||||
the question operators actually ask - when did this break - directly rather
|
||||
than by inferring it from a gap between samples.
|
||||
|
||||
It also makes flapping measurable: 50 transitions in an hour and 50 over a
|
||||
year are the same row count and obviously different faults.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointhealthtransitions'
|
||||
|
||||
endpointhealthtransitionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
level = db.Column(db.String(16), nullable=False)
|
||||
label = db.Column(db.String(255), nullable=True)
|
||||
impactclass = db.Column(db.String(16), nullable=True)
|
||||
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
|
||||
sourceref = db.Column(db.String(64), nullable=True)
|
||||
|
||||
openedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Null means STILL OPEN. Retention never evicts an unresolved row: at 50
|
||||
# deep a long-running blocker's opening transition would otherwise age out
|
||||
# behind a burst of unrelated churn, deleting the record of why the thing
|
||||
# currently on screen is on screen.
|
||||
resolvedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointhealthtransitions_assetopened',
|
||||
'assetid', 'openedat'),
|
||||
db.Index('idx_endpointhealthtransitions_assetresolved',
|
||||
'assetid', 'resolvedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointHealthTransition asset={self.assetid} "
|
||||
f"{self.level} opened={self.openedat}>")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointhealthtransitionid': self.endpointhealthtransitionid,
|
||||
'assetid': self.assetid,
|
||||
'level': self.level,
|
||||
'label': self.label,
|
||||
'impactclass': self.impactclass,
|
||||
'sourcekind': self.sourcekind,
|
||||
'sourceref': self.sourceref,
|
||||
'openedat': _utciso(self.openedat),
|
||||
'resolvedat': _utciso(self.resolvedat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointMonitorImpact(db.Model):
|
||||
"""How much a given monitor matters ON A GIVEN PC TYPE.
|
||||
|
||||
eDNC stopped on a shopfloor PC halts work; the same service stopped on a
|
||||
lobby display is noise. Impact is therefore a property of the monitor IN
|
||||
CONTEXT, not of the monitor and not of the event type - which is why it
|
||||
cannot live on endpointmonitors and could never have lived in the event
|
||||
catalog.
|
||||
|
||||
Why a plugin-owned side table rather than a column on the policy action:
|
||||
monitors bind to PC types through configurationpolicyactions, which is a
|
||||
CORE table shared by every plugin. Adding an endpoints-specific impact
|
||||
column there would be a contract-surface change imposed on unrelated
|
||||
plugins. This keeps the concept inside the plugin that owns it.
|
||||
|
||||
ABSENCE OF A ROW IS THE DEFAULT, and that is load-bearing. An endpoint with
|
||||
no policies, no PC type, or no monitors at all resolves to the built-in
|
||||
default impact for its source kind - the same verdict the old rollup gave.
|
||||
The no-policy path is the NORMAL path here, not a special case.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointmonitorimpacts'
|
||||
|
||||
endpointmonitorimpactid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
endpointmonitorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointmonitors.endpointmonitorid',
|
||||
ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Plain integer, not a ForeignKey: computertypes belongs to the computers
|
||||
# plugin and owns its own migration chain. A cross-plugin FK would couple
|
||||
# the two chains' ordering for no benefit the unique constraint below does
|
||||
# not already provide.
|
||||
computertypeid = db.Column(db.Integer, nullable=False)
|
||||
|
||||
impactclass = db.Column(db.String(16), nullable=False,
|
||||
default=IMPACT_DEGRADED)
|
||||
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('endpointmonitorid', 'computertypeid',
|
||||
name='uq_endpointmonitorimpacts_monitortype'),
|
||||
db.Index('idx_endpointmonitorimpacts_computertypeid', 'computertypeid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointMonitorImpact monitor={self.endpointmonitorid} "
|
||||
f"pctype={self.computertypeid} {self.impactclass}>")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointmonitorimpactid': self.endpointmonitorimpactid,
|
||||
'endpointmonitorid': self.endpointmonitorid,
|
||||
'computertypeid': self.computertypeid,
|
||||
'impactclass': self.impactclass,
|
||||
'createdat': _utciso(self.createdat),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
416
plugins/endpoints/services/health.py
Normal file
416
plugins/endpoints/services/health.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""Evaluate one endpoint's health verdict and record it when it changes.
|
||||
|
||||
Scoped to ONE assetid on purpose. The old rollup aggregated 48h of events
|
||||
fleet-wide on every Fleet page load; this runs when the data actually changes
|
||||
(a check-in, or an explicit refresh from PC detail) and narrows to a single
|
||||
asset, which the existing idx_endpointevents_assetevent (assetid, eventtime)
|
||||
composite already serves. The Fleet page then reads a cached row per endpoint
|
||||
and recomputes nothing.
|
||||
|
||||
THE ORDERING RULE, which is the whole point of this module:
|
||||
|
||||
1. Unresolved BLOCKER conditions (drive down, blocking service stopped)
|
||||
2. DEGRADED conditions (stalled service, non-blocking stop)
|
||||
3. Events, ranked by catalog severity
|
||||
4. ok / unknown
|
||||
|
||||
A condition outranks an event count no matter how large the count. That is the
|
||||
inversion of the old behaviour, where the event branch returned first and a
|
||||
crash loop permanently hid a dead service.
|
||||
|
||||
THE NO-POLICY RULE, which is what keeps it safe:
|
||||
|
||||
A policy binding may only RAISE impact. It may never gate PRESENCE.
|
||||
|
||||
An endpoint with no policies, no PC type, or no monitors still gets a verdict.
|
||||
Drives and events are not policy-gated at all, so they work with zero
|
||||
configuration; services fall back to a built-in default impact. Absence of an
|
||||
EndpointMonitorImpact row is the normal path, not an error path.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from ..models.endpoint import EndpointEvent
|
||||
from ..models.health import (
|
||||
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
||||
IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL, IMPACT_RANK,
|
||||
SOURCE_DRIVE, SOURCE_EVENT, SOURCE_NONE, SOURCE_PROCESS, SOURCE_SERVICE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keep the last 50 transitions per asset, but do not pay for a DELETE on every
|
||||
# write. Let history grow to the high-water mark and then trim back, which
|
||||
# amortises the delete to roughly once per ten transitions and leaves the write
|
||||
# path a plain insert almost every time.
|
||||
TRANSITION_RETENTION = 50
|
||||
TRANSITION_HIGHWATER = 60
|
||||
|
||||
# Window for the flap counter and the event rollup. Matches the 48h the
|
||||
# stability card used, so the number on screen does not change meaning.
|
||||
HEALTH_WINDOW_HOURS = 48
|
||||
|
||||
# A drive that is unavailable stops work on the floor. This is the one signal
|
||||
# that is a blocker with NO configuration at all - it never consults policy,
|
||||
# so it survives an endpoint that has none. What counts as unavailable is
|
||||
# _mapped_drive_rows' decision, not a second list maintained here.
|
||||
|
||||
# Default impact when no EndpointMonitorImpact row applies. This is the
|
||||
# no-policy path, and it reproduces the old rollup's verdict rather than
|
||||
# inventing a new one: a stopped watched service was a warning before and is
|
||||
# 'degraded' now.
|
||||
_DEFAULT_IMPACT_BY_SOURCE = {
|
||||
SOURCE_DRIVE: IMPACT_BLOCKER,
|
||||
SOURCE_SERVICE: IMPACT_DEGRADED,
|
||||
SOURCE_PROCESS: IMPACT_DEGRADED,
|
||||
SOURCE_EVENT: IMPACT_INFORMATIONAL,
|
||||
}
|
||||
|
||||
_LEVEL_BY_IMPACT = {
|
||||
IMPACT_BLOCKER: 'critical',
|
||||
IMPACT_DEGRADED: 'warning',
|
||||
IMPACT_INFORMATIONAL: 'warning',
|
||||
}
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def impact_for_monitor(endpointmonitorid, computertypeid):
|
||||
"""Impact class for a monitor on a PC type, or None if unmapped.
|
||||
|
||||
Returns None rather than raising or defaulting when there is no mapping,
|
||||
no PC type, or no monitor id. The caller supplies the default; this
|
||||
function only reports what policy explicitly said, so "policy said
|
||||
degraded" and "policy said nothing" stay distinguishable.
|
||||
"""
|
||||
if endpointmonitorid is None or computertypeid is None:
|
||||
return None
|
||||
row = (EndpointMonitorImpact.query
|
||||
.filter_by(endpointmonitorid=endpointmonitorid,
|
||||
computertypeid=computertypeid)
|
||||
.first())
|
||||
return row.impactclass if row is not None else None
|
||||
|
||||
|
||||
def _drive_conditions(state):
|
||||
"""Open drive conditions from the last user-context snapshot.
|
||||
|
||||
Not policy-gated: mapped drives arrive with the user-context payload and
|
||||
are evaluated for every endpoint regardless of configuration. On a shop
|
||||
floor a dead share is a stoppage, so this is a blocker by default and does
|
||||
not wait for anyone to declare it one.
|
||||
"""
|
||||
from ..api.routes import _mapped_drive_rows
|
||||
|
||||
raw = getattr(state, 'usercontextjson', None)
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
try:
|
||||
context = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
# A malformed payload is not a health verdict. Say nothing rather than
|
||||
# claiming the endpoint is either broken or fine.
|
||||
logger.warning('malformed usercontextjson for asset %s',
|
||||
getattr(state, 'assetid', None))
|
||||
return []
|
||||
|
||||
if not isinstance(context, dict):
|
||||
return []
|
||||
|
||||
out = []
|
||||
for row in _mapped_drive_rows(context):
|
||||
# Reuse the card's own verdict rather than re-deriving one from the
|
||||
# raw status string. _mapped_drive_rows already resolves the two
|
||||
# conflicting sources, treats unknown as unhealthy, and is the thing
|
||||
# the PC detail card renders - so the Health column and the drive card
|
||||
# cannot disagree about whether a drive is down.
|
||||
if row['statecolor'] == 'green':
|
||||
continue
|
||||
out.append({
|
||||
'impactclass': IMPACT_BLOCKER,
|
||||
'sourcekind': SOURCE_DRIVE,
|
||||
'sourceref': row['drive'],
|
||||
'label': '{} {}'.format(row['drive'], row['statuslabel']),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _service_conditions(state, computertypeid):
|
||||
"""Open service conditions from the last services snapshot.
|
||||
|
||||
Distinguishes STALLED from STOPPED, which the old rollup could not: it
|
||||
read a running/not-running boolean, so a service sitting in stop-pending
|
||||
was reported identically to one cleanly down. _service_transitional() has
|
||||
existed for a while; nothing was calling it.
|
||||
|
||||
Policy-gated only in the sense that an endpoint with no monitors reports no
|
||||
services and therefore has no service conditions - which is the existing
|
||||
contract, not a regression. Where a service IS reported, an unmapped
|
||||
monitor falls back to the built-in default impact.
|
||||
"""
|
||||
from ..api.routes import (_service_name, _service_running,
|
||||
_service_transitional)
|
||||
|
||||
raw = getattr(state, 'servicesjson', None)
|
||||
if not raw:
|
||||
# Nothing watched, or nothing reported. Distinct from malformed: this
|
||||
# is "no condition", and it must read as ok rather than unknown or the
|
||||
# Fleet page would light up every unpolicied endpoint.
|
||||
return []
|
||||
|
||||
try:
|
||||
entries = json.loads(raw) or []
|
||||
except (TypeError, ValueError):
|
||||
# Malformed IS unknown - we genuinely cannot tell. Signalled to the
|
||||
# caller with a sentinel so it does not get mistaken for "healthy".
|
||||
return None
|
||||
|
||||
out = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _service_running(entry):
|
||||
continue
|
||||
|
||||
name = _service_name(entry)
|
||||
monitorid = entry.get('monitorid') or entry.get('endpointmonitorid')
|
||||
raw_state = entry.get('state') or entry.get('status') or ''
|
||||
stalled = _service_transitional(raw_state)
|
||||
|
||||
mapped = impact_for_monitor(monitorid, computertypeid)
|
||||
if mapped is not None:
|
||||
impactclass = mapped
|
||||
elif stalled:
|
||||
# A stalled service MAY be a blocker but we cannot know without
|
||||
# someone saying so. Degraded until policy says otherwise - it is
|
||||
# visible, but it does not cry wolf.
|
||||
impactclass = IMPACT_DEGRADED
|
||||
else:
|
||||
impactclass = _DEFAULT_IMPACT_BY_SOURCE[SOURCE_SERVICE]
|
||||
|
||||
kind = (entry.get('kind') or SOURCE_SERVICE).strip().lower()
|
||||
sourcekind = SOURCE_PROCESS if kind == 'process' else SOURCE_SERVICE
|
||||
|
||||
out.append({
|
||||
'impactclass': impactclass,
|
||||
'sourcekind': sourcekind,
|
||||
'sourceref': str(monitorid) if monitorid is not None else name,
|
||||
'label': '{} {}'.format(name, 'stalled' if stalled else 'stopped'),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _event_condition(assetid, cutoff):
|
||||
"""Worst recent event for one asset, as a pseudo-condition, or None.
|
||||
|
||||
Narrowed to a single assetid so the (assetid, eventtime) composite serves
|
||||
it. Counts are still shown, but a count can no longer outrank a real
|
||||
condition - it is ranked beneath every one of them.
|
||||
"""
|
||||
if not CRITICAL_EVENT_IDS:
|
||||
return None
|
||||
|
||||
rows = (db.session.query(EndpointEvent.eventid,
|
||||
db.func.count(EndpointEvent.endpointeventid))
|
||||
.filter(EndpointEvent.assetid == assetid,
|
||||
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
|
||||
EndpointEvent.eventtime.isnot(None),
|
||||
EndpointEvent.eventtime >= cutoff)
|
||||
.group_by(EndpointEvent.eventid)
|
||||
.all())
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
total = sum(count for _eventid, count in rows)
|
||||
dominant = max(rows, key=lambda row: row[1])[0]
|
||||
title = event_title(dominant)
|
||||
label = title if total == 1 else '{} x{}'.format(title, total)
|
||||
|
||||
severity = event_severity(dominant)
|
||||
impactclass = (IMPACT_DEGRADED if severity == 'critical'
|
||||
else IMPACT_INFORMATIONAL)
|
||||
|
||||
return {
|
||||
'impactclass': impactclass,
|
||||
'sourcekind': SOURCE_EVENT,
|
||||
'sourceref': str(dominant),
|
||||
'label': label,
|
||||
}
|
||||
|
||||
|
||||
def _verdict(state, computertypeid, assetid, now):
|
||||
"""Resolve the single worst signal into a verdict dict.
|
||||
|
||||
Order is impact class first, source second. A flat "conditions always beat
|
||||
events" rule would eventually put a stopped print spooler above filesystem
|
||||
corruption; ranking by impact avoids that while still guaranteeing a
|
||||
blocker outranks any event.
|
||||
"""
|
||||
cutoff = now - timedelta(hours=HEALTH_WINDOW_HOURS)
|
||||
|
||||
services = _service_conditions(state, computertypeid)
|
||||
if services is None:
|
||||
return {
|
||||
'level': 'unknown',
|
||||
'label': None,
|
||||
'impactclass': None,
|
||||
'sourcekind': SOURCE_NONE,
|
||||
'sourceref': None,
|
||||
}
|
||||
|
||||
candidates = list(_drive_conditions(state)) + list(services)
|
||||
|
||||
event = _event_condition(assetid, cutoff)
|
||||
if event is not None:
|
||||
candidates.append(event)
|
||||
|
||||
if not candidates:
|
||||
if getattr(state, 'lastreportedat', None) is None:
|
||||
return {
|
||||
'level': 'unknown',
|
||||
'label': 'no agent',
|
||||
'impactclass': None,
|
||||
'sourcekind': SOURCE_NONE,
|
||||
'sourceref': None,
|
||||
}
|
||||
return {
|
||||
'level': 'ok',
|
||||
'label': 'ok',
|
||||
'impactclass': None,
|
||||
'sourcekind': SOURCE_NONE,
|
||||
'sourceref': None,
|
||||
}
|
||||
|
||||
# Conditions before events at equal impact: a live condition is actionable
|
||||
# now, an event is a report of something that already finished happening.
|
||||
def sort_key(candidate):
|
||||
return (
|
||||
IMPACT_RANK.get(candidate['impactclass'], 99),
|
||||
1 if candidate['sourcekind'] == SOURCE_EVENT else 0,
|
||||
)
|
||||
|
||||
worst = sorted(candidates, key=sort_key)[0]
|
||||
return {
|
||||
'level': _LEVEL_BY_IMPACT.get(worst['impactclass'], 'warning'),
|
||||
'label': worst['label'],
|
||||
'impactclass': worst['impactclass'],
|
||||
'sourcekind': worst['sourcekind'],
|
||||
'sourceref': worst['sourceref'],
|
||||
}
|
||||
|
||||
|
||||
def _same_condition(existing, verdict):
|
||||
"""Is this the same ongoing condition, or a new one?
|
||||
|
||||
Compared on identity (what and where), NOT on the label: a crash count
|
||||
ticking from x12 to x13 is the same condition and must not open a new
|
||||
transition, or the history would fill with noise and the flap counter
|
||||
would be meaningless.
|
||||
"""
|
||||
if existing is None:
|
||||
return False
|
||||
return (existing.level == verdict['level']
|
||||
and existing.impactclass == verdict['impactclass']
|
||||
and existing.sourcekind == verdict['sourcekind']
|
||||
and existing.sourceref == verdict['sourceref'])
|
||||
|
||||
|
||||
def _trim_transitions(assetid):
|
||||
"""Enforce retention, keeping every unresolved row regardless of age."""
|
||||
total = (db.session.query(db.func.count(
|
||||
EndpointHealthTransition.endpointhealthtransitionid))
|
||||
.filter(EndpointHealthTransition.assetid == assetid)
|
||||
.scalar()) or 0
|
||||
if total <= TRANSITION_HIGHWATER:
|
||||
return
|
||||
|
||||
keep = [row.endpointhealthtransitionid for row in
|
||||
(db.session.query(
|
||||
EndpointHealthTransition.endpointhealthtransitionid)
|
||||
.filter(EndpointHealthTransition.assetid == assetid)
|
||||
.order_by(EndpointHealthTransition.openedat.desc())
|
||||
.limit(TRANSITION_RETENTION)
|
||||
.all())]
|
||||
|
||||
query = (EndpointHealthTransition.query
|
||||
.filter(EndpointHealthTransition.assetid == assetid,
|
||||
EndpointHealthTransition.resolvedat.isnot(None)))
|
||||
if keep:
|
||||
query = query.filter(
|
||||
~EndpointHealthTransition.endpointhealthtransitionid.in_(keep))
|
||||
query.delete(synchronize_session=False)
|
||||
|
||||
|
||||
def evaluate_endpoint_health(assetid, state, computertypeid=None, now=None):
|
||||
"""Recompute one endpoint's verdict and record a transition if it changed.
|
||||
|
||||
Does NOT commit. The caller owns the transaction so the verdict lands in
|
||||
the same commit as the telemetry it was derived from - the cached row and
|
||||
the data behind it can never disagree.
|
||||
|
||||
Fail-open is the caller's job too: a health error must never fail an
|
||||
otherwise-good check-in.
|
||||
"""
|
||||
now = now or _naive_utcnow()
|
||||
verdict = _verdict(state, computertypeid, assetid, now)
|
||||
|
||||
existing = (EndpointHealthState.query
|
||||
.filter_by(assetid=assetid)
|
||||
.first())
|
||||
|
||||
unchanged = _same_condition(existing, verdict)
|
||||
|
||||
if existing is None:
|
||||
existing = EndpointHealthState(assetid=assetid)
|
||||
db.session.add(existing)
|
||||
|
||||
if not unchanged:
|
||||
# Close the outgoing condition before opening the new one, so the two
|
||||
# never overlap in history.
|
||||
(EndpointHealthTransition.query
|
||||
.filter(EndpointHealthTransition.assetid == assetid,
|
||||
EndpointHealthTransition.resolvedat.is_(None))
|
||||
.update({'resolvedat': now}, synchronize_session=False))
|
||||
|
||||
db.session.add(EndpointHealthTransition(
|
||||
assetid=assetid,
|
||||
level=verdict['level'],
|
||||
label=verdict['label'],
|
||||
impactclass=verdict['impactclass'],
|
||||
sourcekind=verdict['sourcekind'],
|
||||
sourceref=verdict['sourceref'],
|
||||
openedat=now,
|
||||
))
|
||||
|
||||
existing.firstseenat = now
|
||||
window = now - timedelta(hours=HEALTH_WINDOW_HOURS)
|
||||
existing.flapcount = (db.session.query(db.func.count(
|
||||
EndpointHealthTransition.endpointhealthtransitionid))
|
||||
.filter(EndpointHealthTransition.assetid == assetid,
|
||||
EndpointHealthTransition.openedat >= window)
|
||||
.scalar()) or 0
|
||||
|
||||
_trim_transitions(assetid)
|
||||
|
||||
existing.level = verdict['level']
|
||||
existing.label = verdict['label']
|
||||
existing.impactclass = verdict['impactclass']
|
||||
existing.sourcekind = verdict['sourcekind']
|
||||
existing.sourceref = verdict['sourceref']
|
||||
existing.lastevaluatedat = now
|
||||
|
||||
# Only an explicit observation confirms an endpoint. A verdict recomputed
|
||||
# from a stale snapshot must NOT refresh this, or a pinned blocker on a
|
||||
# silent endpoint would look freshly verified forever.
|
||||
if getattr(state, 'lastreportedat', None) is not None:
|
||||
existing.lastconfirmedat = state.lastreportedat
|
||||
|
||||
return existing
|
||||
Reference in New Issue
Block a user