feat: TLS certificate management in Vault page
Unit Tests / test (push) Successful in 7m26s

Adds live cert status, one-click ACME renewal, and custom cert upload
directly to the Vault page so users never need to touch Caddy config.

Backend:
- CaddyManager.get_cert_status() now returns domain, domain_mode, and
  cert_type so the UI can render the right controls without a separate
  identity fetch
- CaddyManager.renew_cert() reloads Caddy and invalidates the status
  cache; the frontend polls until the cert turns valid
- CaddyManager.upload_custom_cert() validates PEM, writes cert+key to
  the shared config/caddy/certs/ volume, updates identity (cert_type=custom),
  and regenerates the Caddyfile so Caddy references the new paths
- LAN-mode Caddyfile switches from /etc/caddy/internal/ to the shared
  certs dir automatically when cert_type=custom is set
- ddns_api default no longer includes /api/v1 — the plugin appends it;
  legacy /api/v1 suffix is stripped at write time to keep the Caddyfile clean
- POST /api/caddy/cert-renew and POST /api/caddy/custom-cert routes added

Frontend:
- TLSPanel component at the top of Vault.jsx shows status badge
  (valid/expiring-soon/expired/pending/internal) with domain and expiry
- Renew button visible only for ACME modes; spins during the API call
  then polls GET /api/caddy/cert-status every 10 s until valid
- Upload Custom Cert opens a modal with PEM text areas; works for all modes
- caddyAPI.renewCert() and uploadCustomCert() added to api.js

Tests: 22 new tests across 5 classes covering enriched status,
renew_cert guards, upload_custom_cert validation/writes/persistence,
custom-cert Caddyfile path selection, and ddns_api suffix stripping.
All 2093 existing tests continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:53:42 -04:00
parent 85d265187d
commit 33d255f089
5 changed files with 1127 additions and 482 deletions
+37 -1
View File
@@ -897,7 +897,7 @@ def connectivity_get_peer_exits():
@app.route('/api/caddy/cert-status', methods=['GET'])
def caddy_cert_status():
"""Return TLS certificate status (expiry, days remaining, status).
"""Return TLS certificate status (expiry, days remaining, domain, mode).
Refreshes from Caddy if the cached value is older than 5 minutes.
For LAN mode returns {'status': 'internal'}; for ACME modes returns
@@ -910,6 +910,42 @@ def caddy_cert_status():
return jsonify({'error': str(e)}), 500
@app.route('/api/caddy/cert-renew', methods=['POST'])
def caddy_cert_renew():
"""Trigger ACME certificate renewal by reloading Caddy.
Returns immediately with status='pending'; poll GET /api/caddy/cert-status
to track progress (Caddy typically acquires the cert within 30-60 s).
"""
try:
result = caddy_manager.renew_cert()
return jsonify(result), (200 if result.get('ok') else 400)
except Exception as e:
logger.error(f"caddy_cert_renew: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/caddy/custom-cert', methods=['POST'])
def caddy_upload_custom_cert():
"""Install a custom TLS certificate (PEM format).
Body: { "cert_pem": "<PEM>", "key_pem": "<PEM>" }
Validates the cert/key pair, writes to the shared certs directory,
and reloads Caddy with the updated Caddyfile.
"""
try:
data = request.get_json(silent=True) or {}
cert_pem = (data.get('cert_pem') or '').strip()
key_pem = (data.get('key_pem') or '').strip()
if not cert_pem or not key_pem:
return jsonify({'ok': False, 'error': 'cert_pem and key_pem are required'}), 400
result = caddy_manager.upload_custom_cert(cert_pem, key_pem)
return jsonify(result), (200 if result.get('ok') else 422)
except Exception as e:
logger.error(f"caddy_upload_custom_cert: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/egress/status', methods=['GET'])
def egress_status():
"""Return egress status for all installed services that have an egress config."""