Make rollback, first upgrades and pin changes work as tested on Windows

Found in an end-to-end test on a Windows 11 VM against a self-signed server.

- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
  older agent pushed afterwards arrived on an enrolled PC and was refused.
  Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
  never issued a credential and keeps working on the shared key until
  POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
  block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
  certificate when downloading the upgrade, so a store wave failed on PCs that
  do not trust a self-signed server while the Migration tab said ready. A
  failed download now marks the PC blocked with "server certificate not
  trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
  SYSTEM) adds a non-CA server certificate to the trusted roots after checking
  its thumbprint; the server certificate page offers a .cer download and the
  SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
  planned rotation), can leave the shared key out so the PC waits for
  approval, never touches an existing credential, and skips the trusted-root
  import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
  connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
  otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
  verifying the pin against the live URL; agent contract updated.
This commit is contained in:
cproudlock
2026-09-15 13:37:37 -04:00
parent 31ed9a5fde
commit da4b224ac1
20 changed files with 1713 additions and 151 deletions

View File

@@ -374,29 +374,83 @@
<tr v-if="!credentialRows.length">
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
</tr>
<tr v-for="row in credentialRows" :key="row.assetid">
<td>{{ row.hostname || '-' }}</td>
<td>{{ row.serialnumber || '-' }}</td>
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
<td>
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
<span v-else>-</span>
</td>
<td>{{ formatDate(row.issuedat) }}</td>
<td>{{ formatDate(row.lastusedat) }}</td>
<td>{{ row.enrolledvia || '-' }}</td>
<td v-if="isadmin" class="action-cell">
<button
v-if="row.state === 'enrolled'"
type="button"
class="btn btn-danger btn-sm revoke"
:disabled="busy"
@click="revoke(row)"
>
Un-enroll
</button>
</td>
</tr>
<template v-for="row in credentialRows" :key="row.assetid">
<tr class="credential-row">
<td>{{ row.hostname || '-' }}</td>
<td>{{ row.serialnumber || '-' }}</td>
<td>
<span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span>
<span
v-if="row.reenrollblocked"
class="state-chip blocked block-chip"
:title="row.reenrollblockedreason || ''"
>Re-enroll blocked</span>
</td>
<td>
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
<span v-else>-</span>
</td>
<td>{{ formatDate(row.issuedat) }}</td>
<td>{{ formatDate(row.lastusedat) }}</td>
<td>{{ row.enrolledvia || '-' }}</td>
<td v-if="isadmin" class="action-cell">
<button
v-if="row.state === 'enrolled'"
type="button"
class="btn btn-danger btn-sm revoke"
:disabled="busy"
@click="openRevoke(row)"
>
Un-enroll
</button>
<button
v-if="row.reenrollblocked"
type="button"
class="btn btn-secondary btn-sm allow-reenroll"
:disabled="busy"
@click="allowReenroll(row)"
>
Allow re-enrollment
</button>
</td>
</tr>
<tr v-if="revoking && revoking.assetid === row.assetid" class="revoke-row">
<td :colspan="isadmin ? 8 : 7">
<p class="field-hint">
Un-enroll {{ revoking.hostname }}? Its credential stops working
immediately. {{ revokeFallback }}
</p>
<label class="checkbox-row">
<input v-model="revoking.block" type="checkbox" class="revoke-block" />
<span>
Keep this PC from enrolling again (use before rolling back to
an older agent, or for a compromised PC)
</span>
</label>
<div class="approve-form">
<input
v-if="revoking.block"
v-model="revoking.reason"
type="text"
maxlength="64"
class="form-control revoke-reason"
placeholder="Reason (optional)"
/>
<button
type="button"
class="btn btn-danger btn-sm revoke-confirm"
:disabled="busy"
@click="confirmRevoke"
>
Un-enroll
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="revoking = null">
Cancel
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -518,7 +572,9 @@ function waveConfirmText(count) {
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
+ 'Each PC downloads the new agent and restarts its service. Once the new '
+ 'agent is running it enrolls automatically with the shared key it already '
+ 'holds; no further action is needed unless it lands in Pending.'
+ 'holds; no further action is needed unless it lands in Pending.\n\n'
+ 'PCs on agents older than 1.20 must trust the server certificate before '
+ 'they can download the upgrade (see runbook).'
}
async function startWave(body, count) {
@@ -729,15 +785,37 @@ async function reject(request) {
}
}
async function revoke(row) {
const fallback = enrollmentSettings.value.sharedkeyallowed
const revoking = ref(null)
const revokeFallback = computed(() => {
if (revoking.value?.block) {
return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.'
}
return enrollmentSettings.value.sharedkeyallowed
? 'It falls back to the shared key and enrolls again on its own.'
: 'The shared key is off, so it cannot report until an admin approves its new request.'
if (!confirm(`Un-enroll ${row.hostname}?\n\nIts credential stops working immediately. ${fallback}`)) return
})
function openRevoke(row) {
revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' }
}
async function confirmRevoke() {
const current = revoking.value
if (!current) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
toast.success(`Un-enrolled ${row.hostname}`)
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
if (current.block) {
const body = { blockreenroll: true }
const reason = (current.reason || '').trim()
if (reason) body.reason = reason
await api.post(url, body)
} else {
await api.post(url)
}
toast.success(`Un-enrolled ${current.hostname}`)
revoking.value = null
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not un-enroll'))
@@ -746,6 +824,21 @@ async function revoke(row) {
}
}
async function allowReenroll(row) {
if (!confirm(`Allow ${row.hostname} to enroll again?\n\n`
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
busy.value = true
try {
await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`)
toast.success(`${row.hostname} may enroll again`)
await loadEnrollment({ quiet: true })
} catch (error) {
toast.error(apiError(error, 'Could not allow re-enrollment'))
} finally {
busy.value = false
}
}
// ---- Shared -----------------------------------------------------------
function stateLabel(state) {
@@ -982,6 +1075,10 @@ onBeforeUnmount(() => {
.approve-form .form-control { max-width: 260px; }
.block-chip { margin-left: 0.35rem; }
.revoke-row .form-control { max-width: 260px; }
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;