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
164 lines
8.7 KiB
Python
164 lines
8.7 KiB
Python
import os
|
||
from typing import Optional
|
||
from pydantic_settings import BaseSettings
|
||
from pydantic import Field
|
||
from pathlib import Path
|
||
|
||
|
||
def get_version() -> str:
|
||
"""Read version from VERSION file"""
|
||
# Try multiple locations for VERSION file
|
||
possible_paths = [
|
||
Path(__file__).parent.parent.parent.parent / "VERSION", # Development
|
||
Path(__file__).parent.parent / "VERSION", # Docker /app/VERSION
|
||
Path("/app/VERSION"), # Docker absolute
|
||
]
|
||
for version_file in possible_paths:
|
||
if version_file.exists():
|
||
return version_file.read_text().strip()
|
||
# Fallback only if the VERSION file is missing — keep in sync with /VERSION.
|
||
return "1.5.1"
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
# Panel name - configurable but not shown in UI by default
|
||
PANEL_NAME: str = Field(default="PVEmanager", env="PANEL_NAME")
|
||
VERSION: str = Field(default_factory=get_version)
|
||
DEBUG: bool = Field(default=False, env="DEBUG")
|
||
|
||
# Database settings
|
||
DB_HOST: str = Field(default="localhost", env="DB_HOST")
|
||
DB_PORT: int = Field(default=5432, env="DB_PORT")
|
||
DB_USER: str = Field(default="pvemanager", env="DB_USER")
|
||
DB_PASSWORD: str = Field(default="pvemanager", env="DB_PASSWORD")
|
||
DB_NAME: str = Field(default="pvemanager", env="DB_NAME")
|
||
DATABASE_URL: Optional[str] = Field(default=None, env="DATABASE_URL")
|
||
|
||
# Security settings
|
||
SECRET_KEY: str = Field(default="your-secret-key-here", env="SECRET_KEY")
|
||
ALGORITHM: str = "HS256"
|
||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=480, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 8 hours
|
||
# Lifetime of self-service password-reset links (forgot-password flow)
|
||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = Field(default=30, env="PASSWORD_RESET_TOKEN_EXPIRE_MINUTES")
|
||
|
||
# Logging settings
|
||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||
LOG_FILE: str = Field(default="logs/app.log", env="LOG_FILE")
|
||
|
||
# SSH settings
|
||
SSH_TIMEOUT: int = Field(default=10, env="SSH_TIMEOUT")
|
||
DEFAULT_SSH_USER: str = Field(default="root", env="DEFAULT_SSH_USER")
|
||
DEFAULT_SSH_PORT: int = Field(default=22, env="DEFAULT_SSH_PORT")
|
||
# Reject SSH connections to hosts with unknown/changed host keys (mitigates MITM).
|
||
# Default False preserves legacy behaviour (warn & accept); enable in production
|
||
# once your servers' host keys are in known_hosts.
|
||
SSH_REJECT_UNKNOWN_HOSTS: bool = Field(default=False, env="SSH_REJECT_UNKNOWN_HOSTS")
|
||
# Without strict mode, node host keys are pinned on first use (TOFU) in this
|
||
# file; default: <APPSTORE_DATA_DIR>/../ssh_known_hosts (the data volume).
|
||
SSH_KNOWN_HOSTS_FILE: Optional[str] = Field(default=None, env="SSH_KNOWN_HOSTS_FILE")
|
||
|
||
# Metrics history
|
||
METRICS_COLLECT_INTERVAL: int = Field(default=15, env="METRICS_COLLECT_INTERVAL")
|
||
METRICS_RETENTION_DAYS: int = Field(default=30, env="METRICS_RETENTION_DAYS")
|
||
|
||
# Public URL of the panel (e.g. https://panel.example.com). Used to build
|
||
# links in emails (password reset) instead of trusting request headers.
|
||
PANEL_URL: Optional[str] = Field(default=None, env="PANEL_URL")
|
||
|
||
# CORS — comma-separated list of allowed origins; "*" is insecure in production
|
||
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")
|
||
|
||
# Default admin password — MUST be changed via env var in production
|
||
ADMIN_PASSWORD: str = Field(default="admin123", env="ADMIN_PASSWORD")
|
||
|
||
# Fernet key for encrypting sensitive DB fields (Proxmox passwords, cloud-init passwords).
|
||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||
# If not set, values are stored as plaintext (backward-compatible but insecure).
|
||
FERNET_KEY: Optional[str] = Field(default=None, env="FERNET_KEY")
|
||
|
||
# Timezone
|
||
TZ: str = Field(default="UTC", env="TZ")
|
||
|
||
# ── App Store (модуль каталога self-hosted приложений) ──────────────────
|
||
# Источник каталога. Рекомендуется фиксировать REF на конкретный tag/commit
|
||
# (митигация риска смены формата Runtipi, ТЗ п.13); дефолт master — «всегда свежий».
|
||
RUNTIPI_APPSTORE_REPO: str = Field(default="runtipi/runtipi-appstore", env="RUNTIPI_APPSTORE_REPO")
|
||
RUNTIPI_APPSTORE_REF: str = Field(default="master", env="RUNTIPI_APPSTORE_REF")
|
||
# Дополнительный источник каталога — Umbrel (getumbrel/umbrel-apps). Формат
|
||
# манифеста иной (umbrel-app.yml + docker-compose.yml), поэтому подключается
|
||
# отдельным провайдером; см. UmbrelCatalogProvider.
|
||
UMBREL_APPSTORE_REPO: str = Field(default="getumbrel/umbrel-apps", env="UMBREL_APPSTORE_REPO")
|
||
UMBREL_APPSTORE_REF: str = Field(default="master", env="UMBREL_APPSTORE_REF")
|
||
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в галерее
|
||
# (<app-id>/icon.svg). Тянем их пофайлово с GitHub Pages CDN параллельно —
|
||
# это быстрее, чем качать тяжёлый tarball галереи (со скриншотами).
|
||
UMBREL_APPSTORE_GALLERY_CDN: str = Field(
|
||
default="https://getumbrel.github.io/umbrel-apps-gallery",
|
||
env="UMBREL_APPSTORE_GALLERY_CDN")
|
||
# Активные источники каталога (через запятую): "runtipi", "umbrel" или оба.
|
||
# По умолчанию только runtipi — Umbrel включается явно.
|
||
APPSTORE_SOURCES: str = Field(default="runtipi", env="APPSTORE_SOURCES")
|
||
# Каталог для кэша логотипов/данных App Store (монтируемый volume — переживает пересборку).
|
||
APPSTORE_DATA_DIR: str = Field(default="/app/data/appstore", env="APPSTORE_DATA_DIR")
|
||
# Периодичность авто-синхронизации каталога (ТЗ F-CAT-1).
|
||
CATALOG_SYNC_INTERVAL_HOURS: int = Field(default=24, env="CATALOG_SYNC_INTERVAL_HOURS")
|
||
# Архитектура целевых нод (для пометки неподдерживаемых приложений). amd64|arm64.
|
||
APPSTORE_HOST_ARCH: str = Field(default="amd64", env="APPSTORE_HOST_ARCH")
|
||
# Золотой LXC-шаблон (vztmpl) с предустановленным Docker — из него клонируются приложения.
|
||
# Напр. "local:vztmpl/golden-docker_12_amd64.tar.zst". Обязателен для установки (M2).
|
||
APPSTORE_GOLDEN_TEMPLATE: Optional[str] = Field(default=None, env="APPSTORE_GOLDEN_TEMPLATE")
|
||
# DNS для LXC, создаваемых App Store (через пробел — несколько серверов).
|
||
# Явно задаём публичный резолвер: без этого контейнер наследует DNS ноды,
|
||
# который может указывать на адрес, недоступный из сети контейнера
|
||
# (напр. Tailscale-резолвер хоста) — тогда `docker compose up` падает на
|
||
# pull образов с "i/o timeout" при резолве registry-1.docker.io.
|
||
APPSTORE_NAMESERVER: str = Field(default="1.1.1.1 8.8.8.8", env="APPSTORE_NAMESERVER")
|
||
|
||
model_config = {
|
||
"env_file": ".env",
|
||
"env_file_encoding": "utf-8",
|
||
"case_sensitive": False,
|
||
# deploy.sh-generated .env files carry keys this class does not model
|
||
# (e.g. ALLOWED_HOSTS); don't refuse to start because of them.
|
||
"extra": "ignore",
|
||
}
|
||
|
||
@property
|
||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||
if self.DATABASE_URL:
|
||
return self.DATABASE_URL.replace("postgresql://", "postgresql+psycopg2://")
|
||
return (
|
||
f"postgresql+psycopg2://{self.DB_USER}:"
|
||
f"{self.DB_PASSWORD}@{self.DB_HOST}:"
|
||
f"{self.DB_PORT}/{self.DB_NAME}"
|
||
)
|
||
|
||
@property
|
||
def get_db_url(self) -> str:
|
||
"""Get database URL for alembic and other tools"""
|
||
return self.SQLALCHEMY_DATABASE_URI
|
||
|
||
|
||
settings = Settings()
|
||
|
||
|
||
# Timezone-aware datetime helper
|
||
from datetime import datetime, timezone
|
||
|
||
def utcnow() -> datetime:
|
||
"""Get current UTC time as timezone-aware datetime.
|
||
|
||
Use this instead of datetime.utcnow() to avoid timezone issues
|
||
with PostgreSQL columns that have timezone=True.
|
||
"""
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def make_tz_aware(dt: Optional[datetime]) -> Optional[datetime]:
|
||
"""Ensure datetime is timezone-aware. Defaults to UTC if naive."""
|
||
if dt is None:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=timezone.utc)
|
||
return dt
|