Безопасность панели была усилена
Проверка пароля переведена на PAM (python-pam) с fallback на crypt/spwd для совместимости, все POST-формы защищены CSRF-токеном в сессии, после пяти неудачных попыток входа включается пауза 60 секунд, установщик генерирует самоподписанный TLS-сертификат и панель по умолчанию работает по HTTPS (отключается VIRTUALITY_TLS=0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
@@ -18,6 +18,10 @@ 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"
|
||||||
|
TLS_DIR="${PROFILE_DIR}/tls"
|
||||||
|
TLS_CERT="${TLS_DIR}/virtuality.crt"
|
||||||
|
TLS_KEY="${TLS_DIR}/virtuality.key"
|
||||||
|
TLS_ENABLED="${VIRTUALITY_TLS:-1}"
|
||||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||||
TOTAL_STEPS=13
|
TOTAL_STEPS=13
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
@@ -183,6 +187,18 @@ TMP=${UPLOAD_TMP_DIR}
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 "${APP_DIR}/.env"
|
chmod 600 "${APP_DIR}/.env"
|
||||||
ok "Создан ${APP_DIR}/.env"
|
ok "Создан ${APP_DIR}/.env"
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
if [[ -s "$TLS_CERT" && -s "$TLS_KEY" ]]; then
|
||||||
|
ok "Используем существующий TLS-сертификат: $TLS_CERT"
|
||||||
|
else
|
||||||
|
mkdir -p "$TLS_DIR"
|
||||||
|
run_logged "Самоподписанный TLS-сертификат создан на 10 лет" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout "$TLS_KEY" -out "$TLS_CERT" -subj "/CN=virtuality" -addext "subjectAltName=DNS:virtuality,IP:127.0.0.1"
|
||||||
|
chmod 600 "$TLS_KEY"
|
||||||
|
fi
|
||||||
|
ok "Панель будет работать по HTTPS (отключить: VIRTUALITY_TLS=0)"
|
||||||
|
else
|
||||||
|
warn "TLS отключён через VIRTUALITY_TLS=0 — панель будет работать по HTTP"
|
||||||
|
fi
|
||||||
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
||||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||||
|
|
||||||
@@ -198,6 +214,10 @@ run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
|||||||
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
||||||
|
|
||||||
step "Создаём systemd service"
|
step "Создаём systemd service"
|
||||||
|
UVICORN_TLS_ARGS=""
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
UVICORN_TLS_ARGS=" --ssl-certfile ${TLS_CERT} --ssl-keyfile ${TLS_KEY}"
|
||||||
|
fi
|
||||||
cat > "$SERVICE_FILE" <<EOF
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Virtuality Web Panel
|
Description=Virtuality Web Panel
|
||||||
@@ -207,7 +227,7 @@ 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 0.0.0.0 --port ${PORT}${UVICORN_TLS_ARGS}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
User=root
|
User=root
|
||||||
@@ -287,13 +307,18 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||||
|
PANEL_SCHEME="http"
|
||||||
|
[[ "$TLS_ENABLED" == "1" ]] && PANEL_SCHEME="https"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${BOLD}URL:${RESET} http://${SERVER_IP}:${PORT}"
|
echo -e "${BOLD}URL:${RESET} ${PANEL_SCHEME}://${SERVER_IP}:${PORT}"
|
||||||
|
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||||
|
echo -e "${DIM}Сертификат самоподписанный — браузер один раз спросит подтверждение.${RESET}"
|
||||||
|
fi
|
||||||
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
|
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}"
|
||||||
|
|||||||
+193
-32
@@ -1,16 +1,28 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import asyncio
|
import asyncio
|
||||||
import crypt
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import spwd
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
# PAM — основной способ проверки пароля; crypt/spwd удалены в Python 3.13
|
||||||
|
# и остаются только как fallback для старых систем без python-pam.
|
||||||
|
try:
|
||||||
|
import pam as pam_module
|
||||||
|
except ImportError:
|
||||||
|
pam_module = None
|
||||||
|
try:
|
||||||
|
import crypt
|
||||||
|
import spwd
|
||||||
|
except ImportError:
|
||||||
|
crypt = None
|
||||||
|
spwd = None
|
||||||
import lzma
|
import lzma
|
||||||
import tarfile
|
import tarfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -42,7 +54,13 @@ DEFAULT_BRIDGE = "br0"
|
|||||||
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
|
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
|
||||||
|
|
||||||
app = FastAPI(title="Virtuality Panel")
|
app = FastAPI(title="Virtuality Panel")
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
||||||
|
|
||||||
|
def template_globals(request: Request) -> dict[str, Any]:
|
||||||
|
return {"csrf_token": get_csrf_token(request)}
|
||||||
|
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"), context_processors=[template_globals])
|
||||||
static_dir = BASE_DIR / "static"
|
static_dir = BASE_DIR / "static"
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
@@ -76,6 +94,13 @@ 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 not username or not password or username != AUTH_USER:
|
||||||
return False
|
return False
|
||||||
|
if pam_module is not None:
|
||||||
|
try:
|
||||||
|
return bool(pam_module.pam().authenticate(username, password, service="login"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if crypt is None or spwd is None:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
shadow = spwd.getspnam(username)
|
shadow = spwd.getspnam(username)
|
||||||
except (PermissionError, KeyError):
|
except (PermissionError, KeyError):
|
||||||
@@ -86,6 +111,36 @@ def verify_linux_password(username: str, password: str) -> bool:
|
|||||||
return secrets.compare_digest(crypt.crypt(password, stored_hash), stored_hash)
|
return secrets.compare_digest(crypt.crypt(password, stored_hash), stored_hash)
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_FAILURES: dict[str, dict[str, float]] = {}
|
||||||
|
LOGIN_FAILURES_LOCK = threading.Lock()
|
||||||
|
LOGIN_MAX_FAILURES = 5
|
||||||
|
LOGIN_BLOCK_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def login_block_remaining(client_ip: str) -> int:
|
||||||
|
with LOGIN_FAILURES_LOCK:
|
||||||
|
entry = LOGIN_FAILURES.get(client_ip)
|
||||||
|
if not entry or entry["count"] < LOGIN_MAX_FAILURES:
|
||||||
|
return 0
|
||||||
|
remaining = int(entry["last"] + LOGIN_BLOCK_SECONDS - time.time())
|
||||||
|
if remaining <= 0:
|
||||||
|
LOGIN_FAILURES.pop(client_ip, None)
|
||||||
|
return 0
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
|
def register_login_failure(client_ip: str) -> None:
|
||||||
|
with LOGIN_FAILURES_LOCK:
|
||||||
|
entry = LOGIN_FAILURES.setdefault(client_ip, {"count": 0, "last": 0.0})
|
||||||
|
entry["count"] += 1
|
||||||
|
entry["last"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_login_failures(client_ip: str) -> None:
|
||||||
|
with LOGIN_FAILURES_LOCK:
|
||||||
|
LOGIN_FAILURES.pop(client_ip, None)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -106,6 +161,25 @@ def require_auth(request: Request):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(request: Request) -> str:
|
||||||
|
token = request.cookies.get("virtuality_session")
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
data = serializer.loads(token)
|
||||||
|
except BadSignature:
|
||||||
|
return ""
|
||||||
|
return str(data.get("csrf", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def require_csrf(request: Request, csrf_token: str):
|
||||||
|
"""Проверка CSRF-токена для POST-форм. Возвращает 403-ответ или None."""
|
||||||
|
expected = get_csrf_token(request)
|
||||||
|
if expected and csrf_token and secrets.compare_digest(expected, csrf_token):
|
||||||
|
return None
|
||||||
|
return HTMLResponse("<h1>Сессия устарела</h1><p>Открой страницу заново и повтори действие.</p>", status_code=403)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
@@ -1310,16 +1384,25 @@ 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)
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
blocked_for = login_block_remaining(client_ip)
|
||||||
|
if blocked_for:
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Подожди {blocked_for} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429)
|
||||||
if not verify_linux_password(username, password):
|
if not verify_linux_password(username, password):
|
||||||
|
register_login_failure(client_ip)
|
||||||
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)
|
||||||
token = serializer.dumps({"user": AUTH_USER})
|
reset_login_failures(client_ip)
|
||||||
|
token = serializer.dumps({"user": AUTH_USER, "csrf": secrets.token_urlsafe(16)})
|
||||||
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", max_age=60 * 60 * 12, secure=request.url.scheme == "https")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
def logout():
|
def logout(request: Request, csrf_token: str = Form("")):
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
response = RedirectResponse(url="/login", status_code=303)
|
response = RedirectResponse(url="/login", status_code=303)
|
||||||
response.delete_cookie("virtuality_session")
|
response.delete_cookie("virtuality_session")
|
||||||
return response
|
return response
|
||||||
@@ -1344,10 +1427,13 @@ def host_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/host/refresh")
|
@app.post("/host/refresh")
|
||||||
def host_refresh(request: Request):
|
def host_refresh(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
profile = host_profile.detect_host_profile()
|
profile = host_profile.detect_host_profile()
|
||||||
host_profile.save_host_profile(profile)
|
host_profile.save_host_profile(profile)
|
||||||
return RedirectResponse(url="/host", status_code=303)
|
return RedirectResponse(url="/host", status_code=303)
|
||||||
@@ -1362,10 +1448,13 @@ def iso_page(request: Request, error: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/iso/upload", response_class=HTMLResponse)
|
@app.post("/iso/upload", response_class=HTMLResponse)
|
||||||
def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
def iso_upload(request: Request, iso_file: UploadFile = File(...), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
safe_name = safe_iso_filename(iso_file.filename or "")
|
safe_name = safe_iso_filename(iso_file.filename or "")
|
||||||
if not safe_name:
|
if not safe_name:
|
||||||
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
|
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
|
||||||
@@ -1386,10 +1475,13 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/iso/{name}/delete")
|
@app.post("/iso/{name}/delete")
|
||||||
def iso_delete(request: Request, name: str):
|
def iso_delete(request: Request, name: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
path = iso_path_by_name(name)
|
path = iso_path_by_name(name)
|
||||||
if path and path.exists() and path.is_file():
|
if path and path.exists() and path.is_file():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
@@ -1398,10 +1490,13 @@ def iso_delete(request: Request, name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/iso/refresh")
|
@app.post("/iso/refresh")
|
||||||
def iso_refresh(request: Request):
|
def iso_refresh(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
refresh_iso_pool()
|
refresh_iso_pool()
|
||||||
return RedirectResponse(url="/iso", status_code=303)
|
return RedirectResponse(url="/iso", status_code=303)
|
||||||
|
|
||||||
@@ -1415,10 +1510,13 @@ def disk_images_page(request: Request, error: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/disk-images/upload", response_class=HTMLResponse)
|
@app.post("/disk-images/upload", response_class=HTMLResponse)
|
||||||
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
def disk_image_upload(request: Request, image_file: UploadFile = File(...), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
safe_name = safe_disk_upload_filename(image_file.filename or "")
|
||||||
if not safe_name:
|
if not safe_name:
|
||||||
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
|
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
|
||||||
@@ -1462,10 +1560,13 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/disk-images/{name}/delete")
|
@app.post("/disk-images/{name}/delete")
|
||||||
def disk_image_delete(request: Request, name: str):
|
def disk_image_delete(request: Request, name: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
path = disk_image_path_by_name(name)
|
path = disk_image_path_by_name(name)
|
||||||
if path and path.exists() and path.is_file():
|
if path and path.exists() and path.is_file():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
@@ -1483,10 +1584,13 @@ def cloud_images_page(request: Request, error: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/cloud-images/download")
|
@app.post("/cloud-images/download")
|
||||||
def cloud_image_download(request: Request, key: str = Form(...)):
|
def cloud_image_download(request: Request, key: str = Form(...), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
entry = cloud_images.catalog_entry(key)
|
entry = cloud_images.catalog_entry(key)
|
||||||
if not entry:
|
if not entry:
|
||||||
return cloud_images_page(request, error="Неизвестный образ каталога.")
|
return cloud_images_page(request, error="Неизвестный образ каталога.")
|
||||||
@@ -1501,10 +1605,13 @@ def cloud_image_download(request: Request, key: str = Form(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/cloud-images/{name}/delete")
|
@app.post("/cloud-images/{name}/delete")
|
||||||
def cloud_image_delete(request: Request, name: str):
|
def cloud_image_delete(request: Request, name: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
path = cloud_images.image_path_by_name(name)
|
path = cloud_images.image_path_by_name(name)
|
||||||
if path and path.exists() and path.is_file():
|
if path and path.exists() and path.is_file():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
@@ -1526,10 +1633,13 @@ def vm_create_cloud_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/create-cloud", response_class=HTMLResponse)
|
@app.post("/vm/create-cloud", response_class=HTMLResponse)
|
||||||
def vm_create_cloud_submit(request: Request, name: str = Form(...), image_name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), username: str = Form("admin"), password: str = Form(""), ssh_key: str = Form(""), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
|
def vm_create_cloud_submit(request: Request, name: str = Form(...), image_name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), username: str = Form("admin"), password: str = Form(""), ssh_key: str = Form(""), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
form = {"name": name, "image_name": image_name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "username": username, "ssh_key": ssh_key, "network_mode": network_mode, "bridge": bridge}
|
form = {"name": name, "image_name": image_name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "username": username, "ssh_key": ssh_key, "network_mode": network_mode, "bridge": bridge}
|
||||||
image_path = cloud_images.image_path_by_name(image_name)
|
image_path = cloud_images.image_path_by_name(image_name)
|
||||||
error = None
|
error = None
|
||||||
@@ -1648,10 +1758,13 @@ def network_page(request: Request, error: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/vm-ip/save")
|
@app.post("/network/vm-ip/save")
|
||||||
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
|
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form(""), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
if not valid_vm_name(vm_name):
|
if not valid_vm_name(vm_name):
|
||||||
return RedirectResponse(url="/network", status_code=303)
|
return RedirectResponse(url="/network", status_code=303)
|
||||||
manual_ip = (manual_ip or "").strip()
|
manual_ip = (manual_ip or "").strip()
|
||||||
@@ -1676,10 +1789,13 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/nat/setup")
|
@app.post("/network/nat/setup")
|
||||||
def network_nat_setup(request: Request):
|
def network_nat_setup(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
network_core.create_nat_network()
|
network_core.create_nat_network()
|
||||||
network_core.apply_port_forwards()
|
network_core.apply_port_forwards()
|
||||||
@@ -1691,10 +1807,13 @@ def network_nat_setup(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/forward/add")
|
@app.post("/network/forward/add")
|
||||||
def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):
|
def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form(""), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
network_core.add_port_forward(vm_name, guest_ip, external_port, guest_port, protocol, note)
|
network_core.add_port_forward(vm_name, guest_ip, external_port, guest_port, protocol, note)
|
||||||
except NetworkError as exc:
|
except NetworkError as exc:
|
||||||
@@ -1703,10 +1822,13 @@ def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: st
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/forward/{forward_id}/delete")
|
@app.post("/network/forward/{forward_id}/delete")
|
||||||
def network_forward_delete(request: Request, forward_id: str):
|
def network_forward_delete(request: Request, forward_id: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
network_core.delete_port_forward(forward_id)
|
network_core.delete_port_forward(forward_id)
|
||||||
except NetworkError:
|
except NetworkError:
|
||||||
@@ -1715,10 +1837,13 @@ def network_forward_delete(request: Request, forward_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/apply")
|
@app.post("/network/apply")
|
||||||
def network_apply(request: Request):
|
def network_apply(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
network_core.apply_port_forwards()
|
network_core.apply_port_forwards()
|
||||||
except NetworkError as exc:
|
except NetworkError as exc:
|
||||||
@@ -1727,10 +1852,13 @@ def network_apply(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/network/diagnose", response_class=HTMLResponse)
|
@app.post("/network/diagnose", response_class=HTMLResponse)
|
||||||
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")):
|
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
diagnostics = None
|
diagnostics = None
|
||||||
error = None
|
error = None
|
||||||
try:
|
try:
|
||||||
@@ -1793,10 +1921,13 @@ def update_status(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/update/check")
|
@app.post("/update/check")
|
||||||
def update_check(request: Request):
|
def update_check(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
update_core.check_updates(fetch=True)
|
update_core.check_updates(fetch=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1805,10 +1936,13 @@ def update_check(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/update/apply")
|
@app.post("/update/apply")
|
||||||
def update_apply(request: Request):
|
def update_apply(request: Request, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
try:
|
try:
|
||||||
update_core.start_update()
|
update_core.start_update()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1883,10 +2017,13 @@ def vm_create_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/create", response_class=HTMLResponse)
|
@app.post("/vm/create", response_class=HTMLResponse)
|
||||||
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
|
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge}
|
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge}
|
||||||
error = None
|
error = None
|
||||||
if not valid_vm_name(name):
|
if not valid_vm_name(name):
|
||||||
@@ -2025,10 +2162,13 @@ def vm_detail_page(request: Request, name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/resources")
|
@app.post("/vm/{name}/resources")
|
||||||
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")):
|
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep"), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
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 RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
|
||||||
@@ -2036,10 +2176,13 @@ def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...),
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/boot-order")
|
@app.post("/vm/{name}/boot-order")
|
||||||
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
|
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto"), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
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 RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
|
||||||
@@ -2047,10 +2190,13 @@ def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("aut
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/mount")
|
@app.post("/vm/{name}/iso/mount")
|
||||||
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
|
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
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 RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
||||||
@@ -2058,10 +2204,13 @@ def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/iso/unmount")
|
@app.post("/vm/{name}/iso/unmount")
|
||||||
def vm_iso_unmount_apply(request: Request, name: str):
|
def vm_iso_unmount_apply(request: Request, name: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
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 RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
|
||||||
@@ -2069,10 +2218,13 @@ def vm_iso_unmount_apply(request: Request, name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/snapshot/{snap_action}")
|
@app.post("/vm/{name}/snapshot/{snap_action}")
|
||||||
def vm_snapshot_apply(request: Request, name: str, snap_action: str, snapshot_name: str = Form(...), description: str = Form("")):
|
def vm_snapshot_apply(request: Request, name: str, snap_action: str, snapshot_name: str = Form(...), description: str = Form(""), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
if snap_action not in ("create", "revert", "delete"):
|
if snap_action not in ("create", "revert", "delete"):
|
||||||
@@ -2083,10 +2235,13 @@ def vm_snapshot_apply(request: Request, name: str, snap_action: str, snapshot_na
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/clone")
|
@app.post("/vm/{name}/clone")
|
||||||
def vm_clone(request: Request, name: str, new_name: str = Form(...)):
|
def vm_clone(request: Request, name: str, new_name: str = Form(...), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
if not valid_vm_name(new_name):
|
if not valid_vm_name(new_name):
|
||||||
@@ -2103,10 +2258,13 @@ def vm_clone(request: Request, name: str, new_name: str = Form(...)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/template")
|
@app.post("/vm/{name}/template")
|
||||||
def vm_template_toggle(request: Request, name: str, enabled: str = Form("0")):
|
def vm_template_toggle(request: Request, name: str, enabled: str = Form("0"), csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
set_vm_template(name, enabled == "1")
|
set_vm_template(name, enabled == "1")
|
||||||
@@ -2115,10 +2273,13 @@ def vm_template_toggle(request: Request, name: str, enabled: str = Form("0")):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/{action}")
|
@app.post("/vm/{name}/{action}")
|
||||||
def vm_action(request: Request, name: str, action: str):
|
def vm_action(request: Request, name: str, action: str, csrf_token: str = Form("")):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return auth_redirect
|
return auth_redirect
|
||||||
|
csrf_fail = require_csrf(request, csrf_token)
|
||||||
|
if csrf_fail:
|
||||||
|
return csrf_fail
|
||||||
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)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0
|
|||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
python-pam==2.0.2
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<td><span class="status {{ 'ok' if entry.native else 'warn' }}">{{ entry.arch }}{% if entry.native %} · родная{% endif %}</span></td>
|
<td><span class="status {{ 'ok' if entry.native else 'warn' }}">{{ entry.arch }}{% if entry.native %} · родная{% endif %}</span></td>
|
||||||
<td>{% if entry.downloaded %}<span class="status ok">скачан</span>{% else %}<span class="muted">не скачан</span>{% endif %}</td>
|
<td>{% if entry.downloaded %}<span class="status ok">скачан</span>{% else %}<span class="muted">не скачан</span>{% endif %}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/cloud-images/download">
|
<form method="post" action="/cloud-images/download"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="key" value="{{ entry.key }}">
|
<input type="hidden" name="key" value="{{ entry.key }}">
|
||||||
<button class="{{ 'ghost' if entry.downloaded else 'primary' }}">{{ 'Скачать заново' if entry.downloaded else 'Скачать' }}</button>
|
<button class="{{ 'ghost' if entry.downloaded else 'primary' }}">{{ 'Скачать заново' if entry.downloaded else 'Скачать' }}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<td>{{ image.updated }}</td>
|
<td>{{ image.updated }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a class="link-pill" href="/vm/create-cloud">Создать VM</a>
|
<a class="link-pill" href="/vm/create-cloud">Создать VM</a>
|
||||||
<form method="post" action="/cloud-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
|
<form method="post" action="/cloud-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -76,11 +76,11 @@
|
|||||||
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
|
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
|
||||||
<td class="autostart-cell">
|
<td class="autostart-cell">
|
||||||
{% if vm.autostart_enabled %}
|
{% if vm.autostart_enabled %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
|
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
|
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -90,12 +90,12 @@
|
|||||||
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
|
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
|
||||||
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
|
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<h2>Загрузить образ установленной системы</h2>
|
<h2>Загрузить образ установленной системы</h2>
|
||||||
<span class="pill">.img .raw .qcow2 .img.xz .zip .tar.gz</span>
|
<span class="pill">.img .raw .qcow2 .img.xz .zip .tar.gz</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid">
|
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label class="upload-box">
|
<label class="upload-box">
|
||||||
<span>Выбери файл готового диска или архив с диском</span>
|
<span>Выбери файл готового диска или архив с диском</span>
|
||||||
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,.img.xz,.zip,.tar.gz,.tgz" required>
|
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,.img.xz,.zip,.tar.gz,.tgz" required>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<td>{{ image.updated }}</td>
|
<td>{{ image.updated }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a class="link-pill" href="/vm/create">Создать VM</a>
|
<a class="link-pill" href="/vm/create">Создать VM</a>
|
||||||
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
|
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Проверки</h2>
|
<h2>Проверки</h2>
|
||||||
<form method="post" action="/host/refresh"><button>Переопределить профиль</button></form>
|
<form method="post" action="/host/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Переопределить профиль</button></form>
|
||||||
</div>
|
</div>
|
||||||
<div class="services">
|
<div class="services">
|
||||||
{% for check in profile.checks %}
|
{% for check in profile.checks %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<h2>Загрузить установочный ISO</h2>
|
<h2>Загрузить установочный ISO</h2>
|
||||||
<span class="pill">только .iso</span>
|
<span class="pill">только .iso</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid">
|
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label class="upload-box">
|
<label class="upload-box">
|
||||||
<span>Выбери ISO-файл установочного диска</span>
|
<span>Выбери ISO-файл установочного диска</span>
|
||||||
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
|
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="pill">pool</span>
|
<span class="pill">pool</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions big-actions">
|
<div class="actions big-actions">
|
||||||
<form method="post" action="/iso/refresh"><button>Обновить ISO pool</button></form>
|
<form method="post" action="/iso/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Обновить ISO pool</button></form>
|
||||||
<a class="link-pill accent" href="/vm/create">Создать VM</a>
|
<a class="link-pill accent" href="/vm/create">Создать VM</a>
|
||||||
</div>
|
</div>
|
||||||
<pre>sudo virsh pool-refresh virtuality-iso
|
<pre>sudo virsh pool-refresh virtuality-iso
|
||||||
@@ -86,7 +86,7 @@ ls -lh /var/lib/virtuality/iso</pre>
|
|||||||
<td>{{ iso.size }}</td>
|
<td>{{ iso.size }}</td>
|
||||||
<td>{{ iso.updated }}</td>
|
<td>{{ iso.updated }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');">
|
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="alert danger">{{ error }}</div>
|
<div class="alert danger">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/login" class="auth-form">
|
<form method="post" action="/login" class="auth-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Linux-пользователь</span>
|
<span>Linux-пользователь</span>
|
||||||
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
|
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
|
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
|
||||||
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
|
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/nat/setup" class="inline-form top-space">
|
<form method="post" action="/network/nat/setup" class="inline-form top-space"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary">Создать / починить NAT-сеть</button>
|
<button class="primary">Создать / починить NAT-сеть</button>
|
||||||
</form>
|
</form>
|
||||||
<pre>{{ ctx.nat.info }}</pre>
|
<pre>{{ ctx.nat.info }}</pre>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<h2>Добавить проброс порта</h2>
|
<h2>Добавить проброс порта</h2>
|
||||||
<span class="pill">DNAT auto/manual IP · ranges</span>
|
<span class="pill">DNAT auto/manual IP · ranges</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/forward/add" class="form-grid">
|
<form method="post" action="/network/forward/add" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Описание</span>
|
<span>Описание</span>
|
||||||
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
|
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<h2>Диагностика публичного доступа</h2>
|
<h2>Диагностика публичного доступа</h2>
|
||||||
<span class="pill">VM → Internet</span>
|
<span class="pill">VM → Internet</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/network/diagnose" class="form-grid">
|
<form method="post" action="/network/diagnose" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Виртуальная машина</span>
|
<span>Виртуальная машина</span>
|
||||||
<select name="vm_name" required>
|
<select name="vm_name" required>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Пробросы портов</h2>
|
<h2>Пробросы портов</h2>
|
||||||
<form method="post" action="/network/apply"><button>Применить правила</button></form>
|
<form method="post" action="/network/apply"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Применить правила</button></form>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -239,14 +239,14 @@
|
|||||||
<td>{{ item.note }}</td>
|
<td>{{ item.note }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="/network/diagnose">
|
<form method="post" action="/network/diagnose"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
|
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
|
||||||
<input type="hidden" name="external_port" value="{{ item.external_port }}">
|
<input type="hidden" name="external_port" value="{{ item.external_port }}">
|
||||||
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
|
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
|
||||||
<input type="hidden" name="protocol" value="{{ item.protocol }}">
|
<input type="hidden" name="protocol" value="{{ item.protocol }}">
|
||||||
<button>Диагностика</button>
|
<button>Диагностика</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/network/forward/{{ item.id }}/delete"><button class="danger">Удалить</button></form>
|
<form method="post" action="/network/forward/{{ item.id }}/delete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger">Удалить</button></form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/operations">Операции</a>
|
<a class="link-pill" href="/operations">Операции</a>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -82,11 +82,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions top-space update-actions">
|
<div class="actions top-space update-actions">
|
||||||
{% if info.has_update %}
|
{% if info.has_update %}
|
||||||
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');">
|
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
|
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/update/check" id="update-check-form">
|
<form method="post" action="/update/check" id="update-check-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
|
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<span class="pill">VM wizard</span>
|
<span class="pill">VM wizard</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/vm/create" class="form-grid">
|
<form method="post" action="/vm/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Имя VM</span>
|
<span>Имя VM</span>
|
||||||
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="top-meta">
|
<div class="top-meta">
|
||||||
<span>user: {{ user }}</span>
|
<span>user: {{ user }}</span>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<span class="pill">cloud-init</span>
|
<span class="pill">cloud-init</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/vm/create-cloud" class="form-grid">
|
<form method="post" action="/vm/create-cloud" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Имя VM</span>
|
<span>Имя VM</span>
|
||||||
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-web" required>
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-web" required>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
|
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
|
||||||
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
|
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
|
||||||
<a class="link-pill" href="/">← Дашборд</a>
|
<a class="link-pill" href="/">← Дашборд</a>
|
||||||
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
|
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -93,10 +93,10 @@
|
|||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span>▣</span><em>Консоль</em></a>
|
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span>▣</span><em>Консоль</em></a>
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,11 +104,11 @@
|
|||||||
<div class="label">Автозапуск</div>
|
<div class="label">Автозапуск</div>
|
||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
{% if is_autostart %}
|
{% if is_autostart %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span>●</span><strong>Включён</strong><small>Нажми для отключения</small></button>
|
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span>●</span><strong>Включён</strong><small>Нажми для отключения</small></button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form">
|
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span>●</span><strong>Выключен</strong><small>Нажми для включения</small></button>
|
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span>●</span><strong>Выключен</strong><small>Нажми для включения</small></button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -119,10 +119,10 @@
|
|||||||
<div class="label">Опасные действия</div>
|
<div class="label">Опасные действия</div>
|
||||||
<div class="vm-actions compact-actions big-actions">
|
<div class="vm-actions compact-actions big-actions">
|
||||||
{% if is_running %}
|
{% if is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power off</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power off</em></button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not is_running %}
|
{% if not is_running %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
|
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted small-note">Удаление доступно после выключения VM.</span>
|
<span class="muted small-note">Удаление доступно после выключения VM.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<h2>CPU / RAM / Архитектура</h2>
|
<h2>CPU / RAM / Архитектура</h2>
|
||||||
<span class="pill">resources</span>
|
<span class="pill">resources</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
<h2>Порядок загрузки</h2>
|
<h2>Порядок загрузки</h2>
|
||||||
<span class="pill">boot</span>
|
<span class="pill">boot</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
|
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
|
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
|
||||||
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
|
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
|
||||||
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
|
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="muted small-note">ISO сейчас не подключён.</div>
|
<div class="muted small-note">ISO сейчас не подключён.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>ISO образ</span>
|
<span>ISO образ</span>
|
||||||
<select name="iso_path" required>
|
<select name="iso_path" required>
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
|
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
|
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
|
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
|
||||||
</form>
|
</form>
|
||||||
{% if not isos %}
|
{% if not isos %}
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
<h2>Снапшоты</h2>
|
<h2>Снапшоты</h2>
|
||||||
<span class="pill">{{ snapshots|length }} шт.</span>
|
<span class="pill">{{ snapshots|length }} шт.</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/snapshot/create" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
<span>Имя снапшота</span>
|
<span>Имя снапшота</span>
|
||||||
@@ -278,11 +278,11 @@
|
|||||||
<td>{{ snap.created }}</td>
|
<td>{{ snap.created }}</td>
|
||||||
<td><span class="status {{ snap.state_css }}">{{ snap.state }}</span></td>
|
<td><span class="status {{ snap.state_css }}">{{ snap.state }}</span></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/vm/{{ vm.name }}/snapshot/revert" onsubmit="return confirm('Откатить VM {{ vm.name }} к снапшоту {{ snap.name }}? Текущее состояние диска будет потеряно.');">
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/revert" onsubmit="return confirm('Откатить VM {{ vm.name }} к снапшоту {{ snap.name }}? Текущее состояние диска будет потеряно.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
||||||
<button class="ghost">Откатиться</button>
|
<button class="ghost">Откатиться</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/snapshot/delete" onsubmit="return confirm('Удалить снапшот {{ snap.name }}?');">
|
<form method="post" action="/vm/{{ vm.name }}/snapshot/delete" onsubmit="return confirm('Удалить снапшот {{ snap.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
|
||||||
<button class="danger">Удалить</button>
|
<button class="danger">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
<h2>Клонирование</h2>
|
<h2>Клонирование</h2>
|
||||||
<span class="pill">virt-clone</span>
|
<span class="pill">virt-clone</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/clone" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/clone" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>
|
<label>
|
||||||
<span>Имя новой VM</span>
|
<span>Имя новой VM</span>
|
||||||
<input type="text" name="new_name" placeholder="{{ vm.name }}-copy" required>
|
<input type="text" name="new_name" placeholder="{{ vm.name }}-copy" required>
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
<h2>Шаблон</h2>
|
<h2>Шаблон</h2>
|
||||||
<span class="pill">{{ 'template' if is_template else 'обычная VM' }}</span>
|
<span class="pill">{{ 'template' if is_template else 'обычная VM' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/template" class="form-grid">
|
<form method="post" action="/vm/{{ vm.name }}/template" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="enabled" value="{{ '0' if is_template else '1' }}">
|
<input type="hidden" name="enabled" value="{{ '0' if is_template else '1' }}">
|
||||||
<button class="{{ 'ghost' if is_template else 'primary' }} wide">{{ 'Снять флаг шаблона' if is_template else 'Пометить как шаблон' }}</button>
|
<button class="{{ 'ghost' if is_template else 'primary' }} wide">{{ 'Снять флаг шаблона' if is_template else 'Пометить как шаблон' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user