From 975ab4d1a080b845fddfa51ca25de101697b9e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:26:51 +0000 Subject: [PATCH] =?UTF-8?q?Web-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B1=D1=8B=D0=BB=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BA=D1=88=D0=B5=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - исправлен NameError при создании VM: не был импортирован platform; - на странице VM автозапуск всегда показывался выключенным; - сообщения в redirect-URL теперь кодируются (кириллица, пробелы, &); - проверка пароля вынесена в web/auth.py и работает на Python 3.13+, где модули crypt и spwd удалены (fallback на libcrypt и /etc/shadow); - добавлены ограничение попыток входа, срок жизни сессии, флаг Secure для cookie, защита от cross-origin POST и заголовки безопасности; - добавлен публичный /healthz с версией панели; - правила проброса портов восстанавливаются после перезагрузки (virtuality-network.service); - обновление из панели запускается через systemd-run и больше не убивается перезапуском virtuality-web; - install_web_panel.sh сохраняет порт, пользователя и режим автообновления в /var/lib/virtuality/config/web.env, поэтому обновление больше не сбрасывает порт на 8088 и пользователя на viktor; - поддержка офлайн-установки Python-зависимостей из wheels/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- .gitignore | 3 + scripts/install_web_panel.sh | 102 ++++++++++++++++++++++++++--- web/app.py | 119 ++++++++++++++++++++++++++-------- web/auth.py | 120 +++++++++++++++++++++++++++++++++++ web/restore_network.py | 27 ++++++++ web/update_core.py | 9 ++- 6 files changed, 342 insertions(+), 38 deletions(-) create mode 100644 web/auth.py create mode 100644 web/restore_network.py diff --git a/.gitignore b/.gitignore index af771ab..e9b9b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ composer.lock # System files *.pid *.sock + +# Offline Python wheels bundled into system images +wheels/ diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 459dbfa..4d3dc20 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -11,15 +11,51 @@ VENV_DIR="/opt/virtuality/venv" SERVICE_FILE="/etc/systemd/system/virtuality-web.service" AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service" AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer" -PORT="${VIRTUALITY_WEB_PORT:-8088}" -AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}" +NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service" LOG_DIR="/var/log/virtuality" LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log" PROFILE_DIR="/var/lib/virtuality/config" PROFILE_FILE="${PROFILE_DIR}/host_profile.json" SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret" +NODE_CONFIG_FILE="${PROFILE_DIR}/web.env" UPLOAD_TMP_DIR="/var/lib/virtuality/tmp" +WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}" TOTAL_STEPS=13 + +# Settings survive updates: explicit env > saved node config > previous install > defaults. +saved_setting() { + local key="$1" + [[ -f "$NODE_CONFIG_FILE" ]] || return 0 + sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'" +} +legacy_auth_user() { + if [[ -f "${APP_DIR}/.env" ]]; then + sed -n 's/^VIRTUALITY_AUTH_USER=//p' "${APP_DIR}/.env" | tail -n1 + fi +} +legacy_port() { + if [[ -f "$SERVICE_FILE" ]]; then + sed -n 's/.*--port \([0-9][0-9]*\).*/\1/p' "$SERVICE_FILE" | tail -n1 + fi +} +first_human_user() { + getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}' +} + +PORT="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}" +PORT="${PORT:-$(legacy_port)}" +PORT="${PORT:-8088}" +AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}" +AUTH_USER="${AUTH_USER:-$(legacy_auth_user)}" +AUTH_USER="${AUTH_USER:-${SUDO_USER:-}}" +AUTH_USER="${AUTH_USER:-$(first_human_user)}" +AUTH_USER="${AUTH_USER:-root}" +AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}" +AUTO_UPDATE="${AUTO_UPDATE:-1}" +WEB_HOST="${VIRTUALITY_WEB_HOST:-$(saved_setting VIRTUALITY_WEB_HOST)}" +WEB_HOST="${WEB_HOST:-0.0.0.0}" +COOKIE_SECURE="${VIRTUALITY_COOKIE_SECURE:-$(saved_setting VIRTUALITY_COOKIE_SECURE)}" +COOKIE_SECURE="${COOKIE_SECURE:-0}" CURRENT_STEP=0 ESC="\033" @@ -156,7 +192,8 @@ fi step "Копируем web-панель в /opt/virtuality" run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality -run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/" +run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "$APP_DIR/" +run_logged "Версия панели записана" install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}/VERSION" run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR" if [[ -f "$PROFILE_FILE" ]]; then ok "Профиль уже сохранён: $PROFILE_FILE" @@ -174,9 +211,20 @@ else chmod 600 "$SESSION_SECRET_FILE" ok "Создан постоянный session secret: $SESSION_SECRET_FILE" fi +cat > "$NODE_CONFIG_FILE" < "${APP_DIR}/.env" </dev/null 2>&1; then warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR" else + if [[ -d "$VENV_DIR" ]]; then + warn "Virtualenv повреждён (например, после обновления Python), пересоздаём" + rm -rf -- "${VENV_DIR:?}" + fi run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR" fi step "Устанавливаем Python-зависимости" -run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip -run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt" +if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then + run_logged "Python-зависимости установлены офлайн из ${WHEELS_DIR}" "$VENV_DIR/bin/pip" install --no-index --find-links "$WHEELS_DIR" -r "$APP_DIR/requirements.txt" +else + run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip + run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt" +fi +run_logged "Web-панель импортируется без ошибок" bash -c "cd '$APP_DIR' && '$VENV_DIR/bin/python' -c 'import app'" step "Создаём systemd service" cat > "$SERVICE_FILE" < "$NETWORK_SERVICE_FILE" <> "$LOG_FILE" 2>&1 || true + ok "Автообновление отключено (VIRTUALITY_AUTO_UPDATE=0). Обновления — вручную через /update" + fi else warn "auto_update_check.sh не найден, автообновление пропущено" fi @@ -298,7 +379,8 @@ echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-польз echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}" echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}" echo -e "${BOLD}Service:${RESET} virtuality-web.service" -echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве" +echo -e "${BOLD}Auto update:${RESET} $([[ "$AUTO_UPDATE" == "1" ]] && echo "ежедневно в 00:00 по Москве" || echo "отключено")" +echo -e "${BOLD}Settings:${RESET} ${NODE_CONFIG_FILE}" echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}" echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}" echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager" diff --git a/web/app.py b/web/app.py index 4582e77..2ec82ba 100644 --- a/web/app.py +++ b/web/app.py @@ -1,12 +1,10 @@ #!/usr/bin/env python3 import asyncio -import crypt import json import os +import platform import re -import secrets import shutil -import spwd import subprocess import tempfile import threading @@ -17,14 +15,16 @@ import zipfile from datetime import datetime from pathlib import Path from typing import Any +from urllib.parse import quote, urlsplit 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 +from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer +import auth import host_profile import network_core import update_core @@ -49,6 +49,22 @@ if NOVNC_DIR: app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc") OP_LOCK = threading.Lock() +SECURITY_HEADERS = { + "X-Frame-Options": "SAMEORIGIN", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", +} + + +@app.middleware("http") +async def security_middleware(request: Request, call_next): + if request.method in ("POST", "PUT", "PATCH", "DELETE") and not same_origin(request): + return JSONResponse({"ok": False, "error": "Cross-origin request blocked"}, status_code=403) + response = await call_next(request) + for header, value in SECURITY_HEADERS.items(): + response.headers.setdefault(header, value) + return response + def load_env() -> dict[str, str]: data: dict[str, str] = {} @@ -62,9 +78,12 @@ def load_env() -> dict[str, str]: CONFIG = load_env() -AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "viktor")) +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")) -serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session") +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") @@ -73,25 +92,53 @@ def is_configured() -> bool: def verify_linux_password(username: str, password: str) -> bool: - if not username or not password or username != AUTH_USER: + if username != AUTH_USER: 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) + return auth.verify_password(username, password) + + +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() + + +def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse: + return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303) + + +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 user_from_session_token(token: str | None) -> str | None: if not token: return None try: - data = serializer.loads(token) + 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 @@ -1119,14 +1166,19 @@ def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: def vm_details(name: str) -> dict[str, Any]: + dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"] + autostart = vm_autostart_status(name) return { "name": name, - "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"], + "dominfo": dominfo, "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"], + "autostart": dominfo, + "autostart_enabled": autostart["enabled"], + "autostart_label": autostart["label"], + "autostart_css": autostart["css"], } @@ -1232,11 +1284,17 @@ def login_page(request: Request): 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) + key = client_key(request) + retry_after = login_throttle.retry_after(key) + if retry_after: + return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)}) if not verify_linux_password(username, password): + login_throttle.record_failure(key) return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401) + login_throttle.record_success(key) token = serializer.dumps({"user": AUTH_USER}) response = RedirectResponse(url="/", status_code=303) - response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12) + response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", secure=COOKIE_SECURE, max_age=SESSION_MAX_AGE) return response @@ -1819,8 +1877,8 @@ def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), return auth_redirect 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) + return redirect_with_message(f"/vm/{name}", "resource_message", message) + return redirect_with_message(f"/vm/{name}", "resource_error", message) @app.post("/vm/{name}/boot-order") @@ -1830,8 +1888,8 @@ def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("aut return auth_redirect 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) + return redirect_with_message(f"/vm/{name}", "boot_message", message) + return redirect_with_message(f"/vm/{name}", "boot_error", message) @app.post("/vm/{name}/iso/mount") @@ -1841,8 +1899,8 @@ def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)): return auth_redirect 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) + return redirect_with_message(f"/vm/{name}", "iso_message", message) + return redirect_with_message(f"/vm/{name}", "iso_error", message) @app.post("/vm/{name}/iso/unmount") @@ -1852,8 +1910,8 @@ def vm_iso_unmount_apply(request: Request, name: str): return auth_redirect 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) + return redirect_with_message(f"/vm/{name}", "iso_message", message) + return redirect_with_message(f"/vm/{name}", "iso_error", message) @app.post("/vm/{name}/{action}") @@ -1861,6 +1919,8 @@ def vm_action(request: Request, name: str, action: str): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect + if not valid_vm_name(name): + return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400) 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) @@ -1896,6 +1956,11 @@ def live_operations(request: Request): return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)}) +@app.get("/healthz") +def healthz(): + return {"ok": True, "app": APP_NAME, "version": APP_VERSION} + + @app.get("/api/health") def api_health(request: Request): if not get_current_user(request): diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..db3b2d0 --- /dev/null +++ b/web/auth.py @@ -0,0 +1,120 @@ +"""Linux password verification for the Virtuality panel. + +The stdlib `crypt` and `spwd` modules are deprecated in Python 3.11+ and +removed in 3.13 (Debian 13, Ubuntu 25.04+). This module uses them when they +exist and otherwise falls back to libcrypt via ctypes and /etc/shadow. +""" +import ctypes +import ctypes.util +import secrets +import threading +import time +import warnings +from pathlib import Path + +SHADOW_FILE = Path("/etc/shadow") +LOCKED_HASHES = {"", "!", "*", "!!"} + +_crypt_lock = threading.Lock() +_libcrypt = None + +try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import crypt as _stdlib_crypt # type: ignore[import-not-found] +except ImportError: + _stdlib_crypt = None + +try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import spwd as _stdlib_spwd # type: ignore[import-not-found] +except ImportError: + _stdlib_spwd = None + + +def _load_libcrypt(): + global _libcrypt + if _libcrypt is None: + name = ctypes.util.find_library("crypt") or "libcrypt.so.1" + lib = ctypes.CDLL(name) + lib.crypt.restype = ctypes.c_char_p + lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + _libcrypt = lib + return _libcrypt + + +def crypt_hash(password: str, salt: str) -> str | None: + # crypt(3) returns a pointer to a static buffer, so calls are serialized. + with _crypt_lock: + if _stdlib_crypt is not None: + return _stdlib_crypt.crypt(password, salt) + try: + result = _load_libcrypt().crypt(password.encode(), salt.encode()) + except OSError: + return None + return result.decode() if result else None + + +def shadow_hash(username: str) -> str | None: + if _stdlib_spwd is not None: + try: + return _stdlib_spwd.getspnam(username).sp_pwdp + except (KeyError, PermissionError): + return None + try: + for line in SHADOW_FILE.read_text().splitlines(): + parts = line.split(":") + if len(parts) > 1 and parts[0] == username: + return parts[1] + except OSError: + return None + return None + + +def verify_password(username: str, password: str) -> bool: + if not username or not password: + return False + stored_hash = shadow_hash(username) + if stored_hash is None or stored_hash in LOCKED_HASHES or stored_hash.startswith("!"): + return False + computed = crypt_hash(password, stored_hash) + if not computed: + return False + return secrets.compare_digest(computed, stored_hash) + + +class LoginThrottle: + """In-memory brute-force protection keyed by client address.""" + + def __init__(self, max_failures: int = 5, window_seconds: int = 300, lockout_seconds: int = 300): + self.max_failures = max_failures + self.window_seconds = window_seconds + self.lockout_seconds = lockout_seconds + self._failures: dict[str, list[float]] = {} + self._locked_until: dict[str, float] = {} + self._lock = threading.Lock() + + def retry_after(self, key: str) -> int: + with self._lock: + until = self._locked_until.get(key, 0.0) + remaining = until - time.monotonic() + if remaining <= 0: + self._locked_until.pop(key, None) + return 0 + return int(remaining) + 1 + + def record_failure(self, key: str) -> None: + now = time.monotonic() + with self._lock: + recent = [ts for ts in self._failures.get(key, []) if now - ts < self.window_seconds] + recent.append(now) + if len(recent) >= self.max_failures: + self._locked_until[key] = now + self.lockout_seconds + recent = [] + self._failures[key] = recent + + def record_success(self, key: str) -> None: + with self._lock: + self._failures.pop(key, None) + self._locked_until.pop(key, None) diff --git a/web/restore_network.py b/web/restore_network.py new file mode 100644 index 0000000..b3a85b5 --- /dev/null +++ b/web/restore_network.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Re-apply Virtuality NAT port forwards after boot. + +nftables/iptables rules created by the panel live only in the kernel, so they +are lost on reboot. virtuality-network.service runs this once at startup. +""" +import sys + +import network_core + + +def main() -> int: + if not network_core.PORT_FORWARDS_FILE.exists(): + print("No port forwards configured, nothing to restore") + return 0 + try: + result = network_core.apply_port_forwards() + except network_core.NetworkError as exc: + print(f"Failed to restore port forwards: {exc}", file=sys.stderr) + return 1 + count = len(network_core.load_port_forwards()) + print(f"Restored {count} port forward(s), nft ok={result['nft_apply']['ok']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/update_core.py b/web/update_core.py index 16f5827..9121a26 100644 --- a/web/update_core.py +++ b/web/update_core.py @@ -1,5 +1,6 @@ import json import os +import shutil import subprocess from datetime import datetime from pathlib import Path @@ -219,5 +220,11 @@ def start_update() -> dict[str, Any]: 'finished_at': '', } write_json(STATE_FILE, data) - subprocess.Popen(['bash', str(script)], cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + cmd = ['bash', str(script)] + if shutil.which('systemd-run'): + # Run outside virtuality-web's cgroup: the update restarts virtuality-web, + # which would otherwise kill the update script before it finishes. + unit = f"virtuality-update-{datetime.now().strftime('%Y%m%d%H%M%S')}" + cmd = ['systemd-run', '--unit', unit, '--collect', '--quiet', f'--setenv=VIRTUALITY_SOURCE_DIR={SOURCE_DIR}', f'--working-directory={SOURCE_DIR}'] + cmd + subprocess.Popen(cmd, cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return data