feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
Unit Tests / test (push) Failing after 11s

Previously, CaddyManager and NetworkManager contained hardcoded lists of
service names (calendar, files, mail, webdav, etc.), meaning every new
service required a code change to appear in Caddy routes and DNS records.
Now both managers accept a service_registry parameter and derive their
service lists dynamically from the registry at runtime.

- CaddyManager: new _build_registry_service_routes() and
  _http01_service_pairs() methods pull routes from the registry
- NetworkManager: new _get_service_subdomains() method returns registry
  subdomains with a hardcoded fallback when no registry is wired in;
  _build_dns_records, stale-record detection, and service name sets all
  use the registry
- managers.py: service_registry constructed before network_manager so it
  can be injected into both CaddyManager and NetworkManager
- service_registry.py: validation chokepoint in get_caddy_routes() rejects
  invalid subdomain/backend values and reserved service names
- service_store_manager.py: _validate_manifest now validates top-level
  subdomain, backend, extra_subdomains, and extra_backends fields
- tests: 24 new tests covering registry-driven routing and DNS subdomain
  generation (test_caddy_registry_integration.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:27:52 -04:00
parent 63c0dfb9d9
commit 16fb362df7
12 changed files with 1312 additions and 46 deletions
+3
View File
@@ -44,6 +44,9 @@ from managers import (
caddy_manager,
ddns_manager, service_store_manager,
connectivity_manager,
service_registry,
service_composer,
account_manager,
firewall_manager, EventType,
)
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
+97 -13
View File
@@ -52,11 +52,13 @@ class CaddyManager(BaseServiceManager):
def __init__(self, config_manager=None,
data_dir: str = '/app/data',
config_dir: str = '/app/config',
service_bus=None):
service_bus=None,
service_registry=None):
super().__init__('caddy', data_dir, config_dir)
self.config_manager = config_manager
self.container_name = 'cell-caddy'
self.caddyfile_path = LIVE_CADDYFILE
self._service_registry = service_registry
# Consecutive health-check failure counter (reset on success or when
# the caller restarts the container).
self._health_failures = 0
@@ -187,6 +189,69 @@ class CaddyManager(BaseServiceManager):
f" }}"
)
def _build_registry_service_routes(self, domain: str) -> str:
"""Build named-matcher + handle blocks from the service registry.
Falls back to the hardcoded ``_build_core_service_routes`` when no
registry is wired or the registry returns nothing, so the method is
always safe to call even in tests that don't supply a registry.
"""
routes: List[Dict] = []
if self._service_registry is not None:
try:
routes = self._service_registry.get_caddy_routes()
except Exception as exc:
logger.warning('_build_registry_service_routes: registry error: %s', exc)
if not routes:
return self._build_core_service_routes(domain)
# Pre-seed with reserved names so no registry entry can squat them.
seen_matchers: set = {'api', 'webui'}
blocks: List[str] = []
for route in routes:
primary_sub = route['subdomain']
backend = route['backend']
extra_subs: List[str] = route.get('extra_subdomains') or []
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
if primary_sub in seen_matchers:
logger.warning('Caddy: skipping duplicate/reserved matcher %r', primary_sub)
continue
seen_matchers.add(primary_sub)
# Subdomains that share the primary backend go in one matcher block.
shared = [primary_sub] + [s for s in extra_subs if s not in extra_backends]
host_list = ' '.join(f'{s}.{domain}' for s in shared)
blocks.append(
f' @{primary_sub} host {host_list}\n'
f' handle @{primary_sub} {{\n'
f' reverse_proxy {backend}\n'
f' }}'
)
# Extra subdomains with their own backends each get their own block.
for sub, sub_backend in extra_backends.items():
if sub in seen_matchers:
logger.warning('Caddy: skipping duplicate/reserved matcher %r', sub)
continue
seen_matchers.add(sub)
blocks.append(
f' @{sub} host {sub}.{domain}\n'
f' handle @{sub} {{\n'
f' reverse_proxy {sub_backend}\n'
f' }}'
)
# The api subdomain is always infrastructure — not delegated to the registry.
blocks.append(
f' @api host api.{domain}\n'
f' handle @api {{\n'
f' reverse_proxy cell-api:3000\n'
f' }}'
)
return '\n'.join(blocks)
@staticmethod
def _indent_routes(routes: str, spaces: int = 4) -> str:
"""Indent a multi-line route block by ``spaces`` columns."""
@@ -230,7 +295,7 @@ class CaddyManager(BaseServiceManager):
service_routes: str, core_routes: str) -> str:
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
domain = f"{cell_name}.pic.ngo"
body = [self._build_core_service_routes(domain)]
body = [self._build_registry_service_routes(domain)]
if service_routes:
body.append(self._indent_routes(service_routes))
body.append(core_routes)
@@ -253,7 +318,7 @@ class CaddyManager(BaseServiceManager):
def _caddyfile_cloudflare(self, custom_domain: str,
service_routes: str, core_routes: str) -> str:
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin."""
body = [self._build_core_service_routes(custom_domain)]
body = [self._build_registry_service_routes(custom_domain)]
if service_routes:
body.append(self._indent_routes(service_routes))
body.append(core_routes)
@@ -273,7 +338,7 @@ class CaddyManager(BaseServiceManager):
service_routes: str, core_routes: str) -> str:
"""duckdns mode: DNS-01 via the duckdns plugin."""
domain = f"{cell_name}.duckdns.org"
body = [self._build_core_service_routes(domain)]
body = [self._build_registry_service_routes(domain)]
if service_routes:
body.append(self._indent_routes(service_routes))
body.append(core_routes)
@@ -299,15 +364,8 @@ class CaddyManager(BaseServiceManager):
out.append(core_routes)
out.append("}")
# One block per core service subdomain.
_core_services = [
('calendar', 'cell-radicale:5232'),
('mail', 'cell-rainloop:8888'),
('webmail', 'cell-rainloop:8888'),
('files', 'cell-filegator:8080'),
('webdav', 'cell-webdav:80'),
('api', 'cell-api:3000'),
]
# Build (subdomain, backend) pairs from registry when available.
_core_services = self._http01_service_pairs()
for subdomain, backend in _core_services:
out.append("")
out.append(f"{subdomain}.{host} {{")
@@ -330,6 +388,32 @@ class CaddyManager(BaseServiceManager):
out.append("}")
return "\n".join(out) + "\n"
def _http01_service_pairs(self) -> List[tuple]:
"""Return (subdomain, backend) pairs for http01 per-host blocks."""
pairs: List[tuple] = []
if self._service_registry is not None:
try:
for route in self._service_registry.get_caddy_routes():
pairs.append((route['subdomain'], route['backend']))
extra_subs: List[str] = route.get('extra_subdomains') or []
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
for sub in extra_subs:
backend = extra_backends.get(sub, route['backend'])
pairs.append((sub, backend))
except Exception as exc:
logger.warning('_http01_service_pairs: registry error: %s', exc)
pairs = []
if not pairs:
pairs = [
('calendar', 'cell-radicale:5232'),
('mail', 'cell-rainloop:8888'),
('webmail', 'cell-rainloop:8888'),
('files', 'cell-filegator:8080'),
('webdav', 'cell-webdav:80'),
]
pairs.append(('api', 'cell-api:3000'))
return pairs
# ── filesystem + admin-API operations ─────────────────────────────────
def write_caddyfile(self, caddyfile_content: str) -> bool:
+138 -3
View File
@@ -6,6 +6,8 @@ Centralized configuration management for all services
import os
import json
import re
import subprocess
import yaml
import shutil
import hashlib
@@ -14,6 +16,9 @@ from typing import Dict, List, Optional, Any
from pathlib import Path
import logging
_SAFE_CONTAINER_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$')
_SAFE_VOL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{1,64}$')
# The Caddyfile lives on a separate volume mount from the rest of config
LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile')
@@ -228,8 +233,128 @@ class ConfigManager:
"warnings": warnings
}
def backup_config(self) -> str:
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, and DNS zones."""
@staticmethod
def _validate_vol_entry(service_id: str, vol: dict) -> bool:
"""Return True if a backup volume entry is safe to use; log and return False otherwise."""
container = vol.get('container', '')
path = vol.get('path', '')
name = vol.get('name', '')
if not _SAFE_CONTAINER_RE.match(container):
logger.warning('Backup: unsafe container name %r for %s — skipping', container, service_id)
return False
if not path.startswith('/') or '..' in path.split('/') or '\x00' in path:
logger.warning('Backup: unsafe volume path %r for %s — skipping', path, service_id)
return False
if not _SAFE_VOL_NAME_RE.match(name):
logger.warning('Backup: unsafe volume name %r for %s — skipping', name, service_id)
return False
return True
def _backup_service_volumes(self, backup_path: Path, service_registry) -> None:
"""Stream service data out of each container via 'docker exec tar'.
Archives are relative (created with -C <path> .) so they can be safely
restored with -C <path> without risk of path traversal outside the volume.
Writes to a .partial temp file then renames atomically on success.
"""
try:
plan = service_registry.get_backup_plan()
except Exception as e:
logger.warning('_backup_service_volumes: could not get backup plan: %s', e)
return
for entry in plan:
service_id = entry['service_id']
volumes = entry.get('volumes') or []
if not volumes:
continue
svc_dir = backup_path / 'service_data' / service_id
svc_dir.mkdir(parents=True, exist_ok=True)
for vol in volumes:
if not self._validate_vol_entry(service_id, vol):
continue
container = vol['container']
path = vol['path']
name = vol['name']
archive_path = svc_dir / f'{name}.tar.gz'
tmp_path = svc_dir / f'{name}.tar.gz.partial'
try:
with open(tmp_path, 'wb') as af:
result = subprocess.run(
# -C path; then '.' archives the whole dir with relative entries.
# '--' prevents path/container from being parsed as options.
['docker', 'exec', '--', container,
'tar', '-C', path, '-czf', '-', '.'],
stdout=af,
stderr=subprocess.PIPE,
timeout=300,
)
if result.returncode != 0:
logger.warning(
'Backup: docker exec tar failed for %s/%s: %s',
service_id, name, result.stderr.decode(errors='replace'),
)
tmp_path.unlink(missing_ok=True)
else:
os.replace(tmp_path, archive_path)
logger.info('Backup: archived %s/%s', service_id, name)
except subprocess.TimeoutExpired:
logger.warning('Backup: timed out streaming %s/%s', service_id, name)
tmp_path.unlink(missing_ok=True)
except Exception as e:
logger.warning('Backup: failed to archive %s/%s: %s', service_id, name, e)
tmp_path.unlink(missing_ok=True)
def _restore_service_volumes(self, backup_path: Path, service_registry) -> None:
"""Pipe archived service data back into containers via 'docker exec -i tar'.
Extracts with -C <path>, matching how archives were created (relative paths).
This bounds extraction to within the declared volume directory.
"""
svc_data_dir = backup_path / 'service_data'
if not svc_data_dir.is_dir():
return
for svc_dir in svc_data_dir.iterdir():
if not svc_dir.is_dir():
continue
service_id = svc_dir.name
svc = service_registry.get(service_id)
if not svc:
logger.warning('Restore: unknown service %s in backup, skipping', service_id)
continue
volumes = (svc.get('backup') or {}).get('volumes') or []
for vol in volumes:
if not self._validate_vol_entry(service_id, vol):
continue
container = vol['container']
path = vol['path']
name = vol['name']
archive_path = svc_dir / f'{name}.tar.gz'
if not archive_path.exists():
continue
try:
with open(archive_path, 'rb') as af:
result = subprocess.run(
['docker', 'exec', '-i', '--', container,
'tar', '-C', path, '-xzf', '-'],
stdin=af,
stderr=subprocess.PIPE,
timeout=300,
)
if result.returncode != 0:
logger.warning(
'Restore: docker exec tar failed for %s/%s: %s',
service_id, name, result.stderr.decode(errors='replace'),
)
else:
logger.info('Restore: restored %s/%s', service_id, name)
except subprocess.TimeoutExpired:
logger.warning('Restore: timed out restoring %s/%s', service_id, name)
except Exception as e:
logger.warning('Restore: failed to restore %s/%s: %s', service_id, name, e)
def backup_config(self, service_registry=None) -> str:
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, DNS zones,
and (when service_registry is provided) live service data volumes."""
try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_id = f"backup_{timestamp}"
@@ -278,12 +403,17 @@ class ConfigManager:
except (PermissionError, OSError) as e:
logger.warning(f"Could not back up {src.name}: {e} (skipping)")
# Live service data volumes (streamed via docker exec)
if service_registry is not None:
self._backup_service_volumes(backup_path, service_registry)
services = ['identity'] + list(self.service_schemas.keys())
manifest = {
"backup_id": backup_id,
"timestamp": datetime.now().isoformat(),
"services": services,
"files": [f.name for f in backup_path.iterdir()],
"includes_service_data": service_registry is not None,
}
with open(backup_path / 'manifest.json', 'w') as f:
json.dump(manifest, f, indent=2)
@@ -295,7 +425,8 @@ class ConfigManager:
logger.error(f"Error creating backup: {e}")
raise
def restore_config(self, backup_id: str, services: list = None) -> bool:
def restore_config(self, backup_id: str, services: list = None,
service_registry=None) -> bool:
"""Restore from backup. If services list given, only restore those service configs (selective)."""
try:
backup_path = self.backup_dir / backup_id
@@ -373,6 +504,10 @@ class ConfigManager:
except (PermissionError, OSError) as e:
logger.warning(f"Could not restore {dest.name}: {e} (skipping)")
# Live service data volumes
if service_registry is not None:
self._restore_service_volumes(backup_path, service_registry)
self.configs = self._load_all_configs()
logger.info(f"Restored configuration from backup: {backup_id}")
return True
+22 -2
View File
@@ -31,6 +31,9 @@ from setup_manager import SetupManager
from caddy_manager import CaddyManager
from ddns_manager import DDNSManager
from connectivity_manager import ConnectivityManager
from service_registry import ServiceRegistry
from service_composer import ServiceComposer
from account_manager import AccountManager
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
@@ -42,7 +45,13 @@ config_manager = ConfigManager(
service_bus = ServiceBus()
log_manager = LogManager(log_dir='./data/logs')
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
# ServiceRegistry depends only on config_manager; create it early so
# NetworkManager and CaddyManager can derive subdomains from manifests
# instead of hardcoding service names.
service_registry = ServiceRegistry(config_manager=config_manager)
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_registry=service_registry)
wireguard_manager = WireGuardManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
peer_registry = PeerRegistry(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
email_manager = EmailManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
@@ -58,7 +67,8 @@ cell_link_manager = CellLinkManager(
)
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_bus=service_bus, service_registry=service_registry)
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
connectivity_manager = ConnectivityManager(
config_manager=config_manager,
@@ -67,6 +77,15 @@ connectivity_manager = ConnectivityManager(
config_dir=CONFIG_DIR,
)
service_composer = ServiceComposer(config_manager=config_manager, data_dir=DATA_DIR)
account_manager = AccountManager(
service_registry=service_registry,
data_dir=DATA_DIR,
email_manager=email_manager,
calendar_manager=calendar_manager,
file_manager=file_manager,
)
from service_store_manager import ServiceStoreManager
service_store_manager = ServiceStoreManager(
config_manager=config_manager,
@@ -110,6 +129,7 @@ __all__ = [
'routing_manager', 'vault_manager', 'container_manager',
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
'ddns_manager', 'service_store_manager', 'connectivity_manager',
'service_registry', 'service_composer', 'account_manager',
'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR',
]
+31 -12
View File
@@ -18,10 +18,12 @@ logger = logging.getLogger(__name__)
class NetworkManager(BaseServiceManager):
"""Manages network services (DNS, DHCP, NTP)"""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
service_registry=None):
super().__init__('network', data_dir, config_dir)
self.dns_zones_dir = os.path.join(data_dir, 'dns')
self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases')
self._service_registry = service_registry
# Ensure directories exist
self.safe_makedirs(self.dns_zones_dir)
@@ -201,7 +203,7 @@ class NetworkManager(BaseServiceManager):
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
# bootstrap service records like 'api', 'calendar' etc. would pollute the
# zone display and shadow the public domain. Remove them.
_stale = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
_stale = {'api', 'webui'} | set(self._get_service_subdomains())
if effective_domain.endswith('.' + primary_domain):
existing = self._load_dns_records(primary_domain)
cleaned = [r for r in existing if r.get('name', '') not in _stale]
@@ -249,6 +251,25 @@ class NetworkManager(BaseServiceManager):
pass
return '10.0.0.1'
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
def _get_service_subdomains(self) -> List[str]:
"""Return all service subdomains from the registry, or a hardcoded fallback."""
registry = getattr(self, "_service_registry", None)
if registry is not None:
try:
subs: List[str] = []
for route in registry.get_caddy_routes():
for sub in [route['subdomain']] + list(route.get('extra_subdomains') or []):
if self._SUBDOMAIN_RE.match(sub):
subs.append(sub)
else:
logger.warning('_get_service_subdomains: skipping invalid subdomain %r', sub)
return subs
except Exception as exc:
logger.warning('_get_service_subdomains: registry error: %s', exc)
return ['calendar', 'files', 'mail', 'webmail', 'webdav']
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records.
@@ -258,16 +279,14 @@ class NetworkManager(BaseServiceManager):
routes requests to the correct backend by Host header.
"""
wg_ip = self._get_wg_server_ip()
return [
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
{'name': 'calendar', 'type': 'A', 'value': wg_ip},
{'name': 'files', 'type': 'A', 'value': wg_ip},
{'name': 'mail', 'type': 'A', 'value': wg_ip},
{'name': 'webmail', 'type': 'A', 'value': wg_ip},
{'name': 'webdav', 'type': 'A', 'value': wg_ip},
records = [
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
]
for sub in self._get_service_subdomains():
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
return records
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
"""Get all DNS records across all zones"""
@@ -595,7 +614,7 @@ class NetworkManager(BaseServiceManager):
if not new_name:
return {'restarted': restarted, 'warnings': warnings}
# Exclude service names, wildcard, and apex from cell-hostname detection.
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
_service_names = {'api', 'webui'} | set(self._get_service_subdomains())
_reserved = _service_names | {'@', '*'}
changed = False
try:
+9 -4
View File
@@ -784,8 +784,8 @@ def apply_pending_config():
@bp.route('/api/config/backup', methods=['POST'])
def create_config_backup():
try:
from app import config_manager, service_bus, EventType
backup_id = config_manager.backup_config()
from app import config_manager, service_bus, service_registry, EventType
backup_id = config_manager.backup_config(service_registry=service_registry)
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
'backup_id': backup_id,
'timestamp': datetime.utcnow().isoformat()
@@ -809,9 +809,14 @@ def list_config_backups():
@bp.route('/api/config/restore/<backup_id>', methods=['POST'])
def restore_config(backup_id):
try:
from app import config_manager, service_bus, EventType
from app import config_manager, service_bus, service_registry, EventType
data = request.get_json(silent=True) or {}
success = config_manager.restore_config(backup_id, services=data.get('services'))
services = data.get('services')
success = config_manager.restore_config(
backup_id,
services=services,
service_registry=service_registry if services is None else None,
)
if success:
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
'backup_id': backup_id,
+45
View File
@@ -125,6 +125,17 @@ def add_peer():
return jsonify({"error": f"Peer {peer_name} already exists"}), 400
peer_added_to_registry = True
# Store credentials only after the peer is committed — avoids orphaned
# credential entries if peer_registry.add_peer rejects a duplicate name.
try:
from app import account_manager
_svc_names = {'email', 'calendar', 'files'}
for svc in provisioned:
if svc in _svc_names:
account_manager.store_credentials(svc, peer_name, {'password': password})
except Exception as _am_err:
logger.warning(f"Peer {peer_name}: credential storage failed (non-fatal): {_am_err}")
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info,
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
firewall_applied = True
@@ -320,12 +331,46 @@ def remove_peer(peer_name):
_cleanup()
except Exception:
pass
try:
from app import account_manager
account_manager.deprovision_peer(peer_name)
except Exception as _am_err:
logger.warning(f"Peer {peer_name}: account_manager cleanup failed (non-fatal): {_am_err}")
return jsonify({"message": f"Peer {peer_name} removed successfully"})
except Exception as e:
logger.error(f"Error removing peer: {e}")
return jsonify({"error": str(e)}), 500
@bp.route('/api/peers/<peer_name>/service-credentials', methods=['GET'])
def get_peer_service_credentials(peer_name: str):
"""Return service credentials for a peer across all provisioned services (admin only).
Returns filled peer_config_template values for each service the peer is provisioned on.
Intended for an admin to view or copy credentials to share with the peer during
device setup. The global enforce_auth gate already restricts this to admin sessions.
Phase 2 note: a peer-self-service variant should live at /api/peer/service-credentials
(no path arg) and restrict to session['username'] to prevent cross-peer enumeration.
"""
try:
from app import peer_registry, account_manager, service_registry, config_manager
peer = peer_registry.get_peer(peer_name)
if not peer:
return jsonify({'error': f'Peer {peer_name!r} not found'}), 404
raw_creds = account_manager.get_all_credentials(peer_name)
identity = config_manager.get_identity()
domain = config_manager.get_effective_domain() or identity.get('domain', '')
result = {}
for service_id, cred in raw_creds.items():
svc_info = service_registry.get_peer_service_info(service_id, peer_name, domain, cred)
result[service_id] = svc_info if svc_info is not None else cred
return jsonify({'peer': peer_name, 'services': result})
except Exception as e:
logger.error('get_peer_service_credentials(%s): %s', peer_name, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/peers/register', methods=['POST'])
def register_peer():
try:
+168
View File
@@ -6,6 +6,174 @@ from flask import Blueprint, request, jsonify
logger = logging.getLogger('picell')
bp = Blueprint('services', __name__)
@bp.route('/api/services/catalog', methods=['GET'])
def get_services_catalog():
"""
Return all services (builtins + installed store packages) with merged config.
Used by the frontend to build navigation and service pages dynamically.
"""
try:
from app import service_registry
return jsonify({'services': service_registry.list_all()})
except Exception as e:
logger.error('get_services_catalog: %s', e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>', methods=['GET'])
def get_service_catalog_entry(service_id: str):
"""Return a single service manifest+config, or 404 if unknown."""
try:
from app import service_registry
svc = service_registry.get(service_id)
if svc is None:
return jsonify({'error': f'Service {service_id!r} not found'}), 404
return jsonify(svc)
except Exception as e:
logger.error('get_service_catalog_entry(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/status', methods=['GET'])
def get_service_container_status(service_id: str):
"""
Return container status for a service.
Builtins query the main compose stack; store services query their own compose project.
"""
try:
from app import service_registry, service_composer
svc = service_registry.get(service_id)
if svc is None:
return jsonify({'error': f'Service {service_id!r} not found'}), 404
result = service_composer.status_service(service_id, svc)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error('get_service_container_status(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/restart', methods=['POST'])
def restart_service_containers(service_id: str):
"""
Restart containers for a service.
Builtins restart via the main compose stack; store services via their own compose project.
"""
try:
from app import service_registry, service_composer
svc = service_registry.get(service_id)
if svc is None:
return jsonify({'error': f'Service {service_id!r} not found'}), 404
result = service_composer.restart_service(service_id, svc)
if result['ok']:
return jsonify({'message': f'Service {service_id!r} restarted', **result})
return jsonify({'error': result.get('stderr') or result.get('error', 'restart failed')}), 500
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error('restart_service_containers(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/reconfigure', methods=['POST'])
def reconfigure_service(service_id: str):
"""
Re-apply the stored compose file for a store service (rolling `up -d`).
The compose template must already exist on disk from the original install
accepting templates from the request body is deliberately not supported
(arbitrary compose files can mount host paths or request privileged mode).
"""
try:
from app import service_registry, service_composer
svc = service_registry.get(service_id)
if svc is None:
return jsonify({'error': f'Service {service_id!r} not found'}), 404
if svc.get('kind') == 'builtin':
return jsonify({'error': 'Builtins are reconfigured via their settings routes'}), 400
if not service_composer.has_compose_file(service_id):
return jsonify({'error': f'No compose file for {service_id!r} — install it first'}), 400
result = service_composer.up(service_id)
if result['ok']:
return jsonify({'message': f'Service {service_id!r} reconfigured', **result})
return jsonify({'error': result.get('stderr') or result.get('error', 'reconfigure failed')}), 500
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error('reconfigure_service(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['GET'])
def list_service_accounts(service_id: str):
"""Return peer usernames provisioned on a service."""
try:
from app import account_manager
accounts = account_manager.list_accounts(service_id)
return jsonify({'service_id': service_id, 'accounts': accounts})
except Exception as e:
logger.error('list_service_accounts(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['POST'])
def provision_service_account(service_id: str):
"""Provision a peer account on a service. Generates a password if none is given.
The generated or provided password is NOT echoed in this response retrieve it
separately via GET /api/services/catalog/<id>/accounts/<username>/credentials.
This keeps passwords out of HTTP logs and browser network panels.
"""
try:
from app import account_manager
data = request.get_json(silent=True) or {}
peer_username = data.get('username')
if not peer_username:
return jsonify({'error': 'username is required'}), 400
account_manager.provision(service_id, peer_username,
password=data.get('password'))
return jsonify({'service_id': service_id, 'username': peer_username,
'provisioned': True}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except RuntimeError as e:
return jsonify({'error': str(e)}), 500
except Exception as e:
logger.error('provision_service_account(%s): %s', service_id, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/accounts/<username>', methods=['DELETE'])
def deprovision_service_account(service_id: str, username: str):
"""Remove a peer's account from a service."""
try:
from app import account_manager
ok = account_manager.deprovision(service_id, username)
if ok:
return jsonify({'message': f'{username!r} deprovisioned from {service_id!r}'})
return jsonify({'error': 'deprovision failed'}), 500
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error('deprovision_service_account(%s, %s): %s', service_id, username, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/catalog/<service_id>/accounts/<username>/credentials', methods=['GET'])
def get_service_account_credentials(service_id: str, username: str):
"""Return stored credentials for a peer on a service."""
try:
from app import account_manager
creds = account_manager.get_credentials(service_id, username)
if creds is None:
return jsonify({'error': f'{username!r} not provisioned on {service_id!r}'}), 404
return jsonify({'service_id': service_id, 'username': username, **creds})
except Exception as e:
logger.error('get_service_account_credentials(%s, %s): %s', service_id, username, e)
return jsonify({'error': str(e)}), 500
@bp.route('/api/services/bus/status', methods=['GET'])
def get_service_bus_status():
try:
+218
View File
@@ -0,0 +1,218 @@
"""
ServiceRegistry single source of truth for all PIC services.
Merges three layers:
1. Manifest defaults (config_schema.*.default)
2. Admin-saved config from ConfigManager (cell_config.json)
3. Runtime state from installed store records
All consumers (CaddyManager, backup, peer services endpoint) read from here
rather than hardcoding service names or subdomains.
"""
import json
import logging
import os
import re
from typing import Dict, List, Optional
from urllib.parse import quote as _urlquote
logger = logging.getLogger('picell')
# Built-ins are baked into the container image at build time.
# Do not bind-mount this path read-write in docker-compose.
_BUILTINS_DIR = os.path.join(os.path.dirname(__file__), 'services', 'builtins')
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
_BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
_RESERVED_SUBS = frozenset({'api', 'webui', 'admin', 'www', 'ns1', 'ns2', 'git', 'registry', 'install'})
class ServiceRegistry:
def __init__(self, config_manager):
self._cm = config_manager
# ── Manifest loading ──────────────────────────────────────────────────
def _load_manifest(self, path: str) -> Optional[Dict]:
try:
with open(path) as f:
return json.load(f)
except Exception as e:
logger.warning('ServiceRegistry: failed to load manifest %s: %s', path, e)
return None
def _builtin_ids(self) -> List[str]:
if not os.path.isdir(_BUILTINS_DIR):
return []
return sorted(
d for d in os.listdir(_BUILTINS_DIR)
if os.path.isfile(os.path.join(_BUILTINS_DIR, d, 'manifest.json'))
)
def _builtin_manifest(self, service_id: str) -> Optional[Dict]:
return self._load_manifest(
os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
)
# ── Config merging ────────────────────────────────────────────────────
_TYPE_COERCIONS = {'integer': int, 'string': str, 'boolean': bool}
def _merged_config(self, manifest: Dict) -> Dict:
"""Return manifest defaults overridden by admin-saved values, type-coerced."""
svc_id = manifest.get('id', '')
saved = self._cm.configs.get(svc_id, {})
schema = manifest.get('config_schema') or {}
merged = {k: v['default'] for k, v in schema.items() if 'default' in v}
for k, spec in schema.items():
if k not in saved:
continue
raw = saved[k]
coerce = self._TYPE_COERCIONS.get(spec.get('type', ''))
if coerce is not None:
try:
raw = coerce(raw)
except (TypeError, ValueError):
raw = merged.get(k, raw)
merged[k] = raw
return merged
# ── Public API ────────────────────────────────────────────────────────
def get(self, service_id: str) -> Optional[Dict]:
"""Return manifest + merged config for one service, or None if unknown."""
manifest = self._builtin_manifest(service_id)
if manifest is None:
record = self._cm.get_installed_services().get(service_id)
if record:
manifest = record.get('manifest')
if not manifest:
return None
return {**manifest, 'config': self._merged_config(manifest)}
def list_all(self) -> List[Dict]:
"""
Return all services builtins first, then installed store services
each with merged config attached as the 'config' key.
"""
results: List[Dict] = []
seen: set = set()
for svc_id in self._builtin_ids():
manifest = self._builtin_manifest(svc_id)
if manifest:
results.append({**manifest, 'config': self._merged_config(manifest)})
seen.add(svc_id)
for svc_id, record in self._cm.get_installed_services().items():
if svc_id in seen:
continue
manifest = record.get('manifest') or {}
if manifest.get('id'):
results.append({**manifest, 'config': self._merged_config(manifest)})
return results
def get_caddy_routes(self) -> List[Dict]:
"""
Return routing info for all services that have a subdomain.
Used by CaddyManager to build service blocks without hardcoding.
Values are validated here as a chokepoint so Caddyfile/DNS builders
can safely interpolate them regardless of how manifests reached disk.
"""
routes = []
for svc in self.list_all():
caps = svc.get('capabilities') or {}
if not caps.get('has_subdomain'):
continue
sub = svc.get('subdomain', '')
bknd = svc.get('backend', '')
if not sub or not bknd:
continue
svc_id = svc.get('id', '?')
if not _SUBDOMAIN_RE.match(sub) or sub in _RESERVED_SUBS:
logger.warning('ServiceRegistry: skipping %s — invalid/reserved subdomain %r', svc_id, sub)
continue
if not _BACKEND_RE.match(bknd):
logger.warning('ServiceRegistry: skipping %s — invalid backend %r', svc_id, bknd)
continue
extra_subs = [
s for s in (svc.get('extra_subdomains') or [])
if isinstance(s, str) and _SUBDOMAIN_RE.match(s) and s not in _RESERVED_SUBS
]
extra_backends = {
k: v for k, v in (svc.get('extra_backends') or {}).items()
if (isinstance(k, str) and _SUBDOMAIN_RE.match(k) and k not in _RESERVED_SUBS
and isinstance(v, str) and _BACKEND_RE.match(v))
}
routes.append({
'service_id': svc_id,
'subdomain': sub,
'backend': bknd,
'extra_subdomains': extra_subs,
'extra_backends': extra_backends,
})
return routes
def get_backup_plan(self) -> List[Dict]:
"""
Return backup declarations for all services that have storage.
Used by the backup system instead of hardcoded file lists.
Each entry:
service_id service identifier
volumes list of {container, path, name} for docker-exec streaming
config_paths host-relative paths copied directly (config files)
"""
plan = []
for svc in self.list_all():
caps = svc.get('capabilities') or {}
if not caps.get('has_storage'):
continue
backup = svc.get('backup') or {}
volumes = backup.get('volumes') or []
config_paths = backup.get('config_paths') or []
if not volumes and not config_paths:
continue
plan.append({
'service_id': svc['id'],
'volumes': volumes,
'config_paths': config_paths,
})
return plan
def get_peer_service_info(self, service_id: str, peer_username: str,
domain: str, credentials: Dict) -> Optional[Dict]:
"""
Fill peer_config_template for one service+peer combination.
credentials: dict of {field_name: value} for that peer+service.
Returns None if service unknown or has no peer template.
"""
svc = self.get(service_id)
if not svc:
return None
template = svc.get('peer_config_template')
if not template:
return None
# URL-safe peer username (safe='') — prevents path traversal in CalDAV/WebDAV URLs
safe_username = _urlquote(peer_username, safe='')
result = {}
for key, raw in template.items():
val = raw
val = val.replace('{domain}', domain)
val = val.replace('{peer.username}', safe_username)
for field, cred_val in credentials.items():
val = val.replace(
'{peer.service_credentials.' + service_id + '.' + field + '}',
str(cred_val) if cred_val is not None else '',
)
cfg = svc.get('config') or {}
for cfg_key, cfg_val in cfg.items():
val = val.replace('{config.' + cfg_key + '}', str(cfg_val) if cfg_val is not None else '')
result[key] = val
return result
+46 -8
View File
@@ -51,6 +51,8 @@ RESERVED_SUBDOMAINS = frozenset([
'git', 'registry', 'install',
])
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
# ---------------------------------------------------------------------------
@@ -141,19 +143,55 @@ class ServiceStoreManager(BaseServiceManager):
f'iptables_rules[].proto must be tcp or udp, got: {proto}'
)
# Caddy route subdomain
# Legacy caddy_route dict subdomain (for store manifests using the old format)
caddy_route = m.get('caddy_route') or {}
if isinstance(caddy_route, dict):
subdomain = caddy_route.get('subdomain', '')
legacy_sub = caddy_route.get('subdomain', '')
else:
subdomain = ''
if subdomain:
if subdomain in RESERVED_SUBDOMAINS:
errors.append(f'caddy_route.subdomain is reserved: {subdomain}')
elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain):
legacy_sub = ''
if legacy_sub:
if legacy_sub in RESERVED_SUBDOMAINS:
errors.append(f'caddy_route.subdomain is reserved: {legacy_sub}')
elif not SUBDOMAIN_RE.match(legacy_sub):
errors.append(
f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, '
f'got: {subdomain}'
f'got: {legacy_sub}'
)
# Top-level subdomain + backend (consumed by ServiceRegistry.get_caddy_routes)
subdomain = m.get('subdomain', '')
if subdomain:
if subdomain in RESERVED_SUBDOMAINS:
errors.append(f'subdomain is reserved: {subdomain}')
elif not SUBDOMAIN_RE.match(subdomain):
errors.append(
f'subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, got: {subdomain}'
)
backend = m.get('backend', '')
if backend and not BACKEND_RE.match(backend):
errors.append(f'backend must be host:port (e.g. cell-foo:8080), got: {backend}')
for sub in m.get('extra_subdomains') or []:
if not isinstance(sub, str):
errors.append('extra_subdomains entries must be strings')
elif sub in RESERVED_SUBDOMAINS:
errors.append(f'extra_subdomains entry is reserved: {sub}')
elif not SUBDOMAIN_RE.match(sub):
errors.append(
f'extra_subdomains entry must match ^[a-z][a-z0-9-]{{0,30}}$, got: {sub}'
)
for sub, bknd in (m.get('extra_backends') or {}).items():
if not isinstance(sub, str) or not SUBDOMAIN_RE.match(sub):
errors.append(
f'extra_backends key must match ^[a-z][a-z0-9-]{{0,30}}$, got: {sub!r}'
)
elif sub in RESERVED_SUBDOMAINS:
errors.append(f'extra_backends key is reserved: {sub}')
if not isinstance(bknd, str) or not BACKEND_RE.match(bknd):
errors.append(
f'extra_backends[{sub!r}] value must be host:port, got: {bknd!r}'
)
# Env value safety
+528
View File
@@ -0,0 +1,528 @@
"""Integration tests for registry-driven CaddyManager and NetworkManager routing.
These tests cover the new registry path introduced in Step 5 of the PIC Services
Architecture. The no-registry (fallback) paths are already covered by
test_caddy_manager.py and test_network_manager.py.
"""
import os
import sys
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
from caddy_manager import CaddyManager # noqa: E402
from network_manager import NetworkManager # noqa: E402
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _mgr_with_registry(registry=None):
"""Build a CaddyManager wired to an optional mock registry."""
cm = MagicMock()
cm.get_identity.return_value = {}
return CaddyManager(config_manager=cm, service_registry=registry)
def _mock_registry():
"""Return a mock ServiceRegistry that reproduces the 3 builtin service routes."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'calendar',
'subdomain': 'calendar',
'backend': 'cell-radicale:5232',
'extra_subdomains': [],
'extra_backends': {},
},
{
'service_id': 'email',
'subdomain': 'mail',
'backend': 'cell-rainloop:8888',
'extra_subdomains': ['webmail'],
'extra_backends': {},
},
{
'service_id': 'files',
'subdomain': 'files',
'backend': 'cell-filegator:8080',
'extra_subdomains': ['webdav'],
'extra_backends': {'webdav': 'cell-webdav:80'},
},
]
return reg
def _nm(registry=None):
"""Build a NetworkManager backed by temp dirs and an optional mock registry."""
tmpdir = tempfile.mkdtemp()
nm = NetworkManager(
data_dir=os.path.join(tmpdir, 'data'),
config_dir=os.path.join(tmpdir, 'config'),
service_registry=registry,
)
nm._tmpdir = tmpdir # stash so the caller can clean up
return nm
# ---------------------------------------------------------------------------
# TestBuildRegistryServiceRoutes
# ---------------------------------------------------------------------------
class TestBuildRegistryServiceRoutes(unittest.TestCase):
def test_returns_hardcoded_when_no_registry(self):
"""service_registry=None produces the same output as _build_core_service_routes."""
mgr = _mgr_with_registry(registry=None)
domain = 'alpha.pic.ngo'
result = mgr._build_registry_service_routes(domain)
expected = CaddyManager._build_core_service_routes(domain)
self.assertEqual(result, expected)
def test_returns_hardcoded_when_registry_empty(self):
"""An empty route list from the registry falls back to hardcoded."""
reg = MagicMock()
reg.get_caddy_routes.return_value = []
mgr = _mgr_with_registry(registry=reg)
domain = 'alpha.pic.ngo'
result = mgr._build_registry_service_routes(domain)
expected = CaddyManager._build_core_service_routes(domain)
self.assertEqual(result, expected)
def test_registry_error_falls_back(self):
"""When get_caddy_routes raises, output equals _build_core_service_routes."""
reg = MagicMock()
reg.get_caddy_routes.side_effect = Exception('registry unavailable')
mgr = _mgr_with_registry(registry=reg)
domain = 'alpha.pic.ngo'
result = mgr._build_registry_service_routes(domain)
expected = CaddyManager._build_core_service_routes(domain)
self.assertEqual(result, expected)
def test_single_service_no_extras(self):
"""One service with no extra_subdomains produces one matcher + handle + api block."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'calendar',
'subdomain': 'calendar',
'backend': 'cell-radicale:5232',
'extra_subdomains': [],
'extra_backends': {},
}
]
mgr = _mgr_with_registry(registry=reg)
result = mgr._build_registry_service_routes('test.cell')
self.assertIn('@calendar host calendar.test.cell', result)
self.assertIn('reverse_proxy cell-radicale:5232', result)
self.assertIn('@api host api.test.cell', result)
self.assertIn('reverse_proxy cell-api:3000', result)
# Only two named-matcher definition lines: @calendar and @api
matcher_lines = [l for l in result.splitlines() if l.strip().startswith('@') and 'host' in l]
self.assertEqual(len(matcher_lines), 2)
def test_extra_subdomain_same_backend(self):
"""An extra_subdomain NOT in extra_backends shares the primary matcher host line."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'email',
'subdomain': 'mail',
'backend': 'cell-rainloop:8888',
'extra_subdomains': ['webmail'],
'extra_backends': {}, # webmail not listed → shares backend
}
]
mgr = _mgr_with_registry(registry=reg)
result = mgr._build_registry_service_routes('test.cell')
# Both subdomains appear in the same host matcher line
self.assertIn('@mail host mail.test.cell webmail.test.cell', result)
# Only one reverse_proxy for cell-rainloop (shared block)
self.assertEqual(result.count('reverse_proxy cell-rainloop:8888'), 1)
# No separate @webmail block
self.assertNotIn('@webmail host', result)
def test_extra_subdomain_different_backend(self):
"""An extra_subdomain listed in extra_backends gets its own matcher + handle block."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'files',
'subdomain': 'files',
'backend': 'cell-filegator:8080',
'extra_subdomains': ['webdav'],
'extra_backends': {'webdav': 'cell-webdav:80'},
}
]
mgr = _mgr_with_registry(registry=reg)
result = mgr._build_registry_service_routes('test.cell')
# files gets its own block (webdav not in shared list)
self.assertIn('@files host files.test.cell', result)
self.assertIn('reverse_proxy cell-filegator:8080', result)
# webdav gets a separate block
self.assertIn('@webdav host webdav.test.cell', result)
self.assertIn('reverse_proxy cell-webdav:80', result)
# webdav must NOT appear in the @files host line
files_line = [l for l in result.splitlines() if '@files host' in l][0]
self.assertNotIn('webdav', files_line)
def test_api_always_appended(self):
"""The @api block is always the last block even when registry has no api entry."""
reg = _mock_registry()
mgr = _mgr_with_registry(registry=reg)
result = mgr._build_registry_service_routes('alpha.pic.ngo')
self.assertIn('@api host api.alpha.pic.ngo', result)
self.assertIn('reverse_proxy cell-api:3000', result)
# api block is at the end
api_idx = result.rfind('@api')
other_matchers = ['@calendar', '@mail', '@files', '@webdav']
for m in other_matchers:
self.assertLess(result.index(m), api_idx,
f'{m} should appear before @api')
def test_api_not_duplicated_when_registry_returns_api(self):
"""Even if registry somehow returns an 'api' route, the injected api block is cell-api:3000."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'api',
'subdomain': 'api',
'backend': 'cell-other:9999', # wrong backend — should be overridden
'extra_subdomains': [],
'extra_backends': {},
}
]
mgr = _mgr_with_registry(registry=reg)
result = mgr._build_registry_service_routes('test.cell')
# The infrastructure api block is always appended with the canonical backend
self.assertIn('reverse_proxy cell-api:3000', result)
# api host matcher appears at least once (from registry AND from append)
self.assertGreaterEqual(result.count('@api host api.test.cell'), 1)
# ---------------------------------------------------------------------------
# TestHttp01ServicePairs
# ---------------------------------------------------------------------------
class TestHttp01ServicePairs(unittest.TestCase):
def test_pairs_from_registry(self):
"""With the 3 builtins the pairs list matches expected (subdomain, backend) tuples."""
reg = _mock_registry()
mgr = _mgr_with_registry(registry=reg)
pairs = mgr._http01_service_pairs()
pairs_dict = dict(pairs)
self.assertEqual(pairs_dict['calendar'], 'cell-radicale:5232')
self.assertEqual(pairs_dict['mail'], 'cell-rainloop:8888')
self.assertEqual(pairs_dict['webmail'], 'cell-rainloop:8888')
self.assertEqual(pairs_dict['files'], 'cell-filegator:8080')
self.assertEqual(pairs_dict['webdav'], 'cell-webdav:80')
self.assertEqual(pairs_dict['api'], 'cell-api:3000')
def test_webdav_gets_own_backend(self):
"""webdav must map to cell-webdav:80, not to cell-filegator:8080."""
reg = _mock_registry()
mgr = _mgr_with_registry(registry=reg)
pairs = mgr._http01_service_pairs()
webdav_entry = next((b for s, b in pairs if s == 'webdav'), None)
self.assertIsNotNone(webdav_entry)
self.assertEqual(webdav_entry, 'cell-webdav:80')
self.assertNotEqual(webdav_entry, 'cell-filegator:8080')
def test_fallback_when_no_registry(self):
"""Without a registry the hardcoded pairs are returned, including api."""
mgr = _mgr_with_registry(registry=None)
pairs = mgr._http01_service_pairs()
subdomains = [s for s, _ in pairs]
self.assertIn('calendar', subdomains)
self.assertIn('mail', subdomains)
self.assertIn('webmail', subdomains)
self.assertIn('files', subdomains)
self.assertIn('webdav', subdomains)
self.assertIn('api', subdomains)
def test_fallback_when_registry_error(self):
"""When get_caddy_routes raises, falls back to hardcoded pairs."""
reg = MagicMock()
reg.get_caddy_routes.side_effect = RuntimeError('boom')
mgr = _mgr_with_registry(registry=reg)
pairs = mgr._http01_service_pairs()
subdomains = [s for s, _ in pairs]
self.assertIn('calendar', subdomains)
self.assertIn('api', subdomains)
# ---------------------------------------------------------------------------
# TestCaddyfileWithRegistry
# ---------------------------------------------------------------------------
class TestCaddyfileWithRegistry(unittest.TestCase):
def _generate(self, domain_mode, cell_name='alpha', domain_name=None,
registry=None, services=None):
reg = registry if registry is not None else _mock_registry()
mgr = _mgr_with_registry(registry=reg)
identity = {'cell_name': cell_name, 'domain_mode': domain_mode}
if domain_name:
identity['domain_name'] = domain_name
return mgr.generate_caddyfile(identity, services or [])
def test_pic_ngo_with_registry_has_correct_routes(self):
"""pic_ngo Caddyfile has all service matchers with correct subdomains and backends."""
out = self._generate('pic_ngo', cell_name='alpha')
# calendar
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-radicale:5232', out)
# mail + webmail share one matcher
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-rainloop:8888', out)
# files
self.assertIn('@files host files.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-filegator:8080', out)
# webdav separate block
self.assertIn('@webdav host webdav.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-webdav:80', out)
# api always present
self.assertIn('@api host api.alpha.pic.ngo', out)
self.assertIn('reverse_proxy cell-api:3000', out)
def test_cloudflare_with_registry_uses_registry_routes(self):
"""cloudflare Caddyfile routes are sourced from registry, not hardcoded."""
out = self._generate('cloudflare', cell_name='beta',
domain_name='example.com')
self.assertIn('@calendar host calendar.example.com', out)
self.assertIn('@mail host mail.example.com webmail.example.com', out)
self.assertIn('@files host files.example.com', out)
self.assertIn('@webdav host webdav.example.com', out)
self.assertIn('@api host api.example.com', out)
# Correct DNS plugin block is still present
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
def test_duckdns_with_registry_uses_registry_routes(self):
"""duckdns Caddyfile routes are sourced from registry."""
out = self._generate('duckdns', cell_name='gamma')
self.assertIn('@calendar host calendar.gamma.duckdns.org', out)
self.assertIn('@api host api.gamma.duckdns.org', out)
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
def test_http01_with_registry_has_per_host_blocks(self):
"""http01 Caddyfile has individual per-host blocks for every service subdomain."""
out = self._generate('http01', cell_name='delta',
domain_name='delta.noip.me')
self.assertIn('calendar.delta.noip.me {', out)
self.assertIn('mail.delta.noip.me {', out)
self.assertIn('webmail.delta.noip.me {', out)
self.assertIn('files.delta.noip.me {', out)
self.assertIn('webdav.delta.noip.me {', out)
self.assertIn('api.delta.noip.me {', out)
# Correct backends
self.assertIn('reverse_proxy cell-radicale:5232', out)
self.assertIn('reverse_proxy cell-rainloop:8888', out)
self.assertIn('reverse_proxy cell-filegator:8080', out)
self.assertIn('reverse_proxy cell-webdav:80', out)
def test_pic_ngo_fallback_when_registry_empty(self):
"""pic_ngo falls back to hardcoded routes when registry returns empty list."""
reg = MagicMock()
reg.get_caddy_routes.return_value = []
out = self._generate('pic_ngo', cell_name='alpha', registry=reg)
# Hardcoded routes should appear
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
# ---------------------------------------------------------------------------
# TestNetworkManagerGetServiceSubdomains
# ---------------------------------------------------------------------------
class TestNetworkManagerGetServiceSubdomains(unittest.TestCase):
def setUp(self):
self.managers = []
def tearDown(self):
for nm in self.managers:
shutil.rmtree(nm._tmpdir, ignore_errors=True)
def _make(self, registry=None):
nm = _nm(registry=registry)
self.managers.append(nm)
return nm
def test_no_registry_returns_hardcoded(self):
"""Without a registry the hardcoded service subdomain list is returned."""
nm = self._make(registry=None)
subs = nm._get_service_subdomains()
self.assertCountEqual(subs, ['calendar', 'files', 'mail', 'webmail', 'webdav'])
def test_registry_returns_all_subdomains(self):
"""Primary + extra_subdomains from all routes are returned."""
reg = _mock_registry()
nm = self._make(registry=reg)
subs = nm._get_service_subdomains()
# calendar (primary), mail (primary), webmail (extra), files (primary), webdav (extra)
for expected in ('calendar', 'mail', 'webmail', 'files', 'webdav'):
self.assertIn(expected, subs)
def test_registry_error_falls_back(self):
"""When get_caddy_routes raises, hardcoded list is returned."""
reg = MagicMock()
reg.get_caddy_routes.side_effect = Exception('broken registry')
nm = self._make(registry=reg)
subs = nm._get_service_subdomains()
self.assertCountEqual(subs, ['calendar', 'files', 'mail', 'webmail', 'webdav'])
def test_registry_extra_subdomains_included(self):
"""extra_subdomains from each route are included in the returned list."""
reg = MagicMock()
reg.get_caddy_routes.return_value = [
{
'service_id': 'files',
'subdomain': 'files',
'backend': 'cell-filegator:8080',
'extra_subdomains': ['webdav', 'dav'],
'extra_backends': {},
}
]
nm = self._make(registry=reg)
subs = nm._get_service_subdomains()
self.assertIn('files', subs)
self.assertIn('webdav', subs)
self.assertIn('dav', subs)
def test_build_dns_records_with_registry(self):
"""All registry subdomains appear as A records in _build_dns_records output."""
reg = _mock_registry()
nm = self._make(registry=reg)
# Override WG IP lookup so we get a predictable value
nm._get_wg_server_ip = lambda: '10.0.0.1'
records = nm._build_dns_records('mycell', '172.20.0.0/16')
names = [r['name'] for r in records]
for expected in ('mycell', 'api', 'webui', 'calendar', 'mail',
'webmail', 'files', 'webdav'):
self.assertIn(expected, names,
f'{expected!r} should be in DNS records but is not')
# All records must point to the WG server IP
for r in records:
self.assertEqual(r['value'], '10.0.0.1')
self.assertEqual(r['type'], 'A')
# ---------------------------------------------------------------------------
# TestNetworkManagerStaleSet
# ---------------------------------------------------------------------------
class TestNetworkManagerStaleSet(unittest.TestCase):
"""Verify that registry subdomains drive stale record cleanup in update_split_horizon_zone."""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
data_dir = os.path.join(self.test_dir, 'data')
config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True)
os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True)
self.reg = _mock_registry()
self.nm = NetworkManager(
data_dir=data_dir,
config_dir=config_dir,
service_registry=self.reg,
)
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
def _write_zone(self, zone_name: str, content: str):
path = os.path.join(self.nm.dns_zones_dir, f'{zone_name}.zone')
with open(path, 'w') as f:
f.write(content)
def test_stale_set_includes_registry_subdomains(self):
"""Registry subdomains (calendar, mail, webmail, files, webdav) are treated as
stale service records and removed from the parent zone during
update_split_horizon_zone."""
import subprocess
# Build a parent zone with stale service records that the registry knows about
stale_records = [
{'name': 'pic2', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'api', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'webui', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'calendar', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'mail', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'webmail', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'files', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'webdav', 'type': 'A', 'value': '10.0.0.1'},
]
from unittest.mock import patch
with patch('subprocess.run'):
self.nm.update_dns_zone('pic.ngo', stale_records)
self.nm.update_split_horizon_zone(
'pic2.pic.ngo', '172.20.0.2', primary_domain='pic.ngo'
)
parent_zone = os.path.join(self.nm.dns_zones_dir, 'pic.ngo.zone')
content = open(parent_zone).read()
# All registry subdomains must be gone
for stale in ('api', 'webui', 'calendar', 'mail', 'webmail', 'files', 'webdav'):
# Check that no line *starts* with the stale name (to avoid false positives
# on SOA/NS lines that may contain the zone name as a suffix)
lines_with_stale = [
l for l in content.splitlines()
if l.startswith(stale + ' ') or l.startswith(stale + '\t')
]
self.assertEqual(
lines_with_stale, [],
f'Stale record {stale!r} should have been removed from pic.ngo zone'
)
def test_stale_set_uses_registry_not_hardcoded(self):
"""When a registry provides a custom subdomain, it is treated as stale too."""
custom_reg = MagicMock()
custom_reg.get_caddy_routes.return_value = [
{
'service_id': 'chat',
'subdomain': 'chat',
'backend': 'cell-chat:9000',
'extra_subdomains': ['im'],
'extra_backends': {},
}
]
data_dir = os.path.join(self.test_dir, 'data2')
config_dir = os.path.join(self.test_dir, 'config2')
os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True)
os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True)
nm = NetworkManager(data_dir=data_dir, config_dir=config_dir,
service_registry=custom_reg)
stale_records = [
{'name': 'pic3', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'chat', 'type': 'A', 'value': '10.0.0.1'},
{'name': 'im', 'type': 'A', 'value': '10.0.0.1'},
]
from unittest.mock import patch
with patch('subprocess.run'):
nm.update_dns_zone('pic.ngo', stale_records)
nm.update_split_horizon_zone(
'pic3.pic.ngo', '172.20.0.2', primary_domain='pic.ngo'
)
parent_zone = os.path.join(nm.dns_zones_dir, 'pic.ngo.zone')
content = open(parent_zone).read()
for stale in ('chat', 'im'):
lines_with_stale = [
l for l in content.splitlines()
if l.startswith(stale + ' ') or l.startswith(stale + '\t')
]
self.assertEqual(
lines_with_stale, [],
f'Custom registry subdomain {stale!r} should have been removed'
)
if __name__ == '__main__':
unittest.main()
+5 -2
View File
@@ -119,14 +119,17 @@ class TestRestoreConfigBackup(unittest.TestCase):
content_type='application/json',
)
mock_cm.restore_config.assert_called_once_with(
'backup_001', services=['network', 'wireguard']
'backup_001', services=['network', 'wireguard'], service_registry=None
)
@patch('app.config_manager')
def test_restore_passes_none_services_when_no_body(self, mock_cm):
from unittest.mock import ANY
mock_cm.restore_config.return_value = True
self.client.post('/api/config/restore/backup_001')
mock_cm.restore_config.assert_called_once_with('backup_001', services=None)
mock_cm.restore_config.assert_called_once_with(
'backup_001', services=None, service_registry=ANY
)
class TestExportConfig(unittest.TestCase):