"""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" # virsh, ip and qemu-img print translated text under a Russian locale; the panel parses their output. os.environ["LC_ALL"] = "C.UTF-8" os.environ["LANG"] = "C.UTF-8" 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(request, 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: """Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine.""" if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""): return False return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name) 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") def vm_disk_files(name: str) -> tuple[list[Path], list[Path]]: """Файлы дисков машины: (свои — в IMAGES_DIR, чужие — где-то ещё). Приводы CD/DVD сюда не входят.""" own: list[Path] = [] foreign: list[Path] = [] result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10) for disk in presenters.parse_domblklist(result.get("stdout") or ""): if disk["device"] == "disk" and disk["source"]: path = Path(disk["source"]) (own if IMAGES_DIR.resolve() in path.resolve().parents else foreign).append(path) return own, foreign def undefine_vm(name: str) -> tuple[bool, str, list[Path], list[Path]]: """Удалить машину и файлы её дисков из IMAGES_DIR. Возвращает (ok, ошибка, удалённые, оставленные). Без `--remove-all-storage`: он удаляет все тома libvirt, подключённые к машине, в том числе вставленный установочный ISO из пула virtuality-iso. Диски вне IMAGES_DIR не трогаем.""" own, foreign = vm_disk_files(name) # после undefine libvirt о дисках машины уже не расскажет # --nvram: UEFI machines keep firmware variables that block a plain undefine. result = run_cmd(["virsh", "undefine", name, "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120) if not result["ok"]: return False, cmd_error(result, "virsh undefine"), [], [] removed = [] for disk in own: try: disk.unlink() except FileNotFoundError: continue except OSError: foreign.append(disk) continue removed.append(disk) if removed: run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20) return True, "", removed, foreign # ---------------------------------------------------------------- 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 process_owner(pid: int | None = None) -> dict[str, Any] | None: """Who runs an operation: pid plus its start time and boot id, so a reused pid is not mistaken for it.""" pid = pid or os.getpid() try: stat = Path(f"/proc/{pid}/stat").read_text() boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip() except OSError: return None return {"pid": pid, "start": stat.rsplit(")", 1)[1].split()[19], "boot": boot} def owner_alive(owner: Any) -> bool: if not isinstance(owner, dict) or not isinstance(owner.get("pid"), int): return False return process_owner(owner["pid"]) == owner def interrupt_orphaned_operations() -> int: """Operations run in threads of the panel process: after a restart the ones still marked queued/running will never finish. Close them so they do not block the next attempt. Operations of another live process (scheduled backups run from a timer) are left alone.""" if not OPERATIONS_DIR.exists(): return 0 count = 0 for path in OPERATIONS_DIR.glob("*.json"): try: data = json.loads(path.read_text()) except (OSError, ValueError): continue if data.get("status") not in ("queued", "running"): continue owner = data.get("owner") if owner_alive(owner) and owner["pid"] != os.getpid(): continue data.pop("log_tail", None) message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова." update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True) append_operation_log(data["id"], message) count += 1 return count def active_operations_for(vm_name: str, kinds: tuple[str, ...] | set[str] | None = None) -> list[dict[str, Any]]: """Unfinished operations of one machine, over the whole operations directory (not only the latest N).""" ensure_operations_dir() found = [] for path in OPERATIONS_DIR.glob("*.json"): try: data = json.loads(path.read_text()) except (OSError, ValueError): continue if data.get("vm_name") == vm_name and data.get("status") in ("queued", "running") and (kinds is None or data.get("type") in kinds): found.append(data) return found 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, "owner": process_owner()} 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()