diff --git a/tests/fixtures/domblkinfo.txt b/tests/fixtures/domblkinfo.txt
new file mode 100644
index 0000000..075a7e8
--- /dev/null
+++ b/tests/fixtures/domblkinfo.txt
@@ -0,0 +1,3 @@
+Capacity: 42949672960
+Allocation: 6442450944
+Physical: 6443499520
diff --git a/tests/fixtures/domblklist-details.txt b/tests/fixtures/domblklist-details.txt
new file mode 100644
index 0000000..8300047
--- /dev/null
+++ b/tests/fixtures/domblklist-details.txt
@@ -0,0 +1,4 @@
+ Type Device Target Source
+------------------------------------------------------------
+ file disk vda /var/lib/virtuality/images/web01.qcow2
+ file cdrom sda -
diff --git a/tests/fixtures/domstats.txt b/tests/fixtures/domstats.txt
new file mode 100644
index 0000000..5eb2d80
--- /dev/null
+++ b/tests/fixtures/domstats.txt
@@ -0,0 +1,16 @@
+Domain: 'web01'
+ cpu.time=48123456789
+ cpu.user=30000000000
+ cpu.system=18123456789
+ balloon.current=2097152
+ balloon.maximum=2097152
+ balloon.rss=1534208
+ vcpu.current=2
+ vcpu.maximum=2
+ vcpu.0.state=1
+ vcpu.0.time=24000000000
+ vcpu.0.wait=0
+ vcpu.1.state=1
+ vcpu.1.time=24123456789
+ vcpu.1.wait=0
+
diff --git a/tests/fixtures/dumpxml-web01.xml b/tests/fixtures/dumpxml-web01.xml
new file mode 100644
index 0000000..23387f9
--- /dev/null
+++ b/tests/fixtures/dumpxml-web01.xml
@@ -0,0 +1,37 @@
+
+ web01
+ 6c1b3c1e-0000-0000-0000-000000000001
+ Сайт компании
+ Nginx + база. Бэкап по пятницам.
+ 2097152
+ 2097152
+ 2
+
+ hvm
+
+
+
+ /usr/bin/qemu-system-x86_64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/net-dumpxml.xml b/tests/fixtures/net-dumpxml.xml
new file mode 100644
index 0000000..cf4594a
--- /dev/null
+++ b/tests/fixtures/net-dumpxml.xml
@@ -0,0 +1,17 @@
+
+ virtuality-nat
+ 6c1b3c1e-1111-2222-3333-444444444444
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/qemu-img-info.json b/tests/fixtures/qemu-img-info.json
new file mode 100644
index 0000000..426201a
--- /dev/null
+++ b/tests/fixtures/qemu-img-info.json
@@ -0,0 +1,10 @@
+{
+ "children": [],
+ "virtual-size": 42949672960,
+ "filename": "/var/lib/virtuality/images/web01.qcow2",
+ "cluster-size": 65536,
+ "format": "qcow2",
+ "actual-size": 6443499520,
+ "format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}},
+ "dirty-flag": false
+}
diff --git a/tests/fixtures/snapshot-info.txt b/tests/fixtures/snapshot-info.txt
new file mode 100644
index 0000000..e5d500f
--- /dev/null
+++ b/tests/fixtures/snapshot-info.txt
@@ -0,0 +1,9 @@
+Name: clean
+Domain: web01
+Current: yes
+State: running
+Location: internal
+Parent: -
+Children: 1
+Descendants: 1
+Metadata: yes
diff --git a/tests/fixtures/snapshot-list.txt b/tests/fixtures/snapshot-list.txt
new file mode 100644
index 0000000..2ee1326
--- /dev/null
+++ b/tests/fixtures/snapshot-list.txt
@@ -0,0 +1,4 @@
+ Name Creation Time State
+------------------------------------------------------
+ before-upd 2026-09-25 10:12:03 +0000 shutoff
+ clean 2026-09-24 09:00:41 +0000 running
diff --git a/web/catalog.py b/web/catalog.py
new file mode 100644
index 0000000..014871b
--- /dev/null
+++ b/web/catalog.py
@@ -0,0 +1,82 @@
+"""Catalog of ready-to-run cloud images for the quick VM flow.
+
+Each entry points to the vendor's official cloud image. Images boot in seconds
+and are configured through cloud-init (user, password, SSH key) — no installer.
+"""
+from typing import Any
+
+# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
+CLOUD_IMAGES: list[dict[str, Any]] = [
+ {
+ "id": "ubuntu-26.04",
+ "title": "Ubuntu Server 26.04 LTS",
+ "vendor": "Ubuntu",
+ "hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.",
+ "osinfo": "ubuntu24.04",
+ "recommended": True,
+ "login": "ubuntu",
+ "arches": {
+ "x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
+ "aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
+ },
+ },
+ {
+ "id": "ubuntu-24.04",
+ "title": "Ubuntu Server 24.04 LTS",
+ "vendor": "Ubuntu",
+ "hint": "Проверенная LTS, поддержка до 2029 года.",
+ "osinfo": "ubuntu24.04",
+ "login": "ubuntu",
+ "arches": {
+ "x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
+ "aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
+ },
+ },
+ {
+ "id": "debian-13",
+ "title": "Debian 13",
+ "vendor": "Debian",
+ "hint": "Лёгкий и стабильный. Хорош для домашних сервисов.",
+ "osinfo": "debian13",
+ "login": "debian",
+ "arches": {
+ "x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
+ "aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
+ },
+ },
+ {
+ "id": "alpine-3.23",
+ "title": "Alpine Linux 3.23",
+ "vendor": "Alpine",
+ "hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.",
+ "osinfo": "alpinelinux3.21",
+ "login": "alpine",
+ "arches": {
+ "x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
+ "aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
+ },
+ },
+]
+
+# OS families for the "install from ISO" flow. virt-install picks the right
+# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra
+# drivers), win11 additionally gets UEFI + TPM.
+OS_TYPES: list[dict[str, Any]] = [
+ {"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"},
+ {"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"},
+ {"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"},
+ {"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"},
+]
+
+
+def cloud_image(image_id: str) -> dict[str, Any] | None:
+ return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
+
+
+def os_type(type_id: str) -> dict[str, Any]:
+ return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0])
+
+
+def image_filename(entry: dict[str, Any], arch: str) -> str:
+ url = entry["arches"][arch]["url"]
+ return url.rsplit("/", 1)[-1]
diff --git a/web/core.py b/web/core.py
new file mode 100644
index 0000000..cf7b9a1
--- /dev/null
+++ b/web/core.py
@@ -0,0 +1,321 @@
+"""Shared primitives of the Virtuality panel: settings, auth, command runner,
+background operations store, templates. Feature modules import from here;
+app.py re-exports the same names for backwards compatibility."""
+import json
+import os
+import re
+import subprocess
+import threading
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable
+from urllib.parse import quote, urlsplit
+
+from fastapi import Request
+from fastapi.responses import JSONResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer
+
+import auth
+import presenters
+import update_core
+
+BASE_DIR = Path(__file__).resolve().parent
+APP_NAME = "Virtuality"
+ENV_FILE = BASE_DIR / ".env"
+STORAGE_DIR = Path("/var/lib/virtuality")
+ISO_DIR = Path("/var/lib/virtuality/iso")
+IMAGES_DIR = Path("/var/lib/virtuality/images")
+DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")
+BACKUPS_DIR = Path("/var/lib/virtuality/backups")
+CONFIG_DIR = Path("/var/lib/virtuality/config")
+OPERATIONS_DIR = Path("/var/log/virtuality/operations")
+DEFAULT_BRIDGE = "br0"
+IMAGES_POOL = "virtuality-images"
+ISO_POOL = "virtuality-iso"
+
+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", "root"))
+SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me"))
+COOKIE_SECURE = CONFIG.get("VIRTUALITY_COOKIE_SECURE", os.environ.get("VIRTUALITY_COOKIE_SECURE", "0")) == "1"
+SESSION_MAX_AGE = 60 * 60 * 12
+login_throttle = auth.LoginThrottle()
+serializer = URLSafeTimedSerializer(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 read_version() -> str:
+ for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"):
+ try:
+ return path.read_text().strip() or "unknown"
+ except OSError:
+ continue
+ return "unknown"
+
+
+APP_VERSION = read_version()
+
+
+# ---------------------------------------------------------------- templates
+def update_notice() -> dict[str, Any]:
+ try:
+ data = json.loads((update_core.STATE_DIR / "last_check.json").read_text())
+ except Exception:
+ return {"has_update": False}
+ return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")}
+
+
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+templates.env.globals.update(
+ update_notice=update_notice,
+ vm_presets=presenters.VM_PRESETS,
+ port_presets=presenters.PORT_PRESETS,
+ plural=presenters.plural,
+ app_version=APP_VERSION,
+ app_name=APP_NAME,
+)
+templates.env.filters.update(
+ vm_state=presenters.vm_state,
+ operation_state=presenters.operation_state,
+ service_state=presenters.service_state,
+ format_mb=presenters.format_mb,
+ format_bytes=presenters.format_bytes,
+ level_tone=presenters.level_tone,
+ port_service=presenters.port_service,
+)
+
+
+def render(request: Request, template: str, context: dict[str, Any] | None = None, status_code: int = 200, **extra: Any):
+ data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER}
+ data.update(context or {})
+ data.update(extra)
+ return templates.TemplateResponse(template, data, status_code=status_code)
+
+
+# ---------------------------------------------------------------- auth
+def verify_linux_password(username: str, password: str) -> bool:
+ if username != AUTH_USER:
+ return False
+ return auth.verify_password(username, password)
+
+
+def user_from_session_token(token: str | None) -> str | None:
+ if not token:
+ return None
+ try:
+ data = serializer.loads(token, max_age=SESSION_MAX_AGE)
+ except BadSignature:
+ return None
+ if not isinstance(data, dict):
+ 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):
+ """Return a redirect to /login for anonymous requests, otherwise None."""
+ if not get_current_user(request):
+ return RedirectResponse(url="/login", status_code=303)
+ return None
+
+
+def require_api_auth(request: Request):
+ """JSON variant of require_auth for /api and /live endpoints."""
+ if not get_current_user(request):
+ return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
+ return None
+
+
+def client_key(request: Request) -> str:
+ return request.client.host if request.client else "unknown"
+
+
+def same_origin(request: Request) -> bool:
+ origin = request.headers.get("origin") or request.headers.get("referer")
+ if not origin or origin == "null":
+ # Browsers always send Origin on cross-site form posts; tools like curl do not.
+ return origin != "null"
+ origin_host = urlsplit(origin).netloc.lower()
+ allowed = {request.headers.get("host", "").lower()}
+ forwarded = request.headers.get("x-forwarded-host")
+ if forwarded:
+ allowed.update(item.strip().lower() for item in forwarded.split(","))
+ return origin_host in allowed
+
+
+def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse:
+ return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303)
+
+
+# ---------------------------------------------------------------- commands
+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 cmd_error(result: dict[str, Any], fallback: str) -> str:
+ """Best human-readable error text from a run_cmd result."""
+ text = (result.get("stderr") or result.get("stdout") or "").strip()
+ text = re.sub(r"^error:\s*", "", text, flags=re.IGNORECASE)
+ return text.splitlines()[0] if text else fallback
+
+
+def utc_now() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+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 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_label(name: str) -> bool:
+ """Snapshot, backup and similar user-chosen names."""
+ return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name or ""))
+
+
+def vm_exists(name: str) -> bool:
+ return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
+
+
+def vm_state(name: str) -> str:
+ result = run_cmd(["virsh", "domstate", name], timeout=8)
+ return (result.get("stdout") or "unknown").strip().lower()
+
+
+def vm_is_running(name: str) -> bool:
+ return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown")
+
+
+# ---------------------------------------------------------------- operations
+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 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 running_operations(kind: str | None = None) -> list[dict[str, Any]]:
+ return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)]
+
+
+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 new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]:
+ operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER}
+ operation.update(meta)
+ write_operation(operation)
+ append_operation_log(operation["id"], "Операция поставлена в очередь.")
+ return operation
+
+
+def finish_operation(operation: dict[str, Any], ok: bool, message: str, **meta: Any) -> None:
+ fresh = read_operation(operation["id"]) or operation
+ fresh.pop("log_tail", None)
+ update_operation(fresh, status="success" if ok else "error", progress=100, message=message, finished_at=utc_now(), **meta)
+ append_operation_log(operation["id"], message)
+
+
+def run_operation(operation: dict[str, Any], worker: Callable[[dict[str, Any]], None]) -> None:
+ """Run worker(operation) in a daemon thread; the worker reports progress via
+ update_operation/append_operation_log and must call finish_operation."""
+
+ def runner() -> None:
+ try:
+ update_operation(operation, status="running", started_at=utc_now())
+ worker(operation)
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
+ append_operation_log(operation["id"], f"Ошибка: {exc}")
+ finish_operation(operation, False, str(exc)[:240])
+
+ threading.Thread(target=runner, daemon=True).start()
diff --git a/web/presenters.py b/web/presenters.py
index 89e525e..a8ec65e 100644
--- a/web/presenters.py
+++ b/web/presenters.py
@@ -242,3 +242,12 @@ def plural(count: int, one: str, few: str, many: str) -> str:
def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "")
+
+
+def format_bytes(value: int | float) -> str:
+ value = float(value or 0)
+ for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
+ if value < 1024 or unit == "ТБ":
+ return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}"
+ value /= 1024
+ return f"{value:.0f} ТБ"