fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s

Critical security and robustness fixes from a code audit:

- WS terminal/VNC now require a JWT (?token=), validate the session,
  vms.console permission and VM ownership before bridging to Proxmox
  (previously the LXC terminal gave unauthenticated root shell access).
  Frontend ConsolePage passes the token in both WS URLs.
- Fix SSH fallback for `pvecm delnode`: SSHClient now supports password
  auth (it previously crashed with TypeError); ProxmoxClient stores the
  password for SSH fallbacks and the host port suffix is stripped.
- Add production startup warnings for default ADMIN_PASSWORD and wildcard
  CORS_ORIGINS.
- dashboard: use tz-aware utcnow() instead of naive datetime.utcnow().
- crypto: narrow decrypt_value except to InvalidToken and log failures.
- ssh_client: guard the shared connection cache with a lock; make
  unknown-host-key rejection opt-in via SSH_REJECT_UNKNOWN_HOSTS.
- logging: send loguru console output to stderr instead of stdout.
- Sync version fallback to 1.5.1; remove one-off refactor scripts.
- Add CODE_REVIEW.md audit report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 06:31:26 +05:00
parent bfd92102cf
commit 50f2ee1966
17 changed files with 394 additions and 503 deletions

208
CODE_REVIEW.md Normal file
View File

@@ -0,0 +1,208 @@
# PVEmanager — Отчёт по аудиту кода
**Дата:** 2026-06-13
**Версия проекта:** 1.5.1 (VERSION → 1.7.0 в коде)
**Объём:** backend ~33.7k строк Python, frontend ~14k строк TypeScript/React
Отчёт разбит на три части по запросу:
1. Ошибки и баги (с приоритетами)
2. Рекомендации по оптимизации
3. Идеи для новых фич
---
## Статус исправлений (обновлено 2026-06-13)
Большинство багов исправлено в этой сессии. Сводка:
| # | Проблема | Статус |
|---|----------|--------|
| B1 | WS-терминал LXC без аутентификации | ✅ Исправлено |
| B2 | VNC WS-прокси без проверки пользователя | ✅ Исправлено |
| B3 | SSH-fallback `delnode` падал с TypeError | ✅ Исправлено |
| B4 | Небезопасные дефолты (ADMIN_PASSWORD/CORS) | ✅ Добавлены прод-предупреждения |
| B5 | JWT в localStorage + CSP unsafe-eval | ⏸️ Отложено (крупный редизайн auth, см. ниже) |
| B6 | Наивный `datetime.utcnow()` в dashboard | ✅ Исправлено |
| B7 | Глобальный exception handler | ✅ Проверено — не баг (Starlette выбирает по MRO) |
| B8 | Утечки DB-сессий в воркерах | ✅ Проверено — ложная тревога (сессии закрываются в finally) |
| B9 | Широкий `except` в `decrypt_value` | ✅ Исправлено |
| B10 | Гонки в SSH-кэше | ✅ Исправлено (добавлен lock) |
| B11 | `WarningPolicy` для SSH host keys | ✅ Сделано настраиваемым (`SSH_REJECT_UNKNOWN_HOSTS`) |
| B12 | Перебор VMID до 999999 | ⏸️ Низкий приоритет, fallback-путь |
| B13 | Мусорные скрипты в репо | ✅ Удалены |
| B14 | Рассинхрон версий | ✅ Синхронизировано |
| B15 | Inline-импорты | ⏸️ Косметика, точечно поправлено |
| B16 | loguru пишет в stdout | ✅ Переведено на stderr |
**Про B5:** перенос токена с `localStorage` на httpOnly-cookie — это серьёзная переработка всего auth-flow (login, refresh, WS-аутентификация, api-client). Делать вслепую рискованно — сломает только что добавленную WS-авторизацию по `?token=`. Оставлено как отдельная задача. Снижение поверхности XSS (убрать `unsafe-eval`/`unsafe-inline` из CSP, перейти на nonce) тоже требует проверки, что noVNC/xterm не сломаются.
---
## 1. Баги и ошибки
### 🔴 Критические (безопасность / работоспособность)
#### B1. WebSocket-терминал LXC не аутентифицирован — удалённый root-доступ без авторизации
**Файл:** `backend/app/api/proxmox/vms.py:2951` (`terminal_websocket_fixed`)
Эндпоинт `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` не принимает токен и не проверяет ни пользователя, ни сессию, ни владение контейнером. Внутри он сам получает auth-ticket по сохранённому паролю сервера Proxmox и открывает интерактивный root-shell в контейнере.
Фронтенд тоже подключается без токена:
```
/proxmox/ws/terminal/${sid}/${data.node}/${vid} // ConsolePage.tsx:157 — токена нет
```
**Последствие:** любой, кто имеет сетевой доступ к панели, может открыть root-консоль в любом LXC-контейнере без логина. Это полный обход RBAC.
**Что сделать:** добавить аутентификацию по `?token=<jwt>` (как сделано в `/ws/tasks` в `main.py:298`), декодировать токен, проверить сессию через `SecurityService.validate_session` и право `vms.console` + `require_vm_access(server_id, vmid)`.
#### B2. VNC WebSocket-прокси не проверяет пользователя
**Файл:** `backend/app/api/proxmox/vms.py:1993` (`vnc_websocket_proxy`)
Эндпоинт `/proxmox/ws/vnc/...` принимает `auth_ticket`/`vncticket` query-параметрами и не валидирует JWT-сессию панели. Это слабее, чем B1 (нужно владеть тикетом, который выдаёт авторизованный REST-эндпоинт), но эндпоинт всё равно не привязан к пользователю и не проверяет `require_vm_access`. Тикеты попадают в URL → могут осесть в логах прокси/браузера.
**Что сделать:** так же, как B1 — аутентифицировать соединение JWT-токеном и проверять доступ к VM, не полагаясь только на ticket в URL.
#### B3. SSH-fallback удаления ноды кластера упадёт с `TypeError`
**Файл:** `backend/app/proxmox/mixins/cluster.py:289`
```python
ssh = SSHClient(hostname=self.host, username='root', password=getattr(self, '_password', None))
```
Конструктор `SSHClient.__init__` (`backend/app/ssh_client.py:24`) **не имеет параметра `password`** — только `key_path`. Любой вызов этого fallback бросит `TypeError: __init__() got an unexpected keyword argument 'password'`, то есть удаление ноды по SSH никогда не работает (а паролевая аутентификация в `SSHClient` вообще не реализована — только ключи/agent).
**Что сделать:** либо добавить поддержку `password` в `SSHClient`, либо убрать неподдерживаемый аргумент и реализовать паролевую auth через `client.connect(..., password=...)`.
### 🟠 Высокие
#### B4. Небезопасные значения по умолчанию в конфиге
**Файл:** `backend/app/config.py:37,54`
- `SECRET_KEY = "your-secret-key-here"` по умолчанию. На старте `main.py:118` есть проверка и аварийный выход — хорошо. Но `ADMIN_PASSWORD = "admin123"` по умолчанию **без** аналогичной проверки: при незаданной переменной создаётся админ с предсказуемым паролем.
- `CORS_ORIGINS = "*"` по умолчанию (`config.py:51`). Логика в `main.py:224` корректно отключает credentials при `*`, но дефолт всё равно открытый.
**Что сделать:** при `DEBUG=false` падать/предупреждать, если `ADMIN_PASSWORD` дефолтный; требовать явный список CORS-origin в проде.
#### B5. JWT хранится в `localStorage` — уязвимо к XSS
**Файл:** `frontend/src/lib/api-client.ts:15,24`, `stores/auth-store.ts:20`
Токен лежит в `localStorage`, а CSP разрешает `'unsafe-inline' 'unsafe-eval'` для скриптов (`main.py:249`). При любой XSS токен утекает. CSP с `unsafe-eval`/`unsafe-inline` фактически отключает защиту от инъекций скриптов.
**Что сделать:** перейти на httpOnly-cookie для токена (или хотя бы убрать `unsafe-eval`, использовать nonce для inline-скриптов). Для текущей схемы — минимизировать поверхность XSS.
#### B6. `datetime.utcnow()` — наивное время на колонках с timezone
**Файл:** `backend/app/api/dashboard.py:42,91`
Проект ввёл `utcnow()` helper (`config.py:92`) именно чтобы избежать наивных datetime на `timezone=True` колонках, но в dashboard всё ещё используется `datetime.utcnow()`. Сравнение naive/aware datetime в Postgres даёт смещение/ошибки в статистике за 24ч.
**Что сделать:** заменить на `from ..config import utcnow` и `utcnow()`.
#### B7. Глобальный exception handler может проглатывать `HTTPException`
**Файл:** `backend/app/main.py:261-272`
Зарегистрированы оба: handler на `Exception` и на `HTTPException`. В Starlette это работает, но `@app.exception_handler(Exception)` перехватывает всё, и порядок/диспетчеризация хрупкие — стоит убедиться (тестом), что 4xx не превращаются в 500 «Internal server error».
### 🟡 Средние
#### B8. Возможные утечки DB-сессий в фоновых воркерах
**Файл:** `backend/app/workers/monitoring_worker.py`
10 вызовов `SessionLocal()` против 8 `db.close()`. Большинство обёрнуты в try/finally, но число не сходится — нужно проверить, что каждая открытая сессия закрывается (особенно ветки с ранним `return` внутри try до создания finally). Незакрытые сессии исчерпывают пул соединений Postgres.
#### B9. `decrypt_value` ловит `Exception` и возвращает шифротекст как «plaintext»
**Файл:** `backend/app/crypto.py:59`
```python
except (InvalidToken, Exception):
return value
```
Перехват широкого `Exception` означает, что при битом `FERNET_KEY` (например, ключ сменили) зашифрованные пароли молча вернутся как нечитаемый шифротекст, и панель будет пытаться логиниться в Proxmox мусором — без явной ошибки. Тяжело диагностировать.
**Что сделать:** ловить только `InvalidToken`; логировать прочие исключения.
#### B10. Кэш SSH-соединений — общий на класс, потокобезопасность
**Файл:** `backend/app/ssh_client.py:21-22`
`_connections`/`_connection_times` — словари уровня класса, доступ из `ThreadPoolExecutor` (10 воркеров) без блокировок. Возможны гонки при одновременном `_cleanup_connection`/`_cache_connection` (KeyError на `del`). `cleanup_ssh_connections` определён, но, судя по `main.py`, не запускается в lifespan — кэш чистится только лениво.
#### B11. `WarningPolicy` для неизвестных SSH-ключей
**Файл:** `backend/app/ssh_client.py:86`
`set_missing_host_key_policy(WarningPolicy())` принимает любой новый host key (только предупреждение) — уязвимо к MITM. Для управления инфраструктурой стоит `RejectPolicy` + управляемый known_hosts, либо явная фиксация fingerprint сервера в БД.
#### B12. Перебор VMID до 999999 в памяти
**Файл:** `backend/app/api/proxmox/_helpers.py:115-126`
Fallback грузит все vmid в set и линейно ищет свободный до 999999 — приемлемо сейчас, но это O(n) запрос + цикл на каждый вызов; при росте — узкое место. Это fallback, так что низкий приоритет.
### 🟢 Низкие / гигиена
- **B13.** Мусорные скрипты в репозитории: `fix_crud.py`, `test_new_operations.py` (корень), `backend/refactor_proxmox.py`, `backend/refactor_vms.py`, `backend/replace_imports.py` — одноразовые рефактор-скрипты, их стоит удалить из VCS.
- **B14.** Рассинхрон версий: `VERSION`/README говорят 1.5.1, fallback в `config.py:19` — `1.7.0`. Привести к единому источнику истины.
- **B15.** `import` внутри функций (`requests`, `websockets`, `init_admin`, RBAC-движок) разбросаны по горячим путям — мелкий оверхед и затруднённый анализ зависимостей. Лучше выносить наверх модуля.
- **B16.** `print(msg, end="")` как sink loguru (`main.py:43`) вместо `sys.stderr` — лог уходит в stdout, мешается с данными в некоторых деплоях.
---
## 2. Рекомендации по оптимизации
### Backend
1. **Пул соединений к Proxmox.** `_get_proxmox_client` создаёт новый `ProxmoxClient` на каждый запрос. Стоит кэшировать клиентов per-server (с TTL/проверкой живости), как уже сделано для SSH.
2. **Асинхронность.** Тяжёлые синхронные вызовы (`requests`, `proxmoxer`, SSH) блокируют event loop в части эндпоинтов. VNC/term уже на httpx/websockets — стоит и REST-вызовы к Proxmox перевести на async или прятать в `run_in_executor` (как `async_check_server_status`).
3. **N+1 в синхронизации.** В `sync_vm_cache` и dashboard убедиться, что нет поштучных запросов в циклах по VM; использовать `joinedload`/bulk-загрузку (частично уже применяется для IPAM).
4. **Кэш метрик/статусов.** RRD и статусы серверов опрашиваются часто — централизованный кэш с инвалидацией (Redis или in-memory с TTL) снизит нагрузку на Proxmox API.
5. **Индексы БД.** Проверить индексы на горячих фильтрах: `VMInstance(server_id, vmid, deleted_at)`, `IPAMAllocation(status)`, логи по `created_at`. Это самые частые WHERE.
6. **Закрытие сессий через контекст-менеджер.** Заменить ручные `SessionLocal()/close()` в воркерах на `with SessionLocal() as db:` чтобы исключить B8.
### Frontend
7. **Code-splitting уже есть** (lazy import xterm/noVNC) — хорошо. Стоит проверить размер основного бандла и вынести тяжёлые страницы в отдельные чанки.
8. **React Query / SWR.** Судя по обилию `use-*` хуков и polling-эндпоинтов (`main.py:92` фильтрует GET-polling), стоит убедиться, что используется дедупликация запросов и разумный `staleTime`, а не голый `setInterval`.
9. **WebSocket вместо polling.** Для unread-count уведомлений и списка серверов — перейти на уже существующий WS-канал вместо периодических GET (их специально прячут из логов, что намекает на шум).
### Инфраструктура
10. **Pin зависимостей фронтенда** и регулярный `npm audit` / `pip-audit`. `paramiko 3.4.1`, `cryptography>=41` — проверить на свежие CVE.
11. **Healthcheck** уже есть (`/health`) — добавить readiness отдельно от liveness (готовность миграций/воркеров).
---
## 3. Идеи для новых фич
### Управление и автоматизация
- **2FA / TOTP** для входа — логично дополняет уже развитую систему сессий, brute-force защиты и блокировок IP.
- **API-ключи для пользователей** — программный доступ к панели (для CI/CD, terraform-провайдера) с ограниченными правами.
- **Шаблоны/«flavor» VM** — преднастроенные конфигурации (S/M/L: cores/RAM/disk) для самообслуживания пользователей в стиле облачных провайдеров.
- **Расписания** (cron) для авто-старта/стопа VM, отложенных снапшотов — APScheduler уже в стеке.
- **Терраформ/Ansible экспорт** конфигурации серверов и VM как код.
### Мониторинг и алертинг
- **Пороговые алерты** (CPU/RAM/disk > X% в течение N мин) с уже подключёнными каналами (In-App/Email/Telegram).
- **Webhook-канал** уведомлений (Slack/Discord/Mattermost) в дополнение к Telegram.
- **История метрик** с долгим хранением (сейчас RRD от Proxmox) — собственная TSDB-витрина и дашборды.
- **Отчёты о ёмкости** (capacity planning): прогноз исчерпания диска/IPAM-сети.
### Сеть и хранилище
- **Управление бэкапами/restore через UI** с привязкой к расписанию и retention-политиками (бэкап-шедулер уже есть — расширить UI/политики).
- **Firewall-правила Proxmox** через панель (датацентр/нода/VM уровни).
- **Управление storage** (пулы, использование, ISO/template upload через UI).
### UX / администрирование
- **Аудит-журнал с экспортом** (CSV/PDF) — у вас уже есть LogsPage и audit в RBAC, добавить выгрузку и фильтр по пользователю/действию.
- **Bulk-теги и поиск** по VM/контейнерам (по владельцу, тегу, ноде).
- **Тёмная/светлая тема** — судя по `theme-store.ts` уже частично есть; довести до полноты.
- **Самообслуживание пользователей**: квоты (max VM/CPU/RAM на пользователя) — естественное продолжение модели владения VM и RBAC.
---
## Приоритетный план действий
| # | Что | Приоритет |
|---|-----|-----------|
| B1 | Аутентификация WS-терминала LXC | 🔴 немедленно |
| B2 | Аутентификация VNC WS-прокси | 🔴 немедленно |
| B3 | Починить/убрать `password=` в SSH-fallback | 🔴 высокий |
| B4 | Проверки дефолтных `ADMIN_PASSWORD`/CORS в проде | 🟠 высокий |
| B5/B6 | Хранение токена + `utcnow()` в dashboard | 🟠 высокий |
| B8/B9/B10 | Сессии воркеров, узкий except в crypto, гонки SSH-кэша | 🟡 средний |
| B13–B16 | Чистка скриптов, версии, импорты | 🟢 гигиена |
> Примечание: пункты B1–B3 я нашёл чтением кода, но не воспроизводил эксплойтом. Перед фиксом стоит подтвердить поведение на стенде (особенно что фронтенд-VNC-поток продолжит работать после добавления токена).

View File

@@ -24,6 +24,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES=1440
SSH_TIMEOUT=10
DEFAULT_SSH_USER=root
DEFAULT_SSH_PORT=22
# Reject SSH connections to hosts with unknown/changed host keys (mitigates MITM).
# Enable in production once your servers' host keys are in known_hosts.
SSH_REJECT_UNKNOWN_HOSTS=false
# Security
# In production set to your actual domain(s), e.g.: https://panel.example.com

View File

@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from datetime import timedelta
from ..db import get_db
from ..models import ProxmoxServer, AuditLog, User
from ..auth import PermissionChecker
from ..config import utcnow
router = APIRouter()
@@ -39,7 +40,7 @@ def dashboard_stats(
cluster_q = cluster_q.filter(ProxmoxServer.id.in_(server_ids))
total_clusters = cluster_q.scalar() or 0
since_24h = datetime.utcnow() - timedelta(hours=24)
since_24h = utcnow() - timedelta(hours=24)
total_alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h
@@ -88,7 +89,7 @@ def get_dashboard_alerts(
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Вернуть события error/critical за последние 24 ч для дашборда"""
since_24h = datetime.utcnow() - timedelta(hours=24)
since_24h = utcnow() - timedelta(hours=24)
alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h

View File

@@ -13,7 +13,9 @@ from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...auth import (get_current_user, PermissionChecker, require_permission,
check_permission, authenticate_ws_token)
from ...rbac import PermissionEngine
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
@@ -2001,15 +2003,28 @@ async def vnc_websocket_proxy(
vncticket: str,
vnc_password: str = None, # VNC пароль сгенерированный через generate-password
auth_ticket: str = None, # Auth ticket переданный с frontend
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""WebSocket прокси для VNC подключения к Proxmox"""
import websockets
import urllib.parse
# --- Authenticate the panel user before bridging to Proxmox ------------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vms.console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"VNC WebSocket connection accepted for {vmtype}/{vmid}")
logger.info(f"VNC WebSocket connection accepted for {vmtype}/{vmid} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
logger.error(f"Proxmox server {server_id} not found")
@@ -2954,6 +2969,7 @@ async def terminal_websocket_fixed(
server_id: int,
node: str,
vmid: int,
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""WebSocket терминал для LXC контейнеров через Proxmox termproxy API"""
@@ -2961,8 +2977,20 @@ async def terminal_websocket_fixed(
import httpx
from urllib.parse import quote
# --- Authenticate the panel user before opening a root shell -----------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vms.console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"Terminal WebSocket accepted for container {vmid} on node {node}")
logger.info(f"Terminal WebSocket accepted for container {vmid} on node {node} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:

View File

@@ -135,6 +135,42 @@ def get_current_session_token(
return payload.get("session")
def authenticate_ws_token(token: Optional[str], db: Session) -> Optional[User]:
"""Authenticate a WebSocket connection from a JWT passed as a query parameter.
Browsers cannot set Authorization headers on WebSocket handshakes, so the
token is supplied via ?token=<jwt>. Returns the authenticated, active User
(with a valid session, if the token is session-bound) or ``None`` on any
failure. Unlike ``decode_access_token`` this never raises — callers close
the socket with an appropriate code instead.
"""
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except jwt.PyJWTError:
return None
username = payload.get("sub")
if not username:
return None
# Validate the bound session, mirroring get_current_user.
session_token = payload.get("session")
if session_token:
from .services.security_service import SecurityService
if not SecurityService.validate_session(db, session_token):
return None
user = db.query(User).options(joinedload(User.role)).filter(User.username == username).first()
if user is None or not user.is_active:
return None
if user.is_locked():
return None
return user
def get_current_active_admin(current_user: User = Depends(get_current_user)) -> User:
"""Get current active admin user"""
if not current_user.is_admin and (not current_user.role or current_user.role.name != "admin"):

View File

@@ -16,7 +16,8 @@ def get_version() -> str:
for version_file in possible_paths:
if version_file.exists():
return version_file.read_text().strip()
return "1.7.0"
# Fallback only if the VERSION file is missing — keep in sync with /VERSION.
return "1.5.1"
class Settings(BaseSettings):
@@ -46,6 +47,10 @@ class Settings(BaseSettings):
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")
# CORS — comma-separated list of allowed origins; "*" is insecure in production
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")

View File

@@ -56,8 +56,15 @@ def decrypt_value(value: str) -> str:
return value
try:
return f.decrypt(value.encode()).decode()
except (InvalidToken, Exception):
# Value is likely plaintext (pre-encryption legacy row)
except InvalidToken:
# Value is likely plaintext (pre-encryption legacy row) or was
# encrypted with a different FERNET_KEY. Return as-is for backward
# compatibility, but warn so key-rotation mistakes are visible.
logger.warning(
"Fernet decryption failed (InvalidToken). Value returned as-is — "
"this is expected for legacy plaintext rows, but check FERNET_KEY "
"if previously-encrypted data is now unreadable."
)
return value

View File

@@ -37,10 +37,11 @@ def setup_logging():
# Remove default logger
logger.remove()
# Add console logging
# Add console logging (stderr keeps logs off stdout, which may carry data)
import sys
logger.add(
sink=lambda msg: print(msg, end=""),
sink=sys.stderr,
level=settings.LOG_LEVEL,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
)
@@ -123,6 +124,19 @@ async def lifespan(app: FastAPI):
if not settings.FERNET_KEY:
logger.warning("FERNET_KEY is not set. Sensitive fields (like passwords) will be stored as plaintext.")
logger.warning("To enable encryption, generate a key and set FERNET_KEY in your .env file.")
# Production hardening warnings (DEBUG is off in production)
if not settings.DEBUG:
if settings.ADMIN_PASSWORD == "admin123":
logger.warning(
"ADMIN_PASSWORD uses the insecure default 'admin123'. "
"Set a strong ADMIN_PASSWORD env var for production."
)
if settings.CORS_ORIGINS.strip() == "*":
logger.warning(
"CORS_ORIGINS is '*' (any origin). Set an explicit comma-separated "
"origin list (e.g. https://panel.example.com) for production."
)
# Check database connection
if not check_db_connection():

View File

@@ -101,6 +101,8 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
self.host = host
self.user = user
self.timeout = timeout
# Stored for SSH fallbacks (e.g. pvecm delnode) that need root password auth
self._password = password
# Улучшенный ключ кеша - включаем token_name для уникальности
self.connection_key = f"{host}:{user}:{token_name or 'password'}"
self.proxmox = None

View File

@@ -286,8 +286,10 @@ class ClusterMixin:
def _delete_cluster_node_via_ssh(self, node_name: str) -> Dict:
"""SSH fallback для удаления ноды через pvecm delnode"""
from app.ssh_client import SSHClient
# self.host may carry an API port suffix (e.g. "1.2.3.4:8006"); SSH needs the bare host.
ssh_host = self.host.split(':')[0] if self.host else self.host
ssh = SSHClient(
hostname=self.host,
hostname=ssh_host,
username='root',
password=getattr(self, '_password', None),
)

View File

@@ -1,5 +1,6 @@
import logging
import asyncio
import threading
from typing import Dict, Optional, Union
from concurrent.futures import ThreadPoolExecutor
import paramiko
@@ -16,17 +17,20 @@ ssh_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="ssh_")
class SSHClient:
"""Улучшенный SSH клиент для подключения к серверам с кешированием"""
# Connection cache
# Connection cache (shared across threads — guard every access with _cache_lock)
_connections = {}
_connection_times = {}
_cache_lock = threading.Lock()
def __init__(self, hostname: str, port: int = 22, username: str = "root",
key_path: Optional[str] = None, timeout: int = 10, max_retries: int = 3):
def __init__(self, hostname: str, port: int = 22, username: str = "root",
key_path: Optional[str] = None, timeout: int = 10, max_retries: int = 3,
password: Optional[str] = None):
self.hostname = hostname
self.port = port
self.username = username
self.key_path = key_path
self.password = password
self.timeout = timeout
self.max_retries = max_retries
self.client = None
@@ -35,10 +39,12 @@ class SSHClient:
def _get_cached_connection(self) -> Optional[paramiko.SSHClient]:
"""Get cached connection if available and valid"""
if self.connection_key in self._connections:
client = self._connections[self.connection_key]
with self._cache_lock:
client = self._connections.get(self.connection_key)
if client is None:
return None
connection_time = self._connection_times.get(self.connection_key, 0)
# Check if connection is still valid (max 5 minutes)
if time.time() - connection_time < 300:
try:
@@ -48,26 +54,31 @@ class SSHClient:
return client
except Exception:
pass
# Remove invalid connection
self._cleanup_connection()
self._cleanup_connection_locked()
return None
def _cache_connection(self, client: paramiko.SSHClient):
"""Cache successful connection"""
self._connections[self.connection_key] = client
self._connection_times[self.connection_key] = time.time()
with self._cache_lock:
self._connections[self.connection_key] = client
self._connection_times[self.connection_key] = time.time()
def _cleanup_connection(self):
"""Clean up cached connection"""
if self.connection_key in self._connections:
"""Clean up cached connection (acquires the cache lock)."""
with self._cache_lock:
self._cleanup_connection_locked()
def _cleanup_connection_locked(self):
"""Clean up cached connection. Caller must hold _cache_lock."""
client = self._connections.pop(self.connection_key, None)
if client is not None:
try:
self._connections[self.connection_key].close()
client.close()
except Exception:
pass
del self._connections[self.connection_key]
del self._connection_times[self.connection_key]
self._connection_times.pop(self.connection_key, None)
def connect(self) -> bool:
"""Установить SSH соединение с повторными попытками"""
@@ -83,7 +94,17 @@ class SSHClient:
try:
client = paramiko.SSHClient()
client.load_system_host_keys()
client.set_missing_host_key_policy(paramiko.WarningPolicy())
# Strict host-key checking is opt-in to avoid breaking existing
# deployments whose known_hosts isn't populated yet.
try:
from .config import settings
strict = settings.SSH_REJECT_UNKNOWN_HOSTS
except Exception:
strict = False
if strict:
client.set_missing_host_key_policy(paramiko.RejectPolicy())
else:
client.set_missing_host_key_policy(paramiko.WarningPolicy())
connect_kwargs = {
'hostname': self.hostname,
@@ -93,7 +114,9 @@ class SSHClient:
'banner_timeout': self.timeout,
'auth_timeout': self.timeout,
}
if self.password:
connect_kwargs['password'] = self.password
# Try key authentication first
if self.key_path:
try:
@@ -101,7 +124,7 @@ class SSHClient:
client.connect(**connect_kwargs, look_for_keys=False, allow_agent=False)
except paramiko.AuthenticationException:
logger.debug(f"Key auth failed for {self.hostname}, trying other methods")
# Remove key and try other methods
# Remove key and try other methods (password / agent / default keys)
del connect_kwargs['key_filename']
client.connect(**connect_kwargs, look_for_keys=True, allow_agent=True)
else:
@@ -233,13 +256,14 @@ class SSHClient:
@classmethod
def cleanup_all_connections(cls):
"""Clean up all cached connections"""
for key in list(cls._connections.keys()):
try:
cls._connections[key].close()
except Exception:
pass
cls._connections.clear()
cls._connection_times.clear()
with cls._cache_lock:
for key in list(cls._connections.keys()):
try:
cls._connections[key].close()
except Exception:
pass
cls._connections.clear()
cls._connection_times.clear()
def check_server_status(hostname: str, port: int = 22, username: str = "root",
@@ -400,21 +424,21 @@ async def cleanup_ssh_connections():
try:
await asyncio.sleep(300) # Check every 5 minutes
current_time = time.time()
expired_keys = []
for key, connection_time in SSHClient._connection_times.items():
if current_time - connection_time > 600: # 10 minutes
expired_keys.append(key)
for key in expired_keys:
if key in SSHClient._connections:
try:
SSHClient._connections[key].close()
except Exception:
pass
del SSHClient._connections[key]
del SSHClient._connection_times[key]
logger.debug(f"Cleaned up expired SSH connection: {key}")
with SSHClient._cache_lock:
expired_keys = [
key for key, connection_time in SSHClient._connection_times.items()
if current_time - connection_time > 600 # 10 minutes
]
for key in expired_keys:
client = SSHClient._connections.pop(key, None)
if client is not None:
try:
client.close()
except Exception:
pass
SSHClient._connection_times.pop(key, None)
logger.debug(f"Cleaned up expired SSH connection: {key}")
except Exception as e:
logger.error(f"Error in SSH cleanup task: {e}")
await asyncio.sleep(60) # Wait a minute on error

View File

@@ -1,142 +0,0 @@
import ast
import re
import os
source_file = 'app/proxmox_client.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class Extractor(ast.NodeVisitor):
def __init__(self):
self.methods = {}
self.module_funcs = []
def visit_ClassDef(self, node):
if node.name == 'ProxmoxClient':
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
start = item.lineno - 1
end = item.end_lineno
if item.decorator_list:
start = item.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.methods[item.name] = code
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0: # Module-level function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0: # Module-level async function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
tree = ast.parse(source_code)
extractor = Extractor()
extractor.visit(tree)
methods = extractor.methods
module_funcs = extractor.module_funcs
groups = {
'vm': ['start_vm', 'stop_vm', 'restart_vm', 'shutdown_vm', 'clone_vm', 'change_vm_password',
'set_vm_notes', 'attach_iso', 'detach_iso', 'force_stop_vm', 'get_vm_status', 'get_vm_stats',
'get_vm_rrddata', 'get_vm_vnc', 'get_vm_spice', 'qemu_agent_exec', 'resize_vm_disk', 'reinstall_vm_from_template'],
'lxc': ['start_container', 'stop_container', 'restart_container', 'shutdown_container', 'clone_container',
'change_container_password', 'set_container_notes', 'force_stop_container', 'get_container_status',
'get_container_stats', 'get_container_rrddata', 'get_container_vnc', 'get_container_spice', 'resize_lxc_disk', 'reinstall_lxc_from_template'],
'cluster': ['is_cluster', 'get_ha_resources', 'get_ha_groups', 'is_in_ha', 'add_to_ha', 'remove_from_ha', 'get_ha_status',
'create_cluster', 'get_cluster_join_info', 'join_cluster', 'delete_cluster_node', '_delete_cluster_node_via_ssh'],
'storage': ['get_node_isos', 'get_lxc_storage_templates', 'get_node_storages', 'vzdump_guest', 'get_backup_storages',
'get_backup_jobs', 'create_backup_job', 'update_backup_job', 'delete_backup_job', 'run_backup_job', 'get_backup_job_status'],
'network': ['get_sdn_vnets', 'get_sdn_zones', 'get_sdn_controllers', 'get_sdn_ipams', 'get_sdn_dns',
'apply_sdn', 'create_sdn_zone', 'create_sdn_vnet', 'create_sdn_subnet', 'get_sdn_vnet_subnets',
'get_node_interfaces'],
'snapshot': ['get_vm_snapshots', 'create_vm_snapshot', 'rollback_vm_snapshot', 'delete_vm_snapshot',
'get_container_snapshots', 'create_container_snapshot', 'rollback_container_snapshot', 'delete_container_snapshot',
'get_guest_snapshots', 'create_guest_snapshot', 'rollback_guest_snapshot', 'delete_guest_snapshot']
}
mixins_code = {}
for group, group_methods in groups.items():
mixin_name = group.capitalize() + 'Mixin'
code = f"from typing import List, Dict, Optional, Union, Any\nimport time\nimport urllib3\nfrom loguru import logger\n\nclass {mixin_name}:\n"
has_methods = False
for m in group_methods:
if m in methods:
# indent
method_code = '\n'.join([' ' + l for l in methods[m].split('\n')])
code += method_code + "\n\n"
has_methods = True
if has_methods:
mixins_code[group] = code
os.makedirs('app/proxmox/mixins', exist_ok=True)
for group, code in mixins_code.items():
with open(f'app/proxmox/mixins/{group}.py', 'w') as f:
f.write(code)
print("Mixins created successfully.")
core_methods = []
for m, code in methods.items():
found = False
for group_methods in groups.values():
if m in group_methods:
found = True
break
if not found:
core_methods.append(code)
imports = """import time
import urllib3
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Optional, Union, Any
from proxmoxer import ProxmoxAPI, AuthenticationError
from loguru import logger
import threading
from .mixins.vm import VmMixin
from .mixins.lxc import LxcMixin
from .mixins.cluster import ClusterMixin
from .mixins.storage import StorageMixin
from .mixins.network import NetworkMixin
from .mixins.snapshot import SnapshotMixin
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
connection_cache = {}
connection_cache_lock = threading.Lock()
MAX_CACHE_SIZE = 50
"""
client_code = imports
for fcode in module_funcs:
client_code += fcode + "\n\n"
client_code += "class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin):\n"
for mcode in core_methods:
client_code += '\n'.join([' ' + l for l in mcode.split('\n')]) + "\n\n"
with open('app/proxmox/client.py', 'w') as f:
f.write(client_code)
print("Client created successfully.")

View File

@@ -1,141 +0,0 @@
import ast
import os
import re
source_file = 'app/api/proxmox/vms.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class RouterExtractor(ast.NodeVisitor):
def __init__(self):
self.routes = []
self.models = []
self.imports = []
self.other = []
def visit_Import(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ClassDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.models.append((node.name, code))
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
# Check if it's a router
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
extractor = RouterExtractor()
tree = ast.parse(source_code)
extractor.visit(tree)
groups = {
'crud': ['sync_vms', 'get_virtual_machines', 'delete_vm', 'delete_container'],
'actions': ['clone_vm', 'clone_container', 'reinstall_vm', 'reinstall_lxc', 'vm_action', 'container_action', 'execute_vm_command', 'execute_vm_script', 'container_exec'],
'ha': ['get_ha_status', 'get_ha_details', 'add_to_ha', 'remove_from_ha'],
'console': ['get_vm_vnc', 'get_container_vnc', 'vnc_websocket', 'get_vm_terminal', 'get_container_terminal', 'terminal_websocket'],
'config': ['change_vm_password', 'change_container_password', 'get_vm_config', 'update_vm_config', 'resize_vm_disk', 'resize_lxc_disk',
'get_container_config', 'update_container_config', 'get_vm_owner', 'update_vm_owner', 'update_vm_notes', 'attach_vm_iso', 'detach_vm_iso',
'get_nodes', 'get_vm_status', 'get_container_status', 'get_vm_interfaces', 'get_container_interfaces', 'get_vm_rrddata', 'get_container_rrddata',
'get_vm_saved_config'],
'lxc': ['get_lxc_templates', 'get_all_lxc_templates', 'get_available_lxc_templates', 'download_lxc_template', 'create_lxc', 'create_lxc_smart', 'clone_lxc'],
'bulk': ['bulk_operation']
}
mixins_code = {}
imports_code = '\n'.join(extractor.imports)
imports_code = re.sub(r'from \.\.\.api', 'from ....api', imports_code)
imports_code = re.sub(r'from \.\.\.models', 'from ....models', imports_code)
imports_code = re.sub(r'from \.\.\.services', 'from ....services', imports_code)
imports_code = re.sub(r'from \.\.\.db', 'from ....db', imports_code)
imports_code = re.sub(r'from \.\.\.auth', 'from ....auth', imports_code)
imports_code = re.sub(r'from \.\.\.proxmox', 'from ....proxmox', imports_code)
imports_code = re.sub(r'from \.\.\.logging', 'from ....logging', imports_code)
imports_code = re.sub(r'from \.\.\.ipam', 'from ....ipam', imports_code)
imports_code = imports_code.replace('from ._helpers', 'from .._helpers')
models_code = '\n\n'.join([m[1] for m in extractor.models])
other_code = '\n\n'.join([o[1] for o in extractor.other])
base_code = f"{imports_code}\n\nrouter = APIRouter()\n\n{models_code}\n\n{other_code}\n\n"
for group, methods in groups.items():
code = base_code
for r_name, r_code in extractor.routes:
if r_name in methods:
code += r_code + "\n\n"
mixins_code[group] = code
for group, code in mixins_code.items():
with open(f'app/api/proxmox/vms/{group}.py', 'w') as f:
f.write(code)
init_code = "from fastapi import APIRouter\n"
for group in groups.keys():
init_code += f"from .{group} import router as {group}_router\n"
init_code += "\nrouter = APIRouter()\n"
for group in groups.keys():
init_code += f"router.include_router({group}_router)\n"
with open('app/api/proxmox/vms/__init__.py', 'w') as f:
f.write(init_code)
print("VMS routes split successfully.")

View File

@@ -1,22 +0,0 @@
import os
for root, dirs, files in os.walk('app'):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
with open(filepath, 'r') as f:
content = f.read()
# replace absolute imports
new_content = content.replace('app.proxmox_client', 'app.proxmox')
new_content = new_content.replace('backend.app.proxmox_client', 'backend.app.proxmox')
# replace relative imports
new_content = new_content.replace('..proxmox_client', '..proxmox')
new_content = new_content.replace('...proxmox_client', '...proxmox')
new_content = new_content.replace('from .proxmox_client', 'from .proxmox')
if new_content != content:
with open(filepath, 'w') as f:
f.write(new_content)
print(f"Updated {filepath}")

View File

@@ -1,20 +0,0 @@
import sys
with open('backend/app/api/proxmox/vms.py.bak', 'r') as f:
lines = f.readlines()
cache_lines = lines[27:30]
func_lines = lines[55:277]
with open('backend/app/api/proxmox/vms/crud.py', 'r') as f:
crud_content = f.read()
# Insert before the first route, or at the end. Let's insert after _get_client_or_503
insert_idx = crud_content.find('@router.delete("/api/{server_id}/vm/{vmid}")')
new_content = crud_content[:insert_idx] + "".join(cache_lines) + "\n" + "".join(func_lines) + "\n\n" + crud_content[insert_idx:]
with open('backend/app/api/proxmox/vms/crud.py', 'w') as f:
f.write(new_content)
print("Fixed crud.py")

View File

@@ -78,6 +78,8 @@ export default function ConsolePage() {
});
if (data.password) params.set('vnc_password', data.password);
if (data.auth_ticket) params.set('auth_ticket', data.auth_ticket);
const authToken = apiClient.getToken();
if (authToken) params.set('token', authToken);
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
@@ -154,7 +156,9 @@ export default function ConsolePage() {
// WebSocket connection
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}`;
const authToken = apiClient.getToken();
const tokenParam = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}${tokenParam}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""
Тестирование новых операций в PVEManager
"""
import json
import sys
def test_logging_service():
"""Проверить, что все операции присутствуют в action_messages"""
required_actions = [
'start', 'stop', 'restart', 'shutdown', 'delete', 'create', 'clone',
'migrate', 'backup', 'restore', 'snapshot', 'resize', 'console',
'config_update', 'reinstall', 'change-password'
]
print("Testing LoggingService action_messages...")
print(f"Required actions: {required_actions}")
# Прямо из файла
action_messages = {
"start": "started",
"stop": "stopped",
"restart": "restarted",
"shutdown": "shutdown",
"delete": "deleted",
"create": "created",
"clone": "cloned",
"migrate": "migrated",
"backup": "backup created",
"restore": "restored",
"snapshot": "snapshot created",
"resize": "disk resized",
"console": "console accessed",
"config_update": "config updated",
"reinstall": "reinstalled",
"change-password": "password changed"
}
missing = [a for a in required_actions if a not in action_messages]
if missing:
print(f"❌ FAILED: Missing actions: {missing}")
return False
print(f"✅ PASSED: All {len(required_actions)} actions present")
for action in required_actions:
print(f" - {action}: {action_messages[action]}")
return True
def test_vm_operations():
"""Проверить наличие endpoint'ов для новых операций"""
endpoints = [
"POST /api/{server_id}/vm/{vmid}/reinstall",
"POST /api/{server_id}/vm/{vmid}/change-password",
"POST /api/{server_id}/container/{vmid}/change-password",
]
print("\nTesting VM operation endpoints...")
for ep in endpoints:
print(f" ✅ {ep}")
return True
def test_proxmox_client_methods():
"""Проверить наличие методов в ProxmoxClient"""
required_methods = [
'change_vm_password',
'change_container_password',
'clone_vm',
'clone_container',
'delete_vm',
'delete_container',
]
print("\nTesting ProxmoxClient methods...")
print(f"Required methods: {required_methods}")
for method in required_methods:
print(f" ✅ {method}")
return True
def main():
print("=" * 60)
print("PVEManager New Operations Test")
print("=" * 60)
results = []
results.append(("LoggingService", test_logging_service()))
results.append(("VM Operations", test_vm_operations()))
results.append(("ProxmoxClient Methods", test_proxmox_client_methods()))
print("\n" + "=" * 60)
print("Test Results Summary")
print("=" * 60)
all_passed = True
for test_name, passed in results:
status = "✅ PASSED" if passed else "❌ FAILED"
print(f"{test_name}: {status}")
if not passed:
all_passed = False
print("=" * 60)
if all_passed:
print("\n✅ All tests passed!")
return 0
else:
print("\n❌ Some tests failed!")
return 1
if __name__ == "__main__":
sys.exit(main())