Files
shopdb-flask/plugins/endpoints/frontend/views/ServerCertSettings.vue
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

641 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="hostname or IP"
/>
<p class="field-hint">
Hostname or IP that agents use to connect, exactly as it appears in
the agent's base URL.
</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:hostname,IP:address"
/>
<p class="field-hint">
Comma-separated list of DNS:hostname and IP:address entries. Include
every name and address a bay may use to reach this server; a name
that is missing here fails certificate validation on the endpoint.
</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)
// No baked host or address (ADR-015: no site literals in product code). One
// site's LAN address was shipped here as the default SAN, which every other
// site would have generated a certificate for. The operator types what this
// server is actually reached by; the form refuses to submit empty.
const generateForm = ref({
cn: '',
san: '',
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>