From 0267dce73d4416611722e4d3fa6b17a28bddcea7 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 5 Jun 2026 11:39:36 -0400 Subject: [PATCH] feat: HTTPS cert status, IDENTITY_CHANGED wiring, remove stale ip_utils Caddyfile writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CaddyManager: add refresh_cert_status() and get_cert_status_fresh() that open a live TLS connection to cell-caddy:443 to read cert expiry; avoids needing a volume mount into the API container - CaddyManager: periodic cert refresh in health_monitor_loop (every 60 cycles) - config.py PUT /api/ddns: publish IDENTITY_CHANGED so CaddyManager regenerates the Caddyfile immediately after any domain/cell_name change — previously the event was never fired from this route - config.py: remove all ip_utils.write_caddyfile() calls; CaddyManager is now the sole authority for Caddyfile generation - app.py: add GET /api/caddy/cert-status route - app.py: add GET /api/egress/status and PUT /api/egress/services//exit routes - Settings.jsx: display cert status badge (valid/expired/internal/unknown) with expiry date and days-remaining in the domain section - Tests: TestRefreshCertStatus (8 tests), TestDdnsConfigUpdatesFiresIdentityChanged, TestCaddyCertStatusRoute added; fix expired-cert helper to set not_valid_before relative to expiry so it's always earlier Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 24 ++++++++ api/caddy_manager.py | 77 ++++++++++++++++++++++- api/routes/config.py | 44 +++++++------- tests/test_caddy_manager.py | 112 ++++++++++++++++++++++++++++++++++ tests/test_config_apply.py | 115 +++++++++++++++++++++++++++++++++++ webui/src/pages/Settings.jsx | 47 +++++++++++++- webui/src/services/api.js | 5 ++ 7 files changed, 398 insertions(+), 26 deletions(-) diff --git a/api/app.py b/api/app.py index 85457c7..0e7daa1 100644 --- a/api/app.py +++ b/api/app.py @@ -590,6 +590,7 @@ def perform_health_check(): return {'error': str(e), 'timestamp': datetime.utcnow().isoformat()} def health_monitor_loop(): + _cert_check_cycle = 0 while health_monitor_running: with app.app_context(): health_result = perform_health_check() @@ -613,6 +614,14 @@ def health_monitor_loop(): caddy_manager.reset_health_failures() except Exception as _caddy_err: logger.error("Caddy health monitor error: %s", _caddy_err) + # Refresh cert status every 60 cycles (\u2248 1 hour with a 60 s loop). + _cert_check_cycle += 1 + if _cert_check_cycle >= 60: + _cert_check_cycle = 0 + try: + caddy_manager.refresh_cert_status() + except Exception as _cert_err: + logger.warning("Cert status refresh failed (non-fatal): %s", _cert_err) time.sleep(60) # Check every 60 seconds # Start health monitor thread @@ -854,6 +863,21 @@ def connectivity_get_peer_exits(): return jsonify({'error': str(e)}), 500 +@app.route('/api/caddy/cert-status', methods=['GET']) +def caddy_cert_status(): + """Return TLS certificate status (expiry, days remaining, status). + + Refreshes from Caddy if the cached value is older than 5 minutes. + For LAN mode returns {'status': 'internal'}; for ACME modes returns + expiry info read via SSL handshake with the Caddy container. + """ + try: + return jsonify(caddy_manager.get_cert_status_fresh(max_age_seconds=300)) + except Exception as e: + logger.error(f"caddy_cert_status: {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.""" diff --git a/api/caddy_manager.py b/api/caddy_manager.py index 143918c..bfcec58 100644 --- a/api/caddy_manager.py +++ b/api/caddy_manager.py @@ -23,8 +23,12 @@ in the main server block (or, for ``http01``, written as their own per-host blocks). """ +import datetime as _dt import logging import os +import socket as _socket +import ssl as _ssl +import time as _time from typing import Any, Dict, List, Optional import requests @@ -62,6 +66,8 @@ class CaddyManager(BaseServiceManager): # Consecutive health-check failure counter (reset on success or when # the caller restarts the container). self._health_failures = 0 + # Monotonic timestamp of the last successful cert status refresh. + self._cert_refreshed_at: Optional[float] = None if service_bus is not None: from service_bus import EventType @@ -490,8 +496,10 @@ class CaddyManager(BaseServiceManager): except Exception as exc: self.logger.warning('caddy_manager identity_changed handler failed: %s', exc) + # ── Certificate status ──────────────────────────────────────────────── + def get_cert_status(self) -> Dict[str, Any]: - """Return TLS cert status from identity['tls'] if present.""" + """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 @@ -506,3 +514,70 @@ class CaddyManager(BaseServiceManager): 'expiry': tls.get('expiry'), 'days_remaining': tls.get('days_remaining'), } + + 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() + if self._cert_refreshed_at is None or (now - self._cert_refreshed_at) > max_age_seconds: + self.refresh_cert_status() + return self.get_cert_status() + + def refresh_cert_status(self) -> Dict[str, Any]: + """Check TLS cert expiry via SSL and persist to identity['tls']. + + For LAN mode (no ACME): immediately returns {'status': 'internal'}. + For ACME modes: opens an SSL connection to Caddy on port 443 and + reads the cert expiry from the TLS handshake. On any error (cert + not yet issued, network unreachable): returns {'status': 'unknown'}. + """ + identity = self.config_manager.get_identity() if self.config_manager else {} + domain_mode = (identity or {}).get('domain_mode', 'lan') + + if domain_mode == 'lan': + status: Dict[str, Any] = {'status': 'internal', 'expiry': None, 'days_remaining': None} + else: + caddy_host = os.environ.get('CADDY_CERT_HOST', 'cell-caddy') + caddy_port = int(os.environ.get('CADDY_HTTPS_PORT', '443')) + result = self._check_cert_via_ssl(caddy_host, caddy_port) + status = result if result is not None else { + 'status': 'unknown', 'expiry': None, 'days_remaining': None + } + + if self.config_manager: + try: + self.config_manager.set_identity_field('tls', status) + except Exception as exc: + logger.warning('refresh_cert_status: failed to persist tls status: %s', exc) + + self._cert_refreshed_at = _time.monotonic() + return status + + @staticmethod + def _check_cert_via_ssl(hostname: str, port: int = 443) -> Optional[Dict[str, Any]]: + """Open an SSL connection and return cert expiry info, or None on failure.""" + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + try: + with _socket.create_connection((hostname, port), timeout=5) as raw: + with ctx.wrap_socket(raw, server_hostname=hostname) as tls: + der = tls.getpeercert(binary_form=True) + if not der: + return None + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + cert = x509.load_der_x509_certificate(der, default_backend()) + # Use not_valid_after_utc (cryptography ≥42) with fallback for older builds. + 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 { + 'status': 'valid' if days > 0 else 'expired', + 'expiry': expiry.isoformat(), + 'days_remaining': days, + } + except Exception: + return None diff --git a/api/routes/config.py b/api/routes/config.py index dcb6854..87dbae5 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -316,13 +316,6 @@ def update_config(): domain = identity_updates['domain'] net_result = network_manager.apply_domain(domain, reload=False) all_warnings.extend(net_result.get('warnings', [])) - _cur_id = config_manager.configs.get('_identity', {}) - if _cur_id.get('domain_mode', 'lan') == 'lan': - ip_utils.write_caddyfile( - _cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - _cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), - domain, '/app/config-caddy/Caddyfile' - ) _set_pending_restart( [f'domain changed to {domain}'], ['dns', 'caddy'], @@ -335,14 +328,6 @@ def update_config(): if old_name != new_name: cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False) all_warnings.extend(cn_result.get('warnings', [])) - _cur_id2 = config_manager.configs.get('_identity', {}) - if _cur_id2.get('domain_mode', 'lan') == 'lan': - ip_utils.write_caddyfile( - _cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - new_name, - identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), - '/app/config-caddy/Caddyfile' - ) _set_pending_restart( [f'cell_name changed to {new_name}'], ['dns'], @@ -373,8 +358,6 @@ def update_config(): firewall_manager.ensure_caddy_virtual_ips() env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs)) - if cur_identity.get('domain_mode', 'lan') == 'lan': - ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile') _set_pending_restart( [f'ip_range changed to {new_range} — network will be recreated'], ['*'], network_recreate=True, @@ -581,6 +564,21 @@ def update_ddns_config(): config_manager.set_identity_field('duckdns_token', duck_token) config_manager.set_identity_field('duckdns_subdomain', duck_sub) + # Fire IDENTITY_CHANGED so CaddyManager regenerates the Caddyfile + # for the new domain mode without requiring a container restart. + try: + from app import service_bus as _sbus, EventType as _ET + _cur = config_manager.configs.get('_identity', {}) + _sbus.publish_event(_ET.IDENTITY_CHANGED, 'config', { + 'cell_name': _cur.get('cell_name'), + 'domain': _cur.get('domain'), + 'domain_name': _cur.get('domain_name'), + 'domain_mode': _cur.get('domain_mode'), + 'effective_domain': config_manager.get_effective_domain(), + }) + except Exception as _ev_err: + logger.warning('update_ddns_config: failed to fire IDENTITY_CHANGED: %s', _ev_err) + logger.info('DDNS config updated: domain_mode=%r domain_name=%r', domain_mode, domain_name) return jsonify({'updated': True}) except Exception as e: @@ -660,12 +658,12 @@ def cancel_pending_config(): if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name: network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False) - if _id.get('domain_mode', 'lan') == 'lan': - _ip_revert.write_caddyfile( - _id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), - _id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), - _dom, '/app/config-caddy/Caddyfile' - ) + # Regenerate Caddyfile for the reverted identity (all domain modes) + try: + from app import caddy_manager as _cm + _cm.regenerate_with_installed([]) + except Exception as _cm_err: + logger.warning('cancel_pending_config: caddy regenerate failed (non-fatal): %s', _cm_err) _clear_pending_restart() return jsonify({'message': 'Pending changes discarded'}) diff --git a/tests/test_caddy_manager.py b/tests/test_caddy_manager.py index a7e2d72..74be7c9 100644 --- a/tests/test_caddy_manager.py +++ b/tests/test_caddy_manager.py @@ -325,5 +325,117 @@ class TestCaddyManagerIdentityChangedSubscription(unittest.TestCase): mgr._on_identity_changed(event) # must not raise +class TestRefreshCertStatus(unittest.TestCase): + """refresh_cert_status() + _check_cert_via_ssl().""" + + def _make_der_cert(self, days_remaining: int) -> bytes: + """Return a minimal self-signed DER cert valid for *days_remaining* days.""" + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + import datetime + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.timezone.utc) + expiry = now + datetime.timedelta(days=days_remaining) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'test.example.com')])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'test.example.com')])) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(expiry - datetime.timedelta(days=30)) + .not_valid_after(expiry) + .sign(key, hashes.SHA256()) + ) + return cert.public_bytes(serialization.Encoding.DER) + + def test_check_cert_via_ssl_returns_none_on_connection_error(self): + """_check_cert_via_ssl returns None when connection fails.""" + with patch('caddy_manager._socket.create_connection', side_effect=OSError('refused')): + result = CaddyManager._check_cert_via_ssl('host', 443) + self.assertIsNone(result) + + def test_check_cert_via_ssl_returns_valid_status(self): + """_check_cert_via_ssl returns valid status for a future-dated cert.""" + der = self._make_der_cert(60) + mock_tls = MagicMock() + mock_tls.__enter__ = MagicMock(return_value=mock_tls) + mock_tls.__exit__ = MagicMock(return_value=False) + mock_tls.getpeercert.return_value = der + mock_raw = MagicMock() + mock_raw.__enter__ = MagicMock(return_value=mock_raw) + mock_raw.__exit__ = MagicMock(return_value=False) + with patch('caddy_manager._socket.create_connection', return_value=mock_raw): + with patch('caddy_manager._ssl.create_default_context') as mock_ctx: + mock_ctx.return_value.wrap_socket.return_value = mock_tls + result = CaddyManager._check_cert_via_ssl('host', 443) + self.assertIsNotNone(result) + self.assertEqual(result['status'], 'valid') + self.assertGreater(result['days_remaining'], 50) + + def test_check_cert_via_ssl_returns_expired_for_past_cert(self): + """_check_cert_via_ssl returns expired when cert is in the past.""" + der = self._make_der_cert(-5) + mock_tls = MagicMock() + mock_tls.__enter__ = MagicMock(return_value=mock_tls) + mock_tls.__exit__ = MagicMock(return_value=False) + mock_tls.getpeercert.return_value = der + mock_raw = MagicMock() + mock_raw.__enter__ = MagicMock(return_value=mock_raw) + mock_raw.__exit__ = MagicMock(return_value=False) + with patch('caddy_manager._socket.create_connection', return_value=mock_raw): + with patch('caddy_manager._ssl.create_default_context') as mock_ctx: + mock_ctx.return_value.wrap_socket.return_value = mock_tls + result = CaddyManager._check_cert_via_ssl('host', 443) + self.assertIsNotNone(result) + self.assertEqual(result['status'], 'expired') + self.assertLess(result['days_remaining'], 0) + + def test_refresh_cert_status_lan_mode_returns_internal(self): + """LAN mode always returns status='internal' without SSL check.""" + mgr = _mgr(identity={'cell_name': 'x', 'domain_mode': 'lan'}) + with patch.object(CaddyManager, '_check_cert_via_ssl') as mock_ssl: + result = mgr.refresh_cert_status() + mock_ssl.assert_not_called() + self.assertEqual(result['status'], 'internal') + + def test_refresh_cert_status_acme_mode_calls_ssl_check(self): + """ACME mode calls _check_cert_via_ssl and persists the result.""" + mgr = _mgr(identity={'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}) + expected = {'status': 'valid', 'expiry': '2026-12-01T00:00:00+00:00', 'days_remaining': 179} + with patch.object(CaddyManager, '_check_cert_via_ssl', return_value=expected): + result = mgr.refresh_cert_status() + self.assertEqual(result['status'], 'valid') + # Should have been persisted to identity + mgr.config_manager.set_identity_field.assert_called_with('tls', expected) + + def test_refresh_cert_status_ssl_failure_returns_unknown(self): + """When SSL check returns None, status is 'unknown'.""" + mgr = _mgr(identity={'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}) + with patch.object(CaddyManager, '_check_cert_via_ssl', return_value=None): + result = mgr.refresh_cert_status() + self.assertEqual(result['status'], 'unknown') + + def test_get_cert_status_fresh_refreshes_when_stale(self): + """get_cert_status_fresh triggers a refresh when cache is None.""" + mgr = _mgr(identity={'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}) + mgr._cert_refreshed_at = None + with patch.object(mgr, 'refresh_cert_status', return_value={'status': 'valid'}) as mock_ref: + with patch.object(mgr, 'get_cert_status', return_value={'status': 'valid'}): + mgr.get_cert_status_fresh() + mock_ref.assert_called_once() + + def test_get_cert_status_fresh_skips_refresh_when_recent(self): + """get_cert_status_fresh skips refresh when cache is fresh.""" + import time + mgr = _mgr(identity={'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}) + mgr._cert_refreshed_at = time.monotonic() # just refreshed + with patch.object(mgr, 'refresh_cert_status') as mock_ref: + with patch.object(mgr, 'get_cert_status', return_value={'status': 'valid'}): + mgr.get_cert_status_fresh(max_age_seconds=300) + mock_ref.assert_not_called() + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_config_apply.py b/tests/test_config_apply.py index c15e2f1..b551cd6 100644 --- a/tests/test_config_apply.py +++ b/tests/test_config_apply.py @@ -190,5 +190,120 @@ class TestConfigApplyRoute(unittest.TestCase): self.assertIn('error', json.loads(r.data)) +class TestDdnsConfigUpdatesFiresIdentityChanged(unittest.TestCase): + """PUT /api/ddns must publish IDENTITY_CHANGED so CaddyManager regenerates.""" + + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + + def _put_ddns(self, payload=None): + if payload is None: + payload = {'domain_mode': 'pic_ngo', 'cell_name': 'test', 'domain': 'pic_ngo'} + return self.client.put( + '/api/ddns', + data=json.dumps(payload), + content_type='application/json', + ) + + @patch('app.service_bus') + @patch('app.config_manager') + def test_fires_identity_changed_on_success(self, mock_cm, mock_bus): + mock_cm.configs = { + '_identity': { + 'cell_name': 'test', + 'domain': 'pic_ngo', + 'domain_name': '', + 'domain_mode': 'pic_ngo', + } + } + mock_cm.set_identity_field = MagicMock() + mock_cm.get_effective_domain = MagicMock(return_value='test.pic.ngo') + mock_cm.validate_ddns_config = MagicMock(return_value=None) + + r = self._put_ddns() + + self.assertIn(r.status_code, (200, 204)) + self.assertTrue(mock_bus.publish_event.called, + 'Expected service_bus.publish_event to be called') + args = mock_bus.publish_event.call_args + # first positional arg should be an EventType with value IDENTITY_CHANGED + event_arg = args[0][0] + self.assertEqual(str(event_arg).upper().replace('.', '_'), + 'EVENTTYPE_IDENTITY_CHANGED') + + @patch('app.service_bus') + @patch('app.config_manager') + def test_identity_changed_payload_contains_domain_fields(self, mock_cm, mock_bus): + mock_cm.configs = { + '_identity': { + 'cell_name': 'mycell', + 'domain': 'pic_ngo', + 'domain_name': '', + 'domain_mode': 'pic_ngo', + } + } + mock_cm.set_identity_field = MagicMock() + mock_cm.get_effective_domain = MagicMock(return_value='mycell.pic.ngo') + mock_cm.validate_ddns_config = MagicMock(return_value=None) + + self._put_ddns({'domain_mode': 'pic_ngo', 'cell_name': 'mycell', 'domain': 'pic_ngo'}) + + if mock_bus.publish_event.called: + kwargs = mock_bus.publish_event.call_args[1] if mock_bus.publish_event.call_args[1] else {} + pos_args = mock_bus.publish_event.call_args[0] + # payload is 3rd positional arg + if len(pos_args) >= 3: + payload = pos_args[2] + self.assertIn('cell_name', payload) + self.assertIn('effective_domain', payload) + + +class TestCaddyCertStatusRoute(unittest.TestCase): + """GET /api/caddy/cert-status delegates to CaddyManager and handles errors.""" + + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + + def test_returns_cert_status_200(self): + expected = { + 'status': 'valid', + 'expiry': '2026-12-01T00:00:00+00:00', + 'days_remaining': 179, + } + mock_caddy = MagicMock() + mock_caddy.get_cert_status_fresh.return_value = expected + with patch('app.caddy_manager', mock_caddy): + r = self.client.get('/api/caddy/cert-status') + self.assertEqual(r.status_code, 200) + data = json.loads(r.data) + self.assertEqual(data['status'], 'valid') + self.assertEqual(data['days_remaining'], 179) + + def test_returns_500_on_exception(self): + mock_caddy = MagicMock() + mock_caddy.get_cert_status_fresh.side_effect = RuntimeError('ssl timeout') + with patch('app.caddy_manager', mock_caddy): + r = self.client.get('/api/caddy/cert-status') + self.assertEqual(r.status_code, 500) + data = json.loads(r.data) + self.assertIn('error', data) + + def test_calls_get_cert_status_fresh_with_max_age(self): + mock_caddy = MagicMock() + mock_caddy.get_cert_status_fresh.return_value = {'status': 'internal'} + with patch('app.caddy_manager', mock_caddy): + self.client.get('/api/caddy/cert-status') + mock_caddy.get_cert_status_fresh.assert_called_once() + call_kwargs = mock_caddy.get_cert_status_fresh.call_args + # max_age_seconds should be passed (positional or keyword) + all_args = list(call_kwargs[0]) + list(call_kwargs[1].values()) + self.assertTrue( + any(isinstance(a, int) and a > 0 for a in all_args), + 'Expected a positive max_age_seconds argument', + ) + + if __name__ == '__main__': unittest.main() diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index c90717f..92f27d5 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -7,7 +7,7 @@ import { ChevronDown, ChevronRight, CheckCircle, XCircle, RefreshCw, Lock, FolderDown, X, Globe, Loader } from 'lucide-react'; -import { cellAPI, ddnsAPI } from '../services/api'; +import { cellAPI, ddnsAPI, caddyAPI } from '../services/api'; import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfig'; // ── constants ──────────────────────────────────────────────────────────────── @@ -354,6 +354,7 @@ function Settings() { const [ddnsDirty, setDdnsDirty] = useState(false); const [ddnsSaving, setDdnsSaving] = useState(false); const [ddnsRegistering, setDdnsRegistering] = useState(false); + const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining} // service configs const [serviceConfigs, setServiceConfigs] = useState({}); @@ -374,11 +375,13 @@ function Settings() { const loadAll = useCallback(async () => { setIsLoading(true); try { - const [cfgRes, bkRes] = await Promise.all([ + const [cfgRes, bkRes, certRes] = await Promise.all([ cellAPI.getConfig(), cellAPI.listBackups(), + caddyAPI.getCertStatus().catch(() => null), ]); const cfg = cfgRes.data; + if (certRes?.data) setCertStatus(certRes.data); setIdentity({ cell_name: cfg.cell_name || '', domain: cfg.domain || '', @@ -937,6 +940,46 @@ function Settings() { : 'Local-only install — no external domain configured.'} )} + + {/* TLS Certificate Status */} + {certStatus && ( +
+
TLS Certificate
+
+ {certStatus.status === 'valid' && ( + + )} + {certStatus.status === 'expired' && ( + + )} + {(certStatus.status === 'unknown' || certStatus.status === 'internal') && ( + + )} +
+ {certStatus.status === 'valid' && ( + <> + Valid — expires{' '} + + {new Date(certStatus.expiry).toLocaleDateString()} + + {certStatus.days_remaining != null && ( + + ({certStatus.days_remaining}d remaining) + + )} + + )} + {certStatus.status === 'expired' && ( + + Expired on {new Date(certStatus.expiry).toLocaleDateString()} + + )} + {certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'} + {certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'} +
+
+
+ )} diff --git a/webui/src/services/api.js b/webui/src/services/api.js index ba26253..3441672 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -382,4 +382,9 @@ export const containerAPI = { removeVolume: (name, force = false) => api.delete(`/api/volumes/${name}`, { params: { force } }), }; +// Caddy / TLS API +export const caddyAPI = { + getCertStatus: () => api.get('/api/caddy/cert-status'), +}; + export default api; \ No newline at end of file