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
Этот коммит содержится в:
@@ -91,3 +91,6 @@ composer.lock
|
||||
# System files
|
||||
*.pid
|
||||
*.sock
|
||||
|
||||
# Offline Python wheels bundled into system images
|
||||
wheels/
|
||||
|
||||
@@ -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" <<EOF
|
||||
# Virtuality node settings. Edit and re-run: sudo bash scripts/install_web_panel.sh
|
||||
VIRTUALITY_WEB_PORT=${PORT}
|
||||
VIRTUALITY_WEB_HOST=${WEB_HOST}
|
||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
|
||||
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
|
||||
EOF
|
||||
chmod 644 "$NODE_CONFIG_FILE"
|
||||
ok "Настройки ноды сохранены: $NODE_CONFIG_FILE"
|
||||
cat > "${APP_DIR}/.env" <<EOF
|
||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
||||
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
|
||||
TMPDIR=${UPLOAD_TMP_DIR}
|
||||
TEMP=${UPLOAD_TMP_DIR}
|
||||
TMP=${UPLOAD_TMP_DIR}
|
||||
@@ -187,15 +235,24 @@ ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||
|
||||
step "Создаём Python virtualenv"
|
||||
if [[ -d "$VENV_DIR" ]]; then
|
||||
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/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-зависимости"
|
||||
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" <<EOF
|
||||
@@ -207,9 +264,11 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}
|
||||
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${WEB_HOST} --port ${PORT} --workers 1 --no-server-header
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
TimeoutStopSec=20
|
||||
LimitNOFILE=65536
|
||||
User=root
|
||||
Group=root
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
@@ -223,7 +282,24 @@ Environment=TMP=${UPLOAD_TMP_DIR}
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
ok "Создан service: $SERVICE_FILE"
|
||||
cat > "$NETWORK_SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality NAT port forwarding restore
|
||||
After=network-online.target libvirtd.service nftables.service ufw.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
ok "Создан service: $NETWORK_SERVICE_FILE"
|
||||
run_logged "systemd daemon-reload выполнен" systemctl daemon-reload
|
||||
run_logged "virtuality-network.service включён" systemctl enable virtuality-network.service
|
||||
|
||||
step "Настраиваем автообновление"
|
||||
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
|
||||
@@ -256,8 +332,13 @@ Unit=virtuality-auto-update.service
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
|
||||
if [[ "$AUTO_UPDATE" == "1" ]]; then
|
||||
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
|
||||
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
|
||||
else
|
||||
systemctl disable --now virtuality-auto-update.timer >> "$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"
|
||||
|
||||
+91
-26
@@ -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
|
||||
return auth.verify_password(username, password)
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"):
|
||||
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 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