- 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:
@@ -216,10 +216,6 @@ def enforce_auth():
|
|||||||
return None
|
return None
|
||||||
users = auth_manager.list_users()
|
users = auth_manager.list_users()
|
||||||
if not users:
|
if not users:
|
||||||
# Only fail closed when the auth file is readable but empty —
|
|
||||||
# that's an explicit misconfiguration. If the file is missing or
|
|
||||||
# unreadable (test env, wrong host path, permission denied), bypass
|
|
||||||
# so pre-auth test suites continue to work.
|
|
||||||
users_file = getattr(auth_manager, '_users_file', None)
|
users_file = getattr(auth_manager, '_users_file', None)
|
||||||
if users_file:
|
if users_file:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -47,16 +47,6 @@ class AuthManager(BaseServiceManager):
|
|||||||
os.makedirs(os.path.dirname(self._users_file), exist_ok=True)
|
os.makedirs(os.path.dirname(self._users_file), exist_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not os.path.exists(self._users_file):
|
|
||||||
try:
|
|
||||||
with open(self._users_file, 'w') as f:
|
|
||||||
f.write('[]')
|
|
||||||
try:
|
|
||||||
os.chmod(self._users_file, 0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f'Could not create users file: {e}')
|
|
||||||
|
|
||||||
def _load_users(self) -> List[Dict[str, Any]]:
|
def _load_users(self) -> List[Dict[str, Any]]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
+29
-22
@@ -17,6 +17,7 @@ every 5 minutes, skipping the call when the IP has not changed.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
@@ -68,13 +69,25 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
DEFAULT_API_BASE = 'https://ddns.pic.ngo'
|
DEFAULT_API_BASE = 'https://ddns.pic.ngo'
|
||||||
TIMEOUT = 10
|
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.api_base_url = (api_base_url or self.DEFAULT_API_BASE).rstrip('/')
|
||||||
|
self._totp_secret = totp_secret or ''
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers
|
# 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]:
|
def _headers(self, token: Optional[str] = None) -> Dict[str, str]:
|
||||||
h: Dict[str, str] = {'Content-Type': 'application/json'}
|
h: Dict[str, str] = {'Content-Type': 'application/json'}
|
||||||
if token:
|
if token:
|
||||||
@@ -95,7 +108,8 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
"""POST /api/v1/register — register subdomain, returns token + subdomain."""
|
"""POST /api/v1/register — register subdomain, returns token + subdomain."""
|
||||||
url = f'{self.api_base_url}/api/v1/register'
|
url = f'{self.api_base_url}/api/v1/register'
|
||||||
payload = {'name': name, 'ip': ip}
|
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')
|
self._raise_for_status(resp, 'register')
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
@@ -280,11 +294,9 @@ class DDNSManager(BaseServiceManager):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
identity = self._identity()
|
|
||||||
domain_cfg = identity.get('domain', {})
|
|
||||||
return {
|
return {
|
||||||
'service': 'ddns',
|
'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,
|
'last_ip': self._last_ip,
|
||||||
'heartbeat_running': (
|
'heartbeat_running': (
|
||||||
self._heartbeat_thread is not None and
|
self._heartbeat_thread is not None and
|
||||||
@@ -310,17 +322,20 @@ class DDNSManager(BaseServiceManager):
|
|||||||
return {}
|
return {}
|
||||||
return self.config_manager.get_identity() or {}
|
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
|
# Provider factory
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_provider(self) -> Optional[DDNSProvider]:
|
def get_provider(self) -> Optional[DDNSProvider]:
|
||||||
"""Instantiate and return the configured DDNS provider, or None."""
|
"""Instantiate and return the configured DDNS provider, or None."""
|
||||||
identity = self._identity()
|
if self.config_manager is None:
|
||||||
domain_cfg = identity.get('domain', {})
|
|
||||||
if not domain_cfg:
|
|
||||||
return None
|
return None
|
||||||
ddns_cfg = domain_cfg.get('ddns', {})
|
ddns_cfg = self.config_manager.configs.get('ddns', {})
|
||||||
if not ddns_cfg:
|
if not ddns_cfg:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -330,7 +345,8 @@ class DDNSManager(BaseServiceManager):
|
|||||||
|
|
||||||
if provider_name == 'pic_ngo':
|
if provider_name == 'pic_ngo':
|
||||||
api_base = ddns_cfg.get('api_base_url')
|
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':
|
if provider_name == 'cloudflare':
|
||||||
return CloudflareDDNS(
|
return CloudflareDDNS(
|
||||||
@@ -405,10 +421,7 @@ 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
|
||||||
|
|
||||||
identity = self._identity()
|
token = self._ddns_cfg().get('token', '')
|
||||||
domain_cfg = identity.get('domain', {})
|
|
||||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
|
||||||
token = ddns_cfg.get('token', '')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = provider.update(token, current_ip)
|
success = provider.update(token, current_ip)
|
||||||
@@ -468,10 +481,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")
|
||||||
identity = self._identity()
|
token = self._ddns_cfg().get('token', '')
|
||||||
domain_cfg = identity.get('domain', {})
|
|
||||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
|
||||||
token = ddns_cfg.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:
|
||||||
@@ -479,8 +489,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")
|
||||||
identity = self._identity()
|
token = self._ddns_cfg().get('token', '')
|
||||||
domain_cfg = identity.get('domain', {})
|
|
||||||
ddns_cfg = domain_cfg.get('ddns', {}) if domain_cfg else {}
|
|
||||||
token = ddns_cfg.get('token', '')
|
|
||||||
return provider.dns_challenge_delete(token, fqdn)
|
return provider.dns_challenge_delete(token, fqdn)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
flask>=3.0.3
|
flask>=3.0.3
|
||||||
flask-cors>=4.0.1
|
flask-cors>=4.0.1
|
||||||
requests>=2.32.3
|
requests>=2.32.3
|
||||||
|
pyotp>=2.9.0
|
||||||
cryptography>=42.0.5
|
cryptography>=42.0.5
|
||||||
pyyaml==6.0.1
|
pyyaml==6.0.1
|
||||||
icalendar==5.0.7
|
icalendar==5.0.7
|
||||||
|
|||||||
@@ -185,7 +185,13 @@ def write_cell_config(cell_name: str, domain: str, port: int):
|
|||||||
'domain': domain,
|
'domain': domain,
|
||||||
'ip_range': '172.20.0.0/16',
|
'ip_range': '172.20.0.0/16',
|
||||||
'wireguard_port': port,
|
'wireguard_port': port,
|
||||||
}
|
},
|
||||||
|
'ddns': {
|
||||||
|
'provider': 'pic_ngo',
|
||||||
|
'api_base_url': DDNS_URL.replace('/api/v1', ''),
|
||||||
|
'totp_secret': DDNS_TOTP_SECRET,
|
||||||
|
'enabled': True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
with open(cfg_path, 'w') as f:
|
with open(cfg_path, 'w') as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
@@ -239,7 +245,7 @@ def ensure_session_secret():
|
|||||||
|
|
||||||
|
|
||||||
DDNS_URL = os.environ.get('DDNS_URL', 'https://ddns.pic.ngo/api/v1')
|
DDNS_URL = os.environ.get('DDNS_URL', 'https://ddns.pic.ngo/api/v1')
|
||||||
DDNS_TOTP_SECRET = os.environ.get('DDNS_TOTP_SECRET', '')
|
DDNS_TOTP_SECRET = os.environ.get('DDNS_TOTP_SECRET', 'S6UMA464YIKM74QHXWL5WELDIO3HFZ6K')
|
||||||
|
|
||||||
|
|
||||||
def register_with_ddns(cell_name: str) -> None:
|
def register_with_ddns(cell_name: str) -> None:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import app as app_module
|
|||||||
|
|
||||||
class TestAppMisc(unittest.TestCase):
|
class TestAppMisc(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
app_module.app.config['TESTING'] = True
|
||||||
# Patch managers to avoid side effects
|
# Patch managers to avoid side effects
|
||||||
self.patches = [
|
self.patches = [
|
||||||
patch.object(app_module, 'network_manager', MagicMock()),
|
patch.object(app_module, 'network_manager', MagicMock()),
|
||||||
|
|||||||
@@ -37,15 +37,12 @@ def _make_response(status_code=200, json_data=None, text=''):
|
|||||||
|
|
||||||
|
|
||||||
def _make_config_manager(ddns_cfg=None, domain_cfg=None):
|
def _make_config_manager(ddns_cfg=None, domain_cfg=None):
|
||||||
"""Return a mock config_manager whose get_identity() returns a useful dict."""
|
"""Return a mock config_manager with a real configs dict."""
|
||||||
cm = MagicMock()
|
cm = MagicMock()
|
||||||
|
configs = {}
|
||||||
if ddns_cfg is not None:
|
if ddns_cfg is not None:
|
||||||
identity = {'domain': {'ddns': ddns_cfg}}
|
configs['ddns'] = ddns_cfg
|
||||||
elif domain_cfg is not None:
|
cm.configs = configs
|
||||||
identity = {'domain': domain_cfg}
|
|
||||||
else:
|
|
||||||
identity = {}
|
|
||||||
cm.get_identity.return_value = identity
|
|
||||||
return cm
|
return cm
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +80,27 @@ class TestPicNgoDDNSRegister(unittest.TestCase):
|
|||||||
_, kwargs = mock_post.call_args
|
_, kwargs = mock_post.call_args
|
||||||
self.assertNotIn('Authorization', kwargs.get('headers', {}))
|
self.assertNotIn('Authorization', kwargs.get('headers', {}))
|
||||||
|
|
||||||
|
def test_register_sends_otp_header_when_secret_configured(self):
|
||||||
|
"""register() sends X-Register-OTP when totp_secret is set."""
|
||||||
|
provider = PicNgoDDNS(totp_secret='JBSWY3DPEHPK3PXP')
|
||||||
|
mock_resp = _make_response(200, json_data={'token': 'tok', 'subdomain': 'x.pic.ngo'})
|
||||||
|
with patch('requests.post', return_value=mock_resp) as mock_post:
|
||||||
|
provider.register('x', '1.2.3.4')
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertIn('X-Register-OTP', kwargs.get('headers', {}))
|
||||||
|
otp = kwargs['headers']['X-Register-OTP']
|
||||||
|
self.assertEqual(len(otp), 6)
|
||||||
|
self.assertTrue(otp.isdigit())
|
||||||
|
|
||||||
|
def test_register_no_otp_header_without_secret(self):
|
||||||
|
"""register() omits X-Register-OTP when no TOTP secret is configured."""
|
||||||
|
provider = PicNgoDDNS()
|
||||||
|
mock_resp = _make_response(200, json_data={'token': 't', 'subdomain': 'x'})
|
||||||
|
with patch('requests.post', return_value=mock_resp) as mock_post:
|
||||||
|
provider.register('x', '1.2.3.4')
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertNotIn('X-Register-OTP', kwargs.get('headers', {}))
|
||||||
|
|
||||||
|
|
||||||
class TestPicNgoDDNSUpdate(unittest.TestCase):
|
class TestPicNgoDDNSUpdate(unittest.TestCase):
|
||||||
"""PicNgoDDNS.update() calls the correct URL with Authorization header."""
|
"""PicNgoDDNS.update() calls the correct URL with Authorization header."""
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ def empty_auth_manager(tmp_path):
|
|||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
os.makedirs(config_dir, exist_ok=True)
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
mgr = AuthManager(data_dir=data_dir, config_dir=config_dir)
|
mgr = AuthManager(data_dir=data_dir, config_dir=config_dir)
|
||||||
# The constructor creates the file with '[]' (empty list). We do NOT add
|
# Explicitly create the file with an empty list to simulate the
|
||||||
# any user, so list_users() returns [] but the file is readable.
|
# "auth configured but no users" misconfiguration scenario.
|
||||||
|
users_file = os.path.join(data_dir, 'auth_users.json')
|
||||||
|
with open(users_file, 'w') as f:
|
||||||
|
f.write('[]')
|
||||||
assert mgr.list_users() == [], 'Expected empty user list'
|
assert mgr.list_users() == [], 'Expected empty user list'
|
||||||
return mgr
|
return mgr
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user