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
168 lines
7.5 KiB
Python
168 lines
7.5 KiB
Python
from .base import *
|
|
|
|
# target_type скрипта: где он выполняется.
|
|
# "node" — на хосте Proxmox (root@node по SSH)
|
|
# "guest" — внутри VM (QEMU guest agent) или LXC (pct exec через SSH ноды)
|
|
SCRIPT_TARGET_TYPES = ("node", "guest")
|
|
SCRIPT_SOURCES = ("manual", "git", "community-scripts")
|
|
SCRIPT_EXECUTION_STATUSES = ("running", "success", "failed")
|
|
# Формат метаданных, по которому синкается git-источник:
|
|
# "header-comment" — заголовочные bash-комментарии (# name: / # param: ...)
|
|
# "community-scripts-ct" — ct/*.sh из community-scripts/ProxmoxVE (APP=, var_*)
|
|
SCRIPT_REPO_METADATA_FORMATS = ("header-comment", "community-scripts-ct")
|
|
|
|
|
|
class ScriptGitRepo(Base):
|
|
"""Git-источник каталога скриптов (аналог CatalogProvider для appstore)."""
|
|
__tablename__ = "script_git_repos"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
url = Column(String(500), nullable=False)
|
|
branch = Column(String(100), nullable=False, default="main")
|
|
# glob относительно корня репозитория, напр. "scripts/**/*.sh"
|
|
path_glob = Column(String(300), nullable=False, default="**/*.sh")
|
|
# см. SCRIPT_REPO_METADATA_FORMATS
|
|
metadata_format = Column(String(30), nullable=False, default="header-comment")
|
|
enabled = Column(Boolean, nullable=False, default=True)
|
|
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
|
last_sync_error = Column(Text, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"url": self.url,
|
|
"branch": self.branch,
|
|
"path_glob": self.path_glob,
|
|
"metadata_format": self.metadata_format,
|
|
"enabled": self.enabled,
|
|
"last_synced_at": self.last_synced_at.isoformat() if self.last_synced_at else None,
|
|
"last_sync_error": self.last_sync_error,
|
|
}
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptGitRepo(id={self.id}, url='{self.url}')>"
|
|
|
|
|
|
class ScriptCatalog(Base):
|
|
"""Скрипт каталога — свой (manual) или синхронизированный из git."""
|
|
__tablename__ = "script_catalog"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(200), nullable=False)
|
|
slug = Column(String(200), nullable=False, unique=True, index=True)
|
|
description = Column(Text, nullable=True)
|
|
category = Column(String(100), nullable=True, index=True)
|
|
|
|
target_type = Column(String(20), nullable=False, default="guest") # node | guest
|
|
interpreter = Column(String(100), nullable=False, default="/bin/bash")
|
|
content = Column(Text, nullable=False)
|
|
|
|
# [{name, label, type: string|number|bool, required, default}]
|
|
params_schema = Column(JSON, nullable=False, default=list)
|
|
|
|
source = Column(String(20), nullable=False, default="manual", index=True) # manual | git
|
|
git_repo_id = Column(Integer, ForeignKey("script_git_repos.id", ondelete="CASCADE"), nullable=True, index=True)
|
|
git_path = Column(String(500), nullable=True)
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
|
|
|
def to_dict(self, *, with_content: bool = False) -> dict:
|
|
data = {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"slug": self.slug,
|
|
"description": self.description,
|
|
"category": self.category,
|
|
"target_type": self.target_type,
|
|
"interpreter": self.interpreter,
|
|
"params_schema": self.params_schema or [],
|
|
"source": self.source,
|
|
"git_repo_id": self.git_repo_id,
|
|
"git_path": self.git_path,
|
|
"created_by": self.created_by,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if with_content:
|
|
data["content"] = self.content
|
|
return data
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptCatalog(id={self.id}, slug='{self.slug}', target_type='{self.target_type}')>"
|
|
|
|
|
|
SCRIPT_EXECUTION_OUTPUT_LIMIT = 200_000 # символов, защита от раздувания БД
|
|
|
|
|
|
class ScriptExecution(Base):
|
|
"""Журнал запуска скрипта (аналог AppOperation)."""
|
|
__tablename__ = "script_executions"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
script_id = Column(Integer, ForeignKey("script_catalog.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
script_name = Column(String(200), nullable=True) # снимок имени на момент запуска
|
|
|
|
target_type = Column(String(20), nullable=False) # node | guest
|
|
server_id = Column(Integer, nullable=True, index=True)
|
|
node = Column(String(100), nullable=True)
|
|
vmid = Column(Integer, nullable=True) # None для target_type="node"
|
|
|
|
params_used = Column(JSON, nullable=False, default=dict)
|
|
status = Column(String(20), nullable=False, default="running", index=True)
|
|
exit_code = Column(Integer, nullable=True)
|
|
output = Column(Text, nullable=True)
|
|
error_text = Column(Text, nullable=True)
|
|
|
|
user_id = Column(Integer, nullable=False, index=True)
|
|
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
def to_dict(self, *, with_output: bool = True) -> dict:
|
|
data = {
|
|
"id": self.id,
|
|
"kind": "script",
|
|
"script_id": self.script_id,
|
|
"script_name": self.script_name,
|
|
"target_type": self.target_type,
|
|
"server_id": self.server_id,
|
|
"node": self.node,
|
|
"vmid": self.vmid,
|
|
"params_used": self.params_used or {},
|
|
"status": self.status,
|
|
"exit_code": self.exit_code,
|
|
"output": self.output,
|
|
"error_text": self.error_text,
|
|
"user_id": self.user_id,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
}
|
|
if not with_output:
|
|
data.pop("output")
|
|
return data
|
|
|
|
def to_task_dict(self) -> dict:
|
|
"""Представление для общего списка «Задачи» (/api/all-tasks)."""
|
|
return {
|
|
"id": self.id,
|
|
"kind": "script",
|
|
"type": "script_execution",
|
|
"description": f"Скрипт: {self.script_name or self.script_id}",
|
|
"status": self.status,
|
|
"progress": 100 if self.status != "running" else 50,
|
|
"step": None,
|
|
"current_step": None,
|
|
"error_message": self.error_text,
|
|
"user_id": self.user_id,
|
|
"node": self.node,
|
|
"server_id": self.server_id,
|
|
"created_at": self.started_at.isoformat() if self.started_at else None,
|
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
|
|
}
|
|
|
|
def __repr__(self):
|
|
return f"<ScriptExecution(id={self.id}, script_id={self.script_id}, status='{self.status}')>"
|