feat: HTTPS cert status, IDENTITY_CHANGED wiring, remove stale ip_utils Caddyfile writes
Unit Tests / test (push) Successful in 11m18s
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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user