cc7a223fdf
CloudflareDDNS.update() was calling the wrong endpoint; fix to use the correct zone-records API so DDNS updates actually land. NoIP and FreeDNS providers now return explicit "not implemented" errors instead of silently claiming success, preventing false-positive health state. PicNgoDNS ACME dns-challenge now sends the token in the request body (was missing), so cert issuance no longer silently fails. add_peer gates builtin-service provisioning on the installed-services list so a freshly-provisioned peer does not attempt to configure services that aren't present, eliminating the startup error loop. Startup Caddyfile regeneration added to routes/config.py so that a stale on-disk Caddyfile no longer triggers the health-monitor restart loop after a config change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
692 lines
29 KiB
Python
692 lines
29 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
|
|
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)
|