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
+43 -1
View File
@@ -678,10 +678,52 @@ class ConfigManager:
return dict(cfg)
def set_ddns_config(self, ddns_cfg: Dict[str, Any]) -> None:
"""Replace the top-level ddns section and persist."""
"""Replace the top-level ddns section and persist.
Never writes a 'token' key into cell_config.json — tokens live in data/.
"""
ddns_cfg = {k: v for k, v in ddns_cfg.items() if k != 'token'}
self.configs['ddns'] = ddns_cfg
self._save_all_configs()
@property
def _ddns_token_path(self) -> Path:
return self.data_dir / 'api' / 'ddns_token'
def get_ddns_token(self) -> str:
"""Return the DDNS bearer token from data/api/ddns_token.
Migrates automatically from the old cell_config.json location on first
call so existing installs keep working without manual intervention.
"""
path = self._ddns_token_path
if path.exists():
try:
tok = path.read_text().strip()
if tok:
return tok
except (PermissionError, OSError):
pass
# Migrate legacy token from cell_config.json
old_token = self.configs.get('ddns', {}).get('token', '')
if old_token:
self.set_ddns_token(old_token)
return old_token
def set_ddns_token(self, token: str) -> None:
"""Write the DDNS bearer token to data/api/ddns_token (not cell_config.json)."""
path = self._ddns_token_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(token)
except (PermissionError, OSError) as exc:
logger.error('set_ddns_token: failed to write token file: %s', exc)
return
# Remove from cell_config.json if a legacy copy is there
if self.configs.get('ddns', {}).get('token'):
ddns_cfg = {k: v for k, v in self.configs.get('ddns', {}).items() if k != 'token'}
self.configs['ddns'] = ddns_cfg
self._save_all_configs()
def set_connectivity_field(self, field: str, value: Any) -> bool:
"""Set a single field within the connectivity config and persist."""
cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}})