Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
641 lines
20 KiB
Vue
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>
|