- 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>
This commit is contained in:
+29
-22
@@ -17,6 +17,7 @@ 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
|
||||
@@ -68,13 +69,25 @@ class PicNgoDDNS(DDNSProvider):
|
||||
DEFAULT_API_BASE = 'https://ddns.pic.ngo'
|
||||
TIMEOUT = 10
|
||||
|
||||
def __init__(self, api_base_url: Optional[str] = None):
|
||||
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:
|
||||
@@ -95,7 +108,8 @@ class PicNgoDDNS(DDNSProvider):
|
||||
"""POST /api/v1/register — register subdomain, returns token + subdomain."""
|
||||
url = f'{self.api_base_url}/api/v1/register'
|
||||
payload = {'name': name, 'ip': ip}
|
||||
resp = requests.post(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT)
|
||||
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()
|
||||
|
||||
@@ -280,11 +294,9 @@ class DDNSManager(BaseServiceManager):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
identity = self._identity()
|
||||
domain_cfg = identity.get('domain', {})
|
||||
return {
|
||||
'service': 'ddns',
|
||||
'provider': domain_cfg.get('ddns', {}).get('provider') if domain_cfg else None,
|
||||
'provider': self._ddns_cfg().get('provider'),
|
||||
'last_ip': self._last_ip,
|
||||
'heartbeat_running': (
|
||||
self._heartbeat_thread is not None and
|
||||
@@ -310,17 +322,20 @@ class DDNSManager(BaseServiceManager):
|
||||
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."""
|
||||
identity = self._identity()
|
||||
domain_cfg = identity.get('domain', {})
|
||||
if not domain_cfg:
|
||||
if self.config_manager is None:
|
||||
return None
|
||||
ddns_cfg = domain_cfg.get('ddns', {})
|
||||
ddns_cfg = self.config_manager.configs.get('ddns', {})
|
||||
if not ddns_cfg:
|
||||
return None
|
||||
|
||||
@@ -330,7 +345,8 @@ class DDNSManager(BaseServiceManager):
|
||||
|
||||
if provider_name == 'pic_ngo':
|
||||
api_base = ddns_cfg.get('api_base_url')
|
||||
return PicNgoDDNS(api_base_url=api_base)
|
||||
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(
|
||||
@@ -405,10 +421,7 @@ class DDNSManager(BaseServiceManager):
|
||||
logger.debug("DDNS update_ip: IP unchanged (%s), skipping", current_ip)
|
||||
return
|
||||
|
||||
identity = self._identity()
|
||||
domain_cfg = identity.get('domain', {})
|
||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
||||
token = ddns_cfg.get('token', '')
|
||||
token = self._ddns_cfg().get('token', '')
|
||||
|
||||
try:
|
||||
success = provider.update(token, current_ip)
|
||||
@@ -468,10 +481,7 @@ class DDNSManager(BaseServiceManager):
|
||||
provider = self.get_provider()
|
||||
if provider is None:
|
||||
raise DDNSError("No DDNS provider configured")
|
||||
identity = self._identity()
|
||||
domain_cfg = identity.get('domain', {})
|
||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
||||
token = ddns_cfg.get('token', '')
|
||||
token = self._ddns_cfg().get('token', '')
|
||||
return provider.dns_challenge_create(token, fqdn, value)
|
||||
|
||||
def dns_challenge_delete(self, fqdn: str) -> bool:
|
||||
@@ -479,8 +489,5 @@ class DDNSManager(BaseServiceManager):
|
||||
provider = self.get_provider()
|
||||
if provider is None:
|
||||
raise DDNSError("No DDNS provider configured")
|
||||
identity = self._identity()
|
||||
domain_cfg = identity.get('domain', {})
|
||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
||||
token = ddns_cfg.get('token', '')
|
||||
token = self._ddns_cfg().get('token', '')
|
||||
return provider.dns_challenge_delete(token, fqdn)
|
||||
|
||||
Reference in New Issue
Block a user