Files
virtuality/web/app.py
T

380 строки
17 KiB
Python

#!/usr/bin/env python3
import crypt
import os
import re
import secrets
import shutil
import spwd
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeSerializer
BASE_DIR = Path(__file__).resolve().parent
APP_NAME = "Virtuality"
ENV_FILE = BASE_DIR / ".env"
ISO_DIR = Path("/var/lib/virtuality/iso")
IMAGES_DIR = Path("/var/lib/virtuality/images")
DEFAULT_BRIDGE = "br0"
app = FastAPI(title="Virtuality Panel")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
static_dir = BASE_DIR / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
def load_env() -> dict[str, str]:
data: dict[str, str] = {}
if ENV_FILE.exists():
for raw_line in ENV_FILE.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
data[key.strip()] = value.strip().strip('"').strip("'")
return data
CONFIG = load_env()
AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "viktor"))
SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me"))
serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")
def is_configured() -> bool:
return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me")
def verify_linux_password(username: str, password: str) -> bool:
if not username or not password:
return False
if username != AUTH_USER:
return False
try:
shadow = spwd.getspnam(username)
except PermissionError:
return False
except KeyError:
return False
stored_hash = shadow.sp_pwdp
if stored_hash in ("!", "*", "!!", ""):
return False
return secrets.compare_digest(crypt.crypt(password, stored_hash), stored_hash)
def get_current_user(request: Request) -> str | None:
token = request.cookies.get("virtuality_session")
if not token:
return None
try:
data = serializer.loads(token)
except BadSignature:
return None
if data.get("user") == AUTH_USER:
return AUTH_USER
return None
def require_auth(request: Request):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
return None
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join(cmd)}
except Exception as exc:
return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join(cmd)}
def parse_virsh_list() -> list[dict[str, str]]:
result = run_cmd(["virsh", "list", "--all"])
if not result["ok"]:
return []
rows = []
for line in result["stdout"].splitlines()[2:]:
line = line.strip()
if not line:
continue
parts = line.split(None, 2)
if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2:
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
return rows
def parse_pool_list() -> list[dict[str, str]]:
result = run_cmd(["virsh", "pool-list", "--all"])
if not result["ok"]:
return []
rows = []
for line in result["stdout"].splitlines()[2:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 3:
rows.append({"name": parts[0], "state": parts[1], "autostart": parts[2]})
return rows
def list_iso_files() -> list[dict[str, str]]:
ISO_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(ISO_DIR.glob("*.iso")):
try:
stat = item.stat()
size_mb = stat.st_size / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_mb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated})
return files
def safe_iso_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith(".iso"):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
return None
return name
def iso_path_by_name(name: str) -> Path | None:
safe_name = safe_iso_filename(name)
if not safe_name:
return None
path = (ISO_DIR / safe_name).resolve()
iso_root = ISO_DIR.resolve()
if iso_root not in path.parents:
return None
return path
def refresh_iso_pool() -> None:
run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20)
def valid_vm_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
def vm_exists(name: str) -> bool:
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
def vm_details(name: str) -> dict[str, Any]:
return {
"name": name,
"dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",
"disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],
"interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],
"autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
}
def system_summary() -> dict[str, str]:
hostname = run_cmd(["hostname"])["stdout"]
uptime = run_cmd(["uptime", "-p"])["stdout"]
kernel = run_cmd(["uname", "-r"])["stdout"]
ip_addr = run_cmd(["hostname", "-I"])["stdout"].split()
ip_main = ip_addr[0] if ip_addr else "unknown"
load = Path("/proc/loadavg").read_text().split()[:3]
return {"hostname": hostname, "uptime": uptime, "kernel": kernel, "ip": ip_main, "load": " ".join(load), "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
def service_state(unit: str) -> str:
result = run_cmd(["systemctl", "is-active", unit])
return result["stdout"] or "inactive"
def network_summary() -> dict[str, str]:
return {"interfaces": run_cmd(["ip", "-br", "a"])["stdout"], "routes": run_cmd(["ip", "route"])["stdout"]}
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
if get_current_user(request):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
if not is_configured():
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
if not verify_linux_password(username, password):
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
token = serializer.dumps({"user": AUTH_USER})
response = RedirectResponse(url="/", status_code=303)
response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12)
return response
@app.post("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie("virtuality_session")
return response
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
vms = parse_virsh_list()
pools = parse_pool_list()
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": vms, "pools": pools, "network": network_summary(), "user": AUTH_USER})
@app.get("/iso", response_class=HTMLResponse)
def iso_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
@app.post("/iso/upload", response_class=HTMLResponse)
def iso_upload(request: Request, iso_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_iso_filename(iso_file.filename or "")
if not safe_name:
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
ISO_DIR.mkdir(parents=True, exist_ok=True)
target = ISO_DIR / safe_name
if target.exists():
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
tmp_target = ISO_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(iso_file.file, out)
tmp_target.rename(target)
refresh_iso_pool()
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)
return RedirectResponse(url="/iso", status_code=303)
@app.post("/iso/{name}/delete")
def iso_delete(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
path = iso_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
refresh_iso_pool()
return RedirectResponse(url="/iso", status_code=303)
@app.post("/iso/refresh")
def iso_refresh(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
refresh_iso_pool()
return RedirectResponse(url="/iso", status_code=303)
# Важно: статический маршрут /vm/create должен быть выше динамического /vm/{name}.
# Иначе FastAPI воспринимает слово "create" как имя виртуальной машины.
@app.get("/vm/create", response_class=HTMLResponse)
def vm_create_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": None, "form": {"memory": 2048, "vcpus": 2, "disk_size": 20, "bridge": DEFAULT_BRIDGE}})
@app.post("/vm/create", response_class=HTMLResponse)
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), bridge: str = Form(DEFAULT_BRIDGE), autostart_install: str | None = Form(None)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "bridge": bridge}
error = None
if not valid_vm_name(name):
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
elif vm_exists(name):
error = f"VM с именем {name} уже существует."
elif memory < 512 or memory > 262144:
error = "RAM должна быть от 512 MB до 262144 MB."
elif vcpus < 1 or vcpus > 128:
error = "CPU должен быть от 1 до 128 vCPU."
elif disk_size < 4 or disk_size > 4096:
error = "Диск должен быть от 4 GB до 4096 GB."
elif not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge):
error = "Некорректное имя bridge."
else:
iso = Path(iso_path).resolve()
iso_root = ISO_DIR.resolve()
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
if error:
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error, "form": form}, status_code=400)
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Диск уже существует: {disk_path}", "form": form}, status_code=400)
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", f"bridge={bridge},model=virtio", "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
result = run_cmd(cmd, timeout=180)
if not result["ok"]:
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": result["stderr"] or result["stdout"] or "virt-install failed", "form": form}, status_code=500)
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
return RedirectResponse(url="/", status_code=303)
@app.get("/vm/{name}", response_class=HTMLResponse)
def vm_detail_page(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"]})
@app.post("/vm/{name}/{action}")
def vm_action(request: Request, name: str, action: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
allowed = {"start": ["virsh", "start", name], "shutdown": ["virsh", "shutdown", name], "reboot": ["virsh", "reboot", name], "destroy": ["virsh", "destroy", name], "autostart": ["virsh", "autostart", name], "autostart-disable": ["virsh", "autostart", "--disable", name]}
if action == "delete":
run_cmd(["virsh", "destroy", name], timeout=20)
run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60)
return RedirectResponse(url="/", status_code=303)
if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
run_cmd(allowed[action], timeout=30)
return RedirectResponse(url=f"/vm/{name}", status_code=303)
@app.get("/api/health")
def api_health(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"system": system_summary(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary()}