#!/usr/bin/env python3 """ DDNS Manager for Personal Internet Cell. Provides a provider-agnostic adapter for Dynamic DNS services used to keep the cell's public IP registered under its chosen domain. Supported providers: pic_ngo — pic.ngo DDNS service (primary / Phase 3 wiring) cloudflare — Cloudflare API v4 duckdns — DuckDNS (no DNS-01 support) 'noip' and 'freedns' are NOT yet supported — get_provider() rejects them with a DDNSError so misconfiguration fails loudly instead of at update time. The manager runs a background heartbeat thread that re-publishes the public IP every 5 minutes, skipping the call when the IP has not changed. """ import logging import os import threading import time from typing import Any, Dict, Optional import requests from base_service_manager import BaseServiceManager logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Custom exception # --------------------------------------------------------------------------- class DDNSError(Exception): """Raised when a DDNS provider returns an error response.""" class DDNSTokenExpired(DDNSError): """Raised when the DDNS service rejects the token (401) — usually after a DB reset.""" # --------------------------------------------------------------------------- # Provider base class # --------------------------------------------------------------------------- class DDNSProvider: """Base class — all providers implement these methods.""" def register(self, name: str, ip: str) -> dict: """Register subdomain. Returns {'token': str, 'subdomain': str}.""" raise NotImplementedError def update(self, token: str, ip: str) -> bool: """Update A record. Returns True on success.""" raise NotImplementedError def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool: raise NotImplementedError def dns_challenge_delete(self, token: str, fqdn: str) -> bool: raise NotImplementedError # --------------------------------------------------------------------------- # pic.ngo provider # --------------------------------------------------------------------------- class PicNgoDDNS(DDNSProvider): """DDNS provider backed by the roof/pic-ddns API at ddns.pic.ngo.""" DEFAULT_API_BASE = 'https://ddns.pic.ngo' TIMEOUT = 10 def __init__(self, api_base_url: Optional[str] = None, totp_secret: Optional[str] = None): self.api_base_url = (api_base_url or self.DEFAULT_API_BASE).rstrip('/') self._totp_secret = totp_secret or '' # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _otp_header(self) -> Dict[str, str]: """Generate a fresh TOTP header for /register calls.""" if not self._totp_secret: return {} try: import pyotp return {'X-Register-OTP': pyotp.TOTP(self._totp_secret).now()} except ImportError: logger.warning("pyotp not installed — X-Register-OTP header omitted") return {} def _headers(self, token: Optional[str] = None) -> Dict[str, str]: h: Dict[str, str] = {'Content-Type': 'application/json'} if token: h['Authorization'] = f'Bearer {token}' return h def _raise_for_status(self, response: requests.Response, action: str): if not response.ok: if response.status_code == 401: raise DDNSTokenExpired( f"PicNgoDDNS {action} rejected token: HTTP 401 — {response.text}" ) raise DDNSError( f"PicNgoDDNS {action} failed: HTTP {response.status_code} — {response.text}" ) # ------------------------------------------------------------------ # Public interface # ------------------------------------------------------------------ def release(self, token: str) -> bool: """DELETE /api/v1/registration — release the subdomain owned by token.""" url = f'{self.api_base_url}/api/v1/registration' resp = requests.delete(url, json={'token': token}, headers=self._headers(), timeout=self.TIMEOUT) self._raise_for_status(resp, 'release') return True def register(self, name: str, ip: str) -> dict: """POST /api/v1/register — register subdomain, returns token + subdomain.""" url = f'{self.api_base_url}/api/v1/register' payload = {'name': name, 'ip': ip} headers = {**self._headers(), **self._otp_header()} resp = requests.post(url, json=payload, headers=headers, timeout=self.TIMEOUT) self._raise_for_status(resp, 'register') return resp.json() def update(self, token: str, ip: str) -> bool: """PUT /api/v1/update — update A record.""" url = f'{self.api_base_url}/api/v1/update' # DDNS server validates token from request body, not Authorization header payload = {'ip': ip, 'token': token} resp = requests.put(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) self._raise_for_status(resp, 'update') return True def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool: """POST /api/v1/dns-challenge — create DNS-01 TXT record.""" url = f'{self.api_base_url}/api/v1/dns-challenge' # DDNS server authenticates the token from the request body, not the header payload = {'fqdn': fqdn, 'value': value, 'token': token} resp = requests.post(url, json=payload, headers=self._headers(token), timeout=self.TIMEOUT) self._raise_for_status(resp, 'dns_challenge_create') return True def dns_challenge_delete(self, token: str, fqdn: str) -> bool: """DELETE /api/v1/dns-challenge — remove DNS-01 TXT record.""" url = f'{self.api_base_url}/api/v1/dns-challenge' # DDNS server authenticates the token from the request body, not the header payload = {'fqdn': fqdn, 'token': token} resp = requests.delete(url, json=payload, headers=self._headers(token), timeout=self.TIMEOUT) self._raise_for_status(resp, 'dns_challenge_delete') return True # --------------------------------------------------------------------------- # Cloudflare provider # --------------------------------------------------------------------------- class CloudflareDDNS(DDNSProvider): """DDNS via Cloudflare API v4.""" API_BASE = 'https://api.cloudflare.com/client/v4' TIMEOUT = 10 def __init__(self, api_token: str, zone_id: str, domain: str = ''): self.api_token = api_token self.zone_id = zone_id self.domain = domain def _headers(self) -> Dict[str, str]: return { 'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json', } def _find_record_ids(self, record_type: str, name: str) -> list: """Return the ids of DNS records matching type+name, or [] when none exist.""" url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records' resp = requests.get(url, params={'type': record_type, 'name': name}, headers=self._headers(), timeout=self.TIMEOUT) if not resp.ok: raise DDNSError( f"CloudflareDDNS record lookup failed: HTTP {resp.status_code} — {resp.text}" ) records = (resp.json() or {}).get('result') or [] return [r['id'] for r in records if r.get('id')] def register(self, name: str, ip: str) -> dict: # Cloudflare doesn't have a registration step — return stub data. return {'token': self.api_token, 'subdomain': name} def update(self, token: str, ip: str) -> bool: """Update the A record: look up its record id, then PATCH that record.""" if not self.domain: logger.error("CloudflareDDNS.update: no domain configured") return False try: record_ids = self._find_record_ids('A', self.domain) except DDNSError as exc: logger.error("CloudflareDDNS.update: %s", exc) return False if not record_ids: logger.error("CloudflareDDNS.update: no A record found for %s in zone %s", self.domain, self.zone_id) return False url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records/{record_ids[0]}' payload = {'type': 'A', 'name': self.domain, 'content': ip} resp = requests.patch(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) if not resp.ok: logger.error("CloudflareDDNS.update: PATCH failed: HTTP %s — %s", resp.status_code, resp.text) return False return True def _ensure_a_record(self, name: str, ip: str) -> bool: """Ensure a single A record name → ip exists: POST when missing, PATCH when present.""" try: record_ids = self._find_record_ids('A', name) except DDNSError as exc: logger.error("CloudflareDDNS.sync_service_records: lookup failed for %s: %s", name, exc) return False if record_ids: url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records/{record_ids[0]}' payload = {'type': 'A', 'name': name, 'content': ip} resp = requests.patch(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) else: url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records' payload = {'type': 'A', 'name': name, 'content': ip, 'ttl': 120} resp = requests.post(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) if not resp.ok: logger.error("CloudflareDDNS.sync_service_records: write failed for %s: HTTP %s — %s", name, resp.status_code, resp.text) return False return True def sync_service_records(self, subdomains, ip: str) -> dict: """Ensure the apex A record and one A record per service subdomain exist and point at ip. Creates missing records (POST) and updates existing ones (PATCH). Returns {'success': bool, 'synced': [...], 'failed': [...]}. subdomains is an iterable of fully-qualified record names (e.g. 'mail.cell.example.com'). The apex (self.domain) is always synced. """ if not self.domain: logger.error("CloudflareDDNS.sync_service_records: no domain configured") return {'success': False, 'synced': [], 'failed': []} names = [self.domain] for sub in subdomains or []: if sub and sub not in names: names.append(sub) synced = [] failed = [] for name in names: if self._ensure_a_record(name, ip): synced.append(name) else: failed.append(name) return {'success': not failed, 'synced': synced, 'failed': failed} def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool: """POST TXT record for DNS-01 challenge.""" url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records' payload = {'type': 'TXT', 'name': fqdn, 'content': value, 'ttl': 120} resp = requests.post(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT) return resp.ok def dns_challenge_delete(self, token: str, fqdn: str) -> bool: """Delete the DNS-01 TXT record(s): look up their ids, then DELETE each.""" try: record_ids = self._find_record_ids('TXT', fqdn) except DDNSError as exc: logger.error("CloudflareDDNS.dns_challenge_delete: %s", exc) return False if not record_ids: logger.warning("CloudflareDDNS.dns_challenge_delete: no TXT record found for %s", fqdn) return False all_ok = True for record_id in record_ids: url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records/{record_id}' resp = requests.delete(url, headers=self._headers(), timeout=self.TIMEOUT) if not resp.ok: logger.error("CloudflareDDNS.dns_challenge_delete: DELETE %s failed: HTTP %s — %s", record_id, resp.status_code, resp.text) all_ok = False return all_ok # --------------------------------------------------------------------------- # DuckDNS provider (stub) # --------------------------------------------------------------------------- class DuckDNSDDNS(DDNSProvider): """DDNS via DuckDNS. Stub — DNS-01 challenge not supported.""" UPDATE_URL = 'https://www.duckdns.org/update' TIMEOUT = 10 def __init__(self, token: str, domain: str): self._token = token self._domain = domain def register(self, name: str, ip: str) -> dict: return {'token': self._token, 'subdomain': name} def update(self, token: str, ip: str) -> bool: params = {'domains': self._domain, 'token': token, 'ip': ip} resp = requests.get(self.UPDATE_URL, params=params, timeout=self.TIMEOUT) return resp.ok and resp.text.strip() == 'OK' def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool: raise NotImplementedError("DuckDNS does not support programmatic TXT record creation") def dns_challenge_delete(self, token: str, fqdn: str) -> bool: raise NotImplementedError("DuckDNS does not support programmatic TXT record deletion") # --------------------------------------------------------------------------- # Public IP helper # --------------------------------------------------------------------------- def _get_public_ip() -> Optional[str]: """Return the current public IPv4 address using ipify, or None on failure.""" try: resp = requests.get('https://api.ipify.org', timeout=10) if resp.ok: return resp.text.strip() except Exception as exc: logger.warning("Could not determine public IP: %s", exc) return None # --------------------------------------------------------------------------- # Manager # --------------------------------------------------------------------------- _HEARTBEAT_INTERVAL = 300 # 5 minutes class DDNSManager(BaseServiceManager): """Manages DDNS registration and periodic IP updates.""" def __init__(self, config_manager=None, data_dir: str = '/app/data', config_dir: str = '/app/config', service_bus=None, service_registry=None): super().__init__('ddns', data_dir, config_dir) self.config_manager = config_manager self._service_bus = service_bus self._service_registry = service_registry self._last_ip: Optional[str] = None self._stop_event = threading.Event() self._heartbeat_thread: Optional[threading.Thread] = None # ------------------------------------------------------------------ # BaseServiceManager abstract method implementations # ------------------------------------------------------------------ def get_status(self) -> Dict[str, Any]: return { 'service': 'ddns', 'provider': self._ddns_cfg().get('provider'), 'last_ip': self._last_ip, 'heartbeat_running': ( self._heartbeat_thread is not None and self._heartbeat_thread.is_alive() ), } def test_connectivity(self) -> Dict[str, Any]: try: provider = self.get_provider() except DDNSError as exc: return {'success': False, 'reason': str(exc)} if provider is None: return {'success': False, 'reason': 'No DDNS provider configured'} ip = _get_public_ip() if ip is None: return {'success': False, 'reason': 'Could not reach ipify'} return {'success': True, 'public_ip': ip} # ------------------------------------------------------------------ # Identity helpers # ------------------------------------------------------------------ def _identity(self) -> Dict[str, Any]: if self.config_manager is None: return {} return self.config_manager.get_identity() or {} def _ddns_cfg(self) -> Dict[str, Any]: if self.config_manager is None: 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 # ------------------------------------------------------------------ def get_provider(self) -> Optional[DDNSProvider]: """Instantiate and return the configured DDNS provider, or None. Raises DDNSError when the configured provider is recognised but not yet supported ('noip', 'freedns'). """ if self.config_manager is None: return None ddns_cfg = self.config_manager.configs.get('ddns', {}) if not ddns_cfg: return None provider_name = ddns_cfg.get('provider') if not provider_name: return None if provider_name == 'pic_ngo': # Env var takes priority so deployments can switch URLs without re-registering _env_url = os.environ.get('DDNS_URL', '').replace('/api/v1', '').rstrip('/') api_base = _env_url or ddns_cfg.get('api_base_url') totp_secret = ddns_cfg.get('totp_secret') or os.environ.get('DDNS_TOTP_SECRET', '') return PicNgoDDNS(api_base_url=api_base, totp_secret=totp_secret) if provider_name == 'cloudflare': return CloudflareDDNS( api_token=ddns_cfg.get('api_token', ''), zone_id=ddns_cfg.get('zone_id', ''), domain=ddns_cfg.get('domain') or self._identity().get('domain_name', ''), ) if provider_name == 'duckdns': return DuckDNSDDNS( token=ddns_cfg.get('token', ''), domain=ddns_cfg.get('domain', ''), ) if provider_name in ('noip', 'freedns'): raise DDNSError( f"DDNS provider {provider_name!r} is not yet supported — " "use 'pic_ngo', 'cloudflare' or 'duckdns'" ) logger.warning("Unknown DDNS provider: %s", provider_name) return None # ------------------------------------------------------------------ # Registration # ------------------------------------------------------------------ def register(self, name: str, ip: str) -> dict: """Register the cell's subdomain with the configured provider. Fetches the public IP via ipify when ip is empty. Stores the returned token in the top-level ddns config (where update_ip reads it) and updates _identity.domain_name. Returns the dict from provider.register(). """ provider = self.get_provider() if provider is None: raise DDNSError("No DDNS provider configured") if not ip: ip = _get_public_ip() or '' # 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._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: try: provider.release(old_token) logger.info("DDNS released old subdomain %r before registering %r", old_name, name) except Exception as exc: logger.warning("DDNS could not release old subdomain %r: %s", old_name, exc) result = provider.register(name, ip) if self.config_manager is not None: # Token stored in data/api/ddns_token (not cell_config.json) if 'token' in result: 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']) self._last_ip = ip return result # ------------------------------------------------------------------ # IP update # ------------------------------------------------------------------ def update_ip(self): """Fetch current public IP and update DDNS only if it has changed.""" provider = self.get_provider() if provider is None: logger.debug("DDNS update_ip: no provider configured, skipping") return current_ip = _get_public_ip() if current_ip is None: logger.warning("DDNS update_ip: could not determine public IP") return if current_ip == self._last_ip: logger.debug("DDNS update_ip: IP unchanged (%s), skipping", current_ip) return 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) if success: logger.info("DDNS update_ip: updated to %s", current_ip) self._last_ip = current_ip else: logger.warning("DDNS update_ip: provider.update() returned False") except DDNSTokenExpired: logger.warning("DDNS update_ip: token rejected (401) — attempting re-registration") try: cell_name = self._identity().get('cell_name', '') if cell_name: 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: logger.error("DDNS update_ip: re-registration failed: %s", exc2) except DDNSError as exc: logger.error("DDNS update_ip: provider error: %s", exc) def sync_service_records(self) -> dict: """Sync per-service A records for providers that need explicit records (currently Cloudflare). Builds the subdomain list from the service registry via the effective domain and delegates to the provider. """ provider = self.get_provider() if provider is None: raise DDNSError("No DDNS provider configured") if not hasattr(provider, 'sync_service_records'): raise DDNSError( f"Provider {self._ddns_cfg().get('provider')!r} does not support " "per-service record sync" ) ip = _get_public_ip() if ip is None: raise DDNSError("Could not determine public IP") subdomains = self._service_record_names() result = provider.sync_service_records(subdomains, ip) if result.get('success'): self._last_ip = ip return result def _service_record_names(self) -> list: """Return fully-qualified A record names for each installed service subdomain.""" if self.config_manager is None: return [] try: effective_domain = self.config_manager.get_effective_domain() except Exception: return [] registry = getattr(self, '_service_registry', None) names = [] if registry is not None: try: for route in registry.get_caddy_routes(): subs = [route['subdomain']] + list(route.get('extra_subdomains') or []) for sub in subs: names.append(f'{sub}.{effective_domain}') except Exception as exc: logger.warning('_service_record_names: registry error: %s', exc) return names # ------------------------------------------------------------------ # Heartbeat # ------------------------------------------------------------------ def start_heartbeat(self): """Start a daemon thread that calls update_ip() every 5 minutes.""" if self._heartbeat_thread is not None and self._heartbeat_thread.is_alive(): logger.debug("DDNS heartbeat already running") return self._stop_event.clear() self._heartbeat_thread = threading.Thread( target=self._heartbeat_loop, name='ddns-heartbeat', daemon=True, ) self._heartbeat_thread.start() logger.info("DDNS heartbeat thread started (interval=%ds)", _HEARTBEAT_INTERVAL) def stop_heartbeat(self): """Signal the heartbeat thread to stop and wait for it to exit.""" self._stop_event.set() if self._heartbeat_thread is not None: self._heartbeat_thread.join(timeout=10) self._heartbeat_thread = None def _heartbeat_loop(self): """Internal: run update_ip() periodically until _stop_event is set.""" while not self._stop_event.is_set(): try: self.update_ip() except Exception as exc: logger.warning("DDNS heartbeat: unexpected error: %s", exc) # Sleep in short slices so stop_heartbeat() is responsive for _ in range(_HEARTBEAT_INTERVAL): if self._stop_event.is_set(): break time.sleep(1) # ------------------------------------------------------------------ # DNS challenge delegation # ------------------------------------------------------------------ def dns_challenge_create(self, fqdn: str, value: str) -> bool: """Create a DNS-01 TXT record via the configured provider.""" provider = self.get_provider() if provider is None: raise DDNSError("No DDNS provider configured") token = self._get_token() return provider.dns_challenge_create(token, fqdn, value) def dns_challenge_delete(self, fqdn: str) -> bool: """Delete a DNS-01 TXT record via the configured provider.""" provider = self.get_provider() if provider is None: raise DDNSError("No DDNS provider configured") token = self._get_token() return provider.dns_challenge_delete(token, fqdn)