Files
proxmox_manager/app.py
2026-02-17 12:43:27 +01:00

1711 lines
70 KiB
Python

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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/snapshot/<snapname>', 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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<int:vm_id>/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/<node>/<storage>/<path:volid>/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/<int:vm_id>/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/<int:subdomain_id>/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/<int:subdomain_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/remove-vm/<int:vm_id>', 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/<int:vm_id>/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/<int:user_id>/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)