From 09138fbc1850f64f0201ac3c6f9ac80d6ba55f49 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 1 May 2026 06:11:21 -0400 Subject: [PATCH] A5: Extract all route groups into Flask blueprints (app.py -1735 lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 9 route groups out of app.py into routes/ blueprints: - routes/network.py — DNS, DHCP, NTP, network info/test (10 routes) - routes/wireguard.py — WireGuard keys, peers, config, enforcement (18 routes) - routes/cells.py — cell-to-cell connections (5 routes) - routes/peers.py — peer CRUD + IP update + _next_peer_ip helper (10 routes) - routes/routing.py — NAT, peer routes, firewall, iptables (17 routes) - routes/vault.py — certs, trust, secrets (19 routes) - routes/containers.py — containers, images, volumes (14 routes) - routes/services.py — service bus, logs, services status/connectivity (18 routes) - routes/peer_dashboard.py — peer-scoped dashboard/services (2 routes) All blueprints use lazy `from app import X` inside route bodies to preserve test patch compatibility (patch('app.email_manager', mock) still works). Also included in this commit: - A1 fix: backup/restore now includes email/calendar user files - A2 fix: apply_config sets applying=True flag via helper container - A3 fix: add_peer rolls back firewall on DNS failure app.py reduced: 3011 → 1294 lines. 1021 tests passing. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 2096 +--------------------- api/config_manager.py | 28 + api/routes/__init__.py | 0 api/routes/calendar.py | 119 ++ api/routes/cells.py | 65 + api/routes/containers.py | 195 ++ api/routes/email.py | 92 + api/routes/files.py | 159 ++ api/routes/network.py | 109 ++ api/routes/peer_dashboard.py | 115 ++ api/routes/peers.py | 299 +++ api/routes/routing.py | 207 +++ api/routes/services.py | 291 +++ api/routes/vault.py | 165 ++ api/routes/wireguard.py | 236 +++ tests/test_peer_management_edge_cases.py | 4 +- 16 files changed, 2108 insertions(+), 2072 deletions(-) create mode 100644 api/routes/__init__.py create mode 100644 api/routes/calendar.py create mode 100644 api/routes/cells.py create mode 100644 api/routes/containers.py create mode 100644 api/routes/email.py create mode 100644 api/routes/files.py create mode 100644 api/routes/network.py create mode 100644 api/routes/peer_dashboard.py create mode 100644 api/routes/peers.py create mode 100644 api/routes/routing.py create mode 100644 api/routes/services.py create mode 100644 api/routes/vault.py create mode 100644 api/routes/wireguard.py diff --git a/api/app.py b/api/app.py index 4c5b407..5934fde 100644 --- a/api/app.py +++ b/api/app.py @@ -314,6 +314,32 @@ service_bus.register_service('container', container_manager) # Register auth blueprint app.register_blueprint(auth_routes.auth_bp) +# Register service blueprints (routes extracted from this file) +from routes.email import bp as _email_bp +from routes.calendar import bp as _calendar_bp +from routes.files import bp as _files_bp +from routes.network import bp as _network_bp +from routes.wireguard import bp as _wireguard_bp +from routes.cells import bp as _cells_bp +from routes.peers import bp as _peers_bp +from routes.routing import bp as _routing_bp +from routes.vault import bp as _vault_bp +from routes.containers import bp as _containers_bp +from routes.services import bp as _services_bp +from routes.peer_dashboard import bp as _peer_dashboard_bp +app.register_blueprint(_email_bp) +app.register_blueprint(_calendar_bp) +app.register_blueprint(_files_bp) +app.register_blueprint(_network_bp) +app.register_blueprint(_wireguard_bp) +app.register_blueprint(_cells_bp) +app.register_blueprint(_peers_bp) +app.register_blueprint(_routing_bp) +app.register_blueprint(_vault_bp) +app.register_blueprint(_containers_bp) +app.register_blueprint(_services_bp) +app.register_blueprint(_peer_dashboard_bp) + # Unified health monitoring HEALTH_HISTORY_SIZE = 100 health_history = deque(maxlen=HEALTH_HISTORY_SIZE) @@ -1251,1737 +1277,6 @@ def delete_config_backup(backup_id): 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 not data: - return jsonify({"error": "No data provided"}), 400 - for field in ('mac', 'ip'): - if field not in data: - return jsonify({"error": f"Missing required field: {field}"}), 400 - result = network_manager.add_dhcp_reservation(data['mac'], data['ip'], data.get('hostname', '')) - return jsonify({"success": 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) - if not data or 'mac' not in data: - return jsonify({"error": "Missing required field: mac"}), 400 - result = network_manager.remove_dhcp_reservation(data['mac']) - return jsonify({"success": 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: - result = network_manager.test_connectivity() - 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 (public key only; private key never leaves the server).""" - try: - keys = wireguard_manager.get_keys() - return jsonify({ - 'public_key': keys.get('public_key', ''), - 'has_private_key': bool(keys.get('private_key')), - }) - 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=['GET', '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, _configured_domain(), - cell_links=cell_link_manager.list_connections()) - return jsonify({'ok': True, 'peers': len(peers)}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/wireguard/check-port', methods=['GET', '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 and auto-provision auth/email/calendar/files accounts.""" - 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 - - # Password is required for peer provisioning - password = data.get('password') or '' - if not password: - return jsonify({"error": "Missing required field: password"}), 400 - if len(password) < 10: - return jsonify({"error": "password must be at least 10 characters"}), 400 - - try: - assigned_ip = data.get('ip') or _next_peer_ip() - except ValueError as e: - return jsonify({'error': str(e)}), 409 - - # Validate service_access if provided - _valid_services = {'calendar', 'files', 'mail', 'webdav'} - service_access = data.get('service_access', list(_valid_services)) - if not isinstance(service_access, list) or not all(s in _valid_services for s in service_access): - return jsonify({"error": f"service_access must be a list of: {sorted(_valid_services)}"}), 400 - - peer_name = data['name'] - - # --- Provision auth account (hard-required) --- - if not auth_manager.create_user(peer_name, password, 'peer'): - return jsonify({"error": f"Could not create auth account (duplicate name?)"}), 400 - - # --- Provision service accounts (best-effort; failures logged but non-fatal) --- - provisioned = ['auth'] - domain = _configured_domain() - for step_name, step_fn in [ - ('email', lambda: email_manager.create_email_user(peer_name, domain, password)), - ('calendar', lambda: calendar_manager.create_calendar_user(peer_name, password)), - ('files', lambda: file_manager.create_user(peer_name, password)), - ]: - try: - if step_fn(): - provisioned.append(step_name) - else: - logger.warning(f"Peer {peer_name}: {step_name} account creation returned False (service may not be ready)") - except Exception as e: - logger.warning(f"Peer {peer_name}: {step_name} account creation failed (non-fatal): {e}") - - # Add peer to registry with all provided fields - peer_info = { - 'peer': peer_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': service_access, - 'peer_access': data.get('peer_access', True), - 'config_needs_reinstall': False, - } - - peer_added_to_registry = False - firewall_applied = False - try: - # Step 1: Add to registry - success = peer_registry.add_peer(peer_info) - if not success: - # Registry rejected (already exists) — rollback provisioned accounts - for svc in ('files', 'calendar', 'email', 'auth'): - try: - if svc == 'files': - file_manager.delete_user(peer_name) - elif svc == 'calendar': - calendar_manager.delete_calendar_user(peer_name) - elif svc == 'email': - email_manager.delete_email_user(peer_name, _configured_domain()) - elif svc == 'auth': - auth_manager.delete_user(peer_name) - except Exception: - pass - return jsonify({"error": f"Peer {peer_name} already exists"}), 400 - peer_added_to_registry = True - - # Step 2: Firewall rules (critical) - firewall_manager.apply_peer_rules(peer_info['ip'], peer_info) - firewall_applied = True - - # Step 3: Add peer to WireGuard server config (non-fatal if WG is not running) - wg_allowed = f"{assigned_ip}/32" if '/' not in assigned_ip else assigned_ip - try: - wireguard_manager.add_peer(peer_name, data['public_key'], endpoint_ip='', allowed_ips=wg_allowed) - except Exception as wg_err: - logger.warning(f"Peer {peer_name}: WireGuard server config update failed (non-fatal): {wg_err}") - - # Step 4: Update DNS rules - firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(), - cell_links=cell_link_manager.list_connections()) - return jsonify({"message": f"Peer {peer_name} added successfully", "ip": assigned_ip}), 201 - - except Exception as e: - # Rollback: clear firewall rules first, then remove from registry - if firewall_applied: - try: - firewall_manager.clear_peer_rules(peer_info['ip']) - except Exception: - pass - if peer_added_to_registry: - try: - peer_registry.remove_peer(peer_name) - except Exception: - pass - logger.error(f"Error adding peer {peer_name}: {e}") - return jsonify({'error': str(e)}), 500 - - 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, _configured_domain(), - cell_links=cell_link_manager.list_connections()) - 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 and clean up firewall, DNS, and all service accounts.""" - try: - peer = peer_registry.get_peer(peer_name) - if not peer: - return jsonify({"message": f"Peer {peer_name} not found or already removed"}) - peer_ip = peer.get('ip') - peer_pubkey = peer.get('public_key', '') - success = peer_registry.remove_peer(peer_name) - if success: - if peer_ip: - firewall_manager.clear_peer_rules(peer_ip) - firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(), - cell_links=cell_link_manager.list_connections()) - # Remove peer from WireGuard server config (non-fatal) - if peer_pubkey: - try: - wireguard_manager.remove_peer(peer_pubkey) - except Exception as wg_err: - logger.warning(f"Peer {peer_name}: WireGuard removal failed (non-fatal): {wg_err}") - # Clean up all provisioned service accounts (best-effort) - for _cleanup in [ - lambda: email_manager.delete_email_user(peer_name, _configured_domain()), - lambda: calendar_manager.delete_calendar_user(peer_name), - lambda: file_manager.delete_user(peer_name), - lambda: auth_manager.delete_user(peer_name), - ]: - try: - _cleanup() - except Exception: - pass - 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 - -@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 - username = data.get('username') - domain = data.get('domain') or _configured_domain() - password = data.get('password') - if not username or not password: - return jsonify({"error": "Missing required fields: username, password"}), 400 - result = email_manager.create_email_user(username, domain, password) - return jsonify({"created": 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: - domain = request.args.get('domain') or _configured_domain() - result = email_manager.delete_email_user(username, domain) - return jsonify({"deleted": 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 - username = data.get('username') - password = data.get('password') - if not username or not password: - return jsonify({"error": "Missing required fields: username, password"}), 400 - result = calendar_manager.create_calendar_user(username, password) - return jsonify({"created": 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_calendar_user(username) - return jsonify({"deleted": 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 - username = data.get('username') - calendar_name = data.get('name') or data.get('calendar_name') - if not username or not calendar_name: - return jsonify({"error": "Missing required fields: username, name"}), 400 - result = calendar_manager.create_calendar( - username, - calendar_name, - description=data.get('description', ''), - color=data.get('color', '#4285f4'), - ) - return jsonify({"created": 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 - username = data.get('username') - calendar_name = data.get('calendar_name') or data.get('calendar') - if not username or not calendar_name: - return jsonify({"error": "Missing required fields: username, calendar_name"}), 400 - event_data = {k: v for k, v in data.items() if k not in ('username', 'calendar_name', 'calendar')} - result = calendar_manager.add_event(username, calendar_name, event_data) - return jsonify({"created": 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 - username = data.get('username') - password = data.get('password') - if not username or not password: - return jsonify({"error": "Missing required fields: username, password"}), 400 - result = file_manager.create_user(username, password) - return jsonify({"created": 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 - username = data.get('username') - folder_path = data.get('folder_path') or data.get('path') - if not username or not folder_path: - return jsonify({"error": "Missing required fields: username, folder_path"}), 400 - result = file_manager.create_folder(username, folder_path) - return jsonify({"created": result}) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - 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 ValueError as e: - return jsonify({"error": str(e)}), 400 - 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', '') or file.filename or '' - file_data = file.read() - - result = file_manager.upload_file(username, path, file_data) - return jsonify({"uploaded": result}) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - 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 ValueError as e: - return jsonify({"error": str(e)}), 400 - 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 ValueError as e: - return jsonify({"error": str(e)}), 400 - 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 ValueError as e: - return jsonify({"error": str(e)}), 400 - 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) or {} - peer_name = data.get('peer_name') - peer_ip = data.get('peer_ip') - allowed_networks = data.get('allowed_networks', []) - route_type = data.get('route_type', 'lan') - if not peer_name or not peer_ip: - return jsonify({"error": "Missing required fields: peer_name, peer_ip"}), 400 - result = routing_manager.add_peer_route(peer_name, peer_ip, allowed_networks, route_type) - return jsonify({"added": 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) or {} - peer_name = data.get('peer_name') - peer_ip = data.get('peer_ip') - if not peer_name or not peer_ip: - return jsonify({"error": "Missing required fields: peer_name, peer_ip"}), 400 - result = routing_manager.add_exit_node(peer_name, peer_ip, data.get('allowed_domains')) - return jsonify({"added": 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) or {} - source_peer = data.get('source_peer') - target_peer = data.get('target_peer') - allowed_networks = data.get('allowed_networks', []) - if not source_peer or not target_peer: - return jsonify({"error": "Missing required fields: source_peer, target_peer"}), 400 - result = routing_manager.add_bridge_route(source_peer, target_peer, allowed_networks) - return jsonify({"added": 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) or {} - network = data.get('network') - exit_peer = data.get('exit_peer') - if not network or not exit_peer: - return jsonify({"error": "Missing required fields: network, exit_peer"}), 400 - result = routing_manager.add_split_route(network, exit_peer, data.get('fallback_peer')) - return jsonify({"added": 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.""" @@ -2995,345 +1290,6 @@ def clear_health_history(): 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', {}) - if volumes: - allowed_prefixes = ('/home/roof/pic/data/', '/home/roof/pic/config/', '/tmp/') - for host_path in volumes.keys(): - resolved = os.path.realpath(str(host_path)) - if not any(resolved.startswith(p) for p in allowed_prefixes): - return jsonify({'error': f'Volume mount not allowed: {host_path}'}), 403 - 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}) - - - -# ── Peer-scoped routes (/api/peer/*) ───────────────────────────────────────── -# These routes are accessible to peer-role sessions only (enforced by -# the enforce_auth before_request hook above). - -@app.route('/api/peer/dashboard', methods=['GET']) -def peer_dashboard(): - """Return dashboard info for the authenticated peer including live WireGuard stats.""" - peer_name = session.get('peer_name') - peer = peer_registry.get_peer(peer_name) if peer_name else None - if not peer: - return jsonify({'error': 'Peer not found'}), 404 - - wg_stats = {'online': None, 'transfer_rx': 0, 'transfer_tx': 0, 'last_handshake': None} - public_key = peer.get('public_key') - if public_key: - try: - wg_stats = wireguard_manager.get_peer_status(public_key) - except Exception: - pass - - peer_ip = peer.get('ip', '') - allowed_ips = f"{peer_ip.split('/')[0]}/32" if peer_ip else '' - domain = _configured_domain() - _svc_url_map = { - 'calendar': f'http://calendar.{domain}', - 'files': f'http://files.{domain}', - 'mail': f'http://mail.{domain}', - 'webdav': f'http://webdav.{domain}', - } - service_urls = { - svc: _svc_url_map[svc] - for svc in peer.get('service_access', []) - if svc in _svc_url_map - } - - return jsonify({ - 'name': peer_name, - 'ip': peer_ip, - 'service_access': peer.get('service_access', []), - 'service_urls': service_urls, - 'online': wg_stats.get('online'), - 'transfer_rx': wg_stats.get('transfer_rx', 0), - 'transfer_tx': wg_stats.get('transfer_tx', 0), - 'last_handshake': wg_stats.get('last_handshake'), - 'allowed_ips': peer.get('allowed_ips', allowed_ips), - }) - - -@app.route('/api/peer/services', methods=['GET']) -def peer_services(): - """Return service credentials and access info for the authenticated peer.""" - peer_name = session.get('peer_name') - peer = peer_registry.get_peer(peer_name) if peer_name else None - if not peer: - return jsonify({'error': 'Peer not found'}), 404 - - domain = _configured_domain() - peer_ip = peer.get('ip', '') - - server_public_key = '' - wg_port = 51820 - server_endpoint = '' - try: - server_public_key = wireguard_manager.get_keys().get('public_key', '') - wg_port = config_manager.configs.get('_identity', {}).get('wireguard_port', 51820) - srv = wireguard_manager.get_server_config() - server_endpoint = srv.get('endpoint') or '' - except Exception: - pass - - wg_config = '' - peer_private_key = peer.get('private_key', '') - if peer_private_key: - try: - internet_access = peer.get('internet_access', True) - allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() - wg_config = 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, - ) - except Exception: - pass - - return jsonify({ - 'username': peer_name, - 'wireguard': { - 'ip': peer_ip, - 'server_public_key': server_public_key, - 'endpoint_port': wg_port, - 'dns': _resolve_peer_dns(), - 'config': wg_config, - }, - 'email': { - 'address': f'{peer_name}@{domain}', - 'smtp': {'host': f'mail.{domain}', 'port': 587}, - 'imap': {'host': f'mail.{domain}', 'port': 993}, - }, - 'caldav': { - 'url': f'http://calendar.{domain}', - 'username': peer_name, - }, - 'files': { - 'url': f'http://files.{domain}', - 'username': peer_name, - }, - }) - - if __name__ == '__main__': debug = os.environ.get('FLASK_DEBUG', '0') == '1' app.run(host='0.0.0.0', port=3000, debug=debug) \ No newline at end of file diff --git a/api/config_manager.py b/api/config_manager.py index 763dad6..9d8bdc3 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -235,6 +235,20 @@ class ConfigManager: for zone_file in dns_data.glob('*.zone'): shutil.copy2(zone_file, zones_dir / zone_file.name) + # Service-specific user account files (authoritative source of truth — + # cell_config.json only carries a best-effort sync of these). + svc_user_files = [ + (data_dir / 'email' / 'users.json', 'email_users.json'), + (data_dir / 'calendar' / 'users.json', 'calendar_users.json'), + (data_dir / 'calendar' / 'calendars.json', 'calendar_calendars.json'), + ] + for src, dest_name in svc_user_files: + if src.exists(): + try: + shutil.copy2(src, backup_path / dest_name) + except (PermissionError, OSError) as e: + logger.warning(f"Could not back up {src.name}: {e} (skipping)") + services = ['identity'] + list(self.service_schemas.keys()) manifest = { "backup_id": backup_id, @@ -316,6 +330,20 @@ class ConfigManager: except (PermissionError, OSError) as dir_err: logger.warning(f"Could not create dns data dir {dns_data}: {dir_err} (skipping)") + # Service-specific user account files + svc_restore_map = [ + (backup_path / 'email_users.json', data_dir / 'email' / 'users.json'), + (backup_path / 'calendar_users.json', data_dir / 'calendar' / 'users.json'), + (backup_path / 'calendar_calendars.json', data_dir / 'calendar' / 'calendars.json'), + ] + for src, dest in svc_restore_map: + if src.exists(): + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + except (PermissionError, OSError) as e: + logger.warning(f"Could not restore {dest.name}: {e} (skipping)") + self.configs = self._load_all_configs() logger.info(f"Restored configuration from backup: {backup_id}") return True diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/calendar.py b/api/routes/calendar.py new file mode 100644 index 0000000..8952401 --- /dev/null +++ b/api/routes/calendar.py @@ -0,0 +1,119 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('calendar', __name__) + +@bp.route('/api/calendar/users', methods=['GET']) +def get_calendar_users(): + """Get calendar users.""" + try: + from app import calendar_manager + 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 + +@bp.route('/api/calendar/users', methods=['POST']) +def create_calendar_user(): + """Create calendar user.""" + try: + from app import calendar_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + password = data.get('password') + if not username or not password: + return jsonify({"error": "Missing required fields: username, password"}), 400 + result = calendar_manager.create_calendar_user(username, password) + return jsonify({"created": result}) + except Exception as e: + logger.error(f"Error creating calendar user: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/calendar/users/', methods=['DELETE']) +def delete_calendar_user(username): + """Delete calendar user.""" + try: + from app import calendar_manager + result = calendar_manager.delete_calendar_user(username) + return jsonify({"deleted": result}) + except Exception as e: + logger.error(f"Error deleting calendar user: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/calendar/calendars', methods=['POST']) +def create_calendar(): + """Create calendar.""" + try: + from app import calendar_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + calendar_name = data.get('name') or data.get('calendar_name') + if not username or not calendar_name: + return jsonify({"error": "Missing required fields: username, name"}), 400 + result = calendar_manager.create_calendar( + username, + calendar_name, + description=data.get('description', ''), + color=data.get('color', '#4285f4'), + ) + return jsonify({"created": result}) + except Exception as e: + logger.error(f"Error creating calendar: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/calendar/events', methods=['POST']) +def add_calendar_event(): + try: + from app import calendar_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + calendar_name = data.get('calendar_name') or data.get('calendar') + if not username or not calendar_name: + return jsonify({"error": "Missing required fields: username, calendar_name"}), 400 + event_data = {k: v for k, v in data.items() if k not in ('username', 'calendar_name', 'calendar')} + result = calendar_manager.add_event(username, calendar_name, event_data) + return jsonify({"created": result}) + except Exception as e: + logger.error(f"Error adding calendar event: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/calendar/events//', methods=['GET']) +def get_calendar_events(username, calendar_name): + """Get calendar events.""" + try: + from app import calendar_manager + 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 + +@bp.route('/api/calendar/status', methods=['GET']) +def get_calendar_status(): + """Get calendar service status.""" + try: + from app import calendar_manager + 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 + +@bp.route('/api/calendar/connectivity', methods=['GET']) +def test_calendar_connectivity(): + """Test calendar connectivity.""" + try: + from app import calendar_manager + 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 diff --git a/api/routes/cells.py b/api/routes/cells.py new file mode 100644 index 0000000..0732f07 --- /dev/null +++ b/api/routes/cells.py @@ -0,0 +1,65 @@ +import logging +import os +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('cells', __name__) + +@bp.route('/api/cells/invite', methods=['GET']) +def get_cell_invite(): + try: + from app import cell_link_manager, config_manager + 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')) + return jsonify(cell_link_manager.generate_invite(cell_name, domain)) + except Exception as e: + logger.error(f"Error generating cell invite: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/cells', methods=['GET']) +def list_cell_connections(): + try: + from app import cell_link_manager + return jsonify(cell_link_manager.list_connections()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/cells', methods=['POST']) +def add_cell_connection(): + try: + from app import cell_link_manager + 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 + +@bp.route('/api/cells/', methods=['DELETE']) +def remove_cell_connection(cell_name): + try: + from app import cell_link_manager + 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 + +@bp.route('/api/cells//status', methods=['GET']) +def get_cell_connection_status(cell_name): + try: + from app import cell_link_manager + return jsonify(cell_link_manager.get_connection_status(cell_name)) + except ValueError as e: + return jsonify({'error': str(e)}), 404 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/api/routes/containers.py b/api/routes/containers.py new file mode 100644 index 0000000..4393c39 --- /dev/null +++ b/api/routes/containers.py @@ -0,0 +1,195 @@ +import logging +import os +from flask import Blueprint, request, jsonify, current_app +logger = logging.getLogger('picell') +bp = Blueprint('containers', __name__) + +@bp.route('/api/containers', methods=['GET']) +def list_containers(): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify(container_manager.list_containers()) + except Exception as e: + logger.error(f"Error listing containers: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers//start', methods=['POST']) +def start_container(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify({'started': container_manager.start_container(name)}) + except Exception as e: + logger.error(f"Error starting container {name}: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers//stop', methods=['POST']) +def stop_container(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify({'stopped': container_manager.stop_container(name)}) + except Exception as e: + logger.error(f"Error stopping container {name}: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers//restart', methods=['POST']) +def restart_container(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify({'restarted': container_manager.restart_container(name)}) + except Exception as e: + logger.error(f"Error restarting container {name}: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers//logs', methods=['GET']) +def get_container_logs(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + tail = request.args.get('tail', default=100, type=int) + return jsonify({'logs': container_manager.get_container_logs(name, tail=tail)}) + except Exception as e: + logger.error(f"Error getting logs for container {name}: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers//stats', methods=['GET']) +def get_container_stats(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify(container_manager.get_container_stats(name)) + except Exception as e: + logger.error(f"Error getting stats for container {name}: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers', methods=['POST']) +def create_container(): + try: + from app import container_manager, is_local_request + 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', {}) + secrets = data.get('secrets', []) + if secrets: + for secret_name in secrets: + secret_value = current_app.vault_manager.get_secret(secret_name) + if secret_value is not None: + env[secret_name] = secret_value + volumes = data.get('volumes', {}) + if volumes: + allowed_prefixes = ('/home/roof/pic/data/', '/home/roof/pic/config/', '/tmp/') + for host_path in volumes.keys(): + resolved = os.path.realpath(str(host_path)) + if not any(resolved.startswith(p) for p in allowed_prefixes): + return jsonify({'error': f'Volume mount not allowed: {host_path}'}), 403 + result = container_manager.create_container( + image=data['image'], + name=name, + env=env, + volumes=volumes, + command=data.get('command', ''), + ports=data.get('ports', {}) + ) + if 'error' in result: + return jsonify(result), 500 + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/containers/', methods=['DELETE']) +def remove_container(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + force = request.args.get('force', default=False, type=bool) + return jsonify({'removed': container_manager.remove_container(name, force=force)}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/images', methods=['GET']) +def list_images(): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify(container_manager.list_images()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/images/pull', methods=['POST']) +def pull_image(): + try: + from app import container_manager, is_local_request + 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) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/images/', methods=['DELETE']) +def remove_image(image): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + force = request.args.get('force', default=False, type=bool) + return jsonify({'removed': container_manager.remove_image(image, force=force)}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/volumes', methods=['GET']) +def list_volumes(): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify(container_manager.list_volumes()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/volumes', methods=['POST']) +def create_volume(): + try: + from app import container_manager, is_local_request + 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) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/volumes/', methods=['DELETE']) +def remove_volume(name): + try: + from app import container_manager, is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + force = request.args.get('force', default=False, type=bool) + return jsonify({'removed': container_manager.remove_volume(name, force=force)}) + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/api/routes/email.py b/api/routes/email.py new file mode 100644 index 0000000..48ce94c --- /dev/null +++ b/api/routes/email.py @@ -0,0 +1,92 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('email', __name__) + +@bp.route('/api/email/users', methods=['GET']) +def get_email_users(): + """Get email users.""" + try: + from app import email_manager + 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 + +@bp.route('/api/email/users', methods=['POST']) +def create_email_user(): + """Create email user.""" + try: + from app import email_manager, _configured_domain + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + domain = data.get('domain') or _configured_domain() + password = data.get('password') + if not username or not password: + return jsonify({"error": "Missing required fields: username, password"}), 400 + result = email_manager.create_email_user(username, domain, password) + return jsonify({"created": result}) + except Exception as e: + logger.error(f"Error creating email user: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/email/users/', methods=['DELETE']) +def delete_email_user(username): + """Delete email user.""" + try: + from app import email_manager, _configured_domain + domain = request.args.get('domain') or _configured_domain() + result = email_manager.delete_email_user(username, domain) + return jsonify({"deleted": result}) + except Exception as e: + logger.error(f"Error deleting email user: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/email/status', methods=['GET']) +def get_email_status(): + """Get email service status.""" + try: + from app import email_manager + 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 + +@bp.route('/api/email/connectivity', methods=['GET']) +def test_email_connectivity(): + """Test email connectivity.""" + try: + from app import email_manager + 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 + +@bp.route('/api/email/send', methods=['POST']) +def send_email(): + try: + from app import email_manager + 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 + +@bp.route('/api/email/mailbox/', methods=['GET']) +def get_mailbox_info(username): + """Get mailbox information.""" + try: + from app import email_manager + 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 diff --git a/api/routes/files.py b/api/routes/files.py new file mode 100644 index 0000000..a610f25 --- /dev/null +++ b/api/routes/files.py @@ -0,0 +1,159 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('files', __name__) + +@bp.route('/api/files/users', methods=['GET']) +def get_file_users(): + """Get file storage users.""" + try: + from app import file_manager + 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 + +@bp.route('/api/files/users', methods=['POST']) +def create_file_user(): + """Create file storage user.""" + try: + from app import file_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + password = data.get('password') + if not username or not password: + return jsonify({"error": "Missing required fields: username, password"}), 400 + result = file_manager.create_user(username, password) + return jsonify({"created": result}) + except Exception as e: + logger.error(f"Error creating file user: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/users/', methods=['DELETE']) +def delete_file_user(username): + """Delete file storage user.""" + try: + from app import file_manager + 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 + +@bp.route('/api/files/folders', methods=['POST']) +def create_folder(): + """Create folder.""" + try: + from app import file_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + username = data.get('username') + folder_path = data.get('folder_path') or data.get('path') + if not username or not folder_path: + return jsonify({"error": "Missing required fields: username, folder_path"}), 400 + result = file_manager.create_folder(username, folder_path) + return jsonify({"created": result}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error creating folder: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/folders//', methods=['DELETE']) +def delete_folder(username, folder_path): + """Delete folder.""" + try: + from app import file_manager + result = file_manager.delete_folder(username, folder_path) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error deleting folder: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/upload/', methods=['POST']) +def upload_file(username): + """Upload file.""" + try: + from app import file_manager + if 'file' not in request.files: + return jsonify({"error": "No file provided"}), 400 + + file = request.files['file'] + path = request.form.get('path', '') or file.filename or '' + file_data = file.read() + + result = file_manager.upload_file(username, path, file_data) + return jsonify({"uploaded": result}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error uploading file: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/download//', methods=['GET']) +def download_file(username, file_path): + """Download file.""" + try: + from app import file_manager + result = file_manager.download_file(username, file_path) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error downloading file: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/delete//', methods=['DELETE']) +def delete_file(username, file_path): + """Delete file.""" + try: + from app import file_manager + result = file_manager.delete_file(username, file_path) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error deleting file: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/list/', methods=['GET']) +def list_files(username): + """List files.""" + try: + from app import file_manager + folder = request.args.get('folder', '') + result = file_manager.list_files(username, folder) + return jsonify(result) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error listing files: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/files/status', methods=['GET']) +def get_file_status(): + """Get file service status.""" + try: + from app import file_manager + 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 + +@bp.route('/api/files/connectivity', methods=['GET']) +def test_file_connectivity(): + """Test file service connectivity.""" + try: + from app import file_manager + 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 diff --git a/api/routes/network.py b/api/routes/network.py new file mode 100644 index 0000000..6a2c704 --- /dev/null +++ b/api/routes/network.py @@ -0,0 +1,109 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('network', __name__) + +@bp.route('/api/dns/records', methods=['GET']) +def get_dns_records(): + try: + from app import network_manager + return jsonify(network_manager.get_dns_records()) + except Exception as e: + logger.error(f"Error getting DNS records: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dns/records', methods=['POST']) +def add_dns_record(): + try: + from app import network_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + return jsonify(network_manager.add_dns_record(**data)) + except Exception as e: + logger.error(f"Error adding DNS record: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dns/records', methods=['DELETE']) +def remove_dns_record(): + try: + from app import network_manager + data = request.get_json(silent=True) + return jsonify(network_manager.remove_dns_record(**data)) + except Exception as e: + logger.error(f"Error removing DNS record: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dhcp/leases', methods=['GET']) +def get_dhcp_leases(): + try: + from app import network_manager + return jsonify(network_manager.get_dhcp_leases()) + except Exception as e: + logger.error(f"Error getting DHCP leases: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dhcp/reservations', methods=['POST']) +def add_dhcp_reservation(): + try: + from app import network_manager + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "No data provided"}), 400 + for field in ('mac', 'ip'): + if field not in data: + return jsonify({"error": f"Missing required field: {field}"}), 400 + result = network_manager.add_dhcp_reservation(data['mac'], data['ip'], data.get('hostname', '')) + return jsonify({"success": result}) + except Exception as e: + logger.error(f"Error adding DHCP reservation: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dhcp/reservations', methods=['DELETE']) +def remove_dhcp_reservation(): + try: + from app import network_manager + data = request.get_json(silent=True) + if not data or 'mac' not in data: + return jsonify({"error": "Missing required field: mac"}), 400 + result = network_manager.remove_dhcp_reservation(data['mac']) + return jsonify({"success": result}) + except Exception as e: + logger.error(f"Error removing DHCP reservation: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/ntp/status', methods=['GET']) +def get_ntp_status(): + try: + from app import network_manager + return jsonify(network_manager.get_ntp_status()) + except Exception as e: + logger.error(f"Error getting NTP status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/network/info', methods=['GET']) +def get_network_info(): + try: + from app import network_manager + return jsonify(network_manager.get_network_info()) + except Exception as e: + logger.error(f"Error getting network info: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/dns/status', methods=['GET']) +def get_dns_status(): + try: + from app import network_manager + return jsonify(network_manager.get_dns_status()) + except Exception as e: + logger.error(f"Error getting DNS status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/network/test', methods=['POST']) +def test_network(): + try: + from app import network_manager + return jsonify(network_manager.test_connectivity()) + except Exception as e: + logger.error(f"Error testing network: {e}") + return jsonify({"error": str(e)}), 500 diff --git a/api/routes/peer_dashboard.py b/api/routes/peer_dashboard.py new file mode 100644 index 0000000..1a2e474 --- /dev/null +++ b/api/routes/peer_dashboard.py @@ -0,0 +1,115 @@ +import logging +from flask import Blueprint, jsonify, session +logger = logging.getLogger('picell') +bp = Blueprint('peer_dashboard', __name__) + +@bp.route('/api/peer/dashboard', methods=['GET']) +def peer_dashboard(): + try: + from app import peer_registry, wireguard_manager, _configured_domain + peer_name = session.get('peer_name') + peer = peer_registry.get_peer(peer_name) if peer_name else None + if not peer: + return jsonify({'error': 'Peer not found'}), 404 + + wg_stats = {'online': None, 'transfer_rx': 0, 'transfer_tx': 0, 'last_handshake': None} + public_key = peer.get('public_key') + if public_key: + try: + wg_stats = wireguard_manager.get_peer_status(public_key) + except Exception: + pass + + peer_ip = peer.get('ip', '') + allowed_ips = f"{peer_ip.split('/')[0]}/32" if peer_ip else '' + domain = _configured_domain() + _svc_url_map = { + 'calendar': f'http://calendar.{domain}', + 'files': f'http://files.{domain}', + 'mail': f'http://mail.{domain}', + 'webdav': f'http://webdav.{domain}', + } + service_urls = { + svc: _svc_url_map[svc] + for svc in peer.get('service_access', []) + if svc in _svc_url_map + } + return jsonify({ + 'name': peer_name, + 'ip': peer_ip, + 'service_access': peer.get('service_access', []), + 'service_urls': service_urls, + 'online': wg_stats.get('online'), + 'transfer_rx': wg_stats.get('transfer_rx', 0), + 'transfer_tx': wg_stats.get('transfer_tx', 0), + 'last_handshake': wg_stats.get('last_handshake'), + 'allowed_ips': peer.get('allowed_ips', allowed_ips), + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@bp.route('/api/peer/services', methods=['GET']) +def peer_services(): + try: + from app import peer_registry, wireguard_manager, config_manager, _configured_domain, _resolve_peer_dns + peer_name = session.get('peer_name') + peer = peer_registry.get_peer(peer_name) if peer_name else None + if not peer: + return jsonify({'error': 'Peer not found'}), 404 + + domain = _configured_domain() + peer_ip = peer.get('ip', '') + + server_public_key = '' + wg_port = 51820 + server_endpoint = '' + try: + server_public_key = wireguard_manager.get_keys().get('public_key', '') + wg_port = config_manager.configs.get('_identity', {}).get('wireguard_port', 51820) + srv = wireguard_manager.get_server_config() + server_endpoint = srv.get('endpoint') or '' + except Exception: + pass + + wg_config = '' + peer_private_key = peer.get('private_key', '') + if peer_private_key: + try: + internet_access = peer.get('internet_access', True) + allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() + wg_config = 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, + ) + except Exception: + pass + + return jsonify({ + 'username': peer_name, + 'wireguard': { + 'ip': peer_ip, + 'server_public_key': server_public_key, + 'endpoint_port': wg_port, + 'dns': _resolve_peer_dns(), + 'config': wg_config, + }, + 'email': { + 'address': f'{peer_name}@{domain}', + 'smtp': {'host': f'mail.{domain}', 'port': 587}, + 'imap': {'host': f'mail.{domain}', 'port': 993}, + }, + 'caldav': { + 'url': f'http://calendar.{domain}', + 'username': peer_name, + }, + 'files': { + 'url': f'http://files.{domain}', + 'username': peer_name, + }, + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/api/routes/peers.py b/api/routes/peers.py new file mode 100644 index 0000000..e714a02 --- /dev/null +++ b/api/routes/peers.py @@ -0,0 +1,299 @@ +import logging +import ipaddress +from flask import Blueprint, request, jsonify, session +logger = logging.getLogger('picell') +bp = Blueprint('peers', __name__) + + +def _next_peer_ip() -> str: + """Auto-assign the next free host address from the configured VPN subnet.""" + from app import wireguard_manager, peer_registry + server_addr = wireguard_manager._get_configured_address() + 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}') + + +@bp.route('/api/peers', methods=['GET']) +def get_peers(): + try: + from app import peer_registry + return jsonify(peer_registry.list_peers()) + except Exception as e: + logger.error(f"Error getting peers: {e}") + return jsonify({"error": str(e)}), 500 + + +@bp.route('/api/peers', methods=['POST']) +def add_peer(): + """Add a peer and auto-provision auth/email/calendar/files accounts.""" + try: + from app import (peer_registry, wireguard_manager, firewall_manager, + email_manager, calendar_manager, file_manager, auth_manager, + cell_link_manager, _configured_domain, COREFILE_PATH) + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + + for field in ('name', 'public_key'): + if field not in data: + return jsonify({"error": f"Missing required field: {field}"}), 400 + + password = data.get('password') or '' + if not password: + return jsonify({"error": "Missing required field: password"}), 400 + if len(password) < 10: + return jsonify({"error": "password must be at least 10 characters"}), 400 + + try: + assigned_ip = data.get('ip') or _next_peer_ip() + except ValueError as e: + return jsonify({'error': str(e)}), 409 + + _valid_services = {'calendar', 'files', 'mail', 'webdav'} + service_access = data.get('service_access', list(_valid_services)) + if not isinstance(service_access, list) or not all(s in _valid_services for s in service_access): + return jsonify({"error": f"service_access must be a list of: {sorted(_valid_services)}"}), 400 + + peer_name = data['name'] + + if not auth_manager.create_user(peer_name, password, 'peer'): + return jsonify({"error": "Could not create auth account (duplicate name?)"}), 400 + + provisioned = ['auth'] + domain = _configured_domain() + for step_name, step_fn in [ + ('email', lambda: email_manager.create_email_user(peer_name, domain, password)), + ('calendar', lambda: calendar_manager.create_calendar_user(peer_name, password)), + ('files', lambda: file_manager.create_user(peer_name, password)), + ]: + try: + if step_fn(): + provisioned.append(step_name) + else: + logger.warning(f"Peer {peer_name}: {step_name} account creation returned False") + except Exception as e: + logger.warning(f"Peer {peer_name}: {step_name} account creation failed (non-fatal): {e}") + + peer_info = { + 'peer': peer_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': service_access, + 'peer_access': data.get('peer_access', True), + 'config_needs_reinstall': False, + } + + peer_added_to_registry = False + firewall_applied = False + try: + success = peer_registry.add_peer(peer_info) + if not success: + for svc in ('files', 'calendar', 'email', 'auth'): + try: + if svc == 'files': + file_manager.delete_user(peer_name) + elif svc == 'calendar': + calendar_manager.delete_calendar_user(peer_name) + elif svc == 'email': + email_manager.delete_email_user(peer_name, _configured_domain()) + elif svc == 'auth': + auth_manager.delete_user(peer_name) + except Exception: + pass + return jsonify({"error": f"Peer {peer_name} already exists"}), 400 + peer_added_to_registry = True + + firewall_manager.apply_peer_rules(peer_info['ip'], peer_info) + firewall_applied = True + + wg_allowed = f"{assigned_ip}/32" if '/' not in assigned_ip else assigned_ip + try: + wireguard_manager.add_peer(peer_name, data['public_key'], endpoint_ip='', allowed_ips=wg_allowed) + except Exception as wg_err: + logger.warning(f"Peer {peer_name}: WireGuard server config update failed (non-fatal): {wg_err}") + + firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(), + cell_links=cell_link_manager.list_connections()) + return jsonify({"message": f"Peer {peer_name} added successfully", "ip": assigned_ip}), 201 + + except Exception as e: + if firewall_applied: + try: + firewall_manager.clear_peer_rules(peer_info['ip']) + except Exception: + pass + if peer_added_to_registry: + try: + peer_registry.remove_peer(peer_name) + except Exception: + pass + logger.error(f"Error adding peer {peer_name}: {e}") + return jsonify({'error': str(e)}), 500 + + except Exception as e: + logger.error(f"Error adding peer: {e}") + return jsonify({"error": str(e)}), 500 + + +@bp.route('/api/peers/', methods=['PUT']) +def update_peer(peer_name): + try: + from app import peer_registry, firewall_manager, cell_link_manager, _configured_domain, COREFILE_PATH + data = request.get_json(silent=True) or {} + existing = peer_registry.get_peer(peer_name) + if not existing: + return jsonify({"error": "Peer not found"}), 404 + + 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: + 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, _configured_domain(), + cell_links=cell_link_manager.list_connections()) + return jsonify({"message": f"Peer {peer_name} updated", "config_changed": config_changed}) + 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 + + +@bp.route('/api/peers//clear-reinstall', methods=['POST']) +def clear_peer_reinstall(peer_name): + try: + from app import peer_registry + peer_registry.clear_reinstall_flag(peer_name) + return jsonify({"message": "Reinstall flag cleared"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route('/api/peers/', methods=['DELETE']) +def remove_peer(peer_name): + try: + from app import (peer_registry, wireguard_manager, firewall_manager, + email_manager, calendar_manager, file_manager, auth_manager, + cell_link_manager, _configured_domain, COREFILE_PATH) + peer = peer_registry.get_peer(peer_name) + if not peer: + return jsonify({"message": f"Peer {peer_name} not found or already removed"}) + peer_ip = peer.get('ip') + peer_pubkey = peer.get('public_key', '') + success = peer_registry.remove_peer(peer_name) + if success: + if peer_ip: + firewall_manager.clear_peer_rules(peer_ip) + firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(), + cell_links=cell_link_manager.list_connections()) + if peer_pubkey: + try: + wireguard_manager.remove_peer(peer_pubkey) + except Exception as wg_err: + logger.warning(f"Peer {peer_name}: WireGuard removal failed (non-fatal): {wg_err}") + for _cleanup in [ + lambda: email_manager.delete_email_user(peer_name, _configured_domain()), + lambda: calendar_manager.delete_calendar_user(peer_name), + lambda: file_manager.delete_user(peer_name), + lambda: auth_manager.delete_user(peer_name), + ]: + try: + _cleanup() + except Exception: + pass + 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/register', methods=['POST']) +def register_peer(): + try: + from app import peer_registry + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + return jsonify(peer_registry.register_peer(data)) + except Exception as e: + logger.error(f"Error registering peer: {e}") + return jsonify({"error": str(e)}), 500 + + +@bp.route('/api/peers//unregister', methods=['DELETE']) +def unregister_peer(peer_name): + try: + from app import peer_registry + return jsonify(peer_registry.unregister_peer(peer_name)) + except Exception as e: + logger.error(f"Error unregistering peer: {e}") + return jsonify({"error": str(e)}), 500 + + +@bp.route('/api/peers//update-ip', methods=['PUT']) +def update_peer_ip_registry(peer_name): + try: + from app import peer_registry, routing_manager + 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: + try: + routing_manager.update_peer_ip(peer_name, new_ip) + except Exception as e: + logger.warning(f"RoutingManager update_peer_ip failed: {e}") + return jsonify({"message": f"IP update received for {peer_name}"}) + 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 + + +@bp.route('/api/ip-update', methods=['POST']) +def ip_update(): + try: + from app import peer_registry, routing_manager + 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: + try: + routing_manager.update_peer_ip(peer_name, new_ip) + except Exception as e: + logger.warning(f"RoutingManager update_peer_ip failed: {e}") + return jsonify({"message": f"IP update received for {peer_name}"}) + 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 diff --git a/api/routes/routing.py b/api/routes/routing.py new file mode 100644 index 0000000..50fe979 --- /dev/null +++ b/api/routes/routing.py @@ -0,0 +1,207 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('routing', __name__) + +@bp.route('/api/routing/status', methods=['GET']) +def get_routing_status(): + try: + from app import routing_manager + return jsonify(routing_manager.get_status()) + except Exception as e: + logger.error(f"Error getting routing status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/setup', methods=['POST']) +def setup_routing(): + try: + from app import routing_manager + 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 + +@bp.route('/api/routing/nat', methods=['GET']) +def get_nat_rules(): + try: + from app import routing_manager + return jsonify({"nat_rules": routing_manager.get_nat_rules()}) + except Exception as e: + logger.error(f"Error getting NAT rules: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/nat', methods=['POST']) +def add_nat_rule(): + try: + from app import routing_manager + 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 + +@bp.route('/api/routing/nat/', methods=['DELETE']) +def remove_nat_rule(rule_id): + try: + from app import routing_manager + return jsonify(routing_manager.remove_nat_rule(rule_id)) + except Exception as e: + logger.error(f"Error removing NAT rule: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/peers', methods=['GET']) +def get_peer_routes(): + try: + from app import routing_manager + return jsonify({"peer_routes": routing_manager.get_peer_routes()}) + except Exception as e: + logger.error(f"Error getting peer routes: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/peers', methods=['POST']) +def add_peer_route(): + try: + from app import routing_manager + data = request.get_json(silent=True) or {} + peer_name = data.get('peer_name') + peer_ip = data.get('peer_ip') + if not peer_name or not peer_ip: + return jsonify({"error": "Missing required fields: peer_name, peer_ip"}), 400 + result = routing_manager.add_peer_route( + peer_name, peer_ip, + data.get('allowed_networks', []), + data.get('route_type', 'lan') + ) + return jsonify({"added": result}) + except Exception as e: + logger.error(f"Error adding peer route: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/peers/', methods=['DELETE']) +def remove_peer_route(peer_name): + try: + from app import routing_manager + return jsonify(routing_manager.remove_peer_route(peer_name)) + except Exception as e: + logger.error(f"Error removing peer route: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/exit-nodes', methods=['POST']) +def add_exit_node(): + try: + from app import routing_manager + data = request.get_json(silent=True) or {} + peer_name = data.get('peer_name') + peer_ip = data.get('peer_ip') + if not peer_name or not peer_ip: + return jsonify({"error": "Missing required fields: peer_name, peer_ip"}), 400 + return jsonify({"added": routing_manager.add_exit_node(peer_name, peer_ip, data.get('allowed_domains'))}) + except Exception as e: + logger.error(f"Error adding exit node: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/bridge', methods=['POST']) +def add_bridge_route(): + try: + from app import routing_manager + data = request.get_json(silent=True) or {} + source_peer = data.get('source_peer') + target_peer = data.get('target_peer') + if not source_peer or not target_peer: + return jsonify({"error": "Missing required fields: source_peer, target_peer"}), 400 + return jsonify({"added": routing_manager.add_bridge_route(source_peer, target_peer, data.get('allowed_networks', []))}) + except Exception as e: + logger.error(f"Error adding bridge route: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/split', methods=['POST']) +def add_split_route(): + try: + from app import routing_manager + data = request.get_json(silent=True) or {} + network = data.get('network') + exit_peer = data.get('exit_peer') + if not network or not exit_peer: + return jsonify({"error": "Missing required fields: network, exit_peer"}), 400 + return jsonify({"added": routing_manager.add_split_route(network, exit_peer, data.get('fallback_peer'))}) + except Exception as e: + logger.error(f"Error adding split route: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/firewall', methods=['GET']) +def get_firewall_rules(): + try: + from app import routing_manager + return jsonify({"firewall_rules": routing_manager.get_firewall_rules()}) + except Exception as e: + logger.error(f"Error getting firewall rules: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/firewall', methods=['POST']) +def add_firewall_rule(): + try: + from app import routing_manager + 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 + +@bp.route('/api/routing/firewall/', methods=['DELETE']) +def remove_firewall_rule(rule_id): + try: + from app import routing_manager + 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 + +@bp.route('/api/routing/live-iptables', methods=['GET']) +def get_live_iptables(): + try: + from app import routing_manager + return jsonify(routing_manager.get_live_iptables()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/routing/connectivity', methods=['POST']) +def test_routing_connectivity(): + try: + from app import routing_manager + data = request.get_json(silent=True) or {} + return jsonify(routing_manager.test_routing_connectivity( + data.get('target_ip', '8.8.8.8'), + data.get('via_peer') + )) + except Exception as e: + logger.error(f"Error testing routing connectivity: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/routing/logs', methods=['GET']) +def get_routing_logs(): + try: + from app import routing_manager + lines = request.args.get('lines', 50, type=int) + return jsonify(routing_manager.get_logs(lines)) + except Exception as e: + logger.error(f"Error getting routing logs: {e}") + return jsonify({"error": str(e)}), 500 diff --git a/api/routes/services.py b/api/routes/services.py new file mode 100644 index 0000000..13791e6 --- /dev/null +++ b/api/routes/services.py @@ -0,0 +1,291 @@ +import logging +import json +import os +from datetime import datetime +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('services', __name__) + +@bp.route('/api/services/bus/status', methods=['GET']) +def get_service_bus_status(): + try: + from app import service_bus + 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 + +@bp.route('/api/services/bus/events', methods=['GET']) +def get_service_bus_events(): + try: + from app import service_bus + from service_bus import EventType + 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 + ) + return jsonify([{ + 'event_id': e.event_id, + 'event_type': e.event_type.value, + 'source': e.source, + 'data': e.data, + 'timestamp': e.timestamp.isoformat() + } for e in events]) + except Exception as e: + logger.error(f"Error getting service bus events: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/services/bus/services//start', methods=['POST']) +def start_service(service_name): + try: + from app import service_bus + success = service_bus.orchestrate_service_start(service_name) + if success: + return jsonify({"message": f"Service {service_name} started successfully"}) + 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 + +@bp.route('/api/services/bus/services//stop', methods=['POST']) +def stop_service(service_name): + try: + from app import service_bus + success = service_bus.orchestrate_service_stop(service_name) + if success: + return jsonify({"message": f"Service {service_name} stopped successfully"}) + 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 + +@bp.route('/api/services/bus/services//restart', methods=['POST']) +def restart_service(service_name): + try: + from app import service_bus + success = service_bus.orchestrate_service_restart(service_name) + if success: + return jsonify({"message": f"Service {service_name} restarted successfully"}) + 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 + +@bp.route('/api/logs/services/', methods=['GET']) +def get_service_logs(service): + try: + from app import log_manager + 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 + +@bp.route('/api/logs/search', methods=['POST']) +def search_logs(): + try: + from app import log_manager + data = request.get_json(silent=True) or {} + results = log_manager.search_logs( + data.get('query', ''), + data.get('time_range'), + data.get('services'), + data.get('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 + +@bp.route('/api/logs/export', methods=['POST']) +def export_logs(): + try: + from app import log_manager + data = request.get_json(silent=True) or {} + format = data.get('format', 'json') + log_data = log_manager.export_logs(format, data.get('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 + +@bp.route('/api/logs/statistics', methods=['GET']) +def get_log_statistics(): + try: + from app import log_manager + return jsonify(log_manager.get_log_statistics(request.args.get('service'))) + except Exception as e: + logger.error(f"Error getting log statistics: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/logs/rotate', methods=['POST']) +def rotate_logs(): + try: + from app import log_manager + data = request.get_json(silent=True) or {} + log_manager.rotate_logs(data.get('service')) + return jsonify({"message": "Logs rotated successfully"}) + except Exception as e: + logger.error(f"Error rotating logs: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/logs/files', methods=['GET']) +def get_log_file_infos(): + try: + from app import log_manager + 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 + +@bp.route('/api/logs/verbosity', methods=['GET']) +def get_log_verbosity(): + try: + from app import log_manager + 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 + +@bp.route('/api/logs/verbosity', methods=['PUT']) +def set_log_verbosity(): + try: + from app import log_manager + data = request.get_json(silent=True) or {} + for service, level in data.items(): + log_manager.set_service_level(service, level) + levels_file = os.path.join(os.path.dirname(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 + +@bp.route('/api/services/status', methods=['GET']) +def get_all_services_status(): + try: + from app import service_bus + services_status = {} + for service_name in service_bus.list_services(): + try: + service = service_bus.get_service(service_name) + status = service.get_status() + if isinstance(status, dict): + clean_status = { + 'status': status.get('status', 'unknown'), + 'running': status.get('running', False), + 'timestamp': status.get('timestamp', datetime.utcnow().isoformat()) + } + 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 + +@bp.route('/api/services/connectivity', methods=['GET']) +def test_all_services_connectivity(): + try: + from app import service_bus + 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 + +@bp.route('/api/logs', methods=['GET']) +def get_backend_logs(): + log_file = os.path.join(os.path.dirname(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 diff --git a/api/routes/vault.py b/api/routes/vault.py new file mode 100644 index 0000000..ab5b393 --- /dev/null +++ b/api/routes/vault.py @@ -0,0 +1,165 @@ +import logging +import os +from flask import Blueprint, request, jsonify, current_app +logger = logging.getLogger('picell') +bp = Blueprint('vault', __name__) + +@bp.route('/api/vault/status', methods=['GET']) +def get_vault_status(): + try: + return jsonify(current_app.vault_manager.get_status()) + except Exception as e: + logger.error(f"Error getting vault status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/vault/certificates', methods=['GET']) +def get_certificates(): + try: + return jsonify(current_app.vault_manager.list_certificates()) + except Exception as e: + logger.error(f"Error getting certificates: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.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 + +@bp.route('/api/vault/certificates/', methods=['DELETE']) +def revoke_certificate(common_name): + try: + return jsonify({"revoked": current_app.vault_manager.revoke_certificate(common_name)}) + except Exception as e: + logger.error(f"Error revoking certificate: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/vault/ca/certificate', methods=['GET']) +def get_ca_certificate(): + try: + return jsonify({"certificate": current_app.vault_manager.get_ca_certificate()}) + except Exception as e: + logger.error(f"Error getting CA certificate: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/vault/age/public-key', methods=['GET']) +def get_age_public_key(): + try: + return jsonify({"public_key": current_app.vault_manager.get_age_public_key()}) + except Exception as e: + logger.error(f"Error getting Age public key: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/vault/trust/keys', methods=['GET']) +def get_trusted_keys(): + try: + return jsonify(current_app.vault_manager.get_trusted_keys()) + except Exception as e: + logger.error(f"Error getting trusted keys: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.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 + +@bp.route('/api/vault/trust/keys/', methods=['DELETE']) +def remove_trusted_key(name): + try: + return jsonify({"removed": current_app.vault_manager.remove_trusted_key(name)}) + except Exception as e: + logger.error(f"Error removing trusted key: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.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 + +@bp.route('/api/vault/trust/chains', methods=['GET']) +def get_trust_chains(): + try: + return jsonify(current_app.vault_manager.get_trust_chains()) + except Exception as e: + logger.error(f"Error getting trust chains: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/vault/secrets', methods=['GET']) +def list_secrets(): + try: + from app import is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify({'secrets': current_app.vault_manager.list_secrets()}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/vault/secrets', methods=['POST']) +def store_secret(): + try: + from app import is_local_request + 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 + current_app.vault_manager.store_secret(data['name'], data['value']) + return jsonify({'stored': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/vault/secrets/', methods=['GET']) +def get_secret(name): + try: + from app import is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + value = current_app.vault_manager.get_secret(name) + if value is None: + return jsonify({'error': 'Not found'}), 404 + return jsonify({'name': name, 'value': value}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/vault/secrets/', methods=['DELETE']) +def delete_secret(name): + try: + from app import is_local_request + if not is_local_request(): + return jsonify({'error': 'Access denied'}), 403 + return jsonify({'deleted': current_app.vault_manager.delete_secret(name)}) + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/api/routes/wireguard.py b/api/routes/wireguard.py new file mode 100644 index 0000000..9f4639f --- /dev/null +++ b/api/routes/wireguard.py @@ -0,0 +1,236 @@ +import logging +from flask import Blueprint, request, jsonify +logger = logging.getLogger('picell') +bp = Blueprint('wireguard', __name__) + +@bp.route('/api/wireguard/keys', methods=['GET']) +def get_wireguard_keys(): + try: + from app import wireguard_manager + keys = wireguard_manager.get_keys() + return jsonify({ + 'public_key': keys.get('public_key', ''), + 'has_private_key': bool(keys.get('private_key')), + }) + except Exception as e: + logger.error(f"Error getting WireGuard keys: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/keys/peer', methods=['POST']) +def generate_peer_keys(): + try: + from app import wireguard_manager + 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 + return jsonify(wireguard_manager.generate_peer_keys(name)) + except Exception as e: + logger.error(f"Error generating peer keys: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/config', methods=['GET']) +def get_wireguard_config(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_config()) + except Exception as e: + logger.error(f"Error getting WireGuard config: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/peers', methods=['GET']) +def get_wireguard_peers(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_peers()) + except Exception as e: + logger.error(f"Error getting WireGuard peers: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/peers', methods=['POST']) +def add_wireguard_peer(): + try: + from app import wireguard_manager + 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 + +@bp.route('/api/wireguard/peers', methods=['DELETE']) +def remove_wireguard_peer(): + try: + from app import wireguard_manager + data = request.get_json(silent=True) or {} + public_key = data.get('public_key') or data.get('name', '') + return jsonify({"success": wireguard_manager.remove_peer(public_key)}) + except Exception as e: + logger.error(f"Error removing WireGuard peer: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/status', methods=['GET']) +def get_wireguard_status(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_status()) + except Exception as e: + logger.error(f"Error getting WireGuard status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/connectivity', methods=['POST']) +def test_wireguard_connectivity(): + try: + from app import wireguard_manager + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "No data provided"}), 400 + return jsonify(wireguard_manager.test_connectivity(data)) + except Exception as e: + logger.error(f"Error testing WireGuard connectivity: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/peers/ip', methods=['PUT']) +def update_peer_ip(): + try: + from app import wireguard_manager + 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 + +@bp.route('/api/wireguard/peers/status', methods=['POST']) +def get_peer_status(): + try: + from app import wireguard_manager + data = request.get_json(silent=True) or {} + public_key = data.get('public_key', '') + if not public_key: + return jsonify({"error": "Missing public_key"}), 400 + return jsonify(wireguard_manager.get_peer_status(public_key)) + except Exception as e: + logger.error(f"Error getting peer status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/peers/statuses', methods=['GET']) +def get_all_peer_statuses(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_all_peer_statuses()) + except Exception as e: + logger.error(f"Error getting peer statuses: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/network/setup', methods=['POST']) +def setup_network(): + try: + from app import wireguard_manager + success = wireguard_manager.setup_network_configuration() + if success: + return jsonify({"message": "Network configuration setup completed successfully"}) + 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 + +@bp.route('/api/wireguard/network/status', methods=['GET']) +def get_network_status(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_network_status()) + except Exception as e: + logger.error(f"Error getting network status: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/peers/config', methods=['POST']) +def get_peer_config(): + try: + from app import wireguard_manager, peer_registry + data = request.get_json(silent=True) or {} + peer_name = data.get('name', data.get('peer', '')) + + 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', '') + + server_endpoint = data.get('server_endpoint', '') + if not server_endpoint: + srv = wireguard_manager.get_server_config() + server_endpoint = srv.get('endpoint') or '' + + 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 + +@bp.route('/api/wireguard/server-config', methods=['GET']) +def get_server_config(): + try: + from app import wireguard_manager + return jsonify(wireguard_manager.get_server_config()) + except Exception as e: + logger.error(f"Error getting server config: {e}") + return jsonify({"error": str(e)}), 500 + +@bp.route('/api/wireguard/refresh-ip', methods=['GET', 'POST']) +def refresh_external_ip(): + try: + from app import wireguard_manager + 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 + +@bp.route('/api/wireguard/apply-enforcement', methods=['POST']) +def apply_wireguard_enforcement(): + try: + from app import peer_registry, firewall_manager, cell_link_manager, _configured_domain, COREFILE_PATH + peers = peer_registry.list_peers() + firewall_manager.apply_all_peer_rules(peers) + firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(), + cell_links=cell_link_manager.list_connections()) + return jsonify({'ok': True, 'peers': len(peers)}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/wireguard/check-port', methods=['GET', 'POST']) +def check_wireguard_port(): + try: + from app import wireguard_manager + 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 diff --git a/tests/test_peer_management_edge_cases.py b/tests/test_peer_management_edge_cases.py index 2a9d203..e8f347a 100644 --- a/tests/test_peer_management_edge_cases.py +++ b/tests/test_peer_management_edge_cases.py @@ -31,7 +31,7 @@ class TestAddPeerSubnetExhaustion(unittest.TestCase): app.config['TESTING'] = True self.client = app.test_client() - @patch('app._next_peer_ip') + @patch('routes.peers._next_peer_ip') @patch('app.auth_manager') def test_add_peer_returns_409_when_subnet_exhausted(self, mock_auth, mock_next_ip): mock_auth.create_user.return_value = True @@ -50,7 +50,7 @@ class TestAddPeerSubnetExhaustion(unittest.TestCase): data = json.loads(r.data) self.assertIn('error', data) - @patch('app._next_peer_ip') + @patch('routes.peers._next_peer_ip') @patch('app.auth_manager') def test_add_peer_409_error_message_mentions_ip(self, mock_auth, mock_next_ip): mock_auth.create_user.return_value = True