feat: HTTPS cert status, IDENTITY_CHANGED wiring, remove stale ip_utils Caddyfile writes
Unit Tests / test (push) Successful in 11m18s

- 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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:39:36 -04:00
parent 41d09c598b
commit 0267dce73d
7 changed files with 398 additions and 26 deletions
+24
View File
@@ -590,6 +590,7 @@ def perform_health_check():
return {'error': str(e), 'timestamp': datetime.utcnow().isoformat()} return {'error': str(e), 'timestamp': datetime.utcnow().isoformat()}
def health_monitor_loop(): def health_monitor_loop():
_cert_check_cycle = 0
while health_monitor_running: while health_monitor_running:
with app.app_context(): with app.app_context():
health_result = perform_health_check() health_result = perform_health_check()
@@ -613,6 +614,14 @@ def health_monitor_loop():
caddy_manager.reset_health_failures() caddy_manager.reset_health_failures()
except Exception as _caddy_err: except Exception as _caddy_err:
logger.error("Caddy health monitor error: %s", _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 time.sleep(60) # Check every 60 seconds
# Start health monitor thread # Start health monitor thread
@@ -854,6 +863,21 @@ def connectivity_get_peer_exits():
return jsonify({'error': str(e)}), 500 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']) @app.route('/api/egress/status', methods=['GET'])
def egress_status(): def egress_status():
"""Return egress status for all installed services that have an egress config.""" """Return egress status for all installed services that have an egress config."""
+76 -1
View File
@@ -23,8 +23,12 @@ in the main server block (or, for ``http01``, written as their own per-host
blocks). blocks).
""" """
import datetime as _dt
import logging import logging
import os import os
import socket as _socket
import ssl as _ssl
import time as _time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import requests import requests
@@ -62,6 +66,8 @@ class CaddyManager(BaseServiceManager):
# Consecutive health-check failure counter (reset on success or when # Consecutive health-check failure counter (reset on success or when
# the caller restarts the container). # the caller restarts the container).
self._health_failures = 0 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: if service_bus is not None:
from service_bus import EventType from service_bus import EventType
@@ -490,8 +496,10 @@ class CaddyManager(BaseServiceManager):
except Exception as exc: except Exception as exc:
self.logger.warning('caddy_manager identity_changed handler failed: %s', exc) self.logger.warning('caddy_manager identity_changed handler failed: %s', exc)
# ── Certificate status ────────────────────────────────────────────────
def get_cert_status(self) -> Dict[str, Any]: 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} default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
if not self.config_manager: if not self.config_manager:
return default return default
@@ -506,3 +514,70 @@ class CaddyManager(BaseServiceManager):
'expiry': tls.get('expiry'), 'expiry': tls.get('expiry'),
'days_remaining': tls.get('days_remaining'), '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
+21 -23
View File
@@ -316,13 +316,6 @@ def update_config():
domain = identity_updates['domain'] domain = identity_updates['domain']
net_result = network_manager.apply_domain(domain, reload=False) net_result = network_manager.apply_domain(domain, reload=False)
all_warnings.extend(net_result.get('warnings', [])) 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( _set_pending_restart(
[f'domain changed to {domain}'], [f'domain changed to {domain}'],
['dns', 'caddy'], ['dns', 'caddy'],
@@ -335,14 +328,6 @@ def update_config():
if old_name != new_name: if old_name != new_name:
cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False) cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False)
all_warnings.extend(cn_result.get('warnings', [])) 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( _set_pending_restart(
[f'cell_name changed to {new_name}'], [f'cell_name changed to {new_name}'],
['dns'], ['dns'],
@@ -373,8 +358,6 @@ def update_config():
firewall_manager.ensure_caddy_virtual_ips() firewall_manager.ensure_caddy_virtual_ips()
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') 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)) 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( _set_pending_restart(
[f'ip_range changed to {new_range} — network will be recreated'], [f'ip_range changed to {new_range} — network will be recreated'],
['*'], network_recreate=True, ['*'], 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_token', duck_token)
config_manager.set_identity_field('duckdns_subdomain', duck_sub) 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) logger.info('DDNS config updated: domain_mode=%r domain_name=%r', domain_mode, domain_name)
return jsonify({'updated': True}) return jsonify({'updated': True})
except Exception as e: 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: 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) network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False)
if _id.get('domain_mode', 'lan') == 'lan': # Regenerate Caddyfile for the reverted identity (all domain modes)
_ip_revert.write_caddyfile( try:
_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), from app import caddy_manager as _cm
_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), _cm.regenerate_with_installed([])
_dom, '/app/config-caddy/Caddyfile' except Exception as _cm_err:
) logger.warning('cancel_pending_config: caddy regenerate failed (non-fatal): %s', _cm_err)
_clear_pending_restart() _clear_pending_restart()
return jsonify({'message': 'Pending changes discarded'}) return jsonify({'message': 'Pending changes discarded'})
+112
View File
@@ -325,5 +325,117 @@ class TestCaddyManagerIdentityChangedSubscription(unittest.TestCase):
mgr._on_identity_changed(event) # must not raise 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+115
View File
@@ -190,5 +190,120 @@ class TestConfigApplyRoute(unittest.TestCase):
self.assertIn('error', json.loads(r.data)) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+45 -2
View File
@@ -7,7 +7,7 @@ import {
ChevronDown, ChevronRight, CheckCircle, XCircle, ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock, FolderDown, X, Globe, Loader RefreshCw, Lock, FolderDown, X, Globe, Loader
} from 'lucide-react'; } from 'lucide-react';
import { cellAPI, ddnsAPI } from '../services/api'; import { cellAPI, ddnsAPI, caddyAPI } from '../services/api';
import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfig'; import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfig';
// constants // constants
@@ -354,6 +354,7 @@ function Settings() {
const [ddnsDirty, setDdnsDirty] = useState(false); const [ddnsDirty, setDdnsDirty] = useState(false);
const [ddnsSaving, setDdnsSaving] = useState(false); const [ddnsSaving, setDdnsSaving] = useState(false);
const [ddnsRegistering, setDdnsRegistering] = useState(false); const [ddnsRegistering, setDdnsRegistering] = useState(false);
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
// service configs // service configs
const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceConfigs, setServiceConfigs] = useState({});
@@ -374,11 +375,13 @@ function Settings() {
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const [cfgRes, bkRes] = await Promise.all([ const [cfgRes, bkRes, certRes] = await Promise.all([
cellAPI.getConfig(), cellAPI.getConfig(),
cellAPI.listBackups(), cellAPI.listBackups(),
caddyAPI.getCertStatus().catch(() => null),
]); ]);
const cfg = cfgRes.data; const cfg = cfgRes.data;
if (certRes?.data) setCertStatus(certRes.data);
setIdentity({ setIdentity({
cell_name: cfg.cell_name || '', cell_name: cfg.cell_name || '',
domain: cfg.domain || '', domain: cfg.domain || '',
@@ -937,6 +940,46 @@ function Settings() {
: 'Local-only install — no external domain configured.'} : 'Local-only install — no external domain configured.'}
</div> </div>
)} )}
{/* TLS Certificate Status */}
{certStatus && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="text-sm font-medium text-gray-700 mb-2">TLS Certificate</div>
<div className="flex items-center gap-3">
{certStatus.status === 'valid' && (
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
{certStatus.status === 'expired' && (
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
)}
{(certStatus.status === 'unknown' || certStatus.status === 'internal') && (
<Lock className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div className="text-sm text-gray-600">
{certStatus.status === 'valid' && (
<>
Valid &mdash; expires{' '}
<span className="font-mono text-gray-800">
{new Date(certStatus.expiry).toLocaleDateString()}
</span>
{certStatus.days_remaining != null && (
<span className={`ml-2 font-medium ${certStatus.days_remaining < 14 ? 'text-amber-600' : 'text-green-700'}`}>
({certStatus.days_remaining}d remaining)
</span>
)}
</>
)}
{certStatus.status === 'expired' && (
<span className="text-red-600 font-medium">
Expired on {new Date(certStatus.expiry).toLocaleDateString()}
</span>
)}
{certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'}
{certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'}
</div>
</div>
</div>
)}
</div> </div>
</Section> </Section>
+5
View File
@@ -382,4 +382,9 @@ export const containerAPI = {
removeVolume: (name, force = false) => api.delete(`/api/volumes/${name}`, { params: { force } }), 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; export default api;