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:
@@ -222,10 +222,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 +246,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 +320,10 @@
|
||||
/>
|
||||
<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 certificate is
|
||||
bundled but never imported or pinned). Leave empty when the
|
||||
server uses a publicly trusted certificate. An upload takes precedence
|
||||
over the bundled server certificate above.
|
||||
</p>
|
||||
@@ -298,7 +332,9 @@
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
|
||||
:disabled="agentbusy || !agentbaseurl
|
||||
|| (agentincludesharedkey && !agentcollectorkey)
|
||||
|| agentnextpininvalid()"
|
||||
@click="buildSiteAgent"
|
||||
>
|
||||
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
||||
@@ -529,6 +565,14 @@ const agentcertfile = ref(null)
|
||||
const agentbusy = ref(false)
|
||||
const agentmessage = ref('')
|
||||
const agenterror = ref('')
|
||||
const agentincludesharedkey = ref(true)
|
||||
const agentnextserverpin = ref('')
|
||||
const agentserverpin = 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
|
||||
@@ -594,6 +638,8 @@ onMounted(async () => {
|
||||
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) {
|
||||
@@ -717,7 +763,13 @@ async function buildSiteAgent() {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,7 +54,11 @@ function enrollmentPayload() {
|
||||
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
|
||||
computers: [
|
||||
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
|
||||
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto' }
|
||||
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',
|
||||
@@ -125,6 +129,8 @@ describe('EnrollmentSettings', () => {
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -166,14 +172,46 @@ describe('EnrollmentSettings', () => {
|
||||
})
|
||||
|
||||
it('un-enrolls through the revoke route', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user