Make the one-line install survive Defender, and approval leave a PC that works
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

All three found testing the bootstrap installer on Windows.

- The published command used the irm-pipe-iex form, which Microsoft Defender
  detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time,
  on a default install. The command now downloads the script to a file, runs
  it, and removes it in a finally block, which Defender does not object to.
  The UI also offers the script as a plain download for a tech who would
  rather save it.
- Approving a request only linked the asset, so a PC whose pending reason was
  "unknown hostname" - which is every PC installed this way - kept getting 403
  on every call afterwards, because its computer record had no hostname to
  match the credential against. Approval now stamps the hostname when the
  record has none, refuses with a 409 naming both when the record has a
  different one, and overwrites only when the admin confirms. The Credentials
  tab offers that confirmation.
- A hostname that resolves to no record now says so instead of reading as an
  impostor, while still refusing the request.
- While waiting for approval the agent retried on the 15-minute telemetry
  cadence, so a PC approved immediately enrolled a quarter of an hour later
  and the installer always ended on "still pending". It now retries about
  every minute for the first ten minutes, then every five to thirty minutes,
  then every fifteen, with jitter. A rate limit or server error still uses the
  slower backoff.
This commit is contained in:
cproudlock
2026-09-15 20:46:04 -04:00
parent ca57b7f728
commit 2557d1967d
12 changed files with 580 additions and 65 deletions

View File

@@ -621,9 +621,9 @@ request row per hostname and serial (case-insensitive); repeated calls update it
| Status | Body | Agent action |
|---|---|---|
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry every 15 min with jitter |
| 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) |
| 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
@@ -645,6 +645,14 @@ All use the standard error envelope (`data.error.code`).
| 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`,
@@ -729,7 +737,16 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
in state `pending`; `counts.pending` is the number of pending enroll requests,
including requests that match no computer (unknown hostname) and requests from
PCs blocked from re-enrolling, so it equals the pending entries in `requests`.
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
- `POST /api/endpoints/enrollment/requests/<id>/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,
@@ -774,9 +791,28 @@ 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 "irm <baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
```
Download then run, never `irm ... | iex`: Microsoft Defender scores the
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
every bay runs Defender. The shape of the line is load-bearing too:
- a literal temp path and no `$` anywhere, because pasting into an
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
PARENT and hand the child a broken line - as written it means the same in
cmd.exe and in PowerShell;
- single quotes for every inner literal, so the outer `-c "..."` is never
closed early;
- `try`/`finally`, so the dropped file is removed even when the script exits
non-zero or the download itself fails. The wrapper's own exit code is the
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
would rather save the file and run it. Fetching it does not spend a use either
way.
The token is only an authorization to FETCH the installer. The package it
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
says, so the PC enrolls by the normal 9.2 path and waits in Pending until an
@@ -785,7 +821,9 @@ 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", "token"}`.
(`hours` 1-168, `maxuses` 1-500). Returns
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
URL the command downloads, for the UI's Download .ps1 link.
The secret is in the response exactly once; only its sha256 and a 12-character
`tokenprefix` are stored.
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,