feat: PIC Services Architecture Phase 1 — registry-driven services ecosystem
Unit Tests / test (push) Successful in 11m30s

Implements the full Phase 1 services architecture:
- ServiceRegistry: merges built-in + installed + runtime config; drives Caddy and CoreDNS instead of hardcoded service names
- ServiceComposer: docker-compose lifecycle for third-party services
- AccountManager: per-service credential provisioning and deprovisioning per peer
- Built-in manifests (email, calendar, files) with subdomain, backup, and account hooks
- Admin UI: Accounts tab on Email, Calendar, Files pages
- Developer guide v1: manifest reference, compose variables, backup/egress integration
- 158 new tests; 1762 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 05:02:26 -04:00
30 changed files with 4806 additions and 53 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+237
View File
@@ -0,0 +1,237 @@
"""
AccountManager — per-service credential provisioning for PIC peers.
Responsibilities:
- Dispatch account creation/deletion to each service's underlying manager
- Store per-peer per-service credentials securely (0o600 file)
- Provide credential retrieval for peer_config_template filling
- Bulk-deprovision a peer from all services on peer deletion
Credentials file format (data/peer_service_credentials.json):
{
"<service_id>": {
"<peer_username>": {"password": "..."}
}
}
Design note — plaintext passwords:
Credentials are stored in plaintext so the peer endpoint can return them to
the peer's device for one-time client configuration. The file is created with
0o600 so it is only readable by the process owner (same pattern used for
WireGuard keys and service_secrets.json).
"""
import json
import logging
import os
import secrets as _secrets_mod
import threading
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger('picell')
_DISPATCH_PROVISION = {
'email_manager': '_provision_email',
'calendar_manager': '_provision_calendar',
'file_manager': '_provision_files',
}
_DISPATCH_DEPROVISION = {
'email_manager': '_deprovision_email',
'calendar_manager': '_deprovision_calendar',
'file_manager': '_deprovision_files',
}
class AccountManager:
def __init__(self, service_registry, data_dir: str, **managers):
"""
service_registry — ServiceRegistry instance
data_dir — host data directory (data/peer_service_credentials.json lives here)
**managers — named manager instances: email_manager=..., calendar_manager=...,
file_manager=...
"""
self._registry = service_registry
self._creds_path = Path(data_dir) / 'peer_service_credentials.json'
self._managers = managers
self._lock = threading.Lock()
# ── Credential storage (0o600) ────────────────────────────────────────
def _load_creds(self) -> Dict:
if not self._creds_path.exists():
return {}
try:
with open(self._creds_path) as f:
return json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning('AccountManager: failed to load credentials: %s', e)
return {}
def _save_creds(self, creds: Dict) -> None:
tmp = str(self._creds_path) + '.tmp'
with open(tmp, 'w', opener=lambda path, flags: os.open(path, flags, 0o600)) as f:
json.dump(creds, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, str(self._creds_path))
# ── Per-manager provision / deprovision ───────────────────────────────
def _provision_email(self, manager, svc: Dict, peer_username: str, password: str) -> bool:
domain = (svc.get('config') or {}).get('domain', '')
if not domain:
raise ValueError("Email service has no 'domain' configured")
return manager.create_email_user(peer_username, domain, password)
def _deprovision_email(self, manager, svc: Dict, peer_username: str) -> bool:
domain = (svc.get('config') or {}).get('domain', '')
return manager.delete_email_user(peer_username, domain)
@staticmethod
def _provision_calendar(manager, _svc: Dict, peer_username: str, password: str) -> bool:
return manager.create_calendar_user(peer_username, password)
@staticmethod
def _deprovision_calendar(manager, _svc: Dict, peer_username: str) -> bool:
return manager.delete_calendar_user(peer_username)
@staticmethod
def _provision_files(manager, _svc: Dict, peer_username: str, password: str) -> bool:
return manager.create_user(peer_username, password)
@staticmethod
def _deprovision_files(manager, _svc: Dict, peer_username: str) -> bool:
return manager.delete_user(peer_username)
# ── Service validation helper ─────────────────────────────────────────
def _resolve_service(self, service_id: str):
"""Return (svc, manager_name, manager) or raise ValueError."""
svc = self._registry.get(service_id)
if svc is None:
raise ValueError(f'Unknown service: {service_id!r}')
accounts_cfg = svc.get('accounts') or {}
manager_name = accounts_cfg.get('manager')
if not manager_name:
raise ValueError(f'Service {service_id!r} does not support accounts')
manager = self._managers.get(manager_name)
if manager is None:
raise ValueError(f'Manager {manager_name!r} is not registered with AccountManager')
return svc, manager_name, manager
# ── Public API ────────────────────────────────────────────────────────
def provision(self, service_id: str, peer_username: str,
password: str = None) -> Dict:
"""Create an account on the service for the peer; store and return credentials.
Raises ValueError if the service doesn't support accounts.
Raises RuntimeError if the underlying manager fails.
"""
svc, manager_name, manager = self._resolve_service(service_id)
if password is None:
password = _secrets_mod.token_urlsafe(16)
dispatch = _DISPATCH_PROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No provision dispatch for manager: {manager_name!r}')
fn = getattr(self, dispatch)
ok = fn(manager, svc, peer_username, password)
if not ok:
raise RuntimeError(
f'Provision of {peer_username!r} on {service_id!r} returned False — '
'check underlying service manager logs'
)
cred = {'password': password}
with self._lock:
all_creds = self._load_creds()
all_creds.setdefault(service_id, {})[peer_username] = cred
self._save_creds(all_creds)
logger.info('AccountManager: provisioned %s on %s', peer_username, service_id)
return cred
def deprovision(self, service_id: str, peer_username: str) -> bool:
"""Delete the peer's account on the service and clear stored credentials."""
svc, manager_name, manager = self._resolve_service(service_id)
dispatch = _DISPATCH_DEPROVISION.get(manager_name)
if dispatch is None:
raise ValueError(f'No deprovision dispatch for manager: {manager_name!r}')
fn = getattr(self, dispatch)
ok = fn(manager, svc, peer_username)
with self._lock:
all_creds = self._load_creds()
svc_creds = all_creds.get(service_id, {})
if peer_username in svc_creds:
del svc_creds[peer_username]
if not svc_creds:
del all_creds[service_id]
self._save_creds(all_creds)
logger.info('AccountManager: deprovisioned %s from %s', peer_username, service_id)
return bool(ok)
def get_credentials(self, service_id: str, peer_username: str) -> Optional[Dict]:
"""Return stored credentials for peer+service, or None if not provisioned."""
with self._lock:
return self._load_creds().get(service_id, {}).get(peer_username)
def list_accounts(self, service_id: str) -> List[str]:
"""Return peer usernames provisioned on a service."""
with self._lock:
return list(self._load_creds().get(service_id, {}).keys())
def list_peer_services(self, peer_username: str) -> List[str]:
"""Return service IDs where this peer has a provisioned account."""
with self._lock:
creds = self._load_creds()
return [svc_id for svc_id, peers in creds.items() if peer_username in peers]
def is_provisioned(self, service_id: str, peer_username: str) -> bool:
return self.get_credentials(service_id, peer_username) is not None
def deprovision_peer(self, peer_username: str) -> Dict[str, bool]:
"""Remove a peer from every service they are provisioned on.
Called on peer deletion. Continues even if individual services fail.
Returns {service_id: success} for each service attempted.
"""
results: Dict[str, bool] = {}
for service_id in self.list_peer_services(peer_username):
try:
results[service_id] = self.deprovision(service_id, peer_username)
except Exception as e:
logger.warning('AccountManager: deprovision %s from %s failed: %s',
peer_username, service_id, e)
results[service_id] = False
return results
def get_all_credentials(self, peer_username: str) -> Dict[str, Dict]:
"""Return {service_id: {field: value}} for all services the peer is provisioned on."""
with self._lock:
creds = self._load_creds()
return {
svc_id: peers[peer_username]
for svc_id, peers in creds.items()
if peer_username in peers
}
def store_credentials(self, service_id: str, peer_username: str,
cred: Dict) -> None:
"""Directly store credentials without calling the underlying manager.
Used when a peer was provisioned through the legacy peers-POST route
so that their credentials become retrievable via AccountManager.
"""
with self._lock:
all_creds = self._load_creds()
all_creds.setdefault(service_id, {})[peer_username] = cred
self._save_creds(all_creds)
+3
View File
@@ -44,6 +44,9 @@ from managers import (
caddy_manager, caddy_manager,
ddns_manager, service_store_manager, ddns_manager, service_store_manager,
connectivity_manager, connectivity_manager,
service_registry,
service_composer,
account_manager,
firewall_manager, EventType, firewall_manager, EventType,
) )
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` # 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, 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__('caddy', data_dir, config_dir) super().__init__('caddy', data_dir, config_dir)
self.config_manager = config_manager self.config_manager = config_manager
self.container_name = 'cell-caddy' self.container_name = 'cell-caddy'
self.caddyfile_path = LIVE_CADDYFILE self.caddyfile_path = LIVE_CADDYFILE
self._service_registry = service_registry
# Consecutive health-check failure counter (reset on success or when # Consecutive health-check failure counter (reset on success or when
# the caller restarts the container). # the caller restarts the container).
self._health_failures = 0 self._health_failures = 0
@@ -187,6 +189,69 @@ class CaddyManager(BaseServiceManager):
f" }}" 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 @staticmethod
def _indent_routes(routes: str, spaces: int = 4) -> str: def _indent_routes(routes: str, spaces: int = 4) -> str:
"""Indent a multi-line route block by ``spaces`` columns.""" """Indent a multi-line route block by ``spaces`` columns."""
@@ -230,7 +295,7 @@ class CaddyManager(BaseServiceManager):
service_routes: str, core_routes: str) -> str: service_routes: str, core_routes: str) -> str:
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin.""" """pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
domain = f"{cell_name}.pic.ngo" domain = f"{cell_name}.pic.ngo"
body = [self._build_core_service_routes(domain)] body = [self._build_registry_service_routes(domain)]
if service_routes: if service_routes:
body.append(self._indent_routes(service_routes)) body.append(self._indent_routes(service_routes))
body.append(core_routes) body.append(core_routes)
@@ -253,7 +318,7 @@ class CaddyManager(BaseServiceManager):
def _caddyfile_cloudflare(self, custom_domain: str, def _caddyfile_cloudflare(self, custom_domain: str,
service_routes: str, core_routes: str) -> str: service_routes: str, core_routes: str) -> str:
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin.""" """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: if service_routes:
body.append(self._indent_routes(service_routes)) body.append(self._indent_routes(service_routes))
body.append(core_routes) body.append(core_routes)
@@ -273,7 +338,7 @@ class CaddyManager(BaseServiceManager):
service_routes: str, core_routes: str) -> str: service_routes: str, core_routes: str) -> str:
"""duckdns mode: DNS-01 via the duckdns plugin.""" """duckdns mode: DNS-01 via the duckdns plugin."""
domain = f"{cell_name}.duckdns.org" domain = f"{cell_name}.duckdns.org"
body = [self._build_core_service_routes(domain)] body = [self._build_registry_service_routes(domain)]
if service_routes: if service_routes:
body.append(self._indent_routes(service_routes)) body.append(self._indent_routes(service_routes))
body.append(core_routes) body.append(core_routes)
@@ -299,15 +364,8 @@ class CaddyManager(BaseServiceManager):
out.append(core_routes) out.append(core_routes)
out.append("}") out.append("}")
# One block per core service subdomain. # Build (subdomain, backend) pairs from registry when available.
_core_services = [ _core_services = self._http01_service_pairs()
('calendar', 'cell-radicale:5232'),
('mail', 'cell-rainloop:8888'),
('webmail', 'cell-rainloop:8888'),
('files', 'cell-filegator:8080'),
('webdav', 'cell-webdav:80'),
('api', 'cell-api:3000'),
]
for subdomain, backend in _core_services: for subdomain, backend in _core_services:
out.append("") out.append("")
out.append(f"{subdomain}.{host} {{") out.append(f"{subdomain}.{host} {{")
@@ -330,6 +388,32 @@ class CaddyManager(BaseServiceManager):
out.append("}") out.append("}")
return "\n".join(out) + "\n" 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 ───────────────────────────────── # ── filesystem + admin-API operations ─────────────────────────────────
def write_caddyfile(self, caddyfile_content: str) -> bool: def write_caddyfile(self, caddyfile_content: str) -> bool:
+139 -4
View File
@@ -6,6 +6,8 @@ Centralized configuration management for all services
import os import os
import json import json
import re
import subprocess
import yaml import yaml
import shutil import shutil
import hashlib import hashlib
@@ -14,6 +16,9 @@ from typing import Dict, List, Optional, Any
from pathlib import Path from pathlib import Path
import logging 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 # The Caddyfile lives on a separate volume mount from the rest of config
LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile') LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile')
@@ -228,8 +233,128 @@ class ConfigManager:
"warnings": warnings "warnings": warnings
} }
def backup_config(self) -> str: @staticmethod
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, and DNS zones.""" 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: try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_id = f"backup_{timestamp}" backup_id = f"backup_{timestamp}"
@@ -278,12 +403,17 @@ class ConfigManager:
except (PermissionError, OSError) as e: except (PermissionError, OSError) as e:
logger.warning(f"Could not back up {src.name}: {e} (skipping)") 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()) services = ['identity'] + list(self.service_schemas.keys())
manifest = { manifest = {
"backup_id": backup_id, "backup_id": backup_id,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"services": services, "services": services,
"files": [f.name for f in backup_path.iterdir()], "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: with open(backup_path / 'manifest.json', 'w') as f:
json.dump(manifest, f, indent=2) json.dump(manifest, f, indent=2)
@@ -294,8 +424,9 @@ class ConfigManager:
except Exception as e: except Exception as e:
logger.error(f"Error creating backup: {e}") logger.error(f"Error creating backup: {e}")
raise 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).""" """Restore from backup. If services list given, only restore those service configs (selective)."""
try: try:
backup_path = self.backup_dir / backup_id backup_path = self.backup_dir / backup_id
@@ -373,6 +504,10 @@ class ConfigManager:
except (PermissionError, OSError) as e: except (PermissionError, OSError) as e:
logger.warning(f"Could not restore {dest.name}: {e} (skipping)") 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() self.configs = self._load_all_configs()
logger.info(f"Restored configuration from backup: {backup_id}") logger.info(f"Restored configuration from backup: {backup_id}")
return True return True
+22 -2
View File
@@ -31,6 +31,9 @@ from setup_manager import SetupManager
from caddy_manager import CaddyManager from caddy_manager import CaddyManager
from ddns_manager import DDNSManager from ddns_manager import DDNSManager
from connectivity_manager import ConnectivityManager 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') DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
@@ -42,7 +45,13 @@ config_manager = ConfigManager(
service_bus = ServiceBus() service_bus = ServiceBus()
log_manager = LogManager(log_dir='./data/logs') 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) wireguard_manager = WireGuardManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
peer_registry = PeerRegistry(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) 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) auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager) 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) ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
connectivity_manager = ConnectivityManager( connectivity_manager = ConnectivityManager(
config_manager=config_manager, config_manager=config_manager,
@@ -67,6 +77,15 @@ connectivity_manager = ConnectivityManager(
config_dir=CONFIG_DIR, 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 from service_store_manager import ServiceStoreManager
service_store_manager = ServiceStoreManager( service_store_manager = ServiceStoreManager(
config_manager=config_manager, config_manager=config_manager,
@@ -110,6 +129,7 @@ __all__ = [
'routing_manager', 'vault_manager', 'container_manager', 'routing_manager', 'vault_manager', 'container_manager',
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager', 'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
'ddns_manager', 'service_store_manager', 'connectivity_manager', 'ddns_manager', 'service_store_manager', 'connectivity_manager',
'service_registry', 'service_composer', 'account_manager',
'firewall_manager', 'EventType', 'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR', 'DATA_DIR', 'CONFIG_DIR',
] ]
+32 -13
View File
@@ -18,11 +18,13 @@ logger = logging.getLogger(__name__)
class NetworkManager(BaseServiceManager): class NetworkManager(BaseServiceManager):
"""Manages network services (DNS, DHCP, NTP)""" """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) super().__init__('network', data_dir, config_dir)
self.dns_zones_dir = os.path.join(data_dir, 'dns') self.dns_zones_dir = os.path.join(data_dir, 'dns')
self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases') self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases')
self._service_registry = service_registry
# Ensure directories exist # Ensure directories exist
self.safe_makedirs(self.dns_zones_dir) self.safe_makedirs(self.dns_zones_dir)
self.safe_makedirs(os.path.dirname(self.dhcp_leases_file)) self.safe_makedirs(os.path.dirname(self.dhcp_leases_file))
@@ -201,7 +203,7 @@ class NetworkManager(BaseServiceManager):
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'), # domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
# bootstrap service records like 'api', 'calendar' etc. would pollute the # bootstrap service records like 'api', 'calendar' etc. would pollute the
# zone display and shadow the public domain. Remove them. # 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): if effective_domain.endswith('.' + primary_domain):
existing = self._load_dns_records(primary_domain) existing = self._load_dns_records(primary_domain)
cleaned = [r for r in existing if r.get('name', '') not in _stale] cleaned = [r for r in existing if r.get('name', '') not in _stale]
@@ -249,6 +251,25 @@ class NetworkManager(BaseServiceManager):
pass pass
return '10.0.0.1' 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]: def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records. """Build the standard set of DNS A records.
@@ -258,16 +279,14 @@ class NetworkManager(BaseServiceManager):
routes requests to the correct backend by Host header. routes requests to the correct backend by Host header.
""" """
wg_ip = self._get_wg_server_ip() wg_ip = self._get_wg_server_ip()
return [ records = [
{'name': cell_name, 'type': 'A', 'value': wg_ip}, {'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip}, {'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', '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},
] ]
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]: def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
"""Get all DNS records across all zones""" """Get all DNS records across all zones"""
@@ -595,7 +614,7 @@ class NetworkManager(BaseServiceManager):
if not new_name: if not new_name:
return {'restarted': restarted, 'warnings': warnings} return {'restarted': restarted, 'warnings': warnings}
# Exclude service names, wildcard, and apex from cell-hostname detection. # 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 | {'@', '*'} _reserved = _service_names | {'@', '*'}
changed = False changed = False
try: try:
+9 -4
View File
@@ -784,8 +784,8 @@ def apply_pending_config():
@bp.route('/api/config/backup', methods=['POST']) @bp.route('/api/config/backup', methods=['POST'])
def create_config_backup(): def create_config_backup():
try: try:
from app import config_manager, service_bus, EventType from app import config_manager, service_bus, service_registry, EventType
backup_id = config_manager.backup_config() backup_id = config_manager.backup_config(service_registry=service_registry)
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', { service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
'backup_id': backup_id, 'backup_id': backup_id,
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
@@ -809,9 +809,14 @@ def list_config_backups():
@bp.route('/api/config/restore/<backup_id>', methods=['POST']) @bp.route('/api/config/restore/<backup_id>', methods=['POST'])
def restore_config(backup_id): def restore_config(backup_id):
try: 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 {} 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: if success:
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', { service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
'backup_id': backup_id, 'backup_id': backup_id,
+45
View File
@@ -125,6 +125,17 @@ def add_peer():
return jsonify({"error": f"Peer {peer_name} already exists"}), 400 return jsonify({"error": f"Peer {peer_name} already exists"}), 400
peer_added_to_registry = True 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, firewall_manager.apply_peer_rules(peer_info['ip'], peer_info,
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets) wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
firewall_applied = True firewall_applied = True
@@ -320,12 +331,46 @@ def remove_peer(peer_name):
_cleanup() _cleanup()
except Exception: except Exception:
pass 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"}) return jsonify({"message": f"Peer {peer_name} removed successfully"})
except Exception as e: except Exception as e:
logger.error(f"Error removing peer: {e}") logger.error(f"Error removing peer: {e}")
return jsonify({"error": str(e)}), 500 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']) @bp.route('/api/peers/register', methods=['POST'])
def register_peer(): def register_peer():
try: try:
+168
View File
@@ -6,6 +6,174 @@ from flask import Blueprint, request, jsonify
logger = logging.getLogger('picell') logger = logging.getLogger('picell')
bp = Blueprint('services', __name__) 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']) @bp.route('/api/services/bus/status', methods=['GET'])
def get_service_bus_status(): def get_service_bus_status():
try: try:
+310
View File
@@ -0,0 +1,310 @@
"""
ServiceComposer docker-compose generation and container lifecycle for PIC services.
Responsibilities:
- Render compose-template.yml per-service docker-compose.yml with PIC_* substitution
- Manage store-service container lifecycle (up / down / restart / status / reconfigure)
- Manage builtin-service restarts and status via the main compose stack
- Generate and persist PIC_SECRET_* variables in a dedicated secrets file
Template variable reference (for compose-template.yml authors):
${PIC_CFG_<KEY>} value from manifest config_schema, uppercased
${PIC_SECRET_<NAME>} auto-generated random secret, persisted across reconfigures
${PIC_DOMAIN} effective domain (e.g. cell.pic.ngo)
${PIC_CELL_NAME} cell name (e.g. mycell)
${PIC_SERVICE_ID} service identifier (e.g. nextcloud)
"""
import json
import logging
import os
import re
import secrets as _secrets_lib
import shutil
import subprocess
import threading
from typing import Dict, List, Optional
logger = logging.getLogger('picell')
_SECRET_RE = re.compile(r'\$\{(PIC_SECRET_\w+)\}')
_SAFE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
class ServiceComposer:
def __init__(self, config_manager, data_dir: str):
self.cm = config_manager
self.data_dir = data_dir
self._services_dir = os.path.join(data_dir, 'services')
self._secrets_path = os.path.join(data_dir, 'service_secrets.json')
self._lock = threading.Lock()
# ── Path helpers ──────────────────────────────────────────────────────
@staticmethod
def _validate_service_id(service_id: str) -> None:
"""Raise ValueError if service_id could be used for path traversal."""
if not _SAFE_ID_RE.match(service_id):
raise ValueError(
f'Invalid service_id {service_id!r}: '
'must match ^[a-z0-9][a-z0-9_-]{{0,63}}$'
)
def _svc_dir(self, service_id: str) -> str:
self._validate_service_id(service_id)
candidate = os.path.join(self._services_dir, service_id)
# Paranoia: ensure the resolved path stays inside _services_dir
real_base = os.path.realpath(self._services_dir)
real_cand = os.path.realpath(candidate)
if not real_cand.startswith(real_base + os.sep) and real_cand != real_base:
raise ValueError(f'service_id {service_id!r} escapes services directory')
return candidate
def _compose_path(self, service_id: str) -> str:
return os.path.join(self._svc_dir(service_id), 'docker-compose.yml')
def has_compose_file(self, service_id: str) -> bool:
try:
return os.path.exists(self._compose_path(service_id))
except ValueError:
return False
# ── Secrets management ────────────────────────────────────────────────
def _load_secrets(self) -> Dict:
if not os.path.exists(self._secrets_path):
return {}
try:
with open(self._secrets_path) as f:
return json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning('ServiceComposer: failed to load secrets: %s', e)
return {}
def _save_secrets(self, secrets: Dict) -> None:
tmp = self._secrets_path + '.tmp'
# 0o600: readable only by the process owner — secrets must not be world-readable
with open(tmp, 'w',
opener=lambda path, flags: os.open(path, flags, 0o600)) as f:
json.dump(secrets, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self._secrets_path)
def _get_or_create_secret(self, service_id: str, var_name: str) -> str:
with self._lock:
secrets = self._load_secrets()
svc_secrets = secrets.setdefault(service_id, {})
if var_name not in svc_secrets:
svc_secrets[var_name] = _secrets_lib.token_urlsafe(24)
self._save_secrets(secrets)
return svc_secrets[var_name]
def _clear_secrets(self, service_id: str) -> None:
with self._lock:
secrets = self._load_secrets()
if service_id in secrets:
del secrets[service_id]
self._save_secrets(secrets)
# ── Template rendering ────────────────────────────────────────────────
def render_template(self, service_id: str, manifest: Dict,
template_content: str) -> str:
"""
Substitute all PIC_* variables in a compose-template.yml string.
Returns the rendered compose YAML.
"""
schema = manifest.get('config_schema') or {}
saved = self.cm.configs.get(service_id, {})
config: Dict = {k: v['default'] for k, v in schema.items() if 'default' in v}
config.update({k: saved[k] for k in schema if k in saved})
identity = self.cm.get_identity()
domain = self.cm.get_effective_domain() or identity.get('domain', 'cell.local')
cell_name = identity.get('cell_name', 'mycell')
result = template_content
for key, value in config.items():
# Strip newlines/tabs to prevent YAML injection (a config string containing
# \n could inject new YAML keys into the compose file)
safe_val = str(value).replace('\n', '').replace('\r', '').replace('\t', ' ')
result = result.replace(f'${{PIC_CFG_{key.upper()}}}', safe_val)
result = result.replace('${PIC_DOMAIN}', domain)
result = result.replace('${PIC_CELL_NAME}', cell_name)
result = result.replace('${PIC_SERVICE_ID}', service_id)
# PIC_SECRET_* — generate on first use, reuse on reconfigure
for match in _SECRET_RE.finditer(template_content):
var_name = match.group(1)
secret = self._get_or_create_secret(service_id, var_name)
result = result.replace(f'${{{var_name}}}', secret)
return result
def write_compose(self, service_id: str, manifest: Dict,
template_content: str) -> str:
"""Render and atomically write the per-service compose file. Returns rendered content."""
os.makedirs(self._svc_dir(service_id), exist_ok=True)
content = self.render_template(service_id, manifest, template_content)
path = self._compose_path(service_id)
tmp = path + '.tmp'
with open(tmp, 'w') as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
logger.info('ServiceComposer: wrote compose file for %s', service_id)
return content
# ── Subprocess helper ─────────────────────────────────────────────────
def _run(self, cmd: List[str], timeout: int = 120) -> Dict:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0 and r.stderr:
logger.warning('ServiceComposer command failed: %s', r.stderr.strip())
return {
'ok': r.returncode == 0,
'stdout': r.stdout.strip(),
'stderr': r.stderr.strip(),
}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'docker compose command timed out'}
except Exception as e:
logger.error('ServiceComposer._run error: %s', e)
return {'ok': False, 'error': str(e)}
@staticmethod
def _parse_ps_json(output: str) -> List[Dict]:
"""Parse `docker compose ps --format json` output (one JSON object per line)."""
containers = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
try:
containers.append(json.loads(line))
except json.JSONDecodeError:
pass
return containers
# ── Store-service lifecycle (per-service compose file) ────────────────
def _store_cmd(self, service_id: str, *args, timeout: int = 120) -> Dict:
compose_file = self._compose_path(service_id)
if not os.path.exists(compose_file):
return {'ok': False, 'error': f'No compose file found for service {service_id!r}'}
cmd = [
'docker', 'compose',
'-f', compose_file,
'--project-name', f'pic-{service_id}',
*args,
]
return self._run(cmd, timeout)
def up(self, service_id: str) -> Dict:
# 600s: image pulls on slow connections can take several minutes
return self._store_cmd(service_id, 'up', '-d', '--remove-orphans', timeout=600)
def down(self, service_id: str, remove_volumes: bool = False) -> Dict:
args = ['down']
if remove_volumes:
args.append('--volumes')
return self._store_cmd(service_id, *args)
def restart(self, service_id: str) -> Dict:
return self._store_cmd(service_id, 'restart')
def status(self, service_id: str) -> Dict:
result = self._store_cmd(service_id, 'ps', '--format', 'json')
result['containers'] = self._parse_ps_json(result.get('stdout', ''))
return result
def reconfigure(self, service_id: str, manifest: Dict,
template_content: str) -> Dict:
"""Re-render the compose file then re-apply with `up -d` (rolling update)."""
self.write_compose(service_id, manifest, template_content)
return self.up(service_id)
def install(self, service_id: str, manifest: Dict,
template_content: str) -> Dict:
"""Write compose file and start containers."""
self.write_compose(service_id, manifest, template_content)
return self.up(service_id)
def remove(self, service_id: str, purge_data: bool = False) -> Dict:
"""Stop containers, optionally delete compose file, secrets, and service data dir."""
result = self.down(service_id, remove_volumes=purge_data)
if purge_data:
self._clear_secrets(service_id)
svc_dir = self._svc_dir(service_id) # already validates service_id + realpath
if os.path.isdir(svc_dir):
# Final realpath check: reject symlinks that escape the services dir
real_svc = os.path.realpath(svc_dir)
real_base = os.path.realpath(self._services_dir)
if not real_svc.startswith(real_base + os.sep):
logger.error('ServiceComposer: refusing rmtree outside services dir: %s', svc_dir)
else:
try:
shutil.rmtree(svc_dir)
except OSError as e:
logger.warning('ServiceComposer: could not remove %s: %s', svc_dir, e)
elif os.path.exists(self._compose_path(service_id)):
# Remove compose file even without purge so stale file doesn't confuse future installs
try:
os.remove(self._compose_path(service_id))
except OSError:
pass
return result
# ── Builtin-service lifecycle (main compose stack) ─────────────────────
@staticmethod
def _main_compose() -> str:
return os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
def restart_builtin(self, container_names: List[str]) -> Dict:
"""Restart one or more containers that live in the main docker-compose stack."""
if not container_names:
return {'ok': False, 'error': 'No container names provided'}
cmd = ['docker', 'compose', '-f', self._main_compose(),
'restart', *container_names]
return self._run(cmd)
def status_builtin(self, container_names: List[str]) -> Dict:
"""Return status of containers from the main compose stack."""
if not container_names:
return {'ok': False, 'error': 'No container names provided'}
cmd = ['docker', 'compose', '-f', self._main_compose(),
'ps', '--format', 'json', *container_names]
result = self._run(cmd)
result['containers'] = self._parse_ps_json(result.get('stdout', ''))
return result
# ── Unified lifecycle (dispatches based on service kind) ───────────────
def restart_service(self, service_id: str, manifest: Dict) -> Dict:
"""
Restart any service builtin or store using the right compose stack.
Builtin: uses manifest.containers + main docker-compose.yml.
Store: uses per-service compose file.
"""
if manifest.get('kind') == 'builtin':
containers = manifest.get('containers') or []
return self.restart_builtin(containers)
return self.restart(service_id)
def status_service(self, service_id: str, manifest: Dict) -> Dict:
"""
Return container status for any service.
Builtin: queries manifest.containers from main compose stack.
Store: queries per-service compose project.
"""
if manifest.get('kind') == 'builtin':
containers = manifest.get('containers') or []
return self.status_builtin(containers)
return self.status(service_id)
+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', 'git', 'registry', 'install',
]) ])
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$') 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}' 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 {} caddy_route = m.get('caddy_route') or {}
if isinstance(caddy_route, dict): if isinstance(caddy_route, dict):
subdomain = caddy_route.get('subdomain', '') legacy_sub = caddy_route.get('subdomain', '')
else: else:
subdomain = '' legacy_sub = ''
if subdomain: if legacy_sub:
if subdomain in RESERVED_SUBDOMAINS: if legacy_sub in RESERVED_SUBDOMAINS:
errors.append(f'caddy_route.subdomain is reserved: {subdomain}') errors.append(f'caddy_route.subdomain is reserved: {legacy_sub}')
elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain): elif not SUBDOMAIN_RE.match(legacy_sub):
errors.append( errors.append(
f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, ' 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 # Env value safety
@@ -0,0 +1,68 @@
{
"schema_version": 3,
"id": "calendar",
"name": "Calendar & Contacts",
"description": "Radicale CalDAV / CardDAV server",
"version": "1.0.0",
"author": "pic",
"kind": "builtin",
"min_pic_version": "1.0",
"capabilities": {
"has_subdomain": true,
"has_accounts": true,
"has_admin_config": true,
"has_storage": true,
"has_egress": true,
"has_api_hooks": false
},
"subdomain": "calendar",
"extra_subdomains": [],
"backend": "cell-radicale:5232",
"containers": ["cell-radicale"],
"config_schema": {
"port": {
"type": "integer",
"label": "CalDAV port (internal)",
"default": 5232,
"min": 1,
"max": 65535
}
},
"peer_config_template": {
"caldav_url": "https://calendar.{domain}/{peer.username}/",
"carddav_url": "https://calendar.{domain}/{peer.username}/",
"username": "{peer.username}",
"password": "{peer.service_credentials.calendar.password}"
},
"accounts": {
"manager": "calendar_manager",
"credentials": ["password"]
},
"compose": null,
"backup": {
"volumes": [
{"container": "cell-radicale", "path": "/data", "name": "radicale_data"}
],
"config_paths": [
"config/radicale"
]
},
"egress": {
"default": "default",
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
},
"storage": {
"primary_path": "data/radicale",
"quota_mb": null
}
}
+99
View File
@@ -0,0 +1,99 @@
{
"schema_version": 3,
"id": "email",
"name": "Email",
"description": "Postfix (SMTP) + Dovecot (IMAP) email server with Rainloop webmail",
"version": "1.0.0",
"author": "pic",
"kind": "builtin",
"min_pic_version": "1.0",
"capabilities": {
"has_subdomain": true,
"has_accounts": true,
"has_admin_config": true,
"has_storage": true,
"has_egress": true,
"has_api_hooks": false
},
"subdomain": "mail",
"extra_subdomains": ["webmail"],
"backend": "cell-rainloop:8888",
"containers": ["cell-mail", "cell-rainloop"],
"config_schema": {
"domain": {
"type": "string",
"label": "Mail domain",
"required": true
},
"smtp_port": {
"type": "integer",
"label": "SMTP port",
"default": 25,
"min": 1,
"max": 65535
},
"submission_port": {
"type": "integer",
"label": "Submission port",
"default": 587,
"min": 1,
"max": 65535
},
"imap_port": {
"type": "integer",
"label": "IMAP port",
"default": 993,
"min": 1,
"max": 65535
},
"webmail_port": {
"type": "integer",
"label": "Webmail port (internal)",
"default": 8888,
"min": 1,
"max": 65535
}
},
"peer_config_template": {
"imap_server": "{domain}",
"imap_port": "{config.imap_port}",
"smtp_server": "{domain}",
"smtp_port": "{config.submission_port}",
"webmail_url": "https://mail.{domain}/",
"username": "{peer.username}@{domain}",
"password": "{peer.service_credentials.email.password}"
},
"accounts": {
"manager": "email_manager",
"credentials": ["password"]
},
"compose": null,
"backup": {
"volumes": [
{"container": "cell-mail", "path": "/var/mail", "name": "maildata"},
{"container": "cell-mail", "path": "/var/mail-state", "name": "mailstate"},
{"container": "cell-rainloop", "path": "/rainloop/data", "name": "rainloop"}
],
"config_paths": [
"config/mail"
]
},
"egress": {
"default": "default",
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
},
"storage": {
"primary_path": "data/maildata",
"quota_mb": null
}
}
+79
View File
@@ -0,0 +1,79 @@
{
"schema_version": 3,
"id": "files",
"name": "File Storage",
"description": "FileGator browser UI + WebDAV network drive",
"version": "1.0.0",
"author": "pic",
"kind": "builtin",
"min_pic_version": "1.0",
"capabilities": {
"has_subdomain": true,
"has_accounts": true,
"has_admin_config": true,
"has_storage": true,
"has_egress": true,
"has_api_hooks": false
},
"subdomain": "files",
"extra_subdomains": ["webdav"],
"backend": "cell-filegator:8080",
"extra_backends": {
"webdav": "cell-webdav:80"
},
"containers": ["cell-filegator", "cell-webdav"],
"config_schema": {
"manager_port": {
"type": "integer",
"label": "FileGator port (internal)",
"default": 8082,
"min": 1,
"max": 65535
},
"port": {
"type": "integer",
"label": "WebDAV port (internal)",
"default": 8080,
"min": 1,
"max": 65535
}
},
"peer_config_template": {
"files_url": "https://files.{domain}/",
"webdav_url": "https://webdav.{domain}/",
"username": "{peer.username}",
"password": "{peer.service_credentials.files.password}"
},
"accounts": {
"manager": "file_manager",
"credentials": ["password"]
},
"compose": null,
"backup": {
"volumes": [
{"container": "cell-filegator", "path": "/var/www/filegator/private", "name": "filegator"},
{"container": "cell-webdav", "path": "/var/lib/dav", "name": "files"}
],
"config_paths": [
"config/webdav"
]
},
"egress": {
"default": "default",
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
},
"storage": {
"primary_path": "data/files",
"quota_mb": null
}
}
+731
View File
@@ -0,0 +1,731 @@
# PIC Service Developer Guide
This guide is for developers who want to build services that integrate with Personal Internet Cell (PIC). It covers the manifest format, how PIC wires up routing, DNS, backup, and account provisioning for your service, and how to package and submit your work.
**Prerequisites:** you should be comfortable with Docker, Docker Compose, and basic Linux networking. You do not need to know Python to build a store service.
---
## Table of Contents
1. [What a PIC service is](#1-what-a-pic-service-is)
2. [Manifest reference](#2-manifest-reference)
3. [Compose template variables](#3-compose-template-variables)
4. [Account provisioning interface](#4-account-provisioning-interface)
5. [Backup integration](#5-backup-integration)
6. [Egress routing](#6-egress-routing)
7. [Quick-start example](#7-quick-start-example)
8. [Submitting to the store](#8-submitting-to-the-store)
---
## 1. What a PIC service is
A PIC service is a Docker container (or a set of containers) that plugs into the PIC ecosystem through a single JSON file called the **manifest**. The manifest tells PIC everything it needs to know:
- How to route HTTPS traffic to the service through Caddy
- What subdomains to expose
- Which users get accounts on the service and what credentials they receive
- Which paths to include in automated backups
- Which outbound network interfaces the service is allowed to use
PIC has two kinds of services:
**Builtins** ship with PIC itself (email, calendar, files). Their containers are declared in the main `docker-compose.yml`. Their manifests live under `api/services/builtins/<id>/manifest.json`. They use Python manager classes for account operations because they run inside the same Docker network and share internal access.
**Store services** are third-party packages installed by the cell admin from the PIC service store. PIC downloads the manifest from the store index, allocates a static IP for the container in the service pool (`172.20.x.x`, offsets 20254 within your `ip_range` subnet), generates a Docker Compose override file, and starts the container. Store services use a small HTTP API (described in section 4) for account operations.
The `ServiceRegistry` in `api/service_registry.py` is the single source of truth for both kinds. `CaddyManager`, the backup system, and the peer services endpoint all read from it rather than from hardcoded lists.
---
## 2. Manifest reference
The manifest is a JSON file with `"schema_version": 3`. Every field is described below. The three builtin manifests (`email`, `calendar`, `files`) are the canonical examples.
### Top-level identity fields
| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | integer | yes | Must be `3`. |
| `id` | string | yes | Unique service identifier, lowercase, no spaces (e.g. `"notes"`). Must match the directory name for builtins, or the store index entry for store services. |
| `name` | string | yes | Human-readable display name (e.g. `"Notes"`). |
| `description` | string | yes | One-sentence description shown in the UI. |
| `version` | string | yes | Semver string for the service package itself (e.g. `"1.0.0"`). |
| `author` | string | yes | Your name or organisation. |
| `kind` | string | yes | `"builtin"` for built-in services, `"store"` for third-party packages. |
| `min_pic_version` | string | no | Minimum PIC version required (e.g. `"1.0"`). |
```json
{
"schema_version": 3,
"id": "notes",
"name": "Notes",
"description": "Self-hosted Markdown notes with full-text search",
"version": "1.0.0",
"author": "acme",
"kind": "store",
"min_pic_version": "1.0"
}
```
### `capabilities`
A set of boolean flags that tell PIC which integrations to activate for your service.
| Field | Type | Default | Description |
|---|---|---|---|
| `has_subdomain` | bool | `false` | The service gets a subdomain and a Caddy reverse-proxy route. Requires `subdomain` and `backend`. |
| `has_accounts` | bool | `false` | The service provisions per-peer accounts. Requires `accounts`. |
| `has_admin_config` | bool | `false` | The service has admin-configurable fields. Requires `config_schema`. |
| `has_storage` | bool | `false` | The service has data worth backing up. Requires `backup`. |
| `has_egress` | bool | `false` | The admin can choose which outbound interface this service uses. Requires `egress`. |
| `has_api_hooks` | bool | `false` | Reserved for future use; set `false`. |
```json
"capabilities": {
"has_subdomain": true,
"has_accounts": true,
"has_admin_config": false,
"has_storage": true,
"has_egress": false,
"has_api_hooks": false
}
```
### `subdomain`, `extra_subdomains`, `backend`, `extra_backends`
These fields are only read when `has_subdomain` is `true`.
| Field | Type | Required | Description |
|---|---|---|---|
| `subdomain` | string | yes (if `has_subdomain`) | The primary subdomain (e.g. `"notes"`). Results in `notes.<cell-domain>`. Must not collide with reserved names: `api`, `webui`, `admin`, `www`, `mail`, `ns1`, `ns2`, `git`, `registry`, `install`. |
| `extra_subdomains` | array of strings | no | Additional subdomains that point to the same backend (e.g. `["webmail"]`). |
| `backend` | string | yes (if `has_subdomain`) | The container-name:port combination that Caddy proxies to (e.g. `"cell-notes:8080"`). Uses Docker DNS on the `cell-network`. |
| `extra_backends` | object | no | Maps extra subdomain names to separate backends. Key is the subdomain string; value is the backend string. The email service uses this to send `webdav.*` to a different container than `files.*`. |
```json
"subdomain": "notes",
"extra_subdomains": [],
"backend": "cell-notes:8080"
```
**Validation at runtime:** `ServiceRegistry.get_caddy_routes()` validates all subdomain and backend values before passing them to CaddyManager or NetworkManager. Any entry whose `subdomain` does not match `^[a-z][a-z0-9-]{0,30}$`, whose `backend` does not match `^[A-Za-z0-9._-]+:\d{1,5}$`, or whose `subdomain` appears in the reserved list is silently skipped with a warning log. The same validation applies to `extra_subdomains` and `extra_backends` keys/values. For store services, this validation is also performed during installation by `ServiceStoreManager._validate_manifest()`.
### `containers`
Array of container names that belong to this service. Used by the UI and log viewer. For builtins this is informational; for store services PIC only manages the single container declared in the manifest.
```json
"containers": ["cell-notes"]
```
### `config_schema`
Defines admin-configurable fields for this service. When `has_admin_config` is `true`, the UI renders a settings form from this schema. PIC stores admin-saved values in `cell_config.json` and merges them with your `default` values at runtime. The merged result is available as the `config` key when `ServiceRegistry.get()` returns your service.
Each field is an object:
| Key | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | One of `"string"`, `"integer"`, `"boolean"`. |
| `label` | string | yes | Human-readable label for the settings form. |
| `required` | bool | no | Whether the field must have a value before the service starts. |
| `default` | any | no | Default value used when the admin has not set one. |
| `min` / `max` | integer | no (integer only) | Inclusive bounds for integer fields. |
```json
"config_schema": {
"port": {
"type": "integer",
"label": "Internal HTTP port",
"default": 8080,
"min": 1,
"max": 65535
},
"storage_path": {
"type": "string",
"label": "Data directory inside container",
"default": "/data/notes"
}
}
```
### `peer_config_template`
When a peer is provisioned on this service, PIC fills this template and returns the result to the peer as their connection info. Template substitution tokens:
| Token | Replaced with |
|---|---|
| `{domain}` | The cell's public domain (e.g. `alice.pic.ngo`) |
| `{peer.username}` | The peer's username |
| `{peer.service_credentials.<id>.<field>}` | A credential value; `<id>` is the service `id`, `<field>` matches a name in `accounts.credentials` |
| `{config.<key>}` | A value from the merged `config_schema` result |
```json
"peer_config_template": {
"url": "https://notes.{domain}/",
"username": "{peer.username}",
"password": "{peer.service_credentials.notes.password}"
}
```
### `accounts`
Required when `has_accounts` is `true`.
| Field | Type | Description |
|---|---|---|
| `manager` | string | For builtins: the Python manager name used internally (`"email_manager"`, `"calendar_manager"`, `"file_manager"`). For store services: set to `"http"` to indicate the HTTP API (section 4). |
| `credentials` | array of strings | Names of credential fields this service issues per peer. Most services use `["password"]`. The names appear in `peer_config_template` tokens. |
```json
"accounts": {
"manager": "http",
"credentials": ["password"]
}
```
### `compose`
For builtins set this to `null` — their containers come from the main `docker-compose.yml`. For store services this field is unused at the manifest level; compose configuration is provided via `compose-template.yml` in the package (see section 3).
### `backup`
Required when `has_storage` is `true`. Tells PIC's backup system what to snapshot.
| Field | Type | Description |
|---|---|---|
| `volumes` | array of objects | Container paths to stream out via `docker exec tar`. Each entry has three string fields: `container` (container name), `path` (absolute path inside the container), and `name` (archive filename stem). |
| `config_paths` | array of strings | Paths relative to the PIC project root on the host that contain service configuration (not user data). Copied directly into the snapshot. |
Each entry in `volumes` produces an archive at `<name>.tar.gz` inside the snapshot. For example, `"name": "maildata"` produces `maildata.tar.gz`.
```json
"backup": {
"volumes": [
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
],
"config_paths": ["config/notes"]
}
```
`ServiceRegistry.get_backup_plan()` aggregates these declarations across all installed services. The backup runner reads from that method rather than from any hardcoded list.
### `egress`
Required when `has_egress` is `true`. Declares which outbound network interfaces this service is permitted to use.
| Field | Type | Description |
|---|---|---|
| `default` | string | The interface selected when the admin has not changed anything. |
| `allowed` | array of strings | The complete set of interfaces the admin can choose from. |
Valid interface identifiers: `cell_internet`, `openvpn`, `wireguard`, `tor`, `pic_cell`.
```json
"egress": {
"default": "cell_internet",
"allowed": ["cell_internet", "openvpn", "wireguard", "tor", "pic_cell"]
}
```
How enforcement works is described in section 6.
### `storage`
Informational metadata used by the UI to show storage usage.
| Field | Type | Description |
|---|---|---|
| `primary_path` | string | The path (relative to project root) that holds the bulk of user data. |
| `quota_mb` | integer or null | Storage quota in megabytes; `null` means no limit. |
```json
"storage": {
"primary_path": "data/notes",
"quota_mb": null
}
```
### Store-only manifest fields
Store services (where `kind` is `"store"`) have additional required fields that builtins do not use. These are validated by `ServiceStoreManager._validate_manifest()` before installation is permitted.
| Field | Type | Required | Description |
|---|---|---|---|
| `image` | string | yes | Docker image to pull. Must match the pattern `git.pic.ngo/roof/*`. Images from other registries are rejected. |
| `container_name` | string | yes | The name Docker gives the running container. |
| `volumes` | array | no | Named volumes to mount. Each entry must have `name` (the volume name) and `mount` (the absolute path inside the container). Mounts to `/`, `/etc`, `/var`, `/proc`, `/sys`, `/dev`, `/app`, `/run`, `/boot`, and paths that are a prefix of the PIC project root are forbidden. |
| `env` | array | no | Environment variables to pass. Each entry has `key` and `value`. Values must match `^[A-Za-z0-9._@:/+\-= ]*$`. |
| `iptables_rules` | array | no | FORWARD ACCEPT rules PIC should install in `cell-wireguard`. Each rule must have `type: "ACCEPT"`, `dest_ip: "${SERVICE_IP}"`, an integer `dest_port` (165535), and an optional `proto` (`"tcp"` or `"udp"`, default `"tcp"`). The literal string `${SERVICE_IP}` is replaced with the allocated container IP at install time. |
| `caddy_route` | object | no | If the service exposes a web UI, provide `subdomain` (must not be reserved; must match `^[a-z][a-z0-9-]{0,30}$`). PIC inserts the corresponding `reverse_proxy` directive into the Caddyfile. |
---
## 3. Compose template variables
This section applies only to store services. Builtins define their containers directly in `docker-compose.yml`.
When you ship a store service, you include a `compose-template.yml` alongside your `manifest.json`. `ServiceComposer.render_template()` substitutes the variables below before writing the per-service `docker-compose.yml`.
| Variable | Syntax | Value |
|---|---|---|
| `${PIC_CFG_<KEY>}` | uppercase `config_schema` key | The admin-saved value for that field, or the `default` from the schema if the admin has not set it. For example, `config_schema.port``${PIC_CFG_PORT}`. |
| `${PIC_SECRET_<NAME>}` | any name you choose | An auto-generated random secret produced by `secrets.token_urlsafe(24)` (~32 URL-safe base64 characters). Generated once on first install, then reused unchanged on every reconfigure. Stored per service in `data/service_secrets.json`. |
| `${PIC_DOMAIN}` | literal | Effective domain from `ConfigManager` (e.g. `alice.pic.ngo`). |
| `${PIC_CELL_NAME}` | literal | Cell name from the identity config (e.g. `alice`). |
| `${PIC_SERVICE_ID}` | literal | The `id` field from the service manifest (e.g. `notes`). |
**Volume mounts**: Because docker compose runs inside the API container but the Docker daemon runs on the host, relative volume paths in compose templates resolve relative to the compose file's directory as seen by the HOST filesystem. To avoid path resolution surprises, prefer **named volumes** for service data (Docker manages them independently). If bind mounts are required, use absolute host paths with `${PIC_PROJECT_DIR}` once that variable is implemented, or document the expected host layout clearly.
Example `compose-template.yml` for a notes service:
```yaml
services:
cell-notes:
image: git.pic.ngo/roof/pic-notes:latest
container_name: cell-notes
restart: unless-stopped
environment:
NOTES_PORT: "${PIC_CFG_PORT}"
NOTES_DOMAIN: "${PIC_DOMAIN}"
NOTES_DB_PASS: "${PIC_SECRET_DB_PASSWORD}"
volumes:
- notes-data:/data/notes
networks:
cell-network:
ipv4_address: "${SERVICE_IP}"
volumes:
notes-data:
networks:
cell-network:
external: true
```
The `SERVICE_IP` variable is the IP PIC allocated from the service pool. It is always set automatically.
---
## 4. Account provisioning interface
This section covers two related things: the `AccountManager` class that is PIC's central credential dispatcher, and the HTTP API that store services must implement to receive account operations.
### How AccountManager works
`AccountManager` (`api/account_manager.py`) is the single entry point for all account operations across every service type. It is instantiated once in `api/managers.py` and holds references to the three builtin service managers (`email_manager`, `calendar_manager`, `file_manager`).
When a peer account is provisioned, `AccountManager`:
1. Looks up the service in `ServiceRegistry` and reads `accounts.manager` from the manifest.
2. Dispatches to the appropriate internal manager method (for builtins) or to the service's HTTP API endpoint (for store services — not yet implemented; `"http"` manager support is planned).
3. Stores the returned credentials in `data/peer_service_credentials.json` with permissions `0o600`.
Credentials are stored in plaintext. This is intentional: the peer credentials endpoint needs to return them verbatim for one-time client configuration. The `0o600` permission matches the pattern used for WireGuard keys and `data/service_secrets.json`.
The credentials file structure is:
```json
{
"<service_id>": {
"<peer_username>": { "password": "..." }
}
}
```
Writes use a write-then-rename pattern (`tmp` → final path) with `os.fsync` to avoid partial-write corruption.
### Manifest `accounts` field
The `accounts` block in the manifest wires a service into `AccountManager`.
| Field | Type | Description |
|---|---|---|
| `manager` | string | Which underlying manager handles account operations. For builtins: `"email_manager"`, `"calendar_manager"`, or `"file_manager"`. |
| `credentials` | array of strings | Names of the credential fields this service issues per peer. Most services use `["password"]`. These names are used as token keys in `peer_config_template`. |
```json
"accounts": {
"manager": "email_manager",
"credentials": ["password"]
}
```
The `manager` value must match a key that `AccountManager` was instantiated with. If the manager name has no registered dispatch entry, `provision()` raises `ValueError` immediately.
### Provision flow
```
POST /api/services/catalog/<service_id>/accounts
Content-Type: application/json
{ "username": "alice", "password": "optional" }
```
If `password` is omitted, `AccountManager` generates one with `secrets.token_urlsafe(16)`. The response on HTTP 201 is:
```json
{ "service_id": "email", "username": "alice", "provisioned": true }
```
The password is not echoed in the response. To retrieve stored credentials for a provisioned peer, call `GET /api/services/catalog/<id>/accounts/<username>/credentials`.
Internally, `AccountManager.provision(service_id, peer_username, password)`:
1. Resolves the service and its manager via `_resolve_service()`.
2. Calls the appropriate `_provision_*` method, which delegates to the concrete manager:
- `email_manager``create_email_user(username, domain, password)`
- `calendar_manager``create_calendar_user(username, password)`
- `file_manager``create_user(username, password)`
3. Stores `{"password": "<value>"}` under `[service_id][peer_username]` in the credentials file.
4. Returns the credential dict to the caller.
If the underlying manager call returns `False`, `provision()` raises `RuntimeError`. The route handler maps this to HTTP 500.
For email, the domain is read from the service's merged config (`svc['config']['domain']`). If that key is absent, provisioning raises `ValueError` before calling the manager.
### Deprovision flow
```
DELETE /api/services/catalog/<service_id>/accounts/<username>
```
`AccountManager.deprovision(service_id, peer_username)`:
1. Calls the appropriate `_deprovision_*` method on the underlying manager.
2. Removes the peer's entry from the credentials file. If that leaves the service block empty, the service block itself is removed.
3. Returns `True` if the underlying call succeeded.
The route returns HTTP 200 with `{"message": "..."}` on success, or HTTP 400 if the service does not exist or does not support accounts.
**Peer deletion** calls `AccountManager.deprovision_peer(peer_username)`, which iterates over every service the peer is provisioned on and calls `deprovision()` for each. Failures on individual services are logged and skipped rather than aborting the deletion — the method returns `{service_id: bool}` for every service attempted.
### PIC admin API endpoints for account management
These endpoints are in `api/routes/services.py` and `api/routes/peers.py`.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/services/catalog/<service_id>/accounts` | Return `{"service_id": "...", "accounts": ["alice", "bob"]}` — reads directly from the credentials file. |
| `POST` | `/api/services/catalog/<service_id>/accounts` | Provision a peer account. Body: `{"username": "...", "password": "..."}` (password optional). Returns HTTP 201 with `{"service_id", "username", "provisioned": true}`. |
| `DELETE` | `/api/services/catalog/<service_id>/accounts/<username>` | Deprovision the peer's account. Returns HTTP 200 on success, HTTP 400 if the service or username is unknown. |
| `GET` | `/api/services/catalog/<service_id>/accounts/<username>/credentials` | Return stored credentials for one peer+service pair. Returns HTTP 404 if the peer is not provisioned on that service. Response: `{"service_id", "username", "password"}`. |
| `GET` | `/api/peers/<peer_name>/service-credentials` | Return filled `peer_config_template` values for all services the peer is provisioned on (see below). |
**Admin UI:** The Email, Calendar, and Files service pages in the admin dashboard each have an **Accounts** tab. From there, admins can provision and deprovision peer accounts, and reveal stored credentials for a provisioned peer. This tab calls the same API endpoints listed above.
### How `peer_config_template` connects to stored credentials
`GET /api/peers/<peer_name>/service-credentials` is the endpoint a peer device calls during first-time setup to configure email, CalDAV, and file sync clients.
The route:
1. Calls `AccountManager.get_all_credentials(peer_name)``{service_id: {field: value}}`.
2. For each service, calls `ServiceRegistry.get_peer_service_info(service_id, peer_name, domain, cred)`.
3. `get_peer_service_info` iterates over `peer_config_template` and replaces tokens:
- `{domain}` → effective cell domain
- `{peer.username}` → URL-percent-encoded peer username (safe='')
- `{peer.service_credentials.<service_id>.<field>}` → the value from stored credentials
- `{config.<key>}` → value from the service's merged config schema
4. Returns the filled template dict as the value for that service in the response.
Response shape:
```json
{
"peer": "alice",
"services": {
"email": {
"imap_host": "mail.alice.pic.ngo",
"username": "alice@alice.pic.ngo",
"password": "<stored>"
},
"files": {
"url": "https://files.alice.pic.ngo/dav/alice/",
"username": "alice",
"password": "<stored>"
}
}
}
```
If a service has no `peer_config_template` in its manifest, `get_peer_service_info` returns `None` and the raw credential dict is used as the fallback.
### Container lifecycle routes
The following PIC API endpoints are available for all services (builtins and store services). These are called by the web UI and can be called directly from the PIC admin API.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/services/catalog/<id>/status` | Return container status. Builtins query the main compose stack; store services query their own compose project. Response includes a `containers` array with one entry per container. |
| `POST` | `/api/services/catalog/<id>/restart` | Restart the service containers. Builtins restart via the main compose stack; store services restart via their own compose project. |
| `POST` | `/api/services/catalog/<id>/reconfigure` | Re-render the compose file from the template and re-apply with `up -d` (rolling update). Store services only — builtins are reconfigured through their own settings routes. The request body must include a `compose_template` field containing the new template content. |
### Store service HTTP API (planned)
When `accounts.manager` is `"http"`, PIC will call your container's HTTP API for account operations. This path is not yet implemented in `AccountManager`; the dispatch table only covers `email_manager`, `calendar_manager`, and `file_manager`. The interface below is the planned contract — implement it now so your service is ready when HTTP dispatch is wired up.
The base path is `/service-api/accounts` on your container's internal address. There is no authentication on this API — it is reachable only from within the `cell-network` Docker network.
**Create account**
```
POST /service-api/accounts
Content-Type: application/json
{ "username": "alice", "password": "auto-generated-by-pic" }
```
PIC generates the password and passes it to your service. Return HTTP 200 with `{"ok": true}` on success. Return HTTP 400 or 409 with `{"ok": false, "error": "..."}` for expected errors (duplicate username, invalid input). Return HTTP 500 for unexpected internal errors.
**Delete account**
```
DELETE /service-api/accounts/{username}
```
Return HTTP 200 with `{"ok": true}` on success. Return HTTP 404 with `{"ok": false, "error": "not found"}` if the account does not exist.
**List accounts**
```
GET /service-api/accounts
```
Return `{"accounts": ["alice", "bob"]}` — an array of all provisioned usernames.
---
## 5. Backup integration
Declare `has_storage: true` in `capabilities` and fill in the `backup` block. PIC's `ServiceRegistry.get_backup_plan()` returns the combined backup declarations for all installed services. The backup runner reads from that method.
### Why docker exec instead of bind mounts
The API container only has access to `data/api/` on the host filesystem. Service data (mailboxes, calendar collections, file trees) lives in other containers' volumes. Rather than mount every service volume into the API container — which would require compose changes per service — PIC streams data using `docker exec <container> tar czf - <path>`. This works for any container on the Docker host regardless of how its volumes are configured.
### `volumes` entries
Each object in the `volumes` array describes one directory to capture:
| Field | Description |
|---|---|
| `container` | Name of the running container to exec into (e.g. `"cell-notes"`). |
| `path` | Absolute path inside that container to archive (e.g. `"/data/notes"`). |
| `name` | Archive filename stem. PIC saves the archive as `<name>.tar.gz` under `service_data/<service_id>/` in the backup directory. |
A service with multiple containers or multiple data directories lists one entry per directory.
**Security note:** The backup commands use `docker exec -- <container> tar -C <path> -czf - .` (note the `--` separator before the container name) to prevent option injection. The container name is also validated against `^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$` before the command is run.
### `config_paths`
Paths in `config_paths` are relative to the PIC project root on the host and are copied directly into the snapshot (no docker exec). Use this for configuration files the service reads at startup, not for user data.
### Full example
```json
"backup": {
"volumes": [
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
],
"config_paths": ["config/notes"]
}
```
This produces one archive `notes_data.tar.gz` (streamed from the `cell-notes` container) plus a direct copy of `config/notes/` from the host.
### Restore
PIC restores each volume entry by piping the archive back via `docker exec -i -- <container> tar -C <path> -xzf -`. The `-C <path>` flag bounds extraction to the declared volume path — the same path used during backup. Archive entries are relative paths (the backup uses `tar -C <path> -czf - .`), so files land in exactly the location declared in the manifest `volumes` entry. The target container must be running at restore time.
---
## 6. Egress routing
When `has_egress` is `true`, the cell admin can assign a specific outbound interface to your service. PIC enforces the selection using `fwmark` rules and policy routing in the `cell-wireguard` container via the `ConnectivityManager`.
The valid values for `egress.allowed` and what they mean:
| Value | Path |
|---|---|
| `cell_internet` | Default route through the cell's WAN interface (no VPN). |
| `openvpn` | Traffic leaves through `tun0` (fwmark `0x20`, table 120). |
| `wireguard` | Traffic leaves through a second WireGuard interface `wg_ext0` (fwmark `0x10`, table 110). |
| `tor` | Traffic is redirected to the Tor transparent proxy on port 9040 (fwmark `0x30`, table 130). |
| `pic_cell` | Traffic routes through a connected peer cell via a site-to-site WireGuard link. |
List only the interfaces that make sense for your service in `allowed`. Listing all five is fine if your service is general-purpose. The `default` value is used when the admin has not changed anything. Always include `cell_internet` in `allowed` so the admin has a way to use the normal path.
The egress field in the manifest tells PIC what options to present in the UI. Actual enforcement requires the cell to have the corresponding exit type configured (an OpenVPN config uploaded, a WireGuard external config active, etc.). If the chosen exit is not active, packets will be dropped by the kill-switch FORWARD rule in `cell-wireguard`.
---
## 7. Quick-start example
This section walks through a minimal working example: a static website served from Nginx with no accounts, no backup, and no egress policy.
### `manifest.json`
```json
{
"schema_version": 3,
"id": "homepage",
"name": "Homepage",
"description": "A static homepage served from your cell",
"version": "1.0.0",
"author": "acme",
"kind": "store",
"min_pic_version": "1.0",
"capabilities": {
"has_subdomain": true,
"has_accounts": false,
"has_admin_config": false,
"has_storage": false,
"has_egress": false,
"has_api_hooks": false
},
"subdomain": "home",
"extra_subdomains": [],
"backend": "cell-homepage:80",
"containers": ["cell-homepage"],
"image": "git.pic.ngo/roof/pic-homepage:latest",
"container_name": "cell-homepage",
"volumes": [
{ "name": "homepage-html", "mount": "/usr/share/nginx/html" }
],
"env": [],
"iptables_rules": [
{
"type": "ACCEPT",
"dest_ip": "${SERVICE_IP}",
"dest_port": 80,
"proto": "tcp"
}
],
"caddy_route": {
"subdomain": "home"
},
"compose": null
}
```
### What PIC does on install
1. Downloads this manifest from the store index.
2. Validates every field (image allowlist, volume safety, reserved subdomains, iptables rule format).
3. Allocates a static IP from the service pool (`172.20.0.20``172.20.0.254`).
4. Writes a Docker Compose override file that starts `cell-homepage` with the allocated IP on `cell-network`.
5. Runs `docker compose up -d cell-homepage`.
6. Applies the `iptables_rules` in `cell-wireguard` so peers can reach the container.
7. Regenerates the Caddyfile so `home.<cell-domain>` proxies to `cell-homepage:80`.
The result is that any WireGuard peer can reach `https://home.alice.pic.ngo/` immediately after installation.
---
## 8. Submitting to the store
### Package format
A store service package is a ZIP archive containing:
```
homepage-1.0.0.zip
├── manifest.json (required)
├── compose-template.yml (recommended for multi-container services)
└── install.sh (optional post-install script)
```
`install.sh` is executed on the cell host after the container starts. Keep it minimal — initialise data structures, create default config files. Do not use it to install system packages or modify files outside the PIC project root.
### Store index entry
The store index at `https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json` is a JSON array. Each entry looks like:
```json
{
"id": "homepage",
"name": "Homepage",
"description": "A static homepage served from your cell",
"version": "1.0.0",
"author": "acme"
}
```
PIC fetches the full manifest from `https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json` when the admin clicks install.
### Submission process
1. Fork `https://git.pic.ngo/roof/pic-services`.
2. Create a directory `services/<your-id>/` and add your `manifest.json`.
3. Open a pull request against `main`.
The review checks the following before merging:
**Security**
- Image hosted on `git.pic.ngo/roof/*`. No external registries.
- No volume mounts to system paths or to the PIC project root.
- `iptables_rules` only declare `ACCEPT` rules (no DROP, no REJECT, no chain redirects).
- `env` values contain only alphanumeric characters and a small set of safe punctuation.
- `install.sh` does not call `apt`, `yum`, `curl | bash`, or modify files outside the project.
**Correctness**
- `subdomain` does not collide with the reserved list or with any existing store service.
- `backend` points to the declared `container_name`.
- If `has_accounts: true`, the container responds correctly on all three `/service-api/accounts` endpoints.
- If `has_storage: true`, every `volumes` entry names a container that is running and a path that exists inside it.
**Quality**
- `description` is one sentence, no marketing language.
- `version` is a valid semver string.
- `config_schema` labels are in plain English, sentence case.
### Versioning
Increment `version` in `manifest.json` with every change you submit. PIC does not auto-update installed services; the admin manually runs an update. When an update is available, the UI shows the version mismatch between the installed record and the store index.
---
## Appendix: manifest field quick reference
| Field | Builtin | Store | Notes |
|---|---|---|---|
| `schema_version` | required | required | Must be `3` |
| `id` | required | required | |
| `name` | required | required | |
| `description` | required | required | |
| `version` | required | required | |
| `author` | required | required | |
| `kind` | required | required | `"builtin"` or `"store"` |
| `min_pic_version` | optional | optional | |
| `capabilities.*` | required | required | All six flags must be present |
| `subdomain` | if `has_subdomain` | if `has_subdomain` | |
| `extra_subdomains` | optional | optional | |
| `backend` | if `has_subdomain` | if `has_subdomain` | |
| `extra_backends` | optional | optional | |
| `containers` | optional | optional | Informational |
| `config_schema` | if `has_admin_config` | if `has_admin_config` | |
| `peer_config_template` | if `has_accounts` | if `has_accounts` | |
| `accounts` | if `has_accounts` | if `has_accounts` | |
| `compose` | `null` | `null` | |
| `backup` | if `has_storage` | if `has_storage` | |
| `egress` | if `has_egress` | if `has_egress` | |
| `storage` | if `has_storage` | if `has_storage` | |
| `image` | not used | required | |
| `container_name` | not used | required | |
| `volumes` | not used | optional | |
| `env` | not used | optional | |
| `iptables_rules` | not used | optional | |
| `caddy_route` | not used | optional | |
BIN
View File
Binary file not shown.
+441
View File
@@ -0,0 +1,441 @@
"""
Tests for AccountManager per-service credential provisioning.
Covers:
- provision: dispatches to right manager method, stores credentials, generates password
- deprovision: calls manager method, removes stored credentials
- get_credentials / list_accounts / list_peer_services
- deprovision_peer: bulk cleanup on peer deletion
- store_credentials: direct storage (used by peers-POST legacy route)
- get_all_credentials: returns all creds for a peer
- credential file is created with 0o600
- unknown service / missing manager errors
"""
import json
import os
import stat
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
from account_manager import AccountManager
# ── helpers ────────────────────────────────────────────────────────────────────
def _make_am(tmp_path: Path, registry=None, **managers) -> AccountManager:
if registry is None:
registry = _make_registry()
return AccountManager(service_registry=registry, data_dir=str(tmp_path), **managers)
def _make_registry(services=None):
reg = MagicMock()
if services is None:
services = {
'email': {
'id': 'email', 'kind': 'builtin',
'accounts': {'manager': 'email_manager', 'credentials': ['password']},
'config': {'domain': 'example.com', 'smtp_port': 25},
},
'calendar': {
'id': 'calendar', 'kind': 'builtin',
'accounts': {'manager': 'calendar_manager', 'credentials': ['password']},
'config': {},
},
'files': {
'id': 'files', 'kind': 'builtin',
'accounts': {'manager': 'file_manager', 'credentials': ['password']},
'config': {},
},
}
reg.get.side_effect = lambda svc_id: services.get(svc_id)
return reg
def _make_email_mgr(ok=True):
m = MagicMock()
m.create_email_user.return_value = ok
m.delete_email_user.return_value = ok
return m
def _make_cal_mgr(ok=True):
m = MagicMock()
m.create_calendar_user.return_value = ok
m.delete_calendar_user.return_value = ok
return m
def _make_file_mgr(ok=True):
m = MagicMock()
m.create_user.return_value = ok
m.delete_user.return_value = ok
return m
# ── Provision ─────────────────────────────────────────────────────────────────
class TestProvision(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.email_mgr = _make_email_mgr()
self.cal_mgr = _make_cal_mgr()
self.file_mgr = _make_file_mgr()
self.am = _make_am(
self.tmp,
email_manager=self.email_mgr,
calendar_manager=self.cal_mgr,
file_manager=self.file_mgr,
)
def test_provision_email_calls_create_email_user(self):
self.am.provision('email', 'alice', password='s3cret')
self.email_mgr.create_email_user.assert_called_once_with('alice', 'example.com', 's3cret')
def test_provision_calendar_calls_create_calendar_user(self):
self.am.provision('calendar', 'alice', password='s3cret')
self.cal_mgr.create_calendar_user.assert_called_once_with('alice', 's3cret')
def test_provision_files_calls_create_user(self):
self.am.provision('files', 'alice', password='s3cret')
self.file_mgr.create_user.assert_called_once_with('alice', 's3cret')
def test_provision_generates_password_when_none_given(self):
creds = self.am.provision('email', 'alice')
self.assertIn('password', creds)
self.assertTrue(len(creds['password']) >= 16)
def test_provision_returns_credential_dict(self):
creds = self.am.provision('email', 'alice', password='mypassword')
self.assertEqual(creds, {'password': 'mypassword'})
def test_provision_stores_credentials(self):
self.am.provision('email', 'alice', password='pw')
stored = self.am.get_credentials('email', 'alice')
self.assertEqual(stored, {'password': 'pw'})
def test_provision_multiple_peers_stored_independently(self):
self.am.provision('email', 'alice', password='pw-alice')
self.am.provision('email', 'bob', password='pw-bob')
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice'})
self.assertEqual(self.am.get_credentials('email', 'bob'), {'password': 'pw-bob'})
def test_provision_raises_for_unknown_service(self):
with self.assertRaises(ValueError):
self.am.provision('doesnotexist', 'alice')
def test_provision_raises_when_service_has_no_accounts(self):
reg = _make_registry({'nosvc': {'id': 'nosvc', 'accounts': {}, 'config': {}}})
am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr)
with self.assertRaises(ValueError):
am.provision('nosvc', 'alice')
def test_provision_raises_when_manager_not_registered(self):
am = _make_am(self.tmp) # no managers passed
with self.assertRaises(ValueError):
am.provision('email', 'alice')
def test_provision_raises_runtime_error_when_manager_returns_false(self):
am = _make_am(self.tmp, email_manager=_make_email_mgr(ok=False))
with self.assertRaises(RuntimeError):
am.provision('email', 'alice')
def test_provision_email_raises_when_domain_not_configured(self):
reg = _make_registry({'email': {
'id': 'email', 'accounts': {'manager': 'email_manager'},
'config': {'domain': ''},
}})
am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr)
with self.assertRaises(ValueError):
am.provision('email', 'alice')
# ── Credential file permissions ───────────────────────────────────────────────
class TestCredentialFilePermissions(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.am = _make_am(self.tmp, email_manager=_make_email_mgr())
def test_credentials_file_created_with_0600(self):
self.am.provision('email', 'alice', password='pw')
creds_path = self.tmp / 'peer_service_credentials.json'
mode = stat.S_IMODE(creds_path.stat().st_mode)
self.assertEqual(mode, 0o600, f'Expected 0o600, got {oct(mode)}')
# ── Deprovision ───────────────────────────────────────────────────────────────
class TestDeprovision(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.email_mgr = _make_email_mgr()
self.cal_mgr = _make_cal_mgr()
self.file_mgr = _make_file_mgr()
self.am = _make_am(
self.tmp,
email_manager=self.email_mgr,
calendar_manager=self.cal_mgr,
file_manager=self.file_mgr,
)
self.am.provision('email', 'alice', password='pw')
def test_deprovision_email_calls_delete_email_user(self):
self.am.deprovision('email', 'alice')
self.email_mgr.delete_email_user.assert_called_once_with('alice', 'example.com')
def test_deprovision_removes_stored_credentials(self):
self.am.deprovision('email', 'alice')
self.assertIsNone(self.am.get_credentials('email', 'alice'))
def test_deprovision_returns_true_on_success(self):
ok = self.am.deprovision('email', 'alice')
self.assertTrue(ok)
def test_deprovision_raises_for_unknown_service(self):
with self.assertRaises(ValueError):
self.am.deprovision('ghost', 'alice')
def test_deprovision_removes_service_entry_when_last_peer_gone(self):
self.am.deprovision('email', 'alice')
creds_file = self.tmp / 'peer_service_credentials.json'
data = json.loads(creds_file.read_text())
self.assertNotIn('email', data)
def test_deprovision_calendar_calls_delete_calendar_user(self):
self.am.provision('calendar', 'alice', password='pw')
self.am.deprovision('calendar', 'alice')
self.cal_mgr.delete_calendar_user.assert_called_once_with('alice')
def test_deprovision_files_calls_delete_user(self):
self.am.provision('files', 'alice', password='pw')
self.am.deprovision('files', 'alice')
self.file_mgr.delete_user.assert_called_once_with('alice')
# ── Queries ───────────────────────────────────────────────────────────────────
class TestQueries(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.am = _make_am(
self.tmp,
email_manager=_make_email_mgr(),
calendar_manager=_make_cal_mgr(),
file_manager=_make_file_mgr(),
)
self.am.provision('email', 'alice', password='pw-alice-email')
self.am.provision('email', 'bob', password='pw-bob-email')
self.am.provision('calendar', 'alice', password='pw-alice-cal')
def test_get_credentials_returns_stored(self):
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice-email'})
def test_get_credentials_returns_none_for_unknown_peer(self):
self.assertIsNone(self.am.get_credentials('email', 'nobody'))
def test_get_credentials_returns_none_for_unknown_service(self):
self.assertIsNone(self.am.get_credentials('ghost', 'alice'))
def test_list_accounts_returns_provisioned_peers(self):
accounts = self.am.list_accounts('email')
self.assertIn('alice', accounts)
self.assertIn('bob', accounts)
def test_list_accounts_empty_for_unprovisioned_service(self):
self.assertEqual(self.am.list_accounts('files'), [])
def test_list_peer_services_returns_all_services_for_peer(self):
services = self.am.list_peer_services('alice')
self.assertIn('email', services)
self.assertIn('calendar', services)
def test_list_peer_services_returns_empty_for_unknown_peer(self):
self.assertEqual(self.am.list_peer_services('nobody'), [])
def test_is_provisioned_true_when_account_exists(self):
self.assertTrue(self.am.is_provisioned('email', 'alice'))
def test_is_provisioned_false_when_no_account(self):
self.assertFalse(self.am.is_provisioned('email', 'nobody'))
def test_get_all_credentials_returns_all_services(self):
all_creds = self.am.get_all_credentials('alice')
self.assertIn('email', all_creds)
self.assertIn('calendar', all_creds)
self.assertEqual(all_creds['email'], {'password': 'pw-alice-email'})
def test_get_all_credentials_empty_for_unknown_peer(self):
self.assertEqual(self.am.get_all_credentials('nobody'), {})
# ── Bulk deprovision ──────────────────────────────────────────────────────────
class TestDeprovisionPeer(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.email_mgr = _make_email_mgr()
self.cal_mgr = _make_cal_mgr()
self.am = _make_am(
self.tmp,
email_manager=self.email_mgr,
calendar_manager=self.cal_mgr,
file_manager=_make_file_mgr(),
)
self.am.provision('email', 'alice', password='pw')
self.am.provision('calendar', 'alice', password='pw')
def test_deprovision_peer_removes_from_all_services(self):
self.am.deprovision_peer('alice')
self.assertIsNone(self.am.get_credentials('email', 'alice'))
self.assertIsNone(self.am.get_credentials('calendar', 'alice'))
def test_deprovision_peer_returns_results_dict(self):
results = self.am.deprovision_peer('alice')
self.assertIn('email', results)
self.assertIn('calendar', results)
self.assertTrue(results['email'])
self.assertTrue(results['calendar'])
def test_deprovision_peer_continues_after_one_service_fails(self):
self.email_mgr.delete_email_user.side_effect = RuntimeError('smtp down')
results = self.am.deprovision_peer('alice')
self.assertFalse(results.get('email'))
# calendar should still succeed even though email failed
self.assertTrue(results.get('calendar'))
def test_deprovision_peer_no_op_for_unknown_peer(self):
results = self.am.deprovision_peer('nobody')
self.assertEqual(results, {})
# ── Direct credential storage ─────────────────────────────────────────────────
class TestStoreCredentials(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.am = _make_am(self.tmp)
def test_store_credentials_makes_them_retrievable(self):
self.am.store_credentials('email', 'alice', {'password': 'mypassword'})
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'mypassword'})
def test_store_credentials_overwrites_existing(self):
self.am.store_credentials('email', 'alice', {'password': 'old'})
self.am.store_credentials('email', 'alice', {'password': 'new'})
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'new'})
def test_store_credentials_creates_file_with_0600(self):
self.am.store_credentials('email', 'alice', {'password': 'pw'})
creds_path = self.tmp / 'peer_service_credentials.json'
mode = stat.S_IMODE(creds_path.stat().st_mode)
self.assertEqual(mode, 0o600)
# ── Thread safety ─────────────────────────────────────────────────────────────
class TestThreadSafety(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.am = _make_am(self.tmp)
def test_concurrent_store_credentials_no_data_loss(self):
errors = []
def worker(peer_name):
try:
self.am.store_credentials('email', peer_name, {'password': f'pw-{peer_name}'})
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(f'peer{i}',)) for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(errors, [])
accounts = self.am.list_accounts('email')
self.assertEqual(len(accounts), 20)
class TestEdgeCases(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.email_mgr = _make_email_mgr()
self.am = _make_am(self.tmp, email_manager=self.email_mgr,
calendar_manager=_make_cal_mgr(),
file_manager=_make_file_mgr())
def test_deprovision_peer_never_provisioned_returns_empty(self):
self.assertEqual(self.am.deprovision_peer('ghost'), {})
def test_deprovision_clears_credentials_even_when_manager_returns_false(self):
"""Credentials are removed even if underlying manager reports failure."""
self.am.provision('email', 'alice', password='pw')
self.email_mgr.delete_email_user.return_value = False
self.am.deprovision('email', 'alice')
self.assertIsNone(self.am.get_credentials('email', 'alice'))
def test_provision_twice_overwrites_credentials(self):
self.am.provision('email', 'alice', password='first')
self.am.provision('email', 'alice', password='second')
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'second'})
def test_provision_twice_calls_manager_both_times(self):
self.am.provision('email', 'alice', password='first')
self.am.provision('email', 'alice', password='second')
self.assertEqual(self.email_mgr.create_email_user.call_count, 2)
def test_corrupted_credentials_file_returns_empty_and_continues(self):
"""A corrupted JSON file is treated as empty rather than crashing."""
creds_path = self.tmp / 'peer_service_credentials.json'
creds_path.write_text('{invalid json}')
result = self.am.get_all_credentials('alice')
self.assertEqual(result, {})
def test_file_permissions_preserved_on_second_write(self):
"""0o600 must hold even after overwriting with a second provision."""
self.am.provision('email', 'alice', password='first')
self.am.provision('email', 'bob', password='second')
creds_path = self.tmp / 'peer_service_credentials.json'
mode = stat.S_IMODE(creds_path.stat().st_mode)
self.assertEqual(mode, 0o600, f'Expected 0o600 after overwrite, got {oct(mode)}')
def test_generated_password_is_url_safe(self):
"""token_urlsafe must not produce + or / characters."""
creds = self.am.provision('email', 'alice')
pwd = creds['password']
self.assertNotIn('+', pwd)
self.assertNotIn('/', pwd)
def test_store_then_deprovision_removes_credentials(self):
"""store_credentials + deprovision should cleanly remove the entry."""
self.am.store_credentials('email', 'alice', {'password': 'stored'})
self.am.deprovision('email', 'alice')
self.assertIsNone(self.am.get_credentials('email', 'alice'))
if __name__ == '__main__':
unittest.main()
+354
View File
@@ -0,0 +1,354 @@
"""
Tests for service-volume backup/restore in ConfigManager.
Covers:
- _backup_service_volumes: happy path, container not running, timeout
- _restore_service_volumes: happy path, missing archive, unknown service
- backup_config: passes service_registry, records includes_service_data
- restore_config: passes service_registry on full restore, not on selective
"""
import json
import subprocess
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
from config_manager import ConfigManager
def _make_cm(tmp_path: Path) -> ConfigManager:
cfg_file = tmp_path / 'cell_config.json'
cfg_file.write_text('{}')
cm = ConfigManager(config_file=str(cfg_file), data_dir=str(tmp_path))
return cm
def _make_registry(plan=None):
"""Return a mock ServiceRegistry with a preset backup plan."""
reg = MagicMock()
reg.get_backup_plan.return_value = plan if plan is not None else [
{
'service_id': 'email',
'volumes': [
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'},
{'container': 'cell-mail', 'path': '/var/mail-state', 'name': 'mailstate'},
],
'config_paths': [],
},
{
'service_id': 'calendar',
'volumes': [
{'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'},
],
'config_paths': [],
},
]
return reg
class TestBackupServiceVolumesHappyPath(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.cm = _make_cm(self.tmp)
self.backup_path = self.tmp / 'test_backup'
self.backup_path.mkdir()
def _run_backup(self, registry=None):
if registry is None:
registry = _make_registry()
self.cm._backup_service_volumes(self.backup_path, registry)
@patch('config_manager.subprocess.run')
def test_creates_service_data_dir(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
self._run_backup()
self.assertTrue((self.backup_path / 'service_data' / 'email').is_dir())
self.assertTrue((self.backup_path / 'service_data' / 'calendar').is_dir())
@patch('config_manager.subprocess.run')
def test_calls_docker_exec_tar_for_each_volume(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
self._run_backup()
commands = [tuple(c.args[0]) for c in mock_run.call_args_list]
self.assertIn(
('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-czf', '-', '.'),
commands,
)
self.assertIn(
('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail-state', '-czf', '-', '.'),
commands,
)
self.assertIn(
('docker', 'exec', '--', 'cell-radicale', 'tar', '-C', '/data', '-czf', '-', '.'),
commands,
)
@patch('config_manager.subprocess.run')
def test_writes_archive_files(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
self._run_backup()
self.assertTrue((self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists())
self.assertTrue((self.backup_path / 'service_data' / 'email' / 'mailstate.tar.gz').exists())
self.assertTrue((self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists())
@patch('config_manager.subprocess.run')
def test_removes_archive_on_nonzero_returncode(self, mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr=b'container not running')
self._run_backup()
self.assertFalse(
(self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists()
)
@patch('config_manager.subprocess.run')
def test_continues_after_one_volume_fails(self, mock_run):
def side_effect(cmd, **kwargs):
if 'cell-mail' in cmd:
return MagicMock(returncode=1, stderr=b'error')
return MagicMock(returncode=0, stderr=b'')
mock_run.side_effect = side_effect
self._run_backup()
# radicale should still succeed
self.assertTrue(
(self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists()
)
@patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300))
def test_timeout_removes_partial_archive(self, _mock_run):
self._run_backup()
# no archive should remain after a timeout
for svc in ('email', 'calendar'):
for name in ('maildata', 'mailstate', 'radicale_data'):
self.assertFalse(
(self.backup_path / 'service_data' / svc / f'{name}.tar.gz').exists()
)
@patch('config_manager.subprocess.run')
def test_empty_volumes_list_skipped(self, mock_run):
registry = _make_registry(plan=[
{'service_id': 'widget', 'volumes': [], 'config_paths': []}
])
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_get_backup_plan_exception_is_handled(self, mock_run):
registry = MagicMock()
registry.get_backup_plan.side_effect = RuntimeError('registry unavailable')
# should not raise
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_unsafe_container_name_rejected(self, mock_run):
registry = _make_registry(plan=[{
'service_id': 'evil', 'config_paths': [],
'volumes': [{'container': '-it cell-api', 'path': '/data', 'name': 'data'}],
}])
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_path_traversal_in_volume_path_rejected(self, mock_run):
registry = _make_registry(plan=[{
'service_id': 'evil', 'config_paths': [],
'volumes': [{'container': 'cell-mail', 'path': '/../etc', 'name': 'etc'}],
}])
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_relative_volume_path_rejected(self, mock_run):
registry = _make_registry(plan=[{
'service_id': 'evil', 'config_paths': [],
'volumes': [{'container': 'cell-mail', 'path': 'data/maildata', 'name': 'data'}],
}])
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_unsafe_volume_name_rejected(self, mock_run):
registry = _make_registry(plan=[{
'service_id': 'evil', 'config_paths': [],
'volumes': [{'container': 'cell-mail', 'path': '/var/mail', 'name': '../../etc/passwd'}],
}])
self.cm._backup_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_atomic_write_no_archive_on_partial_failure(self, mock_run):
"""If an exception occurs during subprocess, no .tar.gz file should remain."""
mock_run.side_effect = OSError('disk full')
self._run_backup()
for f in self.backup_path.rglob('*.tar.gz'):
self.fail(f'Archive {f} should not exist after exception during backup')
class TestRestoreServiceVolumes(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.cm = _make_cm(self.tmp)
self.backup_path = self.tmp / 'test_backup'
# Prepare a realistic backup structure
svc_data = self.backup_path / 'service_data'
(svc_data / 'email').mkdir(parents=True)
(svc_data / 'email' / 'maildata.tar.gz').write_bytes(b'fake-archive')
(svc_data / 'calendar').mkdir(parents=True)
(svc_data / 'calendar' / 'radicale_data.tar.gz').write_bytes(b'fake-archive')
def _make_registry_with_manifests(self):
reg = MagicMock()
def get_side_effect(service_id):
manifests = {
'email': {'backup': {'volumes': [
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'},
]}},
'calendar': {'backup': {'volumes': [
{'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'},
]}},
}
return manifests.get(service_id)
reg.get.side_effect = get_side_effect
return reg
@patch('config_manager.subprocess.run')
def test_calls_docker_exec_tar_for_each_archive(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
registry = self._make_registry_with_manifests()
self.cm._restore_service_volumes(self.backup_path, registry)
commands = [tuple(c.args[0]) for c in mock_run.call_args_list]
self.assertIn(
('docker', 'exec', '-i', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-xzf', '-'),
commands,
)
self.assertIn(
('docker', 'exec', '-i', '--', 'cell-radicale', 'tar', '-C', '/data', '-xzf', '-'),
commands,
)
@patch('config_manager.subprocess.run')
def test_skips_missing_archive(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
registry = MagicMock()
registry.get.return_value = {'backup': {'volumes': [
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'no_such_archive'},
]}}
self.cm._restore_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_skips_unknown_service(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
registry = MagicMock()
registry.get.return_value = None
self.cm._restore_service_volumes(self.backup_path, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run')
def test_no_service_data_dir_is_noop(self, mock_run):
empty_backup = self.tmp / 'empty_backup'
empty_backup.mkdir()
registry = self._make_registry_with_manifests()
self.cm._restore_service_volumes(empty_backup, registry)
mock_run.assert_not_called()
@patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300))
def test_timeout_is_handled_gracefully(self, _mock_run):
registry = self._make_registry_with_manifests()
# should not raise
self.cm._restore_service_volumes(self.backup_path, registry)
@patch('config_manager.subprocess.run')
def test_continues_after_docker_exec_failure(self, mock_run):
call_count = [0]
def side_effect(cmd, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return MagicMock(returncode=1, stderr=b'container not running')
return MagicMock(returncode=0, stderr=b'')
mock_run.side_effect = side_effect
registry = self._make_registry_with_manifests()
self.cm._restore_service_volumes(self.backup_path, registry)
self.assertEqual(call_count[0], 2)
class TestBackupConfigWithRegistry(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.cm = _make_cm(self.tmp)
@patch.object(ConfigManager, '_backup_service_volumes')
def test_backup_calls_volume_backup_when_registry_given(self, mock_bsv):
registry = _make_registry()
self.cm.backup_config(service_registry=registry)
mock_bsv.assert_called_once()
args = mock_bsv.call_args
self.assertIs(args[0][1], registry)
@patch.object(ConfigManager, '_backup_service_volumes')
def test_backup_skips_volume_backup_when_no_registry(self, mock_bsv):
self.cm.backup_config(service_registry=None)
mock_bsv.assert_not_called()
@patch.object(ConfigManager, '_backup_service_volumes')
def test_manifest_records_includes_service_data_true(self, _mock_bsv):
registry = _make_registry()
backup_id = self.cm.backup_config(service_registry=registry)
manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text())
self.assertTrue(manifest['includes_service_data'])
@patch.object(ConfigManager, '_backup_service_volumes')
def test_manifest_records_includes_service_data_false(self, _mock_bsv):
backup_id = self.cm.backup_config(service_registry=None)
manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text())
self.assertFalse(manifest['includes_service_data'])
class TestRestoreConfigWithRegistry(unittest.TestCase):
def setUp(self):
import tempfile
self.tmp = Path(tempfile.mkdtemp())
self.cm = _make_cm(self.tmp)
# Create a minimal backup
backup_id = 'backup_20260101_000000'
bp = self.cm.backup_dir / backup_id
bp.mkdir(parents=True)
(bp / 'cell_config.json').write_text('{}')
manifest = {'backup_id': backup_id, 'timestamp': '2026-01-01T00:00:00', 'services': []}
(bp / 'manifest.json').write_text(json.dumps(manifest))
self.backup_id = backup_id
@patch.object(ConfigManager, '_restore_service_volumes')
def test_full_restore_calls_volume_restore_when_registry_given(self, mock_rsv):
registry = _make_registry()
self.cm.restore_config(self.backup_id, service_registry=registry)
mock_rsv.assert_called_once()
args = mock_rsv.call_args
self.assertIs(args[0][1], registry)
@patch.object(ConfigManager, '_restore_service_volumes')
def test_full_restore_skips_volume_restore_when_no_registry(self, mock_rsv):
self.cm.restore_config(self.backup_id, service_registry=None)
mock_rsv.assert_not_called()
@patch.object(ConfigManager, '_restore_service_volumes')
def test_selective_restore_never_calls_volume_restore(self, mock_rsv):
"""Volume restore is skipped for selective restores (service list specified)."""
registry = _make_registry()
self.cm.restore_config(self.backup_id, services=['email'], service_registry=registry)
mock_rsv.assert_not_called()
if __name__ == '__main__':
unittest.main()
+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', content_type='application/json',
) )
mock_cm.restore_config.assert_called_once_with( 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') @patch('app.config_manager')
def test_restore_passes_none_services_when_no_body(self, mock_cm): def test_restore_passes_none_services_when_no_body(self, mock_cm):
from unittest.mock import ANY
mock_cm.restore_config.return_value = True mock_cm.restore_config.return_value = True
self.client.post('/api/config/restore/backup_001') 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): class TestExportConfig(unittest.TestCase):
+508
View File
@@ -0,0 +1,508 @@
"""
Unit tests for ServiceComposer.
All subprocess calls and filesystem writes are mocked no Docker daemon required.
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch, mock_open, call
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from service_composer import ServiceComposer, _SECRET_RE
# ── Helpers ─────────────────────────────────────────────────────────────────
def _make_cm(identity=None, service_config=None) -> MagicMock:
cm = MagicMock()
ident = identity or {'cell_name': 'testcell', 'domain': 'cell.local', 'domain_mode': 'lan'}
cm.get_identity.return_value = ident
cm.get_effective_domain.return_value = ident.get('domain', 'cell.local')
cm.configs = {}
if service_config:
cm.configs.update(service_config)
return cm
def _make_manifest(service_id='myservice', kind='store', schema=None):
return {
'id': service_id,
'kind': kind,
'config_schema': schema or {
'port': {'type': 'integer', 'default': 8080},
'username': {'type': 'string', 'default': 'admin'},
},
'containers': [f'cell-{service_id}'],
}
def _composer(cm=None, data_dir=None):
if data_dir is None:
data_dir = '/fake/data'
return ServiceComposer(config_manager=cm or _make_cm(), data_dir=data_dir)
# ── Template rendering ────────────────────────────────────────────────────────
class TestRenderTemplate(unittest.TestCase):
def setUp(self):
self.cm = _make_cm(service_config={'myservice': {'port': 9090}})
self.composer = _composer(self.cm)
def test_substitutes_pic_cfg_uppercase(self):
manifest = _make_manifest()
template = 'PORT=${PIC_CFG_PORT}'
result = self.composer.render_template('myservice', manifest, template)
self.assertEqual(result, 'PORT=9090')
def test_substitutes_default_when_no_saved_config(self):
cm = _make_cm()
composer = _composer(cm)
manifest = _make_manifest()
template = 'USER=${PIC_CFG_USERNAME}'
result = composer.render_template('myservice', manifest, template)
self.assertEqual(result, 'USER=admin')
def test_pic_domain_substituted(self):
manifest = _make_manifest()
template = 'DOMAIN=${PIC_DOMAIN}'
result = self.composer.render_template('myservice', manifest, template)
self.assertIn('cell.local', result)
def test_pic_cell_name_substituted(self):
manifest = _make_manifest()
template = 'CELL=${PIC_CELL_NAME}'
result = self.composer.render_template('myservice', manifest, template)
self.assertIn('testcell', result)
def test_pic_service_id_substituted(self):
manifest = _make_manifest(service_id='myservice')
template = 'ID=${PIC_SERVICE_ID}'
result = self.composer.render_template('myservice', manifest, template)
self.assertEqual(result, 'ID=myservice')
def test_pic_secret_generated_and_substituted(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
manifest = _make_manifest()
template = 'PASS=${PIC_SECRET_DB_PASS}'
result = composer.render_template('myservice', manifest, template)
self.assertNotIn('${PIC_SECRET_DB_PASS}', result)
self.assertNotEqual(result, 'PASS=')
# Secret is a non-empty string
password = result.replace('PASS=', '')
self.assertTrue(len(password) > 8)
def test_pic_secret_stable_across_calls(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
manifest = _make_manifest()
template = 'P=${PIC_SECRET_MY_PASS}'
r1 = composer.render_template('myservice', manifest, template)
r2 = composer.render_template('myservice', manifest, template)
self.assertEqual(r1, r2)
def test_pic_secret_different_per_service(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
m1 = _make_manifest('svc1')
m2 = _make_manifest('svc2')
t = 'P=${PIC_SECRET_PASS}'
r1 = composer.render_template('svc1', m1, t)
r2 = composer.render_template('svc2', m2, t)
self.assertNotEqual(r1, r2)
def test_multiple_secrets_all_replaced(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
manifest = _make_manifest()
template = 'A=${PIC_SECRET_KEY_A}\nB=${PIC_SECRET_KEY_B}'
result = composer.render_template('myservice', manifest, template)
self.assertNotIn('${PIC_SECRET_', result)
def test_no_unknown_vars_left_from_schema(self):
# Use a fresh composer with no saved config so defaults apply
composer = _composer(_make_cm())
manifest = _make_manifest(schema={
'port': {'type': 'integer', 'default': 3000},
})
template = 'PORT=${PIC_CFG_PORT}\nOTHER=${PIC_CFG_UNKNOWN}'
result = composer.render_template('myservice', manifest, template)
# Known var substituted with default, unknown left alone (no crash)
self.assertIn('PORT=3000', result)
self.assertIn('${PIC_CFG_UNKNOWN}', result)
# ── Write compose file ────────────────────────────────────────────────────────
class TestWriteCompose(unittest.TestCase):
def test_writes_rendered_content_to_correct_path(self):
with tempfile.TemporaryDirectory() as tmpdir:
cm = _make_cm()
composer = ServiceComposer(config_manager=cm, data_dir=tmpdir)
manifest = _make_manifest()
template = 'PORT=${PIC_CFG_PORT}'
composer.write_compose('myservice', manifest, template)
expected_path = os.path.join(
tmpdir, 'services', 'myservice', 'docker-compose.yml'
)
self.assertTrue(os.path.exists(expected_path))
with open(expected_path) as f:
content = f.read()
self.assertIn('8080', content)
def test_has_compose_file_false_before_write(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
self.assertFalse(composer.has_compose_file('newservice'))
def test_has_compose_file_true_after_write(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
manifest = _make_manifest()
composer.write_compose('myservice', manifest, 'content: true')
self.assertTrue(composer.has_compose_file('myservice'))
def test_atomic_write_via_tmp_file(self):
"""If fsync fails, the compose file should not be partially written."""
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
manifest = _make_manifest()
# Should not raise even if fsync not available
composer.write_compose('myservice', manifest, 'content: yes')
path = os.path.join(tmpdir, 'services', 'myservice', 'docker-compose.yml')
self.assertTrue(os.path.exists(path))
# ── Secrets ───────────────────────────────────────────────────────────────────
class TestSecrets(unittest.TestCase):
def test_secrets_persisted_to_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
composer._get_or_create_secret('svc', 'PIC_SECRET_PASS')
secrets_path = os.path.join(tmpdir, 'service_secrets.json')
self.assertTrue(os.path.exists(secrets_path))
with open(secrets_path) as f:
data = json.load(f)
self.assertIn('svc', data)
self.assertIn('PIC_SECRET_PASS', data['svc'])
def test_clear_secrets_removes_service_entry(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
composer._get_or_create_secret('svc', 'PIC_SECRET_KEY')
composer._clear_secrets('svc')
secrets = composer._load_secrets()
self.assertNotIn('svc', secrets)
def test_clear_secrets_noop_when_no_secrets_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
# Should not raise
composer._clear_secrets('nonexistent')
def test_load_secrets_returns_empty_when_file_missing(self):
composer = _composer(data_dir='/nonexistent/path')
self.assertEqual(composer._load_secrets(), {})
# ── Subprocess execution ──────────────────────────────────────────────────────
class TestDockerComposeExecution(unittest.TestCase):
def _composer_with_compose_file(self, tmpdir, service_id='myservice'):
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
svc_dir = os.path.join(tmpdir, 'services', service_id)
os.makedirs(svc_dir)
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
f.write('services:\n app:\n image: nginx\n')
return composer
@patch('service_composer.subprocess.run')
def test_up_calls_docker_compose_up(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
composer.up('myservice')
cmd = mock_run.call_args[0][0]
self.assertIn('up', cmd)
self.assertIn('-d', cmd)
self.assertIn('--project-name', cmd)
self.assertIn('pic-myservice', cmd)
@patch('service_composer.subprocess.run')
def test_down_calls_docker_compose_down(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
composer.down('myservice')
cmd = mock_run.call_args[0][0]
self.assertIn('down', cmd)
@patch('service_composer.subprocess.run')
def test_down_with_purge_passes_volumes_flag(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
composer.down('myservice', remove_volumes=True)
cmd = mock_run.call_args[0][0]
self.assertIn('--volumes', cmd)
@patch('service_composer.subprocess.run')
def test_restart_calls_docker_compose_restart(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
composer.restart('myservice')
cmd = mock_run.call_args[0][0]
self.assertIn('restart', cmd)
@patch('service_composer.subprocess.run')
def test_status_parses_json_output(self, mock_run):
container_info = {'Name': 'myservice-app', 'State': 'running'}
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(container_info),
stderr='',
)
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
result = composer.status('myservice')
self.assertTrue(result['ok'])
self.assertEqual(len(result['containers']), 1)
self.assertEqual(result['containers'][0]['Name'], 'myservice-app')
@patch('service_composer.subprocess.run')
def test_status_returns_empty_containers_on_bad_json(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='not json', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
result = composer.status('myservice')
self.assertEqual(result['containers'], [])
def test_store_cmd_returns_error_when_no_compose_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
composer = _composer(data_dir=tmpdir)
result = composer.up('nonexistent')
self.assertFalse(result['ok'])
self.assertIn('No compose file', result['error'])
@patch('service_composer.subprocess.run')
def test_up_uses_600s_timeout(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = self._composer_with_compose_file(tmpdir)
composer.up('myservice')
_, kwargs = mock_run.call_args
self.assertGreaterEqual(kwargs.get('timeout', 0), 600)
@patch('service_composer.subprocess.run')
def test_run_returns_error_on_timeout(self, mock_run):
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired(cmd=[], timeout=120)
composer = _composer()
result = composer._run(['docker', 'compose', 'up'])
self.assertFalse(result['ok'])
self.assertIn('timed out', result['error'])
@patch('service_composer.subprocess.run')
def test_run_returns_false_on_nonzero_exit(self, mock_run):
mock_run.return_value = MagicMock(returncode=1, stdout='', stderr='error msg')
composer = _composer()
result = composer._run(['docker', 'compose', 'up'])
self.assertFalse(result['ok'])
self.assertEqual(result['stderr'], 'error msg')
# ── Builtin lifecycle ─────────────────────────────────────────────────────────
class TestBuiltinLifecycle(unittest.TestCase):
@patch('service_composer.subprocess.run')
def test_restart_builtin_includes_container_names(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
composer = _composer()
composer.restart_builtin(['cell-radicale'])
cmd = mock_run.call_args[0][0]
self.assertIn('cell-radicale', cmd)
self.assertIn('restart', cmd)
@patch('service_composer.subprocess.run')
def test_status_builtin_includes_container_names(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
composer = _composer()
composer.status_builtin(['cell-mail', 'cell-rainloop'])
cmd = mock_run.call_args[0][0]
self.assertIn('cell-mail', cmd)
self.assertIn('cell-rainloop', cmd)
def test_restart_builtin_empty_list_returns_error(self):
composer = _composer()
result = composer.restart_builtin([])
self.assertFalse(result['ok'])
def test_status_builtin_empty_list_returns_error(self):
composer = _composer()
result = composer.status_builtin([])
self.assertFalse(result['ok'])
# ── Unified dispatch ──────────────────────────────────────────────────────────
class TestUnifiedDispatch(unittest.TestCase):
@patch('service_composer.subprocess.run')
def test_restart_service_builtin_uses_main_compose(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
composer = _composer()
manifest = _make_manifest(kind='builtin')
manifest['containers'] = ['cell-myservice']
composer.restart_service('myservice', manifest)
cmd = mock_run.call_args[0][0]
self.assertIn('cell-myservice', cmd)
# Main compose flag present
self.assertIn('-f', cmd)
@patch('service_composer.subprocess.run')
def test_restart_service_store_uses_per_service_compose(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
# Create compose file for the store service
svc_dir = os.path.join(tmpdir, 'services', 'storesvc')
os.makedirs(svc_dir)
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
f.write('services:\n app:\n image: nginx\n')
manifest = _make_manifest('storesvc', kind='store')
composer.restart_service('storesvc', manifest)
cmd = mock_run.call_args[0][0]
self.assertIn('pic-storesvc', cmd)
# ── Remove ────────────────────────────────────────────────────────────────────
class TestServiceIdValidation(unittest.TestCase):
def test_valid_ids_accepted(self):
for sid in ('email', 'my-service', 'svc123', 'a1b2-c3'):
ServiceComposer._validate_service_id(sid) # should not raise
def test_dotdot_rejected(self):
with self.assertRaises(ValueError):
ServiceComposer._validate_service_id('..')
def test_dot_rejected(self):
with self.assertRaises(ValueError):
ServiceComposer._validate_service_id('.')
def test_slash_rejected(self):
with self.assertRaises(ValueError):
ServiceComposer._validate_service_id('evil/path')
def test_uppercase_rejected(self):
with self.assertRaises(ValueError):
ServiceComposer._validate_service_id('MyService')
def test_empty_string_rejected(self):
with self.assertRaises(ValueError):
ServiceComposer._validate_service_id('')
def test_newline_in_config_value_stripped(self):
"""A newline in a config value must not create a new YAML key (injection)."""
cm = _make_cm(service_config={'svc': {'port': '80\nnewline_attack: true'}})
composer = _composer(cm)
manifest = _make_manifest(schema={'port': {'type': 'string', 'default': '80'}})
result = composer.render_template('svc', manifest, 'PORT=${PIC_CFG_PORT}')
# The newline is stripped — 'newline_attack' is concatenated, not a separate YAML key
self.assertNotIn('\n', result)
class TestRemove(unittest.TestCase):
@patch('service_composer.subprocess.run')
def test_remove_deletes_compose_file(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
svc_dir = os.path.join(tmpdir, 'services', 'oldsvc')
os.makedirs(svc_dir)
compose_file = os.path.join(svc_dir, 'docker-compose.yml')
with open(compose_file, 'w') as f:
f.write('services: {}')
composer.remove('oldsvc', purge_data=False)
self.assertFalse(os.path.exists(compose_file))
@patch('service_composer.subprocess.run')
def test_remove_purge_deletes_service_directory(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
svc_dir = os.path.join(tmpdir, 'services', 'purgesvc')
os.makedirs(svc_dir)
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
f.write('services: {}')
with open(os.path.join(svc_dir, 'data.txt'), 'w') as f:
f.write('important data')
composer.remove('purgesvc', purge_data=True)
self.assertFalse(os.path.exists(svc_dir))
@patch('service_composer.subprocess.run')
def test_remove_purge_clears_secrets(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
with tempfile.TemporaryDirectory() as tmpdir:
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
composer._get_or_create_secret('purgesvc', 'PIC_SECRET_KEY')
svc_dir = os.path.join(tmpdir, 'services', 'purgesvc')
os.makedirs(svc_dir)
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
f.write('services: {}')
composer.remove('purgesvc', purge_data=True)
secrets = composer._load_secrets()
self.assertNotIn('purgesvc', secrets)
# ── Parse ps json ─────────────────────────────────────────────────────────────
class TestParsePsJson(unittest.TestCase):
def test_single_json_object(self):
line = json.dumps({'Name': 'c1', 'State': 'running'})
result = ServiceComposer._parse_ps_json(line)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['Name'], 'c1')
def test_multiple_json_lines(self):
lines = '\n'.join([
json.dumps({'Name': 'c1'}),
json.dumps({'Name': 'c2'}),
])
result = ServiceComposer._parse_ps_json(lines)
self.assertEqual(len(result), 2)
def test_ignores_blank_lines(self):
lines = '\n'.join([json.dumps({'Name': 'c1'}), '', json.dumps({'Name': 'c2'})])
result = ServiceComposer._parse_ps_json(lines)
self.assertEqual(len(result), 2)
def test_returns_empty_list_for_empty_output(self):
self.assertEqual(ServiceComposer._parse_ps_json(''), [])
def test_bad_json_lines_skipped(self):
lines = '\n'.join(['not json', json.dumps({'Name': 'c1'})])
result = ServiceComposer._parse_ps_json(lines)
self.assertEqual(len(result), 1)
if __name__ == '__main__':
unittest.main()
+347
View File
@@ -0,0 +1,347 @@
"""
Unit tests for ServiceRegistry.
Tests load actual built-in manifests from api/services/builtins/ and verify
that the registry merges config correctly, returns expected routes/backup plans,
and handles missing manifests gracefully.
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from service_registry import ServiceRegistry, _BUILTINS_DIR
def _make_cm(configs: dict = None) -> MagicMock:
cm = MagicMock()
cm.configs = configs or {}
cm.get_installed_services.return_value = {}
return cm
class TestBuiltinManifests(unittest.TestCase):
"""Verify the built-in manifest files are valid JSON with required fields."""
def _load(self, service_id: str) -> dict:
path = os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
self.assertTrue(os.path.exists(path), f'Missing manifest for {service_id}')
with open(path) as f:
return json.load(f)
def _assert_required(self, manifest: dict):
for field in ('schema_version', 'id', 'name', 'kind', 'capabilities'):
self.assertIn(field, manifest, f'Missing required field: {field}')
caps = manifest['capabilities']
for cap in ('has_subdomain', 'has_accounts', 'has_admin_config',
'has_storage', 'has_egress', 'has_api_hooks'):
self.assertIn(cap, caps, f'Missing capability flag: {cap}')
def test_email_manifest_valid(self):
m = self._load('email')
self._assert_required(m)
self.assertEqual(m['id'], 'email')
self.assertEqual(m['kind'], 'builtin')
self.assertIn('mail', [m.get('subdomain')] + (m.get('extra_subdomains') or []))
self.assertIn('webmail', m.get('extra_subdomains', []))
self.assertEqual(m['capabilities']['has_accounts'], True)
def test_calendar_manifest_valid(self):
m = self._load('calendar')
self._assert_required(m)
self.assertEqual(m['id'], 'calendar')
self.assertEqual(m['subdomain'], 'calendar')
def test_files_manifest_valid(self):
m = self._load('files')
self._assert_required(m)
self.assertEqual(m['id'], 'files')
self.assertIn('webdav', m.get('extra_subdomains', []))
def test_all_builtins_have_backup_volumes(self):
for svc_id in ('email', 'calendar', 'files'):
m = self._load(svc_id)
volumes = m.get('backup', {}).get('volumes')
self.assertTrue(volumes, f'{svc_id}: backup.volumes must not be empty')
for vol in volumes:
for field in ('container', 'path', 'name'):
self.assertIn(field, vol,
f'{svc_id}: backup volume entry missing {field!r}')
def test_all_builtins_have_peer_config_template(self):
for svc_id in ('email', 'calendar', 'files'):
m = self._load(svc_id)
self.assertTrue(m.get('peer_config_template'),
f'{svc_id}: peer_config_template must not be empty')
def test_config_schema_defaults_are_correct_types(self):
for svc_id in ('email', 'calendar', 'files'):
m = self._load(svc_id)
for field, spec in (m.get('config_schema') or {}).items():
if 'default' in spec:
if spec['type'] == 'integer':
self.assertIsInstance(
spec['default'], int,
f'{svc_id}.{field}: integer default must be int')
elif spec['type'] == 'string':
self.assertIsInstance(
spec['default'], str,
f'{svc_id}.{field}: string default must be str')
class TestServiceRegistryListAll(unittest.TestCase):
def setUp(self):
self.cm = _make_cm()
self.registry = ServiceRegistry(self.cm)
def test_lists_three_builtins(self):
services = self.registry.list_all()
ids = [s['id'] for s in services]
self.assertIn('email', ids)
self.assertIn('calendar', ids)
self.assertIn('files', ids)
def test_builtins_come_before_store_services(self):
self.cm.get_installed_services.return_value = {
'zstore': {'manifest': {
'id': 'zstore', 'name': 'Z Store', 'kind': 'store',
'capabilities': {}, 'config_schema': {}
}}
}
services = self.registry.list_all()
ids = [s['id'] for s in services]
# builtins (email, calendar, files) should all appear before zstore
for builtin_id in ('email', 'calendar', 'files'):
self.assertLess(ids.index(builtin_id), ids.index('zstore'))
def test_each_service_has_config_key(self):
for svc in self.registry.list_all():
self.assertIn('config', svc, f'{svc["id"]} missing config key')
def test_no_duplicate_ids(self):
services = self.registry.list_all()
ids = [s['id'] for s in services]
self.assertEqual(len(ids), len(set(ids)))
class TestServiceRegistryConfigMerge(unittest.TestCase):
def test_defaults_used_when_no_saved_config(self):
cm = _make_cm({'calendar': {}})
reg = ServiceRegistry(cm)
svc = reg.get('calendar')
self.assertEqual(svc['config']['port'], 5232)
def test_saved_config_overrides_defaults(self):
cm = _make_cm({'calendar': {'port': 9999}})
reg = ServiceRegistry(cm)
svc = reg.get('calendar')
self.assertEqual(svc['config']['port'], 9999)
def test_unknown_saved_keys_excluded(self):
cm = _make_cm({'calendar': {'port': 5232, 'unknown_field': 'x'}})
reg = ServiceRegistry(cm)
svc = reg.get('calendar')
self.assertNotIn('unknown_field', svc['config'])
def test_partial_override_keeps_other_defaults(self):
cm = _make_cm({'email': {'smtp_port': 2525}})
reg = ServiceRegistry(cm)
svc = reg.get('email')
self.assertEqual(svc['config']['smtp_port'], 2525)
self.assertEqual(svc['config']['imap_port'], 993)
class TestServiceRegistryGet(unittest.TestCase):
def setUp(self):
self.cm = _make_cm()
self.registry = ServiceRegistry(self.cm)
def test_returns_none_for_unknown_id(self):
self.assertIsNone(self.registry.get('nonexistent_service'))
def test_returns_builtin_by_id(self):
svc = self.registry.get('email')
self.assertIsNotNone(svc)
self.assertEqual(svc['id'], 'email')
def test_returns_store_service_from_installed(self):
self.cm.get_installed_services.return_value = {
'mywiki': {'manifest': {
'id': 'mywiki', 'name': 'Wiki', 'kind': 'store',
'capabilities': {}, 'config_schema': {}
}}
}
svc = self.registry.get('mywiki')
self.assertIsNotNone(svc)
self.assertEqual(svc['id'], 'mywiki')
class TestServiceRegistryGetCaddyRoutes(unittest.TestCase):
def setUp(self):
self.cm = _make_cm()
self.registry = ServiceRegistry(self.cm)
def test_all_builtins_appear_in_routes(self):
routes = self.registry.get_caddy_routes()
route_ids = [r['service_id'] for r in routes]
for svc_id in ('email', 'calendar', 'files'):
self.assertIn(svc_id, route_ids)
def test_email_route_has_webmail_extra_subdomain(self):
routes = self.registry.get_caddy_routes()
email_route = next(r for r in routes if r['service_id'] == 'email')
self.assertIn('webmail', email_route['extra_subdomains'])
def test_files_route_has_webdav_extra_subdomain(self):
routes = self.registry.get_caddy_routes()
files_route = next(r for r in routes if r['service_id'] == 'files')
self.assertIn('webdav', files_route['extra_subdomains'])
def test_services_without_subdomain_excluded(self):
self.cm.get_installed_services.return_value = {
'nosubdomain': {'manifest': {
'id': 'nosubdomain', 'name': 'NoSub', 'kind': 'store',
'capabilities': {'has_subdomain': False},
'config_schema': {}
}}
}
routes = self.registry.get_caddy_routes()
self.assertNotIn('nosubdomain', [r['service_id'] for r in routes])
class TestServiceRegistryGetBackupPlan(unittest.TestCase):
def setUp(self):
self.cm = _make_cm()
self.registry = ServiceRegistry(self.cm)
def test_all_builtins_in_backup_plan(self):
plan = self.registry.get_backup_plan()
plan_ids = [p['service_id'] for p in plan]
for svc_id in ('email', 'calendar', 'files'):
self.assertIn(svc_id, plan_ids)
def test_email_backup_includes_maildata_volume(self):
plan = self.registry.get_backup_plan()
email_plan = next(p for p in plan if p['service_id'] == 'email')
names = [v['name'] for v in email_plan['volumes']]
self.assertIn('maildata', names)
vol = next(v for v in email_plan['volumes'] if v['name'] == 'maildata')
self.assertEqual(vol['container'], 'cell-mail')
self.assertEqual(vol['path'], '/var/mail')
def test_calendar_backup_includes_radicale_volume(self):
plan = self.registry.get_backup_plan()
cal_plan = next(p for p in plan if p['service_id'] == 'calendar')
names = [v['name'] for v in cal_plan['volumes']]
self.assertIn('radicale_data', names)
vol = next(v for v in cal_plan['volumes'] if v['name'] == 'radicale_data')
self.assertEqual(vol['container'], 'cell-radicale')
self.assertEqual(vol['path'], '/data')
def test_files_backup_includes_both_volumes(self):
plan = self.registry.get_backup_plan()
files_plan = next(p for p in plan if p['service_id'] == 'files')
names = {v['name'] for v in files_plan['volumes']}
self.assertIn('filegator', names)
self.assertIn('files', names)
def test_service_without_storage_excluded(self):
self.cm.get_installed_services.return_value = {
'nostorage': {'manifest': {
'id': 'nostorage', 'name': 'NoStorage', 'kind': 'store',
'capabilities': {'has_storage': False},
'config_schema': {}
}}
}
plan = self.registry.get_backup_plan()
self.assertNotIn('nostorage', [p['service_id'] for p in plan])
class TestServiceRegistryGetPeerServiceInfo(unittest.TestCase):
def setUp(self):
self.cm = _make_cm({'calendar': {}})
self.registry = ServiceRegistry(self.cm)
def test_fills_domain_placeholder(self):
info = self.registry.get_peer_service_info(
'calendar', 'alice', 'example.com', {})
self.assertIn('example.com', info['caldav_url'])
def test_fills_peer_username(self):
info = self.registry.get_peer_service_info(
'calendar', 'bob', 'example.com', {})
self.assertIn('bob', info['caldav_url'])
def test_fills_credentials(self):
info = self.registry.get_peer_service_info(
'calendar', 'alice', 'example.com', {'password': 'secret123'})
self.assertEqual(info['password'], 'secret123')
def test_returns_none_for_unknown_service(self):
result = self.registry.get_peer_service_info(
'unknown_svc', 'alice', 'example.com', {})
self.assertIsNone(result)
def test_username_url_encoded_in_peer_url(self):
info = self.registry.get_peer_service_info(
'calendar', 'alice/../../etc', 'example.com', {})
self.assertNotIn('../', info['caldav_url'])
self.assertIn('alice%2F', info['caldav_url'])
def test_domain_not_altered_by_username(self):
info = self.registry.get_peer_service_info(
'calendar', 'alice@evil.com', 'legit.example.com', {})
self.assertIn('legit.example.com', info['caldav_url'])
class TestServiceRegistryConfigMergeTypeCoercion(unittest.TestCase):
def test_string_in_config_coerced_to_int(self):
cm = _make_cm({'calendar': {'port': '9999'}})
reg = ServiceRegistry(cm)
svc = reg.get('calendar')
self.assertIsInstance(svc['config']['port'], int)
self.assertEqual(svc['config']['port'], 9999)
def test_unconvertible_value_falls_back_to_default(self):
cm = _make_cm({'calendar': {'port': 'not_a_number'}})
reg = ServiceRegistry(cm)
svc = reg.get('calendar')
self.assertEqual(svc['config']['port'], 5232)
class TestServiceRegistryWithBrokenManifest(unittest.TestCase):
"""Registry must not crash when a manifest file is corrupt or missing."""
def test_missing_builtins_dir_returns_empty(self):
with patch('service_registry._BUILTINS_DIR', '/nonexistent/path'):
reg = ServiceRegistry(_make_cm())
self.assertEqual(reg.list_all(), [])
def test_malformed_json_manifest_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
bad_dir = os.path.join(tmpdir, 'bad_svc')
os.makedirs(bad_dir)
with open(os.path.join(bad_dir, 'manifest.json'), 'w') as f:
f.write('this is not json {{{')
with patch('service_registry._BUILTINS_DIR', tmpdir):
reg = ServiceRegistry(_make_cm())
# Should not raise; just return empty list
result = reg.list_all()
self.assertEqual(result, [])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react';
import { Users, Plus, Trash2, Eye, EyeOff, CheckCircle, XCircle, Copy, CheckCheck } from 'lucide-react';
import { accountsAPI } from '../services/api';
const CRED_LABELS = {
username: 'Username',
password: 'Password',
imap_host: 'IMAP server',
imap_port: 'IMAP port',
smtp_host: 'SMTP server',
smtp_port: 'SMTP port',
caldav_url: 'CalDAV URL',
webdav_url: 'WebDAV URL',
server: 'Server',
address: 'Email address',
};
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
function CopyBtn({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-1 shrink-0 text-gray-400 hover:text-gray-600"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
{copied ? <CheckCheck className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
);
}
export default function ServiceAccountsPanel({ serviceId, serviceName }) {
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [provisioning, setProvisioning] = useState(false);
const [removing, setRemoving] = useState(null);
const [credentials, setCredentials] = useState({});
const [toast, setToast] = useState(null);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3500);
};
const load = async () => {
setLoading(true);
setLoadError(false);
try {
const r = await accountsAPI.list(serviceId);
setAccounts(r.data.accounts || []);
} catch {
setAccounts([]);
setLoadError(true);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [serviceId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleProvision = async () => {
const username = newUsername.trim();
if (!username) return;
setProvisioning(true);
try {
await accountsAPI.provision(serviceId, username);
showToast(`${serviceName} account created for ${username}`);
setNewUsername('');
load();
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to provision account', 'error');
} finally {
setProvisioning(false);
}
};
const handleDeprovision = async (username) => {
if (!window.confirm(`Remove the ${serviceName.toLowerCase()} account for "${username}"? This cannot be undone.`)) return;
setRemoving(username);
try {
await accountsAPI.deprovision(serviceId, username);
showToast(`Account removed for ${username}`);
setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
load();
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to remove account', 'error');
} finally {
setRemoving(null);
}
};
const toggleCredentials = async (username) => {
if (credentials[username]) {
setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
return;
}
try {
const r = await accountsAPI.getCredentials(serviceId, username);
setCredentials(prev => ({ ...prev, [username]: r.data }));
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to load credentials', 'error');
}
};
return (
<div className="space-y-4">
<Toast {...(toast || {})} />
{/* Provision form */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Users className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Provision Account</h3>
</div>
<p className="text-sm text-gray-500 mb-3">
Create a {serviceName.toLowerCase()} account for a peer. The peer uses their dashboard password to authenticate.
</p>
<div className="space-y-2">
<label htmlFor={`provision-${serviceId}`} className="block text-sm font-medium text-gray-700">
Peer username
</label>
<div className="flex gap-2">
<input
id={`provision-${serviceId}`}
type="text"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleProvision()}
placeholder="e.g. alice"
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
/>
<button
onClick={handleProvision}
disabled={provisioning || !newUsername.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus className="h-4 w-4" />
{provisioning ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
{/* Account list */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-medium text-gray-900">
Provisioned Accounts
{accounts.length > 0 && (
<span className="ml-2 text-xs font-normal text-gray-400">({accounts.length})</span>
)}
</h3>
</div>
{loading ? (
<p className="text-sm text-gray-400 py-4 text-center">Loading</p>
) : loadError ? (
<p className="text-sm text-red-500 py-4 text-center">
Failed to load accounts. Check your connection and try again.
</p>
) : accounts.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
No peer accounts provisioned yet. Use the form above to create one.
</p>
) : (
<div className="space-y-2">
{accounts.map(username => (
<div key={username} className="rounded-lg border border-gray-100 bg-gray-50 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm font-medium text-gray-800">{username}</span>
<div className="flex items-center gap-3">
<button
onClick={() => toggleCredentials(username)}
className="inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium"
>
{credentials[username]
? <><EyeOff className="h-3.5 w-3.5" /> Hide</>
: <><Eye className="h-3.5 w-3.5" /> Credentials</>}
</button>
<button
onClick={() => handleDeprovision(username)}
disabled={removing === username}
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-800 font-medium disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{removing === username ? 'Removing…' : 'Remove'}
</button>
</div>
</div>
{credentials[username] && (
<div className="border-t border-gray-200 px-3 py-2 bg-white space-y-1">
{Object.entries(credentials[username]).map(([k, v]) => (
<div key={k} className="flex flex-wrap items-start gap-x-2 gap-y-0.5 text-xs py-0.5">
<span className="text-gray-500 w-28 shrink-0">{CRED_LABELS[k] || k}</span>
<div className="flex items-start min-w-0 flex-1">
<span className="font-mono text-gray-700 break-all">{String(v)}</span>
<CopyBtn text={String(v)} />
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
+27 -2
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const CAL_DEFAULTS = { port: 5232, data_dir: '' }; const CAL_DEFAULTS = { port: 5232, data_dir: '' };
@@ -68,6 +69,7 @@ function Toast({ msg, type }) {
export default function CalendarPage() { export default function CalendarPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -160,11 +162,31 @@ export default function CalendarPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1> <h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p> <p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="calendar" serviceName="Calendar" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection info */} {/* Connection info */}
<div className="card"> <div className="card">
@@ -287,7 +309,10 @@ export default function CalendarPage() {
)} )}
</div> </div>
{isAdmin && ( </div>
)}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
calCfg={calCfg} calCfg={calCfg}
onChange={handleChange} onChange={handleChange}
+27 -3
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 }; const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 };
@@ -78,6 +79,7 @@ function Toast({ msg, type }) {
export default function EmailPage() { export default function EmailPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -182,11 +184,31 @@ export default function EmailPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1> <h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p> <p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="email" serviceName="Email" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IMAP */} {/* IMAP */}
<div className="card"> <div className="card">
@@ -291,8 +313,10 @@ export default function EmailPage() {
)} )}
</div> </div>
{/* Admin config form */} </div>
{isAdmin && ( )}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
emailCfg={emailCfg} emailCfg={emailCfg}
onChange={handleChange} onChange={handleChange}
+27 -2
View File
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 }; const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 };
@@ -75,6 +76,7 @@ function Toast({ msg, type }) {
export default function FilesPage() { export default function FilesPage() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [activeTab, setActiveTab] = useState('overview');
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig(); const draftConfig = useDraftConfig();
@@ -171,11 +173,31 @@ export default function FilesPage() {
<div> <div>
<Toast {...(toast || {})} /> <Toast {...(toast || {})} />
<div className="mb-8"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1> <h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p> <p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div> </div>
{isAdmin && (
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}>
{tab.label}
</button>
))}
</nav>
</div>
)}
{activeTab === 'accounts' && isAdmin ? (
<ServiceAccountsPanel serviceId="files" serviceName="Files" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File manager */} {/* File manager */}
<div className="card"> <div className="card">
@@ -291,7 +313,10 @@ export default function FilesPage() {
)} )}
</div> </div>
{isAdmin && ( </div>
)}
{isAdmin && activeTab !== 'accounts' && (
<AdminConfigSection <AdminConfigSection
filesCfg={filesCfg} filesCfg={filesCfg}
onChange={handleChange} onChange={handleChange}
+14
View File
@@ -273,6 +273,20 @@ export const servicesAPI = {
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`), restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`),
}; };
// Accounts API (peer service account provisioning via AccountManager)
export const accountsAPI = {
list: (serviceId) => api.get(`/api/services/catalog/${serviceId}/accounts`),
provision: (serviceId, username, password) =>
api.post(`/api/services/catalog/${serviceId}/accounts`, {
username,
...(password ? { password } : {}),
}),
deprovision: (serviceId, username) =>
api.delete(`/api/services/catalog/${serviceId}/accounts/${username}`),
getCredentials: (serviceId, username) =>
api.get(`/api/services/catalog/${serviceId}/accounts/${username}/credentials`),
};
// Cell-to-cell connections API // Cell-to-cell connections API
export const cellLinkAPI = { export const cellLinkAPI = {
getInvite: () => api.get('/api/cells/invite'), getInvite: () => api.get('/api/cells/invite'),