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

@@ -330,9 +330,13 @@
</button>
</div>
<p class="field-hint">
The PC receives its credential the next time it asks,
which is within 15 minutes. Only the PC that made this
request can collect it.
The PC receives its credential the next time it asks:
about a minute for one that started asking recently,
slowing to 15 minutes once it has been pending a while.
Only the PC that made this request can collect it.
If the computer record has no hostname, approving
records the one the PC reports, which it needs to
authenticate afterwards.
</p>
</td>
</tr>
@@ -775,6 +779,28 @@ async function searchAssets() {
}
}
// The approval also stamps the request's hostname onto the computer when that
// record has none - without it the PC collects its credential and then 403s on
// every call, because the server resolves the hostname it sends to nothing. A
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
// both, and only an explicit second answer renames it.
async function postApprove(current, sethostname) {
const body = { assetid: current.assetid }
if (sethostname) body.sethostname = true
const response = await api.post(
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
const result = response?.data?.data?.hostnameresult
if (result === 'set') {
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
} else if (result === 'overwritten') {
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
} else {
toast.success(`Approved ${current.hostname}`)
}
approving.value = null
await loadEnrollment({ quiet: true })
}
async function approve() {
const current = approving.value
if (!current || !current.assetid) return
@@ -783,13 +809,24 @@ async function approve() {
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
{ assetid: current.assetid })
toast.success(`Approved ${current.hostname}`)
approving.value = null
await loadEnrollment({ quiet: true })
await postApprove(current, false)
} catch (error) {
toast.error(apiError(error, 'Could not approve the request'))
const details = error?.response?.data?.data?.error?.details
const message = apiError(error, 'Could not approve the request')
if (details?.hostnameconflict) {
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
try {
await postApprove(current, true)
return
} catch (retryError) {
toast.error(apiError(retryError, 'Could not approve the request'))
return
}
}
toast.error(message)
return
}
toast.error(message)
} finally {
busy.value = false
}