fix: P0/P1 audit fixes — DDNS correctness, peer provisioning gates, honest stubs
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>
This commit is contained in:
+171
-64
@@ -7,10 +7,11 @@ cell's public IP registered under its chosen domain.
|
|||||||
|
|
||||||
Supported providers:
|
Supported providers:
|
||||||
pic_ngo — pic.ngo DDNS service (primary / Phase 3 wiring)
|
pic_ngo — pic.ngo DDNS service (primary / Phase 3 wiring)
|
||||||
cloudflare — Cloudflare API v4 (stub; full impl in Phase 3b)
|
cloudflare — Cloudflare API v4
|
||||||
duckdns — DuckDNS (stub; no DNS-01 support)
|
duckdns — DuckDNS (no DNS-01 support)
|
||||||
noip — No-IP (stub)
|
|
||||||
freedns — FreeDNS (stub)
|
'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
|
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.
|
every 5 minutes, skipping the call when the IP has not changed.
|
||||||
@@ -142,7 +143,8 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool:
|
def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool:
|
||||||
"""POST /api/v1/dns-challenge — create DNS-01 TXT record."""
|
"""POST /api/v1/dns-challenge — create DNS-01 TXT record."""
|
||||||
url = f'{self.api_base_url}/api/v1/dns-challenge'
|
url = f'{self.api_base_url}/api/v1/dns-challenge'
|
||||||
payload = {'fqdn': fqdn, 'value': value}
|
# 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,
|
resp = requests.post(url, json=payload,
|
||||||
headers=self._headers(token), timeout=self.TIMEOUT)
|
headers=self._headers(token), timeout=self.TIMEOUT)
|
||||||
self._raise_for_status(resp, 'dns_challenge_create')
|
self._raise_for_status(resp, 'dns_challenge_create')
|
||||||
@@ -151,7 +153,8 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
def dns_challenge_delete(self, token: str, fqdn: str) -> bool:
|
def dns_challenge_delete(self, token: str, fqdn: str) -> bool:
|
||||||
"""DELETE /api/v1/dns-challenge — remove DNS-01 TXT record."""
|
"""DELETE /api/v1/dns-challenge — remove DNS-01 TXT record."""
|
||||||
url = f'{self.api_base_url}/api/v1/dns-challenge'
|
url = f'{self.api_base_url}/api/v1/dns-challenge'
|
||||||
payload = {'fqdn': fqdn}
|
# DDNS server authenticates the token from the request body, not the header
|
||||||
|
payload = {'fqdn': fqdn, 'token': token}
|
||||||
resp = requests.delete(url, json=payload,
|
resp = requests.delete(url, json=payload,
|
||||||
headers=self._headers(token), timeout=self.TIMEOUT)
|
headers=self._headers(token), timeout=self.TIMEOUT)
|
||||||
self._raise_for_status(resp, 'dns_challenge_delete')
|
self._raise_for_status(resp, 'dns_challenge_delete')
|
||||||
@@ -159,18 +162,19 @@ class PicNgoDDNS(DDNSProvider):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cloudflare provider (stub)
|
# Cloudflare provider
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class CloudflareDDNS(DDNSProvider):
|
class CloudflareDDNS(DDNSProvider):
|
||||||
"""DDNS via Cloudflare API v4. Stub — full impl in Phase 3b."""
|
"""DDNS via Cloudflare API v4."""
|
||||||
|
|
||||||
API_BASE = 'https://api.cloudflare.com/client/v4'
|
API_BASE = 'https://api.cloudflare.com/client/v4'
|
||||||
TIMEOUT = 10
|
TIMEOUT = 10
|
||||||
|
|
||||||
def __init__(self, api_token: str, zone_id: str):
|
def __init__(self, api_token: str, zone_id: str, domain: str = ''):
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
self.zone_id = zone_id
|
self.zone_id = zone_id
|
||||||
|
self.domain = domain
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
return {
|
return {
|
||||||
@@ -178,16 +182,92 @@ class CloudflareDDNS(DDNSProvider):
|
|||||||
'Content-Type': 'application/json',
|
'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:
|
def register(self, name: str, ip: str) -> dict:
|
||||||
# Cloudflare doesn't have a registration step — return stub data.
|
# Cloudflare doesn't have a registration step — return stub data.
|
||||||
return {'token': self.api_token, 'subdomain': name}
|
return {'token': self.api_token, 'subdomain': name}
|
||||||
|
|
||||||
def update(self, token: str, ip: str) -> bool:
|
def update(self, token: str, ip: str) -> bool:
|
||||||
"""PATCH /zones/{zone_id}/dns_records — update A record."""
|
"""Update the A record: look up its record id, then PATCH that record."""
|
||||||
url = f'{self.API_BASE}/zones/{self.zone_id}/dns_records'
|
if not self.domain:
|
||||||
resp = requests.patch(url, json={'ip': ip}, headers=self._headers(),
|
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)
|
timeout=self.TIMEOUT)
|
||||||
return resp.ok
|
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:
|
def dns_challenge_create(self, token: str, fqdn: str, value: str) -> bool:
|
||||||
"""POST TXT record for DNS-01 challenge."""
|
"""POST TXT record for DNS-01 challenge."""
|
||||||
@@ -198,9 +278,24 @@ class CloudflareDDNS(DDNSProvider):
|
|||||||
return resp.ok
|
return resp.ok
|
||||||
|
|
||||||
def dns_challenge_delete(self, token: str, fqdn: str) -> bool:
|
def dns_challenge_delete(self, token: str, fqdn: str) -> bool:
|
||||||
"""DELETE TXT record for DNS-01 challenge."""
|
"""Delete the DNS-01 TXT record(s): look up their ids, then DELETE each."""
|
||||||
# A real impl would look up the record ID first; stub returns True.
|
try:
|
||||||
return True
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -232,46 +327,6 @@ class DuckDNSDDNS(DDNSProvider):
|
|||||||
raise NotImplementedError("DuckDNS does not support programmatic TXT record deletion")
|
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
|
# Public IP helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -300,10 +355,12 @@ class DDNSManager(BaseServiceManager):
|
|||||||
def __init__(self, config_manager=None,
|
def __init__(self, config_manager=None,
|
||||||
data_dir: str = '/app/data',
|
data_dir: str = '/app/data',
|
||||||
config_dir: str = '/app/config',
|
config_dir: str = '/app/config',
|
||||||
service_bus=None):
|
service_bus=None,
|
||||||
|
service_registry=None):
|
||||||
super().__init__('ddns', data_dir, config_dir)
|
super().__init__('ddns', data_dir, config_dir)
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self._service_bus = service_bus
|
self._service_bus = service_bus
|
||||||
|
self._service_registry = service_registry
|
||||||
self._last_ip: Optional[str] = None
|
self._last_ip: Optional[str] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||||
@@ -324,7 +381,10 @@ class DDNSManager(BaseServiceManager):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def test_connectivity(self) -> Dict[str, Any]:
|
def test_connectivity(self) -> Dict[str, Any]:
|
||||||
provider = self.get_provider()
|
try:
|
||||||
|
provider = self.get_provider()
|
||||||
|
except DDNSError as exc:
|
||||||
|
return {'success': False, 'reason': str(exc)}
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return {'success': False, 'reason': 'No DDNS provider configured'}
|
return {'success': False, 'reason': 'No DDNS provider configured'}
|
||||||
ip = _get_public_ip()
|
ip = _get_public_ip()
|
||||||
@@ -372,7 +432,11 @@ class DDNSManager(BaseServiceManager):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Raises DDNSError when the configured provider is recognised but not
|
||||||
|
yet supported ('noip', 'freedns').
|
||||||
|
"""
|
||||||
if self.config_manager is None:
|
if self.config_manager is None:
|
||||||
return None
|
return None
|
||||||
ddns_cfg = self.config_manager.configs.get('ddns', {})
|
ddns_cfg = self.config_manager.configs.get('ddns', {})
|
||||||
@@ -394,6 +458,7 @@ class DDNSManager(BaseServiceManager):
|
|||||||
return CloudflareDDNS(
|
return CloudflareDDNS(
|
||||||
api_token=ddns_cfg.get('api_token', ''),
|
api_token=ddns_cfg.get('api_token', ''),
|
||||||
zone_id=ddns_cfg.get('zone_id', ''),
|
zone_id=ddns_cfg.get('zone_id', ''),
|
||||||
|
domain=ddns_cfg.get('domain') or self._identity().get('domain_name', ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
if provider_name == 'duckdns':
|
if provider_name == 'duckdns':
|
||||||
@@ -402,11 +467,11 @@ class DDNSManager(BaseServiceManager):
|
|||||||
domain=ddns_cfg.get('domain', ''),
|
domain=ddns_cfg.get('domain', ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
if provider_name == 'noip':
|
if provider_name in ('noip', 'freedns'):
|
||||||
return NoIPDDNS()
|
raise DDNSError(
|
||||||
|
f"DDNS provider {provider_name!r} is not yet supported — "
|
||||||
if provider_name == 'freedns':
|
"use 'pic_ngo', 'cloudflare' or 'duckdns'"
|
||||||
return FreeDNSDDNS()
|
)
|
||||||
|
|
||||||
logger.warning("Unknown DDNS provider: %s", provider_name)
|
logger.warning("Unknown DDNS provider: %s", provider_name)
|
||||||
return None
|
return None
|
||||||
@@ -524,6 +589,48 @@ class DDNSManager(BaseServiceManager):
|
|||||||
except DDNSError as exc:
|
except DDNSError as exc:
|
||||||
logger.error("DDNS update_ip: provider error: %s", 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
|
# Heartbeat
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from constants import RESERVED_SUBDOMAINS
|
||||||
|
|
||||||
logger = logging.getLogger('picell')
|
logger = logging.getLogger('picell')
|
||||||
|
|
||||||
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||||
@@ -21,12 +23,6 @@ _CAP_DENYLIST = frozenset({
|
|||||||
'ALL', 'SYS_ADMIN', 'SYS_MODULE', 'SYS_PTRACE', 'SYS_RAWIO',
|
'ALL', 'SYS_ADMIN', 'SYS_MODULE', 'SYS_PTRACE', 'SYS_RAWIO',
|
||||||
'SYS_BOOT', 'MAC_ADMIN', 'MAC_OVERRIDE', 'SYS_TIME', 'SYS_TTY_CONFIG',
|
'SYS_BOOT', 'MAC_ADMIN', 'MAC_OVERRIDE', 'SYS_TIME', 'SYS_TTY_CONFIG',
|
||||||
})
|
})
|
||||||
_RESERVED_SUBDOMAINS = frozenset({
|
|
||||||
# Core PIC infrastructure — never allow store services to hijack these
|
|
||||||
'api', 'webui', 'admin', 'www', 'ns1', 'ns2', 'git', 'registry', 'install',
|
|
||||||
# 'mail', 'calendar', 'files', 'webdav', 'webmail' are intentionally absent:
|
|
||||||
# they belong to official PIC store services and must be claimable by them.
|
|
||||||
})
|
|
||||||
_BACKEND_DENYLIST = frozenset({
|
_BACKEND_DENYLIST = frozenset({
|
||||||
'cell-api', 'cell-caddy', 'cell-coredns', 'cell-dnsmasq',
|
'cell-api', 'cell-caddy', 'cell-coredns', 'cell-dnsmasq',
|
||||||
'cell-wireguard', 'cell-vault', 'localhost', '127.0.0.1',
|
'cell-wireguard', 'cell-vault', 'localhost', '127.0.0.1',
|
||||||
@@ -172,9 +168,11 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None,
|
|||||||
|
|
||||||
allow_host_network: when True, the compose file is permitted to use
|
allow_host_network: when True, the compose file is permitted to use
|
||||||
network_mode: host and devices: — required for connectivity services
|
network_mode: host and devices: — required for connectivity services
|
||||||
(wireguard-ext, openvpn-client, tor) that must share the host network
|
(wireguard-ext, openvpn-client, tor, sshuttle [cell-sshuttle],
|
||||||
namespace to create tun/wg interfaces. The external-network requirement
|
proxy [cell-redsocks]) that must share the host network namespace to
|
||||||
is also waived since host-network containers reach the cell network directly.
|
create tun/wg interfaces or expose local transparent-proxy listeners.
|
||||||
|
The external-network requirement is also waived since host-network
|
||||||
|
containers reach the cell network directly.
|
||||||
"""
|
"""
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -330,7 +328,7 @@ def _check_subdomain(value, field_name: str, errors: list) -> None:
|
|||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
errors.append(f'{field_name} must be a string')
|
errors.append(f'{field_name} must be a string')
|
||||||
return
|
return
|
||||||
if value in _RESERVED_SUBDOMAINS:
|
if value in RESERVED_SUBDOMAINS:
|
||||||
errors.append(f'{field_name} is reserved: {value!r}')
|
errors.append(f'{field_name} is reserved: {value!r}')
|
||||||
elif not _SUBDOMAIN_RE.match(value):
|
elif not _SUBDOMAIN_RE.match(value):
|
||||||
errors.append(
|
errors.append(
|
||||||
|
|||||||
@@ -660,6 +660,22 @@ def ddns_register():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/ddns/sync', methods=['POST'])
|
||||||
|
def ddns_sync_records():
|
||||||
|
"""Sync per-service public DNS records (Cloudflare provider)."""
|
||||||
|
try:
|
||||||
|
from app import ddns_manager
|
||||||
|
from ddns_manager import DDNSError
|
||||||
|
try:
|
||||||
|
result = ddns_manager.sync_service_records()
|
||||||
|
except DDNSError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 400
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in /api/ddns/sync: %s', e)
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/config/pending', methods=['GET'])
|
@bp.route('/api/config/pending', methods=['GET'])
|
||||||
def get_pending_config():
|
def get_pending_config():
|
||||||
from app import config_manager
|
from app import config_manager
|
||||||
|
|||||||
@@ -83,11 +83,16 @@ def add_peer():
|
|||||||
|
|
||||||
provisioned = ['auth']
|
provisioned = ['auth']
|
||||||
domain = _configured_domain()
|
domain = _configured_domain()
|
||||||
|
# Only provision accounts on services that are actually installed —
|
||||||
|
# email/calendar/files are optional store services.
|
||||||
for step_name, step_fn in [
|
for step_name, step_fn in [
|
||||||
('email', lambda: email_manager.create_email_user(peer_name, domain, password)),
|
('email', lambda: email_manager.create_email_user(peer_name, domain, password)),
|
||||||
('calendar', lambda: calendar_manager.create_calendar_user(peer_name, password)),
|
('calendar', lambda: calendar_manager.create_calendar_user(peer_name, password)),
|
||||||
('files', lambda: file_manager.create_user(peer_name, password)),
|
('files', lambda: file_manager.create_user(peer_name, password)),
|
||||||
]:
|
]:
|
||||||
|
if step_name not in _installed:
|
||||||
|
logger.debug(f"Peer {peer_name}: {step_name} not installed — skipping account provisioning")
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if step_fn():
|
if step_fn():
|
||||||
provisioned.append(step_name)
|
provisioned.append(step_name)
|
||||||
|
|||||||
Reference in New Issue
Block a user