- Settings > Endpoints > Enrollment. Migration tab: readiness counts and progress, each PC's state with the reasons it is blocked, "Upgrade selected" and "Upgrade next 25", and live wave progress. Credentials tab: enrolled PCs with revoke, pending requests with approve and reject, and the shared key and auto-enroll switches. Turning the shared key off asks for confirmation and shows how many PCs are not enrolled. - The server certificate page shows the SPKI pin to put in the GE-Enforce pin entry. - The agent upgrade script and the asset and printer reporters send the PC's credential when it has one (over https, only to the issuing server), fall back to the shared key, check server pins, and log refusals clearly. - New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1. - AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers phases, gates, rollback, pin and wave entries, and every place the shared key lives and how to clear it at cutover. - Removed the 09-14 certificate design notes and dated session reports, and two VM notes that carried a live collector key and advised plain http. - Corrected the GE-Enforce collector note, which claimed a plain registry token is protected by ACL.
536 lines
16 KiB
Vue
536 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Server Certificate</h2>
|
|
</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).
|
|
</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>
|
|
|
|
<!-- 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 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()
|
|
})
|
|
|
|
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()
|
|
} 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()
|
|
// 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;
|
|
}
|
|
|
|
.info-list li {
|
|
margin-bottom: 12px;
|
|
}
|
|
</style>
|