Files
virtuality/web/app.py
T
Claude 52e2033031 Бэкапы VM с ротацией и ночным расписанием были добавлены
Страница /backups: ручной бэкап любой VM (сжатый qcow2, работающая VM
ставится на паузу на время копирования), восстановление в выключенную
VM, удаление копий и настройка ночного расписания. Ротация хранит
заданное число копий. Установщик создаёт virtuality-backup.timer,
ночной запуск ведёт журнал /var/log/virtuality/backup.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
2026-08-15 19:26:58 +00:00

2488 строки
111 KiB
Python

#!/usr/bin/env python3
import asyncio
import json
import os
import re
import secrets
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
# PAM — основной способ проверки пароля; crypt/spwd удалены в Python 3.13
# и остаются только как fallback для старых систем без python-pam.
try:
import pam as pam_module
except ImportError:
pam_module = None
try:
import crypt
import spwd
except ImportError:
crypt = None
spwd = None
import lzma
import tarfile
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
import xml.etree.ElementTree as ET
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeSerializer
import backup_core
import cloud_images
import host_profile
import metrics
import network_core
import update_core
import virt_backend
from network_core import NetworkError
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")
DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")
OPERATIONS_DIR = Path("/var/log/virtuality/operations")
DEFAULT_BRIDGE = "br0"
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
app = FastAPI(title="Virtuality Panel")
def template_globals(request: Request) -> dict[str, Any]:
return {"csrf_token": get_csrf_token(request)}
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"), context_processors=[template_globals])
static_dir = BASE_DIR / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
if NOVNC_DIR:
app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")
OP_LOCK = threading.Lock()
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 line and not line.startswith("#") and "=" in line:
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")
console_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")
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 or username != AUTH_USER:
return False
if pam_module is not None:
try:
return bool(pam_module.pam().authenticate(username, password, service="login"))
except Exception:
return False
if crypt is None or spwd is None:
return False
try:
shadow = spwd.getspnam(username)
except (PermissionError, 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)
LOGIN_FAILURES: dict[str, dict[str, float]] = {}
LOGIN_FAILURES_LOCK = threading.Lock()
LOGIN_MAX_FAILURES = 5
LOGIN_BLOCK_SECONDS = 60
def login_block_remaining(client_ip: str) -> int:
with LOGIN_FAILURES_LOCK:
entry = LOGIN_FAILURES.get(client_ip)
if not entry or entry["count"] < LOGIN_MAX_FAILURES:
return 0
remaining = int(entry["last"] + LOGIN_BLOCK_SECONDS - time.time())
if remaining <= 0:
LOGIN_FAILURES.pop(client_ip, None)
return 0
return remaining
def register_login_failure(client_ip: str) -> None:
with LOGIN_FAILURES_LOCK:
entry = LOGIN_FAILURES.setdefault(client_ip, {"count": 0, "last": 0.0})
entry["count"] += 1
entry["last"] = time.time()
def reset_login_failures(client_ip: str) -> None:
with LOGIN_FAILURES_LOCK:
LOGIN_FAILURES.pop(client_ip, None)
def user_from_session_token(token: str | None) -> str | None:
if not token:
return None
try:
data = serializer.loads(token)
except BadSignature:
return None
return AUTH_USER if data.get("user") == AUTH_USER else None
def get_current_user(request: Request) -> str | None:
return user_from_session_token(request.cookies.get("virtuality_session"))
def require_auth(request: Request):
if not get_current_user(request):
return RedirectResponse(url="/login", status_code=303)
return None
def get_csrf_token(request: Request) -> str:
token = request.cookies.get("virtuality_session")
if not token:
return ""
try:
data = serializer.loads(token)
except BadSignature:
return ""
return str(data.get("csrf", ""))
def require_csrf(request: Request, csrf_token: str):
"""Проверка CSRF-токена для POST-форм. Возвращает 403-ответ или None."""
expected = get_csrf_token(request)
if expected and csrf_token and secrets.compare_digest(expected, csrf_token):
return None
return HTMLResponse("<h1>Сессия устарела</h1><p>Открой страницу заново и повтори действие.</p>", status_code=403)
# TTL-кеш для дорогих обзорных запросов: на слабом железе (Raspberry Pi)
# каждый рендер дашборда без кеша порождает десятки subprocess-вызовов.
_TTL_CACHE: dict[str, tuple[float, Any]] = {}
_TTL_CACHE_LOCK = threading.Lock()
def cached(key: str, ttl_seconds: float, producer):
now = time.monotonic()
with _TTL_CACHE_LOCK:
entry = _TTL_CACHE.get(key)
if entry and now - entry[0] < ttl_seconds:
return entry[1]
value = producer()
with _TTL_CACHE_LOCK:
_TTL_CACHE[key] = (time.monotonic(), value)
return value
def cache_invalidate(prefix: str = "") -> None:
with _TTL_CACHE_LOCK:
if not prefix:
_TTL_CACHE.clear()
return
for key in [k for k in _TTL_CACHE if k.startswith(prefix)]:
_TTL_CACHE.pop(key, 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 utc_now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def operation_meta_path(operation_id: str) -> Path:
return OPERATIONS_DIR / f"{operation_id}.json"
def operation_log_path(operation_id: str) -> Path:
return OPERATIONS_DIR / f"{operation_id}.log"
def ensure_operations_dir() -> None:
OPERATIONS_DIR.mkdir(parents=True, exist_ok=True)
def tail_text(path: Path, max_lines: int = 220) -> str:
if not path.exists():
return ""
try:
return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:])
except OSError:
return ""
def write_operation(operation: dict[str, Any]) -> None:
ensure_operations_dir()
path = operation_meta_path(operation["id"])
tmp_path = path.with_suffix(".json.tmp")
with OP_LOCK:
tmp_path.write_text(json.dumps(operation, ensure_ascii=False, indent=2))
tmp_path.replace(path)
def read_operation(operation_id: str) -> dict[str, Any] | None:
if not re.fullmatch(r"[a-f0-9-]{36}", operation_id or ""):
return None
path = operation_meta_path(operation_id)
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
data["log_tail"] = tail_text(operation_log_path(operation_id))
return data
def list_operations(limit: int = 25) -> list[dict[str, Any]]:
ensure_operations_dir()
operations: list[dict[str, Any]] = []
for path in sorted(OPERATIONS_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
try:
data = json.loads(path.read_text())
data["log_tail"] = tail_text(operation_log_path(data["id"]), max_lines=20)
operations.append(data)
except Exception:
continue
if len(operations) >= limit:
break
return operations
def append_operation_log(operation_id: str, message: str) -> None:
ensure_operations_dir()
with operation_log_path(operation_id).open("a", encoding="utf-8") as handle:
handle.write(f"[{utc_now()}] {message.rstrip()}\n")
def operation_css(status: str) -> str:
return "ok" if status == "success" else "err" if status == "error" else "warn"
def update_operation(operation: dict[str, Any], **changes: Any) -> None:
operation.update(changes)
operation["updated_at"] = utc_now()
write_operation(operation)
def progress_from_line(current: int, line: str) -> int:
low = line.lower()
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, start_message: str = "virt-install запущен", success_message: str = "VM создана успешно", refresh_pool: bool = True, on_success=None) -> None:
operation = read_operation(operation_id)
if not operation:
return
parse_progress = progress_fn or progress_from_line
update_operation(operation, status="running", progress=10, message=start_message, started_at=utc_now())
append_operation_log(operation_id, "Запуск команды:")
append_operation_log(operation_id, " ".join(cmd))
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
if process.stdout:
for line in process.stdout:
append_operation_log(operation_id, line)
fresh = read_operation(operation_id) or operation
new_progress = parse_progress(int(fresh.get("progress", 10)), line)
if new_progress != fresh.get("progress"):
update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message"))
exit_code = process.wait()
fresh = read_operation(operation_id) or operation
if exit_code == 0:
append_operation_log(operation_id, "Команда завершилась успешно.")
if refresh_pool:
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
cache_invalidate()
if on_success is not None:
try:
result_note = on_success()
if result_note:
append_operation_log(operation_id, str(result_note))
except Exception as exc:
append_operation_log(operation_id, f"Пост-обработка завершилась с ошибкой: {exc}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now())
else:
append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}")
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"Операция завершилась с ошибкой: {exit_code}", finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
def start_background_operation(operation: dict[str, Any], cmd: list[str], **worker_kwargs: Any) -> None:
write_operation(operation)
append_operation_log(operation["id"], "Операция поставлена в очередь.")
threading.Thread(target=run_operation_worker, args=(operation["id"], cmd), kwargs=worker_kwargs, daemon=True).start()
LOG_SOURCES = {
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Операции", "kind": "operations"},
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
}
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
key = source if source in LOG_SOURCES else "web"
cfg = LOG_SOURCES[key]
lines = max(20, min(int(lines or 220), 2000))
content = ""
path = ""
cmd = ""
if cfg["kind"] == "journal":
unit = cfg["unit"]
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
elif cfg["kind"] == "file":
path = cfg["path"]
cmd = f"tail -n {lines} {path}"
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
elif cfg["kind"] == "glob":
pattern = cfg["pattern"]
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
if files:
path = str(files[0])
cmd = f"tail -n {lines} {path}"
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
else:
cmd = f"ls {pattern}"
content = "Логи установки ещё не найдены"
elif cfg["kind"] == "operations":
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
parts = []
ensure_operations_dir()
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
content = "\n\n".join(parts) or "Журналы операций пока пусты"
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
def vm_autostart_status(name: str) -> dict[str, str | bool]:
result = run_cmd(["virsh", "dominfo", name], timeout=8)
output = result.get("stdout", "") or ""
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
raw = match.group(1).strip() if match else "unknown"
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
css = "ok" if enabled else "warn"
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
def parse_virsh_list_uncached() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
return ""
return ip
def manual_ip_map() -> dict[str, str]:
path = Path("/var/lib/virtuality/network/vm_ips.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
except Exception:
pass
return {}
def vm_macs(name: str) -> list[str]:
try:
result = run_cmd(["virsh", "domiflist", name], timeout=8)
if not result.get("ok"):
return []
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
except Exception:
return []
def ip_from_domifaddr(name: str) -> str:
try:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if result.get("ok"):
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/\d+", result.get("stdout", "")):
ip = clean_ip(ip)
if ip:
return ip
except Exception:
pass
return ""
def ip_from_network_core(name: str) -> str:
try:
resolved = network_core.resolve_vm_ip(name)
if resolved:
return clean_ip(str(resolved))
except Exception:
pass
return ""
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
if not macs:
return ""
try:
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
for line in lease_file.read_text(errors="ignore").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
parts = line.split()
if len(parts) >= 3:
ip = clean_ip(parts[2])
if ip:
return ip
except Exception:
pass
return ""
def ip_from_neighbor_tables(macs: list[str]) -> str:
if not macs:
return ""
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
for cmd in commands:
try:
result = run_cmd(cmd, timeout=8)
if not result.get("ok"):
continue
for line in result.get("stdout", "").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
for value in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b", line):
ip = clean_ip(value)
if ip:
return ip
except Exception:
pass
return ""
manual_ips = manual_ip_map()
def resolve_ip(name: str) -> str:
if not name:
return "—"
if manual_ips.get(name):
return manual_ips[name]
macs = vm_macs(name)
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
try:
ip = resolver()
if ip:
return ip
except Exception:
pass
return "—"
rows = []
backend_rows = virt_backend.list_domains()
if backend_rows is not None:
# Быстрый путь: одно libvirt-соединение вместо virsh + dominfo на каждую VM.
for dom in backend_rows:
enabled = dom["autostart_enabled"]
rows.append({"id": dom["id"], "name": dom["name"], "state": dom["state"], "autostart_enabled": enabled, "autostart_label": "enabled" if enabled else "disabled", "autostart_css": "ok" if enabled else "warn"})
else:
result = run_cmd(["virsh", "list", "--all"])
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
autostart = vm_autostart_status(parts[1])
rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
elif len(parts) == 2:
autostart = vm_autostart_status(parts[0])
rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows
def parse_virsh_list() -> list[dict[str, str]]:
return cached("vm_list", 5, parse_virsh_list_uncached)
def parse_pool_list_uncached() -> list[dict[str, str]]:
result = run_cmd(["virsh", "pool-list", "--all"])
rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split()
if len(parts) >= 3:
rows.append({"name": parts[0], "state": parts[1], "autostart": parts[2]})
return rows
def parse_pool_list() -> list[dict[str, str]]:
return cached("pool_list", 15, parse_pool_list_uncached)
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_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
original = Path(filename or "").name.strip()
suffix = Path(original).suffix.lower()
if suffix not in allowed_suffixes:
return None
stem = Path(original).stem.strip()
stem = re.sub(r"\s+", "-", stem)
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")
if not stem:
stem = fallback_prefix
stem = stem[:120]
name = f"{stem}{suffix}"
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
name = f"{fallback_prefix}{suffix}"
return name
def safe_iso_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
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()
if ISO_DIR.resolve() not in path.parents:
return None
return path
def refresh_iso_pool() -> None:
run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20)
def vm_arch_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по профилю хоста"},
{"value": "x86_64", "label": "x86_64 / amd64"},
{"value": "aarch64", "label": "ARM64 / aarch64"},
{"value": "generic", "label": "Generic / no arch override"},
]
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
value = (value or "auto").strip()
if value == "auto":
return str(profile.get("recommended_guest_arch") or "x86_64")
if value in ("x86_64", "amd64"):
return "x86_64"
if value in ("aarch64", "arm64"):
return "aarch64"
if value == "generic":
return "generic"
return str(profile.get("recommended_guest_arch") or "x86_64")
def list_disk_image_files() -> list[dict[str, str]]:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))):
try:
stat = item.stat()
size_gb = stat.st_size / 1024 / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_gb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
return files
def safe_disk_image_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((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
def disk_image_path_by_name(name: str) -> Path | None:
safe_name = safe_disk_image_filename(name)
if not safe_name:
return None
path = (DISK_IMAGES_DIR / safe_name).resolve()
if DISK_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def disk_image_format(path: Path) -> str:
suffix = path.suffix.lower().lstrip('.')
if suffix == 'qcow2':
return 'qcow2'
return 'raw'
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
def safe_disk_upload_filename(filename: str) -> str | None:
original = Path(filename or "").name.strip()
lower = original.lower()
if lower.endswith(".tar.gz"):
suffix = ".tar.gz"
stem = original[:-7]
elif lower.endswith(".img.xz"):
suffix = ".img.xz"
stem = original[:-7]
elif lower.endswith(".tgz"):
suffix = ".tgz"
stem = original[:-4]
else:
suffix = Path(original).suffix.lower()
stem = Path(original).stem
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
return None
stem = re.sub(r"\s+", "-", stem.strip())
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")[:120]
if not stem:
stem = "virtuality-disk"
return f"{stem}{suffix}"
def disk_upload_is_archive(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".zip", ".tar.gz", ".tgz"))
def disk_upload_is_xz_image(name: str) -> bool:
return str(name or "").lower().endswith(".img.xz")
def disk_upload_is_image(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
def archive_member_is_safe(name: str) -> bool:
if not name:
return False
p = Path(name)
if p.is_absolute():
return False
return ".." not in p.parts
def archive_member_basename(name: str) -> str | None:
return safe_disk_image_filename(Path(name).name)
def unique_disk_image_path(name: str) -> Path:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
safe = safe_disk_image_filename(name)
if not safe:
raise ValueError("Некорректное имя образа диска")
target = DISK_IMAGES_DIR / safe
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
for index in range(1, 1000):
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать свободное имя файла")
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
members: list[dict[str, Any]] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir() or not archive_member_is_safe(info.filename):
continue
safe_name = archive_member_basename(info.filename)
if safe_name:
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
elif lower.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path, "r:gz") as archive:
for info in archive.getmembers():
if not info.isfile() or not archive_member_is_safe(info.name):
continue
safe_name = archive_member_basename(info.name)
if safe_name:
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
else:
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
def extract_disk_archive(archive_path: Path) -> list[Path]:
members = find_archive_disk_members(archive_path)
if not members:
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
extracted: list[Path] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for member in members:
target = unique_disk_image_path(member["safe_name"])
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
else:
with tarfile.open(archive_path, "r:gz") as archive:
for member in members:
file_obj = archive.extractfile(member["name"])
if file_obj is None:
continue
target = unique_disk_image_path(member["safe_name"])
with file_obj as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
return extracted
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
source_name = safe_name or compressed_path.name
if not source_name.lower().endswith(".img.xz"):
raise ValueError("Поддерживаются только сжатые образы .img.xz")
raw_name = source_name[:-3]
target = unique_disk_image_path(raw_name)
try:
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
except Exception:
target.unlink(missing_ok=True)
raise
return target
def disk_convert_target_path(source_path: Path) -> Path:
if source_path.suffix.lower() == ".qcow2":
return source_path
target = source_path.with_suffix(".qcow2")
if not target.exists():
return target
for index in range(1, 1000):
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
def disk_convert_progress(line: str, current: int) -> int:
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
if match:
return max(current, int(float(match.group(1))))
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
if match:
return max(current, int(float(match.group(1))))
return current
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
operation = read_operation(operation_id)
if not operation:
return
source = Path(source_path)
target = Path(target_path)
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
append_operation_log(operation_id, "Запуск конвертации:")
append_operation_log(operation_id, " ".join(cmd))
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
progress = 1
if process.stdout:
for line in process.stdout:
append_operation_log(operation_id, line)
progress = disk_convert_progress(line, progress)
fresh = read_operation(operation_id) or operation
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
exit_code = process.wait()
fresh = read_operation(operation_id) or operation
if exit_code == 0:
append_operation_log(operation_id, f"Конвертация завершена: {target}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
else:
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
if source_path.suffix.lower() == ".qcow2":
return None
target_path = disk_convert_target_path(source_path)
operation_id = str(uuid.uuid4())
operation = {
"id": operation_id,
"type": "disk_convert",
"title": f"Конвертация {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Конвертация поставлена в очередь",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
}
write_operation(operation)
append_operation_log(operation_id, "Операция поставлена в очередь.")
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
return operation
def disk_upload_response(request: Request, payload: dict[str, Any]):
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
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 valid_snapshot_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}", name or ""))
def list_vm_snapshots(name: str) -> list[dict[str, str]]:
result = run_cmd(["virsh", "snapshot-list", name], timeout=20)
snapshots = []
if not result["ok"]:
return snapshots
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 1)
if len(parts) != 2:
continue
rest = parts[1].strip()
state = rest.split()[-1] if rest else ""
created = rest[: -len(state)].strip() if state else rest
snapshots.append({"name": parts[0], "created": created, "state": state, "state_css": "ok" if state == "running" else "warn"})
return snapshots
def snapshot_action(vm_name: str, action: str, snapshot_name: str, description: str = "") -> tuple[bool, str]:
if not valid_snapshot_name(snapshot_name):
return False, "Имя снапшота: латиница, цифры, точка, дефис, подчёркивание, до 63 символов."
if action == "create":
cmd = ["virsh", "snapshot-create-as", vm_name, snapshot_name]
if description:
cmd += ["--description", description[:200]]
result = run_cmd(cmd, timeout=600)
return (True, f"Снапшот {snapshot_name} создан") if result["ok"] else (False, result["stderr"][:300] or "virsh snapshot-create-as завершился с ошибкой")
if action == "revert":
result = run_cmd(["virsh", "snapshot-revert", vm_name, snapshot_name], timeout=600)
return (True, f"VM откачена к снапшоту {snapshot_name}") if result["ok"] else (False, result["stderr"][:300] or "virsh snapshot-revert завершился с ошибкой")
if action == "delete":
result = run_cmd(["virsh", "snapshot-delete", vm_name, snapshot_name], timeout=300)
return (True, f"Снапшот {snapshot_name} удалён") if result["ok"] else (False, result["stderr"][:300] or "virsh snapshot-delete завершился с ошибкой")
return False, "Неизвестное действие со снапшотом."
VM_TEMPLATES_FILE = Path("/var/lib/virtuality/config/vm_templates.json")
VM_TEMPLATES_LOCK = threading.Lock()
def load_vm_templates() -> list[str]:
try:
data = json.loads(VM_TEMPLATES_FILE.read_text())
return [name for name in data if isinstance(name, str)]
except Exception:
return []
def set_vm_template(name: str, enabled: bool) -> None:
with VM_TEMPLATES_LOCK:
names = set(load_vm_templates())
if enabled:
names.add(name)
else:
names.discard(name)
VM_TEMPLATES_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = VM_TEMPLATES_FILE.with_suffix(".json.tmp")
tmp_path.write_text(json.dumps(sorted(names), ensure_ascii=False, indent=2))
tmp_path.replace(VM_TEMPLATES_FILE)
def is_vm_template(name: str) -> bool:
return name in load_vm_templates()
def qemu_convert_progress(current: int, line: str) -> int:
"""Адаптер для run_operation_worker: qemu-img convert -p пишет '(42.00/100%)'."""
return disk_convert_progress(line, current)
def wget_progress(current: int, line: str) -> int:
# wget --progress=dot:giga пишет строки вида " 512000K .......... 42% 10.5M 2m30s"
match = re.search(r"(\d{1,3})%", line)
if match:
return max(current, min(99, int(match.group(1))))
return current
def vm_exists(name: str) -> bool:
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
def vm_ip(name: str) -> str:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if not result["ok"]:
return "not available"
match = re.search(r"(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/", result["stdout"])
return match.group(1) if match else "not available"
def vm_vnc_display(name: str) -> str:
return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"
def vnc_display_to_port(display: str) -> int | None:
value = (display or "").strip()
if not value or value == "not available":
return None
match = re.search(r":(\d+)$", value)
if not match:
return None
display_number = int(match.group(1))
if display_number >= 5900:
return display_number
return 5900 + display_number
def console_info(name: str) -> dict[str, Any]:
display = vm_vnc_display(name)
port = vnc_display_to_port(display)
has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())
token = None
url = None
if port and has_novnc:
token = console_serializer.dumps({"vm": name, "port": port})
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
def vm_boot_order_label(value: str) -> str:
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
return labels.get(value or "auto", "Auto — по источнику VM")
def boot_order_to_devs(value: str) -> list[str]:
value = normalize_boot_order(value, "disk_image")
mapping = {
"disk": ["hd"],
"cdrom_disk": ["cdrom", "hd"],
"disk_cdrom": ["hd", "cdrom"],
"network_disk": ["network", "hd"],
}
return mapping.get(value, ["hd"])
def boot_devs_to_order(devs: list[str]) -> str:
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
if clean[:2] == ["cdrom", "hd"]:
return "cdrom_disk"
if clean[:2] == ["hd", "cdrom"]:
return "disk_cdrom"
if clean[:2] == ["network", "hd"]:
return "network_disk"
if clean[:1] == ["hd"]:
return "disk"
return "auto"
def current_vm_boot_order(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "auto"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "auto"
os_node = root.find("os")
if os_node is None:
return "auto"
devs = []
for boot in os_node.findall("boot"):
dev = boot.attrib.get("dev", "").strip()
if dev:
devs.append(dev)
return boot_devs_to_order(devs)
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return False, "Некорректный порядок загрузки VM."
selected = normalize_boot_order(boot_order, "disk_image")
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
for boot in list(os_node.findall("boot")):
os_node.remove(boot)
insert_at = 0
for idx, child in enumerate(list(os_node)):
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
insert_at = idx + 1
for dev in reversed(boot_order_to_devs(selected)):
boot_node = ET.Element("boot", {"dev": dev})
os_node.insert(insert_at, boot_node)
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
def parse_dominfo_value(dominfo: str, label: str) -> str:
for line in (dominfo or "").splitlines():
if line.strip().lower().startswith(label.lower()):
return line.split(":", 1)[1].strip()
return ""
def kib_text_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
return max(0, int(int(match.group(1)) / 1024))
def current_vm_arch(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "unknown"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "unknown"
os_type = root.find("os/type")
return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown"
def vm_runtime_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def vm_resource_settings(name: str) -> dict[str, Any]:
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or ""
state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower()
vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)")
used_memory_raw = parse_dominfo_value(dominfo, "Used memory")
max_memory_raw = parse_dominfo_value(dominfo, "Max memory")
memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024
try:
vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0))
except Exception:
vcpus = 1
return {
"state": state,
"is_shutoff": state in ("shut off", "shutoff", "shut-off"),
"memory_mb": memory_mb,
"vcpus": vcpus,
"arch": current_vm_arch(name),
}
def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
resources = vm_resource_settings(name)
if not resources.get("is_shutoff"):
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
if memory_mb < 512 or memory_mb > 262144:
return False, "RAM должна быть от 512 MB до 262144 MB."
if vcpus < 1 or vcpus > 128:
return False, "CPU должен быть от 1 до 128 vCPU."
if guest_arch not in ("keep", "x86_64", "aarch64"):
return False, "Некорректная архитектура VM."
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
memory_kib = str(int(memory_mb) * 1024)
for tag in ("memory", "currentMemory"):
node = root.find(tag)
if node is None:
node = ET.SubElement(root, tag)
node.text = memory_kib
node.set("unit", "KiB")
vcpu_node = root.find("vcpu")
if vcpu_node is None:
vcpu_node = ET.SubElement(root, "vcpu")
vcpu_node.text = str(int(vcpus))
vcpu_node.set("placement", "static")
arch_changed = False
if guest_arch != "keep":
os_type = root.find("os/type")
if os_type is None:
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
os_type = ET.SubElement(os_node, "type")
os_type.text = "hvm"
old_arch = os_type.attrib.get("arch", "")
if old_arch != guest_arch:
os_type.set("arch", guest_arch)
if guest_arch == "aarch64":
os_type.set("machine", "virt")
arch_changed = True
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
if arch_changed:
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
return True, message
def vm_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def current_vm_iso(name: str) -> str:
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
if not result.get("ok"):
return ""
for line in (result.get("stdout") or "").splitlines():
if ".iso" not in line.lower():
continue
parts = line.split()
if parts:
return parts[-1]
return ""
def detach_vm_iso(name: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
cdrom_targets: list[str] = []
if xml.get("ok"):
try:
root = ET.fromstring(xml.get("stdout") or "")
devices = root.find("devices")
if devices is not None:
for disk in devices.findall("disk"):
if disk.attrib.get("device") != "cdrom":
continue
target = disk.find("target")
dev = target.attrib.get("dev") if target is not None else ""
if dev:
cdrom_targets.append(dev)
except Exception:
pass
if not cdrom_targets:
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
running = vm_state(name) == "running"
errors = []
changed_any = False
for target in cdrom_targets:
commands = []
if running:
commands.append(["virsh", "detach-disk", name, target, "--live"])
commands.append(["virsh", "detach-disk", name, target, "--config"])
for cmd in commands:
result = run_cmd(cmd, timeout=30)
if result.get("ok"):
changed_any = True
elif result.get("stderr"):
errors.append(result.get("stderr"))
if changed_any:
return True, "ISO был отмонтирован."
return False, errors[-1] if errors else "Подключенный ISO не найден."
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
iso = Path(iso_path or "").resolve()
try:
iso_root = ISO_DIR.resolve()
except Exception:
iso_root = Path("/var/lib/virtuality/iso")
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
detach_vm_iso(name)
running = vm_state(name) == "running"
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
if running:
live = run_cmd(base + ["--live"], timeout=30)
if not live.get("ok"):
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
config = run_cmd(base + ["--config"], timeout=30)
if not config.get("ok"):
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
def vm_details(name: str) -> dict[str, Any]:
return {
"name": name,
"dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
"vnc": vm_vnc_display(name),
"ip": vm_ip(name),
"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:
return cached(f"svc:{unit}", 10, lambda: run_cmd(["systemctl", "is-active", unit])["stdout"] or "inactive")
def network_summary() -> dict[str, str]:
return {"interfaces": run_cmd(["ip", "-br", "a"])["stdout"], "routes": run_cmd(["ip", "route"])["stdout"]}
def default_network_mode() -> str:
profile = host_profile.load_host_profile()
return profile.get("recommended_network", "bridge")
def vm_boot_order_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по источнику VM"},
{"value": "disk", "label": "Сначала диск"},
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
]
def normalize_boot_order(value: str, source_type: str) -> str:
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
mapping = {
"disk": "hd",
"cdrom_disk": "cdrom,hd",
"disk_cdrom": "hd,cdrom",
"network_disk": "network,hd",
}
value = mapping.get(boot_order, "hd")
if is_arm:
return "uefi," + value
return value
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
# Virtuality noVNC console patch
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
while True:
data = await reader.read(65536)
if not data:
break
try:
await websocket.send_bytes(data)
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
while True:
try:
message = await websocket.receive()
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
if message.get("type") == "websocket.disconnect":
break
if message.get("bytes") is not None:
writer.write(message["bytes"])
elif message.get("text") is not None:
writer.write(message["text"].encode())
try:
await writer.drain()
except (RuntimeError, ConnectionError, BrokenPipeError):
break
@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)
client_ip = request.client.host if request.client else "unknown"
blocked_for = login_block_remaining(client_ip)
if blocked_for:
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Подожди {blocked_for} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429)
if not verify_linux_password(username, password):
register_login_failure(client_ip)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
reset_login_failures(client_ip)
token = serializer.dumps({"user": AUTH_USER, "csrf": secrets.token_urlsafe(16)})
response = RedirectResponse(url="/", status_code=303)
response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12, secure=request.url.scheme == "https")
return response
@app.post("/logout")
def logout(request: Request, csrf_token: str = Form("")):
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
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
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": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css})
@app.get("/host", response_class=HTMLResponse)
def host_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
profile = host_profile.load_host_profile()
return templates.TemplateResponse("host.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
@app.post("/host/refresh")
def host_refresh(request: Request, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
profile = host_profile.detect_host_profile()
host_profile.save_host_profile(profile)
return RedirectResponse(url="/host", status_code=303)
@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(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
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}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
return RedirectResponse(url="/iso", status_code=303)
@app.post("/iso/{name}/delete")
def iso_delete(request: Request, name: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
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, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
refresh_iso_pool()
return RedirectResponse(url="/iso", status_code=303)
@app.get("/disk-images", response_class=HTMLResponse)
def disk_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
safe_name = safe_disk_upload_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
saved_paths: list[Path] = []
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
if disk_upload_is_archive(safe_name):
saved_paths = extract_disk_archive(tmp_target)
tmp_target.unlink(missing_ok=True)
elif disk_upload_is_xz_image(safe_name):
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
tmp_target.unlink(missing_ok=True)
else:
target = unique_disk_image_path(safe_name)
tmp_target.rename(target)
saved_paths = [target]
except Exception as exc:
tmp_target.unlink(missing_ok=True)
for path in saved_paths:
path.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
operation = start_disk_convert_operation(path)
if operation:
operations.append(operation)
payload = {
"ok": True,
"mode": "converting" if operations else "ready",
"operation_id": operations[0]["id"] if operations else None,
"operation_ids": [op["id"] for op in operations],
"files": [path.name for path in saved_paths],
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
}
return disk_upload_response(request, payload)
@app.post("/disk-images/{name}/delete")
def disk_image_delete(request: Request, name: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
path = disk_image_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
return RedirectResponse(url="/disk-images", status_code=303)
@app.get("/cloud-images", response_class=HTMLResponse)
def cloud_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
profile = host_profile.load_host_profile()
host_arch = str(profile.get("arch") or platform.machine() or "x86_64")
return templates.TemplateResponse("cloud_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "catalog": cloud_images.catalog_for_arch(host_arch), "images": cloud_images.list_images(), "error": error})
@app.post("/cloud-images/download")
def cloud_image_download(request: Request, key: str = Form(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
entry = cloud_images.catalog_entry(key)
if not entry:
return cloud_images_page(request, error="Неизвестный образ каталога.")
cloud_images.CLOUD_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = cloud_images.CLOUD_IMAGES_DIR / entry["filename"]
part = target.with_suffix(target.suffix + ".part")
cmd = ["bash", "-lc", f"set -euo pipefail; wget --progress=dot:giga -O {part} {entry['url']}; mv {part} {target}"]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "cloud_image_download", "title": f"Скачивание {entry['label']} ({entry['arch']})", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "url": entry["url"], "target": str(target), "cmd": " ".join(cmd)}
start_background_operation(operation, cmd, progress_fn=wget_progress, start_message="Скачивание cloud-образа запущено", success_message="Cloud-образ скачан", refresh_pool=False)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/cloud-images/{name}/delete")
def cloud_image_delete(request: Request, name: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
path = cloud_images.image_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
return RedirectResponse(url="/cloud-images", status_code=303)
def cloud_vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse("vm_create_cloud.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": cloud_images.list_images(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 10, "username": "admin", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
@app.get("/vm/create-cloud", response_class=HTMLResponse)
def vm_create_cloud_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return cloud_vm_form_context(request)
@app.post("/vm/create-cloud", response_class=HTMLResponse)
def vm_create_cloud_submit(request: Request, name: str = Form(...), image_name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), username: str = Form("admin"), password: str = Form(""), ssh_key: str = Form(""), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
form = {"name": name, "image_name": image_name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "username": username, "ssh_key": ssh_key, "network_mode": network_mode, "bridge": bridge}
image_path = cloud_images.image_path_by_name(image_name)
error = None
if not valid_vm_name(name):
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
elif vm_exists(name):
error = f"VM с именем {name} уже существует."
elif not image_path or not image_path.exists():
error = "Выбранный cloud-образ не найден. Сначала скачай его на странице Cloud-образы."
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 cloud_images.valid_cloud_username(username):
error = "Имя пользователя VM: строчные латинские буквы, цифры, дефис и подчёркивание."
elif not password and not ssh_key.strip():
error = "Задай пароль или вставь публичный SSH-ключ — иначе в VM нельзя будет войти."
elif ssh_key.strip() and not cloud_images.valid_ssh_key(ssh_key):
error = "SSH-ключ не похож на публичный ключ OpenSSH (ssh-ed25519 / ssh-rsa …)."
elif network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif network_mode == "bridge" and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat."
if error:
return cloud_vm_form_context(request, error=error, form=form, status_code=400)
if network_mode == "nat":
try:
network_core.create_nat_network()
except NetworkError as exc:
return cloud_vm_form_context(request, error=f"NAT-сеть не готова: {exc}", form=form, status_code=500)
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
seed_path = IMAGES_DIR / f"{name}-seed.iso"
if disk_path.exists():
return cloud_vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
image_meta = next((img for img in cloud_images.list_images() if img["name"] == image_name), None)
guest_arch = image_meta["arch"] if image_meta else "x86_64"
profile = host_profile.load_host_profile()
host_arch = str(profile.get("arch") or platform.machine() or "")
is_arm = guest_arch == "aarch64"
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
seed_dir = Path(tempfile.mkdtemp(prefix=f"cloudinit-{name}-"))
(seed_dir / "user-data").write_text(cloud_images.build_user_data(name, username, password, ssh_key.strip()))
(seed_dir / "meta-data").write_text(cloud_images.build_meta_data(name))
virt_cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
if guest_arch == "x86_64":
virt_cmd += ["--arch", "x86_64"]
else:
virt_cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"]
virt_cmd += ["--boot", virt_boot_arg("disk", is_arm), "--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--disk", f"path={seed_path},device=cdrom", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
shell_script = (
"set -euo pipefail; "
f"qemu-img convert -p -O qcow2 {image_path} {disk_path}; "
f"qemu-img resize {disk_path} {disk_size}G; "
f"cloud-localds {seed_path} {seed_dir / 'user-data'} {seed_dir / 'meta-data'}; "
+ " ".join(virt_cmd) + "; "
f"rm -rf {seed_dir}"
)
cmd = ["bash", "-lc", shell_script]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_create_cloud", "title": f"Создание VM {name} из cloud-образа", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "cloud_image": image_name, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cloud_user": username, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd, start_message="Подготовка диска из cloud-образа", success_message=f"VM {name} создана из cloud-образа")
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.get("/backups", response_class=HTMLResponse)
def backups_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("backups.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "backups": backup_core.list_backups(), "schedule": backup_core.load_schedule(), "error": error or request.query_params.get("backup_error", ""), "message": request.query_params.get("backup_message", "")})
@app.post("/backups/create")
def backup_create(request: Request, vm_name: str = Form(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(vm_name) or not vm_exists(vm_name):
return RedirectResponse(url="/backups?backup_error=VM не найдена", status_code=303)
disk_path = backup_core.first_disk_of(vm_name)
if not disk_path:
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск для бэкапа", status_code=303)
target = backup_core.new_backup_path(vm_name)
if target is None:
return RedirectResponse(url="/backups?backup_error=Некорректное имя VM", status_code=303)
keep = backup_core.load_schedule()["keep"]
cmd = ["bash", "-c", backup_core.build_backup_script(vm_name, disk_path, target)]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_backup", "title": f"Бэкап VM {vm_name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "disk_path": disk_path, "target": str(target), "cmd": " ".join(cmd)}
def rotate_after_backup():
removed = backup_core.rotate_backups(vm_name, keep)
return f"Ротация удалила старые бэкапы: {', '.join(removed)}" if removed else ""
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Бэкап диска VM {vm_name}", success_message=f"Бэкап VM {vm_name} создан", refresh_pool=False, on_success=rotate_after_backup)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/backups/{vm_name}/{filename}/restore")
def backup_restore(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
backup_file = backup_core.backup_path(vm_name, filename)
if backup_file is None or not backup_file.exists():
return RedirectResponse(url="/backups?backup_error=Бэкап не найден", status_code=303)
if not vm_exists(vm_name):
return RedirectResponse(url="/backups?backup_error=VM не существует — восстановление возможно только в существующую VM", status_code=303)
if "running" in vm_runtime_state(vm_name):
return RedirectResponse(url="/backups?backup_error=Сначала выключи VM — восстановление перезаписывает её диск", status_code=303)
disk_path = backup_core.first_disk_of(vm_name)
if not disk_path:
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск", status_code=303)
cmd = ["bash", "-c", backup_core.build_restore_script(disk_path, backup_file)]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_restore", "title": f"Восстановление VM {vm_name} из {filename}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "backup": filename, "disk_path": disk_path, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Восстановление диска VM {vm_name}", success_message=f"VM {vm_name} восстановлена из бэкапа", refresh_pool=False)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/backups/{vm_name}/{filename}/delete")
def backup_delete(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
backup_file = backup_core.backup_path(vm_name, filename)
if backup_file and backup_file.exists():
backup_file.unlink()
return RedirectResponse(url="/backups?backup_message=Бэкап удалён", status_code=303)
@app.post("/backups/schedule")
def backup_schedule_save(request: Request, enabled: str = Form("0"), keep: int = Form(5), vms: list[str] = Form([]), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
config = backup_core.save_schedule(enabled == "1", vms, keep)
state = "включено" if config["enabled"] else "выключено"
return RedirectResponse(url=f"/backups?backup_message=Расписание сохранено: {state}, VM: {len(config['vms'])}, хранить копий: {config['keep']}", status_code=303)
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
try:
ctx = network_core.network_context()
except Exception as exc:
ctx = {
"nat": {
"name": network_core.NETWORK_NAME,
"bridge": network_core.NAT_BRIDGE,
"subnet": network_core.NAT_SUBNET,
"gateway": network_core.NAT_GATEWAY,
"dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}",
"exists": False,
"info": str(exc),
"leases": "",
},
"networks": [],
"forwards": [],
"external_interface": "unknown",
"ip_forward": "unknown",
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": ctx,
"error": error,
"diagnostics": diagnostics,
}, status_code=status_code)
@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return safe_network_template(request, error=error)
@app.post("/network/vm-ip/save")
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form(""), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(vm_name):
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
if manual_ip:
data[vm_name] = manual_ip
else:
data.pop(vm_name, None)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/nat/setup")
def network_nat_setup(request: Request, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=500)
except Exception as exc:
return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/forward/add")
def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form(""), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
network_core.add_port_forward(vm_name, guest_ip, external_port, guest_port, protocol, note)
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=400)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/forward/{forward_id}/delete")
def network_forward_delete(request: Request, forward_id: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
network_core.delete_port_forward(forward_id)
except NetworkError:
pass
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/apply")
def network_apply(request: Request, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
network_core.apply_port_forwards()
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=500)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/diagnose", response_class=HTMLResponse)
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
diagnostics = None
error = None
try:
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
except NetworkError as exc:
error = str(exc)
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": network_core.network_context(),
"error": error,
"diagnostics": diagnostics,
}, status_code=400 if error else 200)
@app.get("/update", response_class=HTMLResponse)
def update_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
error = None
try:
info = update_core.check_updates(fetch=False)
except Exception as exc:
error = str(exc)
info = {
"ok": False,
"source_dir": str(update_core.SOURCE_DIR),
"remote": update_core.REMOTE,
"branch": update_core.DEFAULT_BRANCH,
"fetch_ok": False,
"fetch_error": str(exc),
"current_commit": "",
"latest_commit": "",
"current_version": "unknown",
"latest_version": "unknown",
"has_update": False,
"missing_versions": [],
"commits": [],
"checked_at": utc_now(),
"state": update_core.state(),
"log_tail": update_core.update_log_tail(),
}
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
@app.post("/update/check")
def update_check(request: Request, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
update_core.check_updates(fetch=True)
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.post("/update/apply")
def update_apply(request: Request, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
try:
update_core.start_update()
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.get("/operations", response_class=HTMLResponse)
def operations_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
def operation_detail_page(request: Request, operation_id: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
operation = read_operation(operation_id)
if not operation:
return RedirectResponse(url="/operations", status_code=303)
return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
@app.get("/api/operations")
def api_operations(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "operations": list_operations(25)}
@app.get("/api/operations/{operation_id}")
def api_operation(request: Request, operation_id: str):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
operation = read_operation(operation_id)
if not operation:
return JSONResponse({"ok": False, "error": "Operation not found"}, status_code=404)
return {"ok": True, "operation": operation}
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, source: str = "web", lines: int = 220):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
"selected": selected,
})
@app.get("/api/logs")
def api_logs(request: Request, source: str = "web", lines: int = 220):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "log": read_log_source(source, lines)}
@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 vm_form_context(request)
@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(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "boot_order": boot_order, "network_mode": network_mode, "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 network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif source_type not in ("iso", "disk_image"):
error = "Некорректный источник VM."
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
error = "Некорректная архитектура VM."
elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
error = "Некорректный порядок загрузки VM."
elif network_mode == "bridge" and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
if source_type == "iso":
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
else:
disk_image = Path(disk_image_path).resolve()
if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists():
error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images."
if error:
return vm_form_context(request, error=error, form=form, status_code=400)
if network_mode == "nat":
try:
network_core.create_nat_network()
except NetworkError as exc:
return vm_form_context(request, error=f"NAT-сеть не готова: {exc}", form=form, status_code=500)
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
selected_arch = normalize_guest_arch(guest_arch, profile)
selected_boot_order = normalize_boot_order(boot_order, source_type)
host_arch = str(profile.get("arch") or platform.machine() or "")
is_arm = selected_arch == "aarch64"
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
if selected_arch == "x86_64":
cmd += ["--arch", "x86_64"]
elif is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"]
cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]
if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.get("/vm/{name}/console", response_class=HTMLResponse)
def vm_console_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("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
@app.websocket("/console/ws/{token}")
async def console_websocket(websocket: WebSocket, token: str):
if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:
await websocket.close(code=1008)
return
try:
payload = console_serializer.loads(token)
vm_name = payload.get("vm")
target_port = int(payload.get("port"))
except Exception:
await websocket.close(code=1008)
return
if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:
await websocket.close(code=1008)
return
await websocket.accept()
try:
reader, writer = await asyncio.open_connection("127.0.0.1", target_port)
except Exception:
await websocket.close(code=1011)
return
tasks = [
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
]
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
await asyncio.gather(*done, return_exceptions=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
@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"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "is_template": is_vm_template(name), "clone_message": request.query_params.get("clone_message", ""), "clone_error": request.query_params.get("clone_error", ""), "snapshots": list_vm_snapshots(name), "snap_message": request.query_params.get("snap_message", ""), "snap_error": request.query_params.get("snap_error", "")})
@app.post("/vm/{name}/resources")
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep"), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
if ok:
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
@app.post("/vm/{name}/boot-order")
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto"), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
ok, message = apply_vm_boot_order(name, boot_order)
if ok:
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
@app.post("/vm/{name}/iso/mount")
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
ok, message = mount_vm_iso(name, iso_path)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/iso/unmount")
def vm_iso_unmount_apply(request: Request, name: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
ok, message = detach_vm_iso(name)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/snapshot/{snap_action}")
def vm_snapshot_apply(request: Request, name: str, snap_action: str, snapshot_name: str = Form(...), description: str = Form(""), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
if snap_action not in ("create", "revert", "delete"):
return JSONResponse({"ok": False, "error": "Unsupported snapshot action"}, status_code=400)
ok, message = snapshot_action(name, snap_action, snapshot_name.strip(), description.strip())
if ok:
cache_invalidate("vm_list")
param = "snap_message" if ok else "snap_error"
return RedirectResponse(url=f"/vm/{name}?{param}={message}", status_code=303)
@app.post("/vm/{name}/clone")
def vm_clone(request: Request, name: str, new_name: str = Form(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
if not valid_vm_name(new_name):
return RedirectResponse(url=f"/vm/{name}?clone_error=Некорректное имя новой VM", status_code=303)
if vm_exists(new_name):
return RedirectResponse(url=f"/vm/{name}?clone_error=VM {new_name} уже существует", status_code=303)
if "running" in vm_runtime_state(name):
return RedirectResponse(url=f"/vm/{name}?clone_error=Сначала выключи VM: клонировать можно только остановленную машину", status_code=303)
cmd = ["virt-clone", "--original", name, "--name", new_name, "--auto-clone"]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_clone", "title": f"Клонирование {name}{new_name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "clone_name": new_name, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd, start_message=f"virt-clone {name}{new_name}", success_message=f"VM {new_name} склонирована")
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/vm/{name}/template")
def vm_template_toggle(request: Request, name: str, enabled: str = Form("0"), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
set_vm_template(name, enabled == "1")
message = "VM помечена как шаблон: запуск заблокирован, используй клонирование" if enabled == "1" else "Флаг шаблона снят"
return RedirectResponse(url=f"/vm/{name}?clone_message={message}", status_code=303)
@app.post("/vm/{name}/{action}")
def vm_action(request: Request, name: str, action: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
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)
set_vm_template(name, False)
cache_invalidate("vm_list")
return RedirectResponse(url="/", status_code=303)
if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
if action == "start" and is_vm_template(name):
return RedirectResponse(url=f"/vm/{name}?clone_error=VM помечена как шаблон — запуск заблокирован. Склонируй её или сними флаг шаблона", status_code=303)
run_cmd(allowed[action], timeout=30)
cache_invalidate("vm_list")
return RedirectResponse(url=f"/vm/{name}", status_code=303)
@app.get("/live/status")
def live_status(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
vms = []
for vm in parse_virsh_list():
name = vm.get("name", "")
state = vm.get("state", "unknown")
try:
ip = vm_ip(name) if name else "—"
except Exception:
ip = "—"
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")})
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
@app.on_event("startup")
def start_metrics_collector():
metrics.start()
@app.get("/live/metrics")
def live_metrics(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, **metrics.snapshot()})
@app.get("/live/operations")
def live_operations(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)})
@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(), "host_profile": host_profile.load_host_profile(), "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(), "virtuality_nat": network_core.network_context()}