#!/usr/bin/env python3 """ Personal Internet Cell API Server Provides REST API endpoints for managing: - Cell status and configuration - Network services (DNS, DHCP, NTP) - WireGuard VPN and peer management - Email, Calendar, and File services - Routing and VPN gateway - Vault and trust management (Phase 6) """ import os import json import logging from datetime import datetime from flask import Flask, request, jsonify, current_app from flask_cors import CORS import threading import time from collections import deque import json as pyjson from logging.handlers import RotatingFileHandler import uuid import contextvars # Track API start time for uptime calculation API_START_TIME = time.time() from network_manager import NetworkManager from wireguard_manager import WireGuardManager from peer_registry import PeerRegistry from email_manager import EmailManager from calendar_manager import CalendarManager from file_manager import FileManager from routing_manager import RoutingManager from cell_manager import CellManager from vault_manager import VaultManager from container_manager import ContainerManager from config_manager import ConfigManager from service_bus import ServiceBus, EventType from log_manager import LogManager from cell_link_manager import CellLinkManager import firewall_manager # Context variable for request info request_context = contextvars.ContextVar('request_context', default={}) # Set default log level and log file if not already defined LOG_LEVEL = globals().get('LOG_LEVEL', 'INFO') LOG_FILE = globals().get('LOG_FILE', 'picell.log') class ContextFilter(logging.Filter): def filter(self, record): ctx = request_context.get({}) for k, v in ctx.items(): setattr(record, k, v) return True class JsonFormatter(logging.Formatter): def format(self, record): log_record = { 'timestamp': self.formatTime(record, self.datefmt), 'level': record.levelname, 'name': record.name, 'message': record.getMessage(), 'request_id': getattr(record, 'request_id', None), 'client_ip': getattr(record, 'client_ip', None), 'method': getattr(record, 'method', None), 'path': getattr(record, 'path', None), 'status': getattr(record, 'status', None), 'user': getattr(record, 'user', None), } if record.exc_info: log_record['exception'] = self.formatException(record.exc_info) return pyjson.dumps({k: v for k, v in log_record.items() if v is not None}) json_formatter = JsonFormatter() context_filter = ContextFilter() handlers = [logging.StreamHandler()] try: file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5_000_000, backupCount=5, encoding='utf-8') file_handler.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) file_handler.setFormatter(json_formatter) file_handler.addFilter(context_filter) handlers.append(file_handler) except Exception as e: print(f"Warning: Could not create rotating log file handler: {e}") for h in handlers: h.setFormatter(json_formatter) h.addFilter(context_filter) logging.basicConfig( level=getattr(logging, LOG_LEVEL, logging.INFO), handlers=handlers ) logger = logging.getLogger('picell') # Flask app setup app = Flask(__name__) CORS(app) # Development mode flag app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production # Initialize enhanced components config_manager = ConfigManager( config_file=os.path.join(os.environ.get('CONFIG_DIR', '/app/config'), 'cell_config.json'), data_dir=os.environ.get('DATA_DIR', '/app/data'), ) service_bus = ServiceBus() log_manager = LogManager(log_dir='./data/logs') # Initialize service loggers service_log_configs = { 'network': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'wireguard': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'email': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'calendar': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'files': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'routing': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'vault': {'level': 'INFO', 'formatter': 'json', 'console': False}, 'api': {'level': 'INFO', 'formatter': 'json', 'console': True} } for service, config in service_log_configs.items(): log_manager.add_service_logger(service, config) # Apply any persisted log level overrides _levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json') if os.path.exists(_levels_file): try: with open(_levels_file) as _f: for _svc, _lvl in json.load(_f).items(): log_manager.set_service_level(_svc, _lvl) except Exception: pass # Start service bus service_bus.start() @app.before_request def enrich_log_context(): req_id = str(uuid.uuid4()) client_ip = request.remote_addr method = request.method path = request.path user = getattr(getattr(request, 'user', None), 'id', None) or 'anonymous' request_context.set({ 'request_id': req_id, 'client_ip': client_ip, 'method': method, 'path': path, 'user': user }) @app.after_request def log_request(response): ctx = request_context.get({}) ctx['status'] = response.status_code logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}") return response @app.teardown_request def clear_log_context(exc): request_context.set({}) # Initialize managers — paths configurable via env for testing _DATA_DIR = os.environ.get('DATA_DIR', '/app/data') _CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') network_manager = NetworkManager(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) email_manager = EmailManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) calendar_manager = CalendarManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) file_manager = FileManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) routing_manager = RoutingManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) cell_manager = CellManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) container_manager = ContainerManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) cell_link_manager = CellLinkManager( data_dir=_DATA_DIR, config_dir=_CONFIG_DIR, wireguard_manager=wireguard_manager, network_manager=network_manager, ) # Apply firewall + DNS rules from stored peer settings (survives API restarts) def _apply_startup_enforcement(): try: peers = peer_registry.list_peers() firewall_manager.apply_all_peer_rules(peers) firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH) logger.info(f"Applied enforcement rules for {len(peers)} peers on startup") except Exception as e: logger.warning(f"Startup enforcement failed (non-fatal): {e}") def _bootstrap_dns(): try: identity = config_manager.configs.get('_identity', {}) cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')) network_manager.bootstrap_dns_records(cell_name, domain, ip_range) except Exception as e: logger.warning(f"DNS bootstrap failed (non-fatal): {e}") COREFILE_PATH = '/app/config/dns/Corefile' # Run in background so startup isn't blocked waiting on docker exec threading.Thread(target=_apply_startup_enforcement, daemon=True).start() threading.Thread(target=_bootstrap_dns, daemon=True).start() # Register services with service bus service_bus.register_service('network', network_manager) service_bus.register_service('wireguard', wireguard_manager) service_bus.register_service('email', email_manager) service_bus.register_service('calendar', calendar_manager) service_bus.register_service('files', file_manager) service_bus.register_service('routing', routing_manager) service_bus.register_service('vault', app.vault_manager) service_bus.register_service('container', container_manager) # Unified health monitoring HEALTH_HISTORY_SIZE = 100 health_history = deque(maxlen=HEALTH_HISTORY_SIZE) health_monitor_running = True # Health alerting configuration HEALTH_ALERT_THRESHOLD = 3 # Number of consecutive failures before alert service_alert_counters = {} def perform_health_check(): """Perform a unified health check of all services, with alerting.""" try: # Use service bus to get health from all services result = { 'timestamp': datetime.utcnow().isoformat(), 'alerts': [] } # Get health from each service for service_name in service_bus.list_services(): try: service = service_bus.get_service(service_name) if hasattr(service, 'health_check'): health = service.health_check() else: health = service.get_status() result[service_name] = health except Exception as e: result[service_name] = {'error': str(e), 'status': 'offline'} # Health alerting logic — alert only when a service container is not running global service_alert_counters for service_name in service_bus.list_services(): if service_name in result: status = result[service_name] healthy = True if isinstance(status, dict): # Prefer status.running (container actually up) over healthy (connectivity tests) inner = status.get('status', {}) if isinstance(inner, dict): if 'running' in inner: healthy = inner['running'] elif 'status' in inner: healthy = str(inner['status']).lower() in ('ok', 'healthy', 'online', 'active') elif 'running' in status: healthy = status['running'] elif 'error' in status: healthy = False else: healthy = bool(status) # Only count as unhealthy if we're certain it's down if not healthy: service_alert_counters[service_name] = service_alert_counters.get(service_name, 0) + 1 if service_alert_counters[service_name] >= HEALTH_ALERT_THRESHOLD: alert_msg = f"ALERT: {service_name} unhealthy for {service_alert_counters[service_name]} consecutive checks." logger.warning(alert_msg) result['alerts'].append(alert_msg) # Publish alert event service_bus.publish_event(EventType.ERROR_OCCURRED, service_name, { 'error': alert_msg, 'service': service_name, 'consecutive_failures': service_alert_counters[service_name] }) else: # Reset counter if service is healthy if service_alert_counters.get(service_name, 0) > 0: logger.info(f"Service {service_name} recovered, resetting alert counter") service_alert_counters[service_name] = 0 logger.info(f"Unified health check: {result}") return result except Exception as e: logger.error(f"Unified health check failed: {e}") return {'error': str(e), 'timestamp': datetime.utcnow().isoformat()} def health_monitor_loop(): while health_monitor_running: with app.app_context(): health_result = perform_health_check() health_history.appendleft(health_result) # Publish health check event service_bus.publish_event(EventType.HEALTH_CHECK, 'api', health_result) time.sleep(60) # Check every 60 seconds # Start health monitor thread health_monitor_thread = threading.Thread(target=health_monitor_loop, daemon=True) health_monitor_thread.start() def is_local_request(): # Allow requests from localhost, Docker networks, and internal IPs remote_addr = request.remote_addr forwarded_for = request.headers.get('X-Forwarded-For', '') # Check direct remote address if remote_addr in ('127.0.0.1', '::1', 'localhost'): return True # Check forwarded address (for reverse proxy scenarios) if forwarded_for: forwarded_ips = [ip.strip() for ip in forwarded_for.split(',')] for ip in forwarded_ips: if ip in ('127.0.0.1', '::1', 'localhost'): return True # Allow Docker internal networks (172.x.x.x, 192.168.x.x, 10.x.x.x) if remote_addr: try: import ipaddress ip = ipaddress.ip_address(remote_addr) if ip.is_private or ip.is_loopback: return True except: pass return False @app.route('/health', methods=['GET']) def health_check(): """Health check endpoint.""" try: return jsonify({ "status": "healthy", "timestamp": datetime.utcnow().isoformat(), "version": "1.0.0" }) except Exception as e: logger.error(f"Health check failed: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/status', methods=['GET']) def get_cell_status(): """Get overall cell status.""" try: # Use service bus to get status from all services services_status = {} for service_name in service_bus.list_services(): try: service = service_bus.get_service(service_name) services_status[service_name] = service.get_status() except Exception as e: services_status[service_name] = {'error': str(e)} peers = peer_registry.list_peers() # Calculate actual uptime current_time = time.time() uptime_seconds = int(current_time - API_START_TIME) identity = config_manager.configs.get('_identity', {}) return jsonify({ "cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), "domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), "uptime": uptime_seconds, "peers_count": len(peers), "services": services_status, "timestamp": datetime.utcnow().isoformat() }) except Exception as e: logger.error(f"Error getting cell status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config', methods=['GET']) def get_config(): """Get cell configuration.""" try: service_configs = config_manager.get_all_configs() identity = service_configs.pop('_identity', {}) config = { 'cell_name': identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')), 'domain': identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')), 'ip_range': identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')), 'wireguard_port': identity.get('wireguard_port', int(os.environ.get('WG_PORT', '51820'))), } # Expose computed per-service IPs so the frontend doesn't need to derive them import ip_utils as _ip_utils_cfg _ips = _ip_utils_cfg.get_service_ips(config['ip_range']) config['service_ips'] = { 'dns': _ips['dns'], 'vip_mail': _ips['vip_mail'], 'vip_calendar': _ips['vip_calendar'], 'vip_files': _ips['vip_files'], 'vip_webdav': _ips['vip_webdav'], } config['service_configs'] = service_configs return jsonify(config) except Exception as e: logger.error(f"Error getting config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config', methods=['PUT']) def update_config(): """Update cell configuration.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 # Handle identity fields (cell_name, domain, ip_range, wireguard_port) identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'} identity_updates = {k: v for k, v in data.items() if k in identity_keys} # Capture old identity and service configs BEFORE saving, for change detection old_identity = dict(config_manager.configs.get('_identity', {})) old_svc_configs = { svc: dict(config_manager.configs.get(svc, {})) for svc in data if svc in config_manager.service_schemas } if identity_updates: stored = config_manager.configs.get('_identity', {}) stored.update(identity_updates) config_manager.configs['_identity'] = stored config_manager._save_all_configs() # Map service names to their manager instances _svc_managers = { 'network': network_manager, 'wireguard': wireguard_manager, 'email': email_manager, 'calendar': calendar_manager, 'files': file_manager, 'routing': routing_manager, 'vault': app.vault_manager, } all_restarted = [] all_warnings = [] # Update service configurations: persist + apply to real config files for service, config in data.items(): if service in config_manager.service_schemas: config_manager.update_service_config(service, config) mgr = _svc_managers.get(service) if mgr: mgr.update_config(config) result = mgr.apply_config(config) all_restarted.extend(result.get('restarted', [])) all_warnings.extend(result.get('warnings', [])) service_bus.publish_event(EventType.CONFIG_CHANGED, service, { 'service': service, 'config': config }) # VPN port or subnet change → all peer client configs are stale if service == 'wireguard' and ('port' in config or 'address' in config): for p in peer_registry.list_peers(): peer_registry.update_peer(p['peer'], {'config_needs_reinstall': True}) n = len(peer_registry.list_peers()) if n: all_warnings.append(f'WireGuard endpoint changed — {n} peer(s) must reinstall VPN config') # Keep identity.wireguard_port in sync with service config port if 'port' in config: _id = config_manager.configs.get('_identity', {}) _id['wireguard_port'] = config['port'] config_manager.configs['_identity'] = _id config_manager._save_all_configs() # Apply cell identity domain to network and email services if identity_updates.get('domain'): domain = identity_updates['domain'] net_result = network_manager.apply_domain(domain) all_restarted.extend(net_result.get('restarted', [])) all_warnings.extend(net_result.get('warnings', [])) # Apply cell name change to DNS hostname record if identity_updates.get('cell_name'): old_name = old_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) new_name = identity_updates['cell_name'] if old_name != new_name: cn_result = network_manager.apply_cell_name(old_name, new_name) all_restarted.extend(cn_result.get('restarted', [])) all_warnings.extend(cn_result.get('warnings', [])) # Apply ip_range change: regenerate DNS records, update virtual IPs + firewall rules if identity_updates.get('ip_range'): import ip_utils new_range = identity_updates['ip_range'] cur_identity = config_manager.configs.get('_identity', {}) cur_cell_name = cur_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) cur_domain = cur_identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) # Update DNS zone records immediately ip_result = network_manager.apply_ip_range(new_range, cur_cell_name, cur_domain) all_restarted.extend(ip_result.get('restarted', [])) all_warnings.extend(ip_result.get('warnings', [])) # Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately firewall_manager.update_service_ips(new_range) firewall_manager.ensure_caddy_virtual_ips() # Write new .env with updated IPs (and current ports) for next container start env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs)) # Mark ALL containers as needing restart; network_recreate signals that # docker compose down is required before up (Docker can't change subnet in-place) _set_pending_restart( [f'ip_range changed to {new_range} — network will be recreated'], ['*'], network_recreate=True ) # Detect port changes across service configs and identity # Maps (service_key, field_name) → (port_env_key, [containers]) _PORT_CHANGE_MAP = { ('network', 'dns_port'): ('dns_port', ['dns']), ('wireguard','port'): ('wg_port', ['wireguard']), ('email', 'smtp_port'): ('mail_smtp_port', ['mail']), ('email', 'submission_port'): ('mail_submission_port', ['mail']), ('email', 'imap_port'): ('mail_imap_port', ['mail']), ('email', 'webmail_port'): ('rainloop_port', ['rainloop']), ('calendar', 'port'): ('radicale_port', ['radicale']), ('files', 'port'): ('webdav_port', ['webdav']), ('files', 'manager_port'): ('filegator_port', ['filegator']), } port_changed_containers = set() port_change_messages = [] for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items(): if svc_key in data and field in data[svc_key]: old_val = old_svc_configs.get(svc_key, {}).get(field) new_val = data[svc_key][field] if old_val is not None and old_val != new_val: port_changed_containers.update(containers) port_change_messages.append( f'{svc_key} {field}: {old_val} → {new_val}' ) # wireguard_port in identity also drives WG_PORT env var; sync to service config if 'wireguard_port' in identity_updates: old_wg = old_identity.get('wireguard_port') new_wg = identity_updates['wireguard_port'] if old_wg is not None and old_wg != new_wg: # Sync to wireguard service config and update wg0.conf _wg_svc = config_manager.configs.get('wireguard', {}) _wg_svc['port'] = new_wg config_manager.update_service_config('wireguard', _wg_svc) wireguard_manager.update_config({'port': new_wg}) port_changed_containers.add('wireguard') port_change_messages.append(f'wireguard_port: {old_wg} → {new_wg}') if port_changed_containers: import ip_utils as _ip_utils_ports _env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose') _ip_range = config_manager.configs.get('_identity', {}).get( 'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16') ) _ip_utils_ports.write_env_file( _ip_range, _env_file, _collect_service_ports(config_manager.configs) ) _set_pending_restart(port_change_messages, list(port_changed_containers)) logger.info(f"Updated config, restarted: {all_restarted}") return jsonify({ "message": "Configuration updated and applied", "restarted": all_restarted, "warnings": all_warnings, }) except Exception as e: logger.error(f"Error updating config: {e}") return jsonify({"error": str(e)}), 500 # --------------------------------------------------------------------------- # Pending-restart helpers # --------------------------------------------------------------------------- def _collect_service_ports(configs: dict) -> dict: """Extract current port values from service configs for .env generation.""" ports = {} net = configs.get('network', {}) wg = configs.get('wireguard', {}) email = configs.get('email', {}) cal = configs.get('calendar', {}) files = configs.get('files', {}) identity = configs.get('_identity', {}) if 'dns_port' in net: ports['dns_port'] = net['dns_port'] if 'port' in wg: ports['wg_port'] = wg['port'] elif 'wireguard_port' in identity: ports['wg_port'] = identity['wireguard_port'] if 'smtp_port' in email: ports['mail_smtp_port'] = email['smtp_port'] if 'submission_port' in email: ports['mail_submission_port'] = email['submission_port'] if 'imap_port' in email: ports['mail_imap_port'] = email['imap_port'] if 'webmail_port' in email: ports['rainloop_port'] = email['webmail_port'] if 'port' in cal: ports['radicale_port'] = cal['port'] if 'port' in files: ports['webdav_port'] = files['port'] if 'manager_port' in files: ports['filegator_port'] = files['manager_port'] return ports def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False): """Record that specific containers need to be restarted to apply configuration. containers: list of docker-compose service names, or None/'*' to restart all. network_recreate: True when the Docker bridge subnet changed (requires down+up). Merges with any existing pending state so multiple changes accumulate. """ from datetime import datetime as _dt existing = config_manager.configs.get('_pending_restart', {}) existing_changes = existing.get('changes', []) if existing.get('needs_restart') else [] existing_containers = existing.get('containers', []) if existing.get('needs_restart') else [] if containers is None or '*' in (containers or []) or existing_containers == ['*']: new_containers = ['*'] else: new_containers = list(set(existing_containers) | set(containers)) config_manager.configs['_pending_restart'] = { 'needs_restart': True, 'changed_at': _dt.utcnow().isoformat(), 'changes': existing_changes + changes, 'containers': new_containers, 'network_recreate': network_recreate or existing.get('network_recreate', False), } config_manager._save_all_configs() def _clear_pending_restart(): config_manager.configs['_pending_restart'] = { 'needs_restart': False, 'changes': [], 'containers': [], 'network_recreate': False } config_manager._save_all_configs() @app.route('/api/config/pending', methods=['GET']) def get_pending_config(): """Return whether there are unapplied configuration changes that require a restart.""" pending = config_manager.configs.get('_pending_restart', {}) return jsonify({ 'needs_restart': pending.get('needs_restart', False), 'changed_at': pending.get('changed_at'), 'changes': pending.get('changes', []), 'containers': pending.get('containers', ['*']), }) @app.route('/api/config/pending', methods=['DELETE']) def cancel_pending_config(): """Discard pending configuration changes without restarting any containers.""" _clear_pending_restart() return jsonify({'message': 'Pending changes discarded'}) @app.route('/api/config/apply', methods=['POST']) def apply_pending_config(): """Apply pending configuration by restarting containers via docker compose up -d.""" try: pending = config_manager.configs.get('_pending_restart', {}) if not pending.get('needs_restart'): return jsonify({'message': 'No pending changes to apply'}) # Get project working dir from our own container labels (set by docker-compose) project_dir = '/home/roof/pic' try: import docker as _docker_sdk _client = _docker_sdk.from_env() _self = _client.containers.get('cell-api') project_dir = _self.labels.get('com.docker.compose.project.working_dir', project_dir) except Exception: pass containers = pending.get('containers', ['*']) # Clear pending flag before we restart so it shows cleared after new containers start _clear_pending_restart() # Check if the IP range (network subnet) is changing — Docker cannot modify an # existing network's subnet in-place, so we need `down` + `up` in that case. needs_network_recreate = pending.get('network_recreate', False) if '*' in containers: if needs_network_recreate: # down removes containers AND the bridge network; up recreates everything compose_down_args = ['down'] compose_up_args = ['up', '-d'] else: compose_down_args = None compose_up_args = ['up', '-d'] else: compose_down_args = None compose_up_args = ['up', '-d', '--no-deps'] + containers base_cmd = ['docker', 'compose', '--project-directory', project_dir, '-f', '/app/docker-compose.yml', '--env-file', '/app/.env.compose'] # Run in a background thread; 0.3 s delay lets Flask send this response first. def _do_apply(): import time as _time _time.sleep(0.3) if compose_down_args: r = subprocess.run(base_cmd + compose_down_args, capture_output=True, text=True, timeout=60) if r.returncode != 0: logger.error(f"docker compose down failed: {r.stderr.strip()}") return result = subprocess.run(base_cmd + compose_up_args, capture_output=True, text=True, timeout=120) if result.returncode != 0: logger.error(f"docker compose up failed: {result.stderr.strip()}") else: logger.info(f'docker compose {" ".join(compose_up_args)} completed successfully') threading.Thread(target=_do_apply, daemon=False).start() return jsonify({ 'message': 'Applying configuration — containers are restarting', 'restart_in_progress': True, }) except Exception as e: logger.error(f"Error applying config: {e}") return jsonify({'error': str(e)}), 500 # Configuration management endpoints @app.route('/api/config/backup', methods=['POST']) def create_config_backup(): """Create configuration backup.""" try: backup_id = config_manager.backup_config() service_bus.publish_event(EventType.BACKUP_CREATED, 'api', { 'backup_id': backup_id, 'timestamp': datetime.utcnow().isoformat() }) return jsonify({"backup_id": backup_id}) except Exception as e: logger.error(f"Error creating backup: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config/backups', methods=['GET']) def list_config_backups(): """List available backups.""" try: backups = config_manager.list_backups() return jsonify(backups) except Exception as e: logger.error(f"Error listing backups: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config/restore/', methods=['POST']) def restore_config(backup_id): """Restore configuration from backup.""" try: success = config_manager.restore_config(backup_id) if success: service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', { 'backup_id': backup_id, 'timestamp': datetime.utcnow().isoformat() }) return jsonify({"message": f"Configuration restored from backup: {backup_id}"}) else: return jsonify({"error": f"Failed to restore backup: {backup_id}"}), 500 except Exception as e: logger.error(f"Error restoring backup: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config/export', methods=['GET']) def export_config(): """Export configuration.""" try: format = request.args.get('format', 'json') config_data = config_manager.export_config(format) return jsonify({"config": config_data, "format": format}) except Exception as e: logger.error(f"Error exporting config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config/import', methods=['POST']) def import_config(): """Import configuration.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 config_data = data.get('config') format = data.get('format', 'json') success = config_manager.import_config(config_data, format) if success: return jsonify({"message": "Configuration imported successfully"}) else: return jsonify({"error": "Failed to import configuration"}), 500 except Exception as e: logger.error(f"Error importing config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/config/backups/', methods=['DELETE']) def delete_config_backup(backup_id): """Delete a configuration backup.""" try: success = config_manager.delete_backup(backup_id) if success: return jsonify({"message": f"Backup {backup_id} deleted"}) else: return jsonify({"error": f"Failed to delete backup {backup_id}"}), 500 except Exception as e: logger.error(f"Error deleting backup: {e}") return jsonify({"error": str(e)}), 500 # Service bus endpoints @app.route('/api/services/bus/status', methods=['GET']) def get_service_bus_status(): """Get service bus status.""" try: return jsonify(service_bus.get_service_status_summary()) except Exception as e: logger.error(f"Error getting service bus status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services/bus/events', methods=['GET']) def get_service_bus_events(): """Get service bus event history.""" try: event_type = request.args.get('type') source = request.args.get('source') limit = int(request.args.get('limit', 100)) events = service_bus.get_event_history( EventType(event_type) if event_type else None, source, limit ) # Convert events to serializable format serializable_events = [] for event in events: serializable_events.append({ 'event_id': event.event_id, 'event_type': event.event_type.value, 'source': event.source, 'data': event.data, 'timestamp': event.timestamp.isoformat() }) return jsonify(serializable_events) except Exception as e: logger.error(f"Error getting service bus events: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services/bus/services//start', methods=['POST']) def start_service(service_name): """Start a service with orchestration.""" try: success = service_bus.orchestrate_service_start(service_name) if success: return jsonify({"message": f"Service {service_name} started successfully"}) else: return jsonify({"error": f"Failed to start service {service_name}"}), 500 except Exception as e: logger.error(f"Error starting service {service_name}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services/bus/services//stop', methods=['POST']) def stop_service(service_name): """Stop a service with orchestration.""" try: success = service_bus.orchestrate_service_stop(service_name) if success: return jsonify({"message": f"Service {service_name} stopped successfully"}) else: return jsonify({"error": f"Failed to stop service {service_name}"}), 500 except Exception as e: logger.error(f"Error stopping service {service_name}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services/bus/services//restart', methods=['POST']) def restart_service(service_name): """Restart a service with orchestration.""" try: success = service_bus.orchestrate_service_restart(service_name) if success: return jsonify({"message": f"Service {service_name} restarted successfully"}) else: return jsonify({"error": f"Failed to restart service {service_name}"}), 500 except Exception as e: logger.error(f"Error restarting service {service_name}: {e}") return jsonify({"error": str(e)}), 500 # Logging endpoints @app.route('/api/logs/services/', methods=['GET']) def get_service_logs(service): """Get logs for a specific service.""" try: level = request.args.get('level', 'INFO') lines = int(request.args.get('lines', 50)) logs = log_manager.get_service_logs(service, level, lines) return jsonify({"service": service, "logs": logs}) except Exception as e: logger.error(f"Error getting logs for {service}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/search', methods=['POST']) def search_logs(): """Search logs across all services.""" try: data = request.get_json(silent=True) or {} query = data.get('query', '') services = data.get('services') level = data.get('level') time_range = data.get('time_range') results = log_manager.search_logs(query, time_range, services, level) return jsonify({"results": results, "count": len(results)}) except Exception as e: logger.error(f"Error searching logs: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/export', methods=['POST']) def export_logs(): """Export logs in specified format.""" try: data = request.get_json(silent=True) or {} format = data.get('format', 'json') filters = data.get('filters', {}) log_data = log_manager.export_logs(format, filters) return jsonify({"logs": log_data, "format": format}) except Exception as e: logger.error(f"Error exporting logs: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/statistics', methods=['GET']) def get_log_statistics(): """Get log statistics.""" try: service = request.args.get('service') stats = log_manager.get_log_statistics(service) return jsonify(stats) except Exception as e: logger.error(f"Error getting log statistics: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/rotate', methods=['POST']) def rotate_logs(): """Manually rotate an API service log file.""" try: data = request.get_json(silent=True) or {} service = data.get('service') # None = rotate all log_manager.rotate_logs(service) return jsonify({"message": "Logs rotated successfully"}) except Exception as e: logger.error(f"Error rotating logs: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/files', methods=['GET']) def get_log_file_infos(): """List service log files with sizes.""" try: return jsonify(log_manager.get_all_log_file_infos()) except Exception as e: logger.error(f"Error listing log files: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/verbosity', methods=['GET']) def get_log_verbosity(): """Return current per-service log levels.""" try: return jsonify(log_manager.get_service_levels()) except Exception as e: logger.error(f"Error getting log verbosity: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/logs/verbosity', methods=['PUT']) def set_log_verbosity(): """Update log levels for one or all services. Body: {service: level} map.""" try: data = request.get_json(silent=True) or {} for service, level in data.items(): log_manager.set_service_level(service, level) # Persist to config so levels survive API restarts levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json') os.makedirs(os.path.dirname(levels_file), exist_ok=True) current = {} if os.path.exists(levels_file): try: with open(levels_file) as f: current = json.load(f) except Exception: pass current.update(data) with open(levels_file, 'w') as f: json.dump(current, f, indent=2) return jsonify({"message": "Log levels updated", "levels": log_manager.get_service_levels()}) except Exception as e: logger.error(f"Error setting log verbosity: {e}") return jsonify({"error": str(e)}), 500 # Network Services API @app.route('/api/dns/records', methods=['GET']) def get_dns_records(): """Get DNS records.""" try: records = network_manager.get_dns_records() return jsonify(records) except Exception as e: logger.error(f"Error getting DNS records: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dns/records', methods=['POST']) def add_dns_record(): """Add DNS record.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = network_manager.add_dns_record(**data) return jsonify(result) except Exception as e: logger.error(f"Error adding DNS record: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dns/records', methods=['DELETE']) def remove_dns_record(): """Remove DNS record.""" try: data = request.get_json(silent=True) result = network_manager.remove_dns_record(**data) return jsonify(result) except Exception as e: logger.error(f"Error removing DNS record: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dhcp/leases', methods=['GET']) def get_dhcp_leases(): """Get DHCP leases.""" try: leases = network_manager.get_dhcp_leases() return jsonify(leases) except Exception as e: logger.error(f"Error getting DHCP leases: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dhcp/reservations', methods=['POST']) def add_dhcp_reservation(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = network_manager.add_dhcp_reservation(data) return jsonify(result) except Exception as e: logger.error(f"Error adding DHCP reservation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dhcp/reservations', methods=['DELETE']) def remove_dhcp_reservation(): """Remove DHCP reservation.""" try: data = request.get_json(silent=True) result = network_manager.remove_dhcp_reservation(data) return jsonify(result) except Exception as e: logger.error(f"Error removing DHCP reservation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/ntp/status', methods=['GET']) def get_ntp_status(): """Get NTP status.""" try: status = network_manager.get_ntp_status() return jsonify(status) except Exception as e: logger.error(f"Error getting NTP status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/network/info', methods=['GET']) def get_network_info(): """Get general network info (interfaces, gateway, DNS, etc.)""" try: info = network_manager.get_network_info() return jsonify(info) except Exception as e: logger.error(f"Error getting network info: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/dns/status', methods=['GET']) def get_dns_status(): """Get DNS service status and summary info.""" try: status = network_manager.get_dns_status() return jsonify(status) except Exception as e: logger.error(f"Error getting DNS status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/network/test', methods=['POST']) def test_network(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = network_manager.test_connectivity(data) return jsonify(result) except Exception as e: logger.error(f"Error testing network: {e}") return jsonify({"error": str(e)}), 500 # WireGuard API @app.route('/api/wireguard/keys', methods=['GET']) def get_wireguard_keys(): """Get WireGuard keys.""" try: result = wireguard_manager.get_keys() return jsonify(result) except Exception as e: logger.error(f"Error getting WireGuard keys: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/keys/peer', methods=['POST']) def generate_peer_keys(): """Generate peer keys.""" try: data = request.get_json(silent=True) or {} name = data.get('name') or data.get('peer_name') if not name: return jsonify({"error": "Missing peer name"}), 400 result = wireguard_manager.generate_peer_keys(name) return jsonify(result) except Exception as e: logger.error(f"Error generating peer keys: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/config', methods=['GET']) def get_wireguard_config(): """Get WireGuard configuration.""" try: result = wireguard_manager.get_config() return jsonify(result) except Exception as e: logger.error(f"Error getting WireGuard config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers', methods=['GET']) def get_wireguard_peers(): """Get WireGuard peers.""" try: peers = wireguard_manager.get_peers() return jsonify(peers) except Exception as e: logger.error(f"Error getting WireGuard peers: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers', methods=['POST']) def add_wireguard_peer(): """Add WireGuard peer.""" try: data = request.get_json(silent=True) or {} result = wireguard_manager.add_peer( name=data.get('name', ''), public_key=data.get('public_key', ''), endpoint_ip=data.get('endpoint', data.get('endpoint_ip', '')), allowed_ips=data.get('allowed_ips', ''), persistent_keepalive=data.get('persistent_keepalive', 25) ) return jsonify({"success": result}) except Exception as e: logger.error(f"Error adding WireGuard peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers', methods=['DELETE']) def remove_wireguard_peer(): """Remove WireGuard peer.""" try: data = request.get_json(silent=True) or {} public_key = data.get('public_key') or data.get('name', '') result = wireguard_manager.remove_peer(public_key) return jsonify({"success": result}) except Exception as e: logger.error(f"Error removing WireGuard peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/status', methods=['GET']) def get_wireguard_status(): """Get WireGuard status.""" try: status = wireguard_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting WireGuard status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/connectivity', methods=['POST']) def test_wireguard_connectivity(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = wireguard_manager.test_connectivity(data) return jsonify(result) except Exception as e: logger.error(f"Error testing WireGuard connectivity: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers/ip', methods=['PUT']) def update_peer_ip(): """Update peer IP.""" try: data = request.get_json(silent=True) or {} result = wireguard_manager.update_peer_ip( data.get('public_key', data.get('peer', '')), data.get('ip', '') ) return jsonify({"success": result}) except Exception as e: logger.error(f"Error updating peer IP: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers/status', methods=['POST']) def get_peer_status(): """Get live WireGuard status for a single peer.""" try: data = request.get_json(silent=True) or {} public_key = data.get('public_key', '') if not public_key: return jsonify({"error": "Missing public_key"}), 400 status = wireguard_manager.get_peer_status(public_key) return jsonify(status) except Exception as e: logger.error(f"Error getting peer status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers/statuses', methods=['GET']) def get_all_peer_statuses(): """Get live WireGuard status for all peers (keyed by public_key).""" try: statuses = wireguard_manager.get_all_peer_statuses() return jsonify(statuses) except Exception as e: logger.error(f"Error getting peer statuses: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/network/setup', methods=['POST']) def setup_network(): """Setup network configuration for internet access.""" try: success = wireguard_manager.setup_network_configuration() if success: return jsonify({"message": "Network configuration setup completed successfully"}) else: return jsonify({"error": "Failed to setup network configuration"}), 500 except Exception as e: logger.error(f"Error setting up network configuration: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/network/status', methods=['GET']) def get_network_status(): """Get network configuration status.""" try: status = wireguard_manager.get_network_status() return jsonify(status) except Exception as e: logger.error(f"Error getting network status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/peers/config', methods=['POST']) def get_peer_config(): try: data = request.get_json(silent=True) or {} peer_name = data.get('name', data.get('peer', '')) # Look up peer details from registry if not supplied peer_ip = data.get('ip', '') peer_private_key = data.get('private_key', '') registered = peer_registry.get_peer(peer_name) if peer_name else {} if peer_name and (not peer_ip or not peer_private_key): if registered: peer_ip = peer_ip or registered.get('ip', '') peer_private_key = peer_private_key or registered.get('private_key', '') # Use real external endpoint if not supplied server_endpoint = data.get('server_endpoint', '') if not server_endpoint: srv = wireguard_manager.get_server_config() server_endpoint = srv.get('endpoint') or '' # Determine AllowedIPs: explicit > peer's stored internet_access > default full tunnel allowed_ips = data.get('allowed_ips') or None if not allowed_ips and registered: internet_access = registered.get('internet_access', True) allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() result = wireguard_manager.get_peer_config( peer_name=peer_name, peer_ip=peer_ip, peer_private_key=peer_private_key, server_endpoint=server_endpoint, allowed_ips=allowed_ips, ) return jsonify({"config": result}) except Exception as e: logger.error(f"Error getting peer config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/server-config', methods=['GET']) def get_server_config(): try: config = wireguard_manager.get_server_config() return jsonify(config) except Exception as e: logger.error(f"Error getting server config: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/refresh-ip', methods=['POST']) def refresh_external_ip(): try: ip = wireguard_manager.get_external_ip(force_refresh=True) port = wireguard_manager._get_configured_port() return jsonify({ 'external_ip': ip, 'port': port, 'endpoint': f'{ip}:{port}' if ip else None, }) except Exception as e: logger.error(f"Error refreshing external IP: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/wireguard/apply-enforcement', methods=['POST']) def apply_wireguard_enforcement(): """Re-apply per-peer iptables and DNS enforcement rules (call after WireGuard restart).""" try: peers = peer_registry.list_peers() firewall_manager.apply_all_peer_rules(peers) firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH) return jsonify({'ok': True, 'peers': len(peers)}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/wireguard/check-port', methods=['POST']) def check_wireguard_port(): try: port_open = wireguard_manager.check_port_open() return jsonify({'port_open': port_open, 'port': wireguard_manager._get_configured_port()}) except Exception as e: return jsonify({"error": str(e)}), 500 # ── Cell-to-cell connections ───────────────────────────────────────────────── @app.route('/api/cells/invite', methods=['GET']) def get_cell_invite(): """Generate an invite package for this cell.""" try: identity = config_manager.configs.get('_identity', {}) cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')) domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')) invite = cell_link_manager.generate_invite(cell_name, domain) return jsonify(invite) except Exception as e: logger.error(f"Error generating cell invite: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/cells', methods=['GET']) def list_cell_connections(): """List all connected cells.""" try: return jsonify(cell_link_manager.list_connections()) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/cells', methods=['POST']) def add_cell_connection(): """Connect to a remote cell using their invite package.""" try: data = request.get_json(silent=True) if not data: return jsonify({'error': 'No data provided'}), 400 for field in ('cell_name', 'public_key', 'vpn_subnet', 'dns_ip', 'domain'): if field not in data: return jsonify({'error': f'Missing field: {field}'}), 400 link = cell_link_manager.add_connection(data) return jsonify({'message': f"Connected to cell '{data['cell_name']}'", 'link': link}), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 except Exception as e: logger.error(f"Error adding cell connection: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/cells/', methods=['DELETE']) def remove_cell_connection(cell_name): """Disconnect from a remote cell.""" try: cell_link_manager.remove_connection(cell_name) return jsonify({'message': f"Cell '{cell_name}' disconnected"}) except ValueError as e: return jsonify({'error': str(e)}), 404 except Exception as e: logger.error(f"Error removing cell connection: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/cells//status', methods=['GET']) def get_cell_connection_status(cell_name): """Get live status for a connected cell.""" try: status = cell_link_manager.get_connection_status(cell_name) return jsonify(status) except ValueError as e: return jsonify({'error': str(e)}), 404 except Exception as e: return jsonify({'error': str(e)}), 500 # Peer Registry API @app.route('/api/peers', methods=['GET']) def get_peers(): """Get all peers.""" try: peers = peer_registry.list_peers() return jsonify(peers) except Exception as e: logger.error(f"Error getting peers: {e}") return jsonify({"error": str(e)}), 500 def _next_peer_ip() -> str: """Auto-assign the next free host address from the configured VPN subnet.""" import ipaddress server_addr = wireguard_manager._get_configured_address() # e.g. '10.0.0.1/24' network = ipaddress.ip_network(server_addr, strict=False) server_ip = str(ipaddress.ip_interface(server_addr).ip) used = {p.get('ip', '').split('/')[0] for p in peer_registry.list_peers()} for host in network.hosts(): ip = str(host) if ip == server_ip: continue if ip not in used: return ip raise ValueError(f'No free IPs left in {network}') @app.route('/api/peers', methods=['POST']) def add_peer(): """Add a peer.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 # Validate required fields (ip is optional — auto-assigned if omitted) required_fields = ['name', 'public_key'] for field in required_fields: if field not in data: return jsonify({"error": f"Missing required field: {field}"}), 400 assigned_ip = data.get('ip') or _next_peer_ip() # Add peer to registry with all provided fields peer_info = { 'peer': data['name'], 'ip': assigned_ip, 'public_key': data['public_key'], 'private_key': data.get('private_key'), 'server_public_key': data.get('server_public_key'), 'server_endpoint': data.get('server_endpoint'), 'allowed_ips': data.get('allowed_ips'), 'persistent_keepalive': data.get('persistent_keepalive'), 'description': data.get('description'), 'internet_access': data.get('internet_access', True), 'service_access': data.get('service_access', ['calendar', 'files', 'mail', 'webdav']), 'peer_access': data.get('peer_access', True), 'config_needs_reinstall': False, } success = peer_registry.add_peer(peer_info) if success: # Apply server-side enforcement immediately firewall_manager.apply_peer_rules(peer_info['ip'], peer_info) firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH) return jsonify({"message": f"Peer {data['name']} added successfully", "ip": assigned_ip}), 201 else: return jsonify({"error": f"Peer {data['name']} already exists"}), 400 except Exception as e: logger.error(f"Error adding peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/peers/', methods=['PUT']) def update_peer(peer_name): """Update peer settings. Marks config_needs_reinstall if VPN config changed.""" try: data = request.get_json(silent=True) or {} existing = peer_registry.get_peer(peer_name) if not existing: return jsonify({"error": "Peer not found"}), 404 # Detect changes that require client to reinstall tunnel config config_changed = ( ('internet_access' in data and data['internet_access'] != existing.get('internet_access', True)) or ('ip' in data and data['ip'] != existing.get('ip')) or ('persistent_keepalive' in data and data['persistent_keepalive'] != existing.get('persistent_keepalive')) ) updates = {k: v for k, v in data.items()} if config_changed: updates['config_needs_reinstall'] = True success = peer_registry.update_peer(peer_name, updates) if success: # Re-apply server-side enforcement with updated settings updated_peer = peer_registry.get_peer(peer_name) if updated_peer: firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer) firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH) result = {"message": f"Peer {peer_name} updated", "config_changed": config_changed} return jsonify(result) else: return jsonify({"error": "Update failed"}), 500 except Exception as e: logger.error(f"Error updating peer {peer_name}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/peers//clear-reinstall', methods=['POST']) def clear_peer_reinstall(peer_name): """Clear the config_needs_reinstall flag once user has downloaded new config.""" try: peer_registry.clear_reinstall_flag(peer_name) return jsonify({"message": "Reinstall flag cleared"}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/peers/', methods=['DELETE']) def remove_peer(peer_name): """Remove a peer.""" try: success = peer_registry.remove_peer(peer_name) if success: return jsonify({"message": f"Peer {peer_name} removed successfully"}) else: return jsonify({"message": f"Peer {peer_name} not found or already removed"}) except Exception as e: logger.error(f"Error removing peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/peers/register', methods=['POST']) def register_peer(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = peer_registry.register_peer(data) return jsonify(result) except Exception as e: logger.error(f"Error registering peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/peers//unregister', methods=['DELETE']) def unregister_peer(peer_name): """Unregister a peer.""" try: result = peer_registry.unregister_peer(peer_name) return jsonify(result) except Exception as e: logger.error(f"Error unregistering peer: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/peers//update-ip', methods=['PUT']) def update_peer_ip_registry(peer_name): """Update peer IP.""" try: data = request.get_json(silent=True) new_ip = data.get('ip') if data else None if not new_ip: return jsonify({"error": "Missing ip"}), 400 success = peer_registry.update_peer_ip(peer_name, new_ip) if success: # Update routing and WireGuard configs try: routing_manager.update_peer_ip(peer_name, new_ip) except Exception as e: logger.warning(f"RoutingManager update_peer_ip failed: {e}") try: # For now, skip WireGuard update - method not implemented logger.warning(f"WireGuardManager update_peer_ip not implemented yet") except Exception as e: logger.warning(f"WireGuardManager update_peer_ip failed: {e}") return jsonify({"message": f"IP update received for {peer_name}"}) else: return jsonify({"error": f"Peer {peer_name} not found"}), 404 except Exception as e: logger.error(f"Error updating peer IP: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/ip-update', methods=['POST']) def ip_update(): """Handle IP update from peer.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 peer_name = data.get('peer') new_ip = data.get('ip') if not peer_name or not new_ip: return jsonify({"error": "Missing peer or ip"}), 400 success = peer_registry.update_peer_ip(peer_name, new_ip) if success: # Update routing and WireGuard configs try: routing_manager.update_peer_ip(peer_name, new_ip) except Exception as e: logger.warning(f"RoutingManager update_peer_ip failed: {e}") try: # For now, skip WireGuard update - method not implemented logger.warning(f"WireGuardManager update_peer_ip not implemented yet") except Exception as e: logger.warning(f"WireGuardManager update_peer_ip failed: {e}") return jsonify({"message": f"IP update received for {peer_name}"}) else: return jsonify({"error": f"Peer {peer_name} not found"}), 404 except Exception as e: logger.error(f"Error handling IP update: {e}") return jsonify({"error": str(e)}), 500 # Email Services API @app.route('/api/email/users', methods=['GET']) def get_email_users(): """Get email users.""" try: users = email_manager.get_users() return jsonify(users) except Exception as e: logger.error(f"Error getting email users: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/users', methods=['POST']) def create_email_user(): """Create email user.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = email_manager.create_user(data) return jsonify(result) except Exception as e: logger.error(f"Error creating email user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/users/', methods=['DELETE']) def delete_email_user(username): """Delete email user.""" try: result = email_manager.delete_user(username) return jsonify(result) except Exception as e: logger.error(f"Error deleting email user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/status', methods=['GET']) def get_email_status(): """Get email service status.""" try: status = email_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting email status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/connectivity', methods=['GET']) def test_email_connectivity(): """Test email connectivity.""" try: result = email_manager.test_connectivity() return jsonify(result) except Exception as e: logger.error(f"Error testing email connectivity: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/send', methods=['POST']) def send_email(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = email_manager.send_email(data) return jsonify(result) except Exception as e: logger.error(f"Error sending email: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/email/mailbox/', methods=['GET']) def get_mailbox_info(username): """Get mailbox information.""" try: result = email_manager.get_mailbox_info(username) return jsonify(result) except Exception as e: logger.error(f"Error getting mailbox info: {e}") return jsonify({"error": str(e)}), 500 # Calendar Services API @app.route('/api/calendar/users', methods=['GET']) def get_calendar_users(): """Get calendar users.""" try: users = calendar_manager.get_users() return jsonify(users) except Exception as e: logger.error(f"Error getting calendar users: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/users', methods=['POST']) def create_calendar_user(): """Create calendar user.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = calendar_manager.create_user(data) return jsonify(result) except Exception as e: logger.error(f"Error creating calendar user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/users/', methods=['DELETE']) def delete_calendar_user(username): """Delete calendar user.""" try: result = calendar_manager.delete_user(username) return jsonify(result) except Exception as e: logger.error(f"Error deleting calendar user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/calendars', methods=['POST']) def create_calendar(): """Create calendar.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = calendar_manager.create_calendar(data) return jsonify(result) except Exception as e: logger.error(f"Error creating calendar: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/events', methods=['POST']) def add_calendar_event(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = calendar_manager.add_event(data) return jsonify(result) except Exception as e: logger.error(f"Error adding calendar event: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/events//', methods=['GET']) def get_calendar_events(username, calendar_name): """Get calendar events.""" try: params = request.args.to_dict() result = calendar_manager.get_events(username, calendar_name, params) return jsonify(result) except Exception as e: logger.error(f"Error getting calendar events: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/status', methods=['GET']) def get_calendar_status(): """Get calendar service status.""" try: status = calendar_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting calendar status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/calendar/connectivity', methods=['GET']) def test_calendar_connectivity(): """Test calendar connectivity.""" try: result = calendar_manager.test_connectivity() return jsonify(result) except Exception as e: logger.error(f"Error testing calendar connectivity: {e}") return jsonify({"error": str(e)}), 500 # File Services API @app.route('/api/files/users', methods=['GET']) def get_file_users(): """Get file storage users.""" try: users = file_manager.get_users() return jsonify(users) except Exception as e: logger.error(f"Error getting file users: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/users', methods=['POST']) def create_file_user(): """Create file storage user.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = file_manager.create_user(data) return jsonify(result) except Exception as e: logger.error(f"Error creating file user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/users/', methods=['DELETE']) def delete_file_user(username): """Delete file storage user.""" try: result = file_manager.delete_user(username) return jsonify(result) except Exception as e: logger.error(f"Error deleting file user: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/folders', methods=['POST']) def create_folder(): """Create folder.""" try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = file_manager.create_folder(data) return jsonify(result) except Exception as e: logger.error(f"Error creating folder: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/folders//', methods=['DELETE']) def delete_folder(username, folder_path): """Delete folder.""" try: result = file_manager.delete_folder(username, folder_path) return jsonify(result) except Exception as e: logger.error(f"Error deleting folder: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/upload/', methods=['POST']) def upload_file(username): """Upload file.""" try: if 'file' not in request.files: return jsonify({"error": "No file provided"}), 400 file = request.files['file'] path = request.form.get('path', '') result = file_manager.upload_file(username, file, path) return jsonify(result) except Exception as e: logger.error(f"Error uploading file: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/download//', methods=['GET']) def download_file(username, file_path): """Download file.""" try: result = file_manager.download_file(username, file_path) return jsonify(result) except Exception as e: logger.error(f"Error downloading file: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/delete//', methods=['DELETE']) def delete_file(username, file_path): """Delete file.""" try: result = file_manager.delete_file(username, file_path) return jsonify(result) except Exception as e: logger.error(f"Error deleting file: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/list/', methods=['GET']) def list_files(username): """List files.""" try: folder = request.args.get('folder', '') result = file_manager.list_files(username, folder) return jsonify(result) except Exception as e: logger.error(f"Error listing files: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/status', methods=['GET']) def get_file_status(): """Get file service status.""" try: status = file_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting file status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/files/connectivity', methods=['GET']) def test_file_connectivity(): """Test file service connectivity.""" try: result = file_manager.test_connectivity() return jsonify(result) except Exception as e: logger.error(f"Error testing file connectivity: {e}") return jsonify({"error": str(e)}), 500 # Routing API @app.route('/api/routing/status', methods=['GET']) def get_routing_status(): """Get routing status.""" try: status = routing_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting routing status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/setup', methods=['POST']) def setup_routing(): """Apply/verify routing setup (WireGuard handles NAT via PostUp rules).""" try: status = routing_manager.get_status() return jsonify({'success': True, 'message': 'Routing managed by WireGuard PostUp rules', **status}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/routing/nat', methods=['POST']) def add_nat_rule(): """Add NAT rule. JSON fields: - source_network (CIDR) - target_interface (str) - masquerade (bool, default True) - nat_type (MASQUERADE, SNAT, DNAT) - protocol (TCP, UDP, ALL) - external_port (str, optional) - internal_ip (str, optional) - internal_port (str, optional) """ try: data = request.get_json(silent=True) or {} result = routing_manager.add_nat_rule( source_network=data.get('source_network'), target_interface=data.get('target_interface'), masquerade=data.get('masquerade', True), nat_type=data.get('nat_type', 'MASQUERADE'), protocol=data.get('protocol', 'ALL'), external_port=data.get('external_port'), internal_ip=data.get('internal_ip'), internal_port=data.get('internal_port') ) return jsonify({'success': result}) except Exception as e: logger.error(f"Error adding NAT rule: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/nat/', methods=['DELETE']) def remove_nat_rule(rule_id): """Remove NAT rule.""" try: result = routing_manager.remove_nat_rule(rule_id) return jsonify(result) except Exception as e: logger.error(f"Error removing NAT rule: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/peers', methods=['POST']) def add_peer_route(): """Add peer route.""" try: data = request.get_json(silent=True) result = routing_manager.add_peer_route(data) return jsonify(result) except Exception as e: logger.error(f"Error adding peer route: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/peers/', methods=['DELETE']) def remove_peer_route(peer_name): """Remove peer route.""" try: result = routing_manager.remove_peer_route(peer_name) return jsonify(result) except Exception as e: logger.error(f"Error removing peer route: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/exit-nodes', methods=['POST']) def add_exit_node(): """Add exit node.""" try: data = request.get_json(silent=True) result = routing_manager.add_exit_node(data) return jsonify(result) except Exception as e: logger.error(f"Error adding exit node: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/bridge', methods=['POST']) def add_bridge_route(): """Add bridge route.""" try: data = request.get_json(silent=True) result = routing_manager.add_bridge_route(data) return jsonify(result) except Exception as e: logger.error(f"Error adding bridge route: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/split', methods=['POST']) def add_split_route(): """Add split route.""" try: data = request.get_json(silent=True) result = routing_manager.add_split_route(data) return jsonify(result) except Exception as e: logger.error(f"Error adding split route: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/firewall', methods=['POST']) def add_firewall_rule(): """Add firewall rule. JSON fields: - rule_type (INPUT, OUTPUT, FORWARD) - source (CIDR) - destination (CIDR) - action (ACCEPT, DROP, REJECT) - protocol (TCP, UDP, ICMP, ALL) - port (str, optional) - port_range (str, optional, e.g. '1000-2000') """ try: data = request.get_json(silent=True) or {} result = routing_manager.add_firewall_rule( rule_type=data.get('rule_type'), source=data.get('source'), destination=data.get('destination'), action=data.get('action', 'ACCEPT'), port=data.get('port'), protocol=data.get('protocol', 'ALL'), port_range=data.get('port_range') ) return jsonify({'success': result}) except Exception as e: logger.error(f"Error adding firewall rule: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/firewall/', methods=['DELETE']) def remove_firewall_rule(rule_id): try: result = routing_manager.remove_firewall_rule(rule_id) return jsonify({'success': result}), (200 if result else 404) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/routing/live-iptables', methods=['GET']) def get_live_iptables(): try: return jsonify(routing_manager.get_live_iptables()) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/routing/connectivity', methods=['POST']) def test_routing_connectivity(): """Test routing connectivity.""" try: data = request.get_json(silent=True) or {} target_ip = data.get('target_ip', '8.8.8.8') via_peer = data.get('via_peer') result = routing_manager.test_routing_connectivity(target_ip, via_peer) return jsonify(result) except Exception as e: logger.error(f"Error testing routing connectivity: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/logs', methods=['GET']) def get_routing_logs(): """Get routing logs.""" try: lines = request.args.get('lines', 50, type=int) result = routing_manager.get_logs(lines) return jsonify(result) except Exception as e: logger.error(f"Error getting routing logs: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/nat', methods=['GET']) def get_nat_rules(): """Get all NAT rules.""" try: rules = routing_manager.get_nat_rules() return jsonify({"nat_rules": rules}) except Exception as e: logger.error(f"Error getting NAT rules: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/peers', methods=['GET']) def get_peer_routes(): """Get all peer routes.""" try: routes = routing_manager.get_peer_routes() return jsonify({"peer_routes": routes}) except Exception as e: logger.error(f"Error getting peer routes: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/routing/firewall', methods=['GET']) def get_firewall_rules(): """Get all firewall rules.""" try: rules = routing_manager.get_firewall_rules() return jsonify({"firewall_rules": rules}) except Exception as e: logger.error(f"Error getting firewall rules: {e}") return jsonify({"error": str(e)}), 500 # Vault & Trust API (Phase 6) @app.route('/api/vault/status', methods=['GET']) def get_vault_status(): """Get vault status.""" try: status = current_app.vault_manager.get_status() return jsonify(status) except Exception as e: logger.error(f"Error getting vault status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/certificates', methods=['GET']) def get_certificates(): """Get all certificates.""" try: certificates = current_app.vault_manager.list_certificates() return jsonify(certificates) except Exception as e: logger.error(f"Error getting certificates: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/certificates', methods=['POST']) def generate_certificate(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = current_app.vault_manager.generate_certificate( common_name=data['common_name'], domains=data.get('domains', []), key_size=data.get('key_size', 2048), days=data.get('days', 365) ) return jsonify(result) except Exception as e: logger.error(f"Error generating certificate: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/certificates/', methods=['DELETE']) def revoke_certificate(common_name): """Revoke certificate.""" try: result = current_app.vault_manager.revoke_certificate(common_name) return jsonify({"revoked": result}) except Exception as e: logger.error(f"Error revoking certificate: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/ca/certificate', methods=['GET']) def get_ca_certificate(): """Get CA certificate.""" try: cert = current_app.vault_manager.get_ca_certificate() return jsonify({"certificate": cert}) except Exception as e: logger.error(f"Error getting CA certificate: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/age/public-key', methods=['GET']) def get_age_public_key(): """Get Age public key.""" try: key = current_app.vault_manager.get_age_public_key() return jsonify({"public_key": key}) except Exception as e: logger.error(f"Error getting Age public key: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/trust/keys', methods=['GET']) def get_trusted_keys(): """Get trusted keys.""" try: keys = current_app.vault_manager.get_trusted_keys() return jsonify(keys) except Exception as e: logger.error(f"Error getting trusted keys: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/trust/keys', methods=['POST']) def add_trusted_key(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = current_app.vault_manager.add_trusted_key( name=data['name'], public_key=data['public_key'], trust_level=data.get('trust_level', 'direct') ) return jsonify({"added": result}) except Exception as e: logger.error(f"Error adding trusted key: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/trust/keys/', methods=['DELETE']) def remove_trusted_key(name): """Remove trusted key.""" try: result = current_app.vault_manager.remove_trusted_key(name) return jsonify({"removed": result}) except Exception as e: logger.error(f"Error removing trusted key: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/trust/verify', methods=['POST']) def verify_trust_chain(): try: data = request.get_json(silent=True) if data is None: return jsonify({"error": "No data provided"}), 400 result = current_app.vault_manager.verify_trust_chain( peer_name=data['peer_name'], signature=data['signature'], data=data['data'] ) return jsonify({"verified": result}) except Exception as e: logger.error(f"Error verifying trust chain: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/vault/trust/chains', methods=['GET']) def get_trust_chains(): """Get trust chains.""" try: chains = current_app.vault_manager.get_trust_chains() return jsonify(chains) except Exception as e: logger.error(f"Error getting trust chains: {e}") return jsonify({"error": str(e)}), 500 # Services API @app.route('/api/services/status', methods=['GET']) def get_all_services_status(): """Get status of all services.""" try: # Use service bus to get status from all services services_status = {} for service_name in service_bus.list_services(): try: service = service_bus.get_service(service_name) status = service.get_status() # Clean up status for UI consumption if isinstance(status, dict): # Extract core status information clean_status = { 'status': status.get('status', 'unknown'), 'running': status.get('running', False), 'timestamp': status.get('timestamp', datetime.utcnow().isoformat()) } # Add service-specific metrics if service_name == 'network': clean_status.update({ 'dns_status': status.get('dns_running', False), 'dhcp_status': status.get('dhcp_running', False), 'ntp_status': status.get('ntp_running', False) }) elif service_name == 'wireguard': clean_status.update({ 'peers_count': status.get('peers_count', 0), 'interface': status.get('interface', 'unknown') }) elif service_name == 'email': clean_status.update({ 'users_count': status.get('users_count', 0), 'domain': status.get('domain', 'unknown') }) elif service_name == 'calendar': clean_status.update({ 'users_count': status.get('users_count', 0), 'calendars_count': status.get('calendars_count', 0) }) elif service_name == 'files': clean_status.update({ 'users_count': status.get('users_count', 0), 'storage_used': status.get('total_storage_used', {}) }) elif service_name == 'routing': clean_status.update({ 'nat_rules_count': status.get('nat_rules_count', 0), 'peer_routes_count': status.get('peer_routes_count', 0), 'firewall_rules_count': status.get('firewall_rules_count', 0) }) elif service_name == 'vault': clean_status.update({ 'certificates_count': status.get('certificates_count', 0), 'trusted_keys_count': status.get('trusted_keys_count', 0) }) services_status[service_name] = clean_status else: services_status[service_name] = {'status': str(status), 'running': bool(status)} except Exception as e: services_status[service_name] = {'error': str(e), 'status': 'offline', 'running': False} return jsonify({ "network": services_status.get('network', {}), "wireguard": services_status.get('wireguard', {}), "email": services_status.get('email', {}), "calendar": services_status.get('calendar', {}), "files": services_status.get('files', {}), "routing": services_status.get('routing', {}), "vault": services_status.get('vault', {}), "timestamp": datetime.utcnow().isoformat() }) except Exception as e: logger.error(f"Error getting all services status: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/services/connectivity', methods=['GET']) def test_all_services_connectivity(): """Test connectivity of all services.""" try: # Use service bus to test connectivity connectivity_results = {} for service_name in service_bus.list_services(): try: service = service_bus.get_service(service_name) if hasattr(service, 'test_connectivity'): connectivity_results[service_name] = service.test_connectivity() else: connectivity_results[service_name] = {'status': 'ok', 'message': 'No connectivity test available'} except Exception as e: connectivity_results[service_name] = {'status': 'error', 'message': str(e)} return jsonify({ "network": connectivity_results.get('network', {}), "wireguard": connectivity_results.get('wireguard', {}), "email": connectivity_results.get('email', {}), "calendar": connectivity_results.get('calendar', {}), "files": connectivity_results.get('files', {}), "routing": connectivity_results.get('routing', {}), "timestamp": datetime.utcnow().isoformat() }) except Exception as e: logger.error(f"Error testing all services connectivity: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/health/history', methods=['GET']) def get_health_history(): """Get recent unified health check results.""" return jsonify(list(health_history)) @app.route('/api/health/history/clear', methods=['POST']) def clear_health_history(): """Clear health history and reset alert counters.""" global service_alert_counters health_history.clear() service_alert_counters = {} return jsonify({'message': 'Health history cleared'}) @app.route('/api/logs', methods=['GET']) def get_backend_logs(): """Get backend log file contents (last N lines).""" log_file = os.path.join(os.path.dirname(__file__), 'picell.log') lines = int(request.args.get('lines', 100)) try: if not os.path.exists(log_file): return jsonify({"error": "Log file not found."}), 404 with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: all_lines = f.readlines() tail_lines = all_lines[-lines:] if lines > 0 else all_lines return jsonify({"log": ''.join(tail_lines)}) except Exception as e: logger.error(f"Error reading log file: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/containers', methods=['GET']) def list_containers(): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 try: containers = container_manager.list_containers() return jsonify(containers) except Exception as e: logger.error(f"Error listing containers: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/containers//start', methods=['POST']) def start_container(name): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 try: success = container_manager.start_container(name) return jsonify({'started': success}) except Exception as e: logger.error(f"Error starting container {name}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/containers//stop', methods=['POST']) def stop_container(name): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 try: success = container_manager.stop_container(name) return jsonify({'stopped': success}) except Exception as e: logger.error(f"Error stopping container {name}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/containers//restart', methods=['POST']) def restart_container(name): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 try: success = container_manager.restart_container(name) return jsonify({'restarted': success}) except Exception as e: logger.error(f"Error restarting container {name}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/containers//logs', methods=['GET']) def get_container_logs(name): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 tail = request.args.get('tail', default=100, type=int) try: logs = container_manager.get_container_logs(name, tail=tail) return jsonify({'logs': logs}) except Exception as e: logger.error(f"Error getting logs for container {name}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/containers//stats', methods=['GET']) def get_container_stats(name): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 try: stats = container_manager.get_container_stats(name) return jsonify(stats) except Exception as e: logger.error(f"Error getting stats for container {name}: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/vault/secrets', methods=['GET']) def list_secrets(): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 secrets = app.vault_manager.list_secrets() return jsonify({'secrets': secrets}) @app.route('/api/vault/secrets', methods=['POST']) def store_secret(): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 data = request.get_json(silent=True) if not data or 'name' not in data or 'value' not in data: return jsonify({'error': 'Missing name or value'}), 400 app.vault_manager.store_secret(data['name'], data['value']) return jsonify({'stored': True}) @app.route('/api/vault/secrets/', methods=['GET']) def get_secret(name): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 value = app.vault_manager.get_secret(name) if value is None: return jsonify({'error': 'Not found'}), 404 return jsonify({'name': name, 'value': value}) @app.route('/api/vault/secrets/', methods=['DELETE']) def delete_secret(name): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 result = app.vault_manager.delete_secret(name) return jsonify({'deleted': result}) # Enhance container creation to support secrets @app.route('/api/containers', methods=['POST']) def create_container(): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 data = request.get_json(silent=True) if not data or 'image' not in data: return jsonify({'error': 'Missing image parameter'}), 400 name = data.get('name', '') env = data.get('env', {}) # If 'secrets' is provided, resolve secret values and add to env secrets = data.get('secrets', []) if secrets: for secret_name in secrets: secret_value = app.vault_manager.get_secret(secret_name) if secret_value is not None: env[secret_name] = secret_value volumes = data.get('volumes', {}) command = data.get('command', '') ports = data.get('ports', {}) result = container_manager.create_container( image=data['image'], name=name, env=env, volumes=volumes, command=command, ports=ports ) if 'error' in result: return jsonify(result), 500 return jsonify(result) @app.route('/api/containers/', methods=['DELETE']) def remove_container(name): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 force = request.args.get('force', default=False, type=bool) success = container_manager.remove_container(name, force=force) return jsonify({'removed': success}) @app.route('/api/images', methods=['GET']) def list_images(): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 images = container_manager.list_images() return jsonify(images) @app.route('/api/images/pull', methods=['POST']) def pull_image(): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 data = request.get_json(silent=True) if not data or 'image' not in data: return jsonify({'error': 'Missing image parameter'}), 400 result = container_manager.pull_image(data['image']) if 'error' in result: return jsonify(result), 500 return jsonify(result) @app.route('/api/images/', methods=['DELETE']) def remove_image(image): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 force = request.args.get('force', default=False, type=bool) success = container_manager.remove_image(image, force=force) return jsonify({'removed': success}) @app.route('/api/volumes', methods=['GET']) def list_volumes(): # Temporarily disable access control for debugging # if not is_local_request(): # return jsonify({'error': 'Access denied'}), 403 volumes = container_manager.list_volumes() return jsonify(volumes) @app.route('/api/volumes', methods=['POST']) def create_volume(): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 data = request.get_json(silent=True) if not data or 'name' not in data: return jsonify({'error': 'Missing name parameter'}), 400 result = container_manager.create_volume(data['name']) if 'error' in result: return jsonify(result), 500 return jsonify(result) @app.route('/api/volumes/', methods=['DELETE']) def remove_volume(name): if not is_local_request(): return jsonify({'error': 'Access denied'}), 403 force = request.args.get('force', default=False, type=bool) success = container_manager.remove_volume(name, force=force) return jsonify({'removed': success}) if __name__ == '__main__': app.run(host='0.0.0.0', port=3000, debug=True)