Show per-PC credentials across the admin interface
- 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:
@@ -5,9 +5,10 @@
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Manage the TLS certificate that nginx uses for HTTPS (port 5001).
|
||||
Agents validate this certificate using Windows trusted roots or
|
||||
thumbprint pinning (ServerCertThumbprint in appsettings.json).
|
||||
The TLS certificate PCs see when they connect to this site, whether it is
|
||||
served by IIS or by the Docker stack. Its public key pin goes into the
|
||||
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
|
||||
trusted root entry for agents older than 1.20.
|
||||
</p>
|
||||
|
||||
<!-- Certificate status -->
|
||||
@@ -97,6 +98,56 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live check: what the public address really presents -->
|
||||
<section class="settings-section">
|
||||
<h3>What PCs receive</h3>
|
||||
<p class="field-hint">
|
||||
Connects to this site's base URL and reads the certificate it presents,
|
||||
the way a PC does. An IIS binding or a proxy in front of the app can
|
||||
present a different certificate than the file above.
|
||||
</p>
|
||||
<div class="card form-card">
|
||||
<div class="form-group live-check">
|
||||
<label>Presented public key pin (SPKI SHA-256)</label>
|
||||
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
|
||||
<template v-else-if="live">
|
||||
<p v-if="!live.configured" class="field-hint live-unconfigured">
|
||||
No site base URL is set (site_base_url, under Settings, Email /
|
||||
SMTP, or in the setup wizard), so there is no address to check.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
|
||||
<p v-if="live.error" class="live-error">{{ live.error }}</p>
|
||||
<template v-else>
|
||||
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
|
||||
{{ live.presentedspkisha256 }}
|
||||
</p>
|
||||
<p v-if="live.matchesfile" class="live-match live-status">
|
||||
Matches the pin of the certificate file above.
|
||||
</p>
|
||||
<div v-else class="live-warning">
|
||||
<strong>The site presents a different certificate than the file above.</strong>
|
||||
PCs will be pinned to the certificate they actually receive; use the
|
||||
presented pin.
|
||||
<div class="live-pins">
|
||||
<span>File pin:</span>
|
||||
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
|
||||
<span>Presented pin:</span>
|
||||
<span class="mono">{{ live.presentedspkisha256 }}</span>
|
||||
<span>Presented thumbprint:</span>
|
||||
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
|
||||
{{ liveLoading ? 'Checking...' : 'Recheck' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Generate self-signed certificate -->
|
||||
<section class="settings-section">
|
||||
<h3>Generate self-signed certificate</h3>
|
||||
@@ -299,6 +350,8 @@ const busy = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
const cert = ref(null)
|
||||
const live = ref(null)
|
||||
const liveLoading = ref(false)
|
||||
const thumbprintCopied = ref(false)
|
||||
const pinCopied = ref(false)
|
||||
|
||||
@@ -315,8 +368,28 @@ const uploadForm = ref({
|
||||
|
||||
onMounted(() => {
|
||||
loadCertificate()
|
||||
loadLive()
|
||||
})
|
||||
|
||||
// What the site's public address actually presents. A proxy or IIS binding
|
||||
// can serve a different certificate than the file this page manages, and a PC
|
||||
// pins what it receives.
|
||||
async function loadLive() {
|
||||
liveLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/endpoints/settings/server-cert/live')
|
||||
live.value = response.data.data || null
|
||||
} catch (err) {
|
||||
live.value = {
|
||||
configured: true,
|
||||
url: live.value?.url || '',
|
||||
error: `Could not run the check: ${err.message}`
|
||||
}
|
||||
} finally {
|
||||
liveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCertificate() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -357,6 +430,7 @@ async function generateCertificate() {
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate generated successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
} else {
|
||||
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
|
||||
}
|
||||
@@ -405,6 +479,7 @@ async function uploadCertificate() {
|
||||
if (response.data.status === 'success') {
|
||||
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
|
||||
await loadCertificate()
|
||||
loadLive()
|
||||
// Clear file inputs
|
||||
document.getElementById('cert-file').value = ''
|
||||
document.getElementById('key-file').value = ''
|
||||
@@ -529,6 +604,29 @@ pre.code-block {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.live-match { color: var(--success-dark); }
|
||||
.live-mismatch { color: var(--danger); }
|
||||
.live-status { font-size: 13px; margin: 4px 0; }
|
||||
[data-theme="dark"] .live-match { color: var(--success); }
|
||||
|
||||
.live-error,
|
||||
.live-warning {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--danger);
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-pins {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 12px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-list li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user