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:
+167
-13
@@ -49,6 +49,16 @@ LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile')
|
||||
# localhost to match the dev/test wiring.
|
||||
CADDY_ADMIN_URL = os.environ.get('CADDY_ADMIN_URL', 'http://cell-caddy:2019')
|
||||
|
||||
# Directory where the API writes custom TLS cert/key files.
|
||||
# The Caddy container mounts ./config/caddy → /config/caddy, so files written
|
||||
# here appear inside the container as /config/caddy/certs/<file>.
|
||||
CADDY_CERTS_DIR = os.environ.get('CADDY_CERTS_DIR', '/app/config-caddy/certs')
|
||||
# Paths as seen by the Caddy process (inside the container).
|
||||
_CADDY_CUSTOM_CERT = '/config/caddy/certs/cert.pem'
|
||||
_CADDY_CUSTOM_KEY = '/config/caddy/certs/key.pem'
|
||||
_CADDY_INTERNAL_CERT = '/etc/caddy/internal/cert.pem'
|
||||
_CADDY_INTERNAL_KEY = '/etc/caddy/internal/key.pem'
|
||||
|
||||
|
||||
class CaddyManager(BaseServiceManager):
|
||||
"""Manages Caddy reverse-proxy configuration and runtime health."""
|
||||
@@ -135,7 +145,9 @@ class CaddyManager(BaseServiceManager):
|
||||
)
|
||||
|
||||
if domain_mode == 'lan':
|
||||
return self._caddyfile_lan(cell_name, service_routes, core_routes)
|
||||
cert_path, key_path = self._tls_cert_pair()
|
||||
return self._caddyfile_lan(cell_name, service_routes, core_routes,
|
||||
cert_path, key_path)
|
||||
if domain_mode == 'pic_ngo':
|
||||
return self._caddyfile_pic_ngo(cell_name, service_routes, core_routes)
|
||||
if domain_mode == 'cloudflare':
|
||||
@@ -252,8 +264,21 @@ class CaddyManager(BaseServiceManager):
|
||||
chunks.append(route.strip("\n"))
|
||||
return "\n".join(chunks)
|
||||
|
||||
def _tls_cert_pair(self) -> tuple:
|
||||
"""Return (cert_path, key_path) as seen inside the Caddy container.
|
||||
|
||||
Uses the custom-uploaded cert when one is installed, otherwise falls
|
||||
back to the internal-CA cert that the VaultManager writes.
|
||||
"""
|
||||
ident = (self.config_manager.get_identity() if self.config_manager else {}) or {}
|
||||
if ident.get('tls', {}).get('cert_type') == 'custom':
|
||||
return _CADDY_CUSTOM_CERT, _CADDY_CUSTOM_KEY
|
||||
return _CADDY_INTERNAL_CERT, _CADDY_INTERNAL_KEY
|
||||
|
||||
def _caddyfile_lan(self, cell_name: str,
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
service_routes: str, core_routes: str,
|
||||
cert_path: str = _CADDY_INTERNAL_CERT,
|
||||
key_path: str = _CADDY_INTERNAL_KEY) -> str:
|
||||
"""LAN mode: HTTP only + internal-CA TLS, no ACME."""
|
||||
body = []
|
||||
if service_routes:
|
||||
@@ -267,7 +292,7 @@ class CaddyManager(BaseServiceManager):
|
||||
"}\n"
|
||||
"\n"
|
||||
f"http://{cell_name}.cell, http://172.20.0.2:80 {{\n"
|
||||
" tls /etc/caddy/internal/cert.pem /etc/caddy/internal/key.pem\n"
|
||||
f" tls {cert_path} {key_path}\n"
|
||||
f"{inner}\n"
|
||||
"}\n"
|
||||
)
|
||||
@@ -290,7 +315,9 @@ class CaddyManager(BaseServiceManager):
|
||||
# the pic_ngo plugin authenticates to POST /api/v1/dns-challenge with this token.
|
||||
ddns_cfg = self.config_manager.configs.get('ddns', {})
|
||||
ddns_token = (ddns_cfg.get('token') or os.environ.get('DDNS_TOKEN') or '').strip()
|
||||
ddns_api = (os.environ.get('DDNS_URL') or ddns_cfg.get('url') or 'https://ddns.pic.ngo/api/v1').strip()
|
||||
_raw_api = (os.environ.get('DDNS_URL') or ddns_cfg.get('url') or 'https://ddns.pic.ngo').strip()
|
||||
# Strip legacy /api/v1 suffix — the pic_ngo plugin appends /api/v1 itself.
|
||||
ddns_api = _raw_api.rstrip('/').removesuffix('/api/v1')
|
||||
|
||||
return (
|
||||
f"{self._global_acme_block(email)}\n"
|
||||
@@ -502,22 +529,44 @@ class CaddyManager(BaseServiceManager):
|
||||
# ── Certificate status ────────────────────────────────────────────────
|
||||
|
||||
def get_cert_status(self) -> Dict[str, Any]:
|
||||
"""Return TLS cert status from identity['tls'] if present (cached)."""
|
||||
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
||||
if not self.config_manager:
|
||||
return default
|
||||
try:
|
||||
ident = self.config_manager.get_identity() or {}
|
||||
except Exception as e:
|
||||
logger.error("get_cert_status: failed to read identity: %s", e)
|
||||
return default
|
||||
"""Return TLS cert status enriched with identity context (cached)."""
|
||||
ident: Dict[str, Any] = {}
|
||||
if self.config_manager:
|
||||
try:
|
||||
ident = self.config_manager.get_identity() or {}
|
||||
except Exception as e:
|
||||
logger.error("get_cert_status: failed to read identity: %s", e)
|
||||
|
||||
domain_mode = ident.get('domain_mode', 'lan')
|
||||
tls = ident.get('tls') or {}
|
||||
cert_type = tls.get('cert_type', 'custom' if tls.get('cert_type') == 'custom'
|
||||
else ('internal' if domain_mode == 'lan' else 'acme'))
|
||||
|
||||
return {
|
||||
'status': tls.get('status', 'unknown'),
|
||||
'expiry': tls.get('expiry'),
|
||||
'days_remaining': tls.get('days_remaining'),
|
||||
'domain': self._domain_label(ident),
|
||||
'domain_mode': domain_mode,
|
||||
'cert_type': cert_type,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _domain_label(ident: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return a human-readable domain string for display in the UI."""
|
||||
mode = ident.get('domain_mode', 'lan')
|
||||
cell = ident.get('cell_name', '')
|
||||
if mode == 'pic_ngo':
|
||||
return f'*.{cell}.pic.ngo' if cell else None
|
||||
if mode == 'cloudflare':
|
||||
d = ident.get('domain_name') or ident.get('domain', '')
|
||||
return f'*.{d}' if d else None
|
||||
if mode == 'duckdns':
|
||||
return f'*.{cell}.duckdns.org' if cell else None
|
||||
if mode == 'http01':
|
||||
return ident.get('domain_name') or ident.get('domain')
|
||||
return None # lan
|
||||
|
||||
def get_cert_status_fresh(self, max_age_seconds: int = 300) -> Dict[str, Any]:
|
||||
"""Return cert status, refreshing if the cached value is older than max_age_seconds."""
|
||||
now = _time.monotonic()
|
||||
@@ -584,3 +633,108 @@ class CaddyManager(BaseServiceManager):
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── Active cert management ────────────────────────────────────────────
|
||||
|
||||
def renew_cert(self) -> Dict[str, Any]:
|
||||
"""Trigger ACME cert renewal by reloading Caddy.
|
||||
|
||||
Returns immediately with status='pending' so the caller can poll
|
||||
GET /api/caddy/cert-status to track progress. Not applicable to
|
||||
LAN mode — callers should use upload_custom_cert() instead.
|
||||
"""
|
||||
ident = (self.config_manager.get_identity() if self.config_manager else {}) or {}
|
||||
domain_mode = ident.get('domain_mode', 'lan')
|
||||
|
||||
if domain_mode == 'lan':
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'ACME renewal is not available in LAN mode. '
|
||||
'Upload a custom certificate instead.',
|
||||
}
|
||||
|
||||
if not self.reload_caddy():
|
||||
return {'ok': False, 'error': 'Caddy reload failed — check Caddy logs.'}
|
||||
|
||||
# Invalidate the cached status so the next poll triggers a fresh SSL check.
|
||||
self._cert_refreshed_at = None
|
||||
return {
|
||||
'ok': True,
|
||||
'status': 'pending',
|
||||
'message': 'Renewal triggered. Certificate status will update within 60 s.',
|
||||
}
|
||||
|
||||
def upload_custom_cert(self, cert_pem: str, key_pem: str) -> Dict[str, Any]:
|
||||
"""Validate and install a custom TLS certificate.
|
||||
|
||||
Writes cert+key to the shared certs directory (visible to Caddy),
|
||||
regenerates the Caddyfile to reference the new paths, and reloads.
|
||||
Works for all domain modes — use this when you have a certificate
|
||||
issued by your own CA or a commercial provider.
|
||||
"""
|
||||
cert_info = self._parse_pem_cert(cert_pem)
|
||||
if cert_info is None:
|
||||
return {'ok': False, 'error': 'Invalid certificate: could not parse PEM.'}
|
||||
if not self._validate_key_pem(key_pem):
|
||||
return {'ok': False, 'error': 'Invalid private key: expected PEM with PRIVATE KEY header.'}
|
||||
|
||||
try:
|
||||
os.makedirs(CADDY_CERTS_DIR, exist_ok=True)
|
||||
with open(os.path.join(CADDY_CERTS_DIR, 'cert.pem'), 'w') as fh:
|
||||
fh.write(cert_pem)
|
||||
with open(os.path.join(CADDY_CERTS_DIR, 'key.pem'), 'w') as fh:
|
||||
fh.write(key_pem)
|
||||
except OSError as exc:
|
||||
logger.error('upload_custom_cert: write failed: %s', exc)
|
||||
return {'ok': False, 'error': f'Failed to write cert files: {exc}'}
|
||||
|
||||
days = cert_info.get('days_remaining', 0)
|
||||
tls_info: Dict[str, Any] = {
|
||||
'status': 'valid' if days > 0 else 'expired',
|
||||
'expiry': cert_info.get('expiry'),
|
||||
'days_remaining': days,
|
||||
'cert_type': 'custom',
|
||||
}
|
||||
if self.config_manager:
|
||||
try:
|
||||
self.config_manager.set_identity_field('tls', tls_info)
|
||||
except Exception as exc:
|
||||
logger.warning('upload_custom_cert: could not persist tls info: %s', exc)
|
||||
|
||||
# Regenerate Caddyfile so the tls directive references the new cert.
|
||||
if self.config_manager:
|
||||
try:
|
||||
self.regenerate_with_installed([])
|
||||
except Exception as exc:
|
||||
logger.warning('upload_custom_cert: Caddyfile regeneration failed: %s', exc)
|
||||
|
||||
return {'ok': True, **tls_info}
|
||||
|
||||
@staticmethod
|
||||
def _parse_pem_cert(cert_pem: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a PEM certificate and return expiry metadata, or None on error."""
|
||||
try:
|
||||
from cryptography import x509
|
||||
cert_bytes = cert_pem.encode() if isinstance(cert_pem, str) else cert_pem
|
||||
cert = x509.load_pem_x509_certificate(cert_bytes)
|
||||
try:
|
||||
expiry = cert.not_valid_after_utc
|
||||
except AttributeError:
|
||||
expiry = cert.not_valid_after.replace(tzinfo=_dt.timezone.utc) # type: ignore[attr-defined]
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
days = (expiry - now).days
|
||||
return {
|
||||
'expiry': expiry.isoformat(),
|
||||
'days_remaining': days,
|
||||
'subject': cert.subject.rfc4514_string(),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug('_parse_pem_cert failed: %s', exc)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _validate_key_pem(key_pem: str) -> bool:
|
||||
"""Return True if key_pem contains a PEM-encoded private key block."""
|
||||
return ('-----BEGIN' in key_pem
|
||||
and 'PRIVATE KEY' in key_pem
|
||||
and '-----END' in key_pem)
|
||||
|
||||
Reference in New Issue
Block a user