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
Этот коммит содержится в:
Claude
2026-09-25 06:26:51 +00:00
родитель 10a780d381
Коммит 975ab4d1a0
6 изменённых файлов: 342 добавлений и 38 удалений
+3
Просмотреть файл
@@ -91,3 +91,6 @@ composer.lock
# System files
*.pid
*.sock
# Offline Python wheels bundled into system images
wheels/
+92 -10
Просмотреть файл
@@ -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-зависимости"
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" <<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
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
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"
+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):
+120
Просмотреть файл
@@ -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)
+27
Просмотреть файл
@@ -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())
+8 -1
Просмотреть файл
@@ -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