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
+34 -7
View File
@@ -28,6 +28,7 @@ import logging
import os import os
import socket as _socket import socket as _socket
import ssl as _ssl import ssl as _ssl
import threading
import time as _time import time as _time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -78,6 +79,10 @@ class CaddyManager(BaseServiceManager):
self._health_failures = 0 self._health_failures = 0
# Monotonic timestamp of the last successful cert status refresh. # Monotonic timestamp of the last successful cert status refresh.
self._cert_refreshed_at: Optional[float] = None self._cert_refreshed_at: Optional[float] = None
# Debounce: prevent two rapid Caddyfile reloads (e.g. IDENTITY_CHANGED
# fires from wizard AND heartbeat re-registration within seconds of each other).
self._last_regenerate_at: float = 0.0
self._regenerate_lock = threading.Lock()
if service_bus is not None: if service_bus is not None:
from service_bus import EventType from service_bus import EventType
@@ -311,13 +316,17 @@ class CaddyManager(BaseServiceManager):
# Resolve credentials at write time — Caddy runs in its own container # Resolve credentials at write time — Caddy runs in its own container
# and does not inherit the API's environment variables, so we embed the # and does not inherit the API's environment variables, so we embed the
# actual values instead of {$VAR} placeholders. # actual values instead of {$VAR} placeholders.
# Use the registration bearer token (ddns.token), NOT the TOTP secret — # Token is read from data/api/ddns_token (not cell_config.json).
# the pic_ngo plugin authenticates to POST /api/v1/dns-challenge with this token. ddns_cfg = self.config_manager.configs.get('ddns', {})
ddns_cfg = self.config_manager.configs.get('ddns', {}) if hasattr(self.config_manager, 'get_ddns_token'):
ddns_token = (ddns_cfg.get('token') or os.environ.get('DDNS_TOKEN') or '').strip() ddns_token = self.config_manager.get_ddns_token() or ''
_raw_api = (os.environ.get('DDNS_URL') or ddns_cfg.get('url') or 'https://ddns.pic.ngo').strip() else:
ddns_token = (ddns_cfg.get('token') or '').strip()
if not ddns_token:
ddns_token = os.environ.get('DDNS_TOKEN', '').strip()
_raw_api = (os.environ.get('DDNS_URL') or ddns_cfg.get('url') or 'https://ddns.pic.ngo').strip()
# Strip legacy /api/v1 suffix — the pic_ngo plugin appends /api/v1 itself. # Strip legacy /api/v1 suffix — the pic_ngo plugin appends /api/v1 itself.
ddns_api = _raw_api.rstrip('/').removesuffix('/api/v1') ddns_api = _raw_api.rstrip('/').removesuffix('/api/v1')
# No token yet (fresh install, pre-registration) — Caddy would reject a # No token yet (fresh install, pre-registration) — Caddy would reject a
# bare `token` keyword with no value. Fall back to LAN mode so Caddy # bare `token` keyword with no value. Fall back to LAN mode so Caddy
@@ -458,6 +467,10 @@ class CaddyManager(BaseServiceManager):
os.fsync(f.fileno()) os.fsync(f.fileno())
except OSError: except OSError:
pass pass
try:
os.chmod(self.caddyfile_path, 0o600)
except OSError:
pass
logger.info("Wrote Caddyfile to %s (%d bytes)", logger.info("Wrote Caddyfile to %s (%d bytes)",
self.caddyfile_path, len(caddyfile_content)) self.caddyfile_path, len(caddyfile_content))
except Exception as e: except Exception as e:
@@ -530,8 +543,22 @@ class CaddyManager(BaseServiceManager):
# ── certificate status ──────────────────────────────────────────────── # ── certificate status ────────────────────────────────────────────────
_REGENERATE_DEBOUNCE = 5.0 # seconds
def regenerate_with_installed(self, installed_services: list) -> bool: def regenerate_with_installed(self, installed_services: list) -> bool:
"""Regenerate Caddyfile with installed services and reload.""" """Regenerate Caddyfile with installed services and reload.
Debounced: skips if called again within _REGENERATE_DEBOUNCE seconds.
This prevents two simultaneous ACME orders when IDENTITY_CHANGED fires
from multiple sources (e.g. wizard completion + heartbeat re-registration)
within a short window.
"""
now = _time.monotonic()
with self._regenerate_lock:
if now - self._last_regenerate_at < self._REGENERATE_DEBOUNCE:
logger.debug("caddy regenerate_with_installed: skipped (debounce)")
return True
self._last_regenerate_at = now
identity = self.config_manager.get_identity() identity = self.config_manager.get_identity()
content = self.generate_caddyfile(identity, installed_services) content = self.generate_caddyfile(identity, installed_services)
return self.write_caddyfile(content) return self.write_caddyfile(content)
+43 -1
View File
@@ -678,10 +678,52 @@ class ConfigManager:
return dict(cfg) return dict(cfg)
def set_ddns_config(self, ddns_cfg: Dict[str, Any]) -> None: 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.configs['ddns'] = ddns_cfg
self._save_all_configs() 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: def set_connectivity_field(self, field: str, value: Any) -> bool:
"""Set a single field within the connectivity config and persist.""" """Set a single field within the connectivity config and persist."""
cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}}) cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}})
+55 -9
View File
@@ -299,9 +299,11 @@ class DDNSManager(BaseServiceManager):
def __init__(self, config_manager=None, def __init__(self, config_manager=None,
data_dir: str = '/app/data', 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) super().__init__('ddns', data_dir, config_dir)
self.config_manager = config_manager self.config_manager = config_manager
self._service_bus = service_bus
self._last_ip: Optional[str] = None self._last_ip: Optional[str] = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._heartbeat_thread: Optional[threading.Thread] = None self._heartbeat_thread: Optional[threading.Thread] = None
@@ -344,6 +346,27 @@ class DDNSManager(BaseServiceManager):
return {} return {}
return self.config_manager.configs.get('ddns', {}) or {} 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 # Provider factory
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -409,7 +432,7 @@ class DDNSManager(BaseServiceManager):
# Release the old subdomain if the name is changing and we hold a token # 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'): 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_domain = self._identity().get('domain_name', '')
old_name = old_domain.replace('.pic.ngo', '') if old_domain else '' old_name = old_domain.replace('.pic.ngo', '') if old_domain else ''
if old_token and old_name and old_name != name: if old_token and old_name and old_name != name:
@@ -422,11 +445,14 @@ class DDNSManager(BaseServiceManager):
result = provider.register(name, ip) result = provider.register(name, ip)
if self.config_manager is not None: 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: if 'token' in result:
ddns_cfg = dict(self.config_manager.configs.get('ddns', {})) if hasattr(self.config_manager, 'set_ddns_token'):
ddns_cfg['token'] = result['token'] self.config_manager.set_ddns_token(result['token'])
self.config_manager.set_ddns_config(ddns_cfg) 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 # Keep domain_name in identity up to date
if 'subdomain' in result: if 'subdomain' in result:
self.config_manager.set_identity_field('domain_name', result['subdomain']) 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) logger.debug("DDNS update_ip: IP unchanged (%s), skipping", current_ip)
return 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: try:
success = provider.update(token, current_ip) success = provider.update(token, current_ip)
@@ -471,6 +516,7 @@ class DDNSManager(BaseServiceManager):
self.register(cell_name, current_ip) self.register(cell_name, current_ip)
logger.info("DDNS re-registered after token expiry: cell_name=%r", cell_name) logger.info("DDNS re-registered after token expiry: cell_name=%r", cell_name)
self._last_ip = current_ip self._last_ip = current_ip
self._fire_identity_changed('ddns_heartbeat')
else: else:
logger.error("DDNS update_ip: cannot re-register — cell_name not in identity") logger.error("DDNS update_ip: cannot re-register — cell_name not in identity")
except Exception as exc2: except Exception as exc2:
@@ -526,7 +572,7 @@ class DDNSManager(BaseServiceManager):
provider = self.get_provider() provider = self.get_provider()
if provider is None: if provider is None:
raise DDNSError("No DDNS provider configured") raise DDNSError("No DDNS provider configured")
token = self._ddns_cfg().get('token', '') token = self._get_token()
return provider.dns_challenge_create(token, fqdn, value) return provider.dns_challenge_create(token, fqdn, value)
def dns_challenge_delete(self, fqdn: str) -> bool: def dns_challenge_delete(self, fqdn: str) -> bool:
@@ -534,5 +580,5 @@ class DDNSManager(BaseServiceManager):
provider = self.get_provider() provider = self.get_provider()
if provider is None: if provider is None:
raise DDNSError("No DDNS provider configured") raise DDNSError("No DDNS provider configured")
token = self._ddns_cfg().get('token', '') token = self._get_token()
return provider.dns_challenge_delete(token, fqdn) return provider.dns_challenge_delete(token, fqdn)
+2 -1
View File
@@ -68,7 +68,8 @@ cell_link_manager = CellLinkManager(
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR) auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_bus=service_bus, service_registry=service_registry) service_bus=service_bus, service_registry=service_registry)
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR) ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_bus=service_bus)
connectivity_manager = ConnectivityManager( connectivity_manager = ConnectivityManager(
config_manager=config_manager, config_manager=config_manager,
peer_registry=peer_registry, peer_registry=peer_registry,
+8 -3
View File
@@ -123,10 +123,15 @@ def get_config():
config['domain_name'] = identity.get('domain_name', '') config['domain_name'] = identity.get('domain_name', '')
config['effective_domain'] = config_manager.get_effective_domain() config['effective_domain'] = config_manager.get_effective_domain()
ddns_section = config_manager.configs.get('ddns', {}) 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'] = { config['ddns'] = {
'provider': ddns_section.get('provider', ''), 'provider': _provider,
'subdomain': ddns_section.get('subdomain', ''), '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) return jsonify(config)
except Exception as e: except Exception as e:
@@ -613,7 +618,7 @@ def ddns_status():
except Exception: except Exception:
pass pass
registered = bool(ddns_cfg.get('token')) registered = bool(config_manager.get_ddns_token())
return jsonify({ return jsonify({
'registered': registered, 'registered': registered,
'domain_name': identity.get('domain_name', ''), 'domain_name': identity.get('domain_name', ''),
+11 -5
View File
@@ -60,8 +60,9 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
def test_pic_ngo_has_dns_plugin_and_wildcard(self): def test_pic_ngo_has_dns_plugin_and_wildcard(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = { mgr.config_manager.configs = {
'ddns': {'token': 'TESTSECRET123', 'url': 'https://ddns.pic.ngo/api/v1'}, 'ddns': {'url': 'https://ddns.pic.ngo/api/v1'},
} }
mgr.config_manager.get_ddns_token.return_value = 'TESTSECRET123'
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'} identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
with unittest.mock.patch.dict(os.environ, {'DDNS_URL': 'https://ddns.pic.ngo/api/v1'}): with unittest.mock.patch.dict(os.environ, {'DDNS_URL': 'https://ddns.pic.ngo/api/v1'}):
out = mgr.generate_caddyfile(identity, []) out = mgr.generate_caddyfile(identity, [])
@@ -81,7 +82,8 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
def test_pic_ngo_acme_ca_included_when_env_set(self): def test_pic_ngo_acme_ca_included_when_env_set(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = {'ddns': {'token': 'TESTSECRET123'}} mgr.config_manager.configs = {'ddns': {}}
mgr.config_manager.get_ddns_token.return_value = 'TESTSECRET123'
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'} identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
with unittest.mock.patch.dict(os.environ, { with unittest.mock.patch.dict(os.environ, {
'DDNS_URL': 'https://ddns.pic.ngo/api/v1', 'DDNS_URL': 'https://ddns.pic.ngo/api/v1',
@@ -645,7 +647,8 @@ class TestPicNgoNoTokenFallback(unittest.TestCase):
def test_empty_token_generates_lan_caddyfile(self): def test_empty_token_generates_lan_caddyfile(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = {'ddns': {'token': '', 'url': 'https://ddns.pic.ngo'}} mgr.config_manager.configs = {'ddns': {'url': 'https://ddns.pic.ngo'}}
mgr.config_manager.get_ddns_token.return_value = ''
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_TOKEN', None) os.environ.pop('DDNS_TOKEN', None)
os.environ.pop('DDNS_URL', None) os.environ.pop('DDNS_URL', None)
@@ -657,6 +660,7 @@ class TestPicNgoNoTokenFallback(unittest.TestCase):
def test_missing_ddns_config_generates_lan_caddyfile(self): def test_missing_ddns_config_generates_lan_caddyfile(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = {} mgr.config_manager.configs = {}
mgr.config_manager.get_ddns_token.return_value = ''
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_TOKEN', None) os.environ.pop('DDNS_TOKEN', None)
os.environ.pop('DDNS_URL', None) os.environ.pop('DDNS_URL', None)
@@ -671,8 +675,9 @@ class TestDdnsApiStripsLegacySuffix(unittest.TestCase):
def test_api_v1_suffix_stripped_from_config_url(self): def test_api_v1_suffix_stripped_from_config_url(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = { mgr.config_manager.configs = {
'ddns': {'token': 'tok', 'url': 'https://ddns.pic.ngo/api/v1'}, 'ddns': {'url': 'https://ddns.pic.ngo/api/v1'},
} }
mgr.config_manager.get_ddns_token.return_value = 'tok'
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_URL', None) os.environ.pop('DDNS_URL', None)
out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, []) out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, [])
@@ -682,8 +687,9 @@ class TestDdnsApiStripsLegacySuffix(unittest.TestCase):
def test_clean_url_is_unchanged(self): def test_clean_url_is_unchanged(self):
mgr = _mgr() mgr = _mgr()
mgr.config_manager.configs = { mgr.config_manager.configs = {
'ddns': {'token': 'tok', 'url': 'https://ddns.pic.ngo'}, 'ddns': {'url': 'https://ddns.pic.ngo'},
} }
mgr.config_manager.get_ddns_token.return_value = 'tok'
with patch.dict(os.environ, {}, clear=False): with patch.dict(os.environ, {}, clear=False):
os.environ.pop('DDNS_URL', None) os.environ.pop('DDNS_URL', None)
out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, []) out = mgr.generate_caddyfile({'cell_name': 'x', 'domain_mode': 'pic_ngo'}, [])
+32 -11
View File
@@ -42,8 +42,10 @@ def _make_config_manager(ddns_cfg=None, domain_cfg=None):
cm = MagicMock() cm = MagicMock()
configs = {} configs = {}
if ddns_cfg is not None: if ddns_cfg is not None:
configs['ddns'] = ddns_cfg configs['ddns'] = {k: v for k, v in ddns_cfg.items() if k != 'token'}
cm.configs = configs cm.configs = configs
# Token is stored outside cell_config.json via get/set_ddns_token
cm.get_ddns_token.return_value = (ddns_cfg or {}).get('token', '')
return cm return cm
@@ -272,17 +274,17 @@ class TestUpdateIp(unittest.TestCase):
mock_provider = MagicMock() mock_provider = MagicMock()
mock_provider.update.return_value = True mock_provider.update.return_value = True
mgr.get_provider = MagicMock(return_value=mock_provider) mgr.get_provider = MagicMock(return_value=mock_provider)
return mgr, mock_provider return mgr, mock_provider, cm
def test_update_when_ip_changed(self): def test_update_when_ip_changed(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1') mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
with patch('ddns_manager._get_public_ip', return_value='2.2.2.2'): with patch('ddns_manager._get_public_ip', return_value='2.2.2.2'):
mgr.update_ip() mgr.update_ip()
mock_provider.update.assert_called_once_with('tok', '2.2.2.2') mock_provider.update.assert_called_once_with('tok', '2.2.2.2')
self.assertEqual(mgr._last_ip, '2.2.2.2') self.assertEqual(mgr._last_ip, '2.2.2.2')
def test_skips_update_when_ip_unchanged(self): def test_skips_update_when_ip_unchanged(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='3.3.3.3') mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='3.3.3.3')
with patch('ddns_manager._get_public_ip', return_value='3.3.3.3'): with patch('ddns_manager._get_public_ip', return_value='3.3.3.3'):
mgr.update_ip() mgr.update_ip()
mock_provider.update.assert_not_called() mock_provider.update.assert_not_called()
@@ -297,13 +299,13 @@ class TestUpdateIp(unittest.TestCase):
mgr.update_ip() mgr.update_ip()
def test_skips_update_when_ip_unreachable(self): def test_skips_update_when_ip_unreachable(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip=None) mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip=None)
with patch('ddns_manager._get_public_ip', return_value=None): with patch('ddns_manager._get_public_ip', return_value=None):
mgr.update_ip() mgr.update_ip()
mock_provider.update.assert_not_called() mock_provider.update.assert_not_called()
def test_last_ip_not_updated_when_provider_returns_false(self): def test_last_ip_not_updated_when_provider_returns_false(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1') mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.return_value = False mock_provider.update.return_value = False
with patch('ddns_manager._get_public_ip', return_value='9.9.9.9'): with patch('ddns_manager._get_public_ip', return_value='9.9.9.9'):
mgr.update_ip() mgr.update_ip()
@@ -311,12 +313,33 @@ class TestUpdateIp(unittest.TestCase):
self.assertEqual(mgr._last_ip, '1.1.1.1') self.assertEqual(mgr._last_ip, '1.1.1.1')
def test_ddns_error_is_caught_not_propagated(self): def test_ddns_error_is_caught_not_propagated(self):
mgr, mock_provider = self._make_manager_with_mock_provider(last_ip='1.1.1.1') mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.side_effect = DDNSError("server error") mock_provider.update.side_effect = DDNSError("server error")
with patch('ddns_manager._get_public_ip', return_value='5.5.5.5'): with patch('ddns_manager._get_public_ip', return_value='5.5.5.5'):
# Should not raise # Should not raise
mgr.update_ip() mgr.update_ip()
def test_no_token_triggers_registration_and_fires_identity_changed(self):
"""When no token exists, update_ip() registers immediately and fires IDENTITY_CHANGED."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
cm.get_ddns_token.return_value = ''
cm.get_identity.return_value = {'cell_name': 'mytest'}
mock_sbus = MagicMock()
mgr = DDNSManager(config_manager=cm, service_bus=mock_sbus)
mgr._last_ip = None
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'mytest.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
with patch('ddns_manager._get_public_ip', return_value='1.2.3.4'):
mgr.update_ip()
mock_provider.register.assert_called_once_with('mytest', '1.2.3.4')
mock_provider.update.assert_not_called()
self.assertEqual(mgr._last_ip, '1.2.3.4')
mock_sbus.publish_event.assert_called_once()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DDNSManager.register() tests # DDNSManager.register() tests
@@ -334,10 +357,8 @@ class TestRegister(unittest.TestCase):
result = mgr.register('alpha', '1.2.3.4') result = mgr.register('alpha', '1.2.3.4')
self.assertEqual(result['token'], 'new_tok') self.assertEqual(result['token'], 'new_tok')
# Token saved to top-level ddns config so update_ip() can find it # Token stored via set_ddns_token (not embedded in cell_config.json)
cm.set_ddns_config.assert_called_once() cm.set_ddns_token.assert_called_once_with('new_tok')
saved_ddns = cm.set_ddns_config.call_args[0][0]
self.assertEqual(saved_ddns['token'], 'new_tok')
# Subdomain saved to _identity.domain_name # Subdomain saved to _identity.domain_name
cm.set_identity_field.assert_called_once_with('domain_name', 'alpha.pic.ngo') cm.set_identity_field.assert_called_once_with('domain_name', 'alpha.pic.ngo')
+63 -30
View File
@@ -357,6 +357,8 @@ function Settings() {
const [ddnsStatus, setDdnsStatus] = useState(null); const [ddnsStatus, setDdnsStatus] = useState(null);
const [ddnsStatusLoading, setDdnsStatusLoading] = useState(false); const [ddnsStatusLoading, setDdnsStatusLoading] = useState(false);
const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining} const [certStatus, setCertStatus] = useState(null); // {status, expiry, days_remaining}
const [certAcquiring, setCertAcquiring] = useState(false);
const certPollRef = useRef(null);
// service configs // service configs
const [serviceConfigs, setServiceConfigs] = useState({}); const [serviceConfigs, setServiceConfigs] = useState({});
@@ -412,6 +414,7 @@ function Settings() {
}, []); }, []);
useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { loadAll(); }, [loadAll]);
useEffect(() => () => clearInterval(certPollRef.current), []);
useEffect(() => { useEffect(() => {
if (domainMode === 'pic_ngo') checkDdnsStatus(); if (domainMode === 'pic_ngo') checkDdnsStatus();
@@ -532,6 +535,26 @@ function Settings() {
} }
}, []); }, []);
const startCertPolling = useCallback(() => {
clearInterval(certPollRef.current);
setCertAcquiring(true);
let attempts = 0;
certPollRef.current = setInterval(async () => {
attempts++;
try {
const res = await caddyAPI.getCertStatus();
const status = res.data?.status;
if (status) setCertStatus(res.data);
if (status === 'valid' || attempts >= 20) {
clearInterval(certPollRef.current);
setCertAcquiring(false);
}
} catch {
// non-fatal
}
}, 15_000);
}, []);
const reRegister = useCallback(async () => { const reRegister = useCallback(async () => {
setDdnsRegistering(true); setDdnsRegistering(true);
try { try {
@@ -541,12 +564,14 @@ function Settings() {
setPicAvail(null); setPicAvail(null);
toast(`Registered as ${res.data.subdomain}`); toast(`Registered as ${res.data.subdomain}`);
checkDdnsStatus(); checkDdnsStatus();
// IDENTITY_CHANGED fires after registration → Caddy starts ACME; poll for cert
startCertPolling();
} catch (err) { } catch (err) {
toast(err.response?.data?.error || 'Registration failed', 'error'); toast(err.response?.data?.error || 'Registration failed', 'error');
} finally { } finally {
setDdnsRegistering(false); setDdnsRegistering(false);
} }
}, [checkDdnsStatus]); }, [checkDdnsStatus, startCertPolling]);
const verifyDuck = useCallback(async () => { const verifyDuck = useCallback(async () => {
if (!ddnsDuckToken.trim()) return; if (!ddnsDuckToken.trim()) return;
@@ -990,42 +1015,50 @@ function Settings() {
)} )}
{/* TLS Certificate Status */} {/* TLS Certificate Status */}
{certStatus && ( {(certStatus || certAcquiring) && (
<div className="mt-4 pt-4 border-t border-gray-100"> <div className="mt-4 pt-4 border-t border-gray-100">
<div className="text-sm font-medium text-gray-700 mb-2">TLS Certificate</div> <div className="text-sm font-medium text-gray-700 mb-2">TLS Certificate</div>
<div className="flex items-center gap-3"> {certAcquiring && (
{certStatus.status === 'valid' && ( <div className="flex items-center gap-2 text-sm text-purple-700 bg-purple-50 rounded px-3 py-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" /> <Loader className="h-4 w-4 animate-spin flex-shrink-0" />
)} Acquiring certificate from Let&apos;s Encrypt this takes up to 2 minutes&hellip;
{certStatus.status === 'expired' && ( </div>
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" /> )}
)} {certStatus && !certAcquiring && (
{(certStatus.status === 'unknown' || certStatus.status === 'internal') && ( <div className="flex items-center gap-3">
<Lock className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div className="text-sm text-gray-600">
{certStatus.status === 'valid' && ( {certStatus.status === 'valid' && (
<> <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Valid &mdash; expires{' '}
<span className="font-mono text-gray-800">
{new Date(certStatus.expiry).toLocaleDateString()}
</span>
{certStatus.days_remaining != null && (
<span className={`ml-2 font-medium ${certStatus.days_remaining < 14 ? 'text-amber-600' : 'text-green-700'}`}>
({certStatus.days_remaining}d remaining)
</span>
)}
</>
)} )}
{certStatus.status === 'expired' && ( {certStatus.status === 'expired' && (
<span className="text-red-600 font-medium"> <XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
Expired on {new Date(certStatus.expiry).toLocaleDateString()}
</span>
)} )}
{certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'} {(certStatus.status === 'unknown' || certStatus.status === 'internal') && (
{certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'} <Lock className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<div className="text-sm text-gray-600">
{certStatus.status === 'valid' && (
<>
Valid &mdash; expires{' '}
<span className="font-mono text-gray-800">
{new Date(certStatus.expiry).toLocaleDateString()}
</span>
{certStatus.days_remaining != null && (
<span className={`ml-2 font-medium ${certStatus.days_remaining < 14 ? 'text-amber-600' : 'text-green-700'}`}>
({certStatus.days_remaining}d remaining)
</span>
)}
</>
)}
{certStatus.status === 'expired' && (
<span className="text-red-600 font-medium">
Expired on {new Date(certStatus.expiry).toLocaleDateString()}
</span>
)}
{certStatus.status === 'internal' && 'Internal CA certificate (LAN mode)'}
{certStatus.status === 'unknown' && 'Certificate not yet issued or Caddy unreachable'}
</div>
</div> </div>
</div> )}
</div> </div>
)} )}
</div> </div>