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
+11 -5
View File
@@ -60,8 +60,9 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
def test_pic_ngo_has_dns_plugin_and_wildcard(self):
mgr = _mgr()
mgr.config_manager.configs = {
'ddns': {'token': 'TESTSECRET123', 'url': 'https://ddns.pic.ngo/api/v1'},
'ddns': {'url': 'https://ddns.pic.ngo/api/v1'},
}
mgr.config_manager.get_ddns_token.return_value = 'TESTSECRET123'
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
with unittest.mock.patch.dict(os.environ, {'DDNS_URL': 'https://ddns.pic.ngo/api/v1'}):
out = mgr.generate_caddyfile(identity, [])
@@ -81,7 +82,8 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
def test_pic_ngo_acme_ca_included_when_env_set(self):
mgr = _mgr()
mgr.config_manager.configs = {'ddns': {'token': 'TESTSECRET123'}}
mgr.config_manager.configs = {'ddns': {}}
mgr.config_manager.get_ddns_token.return_value = 'TESTSECRET123'
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
with unittest.mock.patch.dict(os.environ, {
'DDNS_URL': 'https://ddns.pic.ngo/api/v1',
@@ -645,7 +647,8 @@ class TestPicNgoNoTokenFallback(unittest.TestCase):
def test_empty_token_generates_lan_caddyfile(self):
mgr = _mgr()
mgr.config_manager.configs = {'ddns': {'token': '', 'url': 'https://ddns.pic.ngo'}}
mgr.config_manager.configs = {'ddns': {'url': 'https://ddns.pic.ngo'}}
mgr.config_manager.get_ddns_token.return_value = ''
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_TOKEN', None)
os.environ.pop('DDNS_URL', None)
@@ -657,6 +660,7 @@ class TestPicNgoNoTokenFallback(unittest.TestCase):
def test_missing_ddns_config_generates_lan_caddyfile(self):
mgr = _mgr()
mgr.config_manager.configs = {}
mgr.config_manager.get_ddns_token.return_value = ''
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_TOKEN', None)
os.environ.pop('DDNS_URL', None)
@@ -671,8 +675,9 @@ class TestDdnsApiStripsLegacySuffix(unittest.TestCase):
def test_api_v1_suffix_stripped_from_config_url(self):
mgr = _mgr()
mgr.config_manager.configs = {
'ddns': {'token': 'tok', 'url': 'https://ddns.pic.ngo/api/v1'},
'ddns': {'url': 'https://ddns.pic.ngo/api/v1'},
}
mgr.config_manager.get_ddns_token.return_value = 'tok'
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_URL', None)
out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, [])
@@ -682,8 +687,9 @@ class TestDdnsApiStripsLegacySuffix(unittest.TestCase):
def test_clean_url_is_unchanged(self):
mgr = _mgr()
mgr.config_manager.configs = {
'ddns': {'token': 'tok', 'url': 'https://ddns.pic.ngo'},
'ddns': {'url': 'https://ddns.pic.ngo'},
}
mgr.config_manager.get_ddns_token.return_value = 'tok'
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_URL', None)
out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, [])