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
+8 -3
View File
@@ -123,10 +123,15 @@ def get_config():
config['domain_name'] = identity.get('domain_name', '')
config['effective_domain'] = config_manager.get_effective_domain()
ddns_section = config_manager.configs.get('ddns', {})
_provider = ddns_section.get('provider', '')
_has_token = bool(
(config_manager.get_ddns_token() if _provider == 'pic_ngo' else '') or
ddns_section.get('api_token') or ddns_section.get('token')
)
config['ddns'] = {
'provider': ddns_section.get('provider', ''),
'provider': _provider,
'subdomain': ddns_section.get('subdomain', ''),
'has_token': bool(ddns_section.get('token') or ddns_section.get('api_token')),
'has_token': _has_token,
}
return jsonify(config)
except Exception as e:
@@ -613,7 +618,7 @@ def ddns_status():
except Exception:
pass
registered = bool(ddns_cfg.get('token'))
registered = bool(config_manager.get_ddns_token())
return jsonify({
'registered': registered,
'domain_name': identity.get('domain_name', ''),