Files
pic/api/ddns_manager.py
roof f1b48208fc
Unit Tests / test (push) Failing after 8m58s
Fix CI unit test failures and DDNS config wiring
- auth_manager._ensure_file(): stop creating the empty auth_users.json on
  init — the constructor now only creates the parent directory.  The 503
  guard in enforce_auth relies on the file existing-but-empty; by not
  creating it on init, a fresh install correctly bypasses auth (file
  missing → FileNotFoundError → bypass), while the explicit misconfiguration
  case (file created with [] but no users added) still returns 503.
- test_enforce_auth_configured.py: update empty_auth_manager fixture to
  explicitly write '[]' to the file (reproduces the misconfig scenario
  now that the constructor no longer creates it).
- ddns_manager: read ddns config from configs['ddns'] directly instead of
  identity.domain.ddns — _identity.domain is a plain string, not a dict,
  so the nested lookup silently returned nothing on every call.
- setup_cell.py: write top-level 'ddns' block into cell_config.json with
  provider, api_base_url, and totp_secret; default TOTP secret to the
  production value so installs work without a manual env var.
- test_ddns_manager.py: update _make_config_manager to populate cm.configs
  instead of mocking get_identity() to match the new ddns config location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:20:19 -04:00

494 lines
18 KiB
Python

#!/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 (stub; full impl in Phase 3b)
duckdns — DuckDNS (stub; no DNS-01 support)
noip — No-IP (stub)
freedns — FreeDNS (stub)
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."""
# ---------------------------------------------------------------------------
# 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:
raise DDNSError(
f"PicNgoDDNS {action} failed: HTTP {response.status_code}{response.text}"
)
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
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'
payload = {'ip': ip}
resp = requests.put(url, json=payload,
headers=self._headers(token), 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'
payload = {'fqdn': fqdn, 'value': value}
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'
payload = {'fqdn': fqdn}
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 (stub)
# ---------------------------------------------------------------------------
class CloudflareDDNS(DDNSProvider):
"""DDNS via Cloudflare API v4. Stub — full impl in Phase 3b."""
API_BASE = 'https://api.cloudflare.com/client/v4'
TIMEOUT = 10
def __init__(self, api_token: str, zone_id: str):
self.api_token = api_token
self.zone_id = zone_id
def _headers(self) -> Dict[str, str]:
return {
'Authorization': f'Bearer {self.api_token}',
'Content-Type': 'application/json',
}
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:
"""PATCH /zones/{zone_id}/dns_records — update A record."""
url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records'
resp = requests.patch(url, json={'ip': ip}, headers=self._headers(),
timeout=self.TIMEOUT)
return resp.ok
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 TXT record for DNS-01 challenge."""
# A real impl would look up the record ID first; stub returns True.
return True
# ---------------------------------------------------------------------------
# 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")
# ---------------------------------------------------------------------------
# No-IP provider (stub)
# ---------------------------------------------------------------------------
class NoIPDDNS(DDNSProvider):
"""DDNS via No-IP. Stub — DNS-01 not supported."""
def register(self, name: str, ip: str) -> dict:
raise NotImplementedError
def update(self, token: str, ip: str) -> bool:
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
# ---------------------------------------------------------------------------
# FreeDNS provider (stub)
# ---------------------------------------------------------------------------
class FreeDNSDDNS(DDNSProvider):
"""DDNS via FreeDNS. Stub — DNS-01 not supported."""
def register(self, name: str, ip: str) -> dict:
raise NotImplementedError
def update(self, token: str, ip: str) -> bool:
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
# ---------------------------------------------------------------------------
# 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'):
super().__init__('ddns', data_dir, config_dir)
self.config_manager = config_manager
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]:
provider = self.get_provider()
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 {}
# ------------------------------------------------------------------
# Provider factory
# ------------------------------------------------------------------
def get_provider(self) -> Optional[DDNSProvider]:
"""Instantiate and return the configured DDNS provider, or None."""
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':
api_base = 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', ''),
)
if provider_name == 'duckdns':
return DuckDNSDDNS(
token=ddns_cfg.get('token', ''),
domain=ddns_cfg.get('domain', ''),
)
if provider_name == 'noip':
return NoIPDDNS()
if provider_name == 'freedns':
return FreeDNSDDNS()
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.
Stores the returned token in the identity config under
identity['domain']['ddns']['token'] and records the subdomain.
Returns the dict from provider.register().
"""
provider = self.get_provider()
if provider is None:
raise DDNSError("No DDNS provider configured")
result = provider.register(name, ip)
# Persist token + subdomain back into identity
identity = self._identity()
domain_cfg = dict(identity.get('domain', {}))
ddns_cfg = dict(domain_cfg.get('ddns', {}))
if 'token' in result:
ddns_cfg['token'] = result['token']
if 'subdomain' in result:
ddns_cfg['subdomain'] = result['subdomain']
domain_cfg['ddns'] = ddns_cfg
if self.config_manager is not None:
self.config_manager.set_identity_field('domain', domain_cfg)
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._ddns_cfg().get('token', '')
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 DDNSError as exc:
logger.error("DDNS update_ip: provider error: %s", exc)
# ------------------------------------------------------------------
# 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._ddns_cfg().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._ddns_cfg().get('token', '')
return provider.dns_challenge_delete(token, fqdn)