Show per-PC credentials across the admin interface
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

- Endpoints (shared key) page: the key is presented as what PCs use until
  they enroll, with enrollment progress, a link to Enrollment, rewritten
  guidance, and rotate/revoke confirmations that say how many PCs are not
  enrolled yet.
- Settings cards describe the server certificate as what PCs actually
  receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked
  the one the Server Certificate card already asked for).
- Server Certificate page checks the certificate the site presents at its
  public address and compares its pin with the certificate file, warning when
  they differ. The host comes from site settings only; the check is a TLS
  handshake, never an HTTP request.
- Fleet list: Credential column and filter (enrolled, pending, re-enroll
  blocked, shared key, not reporting), computed in the list query.
- PC detail: credential state, prefix and dates for fleet readers, state only
  for others, with admin Revoke (optionally blocking re-enrollment) and Allow
  re-enrollment.
- Dashboard: pending enrollment requests, and PCs blocked from re-enrolling
  for more than seven days.
- Runbook: where to find each of these in the app.
This commit is contained in:
cproudlock
2026-09-15 14:09:08 -04:00
parent da4b224ac1
commit c2ffc591bb
17 changed files with 1732 additions and 61 deletions

View File

@@ -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 &gt; Endpoints
&gt; 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 &gt; Endpoints &gt; 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>
@@ -532,7 +567,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
@@ -545,6 +580,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('')
@@ -613,6 +651,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.pending || 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
@@ -632,6 +704,7 @@ onMounted(async () => {
} finally {
loading.value = false
}
loadEnrollment()
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
@@ -656,8 +729,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
@@ -684,8 +758,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
@@ -959,6 +1034,24 @@ 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; }