Fix DDNS security and reliability gaps (#2, #3, #5, #6, #7)
Unit Tests / test (push) Successful in 7m23s
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:
+43
-1
@@ -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': {}})
|
||||
|
||||
Reference in New Issue
Block a user