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:
+55
-9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user