Безопасность панели была усилена

Проверка пароля переведена на 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
Этот коммит содержится в:
Claude
2026-08-15 19:19:33 +00:00
родитель 1da491c9e1
Коммит bb87f52834
17 изменённых файлов: 276 добавлений и 89 удалений
+27 -2
Просмотреть файл
@@ -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)
+1
Просмотреть файл
@@ -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
+3 -3
Просмотреть файл
@@ -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>
+8 -8
Просмотреть файл
@@ -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>
+3 -3
Просмотреть файл
@@ -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>
+2 -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>
@@ -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 %}
+4 -4
Просмотреть файл
@@ -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>
+1 -1
Просмотреть файл
@@ -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>
+1 -1
Просмотреть файл
@@ -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>
+7 -7
Просмотреть файл
@@ -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>
+1 -1
Просмотреть файл
@@ -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>
+1 -1
Просмотреть файл
@@ -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>
+3 -3
Просмотреть файл
@@ -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 %}
+2 -2
Просмотреть файл
@@ -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>
+2 -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>
@@ -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>
+17 -17
Просмотреть файл
@@ -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>