feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
Unit Tests / test (push) Failing after 11s
Unit Tests / test (push) Failing after 11s
Previously, CaddyManager and NetworkManager contained hardcoded lists of service names (calendar, files, mail, webdav, etc.), meaning every new service required a code change to appear in Caddy routes and DNS records. Now both managers accept a service_registry parameter and derive their service lists dynamically from the registry at runtime. - CaddyManager: new _build_registry_service_routes() and _http01_service_pairs() methods pull routes from the registry - NetworkManager: new _get_service_subdomains() method returns registry subdomains with a hardcoded fallback when no registry is wired in; _build_dns_records, stale-record detection, and service name sets all use the registry - managers.py: service_registry constructed before network_manager so it can be injected into both CaddyManager and NetworkManager - service_registry.py: validation chokepoint in get_caddy_routes() rejects invalid subdomain/backend values and reserved service names - service_store_manager.py: _validate_manifest now validates top-level subdomain, backend, extra_subdomains, and extra_backends fields - tests: 24 new tests covering registry-driven routing and DNS subdomain generation (test_caddy_registry_integration.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -784,8 +784,8 @@ def apply_pending_config():
|
||||
@bp.route('/api/config/backup', methods=['POST'])
|
||||
def create_config_backup():
|
||||
try:
|
||||
from app import config_manager, service_bus, EventType
|
||||
backup_id = config_manager.backup_config()
|
||||
from app import config_manager, service_bus, service_registry, EventType
|
||||
backup_id = config_manager.backup_config(service_registry=service_registry)
|
||||
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
|
||||
'backup_id': backup_id,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
@@ -809,9 +809,14 @@ def list_config_backups():
|
||||
@bp.route('/api/config/restore/<backup_id>', methods=['POST'])
|
||||
def restore_config(backup_id):
|
||||
try:
|
||||
from app import config_manager, service_bus, EventType
|
||||
from app import config_manager, service_bus, service_registry, EventType
|
||||
data = request.get_json(silent=True) or {}
|
||||
success = config_manager.restore_config(backup_id, services=data.get('services'))
|
||||
services = data.get('services')
|
||||
success = config_manager.restore_config(
|
||||
backup_id,
|
||||
services=services,
|
||||
service_registry=service_registry if services is None else None,
|
||||
)
|
||||
if success:
|
||||
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
|
||||
'backup_id': backup_id,
|
||||
|
||||
@@ -125,6 +125,17 @@ def add_peer():
|
||||
return jsonify({"error": f"Peer {peer_name} already exists"}), 400
|
||||
peer_added_to_registry = True
|
||||
|
||||
# Store credentials only after the peer is committed — avoids orphaned
|
||||
# credential entries if peer_registry.add_peer rejects a duplicate name.
|
||||
try:
|
||||
from app import account_manager
|
||||
_svc_names = {'email', 'calendar', 'files'}
|
||||
for svc in provisioned:
|
||||
if svc in _svc_names:
|
||||
account_manager.store_credentials(svc, peer_name, {'password': password})
|
||||
except Exception as _am_err:
|
||||
logger.warning(f"Peer {peer_name}: credential storage failed (non-fatal): {_am_err}")
|
||||
|
||||
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info,
|
||||
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
|
||||
firewall_applied = True
|
||||
@@ -320,12 +331,46 @@ def remove_peer(peer_name):
|
||||
_cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app import account_manager
|
||||
account_manager.deprovision_peer(peer_name)
|
||||
except Exception as _am_err:
|
||||
logger.warning(f"Peer {peer_name}: account_manager cleanup failed (non-fatal): {_am_err}")
|
||||
return jsonify({"message": f"Peer {peer_name} removed successfully"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing peer: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/peers/<peer_name>/service-credentials', methods=['GET'])
|
||||
def get_peer_service_credentials(peer_name: str):
|
||||
"""Return service credentials for a peer across all provisioned services (admin only).
|
||||
|
||||
Returns filled peer_config_template values for each service the peer is provisioned on.
|
||||
Intended for an admin to view or copy credentials to share with the peer during
|
||||
device setup. The global enforce_auth gate already restricts this to admin sessions.
|
||||
|
||||
Phase 2 note: a peer-self-service variant should live at /api/peer/service-credentials
|
||||
(no path arg) and restrict to session['username'] to prevent cross-peer enumeration.
|
||||
"""
|
||||
try:
|
||||
from app import peer_registry, account_manager, service_registry, config_manager
|
||||
peer = peer_registry.get_peer(peer_name)
|
||||
if not peer:
|
||||
return jsonify({'error': f'Peer {peer_name!r} not found'}), 404
|
||||
raw_creds = account_manager.get_all_credentials(peer_name)
|
||||
identity = config_manager.get_identity()
|
||||
domain = config_manager.get_effective_domain() or identity.get('domain', '')
|
||||
result = {}
|
||||
for service_id, cred in raw_creds.items():
|
||||
svc_info = service_registry.get_peer_service_info(service_id, peer_name, domain, cred)
|
||||
result[service_id] = svc_info if svc_info is not None else cred
|
||||
return jsonify({'peer': peer_name, 'services': result})
|
||||
except Exception as e:
|
||||
logger.error('get_peer_service_credentials(%s): %s', peer_name, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/peers/register', methods=['POST'])
|
||||
def register_peer():
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,174 @@ from flask import Blueprint, request, jsonify
|
||||
logger = logging.getLogger('picell')
|
||||
bp = Blueprint('services', __name__)
|
||||
|
||||
@bp.route('/api/services/catalog', methods=['GET'])
|
||||
def get_services_catalog():
|
||||
"""
|
||||
Return all services (builtins + installed store packages) with merged config.
|
||||
Used by the frontend to build navigation and service pages dynamically.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry
|
||||
return jsonify({'services': service_registry.list_all()})
|
||||
except Exception as e:
|
||||
logger.error('get_services_catalog: %s', e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>', methods=['GET'])
|
||||
def get_service_catalog_entry(service_id: str):
|
||||
"""Return a single service manifest+config, or 404 if unknown."""
|
||||
try:
|
||||
from app import service_registry
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
return jsonify(svc)
|
||||
except Exception as e:
|
||||
logger.error('get_service_catalog_entry(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/status', methods=['GET'])
|
||||
def get_service_container_status(service_id: str):
|
||||
"""
|
||||
Return container status for a service.
|
||||
Builtins query the main compose stack; store services query their own compose project.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
result = service_composer.status_service(service_id, svc)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('get_service_container_status(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/restart', methods=['POST'])
|
||||
def restart_service_containers(service_id: str):
|
||||
"""
|
||||
Restart containers for a service.
|
||||
Builtins restart via the main compose stack; store services via their own compose project.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
result = service_composer.restart_service(service_id, svc)
|
||||
if result['ok']:
|
||||
return jsonify({'message': f'Service {service_id!r} restarted', **result})
|
||||
return jsonify({'error': result.get('stderr') or result.get('error', 'restart failed')}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('restart_service_containers(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/reconfigure', methods=['POST'])
|
||||
def reconfigure_service(service_id: str):
|
||||
"""
|
||||
Re-apply the stored compose file for a store service (rolling `up -d`).
|
||||
The compose template must already exist on disk from the original install —
|
||||
accepting templates from the request body is deliberately not supported
|
||||
(arbitrary compose files can mount host paths or request privileged mode).
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
if svc.get('kind') == 'builtin':
|
||||
return jsonify({'error': 'Builtins are reconfigured via their settings routes'}), 400
|
||||
if not service_composer.has_compose_file(service_id):
|
||||
return jsonify({'error': f'No compose file for {service_id!r} — install it first'}), 400
|
||||
|
||||
result = service_composer.up(service_id)
|
||||
if result['ok']:
|
||||
return jsonify({'message': f'Service {service_id!r} reconfigured', **result})
|
||||
return jsonify({'error': result.get('stderr') or result.get('error', 'reconfigure failed')}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('reconfigure_service(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['GET'])
|
||||
def list_service_accounts(service_id: str):
|
||||
"""Return peer usernames provisioned on a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
accounts = account_manager.list_accounts(service_id)
|
||||
return jsonify({'service_id': service_id, 'accounts': accounts})
|
||||
except Exception as e:
|
||||
logger.error('list_service_accounts(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['POST'])
|
||||
def provision_service_account(service_id: str):
|
||||
"""Provision a peer account on a service. Generates a password if none is given.
|
||||
|
||||
The generated or provided password is NOT echoed in this response — retrieve it
|
||||
separately via GET /api/services/catalog/<id>/accounts/<username>/credentials.
|
||||
This keeps passwords out of HTTP logs and browser network panels.
|
||||
"""
|
||||
try:
|
||||
from app import account_manager
|
||||
data = request.get_json(silent=True) or {}
|
||||
peer_username = data.get('username')
|
||||
if not peer_username:
|
||||
return jsonify({'error': 'username is required'}), 400
|
||||
account_manager.provision(service_id, peer_username,
|
||||
password=data.get('password'))
|
||||
return jsonify({'service_id': service_id, 'username': peer_username,
|
||||
'provisioned': True}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
except Exception as e:
|
||||
logger.error('provision_service_account(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts/<username>', methods=['DELETE'])
|
||||
def deprovision_service_account(service_id: str, username: str):
|
||||
"""Remove a peer's account from a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
ok = account_manager.deprovision(service_id, username)
|
||||
if ok:
|
||||
return jsonify({'message': f'{username!r} deprovisioned from {service_id!r}'})
|
||||
return jsonify({'error': 'deprovision failed'}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('deprovision_service_account(%s, %s): %s', service_id, username, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts/<username>/credentials', methods=['GET'])
|
||||
def get_service_account_credentials(service_id: str, username: str):
|
||||
"""Return stored credentials for a peer on a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
creds = account_manager.get_credentials(service_id, username)
|
||||
if creds is None:
|
||||
return jsonify({'error': f'{username!r} not provisioned on {service_id!r}'}), 404
|
||||
return jsonify({'service_id': service_id, 'username': username, **creds})
|
||||
except Exception as e:
|
||||
logger.error('get_service_account_credentials(%s, %s): %s', service_id, username, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/bus/status', methods=['GET'])
|
||||
def get_service_bus_status():
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user