Files
pvemanager/backend/app/services/script_engine.py
markmorado 09186e23b9
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 audit: tenant isolation, injection fixes, hardening and performance
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
2026-10-23 00:04:16 +05:00

224 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Движок выполнения скриптов из каталога (backend/app/models/scripts.py).
Переиспользует существующую инфраструктуру:
- SSHClient (root@node) — выполнение на ноде и `pct exec` для LXC (тот же
паттерн, что и backend/app/appstore/pipeline.py: base64 -> файл -> exec,
без конкатенации пользовательского ввода в shell-команду).
- ProxmoxClient.execute_script() — выполнение на VM через QEMU guest agent
(backend/app/proxmox/client.py), уже используется в
POST /api/{server_id}/vm/{vmid}/execute-script.
Безопасность параметров: значения параметров НЕ подставляются в тело скрипта
текстово. Вместо этого перед скриптом добавляется блок `export VAR='...'` с
экранированием одинарных кавычек, скрипт читает их как обычные переменные
окружения — тот же принцип, что и .env в appstore pipeline.
"""
from __future__ import annotations
import re
import secrets
import shlex
from dataclasses import dataclass
from typing import Dict, Optional
from loguru import logger
from ..models import ProxmoxServer
from ..proxmox import ProxmoxClient
from ..ssh_client import SSHClient, write_file_commands
_CMD_TIMEOUT = 60
# Community-скрипты (build.func и т.п.) вызывают `clear` и печатают цветной
# вывод, которым нужен установленный TERM. Панель запускает скрипт без PTY
# (get_pty=False), поэтому TERM не наследуется — и `clear` падает с
# "TERM environment variable not set", обрывая весь скрипт. Задаём безопасный
# дефолт префиксом команды (надёжнее env paramiko: sshd AcceptEnv его режет).
_SCRIPT_ENV = "TERM=xterm"
class ScriptExecError(RuntimeError):
"""Ошибка выполнения скрипта с человекочитаемым сообщением."""
@dataclass
class ExecResult:
success: bool
output: str
exit_code: int
error: Optional[str] = None
def _shell_quote(value: str) -> str:
"""Обернуть значение в одинарные кавычки, безопасно для shell."""
return "'" + str(value).replace("'", "'\\''") + "'"
_PARAM_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
_INTERPRETER_RE = re.compile(r"^/[A-Za-z0-9_./+-]{1,99}$")
def is_valid_param_name(name: str) -> bool:
return bool(_PARAM_NAME_RE.match(name or ""))
def is_valid_interpreter(path: str) -> bool:
return bool(_INTERPRETER_RE.match(path or "")) and ".." not in path
def _tmp_name(prefix: str) -> str:
# Unpredictable name: a guest user cannot pre-plant a symlink or swap the
# file between push and exec, and parallel runs do not collide.
return f"/tmp/{prefix}_{secrets.token_hex(8)}"
def render_params(content: str, params: Dict[str, str]) -> str:
"""Добавить блок export-переменных перед телом скрипта.
Скрипт обращается к параметрам как к обычным переменным окружения
($NAME), значения никогда не конкатенируются в исполняемые команды.
"""
if not params:
return content
lines = ["#!/bin/sh", "# --- параметры, подставленные каталогом скриптов ---"]
for name, value in params.items():
# Имена приходят и из метаданных git-репозиториев — строка вида
# `X;cmd;Y` стала бы командой. Невалидные имена отбрасываем.
if not is_valid_param_name(name):
logger.warning(f"[script_engine] skipping invalid parameter name {name!r}")
continue
lines.append(f"export {name}={_shell_quote('' if value is None else value)}")
lines.append("# --- конец параметров ---")
return "\n".join(lines) + "\n" + content
def _node_ssh(server: ProxmoxServer, client: ProxmoxClient) -> SSHClient:
ssh_host = (server.ip_address or server.hostname or "").split(":")[0]
return SSHClient(
hostname=ssh_host,
username="root",
password=getattr(client, "_password", None),
timeout=30,
)
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
output, exit_code = ssh.execute(cmd, return_exit_code=True, timeout=timeout)
if exit_code == -1:
raise ScriptExecError(f"{what}: таймаут (>{timeout}с) или обрыв SSH")
if exit_code != 0:
raise ScriptExecError(f"{what}: exit={exit_code}: {(output or '').strip()[:2000]}")
return output or ""
def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт на хосте Proxmox (root@node)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=ssh.last_error or f"Не удалось подключиться по SSH к {server.name}")
if not is_valid_interpreter(interpreter):
return ExecResult(success=False, output="", exit_code=-1,
error=f"Недопустимый интерпретатор: {interpreter!r}")
tmp = _tmp_name("pve_script")
try:
for cmd in write_file_commands(tmp, script.encode("utf-8")):
_run(ssh, cmd, what="write script on node")
_run(ssh, f"chmod 700 {tmp}", what="write script on node")
output, exit_code = ssh.execute(f"{_SCRIPT_ENV} {shlex.quote(interpreter)} {tmp}",
return_exit_code=True, timeout=timeout)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
finally:
try:
ssh.execute(f"rm -f {tmp}", timeout=10)
except Exception:
pass
def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: int,
script: str, interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри LXC через `pct exec` (SSH ноды, нет guest agent для LXC)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=ssh.last_error or f"Не удалось подключиться по SSH к {server.name}")
if not is_valid_interpreter(interpreter):
return ExecResult(success=False, output="", exit_code=-1,
error=f"Недопустимый интерпретатор: {interpreter!r}")
vmid = int(vmid)
tmp_node = _tmp_name(f"pve_script_{vmid}")
tmp_ct = _tmp_name("pve_script") + ".sh"
try:
for cmd in write_file_commands(tmp_node, script.encode("utf-8")):
_run(ssh, cmd, what="write script on node")
_run(ssh, f"pct push {vmid} {tmp_node} {tmp_ct} --perms 0700", what="pct push script")
output, exit_code = ssh.execute(
f"pct exec {vmid} -- env {_SCRIPT_ENV} {shlex.quote(interpreter)} {tmp_ct}",
return_exit_code=True, timeout=timeout,
)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
finally:
try:
ssh.execute(f"rm -f {tmp_node}", timeout=10)
ssh.execute(f"pct exec {vmid} -- rm -f {tmp_ct}", timeout=10)
except Exception:
pass
def run_on_qemu(client: ProxmoxClient, node: str, vmid: int, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри VM через QEMU guest agent."""
result = client.execute_script(node, vmid, script, interpreter=interpreter, timeout=timeout)
if not result.get("success"):
return ExecResult(
success=False, output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", -1),
error=result.get("error") or result.get("stderr"),
)
return ExecResult(
success=result.get("exit_code", 0) == 0,
output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", 0),
error=result.get("stderr") or None,
)
def execute(server: ProxmoxServer, *, target_type: str, node: Optional[str],
vmid: Optional[int], vm_type: Optional[str], script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Точка входа: выполнить скрипт на нужной цели.
target_type: "node" (хост Proxmox) | "guest" (VM/LXC).
vm_type (только для guest): "qemu" | "lxc".
"""
try:
client = ProxmoxClient.from_server(server)
except Exception as e:
return ExecResult(success=False, output="", exit_code=-1,
error=f"Не удалось создать Proxmox-клиент: {e}")
try:
if target_type == "node":
return run_on_node(server, client, script, interpreter, timeout)
if target_type == "guest":
if vmid is None or not node:
return ExecResult(success=False, output="", exit_code=-1,
error="Для guest-скрипта нужны node и vmid")
if vm_type == "lxc":
return run_on_lxc(server, client, node, vmid, script, interpreter, timeout)
return run_on_qemu(client, node, vmid, script, interpreter, timeout)
return ExecResult(success=False, output="", exit_code=-1,
error=f"Неизвестный target_type: {target_type}")
except Exception as e:
logger.error(f"[script_engine] execute failed: {e}")
return ExecResult(success=False, output="", exit_code=-1, error=str(e))