- 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.
634 lines
20 KiB
Vue
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>
|