Add server certificate management UI (Settings > Endpoints > Server Certificate)
Operational Feature: Web UI for TLS certificate management
Problem:
- Server certificate (nginx HTTPS) managed manually via certs/ directory
- No visibility into cert expiry from UI
- Regeneration required command-line OpenSSL knowledge
- No warning when cert approaching expiration
- Agents using thumbprint pinning need new thumbprint after renewal
Solution:
- New admin-only settings page: Settings > Endpoints > Server Certificate
- View current certificate details (CN, SANs, expiry, thumbprint)
- Warning banner when cert expires within 30 days
- Generate self-signed certificate (configure CN, SANs, validity)
- Upload CA-signed certificate (PEM format)
- Automatic nginx restart after certificate changes
- Copy-to-clipboard for agent thumbprint pinning
Frontend:
- plugins/endpoints/frontend/views/ServerCertSettings.vue
- Certificate status display with expiry countdown
- Self-signed generation form (CN, SANs, days)
- CA certificate upload (cert + key files)
- Documentation collapsibles (requirements, pinning, air-gapped)
- plugins/endpoints/frontend/routes.js
- Route: /settings/endpoints/server-cert (admin-only)
Backend API:
- plugins/endpoints/api/routes.py
- GET /api/endpoints/settings/server-cert (view cert details)
- Uses openssl to extract CN, issuer, dates, thumbprint, SANs
- Calculates days until expiry (warns if < 30)
- Returns self-signed status
- POST /api/endpoints/settings/server-cert/generate
- Generates self-signed cert with custom CN/SANs/validity
- Backs up old certificate with timestamp
- Writes new cert/key to certs/ directory
- Restarts nginx (docker-compose restart nginx)
- Audit logged: endpoints.server_cert.generated
- POST /api/endpoints/settings/server-cert/upload
- Validates PEM format (cert + unencrypted key)
- Backs up old certificate
- Writes uploaded files to certs/
- Restarts nginx automatically
- Audit logged: endpoints.server_cert.uploaded
- All endpoints admin-only (@require_role('admin'))
Navigation:
- plugins/endpoints/plugin.py
- Added menu item: Settings > Endpoints > Server Certificate
- Icon: shield-check, Position: 29
Security:
- Admin-only access (certificate management is privileged operation)
- Private key validation (rejects encrypted keys)
- Certificate format validation (PEM-encoded)
- Audit logging for all cert operations
- Automatic backup before replacement (timestamped .backup files)
Operational Notes:
- Certificate changes restart nginx (brief HTTPS interruption)
- Agents using ServerCertThumbprint pinning must be updated after renewal
- Self-signed certs valid 1 year by default (configurable)
- Air-gapped sites: cert files in certs/ directory bundled automatically
Related:
- Complements certs/README.md and regeneration scripts (commit fd6240772e)
- Agents validate via Windows roots or thumbprint pinning (CertificateRenewal.cs)
- Client certificate renewal uses same nginx TLS surface (routes.py:3455)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ export default [
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
{
|
||||
path: 'settings/endpoints/server-cert',
|
||||
name: 'endpoints-server-cert-settings',
|
||||
component: () => import('./views/ServerCertSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
|
||||
// non-admin operators, and the sibling pages' admin gate would lock the
|
||||
// holders of the grant out of the library they are trusted to manage. The
|
||||
|
||||
483
plugins/endpoints/frontend/views/ServerCertSettings.vue
Normal file
483
plugins/endpoints/frontend/views/ServerCertSettings.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<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">
|
||||
<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.
|
||||
</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>
|
||||
export default {
|
||||
name: 'ServerCertSettings',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
busy: false,
|
||||
message: '',
|
||||
error: '',
|
||||
cert: null,
|
||||
thumbprintCopied: false,
|
||||
generateForm: {
|
||||
cn: 'localhost',
|
||||
san: 'DNS:localhost,IP:192.168.100.1',
|
||||
days: 365
|
||||
},
|
||||
uploadForm: {
|
||||
certFile: null,
|
||||
keyFile: null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadCertificate()
|
||||
},
|
||||
methods: {
|
||||
async loadCertificate() {
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const response = await fetch('/api/endpoints/settings/server-cert')
|
||||
const data = await response.json()
|
||||
if (data.status === 'success') {
|
||||
this.cert = data.data
|
||||
} else {
|
||||
this.error = data.data?.error?.message || 'Failed to load certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = `Failed to load certificate: ${err.message}`
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async generateCertificate() {
|
||||
if (!this.generateForm.cn) {
|
||||
this.error = '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
|
||||
}
|
||||
|
||||
this.busy = true
|
||||
this.error = ''
|
||||
this.message = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/endpoints/settings/server-cert/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.generateForm)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.message = 'Certificate generated successfully. Nginx is restarting...'
|
||||
await this.loadCertificate()
|
||||
} else {
|
||||
this.error = data.data?.error?.message || 'Failed to generate certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = `Failed to generate certificate: ${err.message}`
|
||||
} finally {
|
||||
this.busy = false
|
||||
}
|
||||
},
|
||||
|
||||
onCertFileChange(event) {
|
||||
this.uploadForm.certFile = event.target.files[0]
|
||||
},
|
||||
|
||||
onKeyFileChange(event) {
|
||||
this.uploadForm.keyFile = event.target.files[0]
|
||||
},
|
||||
|
||||
async uploadCertificate() {
|
||||
if (!this.uploadForm.certFile || !this.uploadForm.keyFile) {
|
||||
this.error = 'Both certificate and private key files are required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(
|
||||
'Upload new certificate? This will replace the current certificate and restart nginx.'
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.busy = true
|
||||
this.error = ''
|
||||
this.message = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('cert', this.uploadForm.certFile)
|
||||
formData.append('key', this.uploadForm.keyFile)
|
||||
|
||||
const response = await fetch('/api/endpoints/settings/server-cert/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.message = 'Certificate uploaded successfully. Nginx is restarting...'
|
||||
await this.loadCertificate()
|
||||
// Clear file inputs
|
||||
document.getElementById('cert-file').value = ''
|
||||
document.getElementById('key-file').value = ''
|
||||
this.uploadForm.certFile = null
|
||||
this.uploadForm.keyFile = null
|
||||
} else {
|
||||
this.error = data.data?.error?.message || 'Failed to upload certificate'
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = `Failed to upload certificate: ${err.message}`
|
||||
} finally {
|
||||
this.busy = false
|
||||
}
|
||||
},
|
||||
|
||||
copyThumbprint() {
|
||||
navigator.clipboard.writeText(this.cert.thumbprint)
|
||||
this.thumbprintCopied = true
|
||||
setTimeout(() => {
|
||||
this.thumbprintCopied = false
|
||||
}, 2000)
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.info-list li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user