fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s
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:
208
CODE_REVIEW.md
Normal file
208
CODE_REVIEW.md
Normal 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-поток продолжит работать после добавления токена).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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}")
|
||||
20
fix_crud.py
20
fix_crud.py
@@ -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")
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user