Files
shopdb-flask/plugins/endpoints/frontend/views/ServerCertSettings.vue
cproudlock c2ffc591bb
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
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.
2026-09-15 14:09:08 -04:00

634 lines
20 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Server Certificate</h2>
</div>
<p class="setting-description">
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 -->
<section class="settings-section">
<h3>Current certificate</h3>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading certificate details...</div>
<template v-else-if="cert">
<div v-if="cert.expires_in_days < 30" class="warning-banner">
<strong>Warning:</strong> Certificate expires in {{ cert.expires_in_days }} days
({{ formatDate(cert.not_after) }}). Renew soon to avoid service disruption.
</div>
<div class="form-group">
<label>Common Name (CN)</label>
<p class="field-hint mono">{{ cert.subject_cn }}</p>
</div>
<div class="form-group">
<label>Subject Alternative Names</label>
<p class="field-hint mono">{{ cert.san_list.join(', ') }}</p>
</div>
<div class="form-group">
<label>Valid From</label>
<p class="field-hint">{{ formatDate(cert.not_before) }}</p>
</div>
<div class="form-group">
<label>Valid Until</label>
<p class="field-hint" :class="{ 'expiry-warning': cert.expires_in_days < 30 }">
{{ formatDate(cert.not_after) }}
<span v-if="cert.expires_in_days >= 0">
({{ cert.expires_in_days }} days remaining)
</span>
<span v-else class="expired-text">
(EXPIRED {{ Math.abs(cert.expires_in_days) }} days ago)
</span>
</p>
</div>
<div class="form-group">
<label>Thumbprint (SHA256)</label>
<p class="field-hint mono">{{ cert.thumbprint }}</p>
<button class="btn btn-secondary btn-sm" @click="copyThumbprint">
{{ thumbprintCopied ? 'Copied' : 'Copy thumbprint' }}
</button>
<p class="field-hint">
Use this value for ServerCertThumbprint in agent appsettings.json
to enable certificate pinning (prevents MITM attacks).
</p>
</div>
<div class="form-group spki-pin">
<label>Public key pin (SPKI SHA-256)</label>
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
<p v-else class="field-hint">Not reported by this server.</p>
<button
v-if="cert.spkisha256"
class="btn btn-secondary btn-sm"
@click="copyPin"
>
{{ pinCopied ? 'Copied' : 'Copy pin' }}
</button>
<p class="field-hint">
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
(-Pins) so agents 1.20 and later trust only this server's key.
It survives a renewal that keeps the same private key.
</p>
</div>
<div class="form-group">
<label>Issuer</label>
<p class="field-hint mono">{{ cert.issuer }}</p>
<span v-if="cert.is_self_signed" class="badge">Self-signed</span>
</div>
</template>
<template v-else>
<p class="field-hint">No certificate file found at certs/server.crt</p>
</template>
</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>
<p class="field-hint">
Create a new self-signed certificate for development or air-gapped sites.
The certificate will be valid for 1 year (365 days).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cn">Common Name (CN)</label>
<input
id="cn"
v-model="generateForm.cn"
type="text"
class="form-control"
placeholder="localhost"
/>
<p class="field-hint">
Hostname or IP that agents use to connect (e.g., shopdb.example.com or 192.168.100.1)
</p>
</div>
<div class="form-group">
<label for="san">Subject Alternative Names (SANs)</label>
<input
id="san"
v-model="generateForm.san"
type="text"
class="form-control"
placeholder="DNS:localhost,IP:192.168.100.1"
/>
<p class="field-hint">
Comma-separated list: DNS:hostname or IP:address (e.g., DNS:localhost,IP:192.168.100.1,DNS:shopdb.local)
</p>
</div>
<div class="form-group">
<label for="days">Validity (days)</label>
<input
id="days"
v-model.number="generateForm.days"
type="number"
class="form-control"
min="1"
max="3650"
/>
<p class="field-hint">
Certificate lifetime (recommended: 365 for self-signed)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Generating a new certificate will replace the current one.
Agents using ServerCertThumbprint pinning will reject connections until you update
their appsettings.json with the new thumbprint. A new certificate also has a new
key, so agents with ServerPins set will reject it until the pin entry carries the
new pin as well. Generating here gives no window to do that; to rotate without an
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
it to the pin entry alongside the old one, and upload that certificate below.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="generateCertificate"
:disabled="busy || !generateForm.cn"
>
{{ busy ? 'Generating...' : 'Generate self-signed certificate' }}
</button>
</div>
</div>
</section>
<!-- Upload certificate -->
<section class="settings-section">
<h3>Upload certificate</h3>
<p class="field-hint">
Upload a certificate from a trusted CA or internal PKI. You must provide
both the certificate (.crt or .pem) and private key (.key).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cert-file">Certificate file</label>
<input
id="cert-file"
type="file"
accept=".crt,.pem"
@change="onCertFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded certificate (.crt or .pem)
</p>
</div>
<div class="form-group">
<label for="key-file">Private key file</label>
<input
id="key-file"
type="file"
accept=".key,.pem"
@change="onKeyFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded private key (.key or .pem, unencrypted)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Uploading a new certificate will replace the current one.
Nginx will be restarted automatically.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="uploadCertificate"
:disabled="busy || !uploadForm.certFile || !uploadForm.keyFile"
>
{{ busy ? 'Uploading...' : 'Upload certificate' }}
</button>
</div>
</div>
</section>
<!-- Documentation -->
<section class="settings-section">
<div class="card form-card">
<details class="collapsible">
<summary><h4>Certificate requirements</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Format:</strong> PEM-encoded X.509 certificate and RSA/ECDSA private key
</li>
<li>
<strong>Private key:</strong> Must be unencrypted (no passphrase)
</li>
<li>
<strong>Common Name:</strong> Must match the hostname agents use to connect
</li>
<li>
<strong>Subject Alternative Names:</strong> Include all hostnames and IPs agents might use
</li>
<li>
<strong>Trust:</strong> Self-signed certs require ServerCertThumbprint pinning on agents,
or agents must disable validation (development only)
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>Agent certificate pinning</h4></summary>
<p class="field-hint">
To prevent MITM attacks with self-signed certificates, configure agents to validate
the server certificate thumbprint:
</p>
<ol class="field-hint info-list">
<li>Copy the SHA256 thumbprint from the "Current certificate" section above</li>
<li>Update each agent's appsettings.json:
<pre class="code-block">{
"ServerCertThumbprint": "AA:BB:CC:..."
}</pre>
</li>
<li>Restart the ShopDbEndpointAgent service on each agent</li>
</ol>
<p class="field-hint">
<strong>Important:</strong> When you renew the server certificate, you must update
ServerCertThumbprint on all agents before the old certificate expires, or agents
will reject connections.
</p>
</details>
<details class="collapsible">
<summary><h4>Air-gapped deployment</h4></summary>
<p class="field-hint">
For air-gapped sites, generate the certificate on the work PC and include it in
the deployment bundle:
</p>
<ol class="field-hint info-list">
<li>Generate certificate using this page (use site-specific CN/SANs)</li>
<li>Certificate files are saved to certs/ directory (already in git)</li>
<li>Commit and bundle the code (certificate included automatically)</li>
<li>Deploy bundle to site - nginx picks up certificate on startup</li>
</ol>
</details>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(false)
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)
const generateForm = ref({
cn: 'localhost',
san: 'DNS:localhost,IP:192.168.100.1',
days: 365
})
const uploadForm = ref({
certFile: null,
keyFile: null
})
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 = ''
try {
const response = await api.get('/endpoints/settings/server-cert')
if (response.data.status === 'success') {
cert.value = response.data.data
} else {
error.value = response.data.data?.error?.message || 'Failed to load certificate'
}
} catch (err) {
error.value = `Failed to load certificate: ${err.message}`
} finally {
loading.value = false
}
}
async function generateCertificate() {
if (!generateForm.value.cn) {
error.value = 'Common Name is required'
return
}
if (!confirm(
'Generate a new self-signed certificate? This will replace the current certificate. ' +
'Agents using thumbprint pinning will need to be reconfigured.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const response = await api.post('/endpoints/settings/server-cert/generate', generateForm.value)
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'
}
} catch (err) {
error.value = `Failed to generate certificate: ${err.message}`
} finally {
busy.value = false
}
}
function onCertFileChange(event) {
uploadForm.value.certFile = event.target.files[0]
}
function onKeyFileChange(event) {
uploadForm.value.keyFile = event.target.files[0]
}
async function uploadCertificate() {
if (!uploadForm.value.certFile || !uploadForm.value.keyFile) {
error.value = 'Both certificate and private key files are required'
return
}
if (!confirm(
'Upload new certificate? This will replace the current certificate and restart nginx.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const formData = new FormData()
formData.append('cert', uploadForm.value.certFile)
formData.append('key', uploadForm.value.keyFile)
const response = await api.post('/endpoints/settings/server-cert/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
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 = ''
uploadForm.value.certFile = null
uploadForm.value.keyFile = null
} else {
error.value = response.data.data?.error?.message || 'Failed to upload certificate'
}
} catch (err) {
error.value = `Failed to upload certificate: ${err.message}`
} finally {
busy.value = false
}
}
function copyThumbprint() {
navigator.clipboard.writeText(cert.value.thumbprint)
thumbprintCopied.value = true
setTimeout(() => {
thumbprintCopied.value = false
}, 2000)
}
function copyPin() {
navigator.clipboard.writeText(cert.value.spkisha256)
pinCopied.value = true
setTimeout(() => {
pinCopied.value = false
}, 2000)
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleString()
}
</script>
<style scoped>
.warning-banner {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.warning-banner {
background: #664d03;
border-color: #ffca2c;
color: #ffecb5;
}
}
.expiry-warning {
color: #d63384;
font-weight: 600;
}
.expired-text {
color: #dc3545;
font-weight: bold;
}
.badge {
display: inline-block;
padding: 4px 8px;
background: #6c757d;
color: white;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.code-block,
pre.code-block {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
color: #212529;
white-space: pre;
}
/* Dark mode support for code blocks */
@media (prefers-color-scheme: dark) {
.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
/* Ensure pre tags inside lists also get dark mode */
.info-list .code-block,
.field-hint .code-block,
pre.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
}
.info-list {
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;
}
</style>