Fix DDNS security and reliability gaps (#2, #3, #5, #6, #7)
Unit Tests / test (push) Successful in 7m23s

- Fix #2: Move DDNS bearer token from cell_config.json to data/api/ddns_token.
  Token is now in the secrets store (data/) rather than the config store (config/).
  Auto-migrates existing installs on first access. ConfigManager.get/set_ddns_token()
  added. set_ddns_config() now strips 'token' key to prevent it leaking back.

- Fix #3: Set Caddyfile permissions to 0o600 after write so the token embedded
  in the Caddyfile is not world-readable on the host filesystem.

- Fix #5: Heartbeat now fires IDENTITY_CHANGED after re-registration so Caddy
  regenerates its config with the new token automatically — users no longer need
  to click Re-register in Settings after a wizard registration failure.
  Also: heartbeat skips the 401-cycle when no token exists and goes straight to
  registration instead. DDNSManager now accepts service_bus= and is wired up.

- Fix #6: Settings page starts polling GET /api/caddy/cert-status every 15s
  after a successful DDNS re-registration and shows "Acquiring certificate…"
  feedback until Let's Encrypt issues the cert (up to 5 minutes).

- Fix #7: regenerate_with_installed() is debounced (5 s window) so two rapid
  IDENTITY_CHANGED events (e.g. wizard + heartbeat) can't start simultaneous
  ACME orders that interfere with each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 03:37:48 -04:00
parent 40f9d90fad
commit 3d750ed1e8
8 changed files with 248 additions and 67 deletions
+32 -11
View File
@@ -42,8 +42,10 @@ def _make_config_manager(ddns_cfg=None, domain_cfg=None):
cm = MagicMock()
configs = {}
if ddns_cfg is not None:
configs['ddns'] = ddns_cfg
configs['ddns'] = {k: v for k, v in ddns_cfg.items() if k != 'token'}
cm.configs = configs
# Token is stored outside cell_config.json via get/set_ddns_token
cm.get_ddns_token.return_value = (ddns_cfg or {}).get('token', '')
return cm
@@ -272,17 +274,17 @@ class TestUpdateIp(unittest.TestCase):
mock_provider = MagicMock()
mock_provider.update.return_value = True
mgr.get_provider = MagicMock(return_value=mock_provider)
return mgr, mock_provider
return mgr, mock_provider, cm
def test_update_when_ip_changed(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
with patch('ddns_manager._get_public_ip', return_value='2.2.2.2'):
mgr.update_ip()
mock_provider.update.assert_called_once_with('tok', '2.2.2.2')
self.assertEqual(mgr._last_ip, '2.2.2.2')
def test_skips_update_when_ip_unchanged(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='3.3.3.3')
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='3.3.3.3')
with patch('ddns_manager._get_public_ip', return_value='3.3.3.3'):
mgr.update_ip()
mock_provider.update.assert_not_called()
@@ -297,13 +299,13 @@ class TestUpdateIp(unittest.TestCase):
mgr.update_ip()
def test_skips_update_when_ip_unreachable(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip=None)
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip=None)
with patch('ddns_manager._get_public_ip', return_value=None):
mgr.update_ip()
mock_provider.update.assert_not_called()
def test_last_ip_not_updated_when_provider_returns_false(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.return_value = False
with patch('ddns_manager._get_public_ip', return_value='9.9.9.9'):
mgr.update_ip()
@@ -311,12 +313,33 @@ class TestUpdateIp(unittest.TestCase):
self.assertEqual(mgr._last_ip, '1.1.1.1')
def test_ddns_error_is_caught_not_propagated(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.side_effect = DDNSError("server error")
with patch('ddns_manager._get_public_ip', return_value='5.5.5.5'):
# Should not raise
mgr.update_ip()
def test_no_token_triggers_registration_and_fires_identity_changed(self):
"""When no token exists, update_ip() registers immediately and fires IDENTITY_CHANGED."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
cm.get_ddns_token.return_value = ''
cm.get_identity.return_value = {'cell_name': 'mytest'}
mock_sbus = MagicMock()
mgr = DDNSManager(config_manager=cm, service_bus=mock_sbus)
mgr._last_ip = None
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'mytest.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
with patch('ddns_manager._get_public_ip', return_value='1.2.3.4'):
mgr.update_ip()
mock_provider.register.assert_called_once_with('mytest', '1.2.3.4')
mock_provider.update.assert_not_called()
self.assertEqual(mgr._last_ip, '1.2.3.4')
mock_sbus.publish_event.assert_called_once()
# ---------------------------------------------------------------------------
# DDNSManager.register() tests
@@ -334,10 +357,8 @@ class TestRegister(unittest.TestCase):
result = mgr.register('alpha', '1.2.3.4')
self.assertEqual(result['token'], 'new_tok')
# Token saved to top-level ddns config so update_ip() can find it
cm.set_ddns_config.assert_called_once()
saved_ddns = cm.set_ddns_config.call_args[0][0]
self.assertEqual(saved_ddns['token'], 'new_tok')
# Token stored via set_ddns_token (not embedded in cell_config.json)
cm.set_ddns_token.assert_called_once_with('new_tok')
# Subdomain saved to _identity.domain_name
cm.set_identity_field.assert_called_once_with('domain_name', 'alpha.pic.ngo')