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:
Proudlock
2026-09-14 15:07:54 -04:00
parent fd6240772e
commit d11ba5c5d6
4 changed files with 838 additions and 0 deletions

View File

@@ -4281,3 +4281,342 @@ def acknowledge_fleet_alert(notification_id: int):
message='Alert acknowledged', http_code=201)
# =============================================================================
# Server certificate management
# =============================================================================
@endpoints_bp.route('/settings/server-cert', methods=['GET'])
@require_role('admin')
def get_server_certificate():
"""Get details about the current server certificate.
Returns certificate subject, expiry, thumbprint, and validation status.
Admin-only - certificate details are sensitive operational data.
"""
import subprocess
from pathlib import Path
cert_path = Path('certs/server.crt')
if not cert_path.exists():
return error_response(ErrorCodes.NOT_FOUND,
'Server certificate not found at certs/server.crt',
http_code=404)
try:
# Extract certificate details using openssl
subject_result = subprocess.run(
['openssl', 'x509', '-in', str(cert_path), '-noout', '-subject'],
capture_output=True, text=True, check=True
)
subject_cn = subject_result.stdout.strip().split('CN = ')[-1] if 'CN = ' in subject_result.stdout else 'Unknown'
issuer_result = subprocess.run(
['openssl', 'x509', '-in', str(cert_path), '-noout', '-issuer'],
capture_output=True, text=True, check=True
)
issuer = issuer_result.stdout.strip().split('issuer=')[-1] if 'issuer=' in issuer_result.stdout else 'Unknown'
dates_result = subprocess.run(
['openssl', 'x509', '-in', str(cert_path), '-noout', '-dates'],
capture_output=True, text=True, check=True
)
dates_lines = dates_result.stdout.strip().split('\n')
not_before = dates_lines[0].split('notBefore=')[-1] if len(dates_lines) > 0 else None
not_after = dates_lines[1].split('notAfter=')[-1] if len(dates_lines) > 1 else None
thumbprint_result = subprocess.run(
['openssl', 'x509', '-in', str(cert_path), '-noout', '-fingerprint', '-sha256'],
capture_output=True, text=True, check=True
)
thumbprint = thumbprint_result.stdout.strip().split('=')[-1] if '=' in thumbprint_result.stdout else 'Unknown'
# Get SANs
san_result = subprocess.run(
['openssl', 'x509', '-in', str(cert_path), '-noout', '-ext', 'subjectAltName'],
capture_output=True, text=True
)
san_list = []
if san_result.returncode == 0 and san_result.stdout:
san_text = san_result.stdout.strip()
if 'DNS:' in san_text or 'IP:' in san_text:
san_parts = san_text.split('\n')
for line in san_parts:
if 'DNS:' in line or 'IP:' in line:
items = line.strip().split(', ')
san_list.extend([item.strip() for item in items if item.strip()])
# Calculate days until expiry
if not_after:
try:
from dateutil import parser
expiry_date = parser.parse(not_after)
now = datetime.now(timezone.utc)
if expiry_date.tzinfo is None:
expiry_date = expiry_date.replace(tzinfo=timezone.utc)
expires_in_days = (expiry_date - now).days
except Exception:
expires_in_days = None
else:
expires_in_days = None
# Check if self-signed
is_self_signed = (subject_cn == issuer or 'CN = ' + subject_cn in issuer)
return success_response({
'subject_cn': subject_cn,
'issuer': issuer,
'not_before': not_before,
'not_after': not_after,
'thumbprint': thumbprint,
'san_list': san_list,
'expires_in_days': expires_in_days,
'is_self_signed': is_self_signed
})
except subprocess.CalledProcessError as e:
current_app.logger.error(f'Failed to read certificate: {e}')
return error_response(ErrorCodes.INTERNAL_ERROR,
f'Failed to read certificate details: {e.stderr}',
http_code=500)
except Exception as e:
current_app.logger.error(f'Unexpected error reading certificate: {e}')
return error_response(ErrorCodes.INTERNAL_ERROR,
f'Failed to read certificate: {str(e)}',
http_code=500)
@endpoints_bp.route('/settings/server-cert/generate', methods=['POST'])
@require_role('admin')
@audit_log('endpoints.server_cert.generated')
def generate_server_certificate():
"""Generate a new self-signed server certificate.
Expects JSON body:
{
"cn": "localhost",
"san": "DNS:localhost,IP:192.168.100.1",
"days": 365
}
Admin-only - generates new TLS certificate and restarts nginx.
"""
import subprocess
from pathlib import Path
import tempfile
payload = request.get_json(silent=True) or {}
cn = (payload.get('cn') or '').strip()
san = (payload.get('san') or '').strip()
days = payload.get('days', 365)
if not cn:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Common Name (cn) is required',
http_code=400)
if not isinstance(days, int) or days < 1 or days > 3650:
return error_response(ErrorCodes.VALIDATION_ERROR,
'days must be an integer between 1 and 3650',
http_code=400)
cert_dir = Path('certs')
cert_path = cert_dir / 'server.crt'
key_path = cert_dir / 'server.key'
# Backup existing certificate
if cert_path.exists():
backup_path = cert_dir / f'server.crt.backup.{int(time.time())}'
try:
cert_path.rename(backup_path)
key_path.rename(cert_dir / f'server.key.backup.{int(time.time())}')
current_app.logger.info(f'Backed up old certificate to {backup_path}')
except Exception as e:
current_app.logger.warning(f'Failed to backup old certificate: {e}')
# Create temporary OpenSSL config
with tempfile.NamedTemporaryFile(mode='w', suffix='.cnf', delete=False) as config_file:
config_content = f"""[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = {cn}
[v3_req]
subjectAltName = @alt_names
[alt_names]
"""
# Parse SANs and add to config
if san:
san_items = [s.strip() for s in san.split(',')]
dns_count = 1
ip_count = 1
for item in san_items:
if item.startswith('DNS:'):
config_content += f'DNS.{dns_count} = {item[4:]}\n'
dns_count += 1
elif item.startswith('IP:'):
config_content += f'IP.{ip_count} = {item[3:]}\n'
ip_count += 1
config_file.write(config_content)
config_path = config_file.name
try:
# Generate certificate
result = subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', str(key_path),
'-out', str(cert_path),
'-days', str(days),
'-config', config_path
], capture_output=True, text=True, check=True)
current_app.logger.info(f'Generated new server certificate: CN={cn}, days={days}')
# Restart nginx to pick up new certificate
try:
restart_result = subprocess.run([
'docker-compose', 'restart', 'nginx'
], capture_output=True, text=True, timeout=30)
if restart_result.returncode == 0:
current_app.logger.info('Nginx restarted successfully with new certificate')
else:
current_app.logger.warning(f'Nginx restart returned {restart_result.returncode}: {restart_result.stderr}')
except Exception as restart_error:
current_app.logger.error(f'Failed to restart nginx: {restart_error}')
return success_response({
'message': 'Certificate generated successfully. Nginx is restarting.'
})
except subprocess.CalledProcessError as e:
current_app.logger.error(f'Failed to generate certificate: {e.stderr}')
return error_response(ErrorCodes.INTERNAL_ERROR,
f'Failed to generate certificate: {e.stderr}',
http_code=500)
except Exception as e:
current_app.logger.error(f'Unexpected error generating certificate: {e}')
return error_response(ErrorCodes.INTERNAL_ERROR,
f'Failed to generate certificate: {str(e)}',
http_code=500)
finally:
# Clean up temp config
try:
Path(config_path).unlink()
except Exception:
pass
@endpoints_bp.route('/settings/server-cert/upload', methods=['POST'])
@require_role('admin')
@audit_log('endpoints.server_cert.uploaded')
def upload_server_certificate():
"""Upload a new server certificate and private key.
Expects multipart/form-data with:
- cert: Certificate file (.crt or .pem)
- key: Private key file (.key or .pem)
Admin-only - replaces TLS certificate and restarts nginx.
"""
import subprocess
from pathlib import Path
if 'cert' not in request.files or 'key' not in request.files:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Both cert and key files are required',
http_code=400)
cert_file = request.files['cert']
key_file = request.files['key']
if not cert_file.filename or not key_file.filename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Both cert and key files must be provided',
http_code=400)
cert_dir = Path('certs')
cert_path = cert_dir / 'server.crt'
key_path = cert_dir / 'server.key'
# Validate certificate format before writing
cert_content = cert_file.read().decode('utf-8')
key_content = key_file.read().decode('utf-8')
if '-----BEGIN CERTIFICATE-----' not in cert_content:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Invalid certificate format (must be PEM-encoded)',
http_code=400)
if '-----BEGIN' not in key_content or 'PRIVATE KEY-----' not in key_content:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Invalid private key format (must be PEM-encoded)',
http_code=400)
# Check if private key is encrypted
if 'ENCRYPTED' in key_content:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Private key must be unencrypted (no passphrase)',
http_code=400)
# Backup existing certificate
if cert_path.exists():
backup_path = cert_dir / f'server.crt.backup.{int(time.time())}'
try:
cert_path.rename(backup_path)
key_path.rename(cert_dir / f'server.key.backup.{int(time.time())}')
current_app.logger.info(f'Backed up old certificate to {backup_path}')
except Exception as e:
current_app.logger.warning(f'Failed to backup old certificate: {e}')
# Write new certificate and key
try:
cert_path.write_text(cert_content)
key_path.write_text(key_content)
# Set restrictive permissions on private key
key_path.chmod(0o600)
current_app.logger.info('Uploaded new server certificate and private key')
# Verify certificate is valid
verify_result = subprocess.run([
'openssl', 'x509', '-in', str(cert_path), '-noout', '-text'
], capture_output=True, text=True)
if verify_result.returncode != 0:
current_app.logger.error(f'Uploaded certificate is invalid: {verify_result.stderr}')
return error_response(ErrorCodes.VALIDATION_ERROR,
'Uploaded certificate is not valid',
http_code=400)
# Restart nginx to pick up new certificate
try:
restart_result = subprocess.run([
'docker-compose', 'restart', 'nginx'
], capture_output=True, text=True, timeout=30)
if restart_result.returncode == 0:
current_app.logger.info('Nginx restarted successfully with uploaded certificate')
else:
current_app.logger.warning(f'Nginx restart returned {restart_result.returncode}: {restart_result.stderr}')
except Exception as restart_error:
current_app.logger.error(f'Failed to restart nginx: {restart_error}')
return success_response({
'message': 'Certificate uploaded successfully. Nginx is restarting.'
})
except Exception as e:
current_app.logger.error(f'Failed to write certificate files: {e}')
return error_response(ErrorCodes.INTERNAL_ERROR,
f'Failed to write certificate files: {str(e)}',
http_code=500)

View File

@@ -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

View 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>

View File

@@ -341,6 +341,16 @@ class EndpointsPlugin(BasePlugin):
'tasks already registered on the endpoint.',
'position': 28,
},
{
'group': 'Endpoints',
'to': '/settings/endpoints/server-cert',
'icon': 'shield-check',
'title': 'Server Certificate',
'description': 'Manage the TLS certificate nginx uses for HTTPS. '
'Agents validate this certificate via pinning or '
'Windows trusted roots.',
'position': 29,
},
]
def get_navigation_items(self) -> List[Dict]: