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
+55 -9
View File
@@ -299,9 +299,11 @@ class DDNSManager(BaseServiceManager):
def __init__(self, config_manager=None,
data_dir: str = '/app/data',
config_dir: str = '/app/config'):
config_dir: str = '/app/config',
service_bus=None):
super().__init__('ddns', data_dir, config_dir)
self.config_manager = config_manager
self._service_bus = service_bus
self._last_ip: Optional[str] = None
self._stop_event = threading.Event()
self._heartbeat_thread: Optional[threading.Thread] = None
@@ -344,6 +346,27 @@ class DDNSManager(BaseServiceManager):
return {}
return self.config_manager.configs.get('ddns', {}) or {}
def _get_token(self) -> str:
"""Return the DDNS bearer token from the secure token store."""
if self.config_manager is None:
return ''
if hasattr(self.config_manager, 'get_ddns_token'):
return self.config_manager.get_ddns_token() or ''
return self.config_manager.configs.get('ddns', {}).get('token', '')
def _fire_identity_changed(self, source: str) -> None:
"""Publish IDENTITY_CHANGED so CaddyManager regenerates its config."""
if self._service_bus is None:
return
try:
from service_bus import EventType
cell_name = self._identity().get('cell_name', '')
self._service_bus.publish_event(EventType.IDENTITY_CHANGED, source, {
'cell_name': cell_name,
})
except Exception as exc:
logger.warning('DDNSManager._fire_identity_changed: %s', exc)
# ------------------------------------------------------------------
# Provider factory
# ------------------------------------------------------------------
@@ -409,7 +432,7 @@ class DDNSManager(BaseServiceManager):
# Release the old subdomain if the name is changing and we hold a token
if self.config_manager is not None and hasattr(provider, 'release'):
old_token = self._ddns_cfg().get('token', '')
old_token = self._get_token()
old_domain = self._identity().get('domain_name', '')
old_name = old_domain.replace('.pic.ngo', '') if old_domain else ''
if old_token and old_name and old_name != name:
@@ -422,11 +445,14 @@ class DDNSManager(BaseServiceManager):
result = provider.register(name, ip)
if self.config_manager is not None:
# Token lives in the top-level ddns config so update_ip() can find it
# Token stored in data/api/ddns_token (not cell_config.json)
if 'token' in result:
ddns_cfg = dict(self.config_manager.configs.get('ddns', {}))
ddns_cfg['token'] = result['token']
self.config_manager.set_ddns_config(ddns_cfg)
if hasattr(self.config_manager, 'set_ddns_token'):
self.config_manager.set_ddns_token(result['token'])
else:
ddns_cfg = dict(self.config_manager.configs.get('ddns', {}))
ddns_cfg['token'] = result['token']
self.config_manager.set_ddns_config(ddns_cfg)
# Keep domain_name in identity up to date
if 'subdomain' in result:
self.config_manager.set_identity_field('domain_name', result['subdomain'])
@@ -454,7 +480,26 @@ class DDNSManager(BaseServiceManager):
logger.debug("DDNS update_ip: IP unchanged (%s), skipping", current_ip)
return
token = self._ddns_cfg().get('token', '')
token = self._get_token()
# No token means we never successfully registered (e.g. wizard failed).
# Attempt registration immediately rather than waiting for the 401 cycle.
if not token:
provider_name = self._ddns_cfg().get('provider', '')
if provider_name == 'pic_ngo':
logger.info("DDNS update_ip: no token — attempting initial registration")
try:
cell_name = self._identity().get('cell_name', '')
if cell_name:
self.register(cell_name, current_ip)
logger.info("DDNS registered (no-token retry): cell_name=%r", cell_name)
self._last_ip = current_ip
self._fire_identity_changed('ddns_heartbeat')
else:
logger.error("DDNS update_ip: cannot register — cell_name not in identity")
except Exception as exc:
logger.error("DDNS update_ip: initial registration failed: %s", exc)
return
try:
success = provider.update(token, current_ip)
@@ -471,6 +516,7 @@ class DDNSManager(BaseServiceManager):
self.register(cell_name, current_ip)
logger.info("DDNS re-registered after token expiry: cell_name=%r", cell_name)
self._last_ip = current_ip
self._fire_identity_changed('ddns_heartbeat')
else:
logger.error("DDNS update_ip: cannot re-register — cell_name not in identity")
except Exception as exc2:
@@ -526,7 +572,7 @@ class DDNSManager(BaseServiceManager):
provider = self.get_provider()
if provider is None:
raise DDNSError("No DDNS provider configured")
token = self._ddns_cfg().get('token', '')
token = self._get_token()
return provider.dns_challenge_create(token, fqdn, value)
def dns_challenge_delete(self, fqdn: str) -> bool:
@@ -534,5 +580,5 @@ class DDNSManager(BaseServiceManager):
provider = self.get_provider()
if provider is None:
raise DDNSError("No DDNS provider configured")
token = self._ddns_cfg().get('token', '')
token = self._get_token()
return provider.dns_challenge_delete(token, fqdn)