Web-панель была подготовлена к продакшену
- исправлен 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
+92
-27
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Ссылка в новой задаче
Block a user