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
|
# System files
|
||||||
*.pid
|
*.pid
|
||||||
*.sock
|
*.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"
|
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
||||||
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service"
|
||||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
||||||
PROFILE_DIR="/var/lib/virtuality/config"
|
PROFILE_DIR="/var/lib/virtuality/config"
|
||||||
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
||||||
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
||||||
|
NODE_CONFIG_FILE="${PROFILE_DIR}/web.env"
|
||||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||||
|
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
|
||||||
TOTAL_STEPS=13
|
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
|
CURRENT_STEP=0
|
||||||
|
|
||||||
ESC="\033"
|
ESC="\033"
|
||||||
@@ -156,7 +192,8 @@ fi
|
|||||||
|
|
||||||
step "Копируем web-панель в /opt/virtuality"
|
step "Копируем web-панель в /opt/virtuality"
|
||||||
run_logged "Создана директория /opt/virtuality" mkdir -p /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"
|
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
|
||||||
if [[ -f "$PROFILE_FILE" ]]; then
|
if [[ -f "$PROFILE_FILE" ]]; then
|
||||||
ok "Профиль уже сохранён: $PROFILE_FILE"
|
ok "Профиль уже сохранён: $PROFILE_FILE"
|
||||||
@@ -174,9 +211,20 @@ else
|
|||||||
chmod 600 "$SESSION_SECRET_FILE"
|
chmod 600 "$SESSION_SECRET_FILE"
|
||||||
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
|
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
|
||||||
fi
|
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
|
cat > "${APP_DIR}/.env" <<EOF
|
||||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||||
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
|
||||||
TMPDIR=${UPLOAD_TMP_DIR}
|
TMPDIR=${UPLOAD_TMP_DIR}
|
||||||
TEMP=${UPLOAD_TMP_DIR}
|
TEMP=${UPLOAD_TMP_DIR}
|
||||||
TMP=${UPLOAD_TMP_DIR}
|
TMP=${UPLOAD_TMP_DIR}
|
||||||
@@ -187,15 +235,24 @@ ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
|||||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||||
|
|
||||||
step "Создаём Python virtualenv"
|
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"
|
warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
|
||||||
else
|
else
|
||||||
|
if [[ -d "$VENV_DIR" ]]; then
|
||||||
|
warn "Virtualenv повреждён (например, после обновления Python), пересоздаём"
|
||||||
|
rm -rf -- "${VENV_DIR:?}"
|
||||||
|
fi
|
||||||
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
|
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Устанавливаем Python-зависимости"
|
step "Устанавливаем Python-зависимости"
|
||||||
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then
|
||||||
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
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"
|
step "Создаём systemd service"
|
||||||
cat > "$SERVICE_FILE" <<EOF
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
@@ -207,9 +264,11 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=${APP_DIR}
|
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
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
TimeoutStopSec=20
|
||||||
|
LimitNOFILE=65536
|
||||||
User=root
|
User=root
|
||||||
Group=root
|
Group=root
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
@@ -223,7 +282,24 @@ Environment=TMP=${UPLOAD_TMP_DIR}
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
ok "Создан service: $SERVICE_FILE"
|
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 "systemd daemon-reload выполнен" systemctl daemon-reload
|
||||||
|
run_logged "virtuality-network.service включён" systemctl enable virtuality-network.service
|
||||||
|
|
||||||
step "Настраиваем автообновление"
|
step "Настраиваем автообновление"
|
||||||
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
|
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
|
||||||
@@ -256,8 +332,13 @@ Unit=virtuality-auto-update.service
|
|||||||
WantedBy=timers.target
|
WantedBy=timers.target
|
||||||
EOF
|
EOF
|
||||||
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
|
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
|
||||||
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
|
if [[ "$AUTO_UPDATE" == "1" ]]; then
|
||||||
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
|
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
|
else
|
||||||
warn "auto_update_check.sh не найден, автообновление пропущено"
|
warn "auto_update_check.sh не найден, автообновление пропущено"
|
||||||
fi
|
fi
|
||||||
@@ -298,7 +379,8 @@ echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-польз
|
|||||||
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
|
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
|
||||||
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
|
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
|
||||||
echo -e "${BOLD}Service:${RESET} virtuality-web.service"
|
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}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}"
|
||||||
echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}"
|
echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}"
|
||||||
echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager"
|
echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager"
|
||||||
|
|||||||
+92
-27
@@ -1,12 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import asyncio
|
import asyncio
|
||||||
import crypt
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import spwd
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -17,14 +15,16 @@ import zipfile
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer
|
||||||
|
|
||||||
|
import auth
|
||||||
import host_profile
|
import host_profile
|
||||||
import network_core
|
import network_core
|
||||||
import update_core
|
import update_core
|
||||||
@@ -49,6 +49,22 @@ if NOVNC_DIR:
|
|||||||
app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")
|
app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")
|
||||||
OP_LOCK = threading.Lock()
|
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]:
|
def load_env() -> dict[str, str]:
|
||||||
data: dict[str, str] = {}
|
data: dict[str, str] = {}
|
||||||
@@ -62,9 +78,12 @@ def load_env() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
CONFIG = load_env()
|
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"))
|
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")
|
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:
|
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 False
|
||||||
try:
|
return auth.verify_password(username, password)
|
||||||
shadow = spwd.getspnam(username)
|
|
||||||
except (PermissionError, KeyError):
|
|
||||||
return False
|
def read_version() -> str:
|
||||||
stored_hash = shadow.sp_pwdp
|
for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"):
|
||||||
if stored_hash in ("!", "*", "!!", ""):
|
try:
|
||||||
return False
|
return path.read_text().strip() or "unknown"
|
||||||
return secrets.compare_digest(crypt.crypt(password, stored_hash), stored_hash)
|
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:
|
def user_from_session_token(token: str | None) -> str | None:
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = serializer.loads(token)
|
data = serializer.loads(token, max_age=SESSION_MAX_AGE)
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
return None
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
return AUTH_USER if data.get("user") == AUTH_USER else 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]:
|
def vm_details(name: str) -> dict[str, Any]:
|
||||||
|
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]
|
||||||
|
autostart = vm_autostart_status(name)
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
|
"dominfo": dominfo,
|
||||||
"vnc": vm_vnc_display(name),
|
"vnc": vm_vnc_display(name),
|
||||||
"ip": vm_ip(name),
|
"ip": vm_ip(name),
|
||||||
"disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],
|
"disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],
|
||||||
"interfaces": run_cmd(["virsh", "domiflist", name], 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(...)):
|
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||||
if not is_configured():
|
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)
|
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):
|
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)
|
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})
|
token = serializer.dumps({"user": AUTH_USER})
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -1819,8 +1877,8 @@ def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...),
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
|
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
|
||||||
if ok:
|
if ok:
|
||||||
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "resource_message", message)
|
||||||
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "resource_error", message)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/boot-order")
|
@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
|
return auth_redirect
|
||||||
ok, message = apply_vm_boot_order(name, boot_order)
|
ok, message = apply_vm_boot_order(name, boot_order)
|
||||||
if ok:
|
if ok:
|
||||||
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "boot_message", message)
|
||||||
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "boot_error", message)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/mount")
|
@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
|
return auth_redirect
|
||||||
ok, message = mount_vm_iso(name, iso_path)
|
ok, message = mount_vm_iso(name, iso_path)
|
||||||
if ok:
|
if ok:
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "iso_message", message)
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "iso_error", message)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/unmount")
|
@app.post("/vm/{name}/iso/unmount")
|
||||||
@@ -1852,8 +1910,8 @@ def vm_iso_unmount_apply(request: Request, name: str):
|
|||||||
return auth_redirect
|
return auth_redirect
|
||||||
ok, message = detach_vm_iso(name)
|
ok, message = detach_vm_iso(name)
|
||||||
if ok:
|
if ok:
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "iso_message", message)
|
||||||
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
|
return redirect_with_message(f"/vm/{name}", "iso_error", message)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/{action}")
|
@app.post("/vm/{name}/{action}")
|
||||||
@@ -1861,6 +1919,8 @@ def vm_action(request: Request, name: str, action: str):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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]}
|
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":
|
if action == "delete":
|
||||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
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)})
|
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")
|
@app.get("/api/health")
|
||||||
def api_health(request: Request):
|
def api_health(request: Request):
|
||||||
if not get_current_user(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 json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -219,5 +220,11 @@ def start_update() -> dict[str, Any]:
|
|||||||
'finished_at': '',
|
'finished_at': '',
|
||||||
}
|
}
|
||||||
write_json(STATE_FILE, data)
|
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
|
return data
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user