#!/usr/bin/env python3 """ ProxMenux Flask Server - Provides REST API endpoints for Proxmox monitoring (system, storage, network, VMs, etc.) - Serves the Next.js dashboard as static files - Integrates a web terminal powered by xterm.js """ # ─── gevent monkey-patch — MUST be the first executable code ───────────── # # When SSL is enabled we serve the dashboard with `gevent.pywsgi.WSGIServer`. # Without `monkey.patch_all()` gevent runs as a single-threaded cooperative # event loop: a request that calls `subprocess.run(pvesh ...)` blocks the # whole event loop, so every other request lined up in parallel returns 502 # until that subprocess finishes. The frontend's `/api/vms` page fires 3-4 # parallel requests on mount, which is exactly the symptom that surfaced as # "first load 502, second load fine" under HTTPS. # # `patch_all()` replaces stdlib blocking primitives (socket, subprocess, # select, threading, ssl, time.sleep, ...) with gevent-friendly equivalents # that yield to the event loop instead of blocking it. This must run BEFORE # any other import touches those primitives — otherwise the unpatched # versions get bound in the module and the patch is silently ineffective. # # Wrapped in a try/except so a host without gevent installed (HTTP-only # mode) still imports cleanly: the patch is only meaningful when gevent is # actually being used as the WSGI server. try: from gevent import monkey monkey.patch_all() except ImportError: pass import glob import hashlib import json import logging import math import os import platform import re import select import shlex import shutil import socket import sqlite3 import subprocess import sys import tempfile import time import threading import urllib.parse import uuid import hardware_monitor from health_persistence import health_persistence import xml.etree.ElementTree as ET from datetime import datetime, timedelta from functools import wraps from pathlib import Path import jwt import psutil from flask import Flask, jsonify, request, send_file, send_from_directory, Response, after_this_request from flask_cors import CORS # Ensure local imports work even if working directory changes BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if BASE_DIR not in sys.path: sys.path.insert(0, BASE_DIR) from smartctl_resolver import ( # noqa: E402 is_usb_disk as resolver_is_usb_disk, probe_smartctl_json, resolve_smartctl_probe, smartctl_command, smartctl_probe_types, smartctl_type_args, ) from temperature_sensor_resolver import get_storage_temperatures # noqa: E402 from zram_metrics import read_zram_metrics # noqa: E402 from flask_script_runner import script_runner import threading from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor from flask_terminal_routes import ( # noqa: E402 terminal_bp, init_terminal_routes, set_script_completion_hook, ) from flask_health_routes import health_bp # noqa: E402 from flask_auth_routes import auth_bp # noqa: E402 from flask_proxmenux_routes import proxmenux_bp # noqa: E402 from flask_pbs_routes import pbs_bp # noqa: E402 from pbs_manager import get_platform, is_pbs_host # noqa: E402 from flask_security_routes import security_bp # noqa: E402 from flask_notification_routes import notification_bp # noqa: E402 from flask_oci_routes import oci_bp # noqa: E402 from flask_audit_routes import audit_bp # noqa: E402 from flask_vm_backup_routes import vm_backup_bp # noqa: E402 from guest_backup_archives import (BackupArchiveError, delete_guest_backups, guest_backup_deletion_status) # noqa: E402 from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402 import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402 import auth_manager # noqa: E402 # ------------------------------------------------------------------- # Logging # ------------------------------------------------------------------- logger = logging.getLogger("proxmenux.flask") logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) # ------------------------------------------------------------------- # Proxmox node name cache # ------------------------------------------------------------------- _PROXMOX_NODE_CACHE = {"name": None, "timestamp": 0.0} _PROXMOX_NODE_CACHE_TTL = 300 # seconds (5 minutes) _PVE_LOCAL_NODE_LINK = "/etc/pve/local" def get_proxmox_node_name() -> str: """ The name of the LOCAL node in Proxmox. `/etc/pve/local` is a link to the folder of the local node, on a single host and on every node of a cluster, so the name is read from it without asking the API. The short hostname is used while `/etc/pve` is not mounted; that answer is not kept, so the link is read again next time. """ now = time.time() cached_name = _PROXMOX_NODE_CACHE.get("name") cached_ts = _PROXMOX_NODE_CACHE.get("timestamp", 0.0) # Cache hit if cached_name and (now - float(cached_ts)) < _PROXMOX_NODE_CACHE_TTL: return str(cached_name) try: node_name = os.path.basename(os.readlink(_PVE_LOCAL_NODE_LINK)) except OSError: node_name = "" if re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?", node_name): _PROXMOX_NODE_CACHE["name"] = node_name _PROXMOX_NODE_CACHE["timestamp"] = now return node_name # Fallback: short hostname (without domain) return socket.gethostname().split(".", 1)[0].lower() # ------------------------------------------------------------------- # Flask application and Blueprints # ------------------------------------------------------------------- app = Flask(__name__) # DoS / cost-amplification cap (audit Tier 3.1 — sin body-size cap en POSTs). # Without this an authenticated client could POST a 100 MB body to /api/notifications/test-ai # (or any other AI endpoint) and stall the dispatch thread plus rack up real # money on the configured AI provider. 1 MB is generous for any legitimate # request — settings payloads top out around 50 KB, AI prompts at 8 KB. app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # 1 MB # Restrict CORS to known origins (audit Tier 3 #18). The previous wide-open # `CORS(app)` enabled drive-by attacks: any malicious site visited by an # admin's browser could call the API directly. # # In production the AppImage serves both Flask AND the Next.js static export # from the same origin — CORS isn't actually needed for the dashboard itself # in that case. We keep the dev origin (`localhost:3000` for `npm run dev`) # always allowed, and let operators add their own deployment URL via the # PROXMENUX_CORS_ORIGINS env var (comma-separated). _cors_origins = [ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8008", "http://127.0.0.1:8008", "https://localhost:8008", "https://127.0.0.1:8008", ] _extra_origins = os.environ.get("PROXMENUX_CORS_ORIGINS", "").strip() if _extra_origins: _cors_origins.extend(o.strip() for o in _extra_origins.split(",") if o.strip()) CORS(app, origins=_cors_origins, supports_credentials=True) # Sprint 12A: scan for post-install function updates BEFORE the rest of # the update-check pipeline (Proxmox upgrade poll, ProxMenux self-update # check) kicks in. The scan parses the on-disk auto/customizable post- # install scripts and compares each declared `# version:` against the # version recorded in installed_tools.json — anything bumped is cached # in memory + written to updates_available.json so the bash menu and # the notification poller can read it without re-parsing. post_install_versions.scan_at_startup() # Register Blueprints app.register_blueprint(auth_bp) app.register_blueprint(health_bp) app.register_blueprint(proxmenux_bp) app.register_blueprint(pbs_bp) app.register_blueprint(security_bp) app.register_blueprint(notification_bp) app.register_blueprint(oci_bp) app.register_blueprint(audit_bp) app.register_blueprint(vm_backup_bp) # Initialize terminal / WebSocket routes init_terminal_routes(app) # ------------------------------------------------------------------- # Security headers — defense-in-depth (audit Tier 2 #16) # ------------------------------------------------------------------- # Defense-in-depth header policy. The XSS sinks in the React frontend are # closed (audit Tier 2 #13/#14/#17b — VM notes, Lynis report, terminal # interaction message). This header layer is the second line of defense. # # `script-src 'self' 'unsafe-inline' 'unsafe-eval'`: # - `'unsafe-inline'` is REQUIRED because Next.js static export injects # `