Four bugs fixed:
1. Banner delay (up to 5 s): DraftConfigContext now exposes isDirty as
reactive useState so App.jsx re-renders immediately when any section
marks itself dirty, instead of waiting for the next checkPending() poll.
2. Banner re-triggers after Apply (race): For non-'*' container restarts
(e.g., cell_name → DNS restart) the background thread took ~300 ms to
clear _pending_restart. A concurrent checkPending() poll could see
needs_restart=True and overwrite the frontend's optimistic clear.
Fix: set needs_restart=False and applying=True synchronously before
spawning the thread.
3. Apply showed banner during applyPending() when hasDirty()==false:
setApplyStatus('saving') was skipped for the auto-save-then-apply
path, leaving applyStatus=null while applyPending() ran and the
banner stayed visible. Always set 'saving' before applyPending().
4. Cert status always 'unknown' in pic_ngo mode: _check_cert_via_ssl
connected to cell-caddy:443 but sent SNI='cell-caddy'. Caddy finds no
matching cert and returns nothing. Fix: pass the effective public
domain (e.g. pic1.pic.ngo) as SNI so Caddy returns the right cert.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -412,6 +412,45 @@ class TestRefreshCertStatus(unittest.TestCase):
|
||||
# Should have been persisted to identity
|
||||
mgr.config_manager.set_identity_field.assert_called_with('tls', expected)
|
||||
|
||||
def test_refresh_cert_status_uses_effective_domain_as_sni(self):
|
||||
"""refresh_cert_status passes the effective domain as SNI, not the container hostname.
|
||||
|
||||
Without this, Caddy receives SNI='cell-caddy' which matches no certificate
|
||||
and the SSL handshake returns nothing, leaving cert status as 'unknown'.
|
||||
"""
|
||||
mgr = _mgr(identity={'cell_name': 'pic1', 'domain_mode': 'pic_ngo'})
|
||||
mgr.config_manager.get_effective_domain.return_value = 'pic1.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) as mock_ssl:
|
||||
mgr.refresh_cert_status()
|
||||
# The SNI keyword argument must be the effective domain, not the container name.
|
||||
call_kwargs = mock_ssl.call_args
|
||||
sni_passed = call_kwargs.kwargs.get('sni') or (
|
||||
call_kwargs.args[2] if len(call_kwargs.args) > 2 else None
|
||||
)
|
||||
self.assertEqual(sni_passed, 'pic1.pic.ngo',
|
||||
f'Expected SNI=pic1.pic.ngo but got {sni_passed!r}')
|
||||
|
||||
def test_check_cert_via_ssl_passes_sni_to_wrap_socket(self):
|
||||
"""_check_cert_via_ssl uses sni parameter as server_hostname in SSL handshake."""
|
||||
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) as mock_conn:
|
||||
with patch('caddy_manager._ssl.create_default_context') as mock_ctx:
|
||||
mock_ctx.return_value.wrap_socket.return_value = mock_tls
|
||||
CaddyManager._check_cert_via_ssl('cell-caddy', 443, sni='pic1.pic.ngo')
|
||||
# TCP connects to container hostname, SSL handshake uses the public domain
|
||||
mock_conn.assert_called_with(('cell-caddy', 443), timeout=5)
|
||||
mock_ctx.return_value.wrap_socket.assert_called_with(
|
||||
mock_raw, server_hostname='pic1.pic.ngo'
|
||||
)
|
||||
|
||||
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'})
|
||||
|
||||
Reference in New Issue
Block a user