from flask import Flask, render_template, jsonify, request, redirect, url_for, session, flash, Response from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from functools import wraps import requests import urllib3 import traceback import json import queue import time import subprocess import platform from datetime import datetime, timedelta from config import Config from models import User, UserVM, ActionLog, Subdomain, IPAssignment, get_db_connection # Notification queues per user notification_queues = {} urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) app = Flask(__name__) app.config.from_object(Config) # Flask-Login setup login_manager = LoginManager() login_manager.init_app(app) login_manager.login_view = 'login' login_manager.login_message = 'Effettua il login per accedere a questa pagina.' # Proxmox API setup BASE_URL = f"https://{Config.PROXMOX_IP}:{Config.PROXMOX_PORT}/api2/json" HEADERS = { "Authorization": f"PVEAPIToken={Config.API_TOKEN_ID}={Config.API_TOKEN_SECRET}" } # User class per Flask-Login class FlaskUser(UserMixin): def __init__(self, user_data): self.id = user_data['id'] self.username = user_data['username'] self.email = user_data['email'] self.is_admin = user_data['is_admin'] @login_manager.user_loader def load_user(user_id): user_data = User.get_by_id(int(user_id)) if user_data: return FlaskUser(user_data) return None # Decorator per verificare se l'utente è admin def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: flash('Accesso negato. Privilegi di amministratore richiesti.', 'error') return redirect(url_for('dashboard')) return f(*args, **kwargs) return decorated_function # Helper per logging azioni def log_user_action(vm_id, action_type, status, error_message=None): try: ActionLog.log_action( user_id=current_user.id, vm_id=vm_id, action_type=action_type, status=status, error_message=error_message, ip_address=request.remote_addr ) except Exception as e: app.logger.error(f"Errore logging azione: {e}") # Helper per chiamate Proxmox API def proxmox_api_call(method, endpoint, data=None, params=None, timeout=30): """Helper per chiamate all'API Proxmox""" url = f"{BASE_URL}{endpoint}" # Timeout più lungo per operazioni su storage di rete (CIFS/NFS) if '/storage/' in endpoint and '/content' in endpoint: timeout = 120 try: if method.upper() == 'GET': response = requests.get(url, headers=HEADERS, params=params, verify=False, timeout=timeout) elif method.upper() == 'POST': response = requests.post(url, headers=HEADERS, data=data, verify=False, timeout=timeout) elif method.upper() == 'PUT': response = requests.put(url, headers=HEADERS, data=data, verify=False, timeout=timeout) elif method.upper() == 'DELETE': response = requests.delete(url, headers=HEADERS, verify=False, timeout=timeout) else: return None, "Metodo HTTP non supportato" response.raise_for_status() return response.json(), None except requests.exceptions.RequestException as e: return None, str(e) # ==================== ROUTES AUTENTICAZIONE ==================== @app.route('/') def index(): if current_user.is_authenticated: return redirect(url_for('dashboard')) return redirect(url_for('login')) @app.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('dashboard')) if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') remember = request.form.get('remember', False) if User.verify_password(username, password): user_data = User.get_by_username(username) user = FlaskUser(user_data) login_user(user, remember=remember) User.update_last_login(user.id) # Log del login ActionLog.log_action(user.id, 0, 'login', 'success', ip_address=request.remote_addr) flash(f'Benvenuto, {user.username}!', 'success') next_page = request.args.get('next') return redirect(next_page or url_for('dashboard')) else: flash('Username o password non validi.', 'error') return render_template('login.html') @app.route('/logout') @login_required def logout(): logout_user() flash('Logout effettuato con successo.', 'success') return redirect(url_for('login')) @app.route('/profile') @login_required def profile(): """Pagina profilo utente""" return render_template('profile.html', user=current_user) @app.route('/domains') @login_required def domains(): """Pagina gestione domini""" return render_template('domains.html', user=current_user, domain=Config.CLOUDFLARE_DOMAIN) @app.route('/api/profile/update-email', methods=['POST']) @login_required def update_email(): """Aggiorna email utente""" try: data = request.get_json() new_email = data.get('email') password = data.get('password') if not new_email or not password: return jsonify({'status': 'error', 'message': 'Email e password richiesti'}), 400 # Verifica password corrente if not User.verify_password(current_user.username, password): return jsonify({'status': 'error', 'message': 'Password non corretta'}), 403 # Verifica che l'email non sia già usata existing_user = User.get_by_username(new_email) # Check if email exists as username conn = get_db_connection() try: with conn.cursor() as cursor: cursor.execute("SELECT id FROM users WHERE email = %s AND id != %s", (new_email, current_user.id)) if cursor.fetchone(): return jsonify({'status': 'error', 'message': 'Email già in uso'}), 400 finally: conn.close() # Aggiorna email if User.update_email(current_user.id, new_email): return jsonify({'status': 'success', 'message': 'Email aggiornata con successo'}) else: return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 except Exception as e: app.logger.error(f"Errore update_email: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/profile/update-password', methods=['POST']) @login_required def update_password(): """Aggiorna password utente""" try: data = request.get_json() current_password = data.get('current_password') new_password = data.get('new_password') confirm_password = data.get('confirm_password') if not current_password or not new_password or not confirm_password: return jsonify({'status': 'error', 'message': 'Tutti i campi sono richiesti'}), 400 # Verifica password corrente if not User.verify_password(current_user.username, current_password): return jsonify({'status': 'error', 'message': 'Password corrente non corretta'}), 403 # Verifica che le nuove password corrispondano if new_password != confirm_password: return jsonify({'status': 'error', 'message': 'Le nuove password non corrispondono'}), 400 # Verifica lunghezza minima if len(new_password) < 6: return jsonify({'status': 'error', 'message': 'La password deve essere di almeno 6 caratteri'}), 400 # Aggiorna password if User.update_password(current_user.id, new_password): return jsonify({'status': 'success', 'message': 'Password aggiornata con successo'}) else: return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 except Exception as e: app.logger.error(f"Errore update_password: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/dashboard') @login_required def dashboard(): if current_user.is_admin: return render_template('admin_dashboard.html', user=current_user) else: return render_template('user_dashboard.html', user=current_user) # ==================== API ROUTES - VM MANAGEMENT ==================== @app.route('/api/my-vms') @login_required def get_my_vms(): """Recupera le VM dell'utente loggato con stato da Proxmox""" try: user_vms = UserVM.get_user_vms(current_user.id) app.logger.info(f"User {current_user.id} has {len(user_vms)} VMs assigned: {user_vms}") # Recupera info da Proxmox per ogni VM vms_with_status = [] for vm in user_vms: vm_id = vm['vm_id'] # Cerca la VM su tutti i nodi app.logger.info(f"Fetching nodes list from Proxmox...") nodes_data, error = proxmox_api_call('GET', '/nodes') if error: app.logger.error(f"Error fetching nodes: {error}") continue if not nodes_data or 'data' not in nodes_data: app.logger.error(f"Invalid nodes response: {nodes_data}") continue app.logger.info(f"Found {len(nodes_data.get('data', []))} nodes: {[n.get('node') for n in nodes_data.get('data', [])]}") vm_info = None for node in nodes_data.get('data', []): node_name = node['node'] app.logger.info(f"Checking node: {node_name} for VM {vm_id}") # Prova QEMU qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') if qemu_data and 'data' in qemu_data: vm_info = qemu_data['data'] vm_info['type'] = 'qemu' vm_info['node'] = node_name app.logger.info(f"Found QEMU VM {vm_id} on node {node_name}") break # Prova LXC lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') if lxc_data and 'data' in lxc_data: vm_info = lxc_data['data'] vm_info['type'] = 'lxc' vm_info['node'] = node_name app.logger.info(f"Found LXC VM {vm_id} on node {node_name}") break if vm_info: vms_with_status.append({ 'vm_id': vm_id, 'vm_name': vm['vm_name'] or vm_info.get('name', f'VM-{vm_id}'), 'notes': vm['notes'], 'ip_address': vm.get('ip_address'), 'status': vm_info.get('status', 'unknown'), 'uptime': vm_info.get('uptime', 0), 'cpu': vm_info.get('cpu', 0), 'mem': vm_info.get('mem', 0), 'maxmem': vm_info.get('maxmem', 0), 'disk': vm_info.get('disk', 0), 'maxdisk': vm_info.get('maxdisk', 0), 'type': vm_info['type'], 'node': vm_info['node'] }) else: app.logger.warning(f"VM {vm_id} not found in Proxmox!") vms_with_status.append({ 'vm_id': vm_id, 'vm_name': vm['vm_name'] or f'VM-{vm_id}', 'notes': vm['notes'], 'ip_address': vm.get('ip_address'), 'status': 'unknown', 'type': 'unknown', 'node': 'unknown', 'cpu': 0, 'mem': 0, 'maxmem': 0, 'disk': 0, 'maxdisk': 0 }) return jsonify({ 'status': 'success', 'data': vms_with_status }) except Exception as e: app.logger.error(f"Errore get_my_vms: {traceback.format_exc()}") return jsonify({ 'status': 'error', 'message': str(e) }), 500 @app.route('/api/vm//status') @login_required def get_vm_status(vm_id): """Recupera lo stato di una VM specifica con metriche real-time""" try: # Verifica che l'utente abbia accesso if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Cerca la VM su tutti i nodi nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') if qemu_data and 'data' in qemu_data: vm_info = qemu_data['data'] # Recupera info sui dischi config_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/config') if config_data and 'data' in config_data: vm_info['config'] = config_data['data'] return jsonify({'status': 'success', 'data': vm_info, 'node': node_name, 'type': 'qemu'}) # Prova LXC lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') if lxc_data and 'data' in lxc_data: vm_info = lxc_data['data'] # Recupera info sui dischi config_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/config') if config_data and 'data' in config_data: vm_info['config'] = config_data['data'] return jsonify({'status': 'success', 'data': vm_info, 'node': node_name, 'type': 'lxc'}) return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: app.logger.error(f"Errore get_vm_status: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//rrddata') @login_required def get_vm_rrddata(vm_id): """Recupera dati RRD per grafici in tempo reale""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 timeframe = request.args.get('timeframe', 'hour') nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU rrd_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/rrddata', params={'timeframe': timeframe}) if rrd_data and 'data' in rrd_data: return jsonify({'status': 'success', 'data': rrd_data['data']}) # Prova LXC rrd_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/rrddata', params={'timeframe': timeframe}) if rrd_data and 'data' in rrd_data: return jsonify({'status': 'success', 'data': rrd_data['data']}) return jsonify({'status': 'error', 'message': 'Dati non disponibili'}), 404 except Exception as e: app.logger.error(f"Errore get_vm_rrddata: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== SNAPSHOT MANAGEMENT ==================== @app.route('/api/vm//snapshots') @login_required def get_vm_snapshots(vm_id): """Recupera le snapshot di una VM (max 3 per utenti normali)""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/snapshot') if snap_data and 'data' in snap_data: snapshots = [s for s in snap_data['data'] if s.get('name') != 'current'] # Limita a 3 per utenti normali if not current_user.is_admin: snapshots = snapshots[:3] return jsonify({ 'status': 'success', 'count': len(snapshots), 'data': snapshots, 'node': node_name, 'type': 'qemu' }) # Prova LXC snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/snapshot') if snap_data and 'data' in snap_data: snapshots = [s for s in snap_data['data'] if s.get('name') != 'current'] if not current_user.is_admin: snapshots = snapshots[:3] return jsonify({ 'status': 'success', 'count': len(snapshots), 'data': snapshots, 'node': node_name, 'type': 'lxc' }) return jsonify({'status': 'success', 'count': 0, 'data': []}), 200 except Exception as e: app.logger.error(f"Errore get_vm_snapshots: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//snapshot', methods=['POST']) @login_required def create_snapshot(vm_id): """Crea una snapshot di una VM""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 data = request.get_json() snapname = data.get('snapname', f'snap-{datetime.now().strftime("%Y%m%d-%H%M%S")}') description = data.get('description', '') # Controlla se l'utente ha già 3 snapshot (se non admin) if not current_user.is_admin: nodes_data, _ = proxmox_api_call('GET', '/nodes') if nodes_data: for node in nodes_data.get('data', []): node_name = node['node'] snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/snapshot') if not snap_data: snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/snapshot') if snap_data and 'data' in snap_data: existing_snaps = [s for s in snap_data['data'] if s.get('name') != 'current'] if len(existing_snaps) >= 3: return jsonify({ 'status': 'error', 'message': 'Hai raggiunto il limite di 3 snapshot. Elimina una snapshot esistente.' }), 400 # Trova il nodo della VM e crea la snapshot nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'snapshot', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/snapshot', data={'snapname': snapname, 'description': description}) if result: log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} creata') return jsonify({'status': 'success', 'message': 'Snapshot creata con successo', 'snapname': snapname}) # Prova LXC result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/snapshot', data={'snapname': snapname, 'description': description}) if result: log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} creata') return jsonify({'status': 'success', 'message': 'Snapshot creata con successo', 'snapname': snapname}) log_user_action(vm_id, 'snapshot', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'snapshot', 'failed', str(e)) app.logger.error(f"Errore create_snapshot: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//snapshot/', methods=['DELETE']) @login_required def delete_snapshot(vm_id, snapname): """Elimina una snapshot""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('DELETE', f'/nodes/{node_name}/qemu/{vm_id}/snapshot/{snapname}') if result: log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} eliminata') return jsonify({'status': 'success', 'message': 'Snapshot eliminata con successo'}) # Prova LXC result, error = proxmox_api_call('DELETE', f'/nodes/{node_name}/lxc/{vm_id}/snapshot/{snapname}') if result: log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} eliminata') return jsonify({'status': 'success', 'message': 'Snapshot eliminata con successo'}) return jsonify({'status': 'error', 'message': 'Snapshot non trovata'}), 404 except Exception as e: app.logger.error(f"Errore delete_snapshot: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//shutdown', methods=['POST']) @login_required def shutdown_vm(vm_id): """Spegne una VM (graceful shutdown)""" try: # Verifica accesso if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Trova il nodo della VM nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'shutdown', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/shutdown') if result: log_user_action(vm_id, 'shutdown', 'success') send_notification(current_user.id, 'success', f'VM {vm_id} in fase di spegnimento') return jsonify({'status': 'success', 'message': 'VM spenta con successo'}) # Prova LXC result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/shutdown') if result: log_user_action(vm_id, 'shutdown', 'success') send_notification(current_user.id, 'success', f'Container {vm_id} in fase di spegnimento') return jsonify({'status': 'success', 'message': 'Container spento con successo'}) log_user_action(vm_id, 'shutdown', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'shutdown', 'failed', str(e)) app.logger.error(f"Errore shutdown_vm: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//stop', methods=['POST']) @login_required def stop_vm(vm_id): """Ferma forzatamente una VM (hard stop)""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'stop', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/stop') if result: log_user_action(vm_id, 'stop', 'success') return jsonify({'status': 'success', 'message': 'VM fermata con successo'}) # Prova LXC result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/stop') if result: log_user_action(vm_id, 'stop', 'success') return jsonify({'status': 'success', 'message': 'Container fermato con successo'}) log_user_action(vm_id, 'stop', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'stop', 'failed', str(e)) app.logger.error(f"Errore stop_vm: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//start', methods=['POST']) @login_required def start_vm(vm_id): """Avvia una VM""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'start', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/start') if result: log_user_action(vm_id, 'start', 'success') send_notification(current_user.id, 'success', f'VM {vm_id} avviata con successo') return jsonify({'status': 'success', 'message': 'VM avviata con successo'}) # Prova LXC result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/start') if result: log_user_action(vm_id, 'start', 'success') send_notification(current_user.id, 'success', f'Container {vm_id} avviato con successo') return jsonify({'status': 'success', 'message': 'Container avviato con successo'}) log_user_action(vm_id, 'start', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'start', 'failed', str(e)) app.logger.error(f"Errore start_vm: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//reboot', methods=['POST']) @login_required def reboot_vm(vm_id): """Riavvia una VM""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'restart', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/reboot') if result: log_user_action(vm_id, 'restart', 'success') send_notification(current_user.id, 'success', f'VM {vm_id} riavviata con successo') return jsonify({'status': 'success', 'message': 'VM riavviata con successo'}) # Prova LXC result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/reboot') if result: log_user_action(vm_id, 'restart', 'success') send_notification(current_user.id, 'success', f'Container {vm_id} riavviato con successo') return jsonify({'status': 'success', 'message': 'Container riavviato con successo'}) log_user_action(vm_id, 'restart', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'restart', 'failed', str(e)) app.logger.error(f"Errore reboot_vm: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== API ROUTES - BACKUP MANAGEMENT ==================== @app.route('/api/vm//backups') @login_required def get_vm_backups(vm_id): """Recupera i backup di una VM specifica (max 3 per utenti normali)""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 vm_backups = [] for node in nodes_data.get('data', []): node_name = node['node'] app.logger.info(f"[BACKUP DEBUG] Checking node: {node_name}") storage_data, storage_error = proxmox_api_call('GET', f'/nodes/{node_name}/storage') if not storage_data: app.logger.warning(f"[BACKUP DEBUG] No storage data for node {node_name}, error: {storage_error}") continue for storage in storage_data.get('data', []): storage_name = storage.get('storage') storage_content = storage.get('content', '') app.logger.info(f"[BACKUP DEBUG] Storage: {storage_name}, content: {storage_content}") if not storage_name or 'backup' not in storage_content: app.logger.info(f"[BACKUP DEBUG] Skipping storage {storage_name} - no backup content") continue content_data, content_error = proxmox_api_call('GET', f'/nodes/{node_name}/storage/{storage_name}/content', params={'content': 'backup'}) if not content_data: app.logger.warning(f"[BACKUP DEBUG] No content for storage {storage_name}, error: {content_error}") continue backups = content_data.get('data', []) app.logger.info(f"[BACKUP DEBUG] Storage {storage_name} has {len(backups)} backups") for backup in backups: volid = backup.get('volid', '') app.logger.info(f"[BACKUP DEBUG] Checking backup volid: {volid} for VM {vm_id}") if f"-{vm_id}-" in volid: backup['node'] = node_name backup['storage'] = storage_name vm_backups.append(backup) app.logger.info(f"[BACKUP DEBUG] MATCH! Added backup for VM {vm_id}") # Ordina per data (più recente prima) vm_backups.sort(key=lambda x: x.get('ctime', 0), reverse=True) return jsonify({ 'status': 'success', 'vmid': vm_id, 'count': len(vm_backups), 'data': vm_backups }) except Exception as e: app.logger.error(f"Errore get_vm_backups: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//backup', methods=['POST']) @login_required def create_backup(vm_id): """Crea un backup di una VM""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Controlla se l'utente ha già 3 backup (se non admin) if not current_user.is_admin: nodes_data, _ = proxmox_api_call('GET', '/nodes') if nodes_data: backup_count = 0 for node in nodes_data.get('data', []): node_name = node['node'] storage_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/storage') if storage_data: for storage in storage_data.get('data', []): storage_name = storage.get('storage') if storage_name and 'backup' in storage.get('content', ''): content_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/storage/{storage_name}/content', params={'content': 'backup'}) if content_data: for backup in content_data.get('data', []): if f"-{vm_id}-" in backup.get('volid', ''): backup_count += 1 if backup_count >= Config.MAX_BACKUPS_PER_USER: return jsonify({ 'status': 'error', 'message': f'Hai raggiunto il limite di {Config.MAX_BACKUPS_PER_USER} backup. Elimina un backup esistente prima di crearne uno nuovo.' }), 400 # Trova il nodo della VM e crea il backup nodes_data, error = proxmox_api_call('GET', '/nodes') if error: log_user_action(vm_id, 'backup', 'failed', error) return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Verifica che la VM esista qemu_exists, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') lxc_exists, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') if qemu_exists or lxc_exists: # Usa modalità snapshot: Proxmox crea snapshot temporanea, fa backup, e la rimuove automaticamente backup_data = { 'vmid': vm_id, 'mode': 'snapshot', # Crea snapshot temporanea automaticamente 'compress': 'zstd', 'storage': 'hetzner-backup01', 'remove': 1 # IMPORTANTE: Rimuove la snapshot temporanea dopo il backup } result, error = proxmox_api_call('POST', f'/nodes/{node_name}/vzdump', data=backup_data) if result: log_user_action(vm_id, 'backup', 'success') return jsonify({ 'status': 'success', 'message': 'Backup avviato! La VM rimarrà in esecuzione. La snapshot temporanea verrà rimossa automaticamente.', 'data': result }) else: log_user_action(vm_id, 'backup', 'failed', error) return jsonify({'status': 'error', 'message': error or 'Errore durante creazione backup'}), 500 log_user_action(vm_id, 'backup', 'failed', 'VM non trovata') return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: log_user_action(vm_id, 'backup', 'failed', str(e)) app.logger.error(f"Errore create_backup: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/backup////delete', methods=['DELETE']) @login_required def delete_backup(node, storage, volid): """Elimina un backup""" try: # Estrai VM ID dal volid per verificare permessi # Format: storage:backup/vzdump-qemu-104-2025_11_24-14_30_00.vma.zst vm_id_match = volid.split('-') if len(vm_id_match) >= 3: try: vm_id = int(vm_id_match[2]) except (ValueError, IndexError): return jsonify({'status': 'error', 'message': 'VM ID non valido nel backup'}), 400 else: return jsonify({'status': 'error', 'message': 'Formato backup non valido'}), 400 # Verifica accesso if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Elimina il backup full_volid = f"{storage}:backup/{volid}" if not volid.startswith(storage) else volid result, error = proxmox_api_call('DELETE', f'/nodes/{node}/storage/{storage}/content/{full_volid}') if result: log_user_action(vm_id, 'backup', 'success', error_message=f'Backup {volid} eliminato') return jsonify({'status': 'success', 'message': 'Backup eliminato con successo'}) else: log_user_action(vm_id, 'backup', 'failed', error) return jsonify({'status': 'error', 'message': error or 'Errore durante eliminazione'}), 500 except Exception as e: app.logger.error(f"Errore delete_backup: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/vm//console') @login_required def get_vm_console(vm_id): """Ottieni URL console VNC/SPICE per la VM""" try: if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU - usa vncproxy result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/vncproxy', data={'websocket': 1}) if result and 'data' in result: console_data = result['data'] ticket = console_data.get('ticket', '') port = console_data.get('port', '') upid = console_data.get('upid', '') # Costruisci URL per noVNC - usa URL pubblico via reverse proxy console_url = ( f"{Config.PROXMOX_PUBLIC_URL}/" f"?console=kvm&novnc=1&vmid={vm_id}&vmname=VM-{vm_id}" f"&node={node_name}&resize=scale&cmd=" ) return jsonify({ 'status': 'success', 'console_url': console_url, 'ticket': ticket, 'type': 'vnc', 'data': console_data }) # Prova LXC - usa termproxy result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/termproxy') if result and 'data' in result: console_data = result['data'] ticket = console_data.get('ticket', '') port = console_data.get('port', '') # Costruisci URL per xterm.js - usa URL pubblico via reverse proxy console_url = ( f"{Config.PROXMOX_PUBLIC_URL}/" f"?console=lxc&xtermjs=1&vmid={vm_id}&vmname=CT-{vm_id}" f"&node={node_name}&resize=scale&cmd=" ) return jsonify({ 'status': 'success', 'console_url': console_url, 'ticket': ticket, 'type': 'shell', 'data': console_data }) return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 except Exception as e: app.logger.error(f"Errore get_vm_console: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== SUBDOMAIN MANAGEMENT (CLOUDFLARE) ==================== @app.route('/api/subdomains') @login_required def get_user_subdomains(): """Recupera i sottodomini dell'utente loggato""" try: subdomains = Subdomain.get_user_subdomains(current_user.id) return jsonify({'status': 'success', 'data': subdomains}) except Exception as e: app.logger.error(f"Errore get_user_subdomains: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/subdomain/create', methods=['POST']) @login_required def create_subdomain(): """Crea un nuovo sottodominio su Cloudflare""" try: data = request.get_json() subdomain = data.get('subdomain', '').strip().lower() ip_address = data.get('ip_address', '').strip() vm_id = data.get('vm_id') proxied = data.get('proxied', True) if not subdomain or not ip_address: return jsonify({'status': 'error', 'message': 'Sottodominio e IP richiesti'}), 400 # Validazione sottodominio (solo caratteri alfanumerici e trattini) import re if not re.match(r'^[a-z0-9-]+$', subdomain): return jsonify({'status': 'error', 'message': 'Sottodominio non valido. Usa solo lettere, numeri e trattini.'}), 400 # Verifica se esiste già if Subdomain.subdomain_exists(subdomain): return jsonify({'status': 'error', 'message': 'Questo sottodominio esiste già'}), 400 # Chiama API Cloudflare if not Config.CLOUDFLARE_API_TOKEN or not Config.CLOUDFLARE_ZONE_ID: return jsonify({'status': 'error', 'message': 'Cloudflare non configurato'}), 500 import cloudflare cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) full_domain = f'{subdomain}.{Config.CLOUDFLARE_DOMAIN}' # Crea record DNS su Cloudflare record = cf.dns.records.create( zone_id=Config.CLOUDFLARE_ZONE_ID, type='A', name=full_domain, content=ip_address, ttl=1, proxied=proxied ) # Salva nel database subdomain_id = Subdomain.create( user_id=current_user.id, subdomain=subdomain, ip_address=ip_address, vm_id=vm_id, proxied=proxied, cloudflare_record_id=record.id ) log_user_action(vm_id or 0, 'subdomain_create', 'success', error_message=f'Sottodominio {full_domain} creato') return jsonify({ 'status': 'success', 'message': f'Sottodominio {full_domain} creato con successo!', 'subdomain_id': subdomain_id, 'full_domain': full_domain }) except cloudflare.APIError as e: app.logger.error(f"Errore Cloudflare: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': f'Errore Cloudflare: {str(e)}'}), 500 except Exception as e: app.logger.error(f"Errore create_subdomain: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/subdomain//delete', methods=['DELETE']) @login_required def delete_subdomain(subdomain_id): """Elimina un sottodominio""" try: # Recupera il sottodominio subdomain_data = Subdomain.get_by_id(subdomain_id) if not subdomain_data: return jsonify({'status': 'error', 'message': 'Sottodominio non trovato'}), 404 # Verifica ownership if subdomain_data['user_id'] != current_user.id and not current_user.is_admin: return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Elimina da Cloudflare if subdomain_data['cloudflare_record_id'] and Config.CLOUDFLARE_API_TOKEN: try: import cloudflare cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) cf.dns.records.delete( zone_id=Config.CLOUDFLARE_ZONE_ID, dns_record_id=subdomain_data['cloudflare_record_id'] ) except cloudflare.APIError as e: app.logger.warning(f"Errore eliminazione Cloudflare: {e}") # Elimina dal database if Subdomain.delete(subdomain_id, current_user.id if not current_user.is_admin else subdomain_data['user_id']): log_user_action( subdomain_data.get('vm_id', 0) or 0, 'subdomain_delete', 'success', error_message=f'Sottodominio {subdomain_data["subdomain"]} eliminato' ) return jsonify({'status': 'success', 'message': 'Sottodominio eliminato con successo'}) else: return jsonify({'status': 'error', 'message': 'Errore durante eliminazione'}), 500 except Exception as e: app.logger.error(f"Errore delete_subdomain: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/subdomain//update-ip', methods=['PUT']) @login_required def update_subdomain_ip(subdomain_id): """Aggiorna l'IP di un sottodominio""" try: data = request.get_json() new_ip = data.get('ip_address', '').strip() if not new_ip: return jsonify({'status': 'error', 'message': 'IP richiesto'}), 400 subdomain_data = Subdomain.get_by_id(subdomain_id) if not subdomain_data: return jsonify({'status': 'error', 'message': 'Sottodominio non trovato'}), 404 if subdomain_data['user_id'] != current_user.id and not current_user.is_admin: return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 # Aggiorna su Cloudflare if subdomain_data['cloudflare_record_id'] and Config.CLOUDFLARE_API_TOKEN: try: import cloudflare cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) full_domain = f"{subdomain_data['subdomain']}.{Config.CLOUDFLARE_DOMAIN}" cf.dns.records.update( zone_id=Config.CLOUDFLARE_ZONE_ID, dns_record_id=subdomain_data['cloudflare_record_id'], type='A', name=full_domain, content=new_ip, ttl=1, proxied=subdomain_data['proxied'] ) except cloudflare.APIError as e: return jsonify({'status': 'error', 'message': f'Errore Cloudflare: {str(e)}'}), 500 # Aggiorna nel database if Subdomain.update_ip(subdomain_id, current_user.id if not current_user.is_admin else subdomain_data['user_id'], new_ip): return jsonify({'status': 'success', 'message': 'IP aggiornato con successo'}) else: return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 except Exception as e: app.logger.error(f"Errore update_subdomain_ip: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== ADMIN ROUTES ==================== @app.route('/admin/users') @login_required @admin_required def admin_users(): """Pannello admin - gestione utenti""" return render_template('admin_users.html', user=current_user) @app.route('/api/admin/users') @login_required @admin_required def api_admin_get_users(): """API admin - lista utenti""" try: users = User.get_all_users() return jsonify({'status': 'success', 'data': users}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/user//vms') @login_required @admin_required def api_admin_get_user_vms(user_id): """API admin - VM di un utente""" try: vms = UserVM.get_user_vms(user_id) return jsonify({'status': 'success', 'data': vms}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/user//assign-vm', methods=['POST']) @login_required @admin_required def api_admin_assign_vm(user_id): """API admin - assegna VM a utente""" try: data = request.get_json() vm_id = data.get('vm_id') vm_name = data.get('vm_name') notes = data.get('notes') if not vm_id: return jsonify({'status': 'error', 'message': 'VM ID richiesto'}), 400 UserVM.assign_vm(user_id, vm_id, vm_name, notes) return jsonify({'status': 'success', 'message': 'VM assegnata con successo'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/user//remove-vm/', methods=['DELETE']) @login_required @admin_required def api_admin_remove_vm(user_id, vm_id): """API admin - rimuove VM da utente""" try: UserVM.remove_vm(user_id, vm_id) return jsonify({'status': 'success', 'message': 'VM rimossa con successo'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/vm//update-ip', methods=['PUT']) @login_required @admin_required def api_admin_update_vm_ip(vm_id): """API admin - aggiorna IP di una VM""" try: data = request.get_json() ip_address = data.get('ip_address', '').strip() conn = get_db_connection() try: with conn.cursor() as cursor: sql = "UPDATE user_vms SET ip_address = %s WHERE vm_id = %s" cursor.execute(sql, (ip_address if ip_address else None, vm_id)) return jsonify({'status': 'success', 'message': 'IP aggiornato con successo'}) finally: conn.close() except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/user/create', methods=['POST']) @login_required @admin_required def api_admin_create_user(): """API admin - crea nuovo utente""" try: data = request.get_json() username = data.get('username') email = data.get('email') password = data.get('password') is_admin = data.get('is_admin', False) if not username or not email or not password: return jsonify({'status': 'error', 'message': 'Username, email e password richiesti'}), 400 user_id = User.create(username, email, password, is_admin) return jsonify({'status': 'success', 'message': 'Utente creato con successo', 'user_id': user_id}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/user//toggle-status', methods=['POST']) @login_required @admin_required def api_admin_toggle_user_status(user_id): """API admin - attiva/disattiva utente""" try: data = request.get_json() active = data.get('active', True) # Non permettere di disattivare se stesso if user_id == current_user.id: return jsonify({'status': 'error', 'message': 'Non puoi disattivare il tuo stesso account'}), 400 if User.update_active_status(user_id, active): action = 'attivato' if active else 'disattivato' return jsonify({'status': 'success', 'message': f'Utente {action} con successo'}) else: return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/logs') @login_required @admin_required def api_admin_get_logs(): """API admin - recupera tutti i log""" try: limit = request.args.get('limit', 100, type=int) logs = ActionLog.get_all_logs(limit) return jsonify({'status': 'success', 'data': logs}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/stats') @login_required @admin_required def api_admin_get_stats(): """API admin - statistiche sistema""" try: # Conta utenti users = User.get_all_users() total_users = len(users) # Conta VM totali e attive all_vms = UserVM.get_all_assignments() total_vms = len(set(vm['vm_id'] for vm in all_vms)) # VM uniche # Conta VM attive controllando Proxmox active_vms = 0 nodes_data, _ = proxmox_api_call('GET', '/cluster/resources') if nodes_data and 'data' in nodes_data: for resource in nodes_data['data']: if resource.get('type') in ['qemu', 'lxc'] and resource.get('status') == 'running': active_vms += 1 # Conta azioni oggi logs = ActionLog.get_all_logs(1000) today = datetime.now().date() actions_today = sum(1 for log in logs if datetime.fromisoformat(str(log['created_at'])).date() == today) return jsonify({ 'status': 'success', 'data': { 'total_users': total_users, 'total_vms': total_vms, 'active_vms': active_vms, 'actions_today': actions_today } }) except Exception as e: app.logger.error(f"Errore api_admin_get_stats: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== OVERVIEW & STATS ==================== @app.route('/overview') @login_required def overview(): """Dashboard overview con statistiche aggregate""" return render_template('overview.html', user=current_user) @app.route('/api/overview/stats') @login_required def get_overview_stats(): """Ottieni statistiche aggregate per tutte le VM dell'utente (o tutte se admin)""" try: # Se admin, mostra TUTTE le VM, altrimenti solo le VM dell'utente if current_user.is_admin: # Recupera tutte le VM dal cluster resources_data, _ = proxmox_api_call('GET', '/cluster/resources') if resources_data and 'data' in resources_data: all_resources = [r for r in resources_data['data'] if r.get('type') in ['qemu', 'lxc']] user_vms = [{'vm_id': r['vmid'], 'vm_name': r.get('name', f"VM-{r['vmid']}")} for r in all_resources] else: user_vms = [] else: user_vms = UserVM.get_user_vms(current_user.id) stats = { 'total_vms': len(user_vms), 'running_vms': 0, 'stopped_vms': 0, 'total_cpu_usage': 0, 'total_memory_used': 0, 'total_memory_max': 0, 'total_disk_used': 0, 'total_disk_max': 0, 'vms': [] } nodes_data, _ = proxmox_api_call('GET', '/nodes') if not nodes_data or 'data' not in nodes_data: return jsonify({'status': 'error', 'message': 'Impossibile contattare Proxmox'}), 500 for vm in user_vms: vm_id = vm['vm_id'] vm_status = None for node in nodes_data.get('data', []): node_name = node['node'] # Prova QEMU qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') if qemu_data and 'data' in qemu_data: vm_status = qemu_data['data'] vm_status['node'] = node_name break # Prova LXC lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') if lxc_data and 'data' in lxc_data: vm_status = lxc_data['data'] vm_status['node'] = node_name break if vm_status: if vm_status.get('status') == 'running': stats['running_vms'] += 1 stats['total_cpu_usage'] += vm_status.get('cpu', 0) stats['total_memory_used'] += vm_status.get('mem', 0) else: stats['stopped_vms'] += 1 stats['total_memory_max'] += vm_status.get('maxmem', 0) stats['total_disk_max'] += vm_status.get('maxdisk', 0) mem_percent = 0 if vm_status.get('maxmem', 0) > 0: mem_percent = round((vm_status.get('mem', 0) / vm_status.get('maxmem', 1)) * 100, 1) stats['vms'].append({ 'vm_id': vm_id, 'name': vm['vm_name'] or vm_status.get('name', f'VM-{vm_id}'), 'status': vm_status.get('status', 'unknown'), 'cpu': vm_status.get('cpu', 0), 'memory_percent': mem_percent }) # Calcola medie if stats['running_vms'] > 0: stats['avg_cpu_usage'] = round((stats['total_cpu_usage'] / stats['running_vms']) * 100, 1) else: stats['avg_cpu_usage'] = 0 if stats['total_memory_max'] > 0: stats['memory_percent'] = round((stats['total_memory_used'] / stats['total_memory_max']) * 100, 1) else: stats['memory_percent'] = 0 return jsonify({'status': 'success', 'data': stats}) except Exception as e: app.logger.error(f"Errore get_overview_stats: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== NOTIFICATIONS (SSE) ==================== def get_user_queue(user_id): """Ottieni o crea la coda di notifiche per un utente""" if user_id not in notification_queues: notification_queues[user_id] = queue.Queue(maxsize=100) return notification_queues[user_id] def send_notification(user_id, notification_type, message, data=None): """Invia una notifica a un utente specifico""" try: q = get_user_queue(user_id) notification = { 'type': 'notification', 'notification_type': notification_type, 'message': message, 'data': data or {}, 'timestamp': time.time() } # Non bloccare se la coda è piena try: q.put_nowait(notification) except queue.Full: pass except Exception as e: app.logger.error(f"Errore send_notification: {e}") @app.route('/api/notifications/stream') @login_required def notification_stream(): """Endpoint SSE per notifiche real-time""" def generate(): q = get_user_queue(current_user.id) while True: try: # Attendi notifica con timeout notification = q.get(timeout=30) yield f"data: {json.dumps(notification)}\n\n" except queue.Empty: # Invia ping keepalive yield f"data: {json.dumps({'type': 'ping'})}\n\n" return Response( generate(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', 'Connection': 'keep-alive' } ) # ==================== NUOVE FUNZIONALITÀ ==================== # Log di Sistema @app.route('/admin/system-logs') @login_required @admin_required def system_logs(): """Pagina log di sistema completa""" return render_template('system_logs.html', user=current_user) # Topologia Cluster @app.route('/admin/cluster-topology') @login_required @admin_required def cluster_topology(): """Pagina topologia cluster""" return render_template('cluster_topology.html', user=current_user) @app.route('/api/admin/cluster/topology') @login_required @admin_required def api_cluster_topology(): """API per ottenere la topologia del cluster""" try: # Recupera informazioni sui nodi nodes_data, error = proxmox_api_call('GET', '/nodes') if error: return jsonify({'status': 'error', 'message': error}), 500 # Recupera tutte le risorse del cluster resources_data, error = proxmox_api_call('GET', '/cluster/resources') if error: return jsonify({'status': 'error', 'message': error}), 500 topology = { 'nodes': nodes_data.get('data', []), 'resources': resources_data.get('data', []) } return jsonify({'status': 'success', 'data': topology}) except Exception as e: app.logger.error(f"Errore api_cluster_topology: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 # IPAM (IP Address Management) @app.route('/admin/ipam') @login_required @admin_required def ipam(): """Pagina gestione IPAM""" return render_template('ipam.html', user=current_user) @app.route('/api/admin/ipam') @login_required @admin_required def api_ipam(): """API per ottenere tutti gli IP delle VM/LXC""" try: ipam_data = [] # Recupera assegnazioni manuali dal database manual_assignments = {item['vm_id']: item for item in IPAssignment.get_all()} # Recupera tutte le risorse resources_data, error = proxmox_api_call('GET', '/cluster/resources') if error: return jsonify({'status': 'error', 'message': error}), 500 # Filtra solo VM e LXC resources = [r for r in resources_data.get('data', []) if r.get('type') in ['qemu', 'lxc']] for resource in resources: vmid = resource.get('vmid') vm_type = resource.get('type') node = resource.get('node') # Controlla se c'è un'assegnazione manuale manual = manual_assignments.get(vmid) if manual: # Usa l'IP assegnato manualmente ip_address = manual['ip_address'] mac_address = manual['mac_address'] notes = manual['notes'] else: # Recupera IP automaticamente notes = None if vm_type == 'qemu': config_data, _ = proxmox_api_call('GET', f'/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces') ip_address = 'N/A' mac_address = None if config_data and 'data' in config_data: result = config_data['data'].get('result', []) for interface in result: if interface.get('name') not in ['lo', 'sit0']: ip_addresses = interface.get('ip-addresses', []) for ip_info in ip_addresses: if ip_info.get('ip-address-type') == 'ipv4': ip_address = ip_info.get('ip-address', 'N/A') break mac_address = interface.get('hardware-address') if ip_address != 'N/A': break else: # LXC config_data, _ = proxmox_api_call('GET', f'/nodes/{node}/lxc/{vmid}/config') ip_address = 'N/A' mac_address = None if config_data and 'data' in config_data: # Cerca net0, net1, etc. for key in config_data['data']: if key.startswith('net'): net_config = config_data['data'][key] # Formato: name=eth0,bridge=vmbr0,hwaddr=XX:XX:XX:XX:XX:XX,ip=192.168.1.100/24 if 'ip=' in net_config: ip_part = net_config.split('ip=')[1].split(',')[0] ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part if 'hwaddr=' in net_config: mac_address = net_config.split('hwaddr=')[1].split(',')[0] break # Controlla conflitti IP conflict = False if ip_address != 'N/A': conflict = sum(1 for item in ipam_data if item['ip'] == ip_address) > 0 ipam_data.append({ 'vmid': vmid, 'name': resource.get('name', f'{vm_type}-{vmid}'), 'type': vm_type, 'ip': ip_address, 'mac': mac_address, 'node': node, 'status': resource.get('status', 'unknown'), 'conflict': conflict, 'notes': notes, 'manual_assignment': manual is not None }) return jsonify({'status': 'success', 'data': ipam_data}) except Exception as e: app.logger.error(f"Errore api_ipam: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/ipam/scan', methods=['POST']) @login_required @admin_required def api_ipam_scan(): """Scansiona la rete per aggiornare gli IP""" try: # In una implementazione reale, qui si eseguirebbe una scansione di rete # Per ora ricarica semplicemente i dati return jsonify({'status': 'success', 'message': 'Scansione completata'}) except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/ipam/assign', methods=['POST']) @login_required @admin_required def api_ipam_assign(): """Assegna manualmente un IP a una VM""" try: data = request.get_json() vmid = data.get('vmid') ip = data.get('ip') notes = data.get('notes', '') if not vmid or not ip: return jsonify({'status': 'error', 'message': 'VM ID e IP sono obbligatori'}), 400 # Valida formato IP import re ip_pattern = re.compile(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$') if not ip_pattern.match(ip): return jsonify({'status': 'error', 'message': 'Formato IP non valido'}), 400 # Controlla conflitti IP conflicting_vm = IPAssignment.check_ip_conflict(ip, exclude_vm_id=vmid) if conflicting_vm: return jsonify({ 'status': 'error', 'message': f'IP già assegnato alla VM {conflicting_vm}' }), 409 # Salva assegnazione nel database IPAssignment.create( vm_id=vmid, ip_address=ip, assigned_by=current_user.id, notes=notes ) # Log azione ActionLog.log_action( user_id=current_user.id, vm_id=vmid, action_type='ip_assign', status='success', ip_address=request.remote_addr ) return jsonify({ 'status': 'success', 'message': f'IP {ip} assegnato a VM {vmid}', 'data': {'vmid': vmid, 'ip': ip, 'notes': notes} }) except Exception as e: app.logger.error(f"Errore api_ipam_assign: {traceback.format_exc()}") return jsonify({'status': 'error', 'message': str(e)}), 500 @app.route('/api/admin/ipam/ping', methods=['POST']) @login_required @admin_required def api_ipam_ping(): """Esegue un ping verso un IP""" try: data = request.get_json() ip = data.get('ip') if not ip: return jsonify({'status': 'error', 'message': 'IP richiesto'}), 400 # Esegue ping (Windows usa -n, Linux usa -c) import platform param = '-n' if platform.system().lower() == 'windows' else '-c' command = f'ping {param} 1 {ip}' import subprocess result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=5) if result.returncode == 0: return jsonify({'status': 'success', 'message': f'{ip} raggiungibile'}) else: return jsonify({'status': 'error', 'message': f'{ip} non raggiungibile'}), 400 except subprocess.TimeoutExpired: return jsonify({'status': 'error', 'message': 'Timeout ping'}), 408 except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 # ==================== ERROR HANDLERS ==================== @app.errorhandler(404) def not_found(e): return render_template('404.html'), 404 @app.errorhandler(500) def internal_error(e): app.logger.error(f"Errore 500: {traceback.format_exc()}") return render_template('500.html'), 500 if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000, threaded=True)