Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security - Enforce guest ownership and server assignment across Proxmox endpoints (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy, LXC/ISO creation, golden template) and WebSocket channels - Keep the panel's PVE root ticket server-side for consoles; single-use WebSocket tickets instead of the JWT in URLs; re-check access on open WebSocket bridges - Validate identifiers that reach Proxmox API paths and node shell commands; reject backup archives / foreign volumes in create requests - Close privilege escalation in user management, TOTP replay, reset-link poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos) - SSH host key pinning (TOFU), encrypted 2FA seeds and notification secrets, password changes end other sessions, redact secrets in logs - Quotas count in-flight creations; 2FA failures count toward lockout Fixes - Consoles on non-default API ports, large App Store/script files, interrupted tasks after restart, impersonation lost on token refresh, HTTPExceptions swallowed into 500s, WebSocket client reconnects Performance and process - Fewer DB writes and queries on hot paths, lazy-loaded locales and heavy pages, off-loop blocking work, indexes (migrations 44-46) - Deploy waits for backend tests and frontend build; frontend checks workflow; API bound to loopback without the SSL profile
191 lines
8.8 KiB
Python
191 lines
8.8 KiB
Python
"""
|
||
Запуск скриптов и журнал выполнений.
|
||
|
||
POST /api/scripts/{id}/execute — запустить скрипт
|
||
GET /api/scripts/executions — история запусков текущего пользователя (админ — все)
|
||
GET /api/scripts/executions/{id} — детали запуска (вывод, exit code)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from loguru import logger
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session, defer
|
||
|
||
from ...auth import PermissionChecker, check_permission
|
||
from ...db import SessionLocal, get_db
|
||
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
|
||
from ...services import script_engine
|
||
from ...config import utcnow
|
||
|
||
router = APIRouter()
|
||
|
||
_MAX_OUTPUT = 200_000 # см. SCRIPT_EXECUTION_OUTPUT_LIMIT в models/scripts.py
|
||
|
||
|
||
def _run_execution_bg(execution_id: int, server_id: int, target_type: str,
|
||
node: Optional[str], vmid: Optional[int],
|
||
vm_type: Optional[str], rendered: str,
|
||
interpreter: str, timeout: int) -> None:
|
||
"""Фоновое выполнение скрипта: community-скрипты (build.func) идут минуты и
|
||
не помещаются в таймаут прокси/HTTP-запроса. Эндпоинт возвращает execution
|
||
со статусом `running` сразу, а результат дописывается сюда; фронтенд поллит
|
||
статус до завершения. Своя DB-сессия — сессия запроса уже закрыта.
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
server = db.get(ProxmoxServer, server_id)
|
||
execution = db.get(ScriptExecution, execution_id)
|
||
if not server or not execution:
|
||
logger.error(f"[scripts] bg exec {execution_id}: server/execution пропал")
|
||
return
|
||
try:
|
||
result = script_engine.execute(
|
||
server, target_type=target_type, node=node, vmid=vmid,
|
||
vm_type=vm_type, script=rendered, interpreter=interpreter,
|
||
timeout=timeout,
|
||
)
|
||
execution.status = "success" if result.success else "failed"
|
||
execution.exit_code = result.exit_code
|
||
execution.output = (result.output or "")[:_MAX_OUTPUT]
|
||
execution.error_text = result.error
|
||
except Exception as e: # защита фонового потока: не оставляем running навсегда
|
||
logger.exception(f"[scripts] bg exec {execution_id} failed: {e}")
|
||
execution.status = "failed"
|
||
execution.exit_code = -1
|
||
execution.error_text = str(e)
|
||
execution.finished_at = utcnow()
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
class ExecuteRequest(BaseModel):
|
||
server_id: int
|
||
# Goes into Proxmox API paths (guest agent): plain node names only.
|
||
node: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
|
||
vmid: Optional[int] = None
|
||
vm_type: Optional[str] = Field(default=None, pattern="^(qemu|lxc)$")
|
||
params: dict = {}
|
||
# Верхняя граница поднята под долгие community-скрипты (build.func ставит
|
||
# Java/OpenSearch/MongoDB и т.п. — это минуты). Выполнение фоновое, так что
|
||
# длинный таймаут не держит HTTP-запрос. 0 → взять дефолт по типу скрипта.
|
||
timeout: int = Field(default=0, ge=0, le=3600)
|
||
|
||
|
||
# Дефолтные таймауты выполнения по источнику скрипта (сек), когда клиент не
|
||
# задал timeout явно (req.timeout == 0). Community-скрипты создают LXC и тянут
|
||
# образы — им нужен большой запас; обычные скрипты остаются короткими.
|
||
_DEFAULT_TIMEOUT = 60
|
||
_COMMUNITY_TIMEOUT = 1800
|
||
|
||
|
||
def _can_admin(user: User) -> bool:
|
||
return bool(getattr(user, "is_admin", False)) or user.has_permission("script:manage")
|
||
|
||
|
||
@router.post("/api/scripts/{script_id}/execute")
|
||
def execute_script(
|
||
script_id: int,
|
||
req: ExecuteRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:execute")),
|
||
):
|
||
from ..proxmox._helpers import get_visible_server_ids, require_vm_access
|
||
|
||
script = db.get(ScriptCatalog, script_id)
|
||
if not script:
|
||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||
|
||
server = db.get(ProxmoxServer, req.server_id)
|
||
visible = get_visible_server_ids(request, db, current_user) if server else None
|
||
if not server or (visible is not None and server.id not in visible):
|
||
raise HTTPException(status_code=404, detail="Сервер не найден")
|
||
|
||
if script.target_type == "guest" and (req.vmid is None or not req.node or not req.vm_type):
|
||
raise HTTPException(status_code=400, detail="Для guest-скрипта нужны node, vmid и vm_type")
|
||
|
||
# script:execute alone used to allow running anything anywhere: a tenant
|
||
# could target other users' guests or get a root shell on the host.
|
||
if script.target_type == "node":
|
||
if not check_permission(current_user, "node:manage"):
|
||
raise HTTPException(status_code=403, detail="Для запуска на ноде нужно право node:manage")
|
||
else:
|
||
require_vm_access(db, current_user, server.id, req.vmid)
|
||
|
||
# Валидация обязательных параметров по схеме скрипта.
|
||
schema = script.params_schema or []
|
||
params = dict(req.params or {})
|
||
for field in schema:
|
||
name = field.get("name")
|
||
if not name:
|
||
continue
|
||
if name not in params or params[name] in (None, ""):
|
||
if field.get("required"):
|
||
raise HTTPException(status_code=400, detail=f"Параметр '{name}' обязателен")
|
||
if field.get("default") is not None:
|
||
params[name] = field["default"]
|
||
params = {k: str(v) for k, v in params.items() if k in {f.get("name") for f in schema}}
|
||
|
||
execution = ScriptExecution(
|
||
script_id=script.id, script_name=script.name,
|
||
target_type=script.target_type, server_id=server.id,
|
||
node=req.node, vmid=req.vmid, params_used=params,
|
||
status="running", user_id=current_user.id,
|
||
)
|
||
db.add(execution)
|
||
db.commit()
|
||
db.refresh(execution)
|
||
|
||
rendered = script_engine.render_params(script.content, params)
|
||
|
||
# Эффективный таймаут: явное значение клиента, иначе дефолт по источнику
|
||
# (community-скрипты идут минуты — см. _COMMUNITY_TIMEOUT).
|
||
is_community = getattr(script, "source", None) == "community-scripts"
|
||
timeout = req.timeout or (_COMMUNITY_TIMEOUT if is_community else _DEFAULT_TIMEOUT)
|
||
|
||
# Выполнение — в фоновом потоке: долгие community-скрипты (build.func) не
|
||
# укладываются в таймаут HTTP/прокси (nginx рвёт на 60с → 504). Возвращаем
|
||
# execution со статусом `running`, фронтенд поллит его до завершения.
|
||
threading.Thread(
|
||
target=_run_execution_bg,
|
||
args=(execution.id, server.id, script.target_type, req.node, req.vmid,
|
||
req.vm_type, rendered, script.interpreter, timeout),
|
||
daemon=True,
|
||
).start()
|
||
|
||
return execution.to_dict()
|
||
|
||
|
||
@router.get("/api/scripts/executions")
|
||
def list_executions(
|
||
limit: int = 50,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:view")),
|
||
):
|
||
# Output (up to 200 KB per run) is only needed by the detail endpoint.
|
||
q = db.query(ScriptExecution).options(defer(ScriptExecution.output))
|
||
if not _can_admin(current_user):
|
||
q = q.filter(ScriptExecution.user_id == current_user.id)
|
||
items = q.order_by(ScriptExecution.started_at.desc()).limit(max(1, min(limit, 200))).all()
|
||
return {"items": [e.to_dict(with_output=False) for e in items]}
|
||
|
||
|
||
@router.get("/api/scripts/executions/{execution_id}")
|
||
def get_execution(
|
||
execution_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:view")),
|
||
):
|
||
execution = db.get(ScriptExecution, execution_id)
|
||
if not execution:
|
||
raise HTTPException(status_code=404, detail="Запуск не найден")
|
||
if not _can_admin(current_user) and execution.user_id != current_user.id:
|
||
raise HTTPException(status_code=403, detail="Нет доступа к этому запуску")
|
||
return execution.to_dict()
|